fix(pet): retain imported world state and clarify discarded visits

Keep the imported browser world alive across source changes and validate an
import once before adopting it. Require explicit Android confirmation before
reopening saved state. Stream the interaction checksum without allocating a
second complete history string; serialized checksum bytes are unchanged.

Validation on this exact source tree:
- npm test: 66 package + 12 SDK + 446 web tests pass, 0 fail.
- npm run check:web: pass, 2 existing warnings.
- Pet typecheck/build and tests: 40 pass, 0 fail.
- Product TUI Watch: 11 pass, 0 fail.
- Apple checkpoint workflows: 9 pass; macOS bundle build passes.
- Android assemble, lint and API 35 emulator: 7 tests pass, 0 fail.
- Browser: 9,790,159-byte recording returns at 13.2 seconds with its code/strike/roll pose.
This commit is contained in:
CodeWhale Bot committed 2026-09-12 10:37:09 -07:00
1 parent a101953474
commit 49e6df27e6
6 files changed
+43 -18

No files matched your search

+4 -1
View File
@@ -217,7 +217,10 @@ class PetWorld {
this.tapeHashes[i] = (0, model_js_1.stableHash)((i ? ',' : '') + JSON.stringify(this.tapeLog[i]), this.tapeHashes[i - 1] ?? (0, model_js_1.stableHash)('[['));
}
historyDigest() {
return (0, model_js_1.stableHash)('],' + JSON.stringify(this.interactionLog) + ']', this.tapeHashes.at(-1) ?? (0, model_js_1.stableHash)('[['));
let hash = (0, model_js_1.stableHash)('],[', this.tapeHashes.at(-1) ?? (0, model_js_1.stableHash)('[['));
for (let i = 0; i < this.interactionLog.length; i++)
hash = (0, model_js_1.stableHash)((i ? ',' : '') + JSON.stringify(this.interactionLog[i]), hash);
return (0, model_js_1.stableHash)(']]', hash);
}
/** Hydrate a new world without stepping history. Validation finishes before
* the caller receives it, so a corrupt checkpoint never mutates a live pet. */
@@ -23,6 +23,11 @@ class PetLifecycleTest {
Thread.sleep(300)
assertEquals(paused.timeMs, model.ui.value.scene!!.timeMs, 0.0)
assertArrayEquals(paused.dots, model.ui.value.scene!!.dots, 0f)
compose.onNodeWithText("More").performClick()
compose.onNodeWithText("Reopen saved habitat").performClick()
compose.onNodeWithText("Reopen the saved habitat?").assertExists()
compose.onNodeWithText("Cancel").performClick()
assertEquals(paused.timeMs, model.ui.value.scene!!.timeMs, 0.0)
compose.onNodeWithText("Still off").performClick()
compose.onNodeWithText("Sound off").performClick()
compose.onNodeWithText("Resume").performClick()
@@ -70,10 +70,15 @@ private fun PetScreen(model: PetViewModel) {
val export = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { it?.let(model::exportRecording) }
val recovery = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { it?.let(model::exportRecovery) }
var restart by remember { mutableStateOf(false) }
var reload by remember { mutableStateOf(false) }
if (restart) AlertDialog(onDismissRequest = { restart = false }, title = { Text("Start a fresh habitat?") },
text = { Text("The current saved world will be kept as a recovery copy. You can export it from More.") },
text = { Text("The saved world will be kept as a recovery copy. Unsaved changes are discarded. Export the recording first to keep the current visit.") },
confirmButton = { TextButton(onClick = { restart = false; model.restart() }) { Text("Start fresh") } },
dismissButton = { TextButton(onClick = { restart = false }) { Text("Cancel") } })
if (reload) AlertDialog(onDismissRequest = { reload = false }, title = { Text("Reopen the saved habitat?") },
text = { Text("Unsaved changes will be discarded. Export the recording first to keep the current visit.") },
confirmButton = { TextButton(onClick = { reload = false; model.reload() }) { Text("Reopen saved") } },
dismissButton = { TextButton(onClick = { reload = false }) { Text("Cancel") } })
Column(Modifier.safeDrawingPadding().fillMaxSize()) {
Row(Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) {
Text("Codewhale", style = MaterialTheme.typography.headlineSmall, modifier = Modifier.weight(1f))
@@ -83,7 +88,7 @@ private fun PetScreen(model: PetViewModel) {
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
DropdownMenuItem(text = { Text("Import recording") }, onClick = { menu = false; import.launch(arrayOf("application/json", "text/plain", "application/octet-stream")) })
DropdownMenuItem(text = { Text("Export recording") }, enabled = ui.scene != null, onClick = { menu = false; export.launch("codewhale-pet.json") })
DropdownMenuItem(text = { Text("Reopen saved habitat") }, onClick = { menu = false; model.reload() })
DropdownMenuItem(text = { Text("Reopen saved habitat") }, onClick = { menu = false; reload = true })
DropdownMenuItem(text = { Text("Start fresh habitat") }, enabled = ui.scene != null, onClick = { menu = false; restart = true })
DropdownMenuItem(text = { Text("Export previous world") }, enabled = ui.canExportRecovery,
onClick = { menu = false; recovery.launch("codewhale-pet-recovery.json") })
+4 -1
View File
@@ -217,7 +217,10 @@ class PetWorld {
this.tapeHashes[i] = (0, model_js_1.stableHash)((i ? ',' : '') + JSON.stringify(this.tapeLog[i]), this.tapeHashes[i - 1] ?? (0, model_js_1.stableHash)('[['));
}
historyDigest() {
return (0, model_js_1.stableHash)('],' + JSON.stringify(this.interactionLog) + ']', this.tapeHashes.at(-1) ?? (0, model_js_1.stableHash)('[['));
let hash = (0, model_js_1.stableHash)('],[', this.tapeHashes.at(-1) ?? (0, model_js_1.stableHash)('[['));
for (let i = 0; i < this.interactionLog.length; i++)
hash = (0, model_js_1.stableHash)((i ? ',' : '') + JSON.stringify(this.interactionLog[i]), hash);
return (0, model_js_1.stableHash)(']]', hash);
}
/** Hydrate a new world without stepping history. Validation finishes before
* the caller receives it, so a corrupt checkpoint never mutates a live pet. */
+4 -1
View File
@@ -119,7 +119,10 @@ export class PetWorld {
this.tapeHashes[i] = stableHash((i ? ',' : '') + JSON.stringify(this.tapeLog[i]), this.tapeHashes[i - 1] ?? stableHash('[['));
}
private historyDigest(): number {
return stableHash('],' + JSON.stringify(this.interactionLog) + ']', this.tapeHashes.at(-1) ?? stableHash('[['));
let hash = stableHash('],[', this.tapeHashes.at(-1) ?? stableHash('[['));
for (let i = 0; i < this.interactionLog.length; i++)
hash = stableHash((i ? ',' : '') + JSON.stringify(this.interactionLog[i]), hash);
return stableHash(']]', hash);
}
/** Hydrate a new world without stepping history. Validation finishes before
+19 -13
View File
@@ -16,7 +16,7 @@ let points: [number, number][] = [], tape: readonly PetBucket[] = [], interactio
let expressionVersion: 1 | 2 = 2;
let world: PetWorld, seconds = 0, last = 0, accumulator = 0, paused = false;
let restoring = false, generation = 0, liveGeneration = 0, liveTimer = 0;
let imported: { tape: readonly PetBucket[]; interactions: PetInteraction[]; name: string; expressionVersion: 1 | 2 } | undefined;
let imported: { world: PetWorld; name: string } | undefined;
const library = new TraceLibrary();
let savedRevision: number | undefined, saving = false, persistenceReady = false, persistenceFailed = false;
let audio: AudioContext | undefined, anchor = 0, sound = false;
@@ -41,20 +41,25 @@ function play(voices: readonly PetVoice[]) {
}
async function rebuild(to = 0, checkpoint?: import('../core/pet-world.js').PetWorldCheckpoint) {
const ticket = ++generation;
silence(); const next = checkpoint ? PetWorld.restore(points, tape, interactions, checkpoint) : new PetWorld(points, tape, interactions, expressionVersion); restoring = true;
canvas.setAttribute('aria-busy', 'true');
for (const id of ['save', 'attention', 'feed']) get<HTMLButtonElement>(id).disabled = true;
const next = checkpoint ? PetWorld.restore(points, tape, interactions, checkpoint) : new PetWorld(points, tape, interactions, expressionVersion);
const ticks = Math.round(Math.max(0, Math.min(86_400, to)) * 30);
if (checkpoint && checkpoint.tick !== ticks) throw new Error('Saved pet clock does not match its checkpoint.');
silence(); restoring = true;
canvas.setAttribute('aria-busy', 'true');
for (const id of ['save', 'attention', 'feed']) get<HTMLButtonElement>(id).disabled = true;
for (let i = checkpoint?.tick ?? 0; i < ticks; i++) {
next.step(1 / 30, { motion: !motion.checked, sensitivity: 1 });
if (i > 0 && i % 600 === 0) { await new Promise(resolve => setTimeout(resolve, 0)); if (ticket !== generation) return; }
}
if (ticket !== generation) return;
adoptWorld(next);
}
function adoptWorld(next: PetWorld) {
world = next; expressionVersion = next.sim.expressionVersion; restoring = false;
if (mode.value === 'replay') imported = { world: next, name: imported?.name ?? source.textContent ?? 'Imported replay' };
canvas.setAttribute('aria-busy', 'false');
for (const id of ['save', 'attention', 'feed']) get<HTMLButtonElement>(id).disabled = false;
seconds = ticks / 30; accumulator = 0; last = 0;
seconds = next.frame.timeMs / 1000; accumulator = 0; last = 0;
if (audio) anchor = audio.currentTime - seconds + .08;
draw();
}
@@ -99,7 +104,6 @@ function draw() {
function interact(kind: PetInteraction['kind'], x = .2, y = -.15) {
if (restoring) return;
world.interact(kind, x, y); interactions = [...world.interactions];
if (mode.value === 'replay' && imported) imported.interactions = [...interactions];
}
canvas.addEventListener('pointerdown', event => { const r = canvas.getBoundingClientRect(); interact('attention', Math.max(-1, Math.min(1, (event.clientX - r.left) / r.width * 2 - 1)), Math.max(-1, Math.min(1, (event.clientY - r.top) / r.height * 2 - 1))); });
get('attention').onclick = () => interact('attention'); get('feed').onclick = () => interact('food');
@@ -113,8 +117,10 @@ motion.onchange = () => rebuild(seconds); seek.oninput = () => rebuild(Number(se
mode.onchange = () => {
stopFollowing();
if (mode.value === 'replay' && imported) {
expressionVersion = imported.expressionVersion; tape = imported.tape; interactions = [...imported.interactions]; source.textContent = imported.name;
seek.max = String(Math.max(90, tape.length * .4)); rebuild(); return;
tape = imported.world.tape; interactions = [...imported.world.interactions]; source.textContent = imported.name;
seek.max = String(Math.max(90, tape.length * .4, imported.world.frame.timeMs / 1000));
++generation; silence(); adoptWorld(imported.world);
message.textContent = 'Returned to the imported world at its current pose.'; return;
}
expressionVersion = 2;
tape = mode.value === 'demo' ? compilePetTelemetry(petDemoEvents(), 80_000) : [];
@@ -138,7 +144,6 @@ get<HTMLInputElement>('file').onchange = async event => {
nextTape = decodePetJSONL(r.tape.map(b => JSON.stringify(b)).join('\n')); nextInteractions = r.interactions;
if (r.checkpoint !== undefined) {
if ((r.checkpoint?.sim?.expressionVersion ?? 1) !== nextExpressionVersion) throw new Error('Pet expression version does not match its checkpoint.');
PetWorld.restore(points, nextTape, nextInteractions, r.checkpoint);
nextCheckpoint = r.checkpoint;
}
} else {
@@ -149,13 +154,14 @@ get<HTMLInputElement>('file').onchange = async event => {
nextTape = compilePetTelemetry(traces[0].events, traces[0].duration); }
}
// Validate before replacing the currently playing world.
new PetWorld(points, nextTape, nextInteractions, nextExpressionVersion);
const next = nextCheckpoint === undefined ? new PetWorld(points, nextTape, nextInteractions, nextExpressionVersion)
: PetWorld.restore(points, nextTape, nextInteractions, nextCheckpoint);
stopFollowing();
expressionVersion = nextExpressionVersion; tape = nextTape; interactions = nextInteractions; mode.value = 'replay'; seek.max = String(Math.max(90, tape.length * .4, (nextCheckpoint?.frame.timeMs ?? 0) / 1000));
imported = { tape, interactions: [...interactions], name: `Local replay · ${file.name}`, expressionVersion };
imported = { world: next, name: `Local replay · ${file.name}` };
mode.querySelector<HTMLOptionElement>('[value="replay"]')!.disabled = false;
source.textContent = `Local replay · ${file.name}`; message.textContent = 'Recording loaded locally. The saved world includes interactions, its current pose and audio onsets.';
await rebuild((nextCheckpoint?.frame.timeMs ?? 0) / 1000, nextCheckpoint);
++generation; silence(); adoptWorld(next);
} catch (error) { message.textContent = error instanceof Error ? error.message : 'Unable to import this file.'; }
};
get('save').onclick = () => {
@@ -234,7 +240,7 @@ try {
source.textContent = h.sourceName;
seek.max = h.source === 'demo' ? '80' : String(Math.max(h.source === 'replay' ? 90 : 120, h.seconds, tape.length * .4));
if (h.source === 'replay') {
imported = { tape, interactions, name: h.sourceName, expressionVersion }; mode.querySelector<HTMLOptionElement>('[value="replay"]')!.disabled = false;
mode.querySelector<HTMLOptionElement>('[value="replay"]')!.disabled = false;
}
message.textContent = 'Restoring the saved habitat…';
await rebuild(h.seconds, h.still === motion.checked ? h.checkpoint : undefined);