Move virtual-pet HUD to the context menu #2

Open
scriptoriumadmin wants to merge 1 commit from pr/virtual-pet-context-menu into main
5 changed files with 99 additions and 129 deletions

View file

@ -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<string, { name: string; commands: Electron.MenuItemConstructorOptions[] }>();
const plugins = new Map<string, { name: string; commands: Electron.MenuItemConstructorOptions[]; menuItems: Electron.MenuItemConstructorOptions[] }>();
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;
}

View file

@ -72,8 +72,8 @@ export type PluginServiceOptions = {
readonly capabilities?: PluginHostCapabilities;
};
export const bundledOfficialPluginIds = ["openpets.reminders"] as const;
const bundledEnabledByDefault = new Set<string>(["openpets.reminders"]);
export const bundledOfficialPluginIds = ["openpets.reminders", "openpets.virtual-pet"] as const;
const bundledEnabledByDefault = new Set<string>(["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 {

View file

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

View file

@ -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!",

View file

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