382 lines
12 KiB
JavaScript
382 lines
12 KiB
JavaScript
// Virtual Pet (openpets.virtual-pet) — SDK v3 Virtual Pet companion.
|
|
|
|
export const SCHEDULE_ID = "virtual-pet-tick";
|
|
|
|
const pinnedBubbles = new WeakMap();
|
|
|
|
function getPinnedBubble(ctx) {
|
|
return pinnedBubbles.get(ctx) ?? null;
|
|
}
|
|
|
|
function setPinnedBubble(ctx, handle) {
|
|
if (handle) pinnedBubbles.set(ctx, handle);
|
|
else pinnedBubbles.delete(ctx);
|
|
}
|
|
|
|
export function cleanState(state = {}) {
|
|
const current = state && typeof state === "object" ? state : {};
|
|
const careCounts = current.careCounts && typeof current.careCounts === "object" ? current.careCounts : {};
|
|
return {
|
|
hunger: typeof current.hunger === "number" ? Math.max(0, Math.min(100, current.hunger)) : 80,
|
|
energy: typeof current.energy === "number" ? Math.max(0, Math.min(100, current.energy)) : 80,
|
|
happiness: typeof current.happiness === "number" ? Math.max(0, Math.min(100, current.happiness)) : 80,
|
|
affection: typeof current.affection === "number" ? Math.max(0, Math.min(100, current.affection)) : 50,
|
|
level: typeof current.level === "number" ? Math.max(1, current.level) : 1,
|
|
xp: typeof current.xp === "number" ? Math.max(0, current.xp) : 0,
|
|
careCounts: {
|
|
fed: typeof careCounts.fed === "number" ? careCounts.fed : 0,
|
|
played: typeof careCounts.played === "number" ? careCounts.played : 0,
|
|
petted: typeof careCounts.petted === "number" ? careCounts.petted : 0,
|
|
napped: typeof careCounts.napped === "number" ? careCounts.napped : 0,
|
|
},
|
|
lastSeenAt: typeof current.lastSeenAt === "number" ? current.lastSeenAt : 0,
|
|
lastNudgeAt: typeof current.lastNudgeAt === "number" ? current.lastNudgeAt : 0,
|
|
sleptUntil: typeof current.sleptUntil === "number" ? current.sleptUntil : 0,
|
|
lastActionAt: typeof current.lastActionAt === "number" ? current.lastActionAt : 0,
|
|
};
|
|
}
|
|
|
|
export function getMood(state, now) {
|
|
if (now < state.sleptUntil) {
|
|
return "sleeping";
|
|
}
|
|
if (state.hunger < 30) {
|
|
return "hungry";
|
|
}
|
|
if (state.energy < 30) {
|
|
return "tired";
|
|
}
|
|
if (state.happiness < 30) {
|
|
return "bored";
|
|
}
|
|
if ((state.hunger + state.energy + state.happiness + state.affection) / 4 >= 75) {
|
|
return "happy";
|
|
}
|
|
return "content";
|
|
}
|
|
|
|
function wakeUpIfSleeping(state, now) {
|
|
if (state.sleptUntil > now) {
|
|
return { ...state, sleptUntil: 0 };
|
|
}
|
|
return state;
|
|
}
|
|
|
|
export function addXp(state, amount) {
|
|
let xp = state.xp + amount;
|
|
let level = state.level;
|
|
let leveledUp = false;
|
|
while (xp >= level * 50) {
|
|
xp -= level * 50;
|
|
level += 1;
|
|
leveledUp = true;
|
|
}
|
|
return { xp, level, leveledUp };
|
|
}
|
|
|
|
export function applyDecay(state, elapsedMs, now) {
|
|
const lastSeen = state.lastSeenAt || now;
|
|
|
|
let sleepMs = 0;
|
|
if (state.sleptUntil > lastSeen) {
|
|
const sleepEnd = Math.min(state.sleptUntil, now);
|
|
sleepMs = sleepEnd - lastSeen;
|
|
}
|
|
const wakeMs = Math.max(0, elapsedMs - sleepMs);
|
|
|
|
const sleepHours = sleepMs / 3600000;
|
|
const wakeHours = wakeMs / 3600000;
|
|
|
|
// Stats decay per hour: hunger (-2), energy (-3), happiness (-2), affection (-1)
|
|
// During sleep: hunger (-2), energy reacts (+15), happiness (-0.5), affection stays same
|
|
const newHunger = state.hunger - wakeHours * 2 - sleepHours * 2;
|
|
const newEnergy = state.energy - wakeHours * 3 + sleepHours * 15;
|
|
const newHappiness = state.happiness - wakeHours * 2 - sleepHours * 0.5;
|
|
const newAffection = state.affection - wakeHours * 1;
|
|
|
|
return {
|
|
...state,
|
|
hunger: Math.max(0, Math.min(100, newHunger)),
|
|
energy: Math.max(0, Math.min(100, newEnergy)),
|
|
happiness: Math.max(0, Math.min(100, newHappiness)),
|
|
affection: Math.max(0, Math.min(100, newAffection)),
|
|
};
|
|
}
|
|
|
|
async function playActionSound(ctx) {
|
|
try {
|
|
const cfg = (await ctx.config.get()) ?? {};
|
|
if (cfg.sound) {
|
|
await ctx.audio.play(cfg.sound);
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
export async function updatePinned(ctx, state, now = Date.now()) {
|
|
const spec = {
|
|
tone: "info",
|
|
sticky: true,
|
|
pin: true,
|
|
priority: "normal",
|
|
hud: {
|
|
items: [
|
|
{ icon: "food", value: state.hunger, tone: "amber", label: ctx.t("hud.food") },
|
|
{ icon: "zap", value: state.energy, tone: "blue", label: ctx.t("hud.energy") },
|
|
{ icon: "play", value: state.happiness, tone: "green", label: ctx.t("hud.play") },
|
|
{ icon: "heart", value: state.affection, tone: "pink", label: ctx.t("hud.bond") },
|
|
],
|
|
},
|
|
};
|
|
|
|
const pinnedBubble = getPinnedBubble(ctx);
|
|
if (pinnedBubble) {
|
|
try {
|
|
await pinnedBubble.update(spec);
|
|
return;
|
|
} catch {
|
|
setPinnedBubble(ctx, null);
|
|
}
|
|
}
|
|
|
|
try {
|
|
const nextBubble = await ctx.ui.bubble(spec);
|
|
nextBubble.onDismiss(() => {
|
|
if (getPinnedBubble(ctx)?.id === nextBubble.id) {
|
|
setPinnedBubble(ctx, null);
|
|
}
|
|
});
|
|
setPinnedBubble(ctx, nextBubble);
|
|
} catch {}
|
|
}
|
|
|
|
export async function maybeNudge(ctx, state, now = Date.now()) {
|
|
const isHungry = state.hunger < 30;
|
|
const isTired = state.energy < 30;
|
|
const isBored = state.happiness < 30;
|
|
const isNeglected = state.affection < 30;
|
|
|
|
if (isHungry || isTired || isBored || isNeglected) {
|
|
const nudgeCooldown = 6 * 3600_000;
|
|
if (state.lastNudgeAt === 0 || now - state.lastNudgeAt >= nudgeCooldown) {
|
|
state.lastNudgeAt = now;
|
|
await ctx.storage.set("state", state);
|
|
|
|
let speechKey = "nudge.neglected";
|
|
if (isHungry) speechKey = "nudge.hungry";
|
|
else if (isTired) speechKey = "nudge.tired";
|
|
else if (isBored) speechKey = "nudge.bored";
|
|
else if (isNeglected) speechKey = "nudge.neglected";
|
|
|
|
try {
|
|
await ctx.pet.speak(ctx.t(speechKey));
|
|
} catch {}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function scheduleNextTick(ctx, now = Date.now()) {
|
|
try {
|
|
await ctx.schedule.cancel(SCHEDULE_ID);
|
|
// Check/tick every 15 minutes.
|
|
const checkInterval = 15 * 60_000;
|
|
await ctx.schedule.once(SCHEDULE_ID, checkInterval, () => reconcile(ctx));
|
|
} catch {}
|
|
}
|
|
|
|
export async function feed(ctx, now = Date.now()) {
|
|
const state = cleanState(await ctx.storage.get("state"));
|
|
const cleanActive = wakeUpIfSleeping(state, now);
|
|
|
|
const hunger = Math.min(100, cleanActive.hunger + 25);
|
|
const xpInfo = addXp(cleanActive, 5);
|
|
const careCounts = { ...cleanActive.careCounts, fed: cleanActive.careCounts.fed + 1 };
|
|
|
|
const newState = {
|
|
...cleanActive,
|
|
hunger,
|
|
xp: xpInfo.xp,
|
|
level: xpInfo.level,
|
|
careCounts,
|
|
lastActionAt: now,
|
|
lastSeenAt: now,
|
|
};
|
|
|
|
await ctx.storage.set("state", newState);
|
|
await playActionSound(ctx);
|
|
|
|
try {
|
|
await ctx.pet.react("celebrating", { showMessage: false });
|
|
if (xpInfo.leveledUp) {
|
|
await ctx.pet.speak(ctx.t("speech.levelup"));
|
|
} else {
|
|
const idx = Math.floor(Math.random() * 4);
|
|
await ctx.pet.speak(ctx.t(`speech.feed.${idx}`));
|
|
}
|
|
} catch {}
|
|
|
|
await updatePinned(ctx, newState, now);
|
|
return newState;
|
|
}
|
|
|
|
export async function play(ctx, now = Date.now()) {
|
|
const state = cleanState(await ctx.storage.get("state"));
|
|
const cleanActive = wakeUpIfSleeping(state, now);
|
|
|
|
const happiness = Math.min(100, cleanActive.happiness + 25);
|
|
const energy = Math.max(0, cleanActive.energy - 15);
|
|
const xpInfo = addXp(cleanActive, 5);
|
|
const careCounts = { ...cleanActive.careCounts, played: cleanActive.careCounts.played + 1 };
|
|
|
|
const newState = {
|
|
...cleanActive,
|
|
happiness,
|
|
energy,
|
|
xp: xpInfo.xp,
|
|
level: xpInfo.level,
|
|
careCounts,
|
|
lastActionAt: now,
|
|
lastSeenAt: now,
|
|
};
|
|
|
|
await ctx.storage.set("state", newState);
|
|
await playActionSound(ctx);
|
|
|
|
try {
|
|
await ctx.pet.react("celebrating", { showMessage: false });
|
|
if (xpInfo.leveledUp) {
|
|
await ctx.pet.speak(ctx.t("speech.levelup"));
|
|
} else {
|
|
const idx = Math.floor(Math.random() * 4);
|
|
await ctx.pet.speak(ctx.t(`speech.play.${idx}`));
|
|
}
|
|
} catch {}
|
|
|
|
await updatePinned(ctx, newState, now);
|
|
return newState;
|
|
}
|
|
|
|
export async function pet(ctx, now = Date.now()) {
|
|
const state = cleanState(await ctx.storage.get("state"));
|
|
const cleanActive = wakeUpIfSleeping(state, now);
|
|
|
|
const affection = Math.min(100, cleanActive.affection + 15);
|
|
const happiness = Math.min(100, cleanActive.happiness + 10);
|
|
const xpInfo = addXp(cleanActive, 3);
|
|
const careCounts = { ...cleanActive.careCounts, petted: cleanActive.careCounts.petted + 1 };
|
|
|
|
const newState = {
|
|
...cleanActive,
|
|
affection,
|
|
happiness,
|
|
xp: xpInfo.xp,
|
|
level: xpInfo.level,
|
|
careCounts,
|
|
lastActionAt: now,
|
|
lastSeenAt: now,
|
|
};
|
|
|
|
await ctx.storage.set("state", newState);
|
|
await playActionSound(ctx);
|
|
|
|
try {
|
|
await ctx.pet.react("waving", { showMessage: false });
|
|
if (xpInfo.leveledUp) {
|
|
await ctx.pet.speak(ctx.t("speech.levelup"));
|
|
} else {
|
|
const idx = Math.floor(Math.random() * 4);
|
|
await ctx.pet.speak(ctx.t(`speech.pet.${idx}`));
|
|
}
|
|
} catch {}
|
|
|
|
await updatePinned(ctx, newState, now);
|
|
return newState;
|
|
}
|
|
|
|
export async function nap(ctx, now = Date.now()) {
|
|
const state = cleanState(await ctx.storage.get("state"));
|
|
|
|
const energy = Math.min(100, state.energy + 40);
|
|
const sleptUntil = now + 15 * 60_000;
|
|
const xpInfo = addXp(state, 5);
|
|
const careCounts = { ...state.careCounts, napped: state.careCounts.napped + 1 };
|
|
|
|
const newState = {
|
|
...state,
|
|
energy,
|
|
sleptUntil,
|
|
xp: xpInfo.xp,
|
|
level: xpInfo.level,
|
|
careCounts,
|
|
lastActionAt: now,
|
|
lastSeenAt: now,
|
|
};
|
|
|
|
await ctx.storage.set("state", newState);
|
|
await playActionSound(ctx);
|
|
|
|
try {
|
|
await ctx.pet.react("waiting", { showMessage: false });
|
|
if (xpInfo.leveledUp) {
|
|
await ctx.pet.speak(ctx.t("speech.levelup"));
|
|
} else {
|
|
const idx = Math.floor(Math.random() * 4);
|
|
await ctx.pet.speak(ctx.t(`speech.nap.${idx}`));
|
|
}
|
|
} catch {}
|
|
|
|
await updatePinned(ctx, newState, now);
|
|
return newState;
|
|
}
|
|
|
|
export async function showStatus(ctx, now = Date.now()) {
|
|
const state = cleanState(await ctx.storage.get("state"));
|
|
await updatePinned(ctx, state, now);
|
|
|
|
const mood = getMood(state, now);
|
|
try {
|
|
await ctx.pet.speak(ctx.t(`speech.status.${mood}`));
|
|
} catch {}
|
|
}
|
|
|
|
export async function reconcile(ctx, now = Date.now()) {
|
|
const rawState = await ctx.storage.get("state");
|
|
const state = cleanState(rawState);
|
|
|
|
let updatedState;
|
|
if (state.lastSeenAt > 0) {
|
|
const elapsedMs = Math.max(0, now - state.lastSeenAt);
|
|
updatedState = applyDecay(state, elapsedMs, now);
|
|
} else {
|
|
updatedState = state;
|
|
}
|
|
|
|
updatedState.lastSeenAt = now;
|
|
const savedState = cleanState(updatedState);
|
|
await ctx.storage.set("state", savedState);
|
|
|
|
await updatePinned(ctx, savedState, now);
|
|
await maybeNudge(ctx, savedState, now);
|
|
await scheduleNextTick(ctx, now);
|
|
return savedState;
|
|
}
|
|
|
|
export function register(OpenPetsPlugin) {
|
|
OpenPetsPlugin.register({
|
|
async start(ctx) {
|
|
await reconcile(ctx);
|
|
|
|
try {
|
|
ctx.events.on("pet:clicked", () => pet(ctx));
|
|
} catch {}
|
|
|
|
const icon = ctx.assets.icon("virtual-pet");
|
|
|
|
await ctx.commands.register({ id: "feed", title: "$t:command.feed.title", description: "$t:command.feed.description", icon }, () => feed(ctx));
|
|
await ctx.commands.register({ id: "play", title: "$t:command.play.title", description: "$t:command.play.description", icon }, () => play(ctx));
|
|
await ctx.commands.register({ id: "pet", title: "$t:command.pet.title", description: "$t:command.pet.description", icon }, () => pet(ctx));
|
|
await ctx.commands.register({ id: "nap", title: "$t:command.nap.title", description: "$t:command.nap.description", icon }, () => nap(ctx));
|
|
await ctx.commands.register({ id: "status", title: "$t:command.status.title", description: "$t:command.status.description", icon }, () => showStatus(ctx));
|
|
},
|
|
async stop() {},
|
|
});
|
|
}
|