From 56c93ae13572958ae4ba5e270eaf67c869125f85 Mon Sep 17 00:00:00 2001 From: 44r0n7 <44r0n7+gitea@pm.me> Date: Sat, 24 Oct 2026 17:51:42 -0400 Subject: [PATCH] Huge world size, and saves are gzip-compressed (about 60x smaller) (t3) Co-Authored-By: Claude Opus 5.5 --- docs/PROJECT_MAP.md | 4 ++-- src/shell.html | 1 + src/ui/saves.js | 31 +++++++++++++++++++++++++++---- test/play.py | 39 +++++++++++++++++++++++++++++++++++---- 4 files changed, 65 insertions(+), 10 deletions(-) diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 49a9a73..de81a41 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -376,9 +376,9 @@ The title screen art. - functions: buildTitleCells, drawTitle - variables: FONT5, TITLE -### src/ui/saves.js (178 lines) +### src/ui/saves.js (201 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, exportWorld, importWorldFile, libLoad, libSave, bpEncode, bpValid, bpDecode +- 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 ### src/ui/session.js (125 lines) diff --git a/src/shell.html b/src/shell.html index b6b66fb..b49f61f 100644 --- a/src/shell.html +++ b/src/shell.html @@ -58,6 +58,7 @@ + diff --git a/src/ui/saves.js b/src/ui/saves.js index 70f0e41..626b70a 100644 --- a/src/ui/saves.js +++ b/src/ui/saves.js @@ -45,7 +45,7 @@ async function worldNewest(id) { 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, worldId: id, worldName: entry ? entry.name : 'World' }; + return { ...sv, buffers: await unpackBuffers(sv.buffers), worldId: id, worldName: entry ? entry.name : 'World' }; } function deleteWorld(id) { return worldWrite(async () => { @@ -75,8 +75,9 @@ async function migrateWorlds() { for (const [slot, old] of [['main', a], ['auto', b]]) if (old) { const copy = await DB.get(`${id}:${slot}`); if (!copy || JSON.stringify(copy.meta) !== JSON.stringify(old.meta) || copy.savedAt !== old.savedAt || copy.playTime !== old.playTime) throw new Error('Save copy failed.'); - for (const k in old.buffers) { - const x = old.buffers[k], y = copy.buffers[k]; + const original = await unpackBuffers(old.buffers), copied = await unpackBuffers(copy.buffers); + for (const k in original) { + const x = original[k], y = copied[k]; if (!y || x.constructor !== y.constructor || x.length !== y.length || !x.every((v, i) => v === y[i])) throw new Error('Save copy failed.'); } } @@ -91,12 +92,13 @@ async function saveWorld(slot, quiet) { const s = serializeWorld(), savedAt = Date.now(), playTime = GAME.playTime; if (!GAME.worldId) { GAME.worldId = newWorldId(); GAME.worldName = `World ${(await listWorlds()).length + 1}`; } const id = GAME.worldId, name = GAME.worldName; + const buffers = await packBuffers(s.buffers); await worldWrite(async () => { const list = await listWorlds(); let entry = list.find(w => w.id === id); if (!entry) { entry = { id, name, createdAt: savedAt }; list.push(entry); } Object.assign(entry, { savedAt, playTime, seed: s.meta.seed, w: s.meta.W, h: s.meta.H }); - await DB.op('readwrite', st => { st.put({ meta: s.meta, buffers: s.buffers, savedAt, playTime }, `${id}:${slot}`); st.put(list, 'worlds'); }); + await DB.op('readwrite', st => { st.put({ meta: s.meta, buffers, savedAt, playTime }, `${id}:${slot}`); st.put(list, 'worlds'); }); }); if (!quiet) toast(slot === 'auto' ? 'Autosaved.' : 'World saved.'); return true; @@ -116,6 +118,27 @@ async function gz(u8, inflate) { const st = new Blob([u8]).stream().pipeThrough(inflate ? new DecompressionStream('gzip') : new CompressionStream('gzip')); return new Uint8Array(await new Response(st).arrayBuffer()); } +async function packBuffers(buffers) { + const packed = {}; + for (const k in buffers) { + const arr = buffers[k], bytes = await gz(new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength)); + packed[k] = bytes ? { type: arr.constructor.name, gz: true, bytes } : arr; + } + return packed; +} +async function unpackBuffers(buffers) { + const raw = {}, types = { Uint8Array, Uint8ClampedArray, Int8Array, Uint16Array, Int16Array, Uint32Array, Int32Array, Float32Array, Float64Array, BigInt64Array, BigUint64Array }; + for (const k in buffers) { + const b = buffers[k]; + if (ArrayBuffer.isView(b)) { raw[k] = b; continue; } + if (!b || b.gz !== true) throw new Error('Invalid saved buffer: ' + k); + if (!Object.hasOwn(types, b.type)) throw new Error('Unknown saved buffer type: ' + b.type); + const bytes = await gz(b.bytes, true), C = types[b.type]; + 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); + } + return raw; +} async function exportWorld() { const s = serializeWorld(); const out = { format: 'deepgrain-world', v: 1, meta: s.meta, buffers: {} }; diff --git a/test/play.py b/test/play.py index aae0a65..201aceb 100644 --- a/test/play.py +++ b/test/play.py @@ -782,12 +782,42 @@ with sync_playwright() as p: pg.keyboard.press('Escape'); settle(100) check('pause opens', ev("!$('m-pause').hidden")) pg.click('#pm-save'); settle(800) + check('saved buffers are compressed and smaller than raw buffers', ev("""async () => { + const sv = await DB.get(`${GAME.worldId}:main`), raw = serializeWorld().buffers; + return Object.values(sv.buffers).every(b => b.gz === true) && + Object.values(sv.buffers).reduce((n, b) => n + b.bytes.byteLength, 0) < + Object.values(raw).reduce((n, b) => n + b.byteLength, 0); + }""")) + check('buffer packing preserves types and subarray bounds', ev("""async () => { + for (const C of [Uint8Array, Uint8ClampedArray, Int8Array, Uint16Array, Int16Array, Uint32Array, Int32Array, Float32Array, Float64Array, BigInt64Array, BigUint64Array]) { + const a = new C(4); a[1] = C === BigInt64Array || C === BigUint64Array ? 17n : 17; + const b = (await unpackBuffers(await packBuffers({a: a.subarray(1, 3)}))).a; + if (b.constructor !== C || b.length !== 2 || b[0] !== a[1] || b[1] !== a[2]) return false; + } + try { await unpackBuffers({a: {gz: true, type: 'constructor'}}); return false; } + catch (e) { return e.message.includes('Unknown saved buffer type'); } + }""")) + check('packing falls back to raw arrays without CompressionStream', ev("""async () => { + const cs = window.CompressionStream, a = new Uint16Array([7, 42]); + try { window.CompressionStream = undefined; return (await unpackBuffers(await packBuffers({a}))).a === a; } + finally { window.CompressionStream = cs; } + }""")) bag_before = ev('bagTotal'); blocks_before = ev('blockP.reduce((a,v)=>a+(v>0),0)') pg.click('#pm-title'); settle(1200) check('back at title with continue', ev("!$('title').hidden && !$('btn-continue').hidden")) pg.click('#btn-continue'); pg.wait_for_function('GAME.running === true', timeout=10000); settle(500) check('continue restores bag and blocks', ev('bagTotal') == bag_before and ev('blockP.reduce((a,v)=>a+(v>0),0)') == blocks_before, f"{bag_before}->{ev('bagTotal')}") + check('normal load still restores an old raw save', ev("""async () => { + GAME.paused = true; + const s = serializeWorld(), id = GAME.worldId; + await DB.op('readwrite', st => st.put({...s, savedAt: Date.now() + 1000, playTime: 4567}, `${id}:main`)); + const sv = await worldNewest(id); + bagTotal = 0; worldSeed = 0; + return startFromSave(sv) && worldSeed === s.meta.seed && GAME.playTime === 4567 && + bagTotal === s.meta.bag.reduce((n, v) => n + v, 0); + }""")) + # 9b. selling: goods sell the moment they drop into the dock; orders pay a lump sum and send a truck ev("SET.cardSeconds = 9; orders = []; nextOrderT = 1e12; R.floaters = [];") bot_home = ev('[bot.x, bot.y]') @@ -868,7 +898,7 @@ with sync_playwright() as p: pg3.wait_for_function('GAME.running === true', timeout=60000) got = pg3.evaluate('JSON.stringify([W, H, Math.round(money), Math.round(debt), bagTotal])') check('importing it on the title screen brings the same world back', got == want, f'{want} -> {got}') - pg3.wait_for_function("async () => (await listWorlds()).length === " + str(worlds_before_import + 1)) + pg3.wait_for_function("[...$('toasts').children].some(e => e.textContent === 'World imported.')") check('import adds an independent saved world', pg3.evaluate('GAME.worldId') != ev('GAME.worldId') and pg3.evaluate('listWorlds().then(w => w.length)') == worlds_before_import + 1) check('world file keeps fog bytes', pg3.evaluate('Array.from(fog)') == fog_want) pg3.close() @@ -921,10 +951,11 @@ with sync_playwright() as p: pg2 = ctx.new_page() pg2.on('pageerror', lambda e: logs.append(f'PAGEERROR(large): {e}')) pg2.goto(url); pg2.evaluate('SET.fog = false'); pg2.wait_for_timeout(300) - check('the size menu offers small, medium and large, and no deep world', pg2.evaluate("[...document.querySelectorAll('#nw-size option')].map(o => o.value).join(',')") == '2048x1024,3072x1280,4096x1536', pg2.evaluate("[...document.querySelectorAll('#nw-size option')].map(o => o.value).join(',')")) - pg2.click('#btn-new'); pg2.fill('#nw-seed', '4242'); pg2.select_option('#nw-size', '4096x1536'); pg2.click('#btn-generate') + 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.running === true', timeout=90000) - check('large world generates', pg2.evaluate('W === 4096 && H === 1536'), pg2.evaluate('[W, H]')) + 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) pg2.screenshot(path=str(out / '11-deep-magma.png')) pg2.close()