diff --git a/docs/agent-log.md b/docs/agent-log.md index 5171a81..7fb4711 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -4,6 +4,18 @@ 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: t80 validate imported world arrays (Codex) + +- Validate positive integer dimensions up to the Huge option (6144 x 2048), cell/block/fog + buffers, columns and slope phases before alloc. Clean unknown materials and block codes + in existing scans using NMAT and PIECE_BY_CODE. Smaller worldgen/test worlds still load. +- Files: src/sim/save.js, test/t_load.js, test/t_fog.js, tasks/t80_imported_world_files.md + and this log. Regression failed first (40 checks); all 37 headless suites passed via + python3 test/run_all.py (/tmp/t80-headless.log; load: 60 checks). Fog's short-buffer test + now requires rejection, matching this task; missing fog still works. +- Browser/GPU tests skipped as requested; owner import/playtesting remains open. + World generation and simulation tick behaviour unchanged. No commit. + ## 2026-10-29: t79 preserve progress before world import (Codex) - importWorldFile uses GAME.running to await saveWorld('auto', true) before reading diff --git a/src/sim/save.js b/src/sim/save.js index 486e44e..bf71dff 100644 --- a/src/sim/save.js +++ b/src/sim/save.js @@ -32,7 +32,24 @@ function serializeWorld() { } function deserializeWorld(meta, buffers) { if (meta.v !== SAVE_VERSION) throw new Error('This world was saved by an older version of the game and cannot be loaded.'); - alloc(meta.W, meta.H); + // worldgen accepts smaller test worlds; the largest nw-size option is 6144 x 2048. + const w = meta.W, h = meta.H; + if (!Number.isInteger(w) || !Number.isInteger(h) || w <= 0 || h <= 0 || w > 6144 || h > 2048) + throw new Error('Invalid world size: expected positive whole dimensions up to 6144 x 2048.'); + const n = w * h, nb = Math.ceil(w / BS) * Math.ceil(h / BS); + for (const key of ['mat', 'temp', 'life', 'flags', 'part', 'blockP', 'blockV', 'bgm', 'fog']) { + if ((key === 'bgm' || key === 'fog') && !Object.hasOwn(buffers, key)) continue; + const expected = key === 'blockP' || key === 'blockV' ? nb : key === 'fog' ? Math.ceil(w / FOG) * Math.ceil(h / FOG) : n; + if (!buffers[key] || buffers[key].length !== expected) + throw new Error('Invalid saved buffer: ' + key + ' must contain ' + expected + ' values.'); + } + for (const key of ['surfY', 'dirtY', 'graniteY', 'basaltY', 'magmaY']) { + if (!meta.cols?.[key] || meta.cols[key].length !== w) + throw new Error('Invalid saved column: ' + key + ' must contain ' + w + ' values.'); + } + if (meta.slopePhases && meta.slopePhases.length !== nb) + throw new Error('Invalid saved buffer: slopePhases must contain ' + nb + ' values.'); + alloc(w, h); devWorld = meta.dev === true; devLog = devWorld && Array.isArray(meta.devLog) ? meta.devLog.filter(e => Array.isArray(e) && Number.isSafeInteger(e[0]) && e[0] >= 0 && typeof e[1] === 'string').slice(-200).map(e => [e[0], e[1]]) : []; worldSeed = meta.seed; @@ -41,9 +58,16 @@ function deserializeWorld(meta, buffers) { surfY.set(meta.cols.surfY); dirtY.set(meta.cols.dirtY); graniteY.set(meta.cols.graniteY); basaltY.set(meta.cols.basaltY); magmaY.set(meta.cols.magmaY); computeAmbient(); fillNoise(); mat.set(buffers.mat); life.set(buffers.life); flags.set(buffers.flags); - for (let i = 0; i < N; i++) if (mat[i] === SPRING) mat[i] = STONE; // springs are gone: older worlds keep plain rock there - blockP.set(buffers.blockP); blockV.set(buffers.blockV); filtersDirty = true; rebuildProcessors(); - for (let b = 0; b < NB; b++) if (blockP[b] === BP_RACK) rackState.set(b, newRack()); + for (let i = 0; i < N; i++) { + if (mat[i] >= NMAT) mat[i] = EMPTY; + else if (mat[i] === SPRING) mat[i] = STONE; // springs are gone: older worlds keep plain rock there + } + blockP.set(buffers.blockP); blockV.set(buffers.blockV); filtersDirty = true; + for (let b = 0; b < NB; b++) { + if (!Object.hasOwn(PIECE_BY_CODE, blockP[b])) blockP[b] = 0; + if (blockP[b] === BP_RACK) rackState.set(b, newRack()); + } + rebuildProcessors(); for (const entry of Array.isArray(meta.racks) ? meta.racks : []) { if (!Array.isArray(entry)) continue; const [b, q, open] = entry; diff --git a/test/t_fog.js b/test/t_fog.js index 7f255d5..128e754 100644 --- a/test/t_fog.js +++ b/test/t_fog.js @@ -20,7 +20,13 @@ check('save/load keeps fog', run('(()=>{fogLook(bot.x+bot.w/2,bot.y+bot.h/2);con rect(run, 200, 160, 203, 163, run('EMPTY')); run('placeBlock(BP_WALL, RSTONE, 50, 40, true)'); check('old save rebuilds exploration', run('(()=>{const s=serializeWorld();delete s.buffers.fog;deserializeWorld(s.meta,s.buffers);return fogSeen(62,104)>=200 && fogSeen(202,162)===255 && fogSeen(20,220)===0})()')); -check('wrong length fog rebuilds exploration', run('(()=>{const s=serializeWorld();s.buffers.fog=new Uint8Array(1);deserializeWorld(s.meta,s.buffers);return fogSeen(202,162)===255})()')); +check('wrong length fog is refused without changing exploration', run(`(() => { + const s = serializeWorld(), originalFog = fog, originalMat = mat; + s.buffers.fog = new Uint8Array(1); + try { deserializeWorld(s.meta, s.buffers); } + catch (e) { return /fog/i.test(e.message) && fog === originalFog && mat === originalMat && fogSeen(202,162) === 255; } + return false; +})()`)); const hash = () => run('mat.reduce((h,v)=>Math.imul(h^v,16777619)>>>0,2166136261)'); run('generateWorld(42, 256, 256, null)'); ticks(run, 300); const h = hash(); run('generateWorld(42, 256, 256, null); fogLook = function () {}'); ticks(run, 300); diff --git a/test/t_load.js b/test/t_load.js index fb69008..3cac6a4 100644 --- a/test/t_load.js +++ b/test/t_load.js @@ -1,8 +1,56 @@ // Reload wakes nearby areas first, without losing distant water, fire or saved state. +const fs = require('fs'); +const path = require('path'); const { load, suite, flatWorld, rect, count, ticks } = require('./harness'); const { run } = load(); const { check, done } = suite(); +// Exercise the real hover function headlessly, without starting a browser or GPU. +run("const localStorage = { getItem: () => '{}', setItem() {} };"); +for (const f of ['ui/state.js', 'ui/hud.js', 'ui/cursor.js', 'ui/info.js']) + run(fs.readFileSync(path.join(__dirname, '../src', f), 'utf8')); +flatWorld(run, 256, 256, 200); +run('globalThis.validationSave = serializeWorld();'); +function refusedBeforeAlloc(label, doctor, message) { + check(label, run(`(() => { + const s = { meta: { ...validationSave.meta, cols: { ...validationSave.meta.cols } }, buffers: { ...validationSave.buffers } }; + ${doctor} + const originalAlloc = alloc, originalMat = mat, originalBlocks = blockP; + const oldW = W, oldH = H, oldMoney = money, oldX = bot.x; + let allocations = 0, error; + alloc = () => { allocations++; throw new Error('Allocation reached'); }; + try { deserializeWorld(s.meta, s.buffers); } catch (e) { error = e; } + finally { alloc = originalAlloc; } + return error instanceof Error && ${message}.test(error.message) && allocations === 0 + && W === oldW && H === oldH && mat === originalMat && blockP === originalBlocks + && money === oldMoney && bot.x === oldX; + })()`)); +} +for (const [w, h] of [[1000000, 1000000], [6145, 1024], [2048, 2049], [0, 1024], [2048, -1], [2048.5, 1024], [2048, NaN]]) + refusedBeforeAlloc(`invalid size ${w} x ${h} is refused before touching the world`, `s.meta.W = ${w}; s.meta.H = ${h};`, '/world size/i'); +for (const key of ['mat', 'temp', 'life', 'flags', 'part', 'blockP', 'blockV', 'bgm', 'fog']) { + refusedBeforeAlloc(`short ${key} buffer is refused before touching the world`, `s.buffers.${key} = s.buffers.${key}.subarray(1);`, `/${key}/i`); + refusedBeforeAlloc(`long ${key} buffer is refused before touching the world`, `s.buffers.${key} = new s.buffers.${key}.constructor(s.buffers.${key}.length + 1);`, `/${key}/i`); +} +for (const key of ['mat', 'temp', 'life', 'flags', 'part', 'blockP', 'blockV']) + refusedBeforeAlloc(`missing ${key} buffer is refused before touching the world`, `delete s.buffers.${key};`, `/${key}/i`); +for (const key of ['surfY', 'dirtY', 'graniteY', 'basaltY', 'magmaY']) + refusedBeforeAlloc(`short ${key} column is refused before touching the world`, `s.meta.cols.${key} = [];`, `/${key}/i`); +refusedBeforeAlloc('short slope phases are refused before touching the world', 's.meta.slopePhases = [0];', '/slopePhases/i'); +for (const [label, doctor, clean] of [ + ['unknown cell material', 's.buffers.mat[20 * W + 20] = 250;', 'mat[20 * W + 20] === EMPTY'], + ['unknown block code', 's.buffers.blockP[5 * BW + 5] = 250;', 'blockP[5 * BW + 5] === 0'], +]) { + check(label + ' loads cleaned and permits hover and removal', run(`(() => { + const s = serializeWorld(); ${doctor} + try { + deserializeWorld(s.meta, s.buffers); + SET.fog = false; player.lens = false; + return ${clean} && hoverInfo(20, 20).air && removePieceAt(20, 20) === ''; + } catch (e) { return false; } + })()`)); +} + flatWorld(run, 1024, 256, 200); run(` bot.x = 510; bot.y = 170;