diff --git a/docs/agent-log.md b/docs/agent-log.md index bf2ff66..5171a81 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -4,6 +4,16 @@ What agents have done, newest first. Add an entry for every piece of work: the d note or request it was for, what changed, the files touched, the tests run, and anything left open or not verified. Keep each entry short; the git history has the details. +## 2026-10-29: t79 preserve progress before world import (Codex) + +- importWorldFile uses GAME.running to await saveWorld('auto', true) before reading + the file or starting loading. Save failure cancels import with a toast. +- Files: src/ui/saves.js, test/t_import_save.js, tasks/t79_importing_world_mid.md + and this log. Regression failed first; seven focused checks, build.py and all + headless suites passed (python3 test/run_all.py; /tmp/t79-headless.log). +- Browser suite and screenshots not run as requested; owner playtesting remains + open. No simulation/worldgen changes; no commit. + ## 2026-10-29: t78 damaged blueprint code validation (Codex) - bpValid checks block codes, non-fixed machine types and mold kinds against their diff --git a/src/ui/saves.js b/src/ui/saves.js index 6e90901..821a2fb 100644 --- a/src/ui/saves.js +++ b/src/ui/saves.js @@ -184,6 +184,10 @@ async function exportWorld() { catch (e) { if (e && e.code !== 'declined') toast('The world file was not saved.'); } } async function importWorldFile(file) { + if (GAME.running && !await saveWorld('auto', true)) { + toast('The current world could not be saved. Import cancelled.'); + return; + } const progress = (f, s) => showLoading(true, f, s, 'Loading world'); progress(0.05, 'Reading the save'); try { diff --git a/test/t_import_save.js b/test/t_import_save.js new file mode 100644 index 0000000..caea677 --- /dev/null +++ b/test/t_import_save.js @@ -0,0 +1,83 @@ +// Import must preserve the running world before reading or replacing it. +const fs = require('fs'); +const path = require('path'); +const { load, suite, flatWorld } = require('./harness'); +const { run } = load(), { check, done } = suite(); +run(fs.readFileSync(path.join(__dirname, '../src/ui/saves.js'), 'utf8')); +run(` + globalThis.GAME = { running: true, paused: true, worldId: 'current', worldName: 'Current', playTime: 42 }; + globalThis.messages = []; + globalThis.toast = text => messages.push(text); + globalThis.$ = () => ({}); + globalThis.showLoading = () => {}; + globalThis.stored = new Map(); + globalThis.failStorage = false; + globalThis.releaseWrite = null; + DB.get = async key => stored.get(key); + DB.op = async (mode, fn) => { + if (failStorage) throw new Error('Storage full'); + await new Promise(resolve => { releaseWrite = resolve; }); + fn({ put: (value, key) => stored.set(key, value) }); + }; + globalThis.loads = 0; + globalThis.startFromSave = async sv => { + loads++; + deserializeWorld(sv.meta, sv.buffers); + GAME.worldId = sv.worldId; GAME.worldName = sv.worldName; + return true; + }; +`); + +async function main() { + flatWorld(run, 64, 64, 48); + const incoming = run('serializeWorld()'); + const buffers = {}; + for (const [key, arr] of Object.entries(incoming.buffers)) { + buffers[key] = { type: arr.constructor.name, gz: false, + b64: Buffer.from(arr.buffer, arr.byteOffset, arr.byteLength).toString('base64') }; + } + const text = JSON.stringify({ format: 'deepgrain-world', meta: incoming.meta, buffers }); + run('money = 12345; setCell(10 * W + 10, CUORE, AMB[10], 0);'); + let reads = 0, savedBeforeRead = false; + // Release each storage transaction on the next turn, after observing whether + // the importer tried to read the file while the first save was still pending. + const pending = run('importWorldFile')({ text: async () => { + reads++; + savedBeforeRead = run("stored.has('current:auto')"); + return text; + } }); + await new Promise(resolve => setImmediate(resolve)); + check('file waits for the running world save to commit', reads === 0); + let settled = false; + pending.finally(() => { settled = true; }); + const deadline = Date.now() + 10000; + while (!settled && Date.now() < deadline) { + if (run('releaseWrite !== null')) run('releaseWrite(); releaseWrite = null;'); + await new Promise(resolve => setTimeout(resolve, 1)); + } + if (!settled) throw new Error('Import timed out'); + await pending; + check('current world is saved before reading the import', savedBeforeRead); + check('autosave contains latest money and grains', run(`(() => { + const sv = stored.get('current:auto'); + return !!sv && sv.meta.economy.money === 12345; + })()`) && (await run("worldNewest('current')"))?.buffers.mat[650] === run('CUORE')); + check('good import loads and saves under its own id', run(`loads === 1 && GAME.worldId !== 'current' + && stored.has(GAME.worldId + ':auto') && messages.includes('World imported.')`)); + + run('failStorage = true; messages.length = 0; loads = 0;'); + const before = run('mat'); + reads = 0; + await run('importWorldFile')({ text: async () => { reads++; return text; } }); + check('failed save stops before reading or replacing the world', reads === 0 && run('loads') === 0 && before === run('mat')); + check('failed save explains that the current world could not be saved', + run("messages.some(s => /current world/i.test(s) && /could not be saved/i.test(s))")); + + // Title-screen import must not attempt to save a nonexistent running world. + run('GAME.running = false; messages.length = 0; loads = 0;'); + reads = 0; + await run('importWorldFile')({ text: async () => { reads++; return text; } }); + check('title-screen import still reads and loads without a pre-save', reads === 1 && run('loads') === 1); + done(); +} +main().catch(e => { console.error(e); process.exitCode = 1; });