New claims: open another claim once one is paid off, travel between them (t3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
+9
-4
@@ -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
|
||||
|
||||
@@ -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:<id>` 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
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
-1
@@ -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.' },
|
||||
|
||||
@@ -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 = '<p class=note>Loading claims…</p>';
|
||||
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 => `<div class="world-row"><div class="world-info"><strong>${esc(w.name)}${w.id === GAME.worldId ? ' · here' : ''}</strong><div class="note">${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'}</div></div>${w.id === GAME.worldId ? '' : `<button class="btn" data-travel="${esc(w.id)}">Travel</button>`}</div>`).join('') +
|
||||
(debt <= 0 ? `<button class="btn primary" id="claim-open" ${money < 5000 || !career ? 'disabled' : ''}>Open a new claim: $5,000 down, then ${fmtMoney(CLAIM_DEBT * 2 ** (career ? career.claimNo : 1))} owed</button>${money < 5000 ? '<p class=note>You need $5,000 for the down payment.</p>' : ''}` : '<p class=note>A new claim opens once this one is paid off.</p>') +
|
||||
'<p class=note>Machines on other claims wait until you come back.</p>';
|
||||
const button = $('claim-open');
|
||||
if (button) button.onclick = () => dialog('Open a new claim?', `<p class="note">Pay $5,000 down and owe ${fmtMoney(CLAIM_DEBT * 2 ** career.claimNo)} on fresh land. Your buildings stay here.</p>`, [
|
||||
{ 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?', '<p class=note>Your current claim will be saved. Money, bag, upgrades and knowledge come with you.</p>', [
|
||||
{ label: 'Cancel', cls: 'ghost' }, { label: 'Travel', cls: 'primary', fn: () => { closeModal('m-dialog'); travel(b.dataset.travel); } },
|
||||
]));
|
||||
} catch (e) { if (nbTab === 'Claims') body.innerHTML = '<p class=note>Could not load claims from browser storage.</p>'; }
|
||||
}
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
|
||||
+3
-1
@@ -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,
|
||||
|
||||
+18
-7
@@ -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')); }
|
||||
|
||||
+27
-19
@@ -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?', `<p class="note">Delete ${esc(w.name)} and its autosave? Exported world files are not affected.</p>`, [
|
||||
{ label: 'Cancel', cls: 'ghost' },
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+89
-1
@@ -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]')
|
||||
|
||||
Reference in New Issue
Block a user