// 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.'); } } // Seeded camp names stay the same unless an existing world already uses one. function claimName(seed, taken = []) { const first = ['Copper', 'Iron', 'Red', 'Black', 'Grey', 'Silver', 'Deep', 'Cinder', 'Ash', 'Granite', 'Basalt', 'Slate', 'Rust', 'Ember', 'Flint', 'Cold', 'Hollow', 'Lone', 'Twin', 'Broken']; const last = ['Hollow', 'Ridge', 'Flats', 'Gulch', 'Mesa', 'Seam', 'Reach', 'Bluff', 'Basin', 'Crag', 'Shelf', 'Draw', 'Cut', 'Rise', 'Pit', 'Vein', 'Wash', 'Fork', 'Run', 'Bench']; const count = first.length * last.length, start = Math.floor(mulberry32(seed)() * count); for (let offset = 0; offset < count; offset++) { const i = (start + offset) % count, a = first[Math.floor(i / last.length)], b = last[i % last.length]; const name = a + ' ' + b; if (a !== b && !taken.includes(name)) return name; } // every camp name is taken: fall back to numbered names let n = 1; while (taken.includes('Mining Camp ' + n)) n++; return 'Mining Camp ' + n; } 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); const name = claimName(seed, (await listWorlds()).map(w => w.name)); await setupNewWorld(seed, w, h, name, carried, 'Opening a new claim'); debt = CLAIM_DEBT * 2 ** (n - 1); career = { ...career, claims: [...career.claims, GAME.worldId], claimNo: n }; GAME.claimNo = n; showLoading(true, 0.9, 'Saving'); if (!await saveWorld('auto', true)) throw new Error('Save failed'); showLoading(true, 1, 'Saving'); await gpuWarmWorld(); showLoading(false); closeAllModals(); enterGame(true); showCard({ kind: 'New claim', title: name, body: [`Claim ${n}. 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; const progress = (f, s) => showLoading(true, f, s, 'Travelling to ' + entry.name); progress(0, 'Saving'); if (!await saveWorld('main', true)) throw new Error('Save failed'); const sv = await worldNewest(worldId, progress); if (!sv || !await startFromSave(sv, progress)) return false; closeAllModals(); arrived = true; return true; } catch (e) { toast('Could not travel. Check browser storage and try again.'); return false; } finally { showLoading(false); 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' : ''} Claim ${esc(w.claimNo)}
${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.

'; body.querySelectorAll('[data-rename]').forEach(b => b.onclick = () => { const w = list.find(w => w.id === b.dataset.rename); dialog('Rename claim', ``, [ { label: 'Cancel', cls: 'ghost' }, { label: 'Save', cls: 'primary', fn: async () => { const name = cleanWorldName($('claim-name').value, w.name); try { await renameWorld(w.id, name); closeModal('m-dialog'); renderClaimsTab(body); } catch (e) { toast('Could not rename this claim. Try again.'); } } }, ]); }); 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.

'; } }