返回 CodeWhale
pet-world.ts
根目录 / pet / src / core / pet-world.ts
1 import { clamp, stableHash } from './model.js';
2 import { PetSim, PET_MAX_SECONDS, mulberry32, validatePetState, type PetOpts, type PetState, type PetSimCheckpoint } from './pet-sim.js';
3 import { validatePetBucket, type PetBucket } from './pet-telemetry.js';
4 import { PetScore, renderPetPCM, type PetVoice } from './pet-audio.js';
5
6 export type Behaviour = 'swim' | 'dive' | 'roll' | 'breathe' | 'drift' | 'doze' | 'wake';
7 export interface PetInteraction { timeMs: number; kind: 'attention' | 'food'; x: number; y: number }
8 export interface PodMember { id: string; slot: number; phase: number; present: boolean }
9 export interface WorldFrame {
10 timeMs: number; behaviour: Behaviour; state: PetState; telemetry?: PetBucket;
11 needs: 'none' | 'orient' | 'approach' | 'call'; pod: readonly PodMember[];
12 surface: number; caustic: number; food: { x: number; y: number; life: number } | null;
13 }
14 export interface PetWorldCheckpoint {
15 petCheckpointVersion: 1 | 2;
16 historyStart?: number;
17 hasTelemetry?: boolean;
18 /** Detects accidentally pairing a checkpoint with another recording. This
19 * is a consistency checksum, not authentication of imported telemetry. */
20 history: number;
21 sim: PetSimCheckpoint;
22 score: [number, number, boolean];
23 accumulator: number; tick: number; bucketIndex: number; interactionIndex: number; branchTick: number;
24 random: number; behaviour: Behaviour; until: number;
25 targetX: number; targetY: number; x: number; y: number; flip: number; lit: number;
26 lastActivity: number; addressedAt: number | null; waitSince: number;
27 food: { time: number; x: number; y: number } | null;
28 members: PodMember[]; lastStill: string;
29 frame: WorldFrame; voices: PetVoice[];
30 }
31 export interface PetRecording {
32 petReplayVersion: 1 | 2; expressionVersion?: 1 | 2;
33 tape: readonly PetBucket[]; interactions: readonly PetInteraction[];
34 start?: PetWorldCheckpoint; checkpoint?: PetWorldCheckpoint;
35 }
36 export interface PetSegment {
37 recording: PetRecording;
38 archive: PetRecording;
39 /** Call only after the outgoing recording and new habitat commit together.
40 * Ticks/inputs accepted while a browser transaction waits remain in memory. */
41 commit(): void;
42 }
43 const HZ = 30;
44 export { PET_MAX_SECONDS } from './pet-sim.js';
45 const seedFor = (name: string) => (0xC0FFEE ^ stableHash(name)) >>> 0;
46
47 /** Fixed-tick creature controller. Wall clocks and pointer APIs belong to drivers.
48 * Reconstructing with the same tape and interactions is also the seek operation.
49 * Particle, behaviour and identity randomness never consume one another's stream. */
50 export class PetWorld {
51 readonly sim: PetSim;
52 private tapeLog: PetBucket[];
53 private tapeHashes: number[] = [];
54 get tape(): readonly PetBucket[] { return this.tapeLog; }
55 private interactionLog: PetInteraction[];
56 get interactions(): readonly PetInteraction[] { return this.interactionLog.map(e => ({ ...e })); }
57 frame: WorldFrame;
58 voices: PetVoice[] = [];
59 private score = new PetScore();
60 private accumulator = 0;
61 private tick = 0;
62 private bucketIndex = -1;
63 private interactionIndex = 0;
64 private branchTick = -1;
65 private random = mulberry32(seedFor('behaviour'));
66 private behaviour: Behaviour = 'swim';
67 private until = 6;
68 private targetX = .35;
69 private targetY = -.08;
70 private x = 0;
71 private y = 0;
72 private flip = 1;
73 private lit = 1;
74 private lastActivity = 0;
75 private addressedAt = -Infinity;
76 private waitSince = -1;
77 private food: { time: number; x: number; y: number } | null = null;
78 private members = new Map<string, PodMember>();
79 private lastStill = '';
80 private origin?: PetWorldCheckpoint;
81 private segmented = false;
82 private hasTelemetry = false;
83 get startTimeMs(): number { return this.origin?.frame.timeMs ?? 0; }
84 get endTimeMs(): number { return Math.max(this.frame.timeMs, (this.tapeLog.at(-1)?.simTimeMs ?? 0) + 400); }
85 get needsSegment(): boolean {
86 return !this.segmented && this.tapeLog.length < 1024 && this.interactionLog.length < 4096
87 || this.bucketIndex >= 1024 || this.interactionIndex >= 4096;
88 }
89
90 constructor(points: [number, number][], tape: readonly PetBucket[] = [], interactions: readonly PetInteraction[] = [], expressionVersion: 1 | 2 = 2, segmented = false) {
91 if (tape.length > 216_000 || interactions.length > 100_000) throw new Error('Pet recording exceeds its input limit.');
92 this.sim = new PetSim(points, 0xC0FFEE, expressionVersion);
93 this.segmented = segmented; this.hasTelemetry = tape.length > 0;
94 this.tapeLog = structuredClone([...tape]);
95 this.interactionLog = structuredClone([...interactions]);
96 for (let i = 0; i < this.tape.length; i++) {
97 const b = this.tape[i];
98 validatePetBucket(b);
99 if (segmented ? i > 0 && b.sequence <= this.tape[i - 1].sequence : b.sequence !== i)
100 throw new Error('World requires contiguous version 1 pet buckets.');
101 }
102 for (let i = 0; i < this.interactionLog.length; i++) {
103 const e = this.interactionLog[i];
104 if (!Number.isFinite(e.timeMs) || e.timeMs < 0 || i > 0 && e.timeMs < this.interactionLog[i - 1].timeMs
105 || !['attention', 'food'].includes(e.kind) || !Number.isFinite(e.x) || !Number.isFinite(e.y)
106 || Math.abs(e.x) > 1 || Math.abs(e.y) > 1) throw new Error('Invalid pet interaction.');
107 }
108 this.hashTape(0);
109 this.frame = this.makeFrame(0);
110 this.voices = this.score.voices(this.frame);
111 if (segmented) this.origin = this.checkpoint();
112 }
113
114 checkpoint(): PetWorldCheckpoint {
115 return structuredClone({ petCheckpointVersion: this.segmented ? 2 : 1,
116 ...(this.segmented ? { historyStart: (this.origin?.tick ?? this.tick), hasTelemetry: this.hasTelemetry } : {}), history: this.historyDigest(),
117 sim: this.sim.checkpoint(), score: this.score.checkpoint(), accumulator: this.accumulator,
118 tick: this.tick, bucketIndex: this.bucketIndex, interactionIndex: this.interactionIndex, branchTick: this.branchTick,
119 random: this.random.state(), behaviour: this.behaviour, until: this.until,
120 targetX: this.targetX, targetY: this.targetY, x: this.x, y: this.y, flip: this.flip, lit: this.lit,
121 lastActivity: this.lastActivity, addressedAt: Number.isFinite(this.addressedAt) ? this.addressedAt : null,
122 waitSince: this.waitSince, food: this.food, members: [...this.members.values()], lastStill: this.lastStill,
123 frame: this.frame, voices: this.voices });
124 }
125
126 recording(withCheckpoint = true, completed = false): PetRecording {
127 const tapeEnd = completed ? this.bucketIndex + 1 : this.tapeLog.length;
128 const inputEnd = completed ? this.interactionIndex : this.interactionLog.length;
129 const history = this.historyDigest(tapeEnd, inputEnd);
130 return { petReplayVersion: this.segmented ? 2 : 1, expressionVersion: this.sim.expressionVersion,
131 tape: this.tapeLog.slice(0, tapeEnd), interactions: this.interactionLog.slice(0, inputEnd).map(e => ({ ...e })),
132 ...(this.origin ? { start: { ...structuredClone(this.origin), hasTelemetry: this.hasTelemetry, history } } : {}),
133 ...(withCheckpoint ? { checkpoint: { ...this.checkpoint(), history } } : {}) };
134 }
135
136 static fromRecording(points: [number, number][], value: unknown): PetWorld {
137 const r = value as PetRecording;
138 if (!r || ![1, 2].includes(r.petReplayVersion) || !Array.isArray(r.tape) || !Array.isArray(r.interactions)) throw new Error('Invalid pet recording.');
139 const version = r.expressionVersion === undefined ? 1 : r.expressionVersion;
140 if (![1, 2].includes(version)) throw new Error('Unsupported pet expression version.');
141 if (r.checkpoint !== undefined && !r.checkpoint || r.start !== undefined && !r.start) throw new Error('Invalid pet checkpoint.');
142 for (const c of [r.start, r.checkpoint]) if (c && (c.sim?.expressionVersion ?? 1) !== version) throw new Error('Pet expression version does not match its checkpoint.');
143 if (r.petReplayVersion === 1 && (r.start || r.checkpoint && r.checkpoint.petCheckpointVersion !== 1)) throw new Error('Invalid legacy pet recording.');
144 if (r.petReplayVersion === 2 && (!r.start || r.start.petCheckpointVersion !== 2 || r.start.historyStart !== r.start.tick)) throw new Error('The recording segment is missing its starting checkpoint.');
145 const first = r.start && PetWorld.restore(points, r.tape, r.interactions, r.start);
146 const world = r.checkpoint ? PetWorld.restore(points, r.tape, r.interactions, r.checkpoint) : first ?? new PetWorld(points, r.tape, r.interactions, version);
147 if (first) {
148 if (!world.segmented || world.tick < first.tick || r.checkpoint && r.checkpoint.historyStart !== first.tick) throw new Error('Pet segment checkpoints do not agree.');
149 world.origin = first.checkpoint();
150 }
151 return world;
152 }
153
154 /** Retire only consumed input. The exact origin makes each archived segment
155 * independently replayable; no particle, random stream or score is reset. */
156 prepareSegment(): PetSegment {
157 const dropTape = Math.max(0, this.bucketIndex), dropInputs = this.interactionIndex, previous = this.origin;
158 const tape = this.tapeLog.slice(dropTape), interactions = this.interactionLog.slice(dropInputs);
159 const points = this.sim.p.map(p => [p.hx, p.hy] as [number, number]);
160 const c = this.checkpoint();
161 c.petCheckpointVersion = 2; c.historyStart = this.tick; c.hasTelemetry = this.hasTelemetry;
162 c.bucketIndex -= dropTape; c.interactionIndex = 0;
163 c.history = new PetWorld(points, tape, interactions, this.sim.expressionVersion, true).historyDigest();
164 const next = PetWorld.restore(points, tape, interactions, c); next.origin = next.checkpoint();
165 let committed = false;
166 return { recording: next.recording(), archive: this.recording(true, true), commit: () => {
167 if (committed || this.origin !== previous || this.tick < c.tick) throw new Error('The pet recording segment has changed.');
168 this.tapeLog.splice(0, dropTape); this.interactionLog.splice(0, dropInputs);
169 this.bucketIndex -= dropTape; this.interactionIndex -= dropInputs;
170 this.tapeHashes = []; this.hashTape(0); this.segmented = true; this.origin = next.origin;
171 committed = true;
172 } };
173 }
174
175 /** Lossless version 1 export, including the current pose and score. Drivers
176 * consume all chunks synchronously on the world's owner before another tick.
177 * Only a small slice is serialized inside an embedded runtime at a time. */
178 recordingChunk(index: number, completed = false): string | null {
179 if (!Number.isSafeInteger(index) || index < 0) throw new Error('Invalid pet export cursor.');
180 const size = 16, tapeEnd = completed ? this.bucketIndex + 1 : this.tapeLog.length;
181 const inputEnd = completed ? this.interactionIndex : this.interactionLog.length;
182 const tapes = Math.ceil(tapeEnd / size), inputs = Math.ceil(inputEnd / size);
183 if (index === 0) return `{"petReplayVersion":${this.segmented ? 2 : 1},"expressionVersion":${this.sim.expressionVersion},"tape":[`;
184 if (index <= tapes) return (index === 1 ? '' : ',') + JSON.stringify(this.tapeLog.slice((index - 1) * size, Math.min(tapeEnd, index * size))).slice(1, -1);
185 if (index === tapes + 1) return '],"interactions":[';
186 const part = index - tapes - 2;
187 if (part < inputs) return (part === 0 ? '' : ',') + JSON.stringify(this.interactionLog.slice(part * size, Math.min(inputEnd, (part + 1) * size))).slice(1, -1);
188 if (part === inputs) {
189 const history = this.historyDigest(tapeEnd, inputEnd);
190 return `]${this.origin ? ',"start":' + JSON.stringify({ ...this.origin, hasTelemetry: this.hasTelemetry, history }) : ''},"checkpoint":${JSON.stringify({ ...this.checkpoint(), history })}}`;
191 }
192 return null;
193 }
194
195 /** Prefix states preserve the original FNV checksum byte for byte. Live
196 * appends and replacements hash only the changed suffix, so checkpointing
197 * does not rescan hours of accepted telemetry on the world worker. */
198 private hashTape(from: number): void {
199 for (let i = from; i < this.tapeLog.length; i++)
200 this.tapeHashes[i] = stableHash((i ? ',' : '') + JSON.stringify(this.tapeLog[i]), this.tapeHashes[i - 1] ?? stableHash('[['));
201 }
202 private historyDigest(tapeEnd = this.tapeLog.length, inputEnd = this.interactionLog.length): number {
203 let hash = stableHash('],[', this.tapeHashes[tapeEnd - 1] ?? stableHash('[['));
204 for (let i = 0; i < inputEnd; i++)
205 hash = stableHash((i ? ',' : '') + JSON.stringify(this.interactionLog[i]), hash);
206 return stableHash(']]', hash);
207 }
208
209 /** Hydrate a new world without stepping history. Validation finishes before
210 * the caller receives it, so a corrupt checkpoint never mutates a live pet. */
211 static restore(points: [number, number][], tape: readonly PetBucket[], interactions: readonly PetInteraction[], value: unknown): PetWorld {
212 const c = value as PetWorldCheckpoint;
213 const range = (n: number, low: number, high: number) => Number.isFinite(n) && n >= low && n <= high;
214 const integer = (n: number, low: number, high: number) => Number.isSafeInteger(n) && range(n, low, high);
215 if (!c || ![1, 2].includes(c.petCheckpointVersion) || JSON.stringify(c).length > 512 * 1024
216 || c.petCheckpointVersion === 2 && (!integer(c.historyStart!, 0, c.tick) || typeof c.hasTelemetry !== 'boolean')
217 || !integer(c.tick, 0, PET_MAX_SECONDS * HZ) || !range(c.accumulator, -1e-8, 1 / HZ + 1e-8)
218 || !integer(c.bucketIndex, -1, tape.length - 1) || !integer(c.interactionIndex, 0, interactions.length)
219 || !integer(c.branchTick, -1, c.tick) || !integer(c.random, 0, 0xffffffff)
220 || !['swim', 'dive', 'roll', 'breathe', 'drift', 'doze', 'wake'].includes(c.behaviour)
221 || !range(c.until, 0, PET_MAX_SECONDS + 10) || ![c.targetX, c.targetY, c.x, c.y, c.flip].every(n => range(n, -1, 1))
222 || !range(c.lit, 0, 1) || !range(c.lastActivity, 0, c.tick / HZ)
223 || c.addressedAt !== null && !range(c.addressedAt, 0, c.tick / HZ)
224 || !range(c.waitSince, -1, c.tick / HZ) || typeof c.lastStill !== 'string' || c.lastStill.length > 2048
225 || !Array.isArray(c.members) || c.members.length > 6
226 || c.members.some(m => !m || typeof m.id !== 'string' || !m.id || m.id.length > 4096
227 || !integer(m.slot, 0, 5) || !range(m.phase, 0, Math.PI * 2) || typeof m.present !== 'boolean')
228 || new Set(c.members.map(m => m.id)).size !== c.members.length || new Set(c.members.map(m => m.slot)).size !== c.members.length
229 || c.food !== null && (!c.food || !range(c.food.time, 0, c.tick / HZ) || ![c.food.x, c.food.y].every(n => range(n, -1, 1)))
230 || !c.frame || c.frame.timeMs !== c.tick * 1000 / HZ || c.frame.behaviour !== c.behaviour
231 || !['none', 'orient', 'approach', 'call'].includes(c.frame.needs)
232 || !range(c.frame.surface, -.9, -.8) || !range(c.frame.caustic, 0, 1)
233 || c.frame.food !== null && (!c.frame.food || !range(c.frame.food.x, -1, 1) || !range(c.frame.food.y, -1, 1.21) || !range(c.frame.food.life, 0, 1))
234 || JSON.stringify(c.frame.pod) !== JSON.stringify(c.members)
235 || !Array.isArray(c.voices) || c.voices.length > 1024
236 || c.voices.some(v => !v || typeof v.id !== 'string' || v.id.length > 256))
237 throw new Error('Invalid pet world checkpoint.');
238 validatePetState(c.frame.state);
239 renderPetPCM(c.voices, 0, 0);
240 const world = new PetWorld(points, tape, interactions, c.sim?.expressionVersion ?? 1, c.petCheckpointVersion === 2);
241 if (c.petCheckpointVersion === 2) { world.hasTelemetry = c.hasTelemetry!; world.origin = structuredClone(c); }
242 if (c.history !== world.historyDigest()
243 || c.bucketIndex >= 0 && world.tape[c.bucketIndex].simTimeMs > c.frame.timeMs + 1e-7
244 // acceptTelemetry may fill past gaps after the most recent fixed tick.
245 // Preserve that pending cursor exactly; it may lag only over empty gaps.
246 || world.tape.some((b, i) => i > c.bucketIndex && b.simTimeMs <= c.frame.timeMs + 1e-7
247 && (b.observed !== 0 || b.channel !== 'other' || b.errors || b.waiting || b.agentIds.length
248 || b.onsets.some(Boolean) || b.activeMs.some(Boolean)))
249 || world.interactionLog.slice(0, c.interactionIndex).some(e => e.timeMs > c.frame.timeMs + 1e-7)
250 || world.interactionLog[c.interactionIndex]?.timeMs <= c.frame.timeMs + 1e-7)
251 throw new Error('Pet checkpoint does not match its recording.');
252 const candidate = world.tape[c.bucketIndex];
253 const telemetry = candidate && c.frame.timeMs < candidate.simTimeMs + candidate.durationMs ? candidate : undefined;
254 if (JSON.stringify(c.frame.telemetry) !== JSON.stringify(telemetry)) throw new Error('Pet checkpoint telemetry does not match its clock.');
255 world.sim.restore(c.sim); world.score.restore(c.score); world.random.restore(c.random);
256 world.accumulator = c.accumulator; world.tick = c.tick; world.bucketIndex = c.bucketIndex;
257 world.interactionIndex = c.interactionIndex; world.branchTick = c.branchTick;
258 world.behaviour = c.behaviour; world.until = c.until; world.targetX = c.targetX; world.targetY = c.targetY;
259 world.x = c.x; world.y = c.y; world.flip = c.flip; world.lit = c.lit;
260 world.lastActivity = c.lastActivity; world.addressedAt = c.addressedAt ?? -Infinity; world.waitSince = c.waitSince;
261 world.food = structuredClone(c.food); world.members = new Map(c.members.map(m => [m.id, { ...m }]));
262 world.lastStill = c.lastStill;
263 // JSON omits undefined properties; keep the same frame shape as makeFrame.
264 world.frame = { ...structuredClone(c.frame), telemetry: telemetry ? structuredClone(telemetry) : undefined };
265 world.voices = structuredClone(c.voices);
266 return world;
267 }
268
269 /** Resume observation beyond all already accepted live packets, without
270 * replaying their sound or exposing a stale request as current work. */
271 resumeObservation(): void {
272 const last = this.tapeLog.at(-1), end = last ? (last.sequence + 1) * 12 : 0;
273 if (!end || end - this.tick > 24) throw new Error('Only a live recording can resume observation.');
274 while (this.tick < end) this.step(1 / 30, { motion: false, sensitivity: 1 });
275 this.voices = [];
276 }
277
278 /** Branch at the current playhead; input is journalled for the next fixed tick.
279 * A live touch never needs to re-simulate the creature's entire lifetime. */
280 interact(kind: PetInteraction['kind'], x: number, y: number): void {
281 if (!['attention', 'food'].includes(kind) || !Number.isFinite(x) || !Number.isFinite(y) || Math.abs(x) > 1 || Math.abs(y) > 1)
282 throw new Error('Invalid pet interaction.');
283 if (this.branchTick !== this.tick) this.interactionLog.splice(this.interactionIndex);
284 this.branchTick = this.tick;
285 this.interactionLog.push({ timeMs: (this.tick + 1) * 1000 / HZ, kind, x, y });
286 }
287
288 /** Accept a live source packet at the next 400ms boundary. The accepted tape,
289 * including any missing intervals, is the exact replay authority for this host. */
290 acceptTelemetry(input: PetBucket): void {
291 validatePetBucket(input);
292 const sequence = Math.floor(this.tick / 12) + 1;
293 if (this.tapeLog.length >= 216_000) throw new Error('Archive this pet recording before accepting more telemetry.');
294 this.hasTelemetry = true;
295 if (this.segmented) {
296 const at = this.tapeLog.findIndex(b => b.sequence >= sequence);
297 const index = at < 0 ? this.tapeLog.length : at;
298 this.tapeLog.splice(index, at >= 0 && this.tapeLog[at].sequence === sequence ? 1 : 0,
299 { ...structuredClone(input), sequence, simTimeMs: sequence * 400 });
300 this.hashTape(index); return;
301 }
302 if (sequence >= 216_000) throw new Error('Archive the legacy recording before accepting more telemetry.');
303 const changedFrom = Math.min(sequence, this.tapeLog.length);
304 while (this.tapeLog.length <= sequence) {
305 const at = this.tapeLog.length;
306 this.tapeLog.push({ version: 1, sequence: at, simTimeMs: at * 400, durationMs: 400,
307 activity: .12, coherence: .25, attention: 0, channel: 'other', observed: 0, roamX: 0, roamY: 0, flip: 1, lit: 1,
308 onsets: Array(13).fill(0), activeMs: Array(13).fill(0), errors: 0, agentIds: [], waiting: false });
309 }
310 this.tapeLog[sequence] = { ...structuredClone(input), sequence, simTimeMs: sequence * 400 };
311 this.hashTape(changedFrom);
312 }
313
314 /** dt is bounded so suspending a surface cannot cause an unbounded catch-up. */
315 step(dt: number, opts: PetOpts = { motion: true, sensitivity: 1 }): WorldFrame {
316 if (!Number.isFinite(dt) || dt < 0 || dt > 10) throw new Error('World dt must be in [0, 10] seconds.');
317 this.accumulator += dt;
318 this.voices = [];
319 while (this.accumulator + 1e-10 >= 1 / HZ) {
320 this.accumulator -= 1 / HZ;
321 const time = ++this.tick / HZ;
322 this.frame = this.makeFrame(time);
323 this.voices.push(...this.score.voices(this.frame));
324 if (!opts.motion) {
325 this.frame.state = { ...this.frame.state, roamX: 0, roamY: 0, flip: 1, lit: this.behaviour === 'doze' ? .18 : 1 };
326 this.frame.surface = -.86; this.frame.caustic = .5;
327 if (this.frame.food) this.frame.food.y = this.food!.y;
328 }
329 const signature = JSON.stringify(this.frame.state);
330 const peers = this.frame.pod.filter(p => p.present);
331 const podSlots = peers.length >= 3 ? peers.map(p => [[0, 2, 4, 1, 3, 5][p.slot], p.phase] as const) : undefined;
332 if (opts.motion || signature !== this.lastStill || podSlots) this.sim.step(1 / HZ, this.frame.state, { ...opts, podSlots });
333 this.lastStill = signature;
334 }
335 return this.frame;
336 }
337
338 private makeFrame(time: number): WorldFrame {
339 const timeMs = this.tick * 1000 / HZ;
340 while (this.bucketIndex + 1 < this.tape.length && this.tape[this.bucketIndex + 1].simTimeMs <= timeMs + 1e-7)
341 this.bucketIndex++;
342 const candidate = this.tape[this.bucketIndex];
343 const telemetry = candidate && timeMs < candidate.simTimeMs + candidate.durationMs ? candidate : undefined;
344 while (this.interactionIndex < this.interactionLog.length && this.interactionLog[this.interactionIndex].timeMs <= timeMs + 1e-7) {
345 const e = this.interactionLog[this.interactionIndex++];
346 this.addressedAt = time; this.lastActivity = time;
347 this.targetX = e.x * .65; this.targetY = e.y * .65;
348 if (e.kind === 'food') this.food = { time, x: e.x, y: e.y };
349 }
350 const busy = telemetry && telemetry.observed >= .92 && telemetry.activity > .2;
351 if (busy) this.lastActivity = time;
352 if (telemetry?.waiting) { if (this.waitSince < 0) this.waitSince = time; }
353 else this.waitSince = -1;
354 const waitingFor = this.waitSince < 0 ? 0 : time - this.waitSince;
355 const needs = this.waitSince < 0 ? 'none' : waitingFor < 8 ? 'orient' : waitingFor < 25 ? 'approach' : 'call';
356 const addressed = time - this.addressedAt < 2;
357 if (this.behaviour === 'doze' && (busy || addressed)) { this.behaviour = 'wake'; this.until = time + 2; }
358 else if (!busy && !addressed && time - this.lastActivity >= 75) this.behaviour = 'doze';
359 else if (time >= this.until) {
360 const choices: Behaviour[] = ['swim', 'swim', 'dive', 'roll', 'breathe', 'drift'];
361 this.behaviour = choices[Math.floor(this.random() * choices.length)];
362 this.until = time + 3 + this.random() * 7;
363 this.targetX = (this.random() * 2 - 1) * .75;
364 this.targetY = this.behaviour === 'dive' ? .6 : this.behaviour === 'breathe' ? -.65 : (this.random() * 2 - 1) * .4;
365 }
366 const sleeping = this.behaviour === 'doze';
367 if (sleeping) { this.targetY = .65; this.targetX = .15; }
368 if (this.sim.expressionVersion === 1 && (needs === 'approach' || needs === 'call')) { this.targetX = 0; this.targetY = .3; }
369 const move = sleeping ? .004 : this.behaviour === 'drift' ? .006 : .018;
370 const dx = this.targetX - this.x;
371 this.x += dx * move; this.y += (this.targetY - this.y) * move;
372 this.flip += ((Math.abs(dx) < .015 ? this.flip < 0 ? -1 : 1 : dx < 0 ? -1 : 1) - this.flip) * .035;
373 this.lit += ((sleeping ? .18 : 1) - this.lit) * .03;
374 const wild = !this.hasTelemetry;
375 const state: PetState = {
376 activity: telemetry?.activity ?? (wild ? sleeping ? .05 : .18 : .12),
377 coherence: telemetry?.coherence ?? (wild ? .94 : .25),
378 attention: Math.max(telemetry?.attention ?? 0, addressed ? .85 : 0, this.sim.expressionVersion === 1 && needs === 'call' ? 1 : 0),
379 // A touch changes orientation, never hides an instrumentation gap.
380 channel: addressed ? 'human' : telemetry?.channel ?? 'other',
381 observed: telemetry?.observed ?? (wild ? 1 : 0),
382 roamX: this.x, roamY: this.y, flip: this.flip, lit: this.lit,
383 };
384 const activeIds = new Set(telemetry?.agentIds ?? []);
385 for (const member of this.members.values()) member.present = activeIds.has(member.id);
386 for (const id of activeIds) {
387 if (!this.members.has(id)) {
388 const vacant = [...this.members.values()].find(m => !m.present);
389 const slot = this.members.size < 6 ? this.members.size : vacant?.slot;
390 if (slot !== undefined) {
391 if (this.members.size >= 6 && vacant) this.members.delete(vacant.id);
392 this.members.set(id, { id, slot, phase: mulberry32(seedFor(`pod:${id}`))() * Math.PI * 2, present: true });
393 }
394 }
395 const member = this.members.get(id); if (member) member.present = true;
396 }
397 return { timeMs, behaviour: this.behaviour, state, telemetry, needs,
398 pod: [...this.members.values()].map(m => ({ ...m })),
399 surface: -.86 + .012 * Math.sin(time * .8), caustic: .5 + .5 * Math.sin(time * .31),
400 food: this.food && time - this.food.time < 5
401 ? { x: this.food.x, y: this.food.y + (time - this.food.time) * .04, life: clamp(1 - (time - this.food.time) / 5, 0, 1) } : null };
402 }
403 }
404
404 lines TYPESCRIPT