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()