| 1 | import { PetWorld, type PetInteraction, type PetSegment } from './pet-world.js'; |
| 2 | import { compilePetTelemetry, decodePetJSONL, PetLiveTape } from './pet-telemetry.js'; |
| 3 | import { ARCH_OF, digest, layout, PetSim } from './pet-sim.js'; |
| 4 | import { renderPetPCM, type PetVoice } from './pet-audio.js'; |
| 5 | import { PetEngineTelemetry } from './pet-engine.js'; |
| 6 | |
| 7 | /** Synchronous native boundary: JSON state and directly transferable PCM. |
| 8 | * Native hosts share the actual world / score implementation, not a rewrite. */ |
| 9 | export class PetNative { |
| 10 | private world: PetWorld; |
| 11 | private engine = new PetEngineTelemetry(); |
| 12 | private engineTick = 0; |
| 13 | private segment?: PetSegment; |
| 14 | private liveTape = new PetLiveTape(); |
| 15 | private stillProjection?: { key: string; points: number[][]; style: PetSim['frame'] }; |
| 16 | constructor(pointsJSON: string, tapeJSONL = '', interactionsJSON = '[]', live = false, expressionVersion: 1 | 2 = 2) { |
| 17 | const points = JSON.parse(pointsJSON) as [number, number][]; |
| 18 | if (!Array.isArray(points) || points.length !== 980 || points.some(p => !Array.isArray(p) || p.length !== 2 || !p.every(n => Number.isFinite(n) && Math.abs(n) <= 1))) |
| 19 | throw new Error('Invalid native whale body.'); |
| 20 | this.world = new PetWorld(points, live ? compilePetTelemetry([]) : decodePetJSONL(tapeJSONL), JSON.parse(interactionsJSON) as PetInteraction[], expressionVersion, true); |
| 21 | } |
| 22 | step(dt: number, motion: boolean): string { this.world.step(dt, { motion, sensitivity: 1 }); return this.snapshot(); } |
| 23 | snapshot(): string { return JSON.stringify({ ...this.world.frame, voices: this.world.voices, digest: digest(this.world.sim) }); } |
| 24 | /** View-only projection. Display cadence and accessibility preferences never |
| 25 | * advance the owner, consume randomness, or change its score/checkpoint. */ |
| 26 | presentation(): string { |
| 27 | const { sim, frame } = this.world; |
| 28 | const state = { ...frame.state, roamX: 0, roamY: 0, flip: 1, lit: frame.behaviour === 'doze' ? .18 : 1 }; |
| 29 | const key = JSON.stringify([state, frame.pod]); |
| 30 | if (this.stillProjection?.key !== key) { |
| 31 | const still = new PetSim(sim.p.map(p => [p.hx, p.hy]), 0xC0FFEE, sim.expressionVersion); |
| 32 | const peers = frame.pod.filter(p => p.present); |
| 33 | still.step(1 / 30, state, { motion: false, sensitivity: 1, |
| 34 | podSlots: peers.length >= 3 ? peers.map(p => [[0, 2, 4, 1, 3, 5][p.slot], p.phase] as const) : undefined }); |
| 35 | this.stillProjection = { key, points: still.p.map(p => [p.x, p.y]), style: still.frame }; |
| 36 | } |
| 37 | return JSON.stringify({ ...frame, digest: digest(sim), style: sim.frame, activity: this.engine.activity(frame.timeMs), |
| 38 | points: sim.p.map(p => [p.x, p.y]), |
| 39 | still: { points: this.stillProjection.points, style: this.stillProjection.style, state } }); |
| 40 | } |
| 41 | /** Losing a producer invalidates outstanding coverage, never the creature. */ |
| 42 | disconnectEngine(): void { this.engine = new PetEngineTelemetry(); this.world.voices = []; } |
| 43 | interact(kind: PetInteraction['kind'], x: number, y: number): void { this.world.interact(kind, x, y); } |
| 44 | interactions(): string { return JSON.stringify(this.world.interactions); } |
| 45 | accept(packet: string): void { this.world.acceptTelemetry(JSON.parse(packet)); } |
| 46 | acceptLiveTail(text: string): boolean { |
| 47 | const packet = this.liveTape.readTail(text); if (!packet) return false; |
| 48 | this.world.acceptTelemetry(packet); return true; |
| 49 | } |
| 50 | resetLiveInput(): number { this.liveTape.reset(); return this.resumeEngine(); } |
| 51 | recording(withCheckpoint = false): string { return JSON.stringify(this.world.recording(withCheckpoint)); } |
| 52 | needsSegment(): boolean { return this.world.needsSegment; } |
| 53 | prepareSegment(): string { this.segment = this.world.prepareSegment(); return JSON.stringify(this.segment.recording); } |
| 54 | commitSegment(): void { if (!this.segment) throw new Error('No pet segment was prepared.'); this.segment.commit(); this.segment = undefined; } |
| 55 | checkpoint(): string { return JSON.stringify(this.world.checkpoint()); } |
| 56 | recordingChunk(index: number, completed = false): string | null { return this.world.recordingChunk(index, completed); } |
| 57 | restoreCheckpoint(text: string): void { |
| 58 | if (text.length > 512 * 1024) throw new Error('Pet checkpoint exceeds its size limit.'); |
| 59 | this.restoreRecording(JSON.stringify({ ...this.world.recording(false), checkpoint: JSON.parse(text) })); |
| 60 | } |
| 61 | restoreRecording(text: string): void { |
| 62 | if (text.length > 8 * 1024 * 1024) throw new Error('Native habitat exceeds 8 MiB.'); |
| 63 | const points = this.world.sim.p.map(p => [p.hx, p.hy] as [number, number]); |
| 64 | this.world = PetWorld.fromRecording(points, JSON.parse(text)); |
| 65 | this.liveTape.reset(); |
| 66 | this.segment = undefined; this.engineTick = Math.round(this.world.frame.timeMs * 30 / 1000); this.engine = new PetEngineTelemetry(); |
| 67 | } |
| 68 | /** Resume a live host at the first unrecorded bucket. Keep every accepted |
| 69 | * interval, but never present its last observed frame as current evidence. */ |
| 70 | resumeEngine(): number { |
| 71 | this.world.resumeObservation(); |
| 72 | this.engineTick = Math.round(this.world.frame.timeMs * 30 / 1000); |
| 73 | this.engine = new PetEngineTelemetry(); |
| 74 | this.world.voices = []; |
| 75 | return this.world.frame.timeMs; |
| 76 | } |
| 77 | observeEngine(metadataJSON: string, timeMs: number): void { this.engine.observe(JSON.parse(metadataJSON), timeMs); } |
| 78 | observeEngineBatch(metadataJSON: string, timeMs: number): void { |
| 79 | const events: unknown = JSON.parse(metadataJSON); |
| 80 | if (!Array.isArray(events) || events.length > 64) throw new Error('Invalid Engine batch.'); |
| 81 | const next = this.engine.clone(); |
| 82 | for (const event of events) next.observe(event, timeMs); |
| 83 | this.engine = next; |
| 84 | } |
| 85 | advanceEngine(timeMs: number, motion: boolean, waiting: boolean): void { |
| 86 | const target = Math.floor(timeMs * 30 / 1000 + 1e-8); |
| 87 | if (!Number.isFinite(timeMs) || target < this.engineTick || target - this.engineTick > 300) throw new Error('Engine pet clock jump.'); |
| 88 | this.engine.confirmWaiting(timeMs, waiting); |
| 89 | const voices: PetVoice[] = []; |
| 90 | while (this.engineTick < target) { |
| 91 | if ((this.engineTick + 1) % 12 === 0) this.world.acceptTelemetry(this.engine.bucket(Math.floor(this.engineTick / 12))); |
| 92 | this.world.step(1 / 30, { motion, sensitivity: 1 }); |
| 93 | voices.push(...this.world.voices); |
| 94 | this.engineTick++; |
| 95 | } |
| 96 | this.world.voices = voices; |
| 97 | } |
| 98 | terminal(width: number, height: number): string { |
| 99 | if (![width, height].every(n => Number.isSafeInteger(n) && n >= 1 && n <= 512)) throw new Error('Invalid pet raster size.'); |
| 100 | const { sim, frame } = this.world, l = layout(width * 2, height * 4, frame.state); |
| 101 | const cells = Array(width * height).fill(0), bits = [[1, 8], [2, 16], [4, 32], [64, 128]]; |
| 102 | sim.p.forEach((p, i) => { |
| 103 | if (frame.state.observed < .92 && i % 2 === 1) return; |
| 104 | const x = Math.round(l.ox + p.x * l.scale * l.flipX), y = Math.round(l.oy + p.y * l.scale); |
| 105 | if (x >= 0 && x < width * 2 && y >= 0 && y < height * 4) cells[Math.floor(y / 4) * width + Math.floor(x / 2)] |= bits[y % 4][x % 2]; |
| 106 | }); |
| 107 | return JSON.stringify({ width, height, cells, timeMs: frame.timeMs, channel: frame.state.channel, arch: ARCH_OF[frame.state.channel], |
| 108 | hollow: frame.state.observed < .92, dozing: frame.behaviour === 'doze', lit: frame.state.lit }); |
| 109 | } |
| 110 | pcmChannels(voicesJSON: string, startSample: number, length: number, rate: number): [Float32Array, Float32Array] { |
| 111 | const p = renderPetPCM(JSON.parse(voicesJSON) as PetVoice[], startSample, length, rate); |
| 112 | return [p.left, p.right]; |
| 113 | } |
| 114 | pcm(voicesJSON: string, startSample: number, length: number, rate: number): string { |
| 115 | return JSON.stringify(this.pcmChannels(voicesJSON, startSample, length, rate).map(channel => Array.from(channel))); |
| 116 | } |
| 117 | } |
| 118 |