Check imported world size, buffers, materials and block codes (t80)
This commit is contained in:
@@ -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
|
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.
|
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)
|
## 2026-10-29: t79 preserve progress before world import (Codex)
|
||||||
|
|
||||||
- importWorldFile uses GAME.running to await saveWorld('auto', true) before reading
|
- importWorldFile uses GAME.running to await saveWorld('auto', true) before reading
|
||||||
|
|||||||
+28
-4
@@ -32,7 +32,24 @@ function serializeWorld() {
|
|||||||
}
|
}
|
||||||
function deserializeWorld(meta, buffers) {
|
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.');
|
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;
|
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]]) : [];
|
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;
|
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);
|
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();
|
computeAmbient(); fillNoise();
|
||||||
mat.set(buffers.mat); life.set(buffers.life); flags.set(buffers.flags);
|
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
|
for (let i = 0; i < N; i++) {
|
||||||
blockP.set(buffers.blockP); blockV.set(buffers.blockV); filtersDirty = true; rebuildProcessors();
|
if (mat[i] >= NMAT) mat[i] = EMPTY;
|
||||||
for (let b = 0; b < NB; b++) if (blockP[b] === BP_RACK) rackState.set(b, newRack());
|
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 : []) {
|
for (const entry of Array.isArray(meta.racks) ? meta.racks : []) {
|
||||||
if (!Array.isArray(entry)) continue;
|
if (!Array.isArray(entry)) continue;
|
||||||
const [b, q, open] = entry;
|
const [b, q, open] = entry;
|
||||||
|
|||||||
+7
-1
@@ -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'));
|
rect(run, 200, 160, 203, 163, run('EMPTY'));
|
||||||
run('placeBlock(BP_WALL, RSTONE, 50, 40, true)');
|
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('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)');
|
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)'); ticks(run, 300); const h = hash();
|
||||||
run('generateWorld(42, 256, 256, null); fogLook = function () {}'); ticks(run, 300);
|
run('generateWorld(42, 256, 256, null); fogLook = function () {}'); ticks(run, 300);
|
||||||
|
|||||||
@@ -1,8 +1,56 @@
|
|||||||
// Reload wakes nearby areas first, without losing distant water, fire or saved state.
|
// 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 { load, suite, flatWorld, rect, count, ticks } = require('./harness');
|
||||||
const { run } = load();
|
const { run } = load();
|
||||||
const { check, done } = suite();
|
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);
|
flatWorld(run, 1024, 256, 200);
|
||||||
run(`
|
run(`
|
||||||
bot.x = 510; bot.y = 170;
|
bot.x = 510; bot.y = 170;
|
||||||
|
|||||||
Reference in New Issue
Block a user