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:
2026-10-24 18:06:01 -04:00
co-authored by Claude Opus 5.5
parent 282e9ce9ab
commit 28893c93bc
12 changed files with 300 additions and 36 deletions
+1
View File
@@ -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
View File
@@ -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.' },
+115
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;