openpetswithchatandmcp/plugins/official/openpets.virtual-pet/index.js
2026-06-11 21:25:55 +02:00

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() {},
});
}