Add Virtual Pet HUD plugin
This commit is contained in:
parent
4f4c02eacd
commit
cd80535c26
13 changed files with 1033 additions and 14 deletions
|
|
@ -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<string, string> = {
|
||||
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 ? `<span class="bubble-hud-item-label">${escapeHtml(item.label)}</span>` : "";
|
||||
const tone = item.tone ?? "slate";
|
||||
const ariaLabel = item.label ? ` aria-label="${escapeHtml(`${item.label} ${value}%`)}"` : "";
|
||||
return `<div class="bubble-hud-item"${ariaLabel}><div class="bubble-hud-item-icon" aria-hidden="true">${iconMarkup}</div><div class="bubble-hud-item-content"><div class="bubble-hud-item-meta">${labelHtml}</div><div class="bubble-hud-item-bar"><div class="bubble-hud-item-fill tone-${tone}" style="width:${value}%"></div></div></div></div>`;
|
||||
}).join("");
|
||||
return `<div class="bubble-hud items-${hud.items.length}">${itemsHtml}</div>`;
|
||||
}
|
||||
|
||||
/** 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(`<div class="bubble-divider" aria-hidden="true"></div>`);
|
||||
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(`<div class="bubble-actions">${buttons}</div>`);
|
||||
}
|
||||
const actionsClass = bubble.actions?.length ? " has-actions" : "";
|
||||
return `<div class="bubble is-plugin${pinned ? " is-pinned" : ""}${actionsClass}${toneClass}${accentClass}" role="status" aria-live="polite"${dismissAttr} data-bubble-token="${token}">${parts.join("")}</div>`;
|
||||
const hudClass = bubble.hud ? " has-hud" : "";
|
||||
return `<div class="bubble is-plugin${pinned ? " is-pinned" : ""}${actionsClass}${hudClass}${toneClass}${accentClass}" role="status" aria-live="polite"${dismissAttr} data-bubble-token="${token}">${parts.join("")}</div>`;
|
||||
}
|
||||
|
||||
function createPluginIndicatorMarkup(indicator: PluginBubbleIndicator): string {
|
||||
|
|
|
|||
|
|
@ -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(!/```|<script|function\s+\w+\(|\b(import|export)\s/.test(markdown), "Bubble markdown looks like code.");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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("<html><head></head><body></body></html>"), /Content
|
|||
assert.match(injectPanelCsp("no head at all"), /^<meta http-equiv="Content-Security-Policy"/);
|
||||
assert.equal((injectPanelCsp('<head><meta http-equiv="Content-Security-Policy" content="default-src *"></head>').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) {
|
||||
|
|
|
|||
|
|
@ -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<PluginSdkBridge["createApi"]>;
|
||||
bridge: PluginSdkBridge;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" />
|
||||
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
|
||||
<line x1="9" y1="9" x2="9.01" y2="9" />
|
||||
<line x1="15" y1="9" x2="15.01" y2="9" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 396 B |
382
plugins/official/openpets.virtual-pet/index.js
Normal file
382
plugins/official/openpets.virtual-pet/index.js
Normal file
|
|
@ -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() {},
|
||||
});
|
||||
}
|
||||
55
plugins/official/openpets.virtual-pet/locales/en.json
Normal file
55
plugins/official/openpets.virtual-pet/locales/en.json
Normal file
|
|
@ -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?"
|
||||
}
|
||||
34
plugins/official/openpets.virtual-pet/openpets.plugin.json
Normal file
34
plugins/official/openpets.virtual-pet/openpets.plugin.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
229
plugins/official/openpets.virtual-pet/test.js
Normal file
229
plugins/official/openpets.virtual-pet/test.js
Normal file
|
|
@ -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.");
|
||||
Loading…
Add table
Reference in a new issue