diff --git a/docs/DESIGN.md b/docs/DESIGN.md index eba5679..8dec1b6 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -358,6 +358,12 @@ above, including: still sleep. Through burning fuel sinking stays at the old slow pace: furnace beds are tuned to it, and dirt heaped on a burning pile must smother it into charcoal rather than sink through. +- **A loaded world starts around the player first (t21, 2026-10-25).** Loading used to start every + area of the world at once, and a Huge world then took 15 ms a tick for its first 10 seconds, + with ticks up to 80 ms. Now the areas within 150 grains of the bot start at once, the rest 12 a + tick, nearest first (a Huge world is fully started after 17 s), and flying toward areas still + waiting starts them. Nothing is skipped: the same settling happens, spread out. + ## Ideas not built yet - **Concrete**, as a stronger wall material from rubble. diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 0f9127f..92a4457 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -220,10 +220,10 @@ Starter furnace designs and the milestone list shown in the notebook. - functions: smelterBlueprint - variables: STARTER_DESIGNS, MILESTONES -### src/sim/world.js (102 lines) +### src/sim/world.js (104 lines) World state: the per-cell typed arrays, alloc(), ambient temperature by depth, waking. - functions: emit, bagCap, discover, alloc, depthU, depthY, meanSurface, computeAmbient, fillNoise, wakeXY, wakeHeatXY, wakeIdx, wakeHeatIdx -- variables: W, H, N, CW, CH, NCH, mat, temp, life, flags, stamp, part, mach, digp, noiseB, airS, netId, isPow, occ, surfY, dirtY, graniteY, basaltY, magmaY, roofY, AMB, worldSeed, surfBase, underD, shallowD, lapse, BW, BH, NB, blockP, blockV, ents, bgm, flyers, ghostBlocks, ghostMolds, awake, hot, hotLvl, powderArea, roofDirty, tick, tickStamp, machines, molds, nets, pcells, powerDirty, beltL, beltR, tubeLists, brokenMachines, ghostMachines, ghostBlocked, EV, bot, bag, bagTotal, player, seen, ledger, stats +- variables: W, H, N, CW, CH, NCH, mat, temp, life, flags, stamp, part, mach, digp, noiseB, airS, netId, isPow, occ, surfY, dirtY, graniteY, basaltY, magmaY, roofY, AMB, worldSeed, surfBase, underD, shallowD, lapse, BW, BH, NB, blockP, blockV, ents, bgm, flyers, ghostBlocks, ghostMolds, awake, hot, hotLvl, powderArea, roofDirty, loadWakeOrder, loadWakePending, loadWakeIndex, tick, tickStamp, machines, molds, nets, pcells, powerDirty, beltL, beltR, tubeLists, brokenMachines, ghostMachines, ghostBlocked, EV, bot, bag, bagTotal, player, seen, ledger, stats ### src/sim/fog.js (52 lines) Exploration only: never changes grains or the random stream. @@ -329,7 +329,7 @@ Weather: clouds from steam, rain and snow, and the world water count. - functions: wrnd, resetWeather, rainGap, worldWater, absorbSteam, spawnRain, rainAt, weatherTick - variables: WBAND, WS, weather -### src/sim/tick.js (27 lines) +### src/sim/tick.js (28 lines) simTick: one simulation step, in order. - functions: simTick @@ -342,9 +342,9 @@ World generation helpers: noise and removal of tiny hard rock specks. World generation: staged terrain, ore, caves and placement, with synchronous and async entry points. - functions: generateWorld, generateWorldAsync -### src/sim/save.js (141 lines) +### src/sim/save.js (177 lines) Saving and loading a world (serializeWorld / deserializeWorld) and old-save migration. -- functions: serializeWorld, deserializeWorld +- functions: serializeWorld, deserializeWorld, startLoadWake, wakeLoadArea, wakeLoadNearBot, loadWakeTick - variables: SAVE_VERSION, TOFF ### src/render/setup.js (200 lines) @@ -509,6 +509,7 @@ The frame loop and boot. - `test/t_econ.js`: Economy: the dock (instant sale), orders, prices, the market dip, money for machines, alloys. - `test/t_fog.js`: Fog: surface, sight, range, saves and simulation independence. - `test/t_furnace_repeat.js`: The starter furnaces keep working when fired again and again without cleaning, however the charge goes in: mixed, ore first with coal on top, or blown in through the vent. Each case plays furnace_play.js, which builds the design and fires it like a player. +- `test/t_load.js`: Reload wakes nearby areas first, without losing distant water, fire or saved state. - `test/t_molds.js`: Molds and furnaces stay usable: ingots drop below and keep their stubs, plugs over molds are cleared, spoiled castings can be knocked out, grains never drop through a grate by teleporting, and slag lets the fire breathe. - `test/t_physics.js`: Sand and other powders, molten metal and gases: heaps, sorting, sinking, setting, rising and fading. Water has its own suite (t_water.js). Tall worlds keep geothermal heat out of the way. - `test/t_pipes.js`: Pipes, pumps and outlets move liquids; the Grinder grinds waste rock for trace ore. diff --git a/docs/agent-log.md b/docs/agent-log.md index bce35e7..4b33fcf 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-25: t21: a loaded world starts around the player first (Claude Code, Codex) + +- Owner: "lets speed up the first load speed. load in areas around the player first". +- `src/sim/save.js`: `deserializeWorld` no longer wakes every area; `startLoadWake()` keeps the + load-time heat scan's result per area, wakes those within 150 grains of the bot, and queues the + rest by distance; `loadWakeTick()` (from `simTick`) wakes 12 a tick and, every 8 ticks, any still + waiting within 150 grains of the bot. The queue is not saved. +- Huge world, ms/tick before -> after: ticks 0-600 average 15.2 -> 5.5, max 79.7 -> 16.2, 95th + percentile 21.0 -> 11.0; ticks 600-1800 average 7.3 -> 10.0 (the same work, later). Every area + has started by tick 1024 (17 s). +- `test/t_load.js` (19 checks). `run_all.py --browser` ALL PASSED. + ## 2026-10-24: t21 phase 4: duplicates merged, largest files split (Claude Code, Codex) - Merged seven real duplicates: wall refunds (`removeWallCell`, walls and molds), tube edges and diff --git a/src/sim/save.js b/src/sim/save.js index 716ff90..67182bf 100644 --- a/src/sim/save.js +++ b/src/sim/save.js @@ -117,13 +117,6 @@ function deserializeWorld(meta, buffers) { seen = new Uint8Array(NMAT); meta.seen.forEach((v, k) => { if (k < NMAT) seen[k] = v; }); ledger = meta.ledger || {}; Object.assign(stats, meta.stats || {}); - awake.fill(3); - for (let c = 0; c < NCH; c++) { - const cx = c % CW, cy = (c / CW) | 0, x0 = cx << 5, y0 = cy << 5, x1 = Math.min(W, x0 + 32), y1 = Math.min(H, y0 + 32); - let dev = 0; - for (let y = y0; y < y1 && dev < 2; y++) for (let x = x0; x < x1; x++) { const d = Math.abs(temp[y * W + x] - AMB[y]); if (d > dev) dev = d; if (flags[y * W + x] & F_BURN) dev = 99; } - if (dev >= 2) { hot[c] = 6; hotLvl[c] = 2; } - } powerDirty = true; rebuildPower(); updateRoof(); if (!weather.base) weather.base = worldWater(); // older saves: take the water it has now as its starting amount if (!meta.specks) clearHardSpecks(true); // older saves: tidy away tiny specks of too-hard rock once @@ -137,5 +130,48 @@ function deserializeWorld(meta, buffers) { for (const mo of molds) for (const i of moldCellsAt(mo.kind, mo.x, mo.y).walls) fogRevealAround(i % W, (i / W) | 0, 12); } fogLook(bot.x + bot.w / 2, bot.y + bot.h / 2); + startLoadWake(); EV.length = 0; } + +// Keep the load-time heat scan, but start distant areas in order instead of all at once. +function startLoadWake() { + awake.fill(0); hot.fill(0); hotLvl.fill(0); + loadWakePending = new Uint8Array(NCH).fill(1); + for (let c = 0; c < NCH; c++) { + const cx = c % CW, cy = (c / CW) | 0, x0 = cx << 5, y0 = cy << 5, x1 = Math.min(W, x0 + 32), y1 = Math.min(H, y0 + 32); + let dev = 0; + for (let y = y0; y < y1 && dev < 2; y++) for (let x = x0; x < x1; x++) { const d = Math.abs(temp[y * W + x] - AMB[y]); if (d > dev) dev = d; if (flags[y * W + x] & F_BURN) dev = 99; } + if (dev >= 2) loadWakePending[c] = 2; + } + const x = bot.x + bot.w / 2, y = bot.y + bot.h / 2; + const distance = c => (c % CW * 32 + 16 - x) ** 2 + (((c / CW) | 0) * 32 + 16 - y) ** 2; + loadWakeOrder = Uint32Array.from({ length: NCH }, (_, c) => c); + loadWakeOrder.sort((a, b) => distance(a) - distance(b)); + loadWakeIndex = 0; + wakeLoadNearBot(); +} +function wakeLoadArea(c) { + const pending = loadWakePending[c]; + if (!pending) return; + loadWakePending[c] = 0; + awake[c] = 3; + if (pending === 2) { hot[c] = 6; hotLvl[c] = 2; } +} +function wakeLoadNearBot() { + const x = bot.x + bot.w / 2, y = bot.y + bot.h / 2; + const x0 = Math.max(0, Math.floor((x - 150) / 32)), x1 = Math.min(CW - 1, Math.floor((x + 150) / 32)); + const y0 = Math.max(0, Math.floor((y - 150) / 32)), y1 = Math.min(CH - 1, Math.floor((y + 150) / 32)); + for (let cy = y0; cy <= y1; cy++) for (let cx = x0; cx <= x1; cx++) { + const dx = Math.max(cx * 32 - x, 0, x - (cx * 32 + 31)); + const dy = Math.max(cy * 32 - y, 0, y - (cy * 32 + 31)); + if (dx * dx + dy * dy <= 150 * 150) wakeLoadArea(cy * CW + cx); + } +} +function loadWakeTick() { + if (tick % 8 === 0) wakeLoadNearBot(); + // Twelve per tick finishes a Huge world in 1024 ticks, and a Small one in 171. + const end = Math.min(loadWakeIndex + 12, loadWakeOrder.length); + while (loadWakeIndex < end) wakeLoadArea(loadWakeOrder[loadWakeIndex++]); + if (loadWakeIndex === loadWakeOrder.length) loadWakeOrder = loadWakePending = null; +} diff --git a/src/sim/tick.js b/src/sim/tick.js index 755ded5..8cbe628 100644 --- a/src/sim/tick.js +++ b/src/sim/tick.js @@ -3,6 +3,7 @@ function simTick() { tick++; tickStamp = (tickStamp % 255) + 1; + if (loadWakeOrder) loadWakeTick(); if (tick % 20 === 1) updateDirtyRoof(); weatherTick(); machinesTick(); diff --git a/src/sim/world.js b/src/sim/world.js index 1456686..f0cee86 100644 --- a/src/sim/world.js +++ b/src/sim/world.js @@ -14,6 +14,7 @@ let bgm; // background layer: trees. Liquids, powders and gases pass in front of let flyers = []; // grains in flight, blown out of the bot let ghostBlocks = new Map(), ghostMolds = []; let awake, hot, hotLvl, powderArea, roofDirty; +let loadWakeOrder = null, loadWakePending = null, loadWakeIndex = 0; let tick = 0, tickStamp = 1; let machines = [null], molds = [], nets = [null], pcells = new Set(), powerDirty = true; let beltL = [], beltR = [], tubeLists = [[], [], [], [], []]; @@ -49,6 +50,7 @@ function alloc(w, h) { surfBase = Math.round(Math.min(h * 0.27, 175)); underD = h - surfBase; shallowD = 0; lapse = 0.36; surfY = new Int16Array(w); roofY = new Int16Array(w); dirtY = new Int16Array(w); graniteY = new Int16Array(w); basaltY = new Int16Array(w); magmaY = new Int16Array(w); AMB = new Float32Array(h); + loadWakeOrder = loadWakePending = null; loadWakeIndex = 0; awake = new Uint8Array(NCH); hot = new Uint8Array(NCH); hotLvl = new Uint8Array(NCH); roofDirty = new Uint8Array(CW).fill(1); powderArea = new Uint8Array(NCH).fill(1); // unknown areas get one visit before being ruled out diff --git a/test/t_load.js b/test/t_load.js new file mode 100644 index 0000000..6308373 --- /dev/null +++ b/test/t_load.js @@ -0,0 +1,67 @@ +// Reload wakes nearby areas first, without losing distant water, fire or saved state. +const { load, suite, flatWorld, rect, count, ticks } = require('./harness'); +const { run } = load(); +const { check, done } = suite(); + +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 && 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();