Several worlds: a Worlds list with load, rename and delete; old save migrates to World 1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 00:20:18 -04:00
co-authored by Claude Opus 5.5
parent c434faefc4
commit aadcbc6efb
11 changed files with 274 additions and 35 deletions
+3
View File
@@ -196,6 +196,9 @@ above, including:
- **Screw tubes carry bars**, one whole bar at a time, in line with the grains: a belt pushes a - **Screw tubes carry bars**, one whole bar at a time, in line with the grains: a belt pushes a
bar into a tube's side, it rides through corners and leaves the mouth. The owner asked how bars bar into a tube's side, it rides through corners and leaves the mouth. The owner asked how bars
should be moved and chose tubes over a separate bar lift. should be moved and chose tubes over a separate bar lift.
- **Several worlds are kept.** Each world has its own save and autosave; the title screen's Worlds
list loads, renames and deletes them, and a new or imported world never overwrites another. An
old single save becomes 'World 1'. The owner asked for multiple saves.
## Ideas not built yet ## Ideas not built yet
+7 -7
View File
@@ -369,16 +369,16 @@ The title screen art.
- functions: buildTitleCells, drawTitle - functions: buildTitleCells, drawTitle
- variables: FONT5, TITLE - variables: FONT5, TITLE
### src/ui/saves.js (106 lines) ### src/ui/saves.js (177 lines)
Saves in IndexedDB, world files (export / import) and the blueprint library. Saves in IndexedDB, world files (export / import) and the blueprint library.
- functions: saveWorld, newestSave, b64FromBytes, bytesFromB64, gz, exportWorld, importWorldFile, libLoad, libSave, bpEncode, bpValid, bpDecode - functions: newWorldId, worldWrite, listWorlds, putWorldEntry, worldNewest, deleteWorld, renameWorld, migrateWorlds, saveWorld, newestSave, b64FromBytes, bytesFromB64, gz, exportWorld, importWorldFile, libLoad, libSave, bpEncode, bpValid, bpDecode
- variables: DB - variables: DB, worldIdTime, worldIdCounter, worldWrites
### src/ui/session.js (87 lines) ### src/ui/session.js (122 lines)
Starting, loading and quitting a game; modal dialogs; the pause menu. Starting, loading and quitting a game; modal dialogs; the pause menu.
- functions: showLoading, enterGame, startNewWorld, startFromSave, quitToTitle, refreshContinue, anyModal, openModal, closeModal, closeAllModals, topModal, dialog, openPause - functions: showLoading, enterGame, startNewWorld, startFromSave, quitToTitle, refreshContinue, anyModal, openModal, closeModal, closeAllModals, topModal, dialog, openPause, renderWorlds
### src/ui/settings.js (116 lines) ### src/ui/settings.js (122 lines)
The settings screen. The settings screen.
- functions: openSettings, renderSettings, applySettings, toggleFullscreen, confirmWipe - functions: openSettings, renderSettings, applySettings, toggleFullscreen, confirmWipe
- variables: SET_TABS, setTab - variables: SET_TABS, setTab
@@ -430,7 +430,7 @@ Previews drawn under the cursor: build ghosts, drill aim and pulses, selections,
Events from the simulation (emit) turned into sounds, toasts and cards. Events from the simulation (emit) turned into sounds, toasts and cards.
- functions: drainEvents - functions: drainEvents
### src/ui/loop.js (117 lines) ### src/ui/loop.js (119 lines)
The frame loop and boot. The frame loop and boot.
- functions: scanVisible, frame, boot - functions: scanVisible, frame, boot
- variables: lastT, acc, lastRender, frameMs, simMs, fpsSm, ambientT - variables: lastT, acc, lastRender, frameMs, simMs, fpsSm, ambientT
+24
View File
@@ -4,6 +4,30 @@ 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 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. open or not verified. Keep each entry short; the git history has the details.
## 2026-10-24: several worlds, with a Worlds list (Claude Code, with Codex)
Note: "support multiple saves". The owner chose a world list.
- `src/ui/saves.js`: each world has an id and its own `<id>:main` / `<id>:auto` saves, plus an
index record `worlds` (name, seed, size, created, saved, playtime). A save writes the world
and its index entry in one transaction. `GAME.worldId` is the open world.
- Migration: an old single `main` / `auto` save is copied to "World 1", every byte compared,
and only then are the old keys deleted (in the same transaction that writes the index). If
anything fails the old save is kept and a toast says so.
- Title screen: Continue loads the most recently saved world; a Worlds button opens a list
with Load, Rename (trimmed, at most 40 characters, escaped when shown) and Delete (with a
confirmation). New and imported worlds get new ids. Settings' "Delete saves" became "Delete
all worlds" with a confirmation. `shell.html`, `session.js`, `loop.js`, `settings.js`,
`state.js`, `style.css`, `docs/DESIGN.md`, `docs/PROJECT_MAP.md`.
- `test/play.py` (149 checks): migration of an old save, two worlds listed, load switches,
rename survives a reload, HTML in a name shows as text, long names cut to 40, delete and
cancel, Continue picks the newest, import adds a world. All 11 headless suites pass;
`t_smooth.py` passed in Codex's run and failed its known flaky check in mine.
- Shots: `shots/worlds-{pixel,smooth}.png` and `-900` versions.
- Not committed: a line in `AGENTS.md` about running `kandown work` appeared in the working
tree during this task; it was not part of the task and is left for the owner.
- Open: nothing.
## 2026-10-24: screw tubes carry bars (Claude Code, with Codex) ## 2026-10-24: screw tubes carry bars (Claude Code, with Codex)
Note: "how do you transport bars? belts work but tubes do not, should they?" The owner chose Note: "how do you transport bars? belts work but tubes do not, should they?" The owner chose
+8
View File
@@ -47,6 +47,7 @@
<p class="tagline">Dig. Smelt. Cast your own tools. Every grain is simulated, and nothing turns into anything unless the physics says so.</p> <p class="tagline">Dig. Smelt. Cast your own tools. Every grain is simulated, and nothing turns into anything unless the physics says so.</p>
<div class="tmenu" id="tmenu"> <div class="tmenu" id="tmenu">
<button class="btn primary" id="btn-continue" hidden>Continue <small id="continue-meta"></small></button> <button class="btn primary" id="btn-continue" hidden>Continue <small id="continue-meta"></small></button>
<button class="btn" id="btn-worlds" hidden>Worlds <small>load · rename · delete</small></button>
<button class="btn" id="btn-new">New world <small>generate</small></button> <button class="btn" id="btn-new">New world <small>generate</small></button>
<div class="plate newworld" id="newworld" hidden> <div class="plate newworld" id="newworld" hidden>
<label>Seed <input id="nw-seed" type="text" inputmode="numeric" autocomplete="off"></label> <label>Seed <input id="nw-seed" type="text" inputmode="numeric" autocomplete="off"></label>
@@ -101,6 +102,13 @@
</div> </div>
</div> </div>
<div id="m-worlds" class="modal" hidden>
<div class="plate">
<div class="mhead"><h2>Worlds</h2><button class="iconbtn" data-close aria-label="Close worlds">Close <kbd>Esc</kbd></button></div>
<div class="mbody" id="worlds-list"></div>
</div>
</div>
<div id="m-dialog" class="modal small" hidden> <div id="m-dialog" class="modal small" hidden>
<div class="plate"> <div class="plate">
<div class="mhead"><h2 id="dlg-title"></h2></div> <div class="mhead"><h2 id="dlg-title"></h2></div>
+10
View File
@@ -347,3 +347,13 @@ input.text { background: var(--soot-0); border: 1px solid var(--soot-4); border-
#baglist .mixsw .sw { width: 7px; height: 14px; border-radius: 1px; } #baglist .mixsw .sw { width: 7px; height: 14px; border-radius: 1px; }
@media (max-width: 1100px) { #hud.building .br { bottom: calc(160px * var(--ui)); } } @media (max-width: 1100px) { #hud.building .br { bottom: calc(160px * var(--ui)); } }
/* saved worlds */
.world-row { padding: 12px 0; border-bottom: 1px solid var(--soot-3); }
.world-row:last-child { border-bottom: none; }
.world-info { overflow-wrap: anywhere; }
.world-info .note { margin: 4px 0; }
.world-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.world-actions input { flex: 1; min-width: 120px; max-width: 100%; }
#m-dialog { z-index: 21; }
#continue-meta { max-width: 72%; overflow-wrap: anywhere; text-align: right; }
+4 -2
View File
@@ -108,9 +108,11 @@ function boot(hotData) {
$('btn-title-help').onclick = () => { auInit(); playSfx('ui'); openNotebook('Controls'); }; $('btn-title-help').onclick = () => { auInit(); playSfx('ui'); openNotebook('Controls'); };
const touchOnly = matchMedia('(pointer: coarse)').matches && !matchMedia('(any-pointer: fine)').matches; const touchOnly = matchMedia('(pointer: coarse)').matches && !matchMedia('(any-pointer: fine)').matches;
$('touchnote').hidden = !touchOnly; $('touchnote').hidden = !touchOnly;
refreshContinue().then(async () => { $('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) startFromSave(sv); } if (hotData && hotData.resume) { const sv = await newestSave(); if (sv) startFromSave(sv); }
}); }).catch(() => { $('title').hidden = false; if (!TITLE.raf) drawTitle(); });
requestAnimationFrame(frame); requestAnimationFrame(frame);
} }
try { if (window.claude && window.claude.hot && window.claude.hot.snapshot) window.claude.hot.snapshot(() => { if (GAME.running) saveWorld('auto', true); return { resume: GAME.running }; }); } catch (e) { /* not in a viewer */ } try { if (window.claude && window.claude.hot && window.claude.hot.snapshot) window.claude.hot.snapshot(() => { if (GAME.running) saveWorld('auto', true); return { resume: GAME.running }; }); } catch (e) { /* not in a viewer */ }
+80 -9
View File
@@ -15,16 +15,88 @@ const DB = {
}, },
async op(mode, fn) { async op(mode, fn) {
const db = await this.open(); const db = await this.open();
return new Promise((res, rej) => { const tx = db.transaction('saves', mode); const st = tx.objectStore('saves'); const rq = fn(st); rq.onsuccess = () => res(rq.result); rq.onerror = () => rej(rq.error); }); 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)); }, put(k, v) { return this.op('readwrite', s => s.put(v, k)); },
get(k) { return this.op('readonly', s => s.get(k)); }, get(k) { return this.op('readonly', s => s.get(k)); },
del(k) { return this.op('readwrite', s => s.delete(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) {
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, worldId: id, worldName: entry ? entry.name : 'World' };
}
function deleteWorld(id) {
return worldWrite(async () => {
const list = (await listWorlds()).filter(w => w.id !== id);
await DB.op('readwrite', st => { st.delete(`${id}:main`); st.delete(`${id}:auto`); st.put(list, 'worlds'); });
if (GAME.worldId === id) { GAME.worldId = ''; GAME.worldName = ''; }
});
}
function renameWorld(id, name) {
return worldWrite(async () => {
const list = await listWorlds(), entry = list.find(w => w.id === id);
if (!entry) return;
entry.name = String(name).trim().slice(0, 40) || 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.');
for (const k in old.buffers) {
const x = old.buffers[k], y = copy.buffers[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; }
}
async function saveWorld(slot, quiet) { async function saveWorld(slot, quiet) {
try { try {
const s = serializeWorld(); const s = serializeWorld(), savedAt = Date.now(), playTime = GAME.playTime;
await DB.put(slot, { meta: s.meta, buffers: s.buffers, 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;
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'); });
});
if (!quiet) toast(slot === 'auto' ? 'Autosaved.' : 'World saved.'); if (!quiet) toast(slot === 'auto' ? 'Autosaved.' : 'World saved.');
return true; return true;
} catch (e) { } catch (e) {
@@ -33,11 +105,8 @@ async function saveWorld(slot, quiet) {
} }
} }
async function newestSave() { async function newestSave() {
try { try { for (const entry of await listWorlds()) { const sv = await worldNewest(entry.id); if (sv) return sv; } return null; }
const a = await DB.get('main'), b = await DB.get('auto'); catch (e) { return null; }
if (a && b) return a.savedAt >= b.savedAt ? a : b;
return a || b || 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 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; } 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; }
@@ -84,7 +153,9 @@ async function importWorldFile(file) {
const C = T[b.type] || Uint8Array; const C = T[b.type] || Uint8Array;
bufs[k] = new C(bytes.buffer, bytes.byteOffset, bytes.byteLength / C.BYTES_PER_ELEMENT); bufs[k] = new C(bytes.buffer, bytes.byteOffset, bytes.byteLength / C.BYTES_PER_ELEMENT);
} }
startFromSave({ meta: obj.meta, buffers: bufs }); const id = newWorldId(), name = `Imported ${obj.meta.seed}`.slice(0, 40);
if (!startFromSave({ meta: obj.meta, buffers: bufs, worldId: id, worldName: name })) return;
if (!await saveWorld('auto')) return;
toast('World imported.'); toast('World imported.');
} catch (e) { toast('That file could not be loaded: ' + (e.message || 'unknown error')); } } catch (e) { toast('That file could not be loaded: ' + (e.message || 'unknown error')); }
} }
+47 -12
View File
@@ -15,13 +15,18 @@ function enterGame() {
function startNewWorld(seed, w, h) { function startNewWorld(seed, w, h) {
auInit(); auInit();
showLoading(true, 0); showLoading(true, 0);
setTimeout(() => { setTimeout(async () => {
generateWorld(seed, w, h, null); try {
GAME.playTime = 0; GAME.clip = null; GAME.paste = false; GAME.selDrag = null; GAME.tool = 0; GAME.dropMat = 0; GAME.matChosen = false; GAME.wallMat = RSTONE; GAME.filterCfg = { m: [], inv: 0 }; GAME.pieceIdx = 0; const worlds = await listWorlds();
showLoading(false); GAME.worldId = newWorldId(); GAME.worldName = `World ${worlds.length + 1}`;
enterGame(); await putWorldEntry({ id: GAME.worldId, name: GAME.worldName, seed, w, h, createdAt: Date.now(), savedAt: 0, playTime: 0 });
showCard({ kind: 'Welcome', title: 'The dig site', body: ['Fly with WASD. The Drill (1) breaks rock loose. The Vacuum (2) sucks up what you click on and blows it back out with right click.', '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 }); generateWorld(seed, w, h, null);
saveWorld('auto', true); GAME.playTime = 0; GAME.clip = null; GAME.paste = false; GAME.selDrag = null; GAME.tool = 0; GAME.dropMat = 0; GAME.matChosen = false; GAME.wallMat = RSTONE; GAME.filterCfg = { m: [], inv: 0 }; GAME.pieceIdx = 0;
showLoading(false);
enterGame();
showCard({ kind: 'Welcome', title: 'The dig site', body: ['Fly with WASD. The Drill (1) breaks rock loose. The Vacuum (2) sucks up what you click on and blows it back out with right click.', '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 });
await saveWorld('auto');
} catch (e) { showLoading(false); toast('Could not create a world. Browser storage is unavailable.'); }
}, 30); }, 30);
} }
function startFromSave(sv) { function startFromSave(sv) {
@@ -32,6 +37,7 @@ function startFromSave(sv) {
GAME.running = false; $('hud').hidden = true; $('title').hidden = false; if (!TITLE.raf) drawTitle(); GAME.running = false; $('hud').hidden = true; $('title').hidden = false; if (!TITLE.raf) drawTitle();
return false; return false;
} }
GAME.worldId = sv.worldId || ''; GAME.worldName = sv.worldName || '';
GAME.playTime = sv.playTime || 0; GAME.playTime = sv.playTime || 0;
GAME.drag = null; GAME.matChosen = false; GAME.dropMat = 0; GAME.drag = null; GAME.matChosen = false; GAME.dropMat = 0;
enterGame(); enterGame();
@@ -46,20 +52,21 @@ async function quitToTitle() {
} }
async function refreshContinue() { async function refreshContinue() {
const sv = await newestSave(); const sv = await newestSave();
$('btn-worlds').hidden = !(await listWorlds()).length;
const b = $('btn-continue'); const b = $('btn-continue');
if (sv && sv.meta && sv.meta.v === SAVE_VERSION) { if (sv && sv.meta && sv.meta.v === SAVE_VERSION) {
b.hidden = false; b.hidden = false;
const d = new Date(sv.savedAt); const d = new Date(sv.savedAt);
$('continue-meta').textContent = `seed ${sv.meta.seed} · ${d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}`; $('continue-meta').textContent = `${sv.worldName} · ${d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}`;
} else b.hidden = true; } else b.hidden = true;
} }
// ---------- Modals ---------- // ---------- Modals ----------
function anyModal() { return ['m-pause', 'm-settings', 'm-notebook', 'm-dialog'].some(id => !$(id).hidden); } function anyModal() { return ['m-pause', 'm-settings', 'm-notebook', 'm-worlds', 'm-dialog'].some(id => !$(id).hidden); }
function openModal(id) { $(id).hidden = false; GAME.paused = true; setTimeout(() => { const f = $(id).querySelector('button, select, input'); if (f && !GAME.usingPadMenu) { /* keep focus neutral for mouse users */ } }, 0); } function openModal(id) { $(id).hidden = false; GAME.paused = true; setTimeout(() => { const f = $(id).querySelector('button, select, input'); if (f && !GAME.usingPadMenu) { /* keep focus neutral for mouse users */ } }, 0); }
function closeModal(id) { $(id).hidden = true; if (id === 'm-dialog') GAME.editRun = null; if (!anyModal()) GAME.paused = false; } function closeModal(id) { $(id).hidden = true; if (id === 'm-dialog') GAME.editRun = null; if (!anyModal()) GAME.paused = false; }
function closeAllModals() { ['m-pause', 'm-settings', 'm-notebook', 'm-dialog'].forEach(id => $(id).hidden = true); GAME.paused = false; } function closeAllModals() { ['m-pause', 'm-settings', 'm-notebook', 'm-worlds', 'm-dialog'].forEach(id => $(id).hidden = true); GAME.paused = false; }
function topModal() { for (const id of ['m-dialog', 'm-settings', 'm-notebook', 'm-pause']) if (!$(id).hidden) return id; return null; } function topModal() { for (const id of ['m-dialog', 'm-worlds', 'm-settings', 'm-notebook', 'm-pause']) if (!$(id).hidden) return id; return null; }
function dialog(title, bodyHtml, buttons) { function dialog(title, bodyHtml, buttons) {
$('dlg-title').textContent = title; $('dlg-body').innerHTML = bodyHtml; $('dlg-title').textContent = title; $('dlg-body').innerHTML = bodyHtml;
const foot = $('dlg-foot'); foot.innerHTML = ''; const foot = $('dlg-foot'); foot.innerHTML = '';
@@ -82,6 +89,34 @@ $('pm-import').onclick = () => { playSfx('ui'); $('importfile').click(); };
$('importfile').onchange = e => { const f = e.target.files[0]; if (f) { closeAllModals(); importWorldFile(f); } e.target.value = ''; }; $('importfile').onchange = e => { const f = e.target.files[0]; if (f) { closeAllModals(); importWorldFile(f); } e.target.value = ''; };
$('pm-title').onclick = () => { playSfx('ui'); quitToTitle(); }; $('pm-title').onclick = () => { playSfx('ui'); quitToTitle(); };
function openPause() { function openPause() {
$('pause-meta').textContent = `seed ${worldSeed} · ${W}×${H}`; $('pause-meta').textContent = `${GAME.worldName} · seed ${worldSeed} · ${W}×${H}`;
openModal('m-pause'); openModal('m-pause');
} }
// ---------- World list ----------
async function renderWorlds(editId) {
const list = await listWorlds(), body = $('worlds-list'); body.innerHTML = '';
if (!list.length) body.innerHTML = '<p class="note">No worlds saved yet. Start a new world.</p>';
for (const w of list) {
const row = el('div', { class: 'world-row', 'data-world': w.id });
const mins = Math.floor((w.playTime || 0) / 60);
row.innerHTML = `<div class="world-info"><strong>${esc(w.name)}</strong><div class="note">seed ${esc(w.seed)} · ${esc(w.w)}×${esc(w.h)} grains · ${Math.floor(mins / 60)}:${String(mins % 60).padStart(2, '0')} played</div><div class="note">${w.savedAt ? esc(new Date(w.savedAt).toLocaleString()) : 'Not saved yet'}</div></div>`;
const actions = el('div', { class: 'world-actions' });
const button = (label, fn, cls = '') => { const b = el('button', { class: 'btn ' + cls }, label); b.onclick = async () => { try { await fn(); } catch (e) { toast('Could not reach browser storage.'); } }; actions.appendChild(b); };
if (editId === w.id) {
const input = el('input', { type: 'text', maxlength: 40, 'aria-label': 'World name' }); input.value = w.name; actions.appendChild(input);
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 && startFromSave(sv)) closeAllModals(); else if (!sv) toast('No saved world found.'); });
button('Rename', () => renderWorlds(w.id));
button('Delete', () => dialog('Delete world?', `<p class="note">Delete ${esc(w.name)} and its autosave? Exported world files are not affected.</p>`, [
{ label: 'Cancel', cls: 'ghost' },
{ label: 'Delete', cls: 'danger', fn: async () => { try { await deleteWorld(w.id); closeModal('m-dialog'); await renderWorlds(); await refreshContinue(); } catch (e) { toast('Could not delete the world.'); } } },
]), 'danger');
}
row.appendChild(actions); body.appendChild(row);
}
if (editId) { const input = body.querySelector('input'); if (input) { input.focus(); input.select(); } }
}
$('btn-worlds').onclick = async () => { try { await renderWorlds(); openModal('m-worlds'); } catch (e) { toast('Could not reach browser storage.'); } };
+9 -3
View File
@@ -49,7 +49,7 @@ const SET_TABS = {
{ k: 'save', label: 'Save', d: 'Saves to this browser.', type: 'button', text: 'Save now', fn: () => saveWorld('main'), ingame: true }, { k: 'save', label: 'Save', d: 'Saves to this browser.', type: 'button', text: 'Save now', fn: () => saveWorld('main'), ingame: true },
{ k: 'export', label: 'World file', d: 'Download the world as a file you can keep or move to another browser.', type: 'button', text: 'Export', fn: exportWorld, ingame: true }, { k: 'export', label: 'World file', d: 'Download the world as a file you can keep or move to another browser.', type: 'button', text: 'Export', fn: exportWorld, ingame: true },
{ k: 'import', label: 'Load a world file', type: 'button', text: 'Import', fn: () => $('importfile').click() }, { k: 'import', label: 'Load a world file', type: 'button', text: 'Import', fn: () => $('importfile').click() },
{ k: 'wipe', label: 'Delete saved worlds', d: 'Removes the saves stored in this browser.', type: 'button', text: 'Delete…', danger: true, fn: confirmWipe }, { k: 'wipe', label: 'Delete all worlds', d: 'Removes the saves stored in this browser.', type: 'button', text: 'Delete all worlds', danger: true, fn: confirmWipe },
], ],
}; };
let setTab = 'Display'; let setTab = 'Display';
@@ -109,8 +109,14 @@ function toggleFullscreen() {
} catch (e) { toast('Fullscreen is not available in this view.'); } } catch (e) { toast('Fullscreen is not available in this view.'); }
} }
function confirmWipe() { function confirmWipe() {
dialog('Delete saved worlds?', '<p class="note">This removes the main save and the autosave from this browser. Exported world files are not affected.</p>', [ dialog('Delete all worlds?', '<p class="note">This removes every world and its autosave from this browser. Exported world files are not affected.</p>', [
{ label: 'Cancel', cls: 'ghost' }, { label: 'Cancel', cls: 'ghost' },
{ label: 'Delete saves', cls: 'danger', fn: async () => { try { await DB.del('main'); await DB.del('auto'); toast('Saves deleted.'); } catch (e) { toast('Could not reach browser storage.'); } refreshContinue(); } }, { label: 'Delete all worlds', cls: 'danger', fn: async () => {
try {
await worldWrite(() => DB.op('readwrite', st => st.clear()));
GAME.worldId = ''; GAME.worldName = '';
closeModal('m-dialog'); toast('All worlds deleted.'); await refreshContinue();
} catch (e) { toast('Could not reach browser storage.'); }
} },
]); ]);
} }
+1 -1
View File
@@ -42,7 +42,7 @@ const GAME = {
brush: [3, 2, 0, 0, 0], collect: loadCollect(), tipsOpen: new Set(), cards: [], vacFilter: 0, vacLocked: false, rmbConsumed: false, rmbSeen: new Set(), lookHold: { x: 0, y: 0 }, brush: [3, 2, 0, 0, 0], collect: loadCollect(), tipsOpen: new Set(), cards: [], vacFilter: 0, vacLocked: false, 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 }, 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, blowMix: false, drillW: 1, drillTune: 0, lastDug: 0, padRun: 0, status: '', statusKind: 'err', statusT: 0, usingPad: false, padAim: { x: 12, y: 0 }, lmb: false, rmb: false, blowMix: false, drillW: 1, drillTune: 0, lastDug: 0, padRun: 0, status: '', statusKind: 'err', statusT: 0, usingPad: false, padAim: { x: 12, y: 0 },
lastAutosave: 0, playTime: 0, lastHud: 0, worldName: '', lastAutosave: 0, playTime: 0, lastHud: 0, worldId: '', worldName: '',
}; };
const keys = new Set(); const keys = new Set();
let hudDirty = true; let hudDirty = true;
+81 -1
View File
@@ -21,7 +21,77 @@ with sync_playwright() as p:
pg.on('pageerror', lambda e: logs.append(f'PAGEERROR: {e}')) pg.on('pageerror', lambda e: logs.append(f'PAGEERROR: {e}'))
pg.on('console', lambda m: logs.append(f'{m.type}: {m.text}') if m.type == 'error' else None) pg.on('console', lambda m: logs.append(f'{m.type}: {m.text}') if m.type == 'error' else None)
pg.goto(url); pg.wait_for_timeout(300) pg.goto(url); pg.wait_for_timeout(300)
pg.click('#btn-new'); pg.fill('#nw-seed', '777'); pg.click('#btn-generate') check('storage transactions roll back when a write throws', pg.evaluate("""async () => {
try { await DB.op('readwrite', st => { st.put('partial', 'abort-test'); throw new Error('test abort'); }); }
catch (e) { return await DB.get('abort-test') === undefined; }
return false;
}"""))
# 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 }); }")
check('a failed migration leaves the legacy save intact', pg.evaluate("""async () => {
const get = DB.get;
DB.get = async function (k) { const v = await get.call(this, k); return k.endsWith(':main') && v ? { ...v, savedAt: -1 } : v; };
try { await migrateWorlds(); return false; }
catch (e) { return !!await get.call(DB, 'main') && await get.call(DB, 'worlds') === undefined; }
finally { DB.get = get; }
}"""))
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 loads the migrated seed and playtime', pg.evaluate('worldSeed === 12345 && GAME.playTime >= 3661 && !!GAME.worldId'))
pg.evaluate('quitToTitle()')
for seed in [222, 333]:
if not pg.is_visible('#nw-seed'): pg.click('#btn-new')
pg.fill('#nw-seed', str(seed)); pg.click('#btn-generate')
pg.wait_for_function('GAME.running', timeout=20000)
pg.evaluate('quitToTitle()')
pg.wait_for_function("!$('title').hidden")
pg.click('#btn-worlds'); pg.locator('#m-worlds').wait_for(state='visible')
check('three independent worlds appear with their seeds', pg.locator('.world-row').count() == 3 and all(str(seed) in pg.inner_text('#worlds-list') for seed in [12345, 222, 333]))
# Both looks, at both requested sizes; title UI shares the same styling.
for look_shot in ['pixel', 'smooth']:
pg.evaluate(f"SET.look = '{look_shot}'")
for width, height, suffix in [(1280, 800, ''), (900, 700, '-900')]:
pg.set_viewport_size({'width': width, 'height': height})
pg.screenshot(path=str(root / 'shots' / f'worlds-{look_shot}{suffix}.png'))
pg.set_viewport_size({'width': 1280, 'height': 800}); pg.evaluate("SET.look = 'pixel'")
ids = pg.evaluate("async () => Object.fromEntries((await listWorlds()).map(w => [w.seed, w.id]))")
for seed in [222, 333]:
row = pg.locator(f'[data-world="{ids[str(seed)]}"]')
row.get_by_role('button', name='Load', exact=True).click(); pg.wait_for_function('GAME.running')
check(f'Load switches to seed {seed}', pg.evaluate('worldSeed') == seed and pg.evaluate('GAME.worldId') == ids[str(seed)])
pg.evaluate('quitToTitle()'); pg.click('#btn-worlds')
row = pg.locator(f'[data-world="{ids["222"]}"]')
row.get_by_role('button', name='Rename', exact=True).click()
row.locator('input').fill('<b>x</b>'); row.get_by_role('button', name='Save', exact=True).click()
pg.wait_for_function("document.querySelector('.world-row strong') && [...document.querySelectorAll('.world-row strong')].some(e => e.textContent === '<b>x</b>')")
check('HTML in world names is text', row.locator('strong').inner_text() == '<b>x</b>' and row.locator('strong b').count() == 0)
pg.reload(); pg.wait_for_function("!$('title').hidden"); pg.click('#btn-worlds')
check('rename persists after reload', row.locator('strong').inner_text() == '<b>x</b>')
check('blank renames keep the old name', pg.evaluate("async id => { await renameWorld(id, ' '); return (await listWorlds()).find(w => w.id === id).name === '<b>x</b>'; }", ids['222']))
row.get_by_role('button', name='Rename', exact=True).click()
row.locator('input').evaluate("e => e.value = 'x'.repeat(60)")
row.get_by_role('button', name='Save', exact=True).click()
pg.wait_for_function("[...document.querySelectorAll('.world-row strong')].some(e => e.textContent === 'x'.repeat(40))")
check('rename boundary cuts names to 40 characters', row.locator('strong').inner_text() == 'x' * 40)
row.get_by_role('button', name='Delete', exact=True).click()
pg.locator('#dlg-foot').get_by_role('button', name='Cancel', exact=True).click()
check('cancelling deletion keeps the world', row.count() == 1)
row.get_by_role('button', name='Delete', exact=True).click()
pg.locator('#dlg-foot').get_by_role('button', name='Delete', exact=True).click()
pg.wait_for_function("document.querySelectorAll('.world-row').length === 2")
check('confirmed deletion removes both saves and the entry', pg.evaluate("async id => !(await listWorlds()).some(w => w.id === id) && !await DB.get(id + ':main') && !await DB.get(id + ':auto')", ids['222']))
check('each world chooses its newer autosave without changing another', pg.evaluate("""async id => {
const main = await DB.get(id + ':main');
await DB.put(id + ':auto', { ...main, savedAt: main.savedAt + 1, playTime: 7777 });
const sv = await worldNewest(id); return sv.playTime === 7777 && sv.worldId === id;
}""", ids['333']))
pg.keyboard.press('Escape'); pg.click('#btn-continue'); pg.wait_for_function('GAME.running')
check('Continue chooses the most recently saved world', pg.evaluate('worldSeed') == 333)
pg.evaluate('quitToTitle()')
if not pg.is_visible('#nw-seed'): pg.click('#btn-new')
pg.fill('#nw-seed', '777'); pg.click('#btn-generate')
pg.wait_for_function('GAME.running === true', timeout=20000) pg.wait_for_function('GAME.running === true', timeout=20000)
pg.wait_for_timeout(500) pg.wait_for_timeout(500)
ev = pg.evaluate ev = pg.evaluate
@@ -542,10 +612,13 @@ with sync_playwright() as p:
pg3 = ctx.new_page() pg3 = ctx.new_page()
pg3.on('pageerror', lambda e: logs.append(f'PAGEERROR(import): {e}')) pg3.on('pageerror', lambda e: logs.append(f'PAGEERROR(import): {e}'))
pg3.goto(url); pg3.wait_for_timeout(300) pg3.goto(url); pg3.wait_for_timeout(300)
worlds_before_import = ev('listWorlds().then(w => w.length)')
pg3.set_input_files('#importfile', dl.path()) pg3.set_input_files('#importfile', dl.path())
pg3.wait_for_function('GAME.running === true', timeout=60000) pg3.wait_for_function('GAME.running === true', timeout=60000)
got = pg3.evaluate('JSON.stringify([W, H, Math.round(money), Math.round(debt), bagTotal])') 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}') 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))
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)
pg3.close() pg3.close()
pg.keyboard.press('Escape'); settle(150) pg.keyboard.press('Escape'); settle(150)
@@ -562,6 +635,13 @@ with sync_playwright() as p:
pg2.close() pg2.close()
print('\n'.join(logs) or 'no console errors') print('\n'.join(logs) or 'no console errors')
check('every status line is short (≤ 90 characters)', len(ev('_maxStatus')) <= 90, ev('_maxStatus')) check('every status line is short (≤ 90 characters)', len(ev('_maxStatus')) <= 90, ev('_maxStatus'))
ev('quitToTitle()'); ev("openSettings('Game')")
before_wipe = ev('listWorlds().then(w => w.length)')
pg.click('#set-wipe'); pg.locator('#dlg-foot').get_by_role('button', name='Cancel', exact=True).click()
check('cancelling Delete all worlds keeps every world', ev('listWorlds().then(w => w.length)') == before_wipe)
pg.click('#set-wipe'); pg.locator('#dlg-foot').get_by_role('button', name='Delete all worlds', exact=True).click()
pg.wait_for_function("async () => await DB.get('worlds') === undefined && $('btn-continue').hidden && $('btn-worlds').hidden")
check('Delete all worlds clears the saves and index', ev("DB.op('readonly', st => st.getAllKeys()).then(keys => keys.length === 0)"))
print('FAILS:', fails) print('FAILS:', fails)
b.close() b.close()
sys.exit(1 if fails else 0) sys.exit(1 if fails else 0)