Files
deepgrain-foundry/src/ui/saves.js
T

225 lines
12 KiB
JavaScript

// Saves in IndexedDB, world files (export / import) and the blueprint library.
// ---------- Saves (IndexedDB) ----------
const DB = {
db: null,
open() {
if (this.db) return Promise.resolve(this.db);
return new Promise((res, rej) => {
try {
const r = indexedDB.open('deepgrain', 1);
r.onupgradeneeded = () => r.result.createObjectStore('saves');
r.onsuccess = () => { this.db = r.result; res(this.db); };
r.onerror = () => rej(r.error);
} catch (e) { rej(e); }
});
},
async op(mode, fn) {
const db = await this.open();
return new Promise((res, rej) => {
const tx = db.transaction('saves', mode); let rq;
tx.oncomplete = () => res(rq && rq.result);
tx.onabort = tx.onerror = () => rej(tx.error || new Error('Storage transaction failed.'));
try { rq = fn(tx.objectStore('saves')); } catch (e) { tx.abort(); rej(e); }
});
},
put(k, v) { return this.op('readwrite', s => s.put(v, k)); },
get(k) { return this.op('readonly', s => s.get(k)); },
del(k) { return this.op('readwrite', s => s.delete(k)); },
};
let worldIdTime = 0, worldIdCounter = 0, worldWrites = Promise.resolve();
function newWorldId() {
const now = Date.now(); worldIdCounter = now === worldIdTime ? worldIdCounter + 1 : 0; worldIdTime = now;
return 'w' + now + (worldIdCounter ? '-' + worldIdCounter : '');
}
function worldWrite(fn) { const p = worldWrites.then(fn); worldWrites = p.catch(() => {}); return p; }
async function listWorlds() { return (await DB.get('worlds') || []).sort((a, b) => b.savedAt - a.savedAt); }
function putWorldEntry(entry) {
return worldWrite(async () => {
const list = await listWorlds(), i = list.findIndex(w => w.id === entry.id);
if (i < 0) list.push(entry); else list[i] = { ...list[i], ...entry };
await DB.put('worlds', list);
});
}
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, onProgress), worldId: id, worldName: entry ? entry.name : 'World' };
}
function deleteWorld(id) {
return worldWrite(async () => {
const worlds = await listWorlds(), entry = worlds.find(w => w.id === id), list = worlds.filter(w => w.id !== id);
const stored = entry && entry.career ? await DB.get('career:' + entry.career) : null;
const c = stored && typeof stored === 'object' && !Array.isArray(stored) ? stored : null;
const claims = entry && entry.career ? list.filter(w => w.career === entry.career).map(w => w.id) : [];
if (c) c.claims = claims;
await DB.op('readwrite', st => {
st.delete(`${id}:main`); st.delete(`${id}:auto`); st.put(list, 'worlds');
if (entry && entry.career) {
if (!claims.length) st.delete('career:' + entry.career);
else if (c) st.put(c, 'career:' + entry.career);
}
});
if (career && c && career.id === c.id) career.claims = c.claims;
if (GAME.worldId === id) { GAME.worldId = ''; GAME.worldName = ''; }
});
}
function cleanWorldName(name, fallback) { return String(name).trim().slice(0, 40) || fallback; }
function renameWorld(id, name) {
return worldWrite(async () => {
const list = await listWorlds(), entry = list.find(w => w.id === id);
if (!entry) return;
entry.name = cleanWorldName(name, entry.name);
await DB.put('worlds', list);
if (GAME.worldId === id) GAME.worldName = entry.name;
});
}
async function migrateWorlds() {
try {
if (await DB.get('worlds') !== undefined) return;
const a = await DB.get('main'), b = await DB.get('auto'), sv = a && b ? (a.savedAt >= b.savedAt ? a : b) : a || b;
if (!sv) return;
const id = newWorldId();
await DB.op('readwrite', st => { if (a) st.put(a, `${id}:main`); if (b) st.put(b, `${id}:auto`); });
// Verify every byte before retiring the only old copies.
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.');
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.');
}
}
await DB.op('readwrite', st => {
st.put([{ id, name: 'World 1', seed: sv.meta.seed, w: sv.meta.W, h: sv.meta.H, createdAt: sv.savedAt, savedAt: sv.savedAt, playTime: sv.playTime || 0 }], 'worlds');
st.delete('main'); st.delete('auto');
});
} 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}`; }
const id = GAME.worldId, name = GAME.worldName, claimNo = GAME.claimNo, c = GAME.careerId && career ? { ...career, ...careerState() } : null;
await worldWrite(async () => {
const buffers = await packBuffers(s.buffers);
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, debt: s.meta.economy.debt, dev: s.meta.dev, ...(c ? { career: c.id, claimNo } : {}) });
await DB.op('readwrite', st => { st.put({ meta: s.meta, buffers, savedAt, playTime }, `${id}:${slot}`); st.put(list, 'worlds'); if (c) st.put(c, 'career:' + c.id); });
});
if (!quiet) toast(slot === 'auto' ? 'Autosaved.' : 'World saved.');
return true;
} 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(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); }
function bytesFromB64(b64) { const s = atob(b64); const u8 = new Uint8Array(s.length); for (let i = 0; i < s.length; i++) u8[i] = s.charCodeAt(i); return u8; }
async function gz(u8, inflate) {
if (typeof CompressionStream === 'undefined') return null;
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, 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);
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);
}
if (onProgress) onProgress(0.7, 'Unpacking');
return raw;
}
async function exportWorld() {
const s = serializeWorld();
const out = { format: 'deepgrain-world', v: 1, meta: s.meta, buffers: {} };
for (const k in s.buffers) {
const arr = s.buffers[k], bytes = new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength);
const z = await gz(bytes, false);
out.buffers[k] = { type: arr.constructor.name, gz: !!z, b64: b64FromBytes(z || bytes) };
}
const text = JSON.stringify(out);
let dl = null;
try { dl = window.claude && window.claude.use ? await window.claude.use('downloads') : null; } catch (e) { dl = null; }
if (!dl) {
// an ordinary browser download (the downloads hook above only exists inside claude.ai)
try {
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
const a = document.createElement('a');
a.href = url; a.download = `deepgrain-${worldSeed}.json`;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 60000);
toast('World file saved.');
} catch (e) { toast('Saving files is not available in this view.'); }
return;
}
try { await dl.save({ filename: `deepgrain-${worldSeed}.json`, data: new Blob([text], { type: 'application/json' }) }); toast('World file saved.'); }
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 }, 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 ----------
function libLoad() { try { return JSON.parse(localStorage.getItem('deepgrain.blueprints') || '[]'); } catch (e) { return []; } }
function libSave(list) { try { localStorage.setItem('deepgrain.blueprints', JSON.stringify(list)); return true; } catch (e) { toast('Blueprints cannot be stored in this view. Copy the blueprint code instead.'); return false; } }
function bpEncode(bp) { return 'DGBP2:' + btoa(unescape(encodeURIComponent(JSON.stringify(bp)))); }
function bpValid(bp) { return !!(bp && bp.v === 2 && Array.isArray(bp.blocks) && bp.bw > 0 && bp.bh > 0); }
function bpDecode(code) {
const s = code.trim();
if (s.startsWith('DGBP1:')) throw new Error('That blueprint is from an older version of the game, before the block grid. It cannot be used.');
if (!s.startsWith('DGBP2:')) throw new Error('Blueprint codes start with DGBP2:');
let bp;
try { bp = JSON.parse(decodeURIComponent(escape(atob(s.slice(6))))); } catch (e) { throw new Error('That code is damaged.'); }
if (!bpValid(bp)) throw new Error('That code is damaged.');
bp.machines = bp.machines || []; bp.molds = bp.molds || [];
return bp;
}