diff --git a/docs/DESIGN.md b/docs/DESIGN.md index ff3d427..023e4d5 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -252,6 +252,13 @@ above, including: career and come along; buildings stay on their claim and wait (they don't run) until you travel back from the notebook's Claims page. +- **Long waits show what's happening (t30, 2026-10-24).** Generating, loading, opening a claim + and travelling show the loading overlay with a title, a moving bar and the current step + ("Laying rock and ore", "Unpacking", "Building the world"...). Generation runs as steps that + give the browser a frame every 30 ms, producing exactly the same world as before. A small + "Saving…" chip shows bottom right only when a save takes longer than 150 ms. The owner found big + worlds froze on a 0% bar with no sign of progress. + - **Clay bricks come from a Brick press (t28, 2026-10-24).** A hopper block like the Saw: every 8 clay lumps on top become a Green brick below, and a Green brick fires into a Brick above about 900°C (any fire). 2 Bricks build a brick wall block; builders order them. The owner found it odd diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index a2cb185..cc33159 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -324,9 +324,9 @@ Weather: clouds from steam, rain and snow, and the world water count. simTick: one simulation step, in order. - functions: simTick -### src/sim/worldgen.js (326 lines) +### src/sim/worldgen.js (344 lines) World generation: noise, layers, ores, caves, water, trees, and clearing tiny hard specks. -- functions: makeNoise, fbm, clearHardSpecks, generateWorld +- functions: makeNoise, fbm, clearHardSpecks, generateWorld, generateWorldAsync - variables: SPECK_MIN ### src/sim/save.js (126 lines) @@ -376,19 +376,19 @@ The title screen art. - functions: buildTitleCells, drawTitle - variables: FONT5, TITLE -### src/ui/saves.js (212 lines) +### src/ui/saves.js (224 lines) Saves in IndexedDB, world files (export / import) and the blueprint library. - functions: newWorldId, worldWrite, listWorlds, putWorldEntry, worldNewest, deleteWorld, cleanWorldName, renameWorld, migrateWorlds, saveWorld, newestSave, b64FromBytes, bytesFromB64, gz, packBuffers, unpackBuffers, exportWorld, importWorldFile, libLoad, libSave, bpEncode, bpValid, bpDecode -- variables: DB, worldIdTime, worldIdCounter, worldWrites +- variables: DB, worldIdTime, worldIdCounter, worldWrites, savingCount -### src/ui/claims.js (141 lines) +### src/ui/claims.js (145 lines) Shared player state; the world save remains a complete standalone fallback. - functions: careerState, validCareer, applyCareerState, loadCareer, claimName, openNewClaim, travel, renderClaimsTab - variables: career, claimBusy -### src/ui/session.js (133 lines) +### src/ui/session.js (157 lines) Starting, loading and quitting a game; modal dialogs; the pause menu. -- functions: showLoading, enterGame, setupNewWorld, startNewWorld, startFromSave, quitToTitle, refreshContinue, anyModal, openModal, closeModal, closeAllModals, topModal, dialog, openPause, renderWorlds +- functions: showLoading, enterGame, setupNewWorld, startNewWorld, startFromSave, loadSavedWorld, quitToTitle, refreshContinue, anyModal, openModal, closeModal, closeAllModals, topModal, dialog, openPause, renderWorlds ### src/ui/settings.js (124 lines) The settings screen. @@ -442,7 +442,7 @@ Previews drawn under the cursor: build ghosts, drill aim and pulses, selections, Events from the simulation (emit) turned into sounds, toasts and cards. - functions: drainEvents -### src/ui/loop.js (126 lines) +### src/ui/loop.js (127 lines) The frame loop and boot. - functions: scanVisible, frame, boot - variables: lastT, acc, lastRender, frameMs, simMs, fpsSm, ambientT diff --git a/docs/agent-log.md b/docs/agent-log.md index 6ea3936..733249e 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -4,6 +4,19 @@ 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-24: t30: progress while generating, loading and saving (Claude Code, Codex) + +- `worldgen.js`: `worldSteps` generator yields `[fraction, label]`; `generateWorld` (sync, tests) + and `generateWorldAsync` (yields every 30 ms) run it. Small and Medium mat/temp/flags hashes + match the old generator exactly. +- `session.js` `showLoading(on, p, label, title)` with `#loadstep`; `GAME.loading` stops the + frame loop's sim and drawing while worlds swap. `loadSavedWorld` for Continue and the world + list; `worldNewest`/`unpackBuffers`/`startFromSave` report progress; travel and new claims too. + `#saving` chip after 150 ms of saving. +- Tests: t_world progress labels and identical worlds; play.py overlay, responsiveness during + async generation, Continue labels, saving chip. All pass except t_smooth half-resolution + (fails under load, as before). Screenshots `shots/t30-loading.png`, `shots/t30-saving.png`. + ## 2026-10-24: t3 follow-up: claim names; t30 added (Claude Code, Codex) - New claims get a camp name from their seed ("Cinder Gulch"; `claimName` in `src/ui/claims.js`, diff --git a/src/shell.html b/src/shell.html index b49f61f..e4539b2 100644 --- a/src/shell.html +++ b/src/shell.html @@ -41,6 +41,7 @@
+Loading claims…
'; diff --git a/src/ui/loop.js b/src/ui/loop.js index fb90053..f49ccef 100644 --- a/src/ui/loop.js +++ b/src/ui/loop.js @@ -16,6 +16,7 @@ function frame(now) { const dtReal = Math.min(0.25, (now - lastT) / 1000); lastT = now; if (SET.fpsCap && now - lastRender < 1000 / SET.fpsCap - 1) return; const dt = Math.min(0.1, (now - (lastRender || now)) / 1000) || 1 / 60; lastRender = now; + if (GAME.loading) { acc = 0; return; } if (!GAME.running) { pollPad(); return; } const t0 = performance.now(); const pad = pollPad(); @@ -110,7 +111,7 @@ function boot(hotData) { const [w, h] = $('nw-size').value.split('x').map(Number); startNewWorld(seed, w, h, $('nw-name').value); }; - $('btn-continue').onclick = async () => { auInit(); playSfx('ui'); const sv = await newestSave(); if (sv) await startFromSave(sv); else toast('No saved world found.'); }; + $('btn-continue').onclick = async () => { auInit(); playSfx('ui'); await loadSavedWorld(); }; $('btn-title-settings').onclick = () => { auInit(); playSfx('ui'); openSettings('Display'); }; $('btn-title-help').onclick = () => { auInit(); playSfx('ui'); openNotebook('Controls'); }; const touchOnly = matchMedia('(pointer: coarse)').matches && !matchMedia('(any-pointer: fine)').matches; @@ -118,7 +119,7 @@ function boot(hotData) { $('title').hidden = true; migrateWorlds().then(() => refreshContinue()).then(async () => { $('title').hidden = false; if (!TITLE.raf) drawTitle(); - if (hotData && hotData.resume) { const sv = await newestSave(); if (sv) await startFromSave(sv); } + if (hotData && hotData.resume) { await loadSavedWorld(); } }).catch(() => { $('title').hidden = false; if (!TITLE.raf) drawTitle(); }); requestAnimationFrame(frame); } diff --git a/src/ui/saves.js b/src/ui/saves.js index bf207a4..8e99a9e 100644 --- a/src/ui/saves.js +++ b/src/ui/saves.js @@ -40,12 +40,13 @@ function putWorldEntry(entry) { await DB.put('worlds', list); }); } -async function worldNewest(id) { +async function worldNewest(id, onProgress) { + if (onProgress) onProgress(0.05, 'Reading the save'); const a = await DB.get(`${id}:main`), b = await DB.get(`${id}:auto`); const sv = a && b ? (a.savedAt >= b.savedAt ? a : b) : a || b; if (!sv) return null; const entry = (await listWorlds()).find(w => w.id === id); - return { ...sv, buffers: await unpackBuffers(sv.buffers), worldId: id, worldName: entry ? entry.name : 'World' }; + return { ...sv, buffers: await unpackBuffers(sv.buffers, onProgress), worldId: id, worldName: entry ? entry.name : 'World' }; } function deleteWorld(id) { return worldWrite(async () => { @@ -98,7 +99,10 @@ async function migrateWorlds() { }); } catch (e) { toast('Could not move the old save. It has been kept. Reload to try again.'); throw e; } } +let savingCount = 0; async function saveWorld(slot, quiet) { + savingCount++; + const indicator = setTimeout(() => { if (!GAME.loading) $('saving').hidden = false; }, 150); try { const s = serializeWorld(), savedAt = Date.now(), playTime = GAME.playTime; if (!GAME.worldId) { GAME.worldId = newWorldId(); GAME.worldName = `World ${(await listWorlds()).length + 1}`; } @@ -116,10 +120,10 @@ async function saveWorld(slot, quiet) { } catch (e) { if (!quiet) toast('Could not save. Browser storage is unavailable in this view. Use Export world file instead.'); return false; - } + } finally { clearTimeout(indicator); if (!--savingCount) $('saving').hidden = true; } } -async function newestSave() { - try { for (const entry of await listWorlds()) { const sv = await worldNewest(entry.id); if (sv) return sv; } return null; } +async function newestSave(onProgress) { + try { for (const entry of await listWorlds()) { const sv = await worldNewest(entry.id, onProgress); if (sv) return sv; } return null; } catch (e) { return null; } } function b64FromBytes(u8) { let s = ''; const CH = 0x8000; for (let i = 0; i < u8.length; i += CH) s += String.fromCharCode.apply(null, u8.subarray(i, i + CH)); return btoa(s); } @@ -137,9 +141,11 @@ async function packBuffers(buffers) { } return packed; } -async function unpackBuffers(buffers) { +async function unpackBuffers(buffers, onProgress) { + let n = 0; const total = Object.keys(buffers).length; const raw = {}, types = { Uint8Array, Uint8ClampedArray, Int8Array, Uint16Array, Int16Array, Uint32Array, Int32Array, Float32Array, Float64Array, BigInt64Array, BigUint64Array }; for (const k in buffers) { + if (onProgress) onProgress(0.1 + 0.6 * n++ / total, 'Unpacking'); const b = buffers[k]; if (ArrayBuffer.isView(b)) { raw[k] = b; continue; } if (!b || b.gz !== true) throw new Error('Invalid saved buffer: ' + k); @@ -148,6 +154,7 @@ async function unpackBuffers(buffers) { if (!bytes) throw new Error('This browser cannot decompress saved worlds.'); raw[k] = new C(bytes.buffer, bytes.byteOffset, bytes.byteLength / C.BYTES_PER_ELEMENT); } + if (onProgress) onProgress(0.7, 'Unpacking'); return raw; } async function exportWorld() { @@ -177,22 +184,27 @@ async function exportWorld() { catch (e) { if (e && e.code !== 'declined') toast('The world file was not saved.'); } } async function importWorldFile(file) { + const progress = (f, s) => showLoading(true, f, s, 'Loading world'); + progress(0.05, 'Reading the save'); try { const obj = JSON.parse(await file.text()); if (obj.format !== 'deepgrain-world') throw new Error('Not a Deepgrain world file.'); const bufs = {}; const T = { Uint8Array, Uint16Array, Int16Array, Float32Array, Int32Array }; + let n = 0; const total = Object.keys(obj.buffers).length; for (const k in obj.buffers) { + progress(0.1 + 0.6 * n++ / total, 'Unpacking'); const b = obj.buffers[k]; let bytes = bytesFromB64(b.b64); if (b.gz) bytes = await gz(bytes, true); const C = T[b.type] || Uint8Array; bufs[k] = new C(bytes.buffer, bytes.byteOffset, bytes.byteLength / C.BYTES_PER_ELEMENT); } const id = newWorldId(), name = `Imported ${obj.meta.seed}`.slice(0, 40); - if (!await startFromSave({ meta: obj.meta, buffers: bufs, worldId: id, worldName: name })) return; + if (!await startFromSave({ meta: obj.meta, buffers: bufs, worldId: id, worldName: name }, progress)) return; if (!await saveWorld('auto')) return; toast('World imported.'); } catch (e) { toast('That file could not be loaded: ' + (e.message || 'unknown error')); } + finally { showLoading(false); } } // ---------- Blueprint library ---------- diff --git a/src/ui/session.js b/src/ui/session.js index cfd246b..ff46c1c 100644 --- a/src/ui/session.js +++ b/src/ui/session.js @@ -1,6 +1,12 @@ // Starting, loading and quitting a game; modal dialogs; the pause menu. // ---------- Start / stop ---------- -function showLoading(on, p) { +function showLoading(on, p, label, title) { + GAME.loading = on; + if (on) { + $('saving').hidden = true; + if (title) $('loading').querySelector('.label').textContent = title; + $('loadstep').textContent = label || ''; + } $('loading').hidden = !on; if (on) { $('loadbar').style.width = Math.round((p || 0) * 100) + '%'; $('loadtext').textContent = Math.round((p || 0) * 100) + '%'; } } @@ -15,11 +21,11 @@ function enterGame() { GAME.drag = null; GAME.lmb = GAME.rmb = false; lastBagKey = ''; hudDirty = true; renderHotbar(); } -async function setupNewWorld(seed, w, h, name, carried) { - auInit(); showLoading(true, 0); +async function setupNewWorld(seed, w, h, name, carried, title = 'Generating world') { + auInit(); showLoading(true, 0, 'Shaping the land', title); await new Promise(resolve => setTimeout(resolve, 30)); for (const k of Object.keys(stats)) stats[k] = 0; - generateWorld(seed, w, h, null); + await generateWorldAsync(seed, w, h, (f, s) => showLoading(true, f * 0.9, s, title)); GAME.worldId = newWorldId(); GAME.worldName = name; GAME.playTime = 0; GAME.clip = null; GAME.paste = false; GAME.selDrag = null; GAME.tool = 0; GAME.blow.clear(); GAME.wallMat = RSTONE; GAME.filterCfg = { m: [], inv: 0 }; GAME.pieceIdx = 0; if (carried) applyCareerState(carried); @@ -30,30 +36,48 @@ async function startNewWorld(seed, w, h, name = '') { await setupNewWorld(seed, w, h, cleanWorldName(name, `World ${worlds.length + 1}`)); career = { id: newWorldId(), claims: [GAME.worldId], claimNo: 1 }; GAME.careerId = career.id; GAME.claimNo = 1; + showLoading(true, 0.9, 'Saving'); await saveWorld('auto'); + showLoading(true, 1, 'Saving'); showLoading(false); closeAllModals(); enterGame(); showCard({ kind: 'Welcome', title: 'The dig site', body: ['Fly with WASD. The Drill (1) breaks rock loose. The Pneumatic nozzle (2) picks up and blows the selection. Click or Q/E in the bag selects; Ctrl+click mixes. Hold Ctrl to see everything found. Clear returns to picking what you click. Shift: everything.', 'You owe $20,000 on this claim. Sell what you dig and make at the trade dock next to you: anything that drops through its grate is paid for on the spot, and buyers post orders that pay far more. Every tip lives in the notebook (N).'], hold: 1.8 }); } catch (e) { showLoading(false); toast('Could not create a world. Browser storage is unavailable.'); } } -async function startFromSave(sv) { +async function startFromSave(sv, onProgress) { + onProgress = onProgress || ((f, s) => showLoading(true, f, s, 'Loading world')); auInit(); GAME.paused = true; try { for (const k of Object.keys(stats)) stats[k] = 0; + onProgress(0.75, 'Building the world'); + await new Promise(r => setTimeout(r, 30)); deserializeWorld(sv.meta, sv.buffers); } catch (e) { toast(e && e.message ? e.message : 'That save could not be loaded.', { big: true, title: 'Save not loaded' }); + showLoading(false); GAME.running = false; $('hud').hidden = true; $('title').hidden = false; if (!TITLE.raf) drawTitle(); return false; } GAME.worldId = sv.worldId || ''; GAME.worldName = sv.worldName || ''; GAME.playTime = sv.playTime || 0; GAME.drag = null; GAME.blow.clear(); + onProgress(0.95, 'Your gear and notebook'); await loadCareer(); + showLoading(false); enterGame(); return true; } +async function loadSavedWorld(id) { + const progress = (f, s) => showLoading(true, f, s, 'Loading world'); + progress(0, 'Reading the save'); + try { + const sv = id ? await worldNewest(id, progress) : await newestSave(progress); + if (sv) return await startFromSave(sv, progress); + toast('No saved world found.'); return false; + } catch (e) { toast('Could not reach browser storage.'); return false; } + finally { showLoading(false); } +} async function quitToTitle() { await saveWorld('main', true); GAME.running = false; closeAllModals(); @@ -119,7 +143,7 @@ async function renderWorlds(editId) { button('Save', async () => { await renameWorld(w.id, input.value); await renderWorlds(); await refreshContinue(); }); button('Cancel', () => renderWorlds()); } else { - button('Load', async () => { const sv = await worldNewest(w.id); if (sv && await startFromSave(sv)) closeAllModals(); else if (!sv) toast('No saved world found.'); }); + button('Load', async () => { const ok = await loadSavedWorld(w.id); if (ok) closeAllModals(); }); button('Rename', () => renderWorlds(w.id)); button('Delete', () => dialog('Delete world?', `Delete ${esc(w.name)} and its autosave? Exported world files are not affected.
`, [ { label: 'Cancel', cls: 'ghost' }, diff --git a/src/ui/state.js b/src/ui/state.js index e9d1400..bb134f8 100644 --- a/src/ui/state.js +++ b/src/ui/state.js @@ -38,7 +38,7 @@ const BAG_ORDER = [BARAU, BARAG, BARBZ, BARBS, BARST, BARFE, BARCU, BARSN, BARZN SCRCU, SCRFE, SCRST, SCRSN, SCRZN, SCRAG, SCRAU, SCRBZ, SCRBS, ASH, SLAG, SNOW, WATER, MAGMA, MCU, MFE, MST, MGL, MSN, MZN, MAG, MAU, MBZ, MBS]; const GAME = { ctrl: false, - running: false, paused: false, tool: 0, blow: new Set(), wallMat: RSTONE, filterCfg: { m: [], inv: 0 }, pieceIdx: 0, pieceDir: {}, + running: false, loading: false, paused: false, tool: 0, blow: new Set(), wallMat: RSTONE, filterCfg: { m: [], inv: 0 }, pieceIdx: 0, pieceDir: {}, brush: [3, 2, 0, 0, 0], tipsOpen: new Set(), cards: [], vacFilter: 0, rmbConsumed: false, rmbSeen: new Set(), lookHold: { x: 0, y: 0 }, paste: false, selDrag: null, rmbRec: null, dragDir: -1, replaceShown: false, clip: null, drag: null, aim: { x: 0, y: 0 }, mouse: { x: 0, y: 0, in: false }, lmb: false, rmb: false, drillW: 1, drillTune: 0, lastDug: 0, padRun: 0, status: '', statusKind: 'err', statusT: 0, usingPad: false, padAim: { x: 12, y: 0 }, diff --git a/tasks/t30_progress_meters_while.md b/tasks/t30_progress_meters_while.md index bdd693b..755ee65 100644 --- a/tasks/t30_progress_meters_while.md +++ b/tasks/t30_progress_meters_while.md @@ -1,9 +1,16 @@ --- id: t30 title: Progress meters while generating, loading and saving -status: Todo +report: | + (agent) Making a world, opening a claim, loading a save and travelling now show the loading + screen with a title, a bar that moves and the step it's on (shaping the land, laying rock and + ore, settling loose ground, unpacking, building the world...). The game stays responsive while a + Huge world generates, and worlds come out exactly as before. A small "Saving…" chip appears at + the bottom right only if a save takes longer than about a sixth of a second. Please try a Huge + world, Continue, a new claim and travel, and say whether the saving chip should stay. +status: Review created: 2026-10-24 -updated: 2026-10-24T22:36:27Z +updated: 2026-10-24T22:54:06Z --- ## Context @@ -12,6 +19,6 @@ updated: 2026-10-24T22:36:27Z ## Subtasks -- [ ] Generating a world shows a progress bar that moves and names the step (terrain, ores, caves, water, trees…) -- [ ] Loading a save, opening a new claim and travelling show progress (reading, unpacking, building the world) -- [ ] Saving shows a small, unobtrusive indicator while it runs +- [x] Generating a world shows a progress bar that moves and names the step (terrain, ores, caves, water, trees…) +- [x] Loading a save, opening a new claim and travelling show progress (reading, unpacking, building the world) +- [x] Saving shows a small, unobtrusive indicator while it runs diff --git a/test/play.py b/test/play.py index 177aac8..c11c5ee 100644 --- a/test/play.py +++ b/test/play.py @@ -26,6 +26,24 @@ with sync_playwright() as p: catch (e) { return await DB.get('abort-test') === undefined; } return false; }""")) + check('async generation preserves the world and yields frames with labels', pg.evaluate("""async () => { + const hash = a => { let h = 2166136261; for (const v of a) h = Math.imul(h ^ v, 16777619); return h; }; + generateWorld(424242, 2048, 1024); const want = hash(mat); + let frames = 0, active = true, labels = []; + const frameCount = () => { if (active) { frames++; requestAnimationFrame(frameCount); } }; + requestAnimationFrame(frameCount); + showLoading(true, 0, '', 'Generating world'); + try { + await generateWorldAsync(424242, 2048, 1024, (f, s) => { showLoading(true, f, s); labels.push($('loadstep').textContent); }); + return hash(mat) === want && frames > 1 && labels.includes('Settling loose ground') && labels.every(Boolean); + } finally { active = false; showLoading(false); } + }""")) + # Record progress at the browser's own frame boundaries, without slowing the operation. + pg.add_init_script(""" + window.__loads = []; + const watch = () => { if (typeof GAME !== 'undefined' && GAME.loading) __loads.push([$('loading').querySelector('.label').textContent, $('loadstep').textContent, $('loadbar').style.width]); requestAnimationFrame(watch); }; + requestAnimationFrame(watch); + """) # Migrate a real legacy save before starting the play-through. pg.evaluate("generateWorld(12345, 512, 384, null)") pg.evaluate("async () => { const s = serializeWorld(); await DB.put('main', { ...s, savedAt: Date.now(), playTime: 3661 }); }") @@ -39,7 +57,18 @@ with sync_playwright() as p: pg.reload(); pg.wait_for_function("!$('title').hidden && !$('btn-continue').hidden") check('legacy save becomes World 1 and retires its old key', pg.evaluate("async () => { const w = await listWorlds(); return w.length === 1 && w[0].name === 'World 1' && await DB.get('main') === undefined; }")) pg.click('#btn-continue'); pg.wait_for_function('GAME.running') + check('Continue paints loading steps', pg.evaluate("__loads.some(([t, s]) => t === 'Loading world' && s === 'Building the world') && !GAME.loading && $('loading').hidden")) check('Continue loads the migrated seed and playtime', pg.evaluate('worldSeed === 12345 && GAME.playTime >= 3661 && !!GAME.worldId')) + pg.evaluate("""() => { + window.__pack = packBuffers; + packBuffers = async b => { await new Promise(r => setTimeout(r, 600)); return __pack(b); }; + window.__saving = saveWorld('main', true); + }""") + pg.locator('#saving').wait_for(state='visible') + pg.screenshot(path=str(out / 'saving-progress.png')) + check('slow save shows the saving chip', pg.is_visible('#saving')) + pg.evaluate('async () => { await __saving; packBuffers = __pack; }') + check('saving chip hides when the save ends', not pg.is_visible('#saving')) pg.evaluate('quitToTitle()') names = {} for seed in [222, 333]: @@ -53,6 +82,7 @@ with sync_playwright() as p: pg.screenshot(path=str(out / 'new-world.png')) pg.fill('#nw-seed', str(seed)); pg.press('#nw-name' if seed == 222 else '#nw-seed', 'Enter') pg.wait_for_function('GAME.running', timeout=20000) + check(f'new world {seed} paints a moving generation bar', pg.evaluate("__loads.some(([t, s, w]) => t === 'Generating world' && parseFloat(w) > 0 && parseFloat(w) < 90) && !GAME.loading")) check(f'new world {seed} shows the Welcome card', pg.evaluate("[...$('cards').children].some(c => c.textContent.includes('Welcome'))")) check(f'pause menu shows world {seed} name', names[seed] in pg.evaluate("() => { openPause(); return $('pause-meta').textContent; }")) pg.evaluate('quitToTitle()') @@ -1058,6 +1088,8 @@ with sync_playwright() as p: check('the size menu offers small, medium, large and huge', pg2.evaluate("[...document.querySelectorAll('#nw-size option')].map(o => o.value).join(',')") == '2048x1024,3072x1280,4096x1536,6144x2048', pg2.evaluate("[...document.querySelectorAll('#nw-size option')].map(o => o.value).join(',')")) check('Medium remains the default size', pg2.input_value('#nw-size') == '3072x1280') pg2.click('#btn-new'); pg2.fill('#nw-seed', '4242'); pg2.select_option('#nw-size', '6144x2048'); pg2.click('#btn-generate') + pg2.wait_for_function("GAME.loading && parseFloat($('loadbar').style.width) > 10 && parseFloat($('loadbar').style.width) < 85", timeout=30000) + pg2.screenshot(path=str(out / 'generation-progress.png')) pg2.wait_for_function('GAME.running === true', timeout=90000) check('huge world generates', pg2.evaluate('W === 6144 && H === 2048'), pg2.evaluate('[W, H]')) pg2.evaluate("bot.x = 2000; bot.y = magmaY[2000] - 60; snapCamera();"); pg2.wait_for_timeout(800) diff --git a/test/t_world.js b/test/t_world.js index fcebce0..7e42890 100644 --- a/test/t_world.js +++ b/test/t_world.js @@ -6,6 +6,15 @@ const { check, done } = suite(); const fresh = () => { flatWorld(run, 240, 160, 100); run('player.tankTier = 3; saplings = []; stats.grown = 0;'); }; const box = (x0, y0, x1, y1, m) => rect(run, x0, y0, x1, y1, m); +const { createHash } = require('node:crypto'); +const worldHash = () => { const hash = createHash('sha256'); for (const k of ['mat', 'temp', 'flags']) { const a = run(k); hash.update(new Uint8Array(a.buffer, a.byteOffset, a.byteLength)); } return hash.digest('hex'); }; +run('generateWorld(4242, 512, 384)'); +const generatedHash = worldHash(); +const progress = []; +run('generateWorld')(4242, 512, 384, (f, label) => progress.push([f, label])); +check('generation progress rises to 1 with named steps', progress.length > 10 && progress[0][0] === 0 && progress.at(-1)[0] === 1 && progress.every(([f, label], i) => f >= 0 && f <= 1 && (!i || f >= progress[i - 1][0]) && typeof label === 'string' && label.length > 0)); +check('generation callback keeps the same world', worldHash() === generatedHash); + // 1. acorns sprout on soil under open sky and grow into a tree fresh(); box(100, 100, 140, 103, run('DIRT'));