Huge world size, and saves are gzip-compressed (about 60x smaller) (t3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 17:51:42 -04:00
co-authored by Claude Opus 5.5
parent 4a8825415d
commit 56c93ae135
4 changed files with 65 additions and 10 deletions
+2 -2
View File
@@ -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)
+1
View File
@@ -58,6 +58,7 @@
<option value="2048x1024">Small · 2048 × 1024</option>
<option value="3072x1280" selected>Medium · 3072 × 1280</option>
<option value="4096x1536">Large · 4096 × 1536</option>
<option value="6144x2048">Huge · 6144 × 2048</option>
</select>
</label>
<button class="btn primary full" id="btn-generate">Generate and start</button>
+27 -4
View File
@@ -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: {} };
+35 -4
View File
@@ -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()