| 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 |