Extract plugin familiar action seam
This commit is contained in:
parent
b913978a3c
commit
c42e1abe6f
5 changed files with 464 additions and 95 deletions
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
252
apps/desktop/src/plugin-familiar-registry-actions.ts
Normal file
252
apps/desktop/src/plugin-familiar-registry-actions.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
151
apps/desktop/tests/plugin-familiar-registry-actions.test.ts
Normal file
151
apps/desktop/tests/plugin-familiar-registry-actions.test.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue