// 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; setCell(192 * W + 16, WOOD, AMB[192], F_BURN); life[192 * W + 16] = FUEL[WOOD]; temp[210 * W + 16] = AMB[210] + 200; temp[210 * W + 512] = AMB[210] + 200; `); rect(run, 48, 160, 49, 199, run('STONE')); rect(run, 78, 160, 79, 199, run('STONE')); rect(run, 50, 170, 60, 179, run('WATER')); run('globalThis.loadSave = serializeWorld(); deserializeWorld(loadSave.meta, loadSave.buffers);'); check('nearby areas wake immediately, with heat', run('awake[6 * CW + 16] > 0 && hot[6 * CW + 16] > 0')); check('distant areas wait, including their heat', run('awake[6 * CW] === 0 && hot[6 * CW] === 0')); ticks(run, 1); check('nearby areas are awake on the first tick while far areas wait', run('awake[6 * CW + 16] > 0 && awake[6 * CW] === 0')); check('the order contains every area once and goes outward from the saved bot', run(`(() => { const visited = new Uint8Array(NCH); let previous = -1; for (const c of loadWakeOrder) { const d = (c % CW * 32 + 16 - (bot.x + bot.w / 2)) ** 2 + (((c / CW) | 0) * 32 + 16 - (bot.y + bot.h / 2)) ** 2; if (visited[c] || d < previous) return false; visited[c] = 1; previous = d; } return visited.every(v => v === 1); })()`)); check('waiting leaves distant fire and water unchanged', run('life[192 * W + 16] === FUEL[WOOD] && mat[170 * W + 50] === WATER')); ticks(run, 120); check('all areas of a small world have had their turn', run('loadWakeOrder === null && loadWakePending === null')); check('distant burning wood resumes burning after its turn', run('life[192 * W + 16] < FUEL[WOOD] || mat[192 * W + 16] !== WOOD')); check('distant stored heat resumes flowing', run('temp[210 * W + 16] < AMB[210] + 190')); ticks(run, 600); check('distant water settles without losing grains', count(run, run('WATER')) === 110 && count(run, run('WATER'), 50, 196, 77, 199) === 110); check('the settled water area goes back to sleep', run('awake[6 * CW + 1] === 0 && awake[6 * CW + 2] === 0')); // Moving to a waiting area must apply its saved heat hint, even at ambient with F_BURN. run('deserializeWorld(loadSave.meta, loadSave.buffers); bot.x = 14; bot.y = 190;'); check('the destination is still waiting before moving wakes it', run('loadWakePending[6 * CW] === 2 && hot[6 * CW] === 0')); ticks(run, 8); check('moving near a waiting area wakes it within eight ticks, including heat', run('loadWakePending[6 * CW] === 0 && hot[6 * CW] > 0')); // Burning is probabilistic: waking does not promise a fuel draw on that exact tick. ticks(run, 32); check('newly woken fuel resumes burning', run('life[192 * W + 16] < FUEL[WOOD]')); // Saving midway keeps the actual cells, and loading starts a fresh queue at the new bot. run('globalThis.midLoadSave = serializeWorld(); deserializeWorld(midLoadSave.meta, midLoadSave.buffers);'); check('saving midway preserves all cell buffers', run(`(() => { const saved = serializeWorld(); return ['mat', 'temp', 'life', 'flags'].every(key => saved.buffers[key].every((v, i) => v === midLoadSave.buffers[key][i])); })()`)); check('loading midway starts a new queue around the current bot', run('loadWakeIndex === 0 && loadWakePending[6 * CW] === 0 && loadWakePending[6 * CW + 16] > 0')); run('alloc(64, 64);'); check('alloc clears a previous load queue', run('loadWakeOrder === null && loadWakePending === null && loadWakeIndex === 0')); run('deserializeWorld(loadSave.meta, loadSave.buffers); generateWorld(77, 256, 256, null);'); check('generating a new world clears the load queue', run('loadWakeOrder === null && loadWakePending === null && loadWakeIndex === 0')); // The Huge-world budget is checked with real ticks on quiet terrain. flatWorld(run, 6144, 2048, 1900); run('startLoadWake();'); check('a Huge world has 12288 scheduled areas', run('loadWakeOrder.length === 12288')); ticks(run, 1023); check('the final areas are still queued at tick 1023', run('loadWakeOrder !== null && loadWakePending.some(v => v > 0)')); ticks(run, 1); check('every Huge-world area has had its turn by tick 1024 (17.07 seconds)', run('loadWakeOrder === null && loadWakePending === null && loadWakeIndex === 12288')); done();