Extract plugin familiar action seam

This commit is contained in:
OpenPets Dev 2026-06-19 22:16:30 +00:00
parent b913978a3c
commit c42e1abe6f
5 changed files with 464 additions and 95 deletions

View file

@ -207,6 +207,8 @@ const agentSetupEditorToolsSource = readFileSync(join(appDir, "src", "agent-setu
const agentSetupEditorToolsOpenCodeSource = readFileSync(join(appDir, "src", "agent-setup-editor-tools-opencode.ts"), "utf8");
const agentSetupEditorToolsCursorSource = readFileSync(join(appDir, "src", "agent-setup-editor-tools-cursor.ts"), "utf8");
const agentSetupSupportSource = readFileSync(join(appDir, "src", "agent-setup-support.ts"), "utf8");
const pluginFamiliarRegistrySource = readFileSync(join(appDir, "src", "plugin-familiar-registry.ts"), "utf8");
const pluginFamiliarRegistryActionsSource = readFileSync(join(appDir, "src", "plugin-familiar-registry-actions.ts"), "utf8");
const pluginSdkBridgeSource = readFileSync(join(appDir, "src", "plugin-sdk-bridge.ts"), "utf8");
const pluginSdkApiBuilderSource = readFileSync(join(appDir, "src", "plugin-sdk-api-builder.ts"), "utf8");
const pluginSdkApiBuilderPlatformSource = readFileSync(join(appDir, "src", "plugin-sdk-api-builder-platform.ts"), "utf8");
@ -604,6 +606,10 @@ assert.match(agentSetupEditorToolsOpenCodeSource, /export function installOpenCo
assert.match(agentSetupEditorToolsCursorSource, /export function buildCursorSetupSnapshot/, "agent-setup Cursor helper seam must export setup snapshot building.");
assert.match(agentSetupEditorToolsCursorSource, /export function replaceCursorGlobalConfig/, "agent-setup Cursor helper seam must export replace writes.");
assert.match(pluginServiceDefaultPetSource, /export async function buildDefaultPetPluginCommands/, "plugin-service default-pet helper must export default familiar command shaping.");
assert.match(pluginFamiliarRegistrySource, /from "\.\/plugin-familiar-registry-actions(?:\.js)?"/, "plugin familiar registry must compose the extracted action seam.");
assert.match(pluginFamiliarRegistryActionsSource, /export function reactPluginPetAction/, "plugin familiar action seam must export reaction routing.");
assert.match(pluginFamiliarRegistryActionsSource, /export async function movePluginPetByAction/, "plugin familiar action seam must export bounded motion routing.");
assert.match(pluginFamiliarRegistryActionsSource, /export function getPluginPetArbiterAction/, "plugin familiar action seam must export bubble arbiter routing.");
assert.match(pluginServiceSupportSource, /from "\.\/plugin-service-actions(?:\.js)?"/, "plugin-service support barrel must re-export the service action seam.");
assert.match(pluginServiceActionsSource, /export async function buildPluginCatalogSnapshot/, "plugin-service action seam must export catalog snapshot shaping.");
assert.match(pluginServiceActionsSource, /export async function installOrUpdateCatalogPlugin/, "plugin-service action seam must export catalog install and update flows.");

View file

@ -318,6 +318,8 @@ plugin-service-local-support.ts → plugin-local-loader.ts validates selected fo
- `plugin-state.ts`: Persistent plugin state store (`familiaros-plugin-state.json`) with atomic temp+rename writes, normalized records, approved permissions, config, source, and broken reason.
- `plugin-runtime.ts`: Runtime that compiles enabled declarative timer triggers, starts/stops JavaScript plugin hosts, verifies approved permissions, exposes public command/status state, validates actions, schedules cancellable timers, and marks broken plugins on validation/action failure.
- `plugin-familiar-api.ts`: Narrow adapter from plugin actions to default familiar external `say`/`react` controller calls.
- `plugin-familiar-registry.ts`: Plugin-spawned familiar lifecycle ownership, list/change subscriptions, per-plugin teardown, and delegation into the extracted familiar action seam.
- `plugin-familiar-registry-actions.ts`: Extracted plugin familiar reaction/animation/status/motion/state/arbiter routing for default and spawned familiar handles.
- `plugin-service.ts`: Application-facing plugin orchestrator for safe snapshots, enable/disable, config save, command execution, reload, local load, bundled seeding, and delegation into the extracted bundled lifecycle plus catalog/install/uninstall/config-sound seams.
- `plugin-service-app.ts`: Extracted app-global plugin-service singleton, default familiar command/menu bridge, and test override hooks
- `plugin-service-support.ts`: Service barrel for the extracted plugin-service action/bundled-lifecycle/bundled/catalog/default-pet/dialog/local/snapshot/text helper seams

View file

@ -0,0 +1,252 @@
import type { BrowserWindow } from "electron";
import type { PetScaleValue } from "./app-state.js";
import type { Point } from "./display.js";
import type { WindowAccessor } from "./familiar-motion-engine.js";
import type { FamiliarOSReaction } from "./local-ipc-protocol.js";
import type { PetPluginBubbles, PetStatusBadgeReaction } from "./familiar-window.js";
import type { PetBubbleArbiter } from "./plugin-bubble-arbiter.js";
import type { PluginAnimationSpec, PluginPetState } from "./plugin-sdk-types.js";
import type { UniversalSpriteState } from "./reaction-animation-mapping.js";
export type PluginPetSpriteOverride = {
readonly filePath: string;
readonly fps: number;
readonly loop: boolean;
};
export type PluginFamiliarRecord = {
readonly handleId: string;
window: BrowserWindow | null;
readonly arbiter: PetBubbleArbiter;
bubbles: PetPluginBubbles;
statusReaction: PetStatusBadgeReaction | null;
currentAnimation: string;
spriteOverride: PluginPetSpriteOverride | null;
scale: PetScaleValue;
reactionRevert: NodeJS.Timeout | null;
};
export type PluginSpawnedFamiliarRecord = PluginFamiliarRecord & {
readonly petId: string;
};
type PluginFamiliarActionDependencies = {
readonly requireWindow: (petHandleId: string) => BrowserWindow;
readonly getSpawnedPet: (petHandleId: string) => PluginSpawnedFamiliarRecord | undefined;
readonly refreshSpawnedPet: (familiar: PluginSpawnedFamiliarRecord) => void;
readonly applyExternalPetReaction: (reaction: FamiliarOSReaction, options?: { readonly showMessage?: boolean }) => void;
readonly applyExternalPetStatusReaction: (reaction: FamiliarOSReaction | null) => void;
readonly getDefaultPetPaused: () => boolean;
readonly getDefaultAnimation: () => string;
readonly setDefaultAnimation: (animation: string) => void;
readonly setPetReactionState: (window: BrowserWindow, state: UniversalSpriteState) => void;
readonly setPetSpriteOverride: (window: BrowserWindow, override: PluginPetSpriteOverride | null) => void;
readonly setPetWindowScale: (window: BrowserWindow, scale: number) => void;
readonly resolveReactionState: (reaction: FamiliarOSReaction) => UniversalSpriteState;
readonly motionMoveTo: (petHandleId: string, accessor: WindowAccessor, point: Point, opts: { readonly durationMs?: number; readonly easing?: string }) => Promise<void>;
readonly motionSetFollowCursor: (petHandleId: string, accessor: WindowAccessor, opts: { readonly enabled: boolean; readonly lag?: number }) => void;
readonly motionSetPhysics: (petHandleId: string, accessor: WindowAccessor, opts: { readonly gravity?: boolean; readonly bounce?: number }) => void;
readonly getWindowAccessor: (petHandleId: string) => WindowAccessor;
readonly isPetWindowDragging: (window: BrowserWindow) => boolean;
readonly getDefaultPetHomePosition: () => Point;
readonly getDefaultPetArbiter: () => PetBubbleArbiter;
};
export function reactPluginPetAction(
dependencies: PluginFamiliarActionDependencies,
petHandleId: string,
reaction: FamiliarOSReaction,
options: { readonly showMessage?: boolean } = {},
): void {
if (petHandleId === "default") {
dependencies.applyExternalPetReaction(reaction, options);
dependencies.setDefaultAnimation(String(reaction));
return;
}
const familiar = dependencies.getSpawnedPet(petHandleId);
if (!familiar) throw new Error(`Familiar is not available: ${petHandleId}`);
applySpawnedPetAnimationAction(dependencies, familiar, { kind: "reaction", reaction });
}
export function setPluginPetAnimationAction(
dependencies: PluginFamiliarActionDependencies,
petHandleId: string,
spec: PluginAnimationSpec,
): void {
if (petHandleId === "default") {
const window = dependencies.requireWindow("default");
if (spec.kind === "reaction") {
dependencies.applyExternalPetReaction(spec.reaction);
dependencies.setPetSpriteOverride(window, null);
dependencies.setDefaultAnimation(String(spec.reaction));
} else {
dependencies.setPetSpriteOverride(window, { filePath: spec.spritePath, fps: spec.fps, loop: spec.loop });
dependencies.setDefaultAnimation("sprite");
}
return;
}
const familiar = dependencies.getSpawnedPet(petHandleId);
if (!familiar) throw new Error(`Familiar is not available: ${petHandleId}`);
applySpawnedPetAnimationAction(dependencies, familiar, spec);
}
export function applySpawnedPetAnimationAction(
dependencies: PluginFamiliarActionDependencies,
familiar: PluginFamiliarRecord,
spec: PluginAnimationSpec,
): void {
if (!familiar.window || familiar.window.isDestroyed()) throw new Error(`Familiar is not available: ${familiar.handleId}`);
if (familiar.reactionRevert) {
clearTimeout(familiar.reactionRevert);
familiar.reactionRevert = null;
}
if (spec.kind === "reaction") {
familiar.spriteOverride = null;
dependencies.setPetSpriteOverride(familiar.window, null);
dependencies.setPetReactionState(familiar.window, dependencies.resolveReactionState(spec.reaction));
familiar.currentAnimation = String(spec.reaction);
familiar.reactionRevert = setTimeout(() => {
familiar.reactionRevert = null;
if (familiar.window && !familiar.window.isDestroyed()) dependencies.setPetReactionState(familiar.window, "idle");
familiar.currentAnimation = "idle";
}, 4_000);
familiar.reactionRevert.unref?.();
return;
}
familiar.spriteOverride = { filePath: spec.spritePath, fps: spec.fps, loop: spec.loop };
dependencies.setPetSpriteOverride(familiar.window, familiar.spriteOverride);
familiar.currentAnimation = "sprite";
}
export function setPluginPetScaleAction(
dependencies: PluginFamiliarActionDependencies,
petHandleId: string,
scale: number,
): void {
dependencies.setPetWindowScale(dependencies.requireWindow(petHandleId), scale);
const familiar = dependencies.getSpawnedPet(petHandleId);
if (familiar) familiar.scale = scale as PetScaleValue;
}
export function setPluginPetStatusReactionAction(
dependencies: PluginFamiliarActionDependencies,
petHandleId: string,
reaction: FamiliarOSReaction | null,
): void {
if (petHandleId === "default") {
dependencies.applyExternalPetStatusReaction(reaction);
return;
}
const familiar = dependencies.getSpawnedPet(petHandleId);
if (!familiar) throw new Error(`Familiar is not available: ${petHandleId}`);
familiar.statusReaction = reaction === null || reaction === "idle" ? null : reaction as PetStatusBadgeReaction;
dependencies.refreshSpawnedPet(familiar);
}
export async function movePluginPetByAction(
dependencies: PluginFamiliarActionDependencies,
petHandleId: string,
opts: { readonly x: number; readonly y: number; readonly durationMs?: number },
): Promise<void> {
const window = dependencies.requireWindow(petHandleId);
const [x, y] = window.getPosition();
const distance = Math.min(Math.hypot(opts.x, opts.y), 160);
const magnitude = Math.hypot(opts.x, opts.y);
const scale = distance > 0 && magnitude > 0 ? distance / magnitude : 0;
await dependencies.motionMoveTo(
petHandleId,
dependencies.getWindowAccessor(petHandleId),
{ x: x + opts.x * scale, y: y + opts.y * scale },
{ durationMs: opts.durationMs ?? 700 },
);
}
export async function wanderPluginPetAction(
dependencies: PluginFamiliarActionDependencies,
petHandleId: string,
opts: { readonly distance?: number; readonly durationMs?: number },
random: () => number = Math.random,
): Promise<void> {
const distance = Math.min(Math.max(opts.distance ?? 80, 0), 160);
const angle = random() * Math.PI * 2;
await movePluginPetByAction(dependencies, petHandleId, {
x: Math.cos(angle) * distance,
y: Math.sin(angle) * distance,
durationMs: opts.durationMs,
});
}
export async function movePluginPetToHomeAction(
dependencies: PluginFamiliarActionDependencies,
petHandleId: string,
): Promise<void> {
await dependencies.motionMoveTo(
petHandleId,
dependencies.getWindowAccessor(petHandleId),
dependencies.getDefaultPetHomePosition(),
{ durationMs: 1_200 },
);
}
export async function movePluginPetToAction(
dependencies: PluginFamiliarActionDependencies,
petHandleId: string,
point: Point,
opts: { readonly durationMs?: number; readonly easing?: string } = {},
): Promise<void> {
dependencies.requireWindow(petHandleId);
await dependencies.motionMoveTo(petHandleId, dependencies.getWindowAccessor(petHandleId), point, opts);
}
export function setPluginPetFollowCursorAction(
dependencies: PluginFamiliarActionDependencies,
petHandleId: string,
opts: { readonly enabled: boolean; readonly lag?: number },
): void {
dependencies.requireWindow(petHandleId);
dependencies.motionSetFollowCursor(petHandleId, dependencies.getWindowAccessor(petHandleId), opts);
}
export function setPluginPetPhysicsAction(
dependencies: PluginFamiliarActionDependencies,
petHandleId: string,
opts: { readonly gravity?: boolean; readonly bounce?: number },
): void {
dependencies.requireWindow(petHandleId);
dependencies.motionSetPhysics(petHandleId, dependencies.getWindowAccessor(petHandleId), opts);
}
export function getPluginPetStateAction(
dependencies: PluginFamiliarActionDependencies,
petHandleId: string,
): PluginPetState {
const window = dependencies.requireWindow(petHandleId);
const [x, y] = window.getPosition();
const [width, height] = window.getSize();
const familiar = dependencies.getSpawnedPet(petHandleId);
return {
position: { x, y },
bounds: { x, y, width, height },
currentAnimation: petHandleId === "default"
? (dependencies.getDefaultPetPaused() ? "paused" : dependencies.getDefaultAnimation())
: familiar?.currentAnimation ?? "idle",
visible: window.isVisible(),
dragging: dependencies.isPetWindowDragging(window),
};
}
export function getPluginPetArbiterAction(
dependencies: PluginFamiliarActionDependencies,
petHandleId: string,
): PetBubbleArbiter {
if (petHandleId === "default") return dependencies.getDefaultPetArbiter();
const familiar = dependencies.getSpawnedPet(petHandleId);
if (!familiar) throw new Error(`Familiar is not available: ${petHandleId}`);
return familiar.arbiter;
}

View file

@ -10,6 +10,21 @@ import { motionMoveTo, motionSetFollowCursor, motionSetPhysics, motionStop, type
import { createAgentPetWindow, isPetWindowDragging, loadExplicitPetContent, setPetReactionState, setPetSpriteOverride, setPetWindowScale, type PetPluginBubbles, type PetStatusBadgeReaction } from "./familiar-window.js";
import { PetBubbleArbiter, type PetBubbleSink } from "./plugin-bubble-arbiter.js";
import { publishPluginPetEvent } from "./plugin-events-source.js";
import {
getPluginPetArbiterAction,
getPluginPetStateAction,
movePluginPetByAction,
movePluginPetToAction,
movePluginPetToHomeAction,
reactPluginPetAction,
setPluginPetAnimationAction,
setPluginPetFollowCursorAction,
setPluginPetPhysicsAction,
setPluginPetScaleAction,
setPluginPetStatusReactionAction,
type PluginSpawnedFamiliarRecord,
wanderPluginPetAction,
} from "./plugin-familiar-registry-actions.js";
import { resolveReactionSpriteState } from "./reaction-animation-mapping.js";
import type { PluginAnimationSpec, PluginPetInfo, PluginPetState } from "./plugin-sdk-types.js";
@ -19,19 +34,9 @@ import type { PluginAnimationSpec, PluginPetInfo, PluginPetState } from "./plugi
* arbiters, and liveness loops, and are torn down with their owning plugin.
*/
type SpawnedPet = {
readonly handleId: string;
type SpawnedPet = PluginSpawnedFamiliarRecord & {
readonly ownerPluginId: string;
readonly petId: string;
readonly name: string;
window: BrowserWindow | null;
readonly arbiter: PetBubbleArbiter;
bubbles: PetPluginBubbles;
statusReaction: PetStatusBadgeReaction | null;
currentAnimation: string;
spriteOverride: { filePath: string; fps: number; loop: boolean } | null;
scale: PetScaleValue;
reactionRevert: NodeJS.Timeout | null;
};
const spawnedPets = new Map<string, SpawnedPet>();
@ -42,6 +47,30 @@ const tickIntervalMs = 100;
let nextSpawnId = 0;
let defaultAnimation = "idle";
const pluginPetActionDependencies = {
requireWindow,
getSpawnedPet: (petHandleId: string) => spawnedPets.get(petHandleId),
refreshSpawnedPet,
applyExternalPetReaction,
applyExternalPetStatusReaction,
getDefaultPetPaused,
getDefaultAnimation: () => defaultAnimation,
setDefaultAnimation: (animation: string) => {
defaultAnimation = animation;
},
setPetReactionState,
setPetSpriteOverride,
setPetWindowScale,
resolveReactionState: (reaction: FamiliarOSReaction) => resolveReactionSpriteState(reaction, getAppStateSnapshot().preferences.reactionAnimationOverrides),
motionMoveTo,
motionSetFollowCursor,
motionSetPhysics,
getWindowAccessor: windowAccessor,
isPetWindowDragging,
getDefaultPetHomePosition: () => getDefaultPetInitialPosition(defaultPetWindowSize),
getDefaultPetArbiter: () => defaultPetBubbleArbiter,
} satisfies Parameters<typeof reactPluginPetAction>[0];
function windowAccessor(petHandleId: string): WindowAccessor {
if (petHandleId === "default") return getDefaultPetWindowForPlugins;
return () => {
@ -76,7 +105,7 @@ export function onPluginPetsChange(listener: (familiars: PluginPetInfo[]) => voi
return () => changeListeners.delete(listener);
}
function refreshSpawnedPet(familiar: SpawnedPet): void {
function refreshSpawnedPet(familiar: PluginSpawnedFamiliarRecord): void {
if (!familiar.window || familiar.window.isDestroyed()) return;
void loadExplicitPetContent(familiar.window, familiar.petId, null, familiar.statusReaction, undefined, familiar.scale, familiar.bubbles.transient || familiar.bubbles.pinned ? familiar.bubbles : null).then(() => {
if (familiar.window && !familiar.window.isDestroyed() && familiar.spriteOverride) setPetSpriteOverride(familiar.window, familiar.spriteOverride);
@ -171,115 +200,47 @@ export function hidePluginPet(petHandleId: string): void {
}
export function reactPluginPet(petHandleId: string, reaction: FamiliarOSReaction, options: { readonly showMessage?: boolean } = {}): void {
if (petHandleId === "default") { applyExternalPetReaction(reaction, options); defaultAnimation = String(reaction); return; }
const familiar = spawnedPets.get(petHandleId);
if (!familiar) throw new Error(`Familiar is not available: ${petHandleId}`);
applySpawnedPetAnimation(familiar, { kind: "reaction", reaction });
reactPluginPetAction(pluginPetActionDependencies, petHandleId, reaction, options);
}
export function setPluginPetAnimation(petHandleId: string, spec: PluginAnimationSpec): void {
if (petHandleId === "default") {
const window = requireWindow("default");
if (spec.kind === "reaction") {
applyExternalPetReaction(spec.reaction);
setPetSpriteOverride(window, null);
defaultAnimation = String(spec.reaction);
} else {
setPetSpriteOverride(window, { filePath: spec.spritePath, fps: spec.fps, loop: spec.loop });
defaultAnimation = "sprite";
}
return;
}
const familiar = spawnedPets.get(petHandleId);
if (!familiar) throw new Error(`Familiar is not available: ${petHandleId}`);
applySpawnedPetAnimation(familiar, spec);
}
function applySpawnedPetAnimation(familiar: SpawnedPet, spec: PluginAnimationSpec): void {
if (!familiar.window || familiar.window.isDestroyed()) throw new Error(`Familiar is not available: ${familiar.handleId}`);
if (familiar.reactionRevert) { clearTimeout(familiar.reactionRevert); familiar.reactionRevert = null; }
if (spec.kind === "reaction") {
familiar.spriteOverride = null;
setPetSpriteOverride(familiar.window, null);
const spriteState = resolveReactionSpriteState(spec.reaction, getAppStateSnapshot().preferences.reactionAnimationOverrides);
setPetReactionState(familiar.window, spriteState);
familiar.currentAnimation = String(spec.reaction);
familiar.reactionRevert = setTimeout(() => {
familiar.reactionRevert = null;
if (familiar.window && !familiar.window.isDestroyed()) setPetReactionState(familiar.window, "idle");
familiar.currentAnimation = "idle";
}, 4_000);
familiar.reactionRevert.unref?.();
} else {
familiar.spriteOverride = { filePath: spec.spritePath, fps: spec.fps, loop: spec.loop };
setPetSpriteOverride(familiar.window, familiar.spriteOverride);
familiar.currentAnimation = "sprite";
}
setPluginPetAnimationAction(pluginPetActionDependencies, petHandleId, spec);
}
export function setPluginPetScale(petHandleId: string, scale: number): void {
setPetWindowScale(requireWindow(petHandleId), scale);
const familiar = spawnedPets.get(petHandleId);
if (familiar) familiar.scale = scale as PetScaleValue;
setPluginPetScaleAction(pluginPetActionDependencies, petHandleId, scale);
}
export function setPluginPetStatusReaction(petHandleId: string, reaction: FamiliarOSReaction | null): void {
if (petHandleId === "default") {
applyExternalPetStatusReaction(reaction);
return;
}
const familiar = spawnedPets.get(petHandleId);
if (!familiar) throw new Error(`Familiar is not available: ${petHandleId}`);
familiar.statusReaction = reaction === null || reaction === "idle" ? null : reaction as PetStatusBadgeReaction;
refreshSpawnedPet(familiar);
setPluginPetStatusReactionAction(pluginPetActionDependencies, petHandleId, reaction);
}
export async function movePluginPetBy(petHandleId: string, opts: { x: number; y: number; durationMs?: number }): Promise<void> {
const window = requireWindow(petHandleId);
const [x, y] = window.getPosition();
const distance = Math.min(Math.hypot(opts.x, opts.y), 160);
const scale = distance > 0 ? distance / Math.hypot(opts.x, opts.y) : 0;
await motionMoveTo(petHandleId, windowAccessor(petHandleId), { x: x + opts.x * scale, y: y + opts.y * scale }, { durationMs: opts.durationMs ?? 700 });
await movePluginPetByAction(pluginPetActionDependencies, petHandleId, opts);
}
export async function wanderPluginPet(petHandleId: string, opts: { distance?: number; durationMs?: number }): Promise<void> {
const distance = Math.min(Math.max(opts.distance ?? 80, 0), 160);
const angle = Math.random() * Math.PI * 2;
await movePluginPetBy(petHandleId, { x: Math.cos(angle) * distance, y: Math.sin(angle) * distance, durationMs: opts.durationMs });
await wanderPluginPetAction(pluginPetActionDependencies, petHandleId, opts);
}
export async function movePluginPetToHome(petHandleId: string): Promise<void> {
const home = getDefaultPetInitialPosition(defaultPetWindowSize);
await motionMoveTo(petHandleId, windowAccessor(petHandleId), home, { durationMs: 1_200 });
await movePluginPetToHomeAction(pluginPetActionDependencies, petHandleId);
}
export async function movePluginPetTo(petHandleId: string, point: Point, opts: { durationMs?: number; easing?: string } = {}): Promise<void> {
requireWindow(petHandleId);
await motionMoveTo(petHandleId, windowAccessor(petHandleId), point, opts);
await movePluginPetToAction(pluginPetActionDependencies, petHandleId, point, opts);
}
export function setPluginPetFollowCursor(petHandleId: string, opts: { enabled: boolean; lag?: number }): void {
requireWindow(petHandleId);
motionSetFollowCursor(petHandleId, windowAccessor(petHandleId), opts);
setPluginPetFollowCursorAction(pluginPetActionDependencies, petHandleId, opts);
}
export function setPluginPetPhysics(petHandleId: string, opts: { gravity?: boolean; bounce?: number }): void {
requireWindow(petHandleId);
motionSetPhysics(petHandleId, windowAccessor(petHandleId), opts);
setPluginPetPhysicsAction(pluginPetActionDependencies, petHandleId, opts);
}
export function getPluginPetState(petHandleId: string): PluginPetState {
const window = requireWindow(petHandleId);
const [x, y] = window.getPosition();
const [width, height] = window.getSize();
const familiar = spawnedPets.get(petHandleId);
return {
position: { x, y },
bounds: { x, y, width, height },
currentAnimation: petHandleId === "default" ? (getDefaultPetPaused() ? "paused" : defaultAnimation) : familiar?.currentAnimation ?? "idle",
visible: window.isVisible(),
dragging: isPetWindowDragging(window),
};
return getPluginPetStateAction(pluginPetActionDependencies, petHandleId);
}
/** Host-driven behavior loop: throttled, auto-paused while hidden/dragging. */
@ -317,10 +278,7 @@ function stopTicker(petHandleId: string): void {
/** Show a plugin bubble on a familiar surface (the bubbles capability entry point). */
export function getPluginPetArbiter(petHandleId: string): PetBubbleArbiter {
if (petHandleId === "default") return defaultPetBubbleArbiter;
const familiar = spawnedPets.get(petHandleId);
if (!familiar) throw new Error(`Familiar is not available: ${petHandleId}`);
return familiar.arbiter;
return getPluginPetArbiterAction(pluginPetActionDependencies, petHandleId);
}
export function closeAllPluginPets(): void {

View file

@ -0,0 +1,151 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { BrowserWindow } from "electron";
import {
applySpawnedPetAnimationAction,
getPluginPetArbiterAction,
getPluginPetStateAction,
movePluginPetByAction,
reactPluginPetAction,
type PluginSpawnedFamiliarRecord,
} from "../src/plugin-familiar-registry-actions.js";
const desktopRoot = process.env.FAMILIAROS_DESKTOP_ROOT ?? resolve(dirname(fileURLToPath(import.meta.url)), "..");
const pluginFamiliarRegistrySource = readFileSync(resolve(desktopRoot, "src/plugin-familiar-registry.ts"), "utf8");
const pluginFamiliarRegistryActionsSource = readFileSync(resolve(desktopRoot, "src/plugin-familiar-registry-actions.ts"), "utf8");
assert.match(pluginFamiliarRegistrySource, /from "\.\/plugin-familiar-registry-actions(?:\.js)?"/, "plugin familiar registry must compose the extracted action seam.");
assert.match(pluginFamiliarRegistryActionsSource, /export function reactPluginPetAction/, "plugin familiar action seam must export reaction routing.");
assert.match(pluginFamiliarRegistryActionsSource, /export async function movePluginPetByAction/, "plugin familiar action seam must export bounded motion routing.");
assert.match(pluginFamiliarRegistryActionsSource, /export function getPluginPetArbiterAction/, "plugin familiar action seam must export bubble arbiter routing.");
let defaultAnimation = "idle";
const externalReactions: Array<{ reaction: string; showMessage?: boolean }> = [];
reactPluginPetAction(createActionDependencies({
applyExternalPetReaction: (reaction, options) => {
externalReactions.push({ reaction, showMessage: options?.showMessage });
},
getDefaultAnimation: () => defaultAnimation,
setDefaultAnimation: (animation) => {
defaultAnimation = animation;
},
}), "default", "thinking", { showMessage: false });
assert.deepEqual(externalReactions, [{ reaction: "thinking", showMessage: false }], "default familiar reactions must delegate through the external familiar controller seam.");
assert.equal(defaultAnimation, "thinking", "default familiar reactions must preserve the registry animation state.");
const animationStates: string[] = [];
const spriteOverrides: Array<Record<string, unknown> | null> = [];
const spawnedWindow = createWindowStub({ x: 12, y: 20, width: 96, height: 96, visible: true });
const spawnedPet = createSpawnedPet("plugin-familiar-1", spawnedWindow);
applySpawnedPetAnimationAction(createActionDependencies({
setPetReactionState: (_window, state) => {
animationStates.push(state);
},
setPetSpriteOverride: (_window, override) => {
spriteOverrides.push(override);
},
resolveReactionState: () => "waiting",
}), spawnedPet, { kind: "reaction", reaction: "thinking" });
assert.equal(spawnedPet.currentAnimation, "thinking", "spawned familiar reactions must update the current animation state.");
assert.deepEqual(animationStates, ["waiting"], "spawned familiar reactions must route through the injected reaction-state mapper.");
assert.deepEqual(spriteOverrides, [null], "spawned familiar reactions must clear sprite overrides before applying reaction animation.");
if (spawnedPet.reactionRevert) clearTimeout(spawnedPet.reactionRevert);
const moveTargets: Array<{ petHandleId: string; point: { x: number; y: number }; durationMs?: number }> = [];
await movePluginPetByAction(createActionDependencies({
requireWindow: () => createWindowStub({ x: 10, y: 20, width: 96, height: 96, visible: true }),
motionMoveTo: async (petHandleId, _accessor, point, opts) => {
moveTargets.push({ petHandleId, point, durationMs: opts.durationMs });
},
}), "plugin-familiar-2", { x: 300, y: 0, durationMs: 450 });
assert.deepEqual(moveTargets, [{ petHandleId: "plugin-familiar-2", point: { x: 170, y: 20 }, durationMs: 450 }], "plugin familiar movement must clamp travel distance before routing to motion.");
const defaultState = getPluginPetStateAction(createActionDependencies({
requireWindow: () => createWindowStub({ x: 30, y: 40, width: 120, height: 140, visible: false }),
getDefaultPetPaused: () => true,
getDefaultAnimation: () => "wave",
isPetWindowDragging: () => true,
}), "default");
assert.deepEqual(defaultState, {
position: { x: 30, y: 40 },
bounds: { x: 30, y: 40, width: 120, height: 140 },
currentAnimation: "paused",
visible: false,
dragging: true,
}, "plugin familiar state must preserve paused default familiar semantics.");
const defaultArbiter = { kind: "default" };
const spawnedArbiter = { kind: "spawned" };
const arbiterPet = createSpawnedPet("plugin-familiar-3", createWindowStub({ x: 0, y: 0, width: 80, height: 80, visible: true }), spawnedArbiter);
assert.equal(getPluginPetArbiterAction(createActionDependencies({ getDefaultPetArbiter: () => defaultArbiter as never }), "default"), defaultArbiter, "default familiar arbiter routing must stay on the default familiar controller surface.");
assert.equal(getPluginPetArbiterAction(createActionDependencies({ getSpawnedPet: () => arbiterPet }), "plugin-familiar-3"), spawnedArbiter, "spawned familiar arbiter routing must preserve per-window bubble arbiters.");
console.error("Plugin familiar registry action seam validation passed.");
function createActionDependencies(overrides: Partial<Parameters<typeof reactPluginPetAction>[0]> = {}): Parameters<typeof reactPluginPetAction>[0] {
return {
requireWindow: () => createWindowStub({ x: 0, y: 0, width: 96, height: 96, visible: true }),
getSpawnedPet: () => undefined,
refreshSpawnedPet: () => undefined,
applyExternalPetReaction: () => undefined,
applyExternalPetStatusReaction: () => undefined,
getDefaultPetPaused: () => false,
getDefaultAnimation: () => "idle",
setDefaultAnimation: () => undefined,
setPetReactionState: () => undefined,
setPetSpriteOverride: () => undefined,
setPetWindowScale: () => undefined,
resolveReactionState: () => "idle",
motionMoveTo: async () => undefined,
motionSetFollowCursor: () => undefined,
motionSetPhysics: () => undefined,
getWindowAccessor: () => () => createWindowStub({ x: 0, y: 0, width: 96, height: 96, visible: true }),
isPetWindowDragging: () => false,
getDefaultPetHomePosition: () => ({ x: 0, y: 0 }),
getDefaultPetArbiter: () => ({}) as never,
...overrides,
};
}
function createSpawnedPet(
handleId: string,
window: BrowserWindow,
arbiter: unknown = { kind: "spawned" },
): PluginSpawnedFamiliarRecord {
return {
handleId,
petId: "builtin:test",
window,
arbiter: arbiter as never,
bubbles: { transient: null, pinned: null },
statusReaction: null,
currentAnimation: "idle",
spriteOverride: null,
scale: 1,
reactionRevert: null,
} as PluginSpawnedFamiliarRecord;
}
function createWindowStub({
x,
y,
width,
height,
visible,
}: {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
readonly visible: boolean;
}): BrowserWindow {
return {
getPosition: () => [x, y] as const,
getSize: () => [width, height] as const,
isVisible: () => visible,
isDestroyed: () => false,
} as unknown as BrowserWindow;
}