Skip to content

Commit 122a4be

Browse files
committed
Add random pet action runner
1 parent f6f513c commit 122a4be

4 files changed

Lines changed: 294 additions & 0 deletions

File tree

packages/core/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export { createCodexPetAnimator } from "./animator.js";
1111
export { createCodexPetDragController } from "./drag.js";
1212
export { createCodexPetElement } from "./dom.js";
1313
export { createCodexPetRegistry } from "./registry.js";
14+
export { createCodexPetRandomActionRunner } from "./random-actions.js";
1415
export {
1516
CODEX_PET_EXAMPLES,
1617
getCodexPetExample,
@@ -38,6 +39,10 @@ export type {
3839
CodexPetPosition,
3940
CodexPetRegistry,
4041
CodexPetRegistryListener,
42+
CodexPetRandomAction,
43+
CodexPetRandomActionRunner,
44+
CodexPetRandomActionRunnerOptions,
45+
CodexPetRandomActionTarget,
4146
CodexPetSetStateOptions,
4247
CodexPetSnapshot,
4348
CodexPetState,
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import type {
2+
CodexPetRandomAction,
3+
CodexPetRandomActionRunner,
4+
CodexPetRandomActionRunnerOptions,
5+
CodexPetRandomActionTarget
6+
} from "./types.js";
7+
8+
const DEFAULT_MIN_INTERVAL_SECONDS = 30;
9+
const DEFAULT_MAX_INTERVAL_MULTIPLIER = 3;
10+
const SECONDS_TO_MS = 1000;
11+
12+
type TimeoutHandle = ReturnType<typeof setTimeout>;
13+
14+
function clamp(value: number, min: number, max: number): number {
15+
return Math.min(Math.max(value, min), max);
16+
}
17+
18+
function getPositiveNumber(value: number | undefined, fallback: number): number {
19+
return Number.isFinite(value) && value !== undefined && value > 0
20+
? value
21+
: fallback;
22+
}
23+
24+
function getDelayMs(
25+
options: CodexPetRandomActionRunnerOptions,
26+
random: () => number
27+
): number {
28+
const average = getPositiveNumber(options.averageIntervalSeconds, 120);
29+
const min = getPositiveNumber(
30+
options.minIntervalSeconds,
31+
DEFAULT_MIN_INTERVAL_SECONDS
32+
);
33+
const max = getPositiveNumber(
34+
options.maxIntervalSeconds,
35+
average * DEFAULT_MAX_INTERVAL_MULTIPLIER
36+
);
37+
const sample = -Math.log(1 - clamp(random(), 0, 0.999999)) * average;
38+
39+
return clamp(sample, min, Math.max(min, max)) * SECONDS_TO_MS;
40+
}
41+
42+
function pickWeightedAction(
43+
actions: CodexPetRandomAction[],
44+
random: () => number
45+
): CodexPetRandomAction | null {
46+
const weightedActions = actions
47+
.map((action) => ({
48+
...action,
49+
weight: getPositiveNumber(action.weight, 1)
50+
}))
51+
.filter((action) => action.weight > 0);
52+
const totalWeight = weightedActions.reduce(
53+
(total, action) => total + action.weight,
54+
0
55+
);
56+
57+
if (totalWeight <= 0) {
58+
return null;
59+
}
60+
61+
let cursor = clamp(random(), 0, 0.999999) * totalWeight;
62+
63+
for (const action of weightedActions) {
64+
if (cursor < action.weight) {
65+
return action;
66+
}
67+
68+
cursor -= action.weight;
69+
}
70+
71+
return weightedActions[weightedActions.length - 1] ?? null;
72+
}
73+
74+
function canPlayRandomAction(target: CodexPetRandomActionTarget): boolean {
75+
const snapshot = target.getSnapshot();
76+
77+
return (
78+
snapshot.state === "idle" &&
79+
!snapshot.hidden &&
80+
!snapshot.paused &&
81+
!snapshot.removed &&
82+
snapshot.mounted
83+
);
84+
}
85+
86+
export function createCodexPetRandomActionRunner(
87+
target: CodexPetRandomActionTarget,
88+
options: CodexPetRandomActionRunnerOptions
89+
): CodexPetRandomActionRunner {
90+
const random = options.random ?? Math.random;
91+
let timeout: TimeoutHandle | null = null;
92+
let running = false;
93+
94+
function clear(): void {
95+
if (timeout === null) {
96+
return;
97+
}
98+
99+
clearTimeout(timeout);
100+
timeout = null;
101+
}
102+
103+
function schedule(): void {
104+
clear();
105+
106+
if (!running) {
107+
return;
108+
}
109+
110+
timeout = setTimeout(run, getDelayMs(options, random));
111+
}
112+
113+
function run(): void {
114+
timeout = null;
115+
116+
if (running && canPlayRandomAction(target)) {
117+
const action = pickWeightedAction(options.actions, random);
118+
119+
if (action) {
120+
target.play(action.state, { loops: action.loops ?? 1 });
121+
}
122+
}
123+
124+
schedule();
125+
}
126+
127+
return {
128+
start: () => {
129+
if (running) {
130+
return;
131+
}
132+
133+
running = true;
134+
schedule();
135+
},
136+
stop: () => {
137+
running = false;
138+
clear();
139+
},
140+
destroy: () => {
141+
running = false;
142+
clear();
143+
}
144+
};
145+
}

packages/core/src/types.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,3 +220,28 @@ export interface CodexPetRegistry {
220220
list(): CodexPetController[];
221221
subscribe(listener: CodexPetRegistryListener): () => void;
222222
}
223+
224+
export interface CodexPetRandomAction {
225+
state: CodexPetState;
226+
weight?: number;
227+
loops?: number;
228+
}
229+
230+
export interface CodexPetRandomActionTarget {
231+
getSnapshot(): CodexPetSnapshot;
232+
play(state: CodexPetState, options?: CodexPetPlayOptions): void;
233+
}
234+
235+
export interface CodexPetRandomActionRunnerOptions {
236+
actions: CodexPetRandomAction[];
237+
averageIntervalSeconds: number;
238+
minIntervalSeconds?: number;
239+
maxIntervalSeconds?: number;
240+
random?: () => number;
241+
}
242+
243+
export interface CodexPetRandomActionRunner {
244+
start(): void;
245+
stop(): void;
246+
destroy(): void;
247+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import {
2+
createCodexPetRandomActionRunner,
3+
type CodexPetRandomActionTarget,
4+
type CodexPetSnapshot,
5+
type CodexPetState
6+
} from "../src/index";
7+
8+
function createTarget(state: CodexPetState): CodexPetRandomActionTarget & {
9+
played: CodexPetState[];
10+
setState: (state: CodexPetState) => void;
11+
setHidden: (hidden: boolean) => void;
12+
} {
13+
let currentState = state;
14+
let hidden = false;
15+
const played: CodexPetState[] = [];
16+
17+
return {
18+
played,
19+
setState: (nextState) => {
20+
currentState = nextState;
21+
},
22+
setHidden: (nextHidden) => {
23+
hidden = nextHidden;
24+
},
25+
getSnapshot: () =>
26+
({
27+
id: "assistant",
28+
spritesheetUrl: "/pets/sapling/spritesheet.webp",
29+
state: currentState,
30+
baseState: currentState,
31+
frame: 0,
32+
scale: 1,
33+
fps: 8,
34+
position: null,
35+
hidden,
36+
mounted: true,
37+
paused: false,
38+
removed: false
39+
}) satisfies CodexPetSnapshot,
40+
play: (nextState) => {
41+
played.push(nextState);
42+
}
43+
};
44+
}
45+
46+
describe("Codex pet random action runner", () => {
47+
beforeEach(() => {
48+
vi.useFakeTimers();
49+
});
50+
51+
afterEach(() => {
52+
vi.useRealTimers();
53+
});
54+
55+
it("plays a weighted random action only when the pet is idle", () => {
56+
const target = createTarget("idle");
57+
const runner = createCodexPetRandomActionRunner(target, {
58+
averageIntervalSeconds: 120,
59+
minIntervalSeconds: 45,
60+
maxIntervalSeconds: 300,
61+
actions: [
62+
{ state: "waving", weight: 1 },
63+
{ state: "jumping", weight: 3 }
64+
],
65+
random: () => 0.5
66+
});
67+
68+
runner.start();
69+
vi.advanceTimersByTime(83_200);
70+
71+
expect(target.played).toEqual(["jumping"]);
72+
73+
target.setState("review");
74+
vi.advanceTimersByTime(83_200);
75+
76+
expect(target.played).toEqual(["jumping"]);
77+
78+
runner.destroy();
79+
});
80+
81+
it("clamps random delays to configured min and max bounds", () => {
82+
const target = createTarget("idle");
83+
const runner = createCodexPetRandomActionRunner(target, {
84+
averageIntervalSeconds: 120,
85+
minIntervalSeconds: 45,
86+
maxIntervalSeconds: 300,
87+
actions: [{ state: "waving", weight: 1 }],
88+
random: () => 0
89+
});
90+
91+
runner.start();
92+
vi.advanceTimersByTime(44_999);
93+
expect(target.played).toEqual([]);
94+
95+
vi.advanceTimersByTime(1);
96+
expect(target.played).toEqual(["waving"]);
97+
98+
runner.destroy();
99+
});
100+
101+
it("skips hidden, paused, removed, or unmounted pets", () => {
102+
const target = createTarget("idle");
103+
target.setHidden(true);
104+
const runner = createCodexPetRandomActionRunner(target, {
105+
averageIntervalSeconds: 120,
106+
minIntervalSeconds: 45,
107+
maxIntervalSeconds: 300,
108+
actions: [{ state: "waving", weight: 1 }],
109+
random: () => 0
110+
});
111+
112+
runner.start();
113+
vi.advanceTimersByTime(45_000);
114+
115+
expect(target.played).toEqual([]);
116+
117+
runner.destroy();
118+
});
119+
});

0 commit comments

Comments
 (0)