diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 2e43a65..63bb0a0 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -232,6 +232,22 @@ above, including: Items piling up under a block stay a thing to build around (a belt or an open drop), and the Saw's text says so. +- **A world that lasts: deep ore, rock that pays, Huge worlds, new claims (t3, 2026-10-24).** + The owner found worlds small and quickly mined out, so the only way on was a new game. Keep the + bot and drill at today's pace and add more to find instead: + - Deeper is richer: ore gets more common the deeper you dig, about 4.7x the ore in a Medium + world (coal and copper ~5x, iron, zinc and tin ~4.5-5x, silver and gold ~3.4x, kept lower for + their price). The top quarter of the underground is unchanged; copper also forms in deep stone + and granite, coal in basalt, tin deeper. Lenses grow from lower noise thresholds only: + stretching the noise with depth sheared it into diagonal streaks. + - Waste rock pays: grinding granite and basalt finds about twice the ore, and builders order + granite and basalt rubble in bulk. + - A Huge size (6144 x 2048); saves are gzip-compressed (a Medium save went from 29 MB to 0.4 MB). + - New claims: once a claim is paid off, open another for $5,000 down and twice the last debt + ($40,000, $80,000, ...). Money, bag, upgrades, discoveries, milestones and stats belong to the + career and come along; buildings stay on their claim and wait (they don't run) until you + travel back from the notebook's Claims page. + - **Clay bricks come from a Brick press (t28, 2026-10-24).** A hopper block like the Saw: every 8 clay lumps on top become a Green brick below, and a Green brick fires into a Brick above about 900°C (any fire). 2 Bricks build a brick wall block; builders order them. The owner found it odd diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index de81a41..314133c 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -376,21 +376,26 @@ The title screen art. - functions: buildTitleCells, drawTitle - variables: FONT5, TITLE -### src/ui/saves.js (201 lines) +### src/ui/saves.js (212 lines) Saves in IndexedDB, world files (export / import) and the blueprint library. - functions: newWorldId, worldWrite, listWorlds, putWorldEntry, worldNewest, deleteWorld, cleanWorldName, renameWorld, migrateWorlds, saveWorld, newestSave, b64FromBytes, bytesFromB64, gz, packBuffers, unpackBuffers, exportWorld, importWorldFile, libLoad, libSave, bpEncode, bpValid, bpDecode - variables: DB, worldIdTime, worldIdCounter, worldWrites -### src/ui/session.js (125 lines) +### src/ui/claims.js (115 lines) +Shared player state; the world save remains a complete standalone fallback. +- functions: careerState, validCareer, applyCareerState, loadCareer, openNewClaim, travel, renderClaimsTab +- variables: career, claimBusy + +### src/ui/session.js (133 lines) 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, renderWorlds +- functions: showLoading, enterGame, setupNewWorld, startNewWorld, startFromSave, quitToTitle, refreshContinue, anyModal, openModal, closeModal, closeAllModals, topModal, dialog, openPause, renderWorlds ### src/ui/settings.js (124 lines) The settings screen. - functions: openSettings, renderSettings, applySettings, toggleFullscreen, confirmWipe - variables: SET_TABS, setTab -### src/ui/notebook.js (202 lines) +### src/ui/notebook.js (204 lines) The notebook (N): milestones, market, wallet, blueprints, controls. - functions: openNotebook, renderNotebook, fmtClock, orderName, orderLeft, renderMarketTab, fmtPrice, itemsText, renderWallet, renderBlueprintTab, copyCode, controlsHtml - variables: nbTab, MARKET_GROUPS diff --git a/docs/agent-log.md b/docs/agent-log.md index 69adf22..5899f41 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -4,6 +4,24 @@ 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 open or not verified. Keep each entry short; the git history has the details. +## 2026-10-24: t3: a world that lasts (Claude Code, Codex) + +- `26d17c2` deeper is richer (`worldgen.js` ore thresholds drop with depth below fy 0.25). + Medium seed 424242 ore grains before -> after: coal 33,493 -> 170,437; copper 26,508 -> + 127,298; iron 77,679 -> 361,683; zinc 11,797 -> 53,527; tin 7,723 -> 38,551; silver 3,638 -> + 12,166; gold 1,681 -> 5,858; total 162,519 -> 769,520 (4.73x). Shallow ore unchanged. Codex's + first try stretched noise by depth (streaks) and gave gold 26x; redone. +- `4a88254` waste rock: `REFINE` granite/basalt about 2x; granite and basalt bulk orders; prices. +- `56c93ae` Huge 6144x2048 and gzip-packed save buffers (`packBuffers`/`unpackBuffers`, old raw + saves still load). Medium 29.0 MB -> 0.44 MB, Huge 92.8 MB -> 1.36 MB; Huge generation 5.9 s, + save 0.5 s, 0.39 ms per idle tick. +- New claims: `src/ui/claims.js` (career record `career:` in IndexedDB, applied over each + loaded claim; `openNewClaim`, `travel`, Claims tab), `session.js` `setupNewWorld`, `saves.js`. + I switched claims.js to the repo's single quotes. Screenshot `shots/t3-claims.png`. +- Tests: play.py 268 checks and all headless suites pass. t_smooth's half-resolution check + fails under load on this machine; the build before t3 failed it 3 of 3 times under the same + load, so it isn't caused by this work. + ## 2026-10-24: t4 follow-ups and t28 Brick press (Claude Code, Codex) - `9739ca0`: Refiner and Stone cutter code removed; `save.js` skips unknown machine types on load diff --git a/src/manifest.json b/src/manifest.json index 0ea648f..c1fda92 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -46,6 +46,7 @@ "ui/state.js", "ui/title.js", "ui/saves.js", + "ui/claims.js", "ui/session.js", "ui/settings.js", "ui/notebook.js", diff --git a/src/sim/progress.js b/src/sim/progress.js index b9e292a..19dff3a 100644 --- a/src/sim/progress.js +++ b/src/sim/progress.js @@ -41,7 +41,7 @@ const MILESTONES = [ { id: 'order', title: 'First order filled', msg: 'Bigger buyers notice you as you fill more orders.', hint: 'Buyers post orders on the Market page: an amount of one good by a deadline, at well over market price. Goods dropped into the dock fill an open order first.' }, { id: 'alloy', title: 'An alloy', msg: 'Alloys sell for far more than the metals that went into them.', hint: 'Tin ore lies a little deeper than copper, zinc deeper again. Melt tin into a pool of molten copper and it mixes into bronze; zinc makes brass.' }, { id: 'precious', title: 'Precious metal', hint: 'Silver lies deep in the granite, gold deeper still. Their ore sells high even raw, and much higher as bars.' }, - { id: 'claim', title: 'Pay off the claim', msg: 'The land is yours.', hint: 'You owe the claim office $20,000 for this land. Pay it down from the Market page of your notebook.', reward: 'The land is yours.' }, + { id: 'claim', title: 'Pay off the claim', msg: 'The land is yours. Open a new claim from the notebook’s Claims page.', hint: 'Pay down this land from the Market page of your notebook. Once paid off, open a new claim from the Claims page.', reward: 'The land is yours.' }, { id: 'rich1', title: 'Prosperous foundry', hint: 'Earn $50,000 in total.' }, { id: 'rich2', title: 'Mining magnate', hint: 'Earn $200,000 in total.' }, { id: 'deep', title: 'The magma sea', msg: 'Magma is a heat source that never runs out.', hint: 'Dig down through granite and basalt to the molten layer at the bottom of the world.' }, diff --git a/src/ui/claims.js b/src/ui/claims.js new file mode 100644 index 0000000..1411272 --- /dev/null +++ b/src/ui/claims.js @@ -0,0 +1,115 @@ +// Shared player state; the world save remains a complete standalone fallback. +let career = null, claimBusy = false; +function careerState() { + const carriedBag = Array.from(bag); + for (const f of flyers) if (f.bag) carriedBag[f.m]++; + return { money, earned, bag: carriedBag, player: { ...player }, seen: Array.from(seen), ledger: { ...ledger }, stats: { ...stats } }; +} +function validCareer(c, id, worldId) { + const obj = v => v && typeof v === 'object' && !Array.isArray(v); + const count = v => Number.isFinite(v) && v >= 0; + const counts = v => obj(v) && Object.values(v).every(count); + return obj(c) && c.id === id && Array.isArray(c.claims) && c.claims.length > 0 && + c.claims.every(v => typeof v === 'string' && v.length > 0) && new Set(c.claims).size === c.claims.length && c.claims.includes(worldId) && + Number.isSafeInteger(c.claimNo) && c.claimNo >= c.claims.length && count(c.money) && count(c.earned) && + Array.isArray(c.bag) && c.bag.length > 0 && c.bag.every(v => Number.isInteger(v) && v >= 0 && v <= 2147483647) && + Array.isArray(c.seen) && c.seen.length > 0 && c.seen.every(v => v === 0 || v === 1) && + obj(c.player) && Number.isInteger(c.player.bitTier) && !!BIT_TIERS[c.player.bitTier] && + Number.isInteger(c.player.tankTier) && !!TANK_TIERS[c.player.tankTier] && + typeof c.player.lens === 'boolean' && typeof c.player.flask === 'boolean' && + counts(c.ledger) && counts(c.stats) && ['dug', 'moved', 'placed', 'ignitions', 'cast', 'bars'].every(k => count(c.stats[k])); +} +function applyCareerState(c) { + money = c.money; earned = c.earned; + bag = new Int32Array(NMAT); bag.set(c.bag.slice(0, NMAT)); + bagTotal = bag.reduce((n, v) => n + v, 0); + // These grains are already included in the shared bag, even on an older claim. + flyers = flyers.filter(f => !f.bag); + for (const k of Object.keys(player)) player[k] = c.player[k]; + seen = new Uint8Array(NMAT); seen.set(c.seen.slice(0, NMAT)); + ledger = { ...c.ledger }; + for (const k of Object.keys(stats)) delete stats[k]; + Object.assign(stats, c.stats); +} +async function loadCareer() { + career = null; GAME.careerId = ''; GAME.claimNo = 1; + try { + if (!GAME.worldId) GAME.worldId = newWorldId(); + const entry = (await listWorlds()).find(w => w.id === GAME.worldId); + const c = entry && entry.career ? await DB.get('career:' + entry.career) : null; + if (entry && validCareer(c, entry.career, GAME.worldId)) { + career = c; applyCareerState(c); GAME.claimNo = Number.isSafeInteger(entry.claimNo) && entry.claimNo > 0 ? entry.claimNo : 1; + } else { + // Preserve surviving claim membership when repairing a missing or damaged record. + const claims = entry && entry.career ? (await listWorlds()).filter(w => w.career === entry.career) : []; + career = { id: entry && entry.career || newWorldId(), claims: claims.length ? claims.map(w => w.id) : [GAME.worldId], + claimNo: Math.max(1, ...claims.map(w => Number.isSafeInteger(w.claimNo) && w.claimNo > 0 ? w.claimNo : 1)), ...careerState() }; + GAME.claimNo = entry && Number.isSafeInteger(entry.claimNo) && entry.claimNo > 0 ? entry.claimNo : 1; + await worldWrite(async () => { + const list = await listWorlds(); + let w = list.find(w => w.id === GAME.worldId); + if (!w) { w = { id: GAME.worldId, name: GAME.worldName || 'World', seed: worldSeed, w: W, h: H, savedAt: 0 }; list.push(w); } + Object.assign(w, { career: career.id, claimNo: GAME.claimNo, debt }); + await DB.op('readwrite', st => { st.put(career, 'career:' + career.id); st.put(list, 'worlds'); }); + }); + } + GAME.careerId = career.id; + } catch (e) { career = null; toast('Career could not be loaded. Using this world’s own money and bag.'); } +} +async function openNewClaim() { + if (claimBusy || !career || debt > 0 || money < 5000) return false; + claimBusy = true; const paused = GAME.paused; GAME.paused = true; + let previous; + try { + if (!await saveWorld('main', true)) throw new Error('Save failed'); + previous = { save: serializeWorld(), id: GAME.worldId, name: GAME.worldName, time: GAME.playTime, career, claimNo: GAME.claimNo }; + const carried = careerState(), n = career.claimNo + 1, w = W, h = H; + carried.money -= 5000; + let seed; do { seed = Math.floor(Math.random() * 900000) + 100000; } while (seed === worldSeed); + await setupNewWorld(seed, w, h, `Claim ${n}`, carried); + debt = CLAIM_DEBT * 2 ** (n - 1); + career = { ...career, claims: [...career.claims, GAME.worldId], claimNo: n }; + GAME.claimNo = n; + if (!await saveWorld('auto', true)) throw new Error('Save failed'); + showLoading(false); closeAllModals(); enterGame(); + showCard({ kind: 'New claim', title: `Claim ${n}`, body: [`You owe ${fmtMoney(debt)} on this claim. Your money, bag, upgrades and what you’ve learned came along. Buildings stayed on the old claim.`], hold: 1.8 }); + return true; + } catch (e) { + if (previous) { + deserializeWorld(previous.save.meta, previous.save.buffers); + GAME.worldId = previous.id; GAME.worldName = previous.name; GAME.playTime = previous.time; + career = previous.career; GAME.claimNo = previous.claimNo; + } + showLoading(false); GAME.paused = paused; toast('Could not open a claim. Your old claim has been kept.'); return false; + } finally { claimBusy = false; } +} +async function travel(worldId) { + if (claimBusy || !career || worldId === GAME.worldId || !career.claims.includes(worldId)) return false; + claimBusy = true; const paused = GAME.paused; let arrived = false; GAME.paused = true; + try { + const entry = (await listWorlds()).find(w => w.id === worldId); + if (!entry || entry.career !== GAME.careerId) return false; + if (!await saveWorld('main', true)) throw new Error('Save failed'); + const sv = await worldNewest(worldId); + if (!sv || !await startFromSave(sv)) return false; + closeAllModals(); arrived = true; return true; + } catch (e) { toast('Could not travel. Check browser storage and try again.'); return false; } + finally { claimBusy = false; if (!arrived) GAME.paused = paused; } +} +async function renderClaimsTab(body) { + body.innerHTML = '

Loading claims…

'; + try { + const list = (await listWorlds()).filter(w => career && w.career === GAME.careerId && career.claims.includes(w.id)); + if (nbTab !== 'Claims') return; + body.innerHTML = list.map(w => `
${esc(w.name)}${w.id === GAME.worldId ? ' · here' : ''}
${esc(w.w)}×${esc(w.h)} grains · ${(w.id === GAME.worldId ? debt : w.debt) <= 0 ? 'paid off ✓' : fmtMoney(w.id === GAME.worldId ? debt : w.debt || 0) + ' owed'} · Last played: ${w.savedAt ? esc(new Date(w.savedAt).toLocaleString()) : 'Just opened'}
${w.id === GAME.worldId ? '' : ``}
`).join('') + + (debt <= 0 ? `${money < 5000 ? '

You need $5,000 for the down payment.

' : ''}` : '

A new claim opens once this one is paid off.

') + + '

Machines on other claims wait until you come back.

'; + const button = $('claim-open'); + if (button) button.onclick = () => dialog('Open a new claim?', `

Pay $5,000 down and owe ${fmtMoney(CLAIM_DEBT * 2 ** career.claimNo)} on fresh land. Your buildings stay here.

`, [ + { label: 'Cancel', cls: 'ghost' }, { label: 'Open claim', cls: 'primary', fn: () => { closeModal('m-dialog'); openNewClaim(); } }, + ]); + body.querySelectorAll('[data-travel]').forEach(b => b.onclick = () => dialog('Travel to this claim?', '

Your current claim will be saved. Money, bag, upgrades and knowledge come with you.

', [ + { label: 'Cancel', cls: 'ghost' }, { label: 'Travel', cls: 'primary', fn: () => { closeModal('m-dialog'); travel(b.dataset.travel); } }, + ])); + } catch (e) { if (nbTab === 'Claims') body.innerHTML = '

Could not load claims from browser storage.

'; } +} diff --git a/src/ui/loop.js b/src/ui/loop.js index b12c21e..fb90053 100644 --- a/src/ui/loop.js +++ b/src/ui/loop.js @@ -110,7 +110,7 @@ function boot(hotData) { const [w, h] = $('nw-size').value.split('x').map(Number); startNewWorld(seed, w, h, $('nw-name').value); }; - $('btn-continue').onclick = async () => { auInit(); playSfx('ui'); const sv = await newestSave(); if (sv) startFromSave(sv); else toast('No saved world found.'); }; + $('btn-continue').onclick = async () => { auInit(); playSfx('ui'); const sv = await newestSave(); if (sv) await startFromSave(sv); else toast('No saved world found.'); }; $('btn-title-settings').onclick = () => { auInit(); playSfx('ui'); openSettings('Display'); }; $('btn-title-help').onclick = () => { auInit(); playSfx('ui'); openNotebook('Controls'); }; const touchOnly = matchMedia('(pointer: coarse)').matches && !matchMedia('(any-pointer: fine)').matches; @@ -118,7 +118,7 @@ function boot(hotData) { $('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) await startFromSave(sv); } }).catch(() => { $('title').hidden = false; if (!TITLE.raf) drawTitle(); }); requestAnimationFrame(frame); } diff --git a/src/ui/notebook.js b/src/ui/notebook.js index 340957a..a5bfe45 100644 --- a/src/ui/notebook.js +++ b/src/ui/notebook.js @@ -4,7 +4,7 @@ let nbTab = 'Ledger'; function openNotebook(tab) { if (tab) nbTab = tab; renderNotebook(); openModal('m-notebook'); } function renderNotebook() { const tabs = $('nb-tabs'); tabs.innerHTML = ''; - for (const name of ['Ledger', 'Market', 'Materials', 'Pieces', 'Blueprints', 'Controls']) { + for (const name of ['Ledger', 'Market', 'Claims', 'Materials', 'Pieces', 'Blueprints', 'Controls']) { const b = el('button', { role: 'tab', class: name === nbTab ? 'on' : '', 'aria-selected': name === nbTab }, name); b.onclick = () => { playSfx('ui'); nbTab = name; renderNotebook(); }; tabs.appendChild(b); @@ -25,6 +25,8 @@ function renderNotebook() { body.querySelectorAll('[data-tip]').forEach(b => b.onclick = () => { playSfx('ui'); GAME.tipsOpen.add(b.dataset.tip); renderNotebook(); }); } else if (nbTab === 'Market') { renderMarketTab(body); + } else if (nbTab === 'Claims') { + renderClaimsTab(body); } else if (nbTab === 'Materials') { let rows = '', unknown = 0; const list = [DIRT, LDIRT, STONE, RSTONE, BARSB, PLANK, GREENB, BARBR, CLAY, LCLAY, WOOD, LWOOD, ACORN, CHARCOAL, SNOW, ICE, COALV, COAL, CUV, CUORE, FEV, FEORE, SAND, GRAVEL, GRANITE, RGRAN, BASALT, RBAS, diff --git a/src/ui/saves.js b/src/ui/saves.js index 626b70a..bf207a4 100644 --- a/src/ui/saves.js +++ b/src/ui/saves.js @@ -49,8 +49,19 @@ async function worldNewest(id) { } 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'); }); + 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 = ''; } }); } @@ -91,14 +102,14 @@ async function saveWorld(slot, quiet) { 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; - const buffers = await packBuffers(s.buffers); + 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 }); - await DB.op('readwrite', st => { st.put({ meta: s.meta, buffers, savedAt, playTime }, `${id}:${slot}`); st.put(list, 'worlds'); }); + Object.assign(entry, { savedAt, playTime, seed: s.meta.seed, w: s.meta.W, h: s.meta.H, debt: s.meta.economy.debt, ...(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; @@ -178,7 +189,7 @@ async function importWorldFile(file) { 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 (!startFromSave({ meta: obj.meta, buffers: bufs, worldId: id, worldName: name })) return; + if (!await startFromSave({ meta: obj.meta, buffers: bufs, worldId: id, worldName: name })) return; if (!await saveWorld('auto')) return; toast('World imported.'); } catch (e) { toast('That file could not be loaded: ' + (e.message || 'unknown error')); } diff --git a/src/ui/session.js b/src/ui/session.js index 1ea86ef..cfd246b 100644 --- a/src/ui/session.js +++ b/src/ui/session.js @@ -15,26 +15,33 @@ function enterGame() { GAME.drag = null; GAME.lmb = GAME.rmb = false; lastBagKey = ''; hudDirty = true; renderHotbar(); } -function startNewWorld(seed, w, h, name = '') { - auInit(); - showLoading(true, 0); - setTimeout(async () => { - try { - const worlds = await listWorlds(); - GAME.worldId = newWorldId(); GAME.worldName = cleanWorldName(name, `World ${worlds.length + 1}`); - await putWorldEntry({ id: GAME.worldId, name: GAME.worldName, seed, w, h, createdAt: Date.now(), savedAt: 0, playTime: 0 }); - generateWorld(seed, w, h, null); - GAME.playTime = 0; GAME.clip = null; GAME.paste = false; GAME.selDrag = null; GAME.tool = 0; GAME.blow.clear(); 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 Pneumatic nozzle (2) picks up and blows the selection. Click or Q/E in the bag selects; Ctrl+click mixes. Hold Ctrl to see everything found. Clear returns to picking what you click. Shift: everything.', '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); +async function setupNewWorld(seed, w, h, name, carried) { + auInit(); showLoading(true, 0); + await new Promise(resolve => setTimeout(resolve, 30)); + for (const k of Object.keys(stats)) stats[k] = 0; + generateWorld(seed, w, h, null); + GAME.worldId = newWorldId(); GAME.worldName = name; + GAME.playTime = 0; GAME.clip = null; GAME.paste = false; GAME.selDrag = null; GAME.tool = 0; GAME.blow.clear(); GAME.wallMat = RSTONE; GAME.filterCfg = { m: [], inv: 0 }; GAME.pieceIdx = 0; + if (carried) applyCareerState(carried); } -function startFromSave(sv) { +async function startNewWorld(seed, w, h, name = '') { + try { + const worlds = await listWorlds(); + await setupNewWorld(seed, w, h, cleanWorldName(name, `World ${worlds.length + 1}`)); + career = { id: newWorldId(), claims: [GAME.worldId], claimNo: 1 }; + GAME.careerId = career.id; GAME.claimNo = 1; + await saveWorld('auto'); + showLoading(false); closeAllModals(); enterGame(); + showCard({ kind: 'Welcome', title: 'The dig site', body: ['Fly with WASD. The Drill (1) breaks rock loose. The Pneumatic nozzle (2) picks up and blows the selection. Click or Q/E in the bag selects; Ctrl+click mixes. Hold Ctrl to see everything found. Clear returns to picking what you click. Shift: everything.', '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 }); + } catch (e) { showLoading(false); toast('Could not create a world. Browser storage is unavailable.'); } +} +async function startFromSave(sv) { auInit(); - try { deserializeWorld(sv.meta, sv.buffers); } + GAME.paused = true; + try { + for (const k of Object.keys(stats)) stats[k] = 0; + deserializeWorld(sv.meta, sv.buffers); + } catch (e) { toast(e && e.message ? e.message : 'That save could not be loaded.', { big: true, title: 'Save not loaded' }); GAME.running = false; $('hud').hidden = true; $('title').hidden = false; if (!TITLE.raf) drawTitle(); @@ -43,6 +50,7 @@ function startFromSave(sv) { GAME.worldId = sv.worldId || ''; GAME.worldName = sv.worldName || ''; GAME.playTime = sv.playTime || 0; GAME.drag = null; GAME.blow.clear(); + await loadCareer(); enterGame(); return true; } @@ -111,7 +119,7 @@ async function renderWorlds(editId) { 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('Load', async () => { const sv = await worldNewest(w.id); if (sv && await startFromSave(sv)) closeAllModals(); else if (!sv) toast('No saved world found.'); }); button('Rename', () => renderWorlds(w.id)); button('Delete', () => dialog('Delete world?', `

Delete ${esc(w.name)} and its autosave? Exported world files are not affected.

`, [ { label: 'Cancel', cls: 'ghost' }, diff --git a/src/ui/state.js b/src/ui/state.js index 9e858cf..e9d1400 100644 --- a/src/ui/state.js +++ b/src/ui/state.js @@ -42,7 +42,7 @@ const GAME = { brush: [3, 2, 0, 0, 0], tipsOpen: new Set(), cards: [], vacFilter: 0, 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 }, lmb: false, rmb: 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, worldId: '', worldName: '', + lastAutosave: 0, playTime: 0, lastHud: 0, worldId: '', worldName: '', careerId: '', claimNo: 1, }; const keys = new Set(); let hudDirty = true; diff --git a/test/play.py b/test/play.py index 201aceb..3a22964 100644 --- a/test/play.py +++ b/test/play.py @@ -814,10 +814,98 @@ with sync_playwright() as p: await DB.op('readwrite', st => st.put({...s, savedAt: Date.now() + 1000, playTime: 4567}, `${id}:main`)); const sv = await worldNewest(id); bagTotal = 0; worldSeed = 0; - return startFromSave(sv) && worldSeed === s.meta.seed && GAME.playTime === 4567 && + return await startFromSave(sv) && worldSeed === s.meta.seed && GAME.playTime === 4567 && bagTotal === s.meta.bag.reduce((n, v) => n + v, 0); }""")) + # Careers use their own browser storage so the play-through stays untouched. + career_play_paused = ev('GAME.paused'); ev('GAME.paused = true') + cc = b.new_context(viewport={'width': 1280, 'height': 800}) + cp = cc.new_page(); cp.on('pageerror', lambda e: logs.append(f'PAGEERROR: {e}')) + cp.goto(url); cp.wait_for_function("!$('title').hidden") + ev = cp.evaluate + ev("async () => { SET.fog = false; await startNewWorld(424242, 512, 384, 'First claim'); GAME.paused = true; }") + check('fresh world has a career with claim 1', ev("async () => { const c = await DB.get('career:' + GAME.careerId); return c.claimNo === 1 && c.claims.length === 1 && c.claims[0] === GAME.worldId && (await listWorlds())[0].claimNo === 1; }")) + check('unpaid claim cannot open another', ev('async () => !await openNewClaim() && career.claims.length === 1')) + ev("openNotebook('Claims')") + cp.wait_for_function("$('nb-body').textContent.includes('paid off.')") + check('Claims explains the unpaid requirement', ev("!$('claim-open') && $('nb-body').textContent.includes('Machines on other claims wait')")) + ev("debt = 0; money = 4999; renderNotebook()") + cp.wait_for_function("$('claim-open') && $('claim-open').disabled") + check('Claims disables opening without the down payment', ev("$('claim-open').disabled && $('nb-body').textContent.includes('You need $5,000')")) + check('insufficient funds cannot open another claim', ev('async () => !await openNewClaim() && money === 4999')) + ev("""() => { + money = 10000; earned = 12345; bag.fill(0); bag[RSTONE] = 91; bag[COAL] = 13; bagTotal = 104; + player.bitTier = 2; seen[GOLD] = 1; ledger.dig = 42; stats.dug = 321; + for (let y = 36; y < 40; y++) for (let x = 40; x < 44; x++) setCell(y * W + x, EMPTY, 20, 0); + undoRecord('Wall', () => { const err = placeBlock(BP_WALL, RSTONE, 10, 9); if (err) throw new Error(err); }); + window.claimTest = { id: GAME.worldId, seed: worldSeed, bag: Array.from(bag), career: GAME.careerId }; + renderNotebook(); + }""") + cp.wait_for_function("$('claim-open') && !$('claim-open').disabled") + cp.click('#claim-open'); cp.locator('#dlg-foot').get_by_role('button', name='Cancel', exact=True).click() + check('opening a claim asks for confirmation', ev('career.claims.length === 1 && money === 10000')) + cp.click('#claim-open'); cp.locator('#dlg-foot').get_by_role('button', name='Open claim', exact=True).click() + cp.wait_for_function('career.claims.length === 2 && !claimBusy', timeout=20000) + ev('GAME.paused = true') + check('new claim carries money, bag, upgrades and knowledge', ev("money === 5000 && earned === 12345 && JSON.stringify(Array.from(bag)) === JSON.stringify(claimTest.bag) && player.bitTier === 2 && seen[GOLD] === 1 && ledger.dig === 42 && stats.dug === 321")) + check('claim 2 has doubled debt, a new seed and the same dimensions', ev('debt === 40000 && worldSeed !== claimTest.seed && W === 512 && H === 384')) + check('claim 2 is stored with its career and name', ev("async () => { const c = await DB.get('career:' + GAME.careerId); const w = (await listWorlds()).find(w => w.id === GAME.worldId); return c.claims.length === 2 && c.claimNo === 2 && w.name === 'Claim 2' && w.career === c.id && w.claimNo === 2; }")) + check('new claim shows its arrival card', ev("$('cards').textContent.includes('New claim') && $('cards').textContent.includes('$40,000')")) + ev("openNotebook('Claims')") + cp.wait_for_function("$('nb-body').querySelectorAll('[data-travel]').length === 1") + check('Claims lists both claims with here, size and paid off', ev("$('nb-body').textContent.includes('Claim 2 · here') && $('nb-body').textContent.includes('512×384') && $('nb-body').textContent.includes('paid off ✓')")) + cp.screenshot(path=str(out / '09-claims.png')) + cp.click('[data-travel]'); cp.locator('#dlg-foot').get_by_role('button', name='Cancel', exact=True).click() + check('travelling asks for confirmation', ev('GAME.worldId !== claimTest.id')) + cp.click('[data-travel]'); cp.locator('#dlg-foot').get_by_role('button', name='Travel', exact=True).click() + cp.wait_for_function('GAME.worldId === claimTest.id && !claimBusy', timeout=20000) + ev('GAME.paused = true') + check('travel restores old buildings with shared money and bag', ev('blockP[9 * BW + 10] === BP_WALL && money === 5000 && JSON.stringify(Array.from(bag)) === JSON.stringify(claimTest.bag) && player.bitTier === 2 && debt === 0')) + ev("openNotebook('Claims')") + cp.wait_for_function("$('claim-open') && !$('claim-open').disabled") + check('returning to claim 1 still offers claim 3 at $80,000', ev("$('claim-open').textContent.includes('$80,000')")) + cp.screenshot(path=str(out / '09-claims-open.png')) + ev('quitToTitle()'); cp.wait_for_function("!$('btn-continue').hidden") + cp.click('#btn-continue'); cp.wait_for_function('GAME.running') + ev('GAME.paused = true') + check('Continue keeps the career state', ev('GAME.worldId === claimTest.id && GAME.careerId === claimTest.career && money === 5000 && player.bitTier === 2 && JSON.stringify(Array.from(bag)) === JSON.stringify(claimTest.bag)')) + check('failed claim save keeps the old world and money', ev("""async () => { + const op = DB.op; let writes = 0; + DB.op = function(mode, fn) { if (mode === 'readwrite' && ++writes === 2) return Promise.reject(new Error('test full')); return op.call(this, mode, fn); }; + try { return !await openNewClaim() && GAME.worldId === claimTest.id && money === 5000 && debt === 0 && blockP[9 * BW + 10] === BP_WALL && career.claims.length === 2; } + finally { DB.op = op; } + }""")) + check('old world gets a career without changing its money or bag', ev("""async () => { + const s = serializeWorld(), id = newWorldId(); + s.meta.economy.money = 777; s.meta.bag[RSTONE] = 37; + await putWorldEntry({ id, name: 'Old world', seed: s.meta.seed, w: W, h: H }); + await DB.put(id + ':main', { ...s, savedAt: Date.now() }); + await startFromSave(await worldNewest(id)); GAME.paused = true; + const c = await DB.get('career:' + GAME.careerId); + return money === 777 && bag[RSTONE] === 37 && c.claimNo === 1 && c.claims[0] === id; + }""")) + check('broken career data falls back to the world state', ev("""async () => { + await saveWorld('main', true); const id = GAME.worldId, cid = GAME.careerId; + const c = await DB.get('career:' + cid); + for (const bad of [{ ...c, bag: ['bad'] }, { ...c, player: { ...c.player, bitTier: 999 } }, { ...c, stats: [] }, { ...c, money: Infinity }, { ...c, seen: [3] }, { ...c, ledger: null }, undefined]) { + if (bad) await DB.put('career:' + cid, bad); else await DB.del('career:' + cid); + if (!await startFromSave(await worldNewest(id)) || money !== 777 || bag[RSTONE] !== 37) return false; + GAME.paused = true; + } + return true; + }""")) + check('deleting claims updates membership and deletes the last career', ev("""async () => { + const cid = claimTest.career, c = await DB.get('career:' + cid); + await deleteWorld(c.claims[0]); + const left = await DB.get('career:' + cid); + if (left.claims.length !== 1 || left.claimNo !== 2) return false; + await deleteWorld(left.claims[0]); + return await DB.get('career:' + cid) === undefined; + }""")) + cc.close(); ev = pg.evaluate + ev(f'GAME.paused = {json.dumps(career_play_paused)}') + # 9b. selling: goods sell the moment they drop into the dock; orders pay a lump sum and send a truck ev("SET.cardSeconds = 9; orders = []; nextOrderT = 1e12; R.floaters = [];") bot_home = ev('[bot.x, bot.y]')