diff --git a/apps/desktop/src/pet-window.ts b/apps/desktop/src/pet-window.ts index 74a1217e..d2ffe891 100644 --- a/apps/desktop/src/pet-window.ts +++ b/apps/desktop/src/pet-window.ts @@ -154,25 +154,33 @@ async function buildPetContextMenuTemplate(action: { readonly label: string; rea if (!action.defaultPet) return [{ label: action.label, click: action.click }]; const commands = await getDefaultPetPluginCommands(); const topLevel: Electron.MenuItemConstructorOptions[] = []; - const plugins = new Map(); + const plugins = new Map(); const sorted = [...commands].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); for (const command of sorted) { const item: Electron.MenuItemConstructorOptions = { label: command.commandTitle, click: () => { if (command.form) openPluginCommandForm(command).catch((error) => logError("pet.window", "plugin command form failed", error)); else executeDefaultPetPluginCommand(command.pluginId, command.commandId).catch((error) => logError("pet.window", "plugin command failed", error)); } }; if (command.placement === "top" || command.featured) { topLevel.push(item); continue; } - const group = plugins.get(command.pluginId) ?? { name: command.pluginName, commands: [] }; + const group = plugins.get(command.pluginId) ?? { name: command.pluginName, commands: [], menuItems: [] }; group.commands.push(item); plugins.set(command.pluginId, group); } - // Fully dynamic per-plugin menu sections (ui.menu.setItems). + // Fully dynamic per-plugin menu sections (ui.menu.setItems) — show status labels first. const menuItems = await getDefaultPetPluginMenuItems(); for (const item of menuItems) { - const group = plugins.get(item.pluginId) ?? { name: item.pluginName, commands: [] }; - group.commands.push({ label: item.title, enabled: item.enabled !== false, type: item.checked === true ? "checkbox" : "normal", checked: item.checked === true ? true : undefined, click: () => { executeDefaultPetPluginMenuSelect(item.pluginId, item.itemId).catch((error) => logError("pet.window", "plugin menu select failed", error)); } }); + const group = plugins.get(item.pluginId) ?? { name: item.pluginName, commands: [], menuItems: [] }; + group.menuItems.push({ label: item.title, enabled: item.enabled !== false, type: item.checked === true ? "checkbox" : "normal", checked: item.checked === true ? true : undefined, click: () => { executeDefaultPetPluginMenuSelect(item.pluginId, item.itemId).catch((error) => logError("pet.window", "plugin menu select failed", error)); } }); plugins.set(item.pluginId, group); } const template: Electron.MenuItemConstructorOptions[] = []; if (topLevel.length > 0) template.push(...topLevel.slice(0, 8), { type: "separator" }); - if (plugins.size > 0) template.push(...[...plugins.values()].map((plugin) => ({ label: plugin.name, submenu: plugin.commands })), { type: "separator" }); + if (plugins.size > 0) { + template.push(...[...plugins.values()].map((plugin) => { + const submenu: Electron.MenuItemConstructorOptions[] = []; + if (plugin.menuItems.length > 0) submenu.push(...plugin.menuItems); + if (plugin.menuItems.length > 0 && plugin.commands.length > 0) submenu.push({ type: "separator" }); + if (plugin.commands.length > 0) submenu.push(...plugin.commands); + return { label: plugin.name, submenu }; + }), { type: "separator" }); + } template.push({ label: t("pet.menu.openControlCenter"), click: () => { import("./windows.js").then(({ openControlCenterWindow }) => openControlCenterWindow()).catch((error) => logError("pet.window", "open control center failed", error)); } }, { label: action.label, click: action.click }); return template; } diff --git a/apps/desktop/src/plugin-service.ts b/apps/desktop/src/plugin-service.ts index e4b7f727..c603543b 100644 --- a/apps/desktop/src/plugin-service.ts +++ b/apps/desktop/src/plugin-service.ts @@ -72,8 +72,8 @@ export type PluginServiceOptions = { readonly capabilities?: PluginHostCapabilities; }; -export const bundledOfficialPluginIds = ["openpets.reminders"] as const; -const bundledEnabledByDefault = new Set(["openpets.reminders"]); +export const bundledOfficialPluginIds = ["openpets.reminders", "openpets.virtual-pet"] as const; +const bundledEnabledByDefault = new Set(["openpets.reminders", "openpets.virtual-pet"]); const staleBundledPluginIds = ["openpets.daily-reminders", "openpets.pomodoro", "openpets.ambient-companion", "openpets.break-buddy", "openpets.focus-buddy", "openpets.github-notifications", "openpets.pet-pal", "openpets.quick-reminders", "openpets.wander-buddy"] as const; export class PluginService { diff --git a/plugins/official/openpets.virtual-pet/index.js b/plugins/official/openpets.virtual-pet/index.js index 13556200..da4d0f65 100644 --- a/plugins/official/openpets.virtual-pet/index.js +++ b/plugins/official/openpets.virtual-pet/index.js @@ -2,17 +2,6 @@ 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 : {}; @@ -76,24 +65,24 @@ export function addXp(state, amount) { 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)), @@ -112,40 +101,21 @@ async function playActionSound(ctx) { } 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); - } - } - +function buildStatusMenuItems(ctx, state, now = Date.now()) { + const mood = getMood(state, now); + return [ + { id: "mood", title: `${ctx.t("menu.mood")}: ${ctx.t(`mood.${mood}`)}`, enabled: false }, + { id: "food", title: `${ctx.t("menu.food")}: ${Math.round(state.hunger)}%`, enabled: false }, + { id: "energy", title: `${ctx.t("menu.energy")}: ${Math.round(state.energy)}%`, enabled: false }, + { id: "play", title: `${ctx.t("menu.play")}: ${Math.round(state.happiness)}%`, enabled: false }, + { id: "bond", title: `${ctx.t("menu.bond")}: ${Math.round(state.affection)}%`, enabled: false }, + { id: "level", title: `${ctx.t("menu.level")}: ${state.level}`, enabled: false }, + ]; +} + +async function updateMenu(ctx, state, now = Date.now()) { try { - const nextBubble = await ctx.ui.bubble(spec); - nextBubble.onDismiss(() => { - if (getPinnedBubble(ctx)?.id === nextBubble.id) { - setPinnedBubble(ctx, null); - } - }); - setPinnedBubble(ctx, nextBubble); + await ctx.ui.menu.setItems(buildStatusMenuItems(ctx, state, now)); } catch {} } @@ -154,19 +124,19 @@ export async function maybeNudge(ctx, state, now = Date.now()) { 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 {} @@ -186,11 +156,11 @@ async function scheduleNextTick(ctx, now = Date.now()) { 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, @@ -200,10 +170,10 @@ export async function feed(ctx, now = Date.now()) { lastActionAt: now, lastSeenAt: now, }; - + await ctx.storage.set("state", newState); await playActionSound(ctx); - + try { await ctx.pet.react("celebrating", { showMessage: false }); if (xpInfo.leveledUp) { @@ -213,20 +183,20 @@ export async function feed(ctx, now = Date.now()) { await ctx.pet.speak(ctx.t(`speech.feed.${idx}`)); } } catch {} - - await updatePinned(ctx, newState, now); + + await updateMenu(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, @@ -237,10 +207,10 @@ export async function play(ctx, now = Date.now()) { lastActionAt: now, lastSeenAt: now, }; - + await ctx.storage.set("state", newState); await playActionSound(ctx); - + try { await ctx.pet.react("celebrating", { showMessage: false }); if (xpInfo.leveledUp) { @@ -250,20 +220,20 @@ export async function play(ctx, now = Date.now()) { await ctx.pet.speak(ctx.t(`speech.play.${idx}`)); } } catch {} - - await updatePinned(ctx, newState, now); + + await updateMenu(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, @@ -274,10 +244,10 @@ export async function pet(ctx, now = Date.now()) { lastActionAt: now, lastSeenAt: now, }; - + await ctx.storage.set("state", newState); await playActionSound(ctx); - + try { await ctx.pet.react("waving", { showMessage: false }); if (xpInfo.leveledUp) { @@ -287,19 +257,19 @@ export async function pet(ctx, now = Date.now()) { await ctx.pet.speak(ctx.t(`speech.pet.${idx}`)); } } catch {} - - await updatePinned(ctx, newState, now); + + await updateMenu(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, @@ -310,10 +280,10 @@ export async function nap(ctx, now = Date.now()) { lastActionAt: now, lastSeenAt: now, }; - + await ctx.storage.set("state", newState); await playActionSound(ctx); - + try { await ctx.pet.react("waiting", { showMessage: false }); if (xpInfo.leveledUp) { @@ -323,15 +293,15 @@ export async function nap(ctx, now = Date.now()) { await ctx.pet.speak(ctx.t(`speech.nap.${idx}`)); } } catch {} - - await updatePinned(ctx, newState, now); + + await updateMenu(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); - + await updateMenu(ctx, state, now); + const mood = getMood(state, now); try { await ctx.pet.speak(ctx.t(`speech.status.${mood}`)); @@ -341,7 +311,7 @@ export async function showStatus(ctx, now = Date.now()) { 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); @@ -349,12 +319,12 @@ export async function reconcile(ctx, now = Date.now()) { } else { updatedState = state; } - + updatedState.lastSeenAt = now; const savedState = cleanState(updatedState); await ctx.storage.set("state", savedState); - - await updatePinned(ctx, savedState, now); + + await updateMenu(ctx, savedState, now); await maybeNudge(ctx, savedState, now); await scheduleNextTick(ctx, now); return savedState; @@ -364,18 +334,18 @@ 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)); + await ctx.commands.register({ id: "status", title: "$t:command.status.title", description: "$t:command.status.description", icon, placement: "top" }, () => showStatus(ctx)); }, async stop() {}, }); diff --git a/plugins/official/openpets.virtual-pet/locales/en.json b/plugins/official/openpets.virtual-pet/locales/en.json index b3d0cceb..827bbe0c 100644 --- a/plugins/official/openpets.virtual-pet/locales/en.json +++ b/plugins/official/openpets.virtual-pet/locales/en.json @@ -7,7 +7,7 @@ "hud.bond": "Bond", "config.sound.label": "Action sound", "config.sound.description": "Optionally play a sound for direct care actions like feeding, playing, petting, or resting.", - + "command.feed.title": "Feed snack", "command.feed.description": "Feed your pet a snack.", "command.play.title": "Play game", @@ -19,6 +19,20 @@ "command.status.title": "Check on pet", "command.status.description": "Check how your pet is feeling.", + "menu.mood": "Mood", + "menu.food": "Food", + "menu.energy": "Energy", + "menu.play": "Play", + "menu.bond": "Bond", + "menu.level": "Level", + + "mood.sleeping": "Sleeping", + "mood.hungry": "Hungry", + "mood.tired": "Tired", + "mood.bored": "Bored", + "mood.happy": "Happy", + "mood.content": "Content", + "speech.feed.0": "Thanks for the food!", "speech.feed.1": "*munch munch munch*", "speech.feed.2": "Yum, tasty!", diff --git a/plugins/official/openpets.virtual-pet/test.js b/plugins/official/openpets.virtual-pet/test.js index e26ff401..5ce566f3 100644 --- a/plugins/official/openpets.virtual-pet/test.js +++ b/plugins/official/openpets.virtual-pet/test.js @@ -91,35 +91,13 @@ const LOCALES = { en: JSON.parse(await readFile(new URL("./locales/en.json", imp h.expectStored("state", (s) => s.lastSeenAt === 100_000_000_000 && s.hunger === 80); // Expect schedules to contain tick schedule assert.ok(h.calls.schedules.has(SCHEDULE_ID)); - // Expect pinned status bubble to show HUD - h.expectBubble({ sticky: true, pin: true }); - - // Verify that the bubble contains the correct HUD items/values rather than text - const lastBubble = h.calls.bubbles[h.calls.bubbles.length - 1]; - assert.ok(lastBubble, "Should have a bubble"); - assert.ok(lastBubble.spec.hud, "Bubble should have a HUD spec"); - assert.equal(lastBubble.spec.hud.items.length, 4); - - const [food, energy, play, bond] = lastBubble.spec.hud.items; - assert.equal(food.icon, "food"); - assert.equal(food.value, 80); - assert.equal(food.tone, "amber"); - assert.equal(food.label, "Food"); - - assert.equal(energy.icon, "zap"); - assert.equal(energy.value, 80); - assert.equal(energy.tone, "blue"); - assert.equal(energy.label, "Energy"); - - assert.equal(play.icon, "play"); - assert.equal(play.value, 80); - assert.equal(play.tone, "green"); - assert.equal(play.label, "Play"); - - assert.equal(bond.icon, "heart"); - assert.equal(bond.value, 50); - assert.equal(bond.tone, "pink"); - assert.equal(bond.label, "Bond"); + // Expect status labels in the dynamic context menu (no on-pet HUD overlay) + const menu = h.calls.menuItems; + assert.ok(menu.length > 0, "Should populate the context menu with status labels"); + assert.ok(menu.some((item) => item.id === "food" && item.title.includes("80%")), "Menu should show food status"); + assert.ok(menu.some((item) => item.id === "energy" && item.title.includes("80%")), "Menu should show energy status"); + assert.ok(menu.some((item) => item.id === "play" && item.title.includes("80%")), "Menu should show play status"); + assert.ok(menu.some((item) => item.id === "bond" && item.title.includes("50%")), "Menu should show bond status"); h.expectNoErrors(); } @@ -132,7 +110,7 @@ const LOCALES = { en: JSON.parse(await readFile(new URL("./locales/en.json", imp const oldState = cleanState({ hunger: 100, energy: 100, happiness: 100, affection: 100, lastSeenAt: 101_000_000_000 - 2 * 3600_000 }); await h.ctx.storage.set("state", oldState); await h.start(); - + // 2 hours elapsed = hunger -4 (96), energy -6 (94), happiness -4 (96), affection -2 (98) h.expectStored("state", (s) => s.hunger === 96 && s.energy === 94 && s.happiness === 96 && s.affection === 98); h.expectNoErrors(); @@ -143,7 +121,7 @@ const LOCALES = { en: JSON.parse(await readFile(new URL("./locales/en.json", imp const h = createTestHarness(register, { permissions: PERMISSIONS, locales: LOCALES, nowMs: 102_000_000_000 }); activeHarness = h; await h.start(); - + // Feed command: hunger +25, xp +5 await h.runCommand("feed"); h.expectStored("state", (s) => s.hunger === 100 && s.xp === 5 && s.careCounts.fed === 1); @@ -180,7 +158,7 @@ const LOCALES = { en: JSON.parse(await readFile(new URL("./locales/en.json", imp await h.start(); await h.runCommand("nap"); // sleeping until 103B + 15M h.expectStored("state", (s) => s.sleptUntil > 0); - + // Running play should wake up pet await h.runCommand("play"); h.expectStored("state", (s) => s.sleptUntil === 0); @@ -192,7 +170,7 @@ const LOCALES = { en: JSON.parse(await readFile(new URL("./locales/en.json", imp const h = createTestHarness(register, { permissions: PERMISSIONS, locales: LOCALES, nowMs: 104_000_000_000 }); activeHarness = h; await h.start(); - + // Emit click event await h.emit("pet:clicked", {}); h.expectStored("state", (s) => s.affection === 65 && s.careCounts.petted === 1); @@ -208,7 +186,7 @@ const LOCALES = { en: JSON.parse(await readFile(new URL("./locales/en.json", imp const neglectedState = cleanState({ hunger: 10, lastSeenAt: 105_000_000_000 - 15 * 60_000 }); await h.ctx.storage.set("state", neglectedState); await h.start(); - + // check if nudge triggered h.expectSpoke(/hungry/); h.expectStored("state", (s) => s.lastNudgeAt === 105_000_000_000); @@ -217,7 +195,7 @@ const LOCALES = { en: JSON.parse(await readFile(new URL("./locales/en.json", imp const previousSpeakCount = h.calls.speak.length; await h.clock.advance("5m"); assert.equal(h.calls.speak.length, previousSpeakCount, "nudge should not spam"); - + h.expectNoErrors(); }