diff --git a/apps/desktop/src/pet-window.ts b/apps/desktop/src/pet-window.ts index 7c8719ba..fb5c0205 100644 --- a/apps/desktop/src/pet-window.ts +++ b/apps/desktop/src/pet-window.ts @@ -14,7 +14,7 @@ import { pickReactionMessage } from "./reaction-messages.js"; import { debug, error as logError, info, warn } from "./logger.js"; import { executeDefaultPetPluginCommand, executeDefaultPetPluginMenuSelect, getDefaultPetPluginCommands, getDefaultPetPluginMenuItems } from "./plugin-service.js"; import type { ActiveBubble } from "./plugin-bubble-arbiter.js"; -import type { PluginBubbleIndicator, PluginCommandForm } from "./plugin-sdk-bridge.js"; +import type { PluginBubbleIndicator, PluginCommandForm, PluginBubbleHud, PluginBubbleHudItem } from "./plugin-sdk-bridge.js"; import { defaultPetSprite, motionToSpriteState, resolveReactionSpriteState, type PetMotionState, type UniversalSpriteState } from "./reaction-animation-mapping.js"; export interface PetWindowInteractionHooks { @@ -1003,7 +1003,7 @@ function createPetWindowCss(paused: boolean, scale: PetScaleValue): string { } .bubble.is-pinned::after { content: none !important; } .bubble.is-pinned .bubble-body { width: 100%; text-align: center; } - .bubble.is-pinned .bubble-text { display: block; -webkit-line-clamp: 1; font-size: 10px; font-weight: 700; line-height: 12px; color: #334155; } + .bubble.is-pinned .bubble-text { display: inline-block; -webkit-line-clamp: unset; -webkit-box-orient: initial; white-space: pre; overflow-wrap: normal; word-break: keep-all; font: 800 10px/13px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; letter-spacing: -0.03em; color: #334155; text-align: left; } .bubble.is-pinned .bubble-actions { display: flex; flex-direction: row; flex-wrap: nowrap; gap: 4px; width: 100%; margin-top: 5px; justify-content: center; } .bubble.is-pinned .bubble-action { flex: 1 1 auto; min-width: 0; padding: 3px 6px; font-size: 9px; font-weight: 700; line-height: 11px; border-radius: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: center; background: rgba(30, 58, 138, 0.08); color: #1e293b; transition: background 150ms ease; } .bubble.is-pinned .bubble-action:hover { background: rgba(30, 58, 138, 0.14); } @@ -1027,6 +1027,86 @@ function createPetWindowCss(paused: boolean, scale: PetScaleValue): string { .bubble.is-plugin.accent-red { background: linear-gradient(135deg, rgba(254, 226, 226, 0.97), rgba(254, 202, 202, 0.94)); } .bubble.is-plugin.accent-pink { background: linear-gradient(135deg, rgba(252, 231, 243, 0.97), rgba(251, 207, 232, 0.94)); } .bubble.is-plugin.accent-slate { background: linear-gradient(135deg, rgba(241, 245, 249, 0.97), rgba(226, 232, 240, 0.94)); } + .bubble-hud { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px 8px; + width: 100%; + margin: 2px 0; + box-sizing: border-box; + } + .bubble-hud.items-1 { + grid-template-columns: 1fr; + } + .bubble-hud.items-3 .bubble-hud-item:last-child { + grid-column: span 2; + } + .bubble-hud-item { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + width: 100%; + } + .bubble-hud-item-icon { + flex: 0 0 12px; + width: 12px; + height: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 11px; + line-height: 1; + font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", system-ui, sans-serif; + } + .bubble-hud-item-icon img, .bubble-hud-item-icon svg { + width: 12px; + height: 12px; + object-fit: contain; + display: block; + } + .bubble-hud-item-content { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + } + .bubble-hud-item-meta { + display: flex; + justify-content: flex-start; + align-items: baseline; + gap: 2px; + font-size: 8px; + font-weight: 700; + line-height: 1; + color: #475569; + } + .bubble-hud-item-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .bubble-hud-item-bar { + height: 4px; + background: rgba(71, 85, 105, 0.15); + border-radius: 99px; + overflow: hidden; + position: relative; + width: 100%; + } + .bubble-hud-item-fill { + height: 100%; + border-radius: 99px; + width: 0%; + transition: width 200ms ease; + } + .bubble-hud-item-fill.tone-amber { background: #d97706; } + .bubble-hud-item-fill.tone-blue { background: #2563eb; } + .bubble-hud-item-fill.tone-green { background: #16a34a; } + .bubble-hud-item-fill.tone-pink { background: #db2777; } + .bubble-hud-item-fill.tone-slate { background: #475569; } + .bubble-hud-item-fill.tone-red { background: #dc2626; } @keyframes bubble-in { from { opacity: 0; transform: translateX(-50%) translateY(4px) scale(0.96); } to { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); } } @keyframes status-pulse { 0%, 100% { opacity: 0.52; } 50% { opacity: 1; } } @media (prefers-reduced-motion: reduce) { .sprite, .installed-sprite, .bubble, .bubble-status-icon::before { animation: none !important; } } @@ -1052,10 +1132,33 @@ function getReactionSpriteState(reaction: OpenPetsReaction | undefined): Univers } const namedHostIconGlyphs: Record = { - info: "ℹ", check: "✓", alert: "⚠", heart: "♥", star: "★", bell: "🔔", coffee: "☕", timer: "⏱", - droplet: "💧", sparkles: "✨", zap: "⚡", moon: "☾", sun: "☀", food: "🍖", play: "▶", pause: "⏸", + info: "ℹ", check: "✓", alert: "⚠", heart: "💛", star: "★", bell: "🔔", coffee: "☕", timer: "⏱", + droplet: "💧", sparkles: "✨", zap: "⚡", moon: "☾", sun: "☀", food: "🍖", play: "🎾", pause: "⏸", }; +function createHudIconMarkup(item: PluginBubbleHudItem): string { + if (item.svgPath) { + const svg = readSafePluginSvg(item.svgPath); + if (svg) return svg; + } + if (item.iconName) { + return escapeHtml(namedHostIconGlyphs[item.iconName] ?? "•"); + } + return "•"; +} + +function createPluginHudMarkup(hud: PluginBubbleHud): string { + const itemsHtml = hud.items.map((item) => { + const value = Math.round(Math.max(0, Math.min(100, item.value))); + const iconMarkup = createHudIconMarkup(item); + const labelHtml = item.label ? `${escapeHtml(item.label)}` : ""; + const tone = item.tone ?? "slate"; + const ariaLabel = item.label ? ` aria-label="${escapeHtml(`${item.label} ${value}%`)}"` : ""; + return `
${labelHtml}
`; + }).join(""); + return `
${itemsHtml}
`; +} + /** Render a plugin-arbiter bubble descriptor into host markup (descriptor-only — no plugin markup). */ function createPluginBubbleMarkup(active: ActiveBubble, pinned: boolean): string { const bubble = active.bubble; @@ -1079,6 +1182,9 @@ function createPluginBubbleMarkup(active: ActiveBubble, pinned: boolean): string : ""; if (bubble.indicator && body) parts.push(``); if (body) parts.push(body); + if (bubble.hud) { + parts.push(createPluginHudMarkup(bubble.hud)); + } if (bubble.input) { const input = bubble.input; const inputId = escapeHtml(input.id); @@ -1094,7 +1200,8 @@ function createPluginBubbleMarkup(active: ActiveBubble, pinned: boolean): string parts.push(`
${buttons}
`); } const actionsClass = bubble.actions?.length ? " has-actions" : ""; - return `
${parts.join("")}
`; + const hudClass = bubble.hud ? " has-hud" : ""; + return `
${parts.join("")}
`; } function createPluginIndicatorMarkup(indicator: PluginBubbleIndicator): string { diff --git a/apps/desktop/src/plugin-sdk-bridge.ts b/apps/desktop/src/plugin-sdk-bridge.ts index 5d22c06e..9222ff8d 100644 --- a/apps/desktop/src/plugin-sdk-bridge.ts +++ b/apps/desktop/src/plugin-sdk-bridge.ts @@ -67,7 +67,18 @@ export type PluginBubbleIndicator = { background?: string; borderColor?: string; }; +export type PluginBubbleHudItem = { + iconName?: string; + svgPath?: string; + value: number; + label?: string; + tone?: "amber" | "blue" | "green" | "pink" | "slate" | "red"; +}; +export type PluginBubbleHud = { + items: PluginBubbleHudItem[]; +}; export type PluginBubbleDescriptor = { + hud?: PluginBubbleHud; text?: string; /** Pre-sanitized HTML rendered from limited markdown (everything escaped first). */ markdownHtml?: string; @@ -395,7 +406,8 @@ export class PluginSdkBridge { check(caps.settings.dynamicSpeechAllowed(), "AI-generated pet speech is disabled in settings."); out.dynamic = true; } - if (raw.text !== undefined) out.text = dynamic ? validateDynamicText(String(raw.text)) : validateSayMessage(String(raw.text)); + const pinned = raw.pin === true; + if (raw.text !== undefined) out.text = dynamic ? validateDynamicText(String(raw.text)) : pinned ? validatePinnedBubbleText(String(raw.text)) : validateSayMessage(String(raw.text)); if (raw.markdown !== undefined) { const markdown = String(raw.markdown); check(markdown.length <= (dynamic ? quotas.dynamicTextChars : quotas.markdownChars), "Plugin bubble markdown is too long."); @@ -414,6 +426,12 @@ export class PluginSdkBridge { if (raw.durationMs !== undefined) { const duration = Number(raw.durationMs); check(Number.isFinite(duration) && duration >= 500 && duration <= 10 * 60_000, "Invalid bubble durationMs."); out.durationMs = duration; } if (raw.sticky !== undefined) out.sticky = raw.sticky === true; if (raw.pin !== undefined) { if (raw.pin === true) { requirePermission("pet:pin"); out.pin = true; if (out.sticky === undefined && out.durationMs === undefined) out.sticky = true; } } + if (raw.hud !== undefined) { + if (!forUpdate) { + check(raw.pin === true, "Bubble HUD descriptor is only allowed for pinned bubbles."); + } + out.hud = validateBubbleHud(raw.hud); + } if (raw.dismissOn !== undefined) { check(Array.isArray(raw.dismissOn) && raw.dismissOn.length <= 5, "Invalid bubble dismissOn."); const allowed = new Set(["timeout", "click", "petClick", "action", "outsideClick"]); @@ -425,8 +443,12 @@ export class PluginSdkBridge { if (raw.input !== undefined) { requirePermission("pet:interact"); out.input = validateBubbleInput(raw.input); } if (out.text !== undefined || out.markdownHtml !== undefined) { check(out.iconName === undefined && out.svgPath === undefined && out.imagePath === undefined, "Plugin bubble body media cannot be combined with text or markdown. Use indicator for icon + message alerts."); + check(out.hud === undefined, "Plugin bubble HUD cannot be combined with text or markdown."); } - if (!forUpdate && out.text === undefined && out.markdownHtml === undefined && out.svgPath === undefined && out.imagePath === undefined && out.iconName === undefined) throw new Error("Plugin bubble needs content (text, markdown, icon, svg, or image)."); + if (out.hud !== undefined) { + check(out.text === undefined && out.markdownHtml === undefined && out.svgPath === undefined && out.imagePath === undefined && out.iconName === undefined && out.indicator === undefined, "Plugin bubble HUD cannot be combined with text, markdown, body media, or indicator."); + } + if (!forUpdate && out.text === undefined && out.markdownHtml === undefined && out.svgPath === undefined && out.imagePath === undefined && out.iconName === undefined && out.hud === undefined) throw new Error("Plugin bubble needs content (text, markdown, icon, svg, image, or hud)."); return out; }; @@ -454,6 +476,45 @@ export class PluginSdkBridge { else indicator.imagePath = path; }; + const validateBubbleHud = (value: unknown): PluginBubbleHud => { + if (!isRecord(value)) throw new Error("Invalid bubble HUD descriptor."); + const rawItems = value.items; + check(Array.isArray(rawItems), "Bubble HUD items must be an array."); + const itemsList = rawItems as unknown[]; + check(itemsList.length >= 1 && itemsList.length <= 4, "Bubble HUD items must contain between 1 and 4 items."); + const items: PluginBubbleHudItem[] = []; + for (const item of itemsList) { + if (!isRecord(item)) throw new Error("Invalid bubble HUD item."); + check(item.value !== undefined, "Bubble HUD item must have a value."); + const val = Number(item.value); + check(Number.isFinite(val) && val >= 0 && val <= 100, "Bubble HUD item value must be a number between 0 and 100."); + + const hudItem: PluginBubbleHudItem = { value: Math.round(val) }; + + if (item.icon !== undefined) { + if (typeof item.icon === "string") { + check(namedHostIcons.has(item.icon), "Unknown host icon name in HUD item."); + hudItem.iconName = item.icon; + } else { + hudItem.svgPath = resolveAssetRef(item.icon, ["icons"]).path; + } + } else { + throw new Error("Bubble HUD item must have an icon."); + } + + if (item.label !== undefined) { + hudItem.label = validateSayMessage(String(item.label)); + } + + if (item.tone !== undefined) { + check(["amber", "blue", "green", "pink", "slate", "red"].includes(String(item.tone)), "Invalid HUD item tone."); + hudItem.tone = item.tone as PluginBubbleHudItem["tone"]; + } + items.push(hudItem); + } + return { items }; + }; + const audio = createPluginAudioApi({ pluginId, state, capabilities: caps, requirePermission, audioPerMinute: quotas.audioPerMinute, resolveAssetRef }); const ui = createPluginUiApi({ pluginId, manifest, installPath: record.installPath, state, capabilities: caps, audio, requirePermission, guardCallback, validateBubbleSpec, validatePetHandleId, resolvePanelPath: (name) => resolveDeclaredPanelPath(manifest, record.installPath, name), normalizeJson, validateMenuItems, validateSayMessage, safeError, logger: this.#logger, onError: (reason) => this.#onError(pluginId, reason), quotas }); const storage = createPluginStorageApi({ pluginId, state, storage: this.#storage, requirePermission, guardCallback, validateStorageKey, onError: (reason) => this.#onError(pluginId, reason), safeError, storageSubscriptionsQuota: quotas.storageSubscriptions }); @@ -947,6 +1008,16 @@ export function validateDynamicText(value: string): string { .replace(/\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, "[redacted]"); } +export function validatePinnedBubbleText(value: string): string { + const text = value.trim().replace(/\r\n?/g, "\n"); + check(text.length >= 1, "Pinned bubble text cannot be empty."); + check(text.length <= 140, "Pinned bubble text is too long."); + const lines = text.split("\n"); + check(lines.length <= 4, "Pinned bubble text has too many lines."); + check(lines.every((line) => line.trim().length > 0), "Pinned bubble text cannot contain blank lines."); + return lines.map((line) => validateSayMessage(line)).join("\n"); +} + /** Static (non-dynamic) bubble markdown still gets the ambient content screen. */ function screenStaticBubbleText(markdown: string): void { check(!/```|(["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/apps/desktop/tests/plugin-bridge-fuzz.test.ts b/apps/desktop/tests/plugin-bridge-fuzz.test.ts index f36e2cfe..4b7387d6 100644 --- a/apps/desktop/tests/plugin-bridge-fuzz.test.ts +++ b/apps/desktop/tests/plugin-bridge-fuzz.test.ts @@ -10,6 +10,7 @@ import { renderLimitedMarkdown, validateCommandFormValues, validateDynamicText, + validatePinnedBubbleText, type PluginBubbleDescriptor, type PluginCommandForm, } from "../src/plugin-sdk-bridge.js"; @@ -107,6 +108,13 @@ assert.match(injectPanelCsp(""), /Content assert.match(injectPanelCsp("no head at all"), /^').match(/Content-Security-Policy/g) ?? []).length, 1, "existing CSP metas are replaced, not stacked"); +// --- pinned bubble text ------------------------------------------------------- + +assert.equal(validatePinnedBubbleText("🍖 ███░ ⚡ ███░\n🎾 ███░ 💛 ██░░"), "🍖 ███░ ⚡ ███░\n🎾 ███░ 💛 ██░░", "pinned HUD text may use a few safe lines"); +assert.throws(() => validatePinnedBubbleText("ok\n\nblank"), /blank lines/); +assert.throws(() => validatePinnedBubbleText("line 1\nline 2\nline 3\nline 4\nline 5"), /too many lines/); +assert.throws(() => validatePinnedBubbleText("ok\nhttps://example.com"), /URL|path-like/); + // --- normalizeJson ---------------------------------------------------------- for (let index = 0; index < rounds; index += 1) { diff --git a/apps/desktop/tests/plugin-sdk-bridge.test.ts b/apps/desktop/tests/plugin-sdk-bridge.test.ts index c6fdcbfb..eae4f5f1 100644 --- a/apps/desktop/tests/plugin-sdk-bridge.test.ts +++ b/apps/desktop/tests/plugin-sdk-bridge.test.ts @@ -90,6 +90,96 @@ await scenario("pet.react validates silent reaction options", async ({ api }) => await assert.rejects(() => api.pet.react("waving", { showMessage: false, extra: true }), /Invalid pet reaction option\./); }); +await scenario("hud bubble spec validation is enforced", async ({ store, bridge }) => { + const record = store.getRecord("plug")!; + const updatedRecord = { + ...record, + approvedPermissions: [...record.approvedPermissions, "pet:pin" as const], + }; + store.upsertRecord(updatedRecord); + + const approvedApi = bridge.createApi(updatedRecord, manifest()); + + // Should succeed with valid HUD + await approvedApi.ui.bubble({ + pin: true, + hud: { + items: [ + { icon: "food", value: 80, tone: "amber", label: "Food" }, + ], + }, + }); + + // Should reject if pin: true is missing + await assert.rejects( + () => approvedApi.ui.bubble({ + hud: { + items: [ + { icon: "food", value: 80, tone: "amber", label: "Food" }, + ], + }, + }), + /Bubble HUD descriptor is only allowed for pinned bubbles\./, + ); + + // Should reject if combined with text + await assert.rejects( + () => approvedApi.ui.bubble({ + pin: true, + text: "hello", + hud: { + items: [ + { icon: "food", value: 80, tone: "amber", label: "Food" }, + ], + }, + }), + /Plugin bubble HUD cannot be combined with text, markdown, body media, or indicator\./, + ); + + // Should reject if items contains more than 4 items + await assert.rejects( + () => approvedApi.ui.bubble({ + pin: true, + hud: { + items: [ + { icon: "food", value: 80 }, + { icon: "zap", value: 80 }, + { icon: "play", value: 80 }, + { icon: "heart", value: 80 }, + { icon: "star", value: 80 }, + ], + }, + }), + /Bubble HUD items must contain between 1 and 4 items\./, + ); + + // Should reject if item lacks icon + await assert.rejects( + () => approvedApi.ui.bubble({ + pin: true, + hud: { + items: [ + { value: 80 }, + ], + }, + }), + /Bubble HUD item must have an icon\./, + ); + + // Should reject if item value is outside 0..100 + await assert.rejects( + () => approvedApi.ui.bubble({ + pin: true, + hud: { + items: [ + { icon: "food", value: 150 }, + ], + }, + }), + /Bubble HUD item value must be a number between 0 and 100\./, + ); +}); + type ScenarioContext = { api: ReturnType; bridge: PluginSdkBridge; diff --git a/docs/plugins.md b/docs/plugins.md index e98c9a50..988db65e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -153,15 +153,18 @@ CLI `reminder` template. `ctx.ui.bubble(spec)` / `pet.speak(spec)` accept a string or a descriptor (text, limited markdown, icon/svg/image refs, tone, accent token, duration, -sticky, pin, dismissOn, priority, actions, input) and return a live handle +sticky, pin, dismissOn, priority, actions, input, hud) and return a live handle (`update`, `dismiss`, `pin`, `unpin`, `onAction`, `onSubmit`, `onDismiss`). +A **pinned mini HUD bubble** can be rendered using the `hud` descriptor (requires `pin: true`). The `hud` property takes an `items` array (1–4 items), where each item has `icon` (named host icon or asset ref), `value` (0–100), optional `label`, and optional `tone` ("amber", "blue", "green", "pink", "slate", "red"). When `hud` is present, it must not be combined with text, markdown, body media, or indicator. Pinned bubbles with `hud` render as a compact, polished 2x2 grid with CSS progress bars, avoiding emoji alignment issues. + `plugin-bubble-arbiter.ts` (one per pet surface) arbitrates: priority queue, do-not-interrupt for sticky/urgent, coalescing of identical back-to-back messages, and a single **pinned slot** above the transient slot with -priority-aware replace semantics. Non-dynamic text goes through the static -content filter; `dynamic: true` content needs `pet:speak:dynamic` plus the -global toggle and gets the relaxed screen (2,000 chars, secret redaction). +priority-aware replace semantics. Non-dynamic transient text goes through the +static content filter and stays single-line; pinned text may use a few safe lines +for compact status HUDs. `dynamic: true` content needs `pet:speak:dynamic` plus +the global toggle and gets the relaxed screen (2,000 chars, secret redaction). ## Multi-pet & liveness diff --git a/packages/sdk/src/check-plugin-sdk.ts b/packages/sdk/src/check-plugin-sdk.ts index a38dcd91..76815b42 100644 --- a/packages/sdk/src/check-plugin-sdk.ts +++ b/packages/sdk/src/check-plugin-sdk.ts @@ -53,6 +53,15 @@ const plugin: OpenPetsPluginDefinition = { bubble.onAction(async (actionId) => { if (actionId === "done") await bubble.dismiss(); }); + const hudBubble: OpenPetsBubbleHandle = await ctx.ui.bubble({ + pin: true, + hud: { + items: [ + { icon: "food", value: 80, tone: "amber", label: "Food" }, + { icon: "zap", value: 60, tone: "blue", label: "Energy" }, + ], + }, + } satisfies OpenPetsBubble); await ctx.schedule.every("tick", 60_000, async () => { await ctx.storage.set("lastTick", "now"); }); @@ -88,7 +97,12 @@ assert.equal(calls.status.length, 2); assert.ok(calls.schedules.has("tick")); assert.ok(calls.schedules.has("daily-summary")); assert.ok(calls.commands.has("greet")); -assert.equal(calls.bubbles.length, 3, "speak + ui.alert + ui.bubble all produce bubbles"); +assert.equal(calls.bubbles.length, 4, "speak + ui.alert + ui.bubble all produce bubbles"); +assert.equal(calls.bubbles[3]!.spec.hud?.items.length, 2); +assert.equal(calls.bubbles[3]!.spec.hud?.items[0]?.icon, "food"); +assert.equal(calls.bubbles[3]!.spec.hud?.items[0]?.value, 80); +assert.equal(calls.bubbles[3]!.spec.hud?.items[0]?.tone, "amber"); +assert.equal(calls.bubbles[3]!.spec.hud?.items[0]?.label, "Food"); assert.equal(calls.alerts.length, 1); assert.equal(calls.alerts[0]!.acknowledged, true); assert.equal(calls.sounds[0]!.sound, "alert"); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 90ec34fb..1d681d2c 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -168,12 +168,32 @@ export interface OpenPetsBubbleInput { submitLabel?: string; } +/** A HUD item shown in the host-rendered pinned mini-HUD bubble. */ +export interface OpenPetsBubbleHudItem { + /** Named host icon or bundled icon asset reference. */ + icon: OpenPetsIconRef; + /** Numeric value between 0 and 100 inclusive. */ + value: number; + /** Optional short display label. */ + label?: string; + /** Optional theme color tone for the bar/indicator. */ + tone?: "amber" | "blue" | "green" | "pink" | "slate" | "red"; +} + +/** Descriptor for a host-rendered mini HUD layout, used in pinned bubbles. */ +export interface OpenPetsBubbleHud { + /** List of HUD items (usually up to 4 items). */ + items: OpenPetsBubbleHudItem[]; +} + /** * A structured, host-rendered bubble descriptor. The plugin describes; the * host renders — no raw HTML or live DOM ever crosses the SDK boundary. */ export interface OpenPetsBubble { // --- content --- + /** Host-rendered mini HUD layout for pinned bubbles. */ + hud?: OpenPetsBubbleHud; /** Plain text, length-capped, content-filtered. */ text?: string; /** Limited markdown (bold/italic/code/line breaks), host-sanitized. */ diff --git a/plugins/official/openpets.virtual-pet/assets/virtual-pet.svg b/plugins/official/openpets.virtual-pet/assets/virtual-pet.svg new file mode 100644 index 00000000..68b977fd --- /dev/null +++ b/plugins/official/openpets.virtual-pet/assets/virtual-pet.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/plugins/official/openpets.virtual-pet/index.js b/plugins/official/openpets.virtual-pet/index.js new file mode 100644 index 00000000..13556200 --- /dev/null +++ b/plugins/official/openpets.virtual-pet/index.js @@ -0,0 +1,382 @@ +// 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() {}, + }); +} diff --git a/plugins/official/openpets.virtual-pet/locales/en.json b/plugins/official/openpets.virtual-pet/locales/en.json new file mode 100644 index 00000000..b3d0cceb --- /dev/null +++ b/plugins/official/openpets.virtual-pet/locales/en.json @@ -0,0 +1,55 @@ +{ + "plugin.name": "Virtual Pet", + "plugin.description": "Care for a little desktop companion and watch your bond grow through gentle everyday interactions.", + "hud.food": "Food", + "hud.energy": "Energy", + "hud.play": "Play", + "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", + "command.play.description": "Play a quick game.", + "command.pet.title": "Pet", + "command.pet.description": "Give your pet some affection.", + "command.nap.title": "Let rest", + "command.nap.description": "Let your pet rest for 15 minutes.", + "command.status.title": "Check on pet", + "command.status.description": "Check how your pet is feeling.", + + "speech.feed.0": "Thanks for the food!", + "speech.feed.1": "*munch munch munch*", + "speech.feed.2": "Yum, tasty!", + "speech.feed.3": "That was delicious!", + + "speech.play.0": "That was fun!", + "speech.play.1": "Yay!", + "speech.play.2": "I love playing games!", + "speech.play.3": "Again!", + + "speech.pet.0": "Purr...", + "speech.pet.1": "*nuzzles*", + "speech.pet.2": "Warm...", + "speech.pet.3": "So soft!", + + "speech.nap.0": "Zzz...", + "speech.nap.1": "*curls up*", + "speech.nap.2": "Sleepy...", + "speech.nap.3": "Resting...", + + "speech.levelup": "We're growing closer!", + + "speech.status.sleeping": "Zzz... resting.", + "speech.status.hungry": "I'm a bit hungry.", + "speech.status.tired": "I'm feeling sleepy.", + "speech.status.bored": "I want to play.", + "speech.status.happy": "I'm feeling great!", + "speech.status.content": "I'm content.", + + "nudge.hungry": "I'm getting a little hungry.", + "nudge.tired": "I'm getting sleepy.", + "nudge.bored": "Would you like to play?", + "nudge.neglected": "Could I have some attention?" +} diff --git a/plugins/official/openpets.virtual-pet/openpets.plugin.json b/plugins/official/openpets.virtual-pet/openpets.plugin.json new file mode 100644 index 00000000..b1c2ea8b --- /dev/null +++ b/plugins/official/openpets.virtual-pet/openpets.plugin.json @@ -0,0 +1,34 @@ +{ + "manifestVersion": 3, + "id": "openpets.virtual-pet", + "name": "$t:plugin.name", + "description": "$t:plugin.description", + "version": "1.0.0", + "runtime": "javascript", + "icon": "heart", + "sdkVersion": "3.0.0", + "entry": "index.js", + "assets": { + "icons": { + "virtual-pet": "assets/virtual-pet.svg" + } + }, + "permissions": [ + "pet:speak", + "pet:interact", + "pet:pin", + "pet:reaction", + "schedule", + "storage", + "commands", + "audio", + "events" + ], + "configSchema": { + "sound": { + "type": "sound", + "label": "$t:config.sound.label", + "description": "$t:config.sound.description" + } + } +} diff --git a/plugins/official/openpets.virtual-pet/test.js b/plugins/official/openpets.virtual-pet/test.js new file mode 100644 index 00000000..e26ff401 --- /dev/null +++ b/plugins/official/openpets.virtual-pet/test.js @@ -0,0 +1,229 @@ +// Golden test for openpets.virtual-pet. +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { + cleanState, + getMood, + addXp, + applyDecay, + register, + SCHEDULE_ID, +} from "./index.js"; + +let createTestHarness; +try { + ({ createTestHarness } = await import("@open-pets/plugin-sdk/testing")); +} catch { + ({ createTestHarness } = await import(new URL("../../../packages/sdk/dist/testing.js", import.meta.url))); +} + +let activeHarness = null; +const originalDateNow = Date.now; +Object.defineProperty(Date, "now", { + value: () => { + if (activeHarness && activeHarness.clock) { + return activeHarness.clock.now(); + } + return 1000000; + }, + configurable: true, +}); + +// 1) Test Pure Helpers +{ + // cleanState + const defaultState = cleanState(null); + assert.equal(defaultState.hunger, 80); + assert.equal(defaultState.energy, 80); + assert.equal(defaultState.happiness, 80); + assert.equal(defaultState.affection, 50); + assert.equal(defaultState.level, 1); + assert.equal(defaultState.xp, 0); + + const customState = cleanState({ hunger: 20, level: 3, careCounts: { fed: 5 } }); + assert.equal(customState.hunger, 20); + assert.equal(customState.level, 3); + assert.equal(customState.careCounts.fed, 5); + + // getMood + assert.equal(getMood({ hunger: 80, energy: 80, happiness: 80, affection: 80, sleptUntil: 0 }, 1000), "happy"); + assert.equal(getMood({ hunger: 80, energy: 80, happiness: 80, affection: 80, sleptUntil: 5000 }, 1000), "sleeping"); + assert.equal(getMood({ hunger: 20, energy: 80, happiness: 80, affection: 50, sleptUntil: 0 }, 1000), "hungry"); + assert.equal(getMood({ hunger: 80, energy: 10, happiness: 80, affection: 50, sleptUntil: 0 }, 1000), "tired"); + assert.equal(getMood({ hunger: 80, energy: 80, happiness: 15, affection: 50, sleptUntil: 0 }, 1000), "bored"); + + // addXp + const levelUp = addXp({ xp: 45, level: 1 }, 10); + assert.equal(levelUp.xp, 5); + assert.equal(levelUp.level, 2); + assert.equal(levelUp.leveledUp, true); + + const normalXp = addXp({ xp: 10, level: 1 }, 10); + assert.equal(normalXp.xp, 20); + assert.equal(normalXp.level, 1); + assert.equal(normalXp.leveledUp, false); + + // applyDecay + // 2 hours awake decay: hunger -4, energy -6, happiness -4, affection -2 + const stateDecayed = applyDecay({ hunger: 80, energy: 80, happiness: 80, affection: 50, sleptUntil: 0, lastSeenAt: 1000 }, 2 * 3600_000, 1000 + 2 * 3600_000); + assert.equal(stateDecayed.hunger, 76); + assert.equal(stateDecayed.energy, 74); + assert.equal(stateDecayed.happiness, 76); + assert.equal(stateDecayed.affection, 48); + + // 1 hour sleep decay: hunger -2, energy +15, happiness -0.5, affection same + const stateSlept = applyDecay({ hunger: 80, energy: 50, happiness: 80, affection: 50, sleptUntil: 1000 + 3600_000, lastSeenAt: 1000 }, 3600_000, 1000 + 3600_000); + assert.equal(stateSlept.hunger, 78); + assert.equal(stateSlept.energy, 65); + assert.equal(stateSlept.happiness, 79.5); + assert.equal(stateSlept.affection, 50); +} + +const PERMISSIONS = ["pet:speak", "pet:interact", "pet:pin", "pet:reaction", "schedule", "storage", "commands", "audio", "events"]; +const LOCALES = { en: JSON.parse(await readFile(new URL("./locales/en.json", import.meta.url), "utf8")) }; + +// 2) Start / Reconcile logic +{ + const h = createTestHarness(register, { permissions: PERMISSIONS, locales: LOCALES, nowMs: 100_000_000_000 }); + activeHarness = h; + await h.start(); + // Expect state to be initialized is storage + 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"); + + h.expectNoErrors(); +} + +// 3) Reconcile with wall-clock decay catch-up +{ + const h = createTestHarness(register, { permissions: PERMISSIONS, locales: LOCALES, nowMs: 101_000_000_000 }); + activeHarness = h; + // Set old state manually in storage. Last seen 2 hours ago. + 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(); +} + +// 4) Commands / actions mutate stats +{ + 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); + h.expectSpoke(/food|munch|tasty|delicious/); + h.expectReacted("celebrating"); + + // Play command: happiness +25, energy -15, xp +5 + await h.runCommand("play"); + h.expectStored("state", (s) => s.happiness === 100 && s.energy === 65 && s.xp === 10 && s.careCounts.played === 1); + h.expectSpoke(/fun|Yay|games|Again/); + + // Pet command: affection +15, happiness +10, xp +3 + await h.runCommand("pet"); + h.expectStored("state", (s) => s.affection === 65 && s.happiness === 100 && s.xp === 13 && s.careCounts.petted === 1); + h.expectSpoke(/Purr|nuzzles|Warm|soft/); + + // Nap command: energy +40, sleptUntil is set + await h.runCommand("nap"); + h.expectStored("state", (s) => s.energy === 100 && s.sleptUntil === 102_000_000_000 + 15 * 60_000 && s.careCounts.napped === 1); + h.expectSpoke(/Zzz|curls|Sleepy|Resting/); + h.expectReacted("waiting"); + + // Show status command + await h.runCommand("status"); + h.expectSpoke(/resting|hungry|sleepy|play|great|content/); + + h.expectNoErrors(); +} + +// 5) Play wakes up pet if sleeping +{ + const h = createTestHarness(register, { permissions: PERMISSIONS, locales: LOCALES, nowMs: 103_000_000_000 }); + activeHarness = h; + 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); + h.expectNoErrors(); +} + +// 6) Click event triggers petting +{ + 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); + h.expectSpoke(/Purr|nuzzles|Warm|soft/); + h.expectNoErrors(); +} + +// 7) Nudge neglected +{ + const h = createTestHarness(register, { permissions: PERMISSIONS, locales: LOCALES, nowMs: 105_000_000_000 }); + activeHarness = h; + // Set neglected state + 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); + + // If we advance clock by 5 mins, nudge should NOT fire again (cooldown) + const previousSpeakCount = h.calls.speak.length; + await h.clock.advance("5m"); + assert.equal(h.calls.speak.length, previousSpeakCount, "nudge should not spam"); + + h.expectNoErrors(); +} + +Object.defineProperty(Date, "now", { + value: originalDateNow, + configurable: true, +}); + +console.log("openpets.virtual-pet: all checks passed.");