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:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user