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 56c93ae135
commit 0b37e497eb
13 changed files with 320 additions and 38 deletions
+16
View File
@@ -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 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. 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 - **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 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 900°C (any fire). 2 Bricks build a brick wall block; builders order them. The owner found it odd
+9 -4
View File
@@ -376,21 +376,26 @@ The title screen art.
- functions: buildTitleCells, drawTitle - functions: buildTitleCells, drawTitle
- variables: FONT5, TITLE - 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. 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 - 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 - 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. 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) ### src/ui/settings.js (124 lines)
The settings screen. The settings screen.
- functions: openSettings, renderSettings, applySettings, toggleFullscreen, confirmWipe - functions: openSettings, renderSettings, applySettings, toggleFullscreen, confirmWipe
- variables: SET_TABS, setTab - variables: SET_TABS, setTab
### src/ui/notebook.js (202 lines) ### src/ui/notebook.js (204 lines)
The notebook (N): milestones, market, wallet, blueprints, controls. The notebook (N): milestones, market, wallet, blueprints, controls.
- functions: openNotebook, renderNotebook, fmtClock, orderName, orderLeft, renderMarketTab, fmtPrice, itemsText, renderWallet, renderBlueprintTab, copyCode, controlsHtml - functions: openNotebook, renderNotebook, fmtClock, orderName, orderLeft, renderMarketTab, fmtPrice, itemsText, renderWallet, renderBlueprintTab, copyCode, controlsHtml
- variables: nbTab, MARKET_GROUPS - variables: nbTab, MARKET_GROUPS
+18
View File
@@ -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 note or request it was for, what changed, the files touched, the tests run, and anything left
open or not verified. Keep each entry short; the git history has the details. open or not verified. Keep each entry short; the git history has the details.
## 2026-10-24: 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) ## 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 - `9739ca0`: Refiner and Stone cutter code removed; `save.js` skips unknown machine types on load
+1
View File
@@ -46,6 +46,7 @@
"ui/state.js", "ui/state.js",
"ui/title.js", "ui/title.js",
"ui/saves.js", "ui/saves.js",
"ui/claims.js",
"ui/session.js", "ui/session.js",
"ui/settings.js", "ui/settings.js",
"ui/notebook.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: '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: '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: '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: 'rich1', title: 'Prosperous foundry', hint: 'Earn $50,000 in total.' },
{ id: 'rich2', title: 'Mining magnate', hint: 'Earn $200,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.' }, { 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); const [w, h] = $('nw-size').value.split('x').map(Number);
startNewWorld(seed, w, h, $('nw-name').value); 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-settings').onclick = () => { auInit(); playSfx('ui'); openSettings('Display'); };
$('btn-title-help').onclick = () => { auInit(); playSfx('ui'); openNotebook('Controls'); }; $('btn-title-help').onclick = () => { auInit(); playSfx('ui'); openNotebook('Controls'); };
const touchOnly = matchMedia('(pointer: coarse)').matches && !matchMedia('(any-pointer: fine)').matches; const touchOnly = matchMedia('(pointer: coarse)').matches && !matchMedia('(any-pointer: fine)').matches;
@@ -118,7 +118,7 @@ function boot(hotData) {
$('title').hidden = true; $('title').hidden = true;
migrateWorlds().then(() => refreshContinue()).then(async () => { migrateWorlds().then(() => refreshContinue()).then(async () => {
$('title').hidden = false; if (!TITLE.raf) drawTitle(); $('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(); }); }).catch(() => { $('title').hidden = false; if (!TITLE.raf) drawTitle(); });
requestAnimationFrame(frame); 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 openNotebook(tab) { if (tab) nbTab = tab; renderNotebook(); openModal('m-notebook'); }
function renderNotebook() { function renderNotebook() {
const tabs = $('nb-tabs'); tabs.innerHTML = ''; 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); const b = el('button', { role: 'tab', class: name === nbTab ? 'on' : '', 'aria-selected': name === nbTab }, name);
b.onclick = () => { playSfx('ui'); nbTab = name; renderNotebook(); }; b.onclick = () => { playSfx('ui'); nbTab = name; renderNotebook(); };
tabs.appendChild(b); 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(); }); body.querySelectorAll('[data-tip]').forEach(b => b.onclick = () => { playSfx('ui'); GAME.tipsOpen.add(b.dataset.tip); renderNotebook(); });
} else if (nbTab === 'Market') { } else if (nbTab === 'Market') {
renderMarketTab(body); renderMarketTab(body);
} else if (nbTab === 'Claims') {
renderClaimsTab(body);
} else if (nbTab === 'Materials') { } else if (nbTab === 'Materials') {
let rows = '', unknown = 0; 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, 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) { function deleteWorld(id) {
return worldWrite(async () => { return worldWrite(async () => {
const list = (await listWorlds()).filter(w => w.id !== id); const worlds = await listWorlds(), entry = worlds.find(w => w.id === id), list = worlds.filter(w => w.id !== id);
await DB.op('readwrite', st => { st.delete(`${id}:main`); st.delete(`${id}:auto`); st.put(list, 'worlds'); }); 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 = ''; } if (GAME.worldId === id) { GAME.worldId = ''; GAME.worldName = ''; }
}); });
} }
@@ -91,14 +102,14 @@ async function saveWorld(slot, quiet) {
try { try {
const s = serializeWorld(), savedAt = Date.now(), playTime = GAME.playTime; const s = serializeWorld(), savedAt = Date.now(), playTime = GAME.playTime;
if (!GAME.worldId) { GAME.worldId = newWorldId(); GAME.worldName = `World ${(await listWorlds()).length + 1}`; } if (!GAME.worldId) { GAME.worldId = newWorldId(); GAME.worldName = `World ${(await listWorlds()).length + 1}`; }
const id = GAME.worldId, name = GAME.worldName; const id = GAME.worldId, name = GAME.worldName, claimNo = GAME.claimNo, c = GAME.careerId && career ? { ...career, ...careerState() } : null;
const buffers = await packBuffers(s.buffers);
await worldWrite(async () => { await worldWrite(async () => {
const buffers = await packBuffers(s.buffers);
const list = await listWorlds(); const list = await listWorlds();
let entry = list.find(w => w.id === id); let entry = list.find(w => w.id === id);
if (!entry) { entry = { id, name, createdAt: savedAt }; list.push(entry); } 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 }); 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'); }); 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.'); if (!quiet) toast(slot === 'auto' ? 'Autosaved.' : 'World saved.');
return true; return true;
@@ -178,7 +189,7 @@ async function importWorldFile(file) {
bufs[k] = new C(bytes.buffer, bytes.byteOffset, bytes.byteLength / C.BYTES_PER_ELEMENT); bufs[k] = new C(bytes.buffer, bytes.byteOffset, bytes.byteLength / C.BYTES_PER_ELEMENT);
} }
const id = newWorldId(), name = `Imported ${obj.meta.seed}`.slice(0, 40); 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; if (!await saveWorld('auto')) return;
toast('World imported.'); toast('World imported.');
} catch (e) { toast('That file could not be loaded: ' + (e.message || 'unknown error')); } } catch (e) { toast('That file could not be loaded: ' + (e.message || 'unknown error')); }
+27 -19
View File
@@ -15,26 +15,33 @@ function enterGame() {
GAME.drag = null; GAME.lmb = GAME.rmb = false; lastBagKey = ''; GAME.drag = null; GAME.lmb = GAME.rmb = false; lastBagKey = '';
hudDirty = true; renderHotbar(); hudDirty = true; renderHotbar();
} }
function startNewWorld(seed, w, h, name = '') { async function setupNewWorld(seed, w, h, name, carried) {
auInit(); auInit(); showLoading(true, 0);
showLoading(true, 0); await new Promise(resolve => setTimeout(resolve, 30));
setTimeout(async () => { for (const k of Object.keys(stats)) stats[k] = 0;
try { generateWorld(seed, w, h, null);
const worlds = await listWorlds(); GAME.worldId = newWorldId(); GAME.worldName = name;
GAME.worldId = newWorldId(); GAME.worldName = cleanWorldName(name, `World ${worlds.length + 1}`); 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;
await putWorldEntry({ id: GAME.worldId, name: GAME.worldName, seed, w, h, createdAt: Date.now(), savedAt: 0, playTime: 0 }); if (carried) applyCareerState(carried);
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);
} }
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(); 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) { catch (e) {
toast(e && e.message ? e.message : 'That save could not be loaded.', { big: true, title: 'Save not loaded' }); 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(); 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.worldId = sv.worldId || ''; GAME.worldName = sv.worldName || '';
GAME.playTime = sv.playTime || 0; GAME.playTime = sv.playTime || 0;
GAME.drag = null; GAME.blow.clear(); GAME.drag = null; GAME.blow.clear();
await loadCareer();
enterGame(); enterGame();
return true; return true;
} }
@@ -111,7 +119,7 @@ async function renderWorlds(editId) {
button('Save', async () => { await renameWorld(w.id, input.value); await renderWorlds(); await refreshContinue(); }); button('Save', async () => { await renameWorld(w.id, input.value); await renderWorlds(); await refreshContinue(); });
button('Cancel', () => renderWorlds()); button('Cancel', () => renderWorlds());
} else { } 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('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>`, [ button('Delete', () => dialog('Delete world?', `<p class="note">Delete ${esc(w.name)} and its autosave? Exported world files are not affected.</p>`, [
{ label: 'Cancel', cls: 'ghost' }, { label: '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 }, 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 }, 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 }, 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(); const keys = new Set();
let hudDirty = true; let hudDirty = true;
+20 -2
View File
@@ -1,12 +1,30 @@
--- ---
id: t3 id: t3
title: World feels small; resources run out title: World feels small; resources run out
status: Backlog status: Review
created: 2026-10-24 created: 2026-10-24
updated: 2026-10-24T04:41:45Z updated: 2026-10-24T22:06:01Z
tags: [discussion] tags: [discussion]
report: |
(agent) Built all four. New worlds hold about 4.7x the ore: the top layers are as before, and
ore gets steadily richer and veins bigger as you go deeper (copper, coal and tin now reach the
deep layers too; gold and silver grew least so they stay valuable). Grinding granite and basalt
finds about twice the ore, and builders order those rocks by the bagful. There's a Huge size, and
saves are now compressed (a Medium world dropped from 29 MB to under 0.5 MB). Once a claim is
paid off, the notebook's new Claims page opens another claim for $5,000 down and $40,000 owed
(then $80,000...). Your money, bag, upgrades and discoveries come along, buildings stay put, and
you can travel back. Existing worlds only get the richer ore if you start a new world or claim.
order: 1
--- ---
## Context ## Context
- the world still seems quite small. i dont know if thats because the bot moves so fast, the bot can mine too fast, or the grains should be smaller... i would like to make the worl seem huge to the player and give them loads of resources to mine for a long game. as is, you could mine all the resources from the map pretty quickly and end up having to start a new world and new game as a result. or maybe there is some other solution that i havent thought of yet. need to brainstorm this. - the world still seems quite small. i dont know if thats because the bot moves so fast, the bot can mine too fast, or the grains should be smaller... i would like to make the worl seem huge to the player and give them loads of resources to mine for a long game. as is, you could mine all the resources from the map pretty quickly and end up having to start a new world and new game as a result. or maybe there is some other solution that i havent thought of yet. need to brainstorm this.
## Subtasks
- [x] Deeper is richer: ore gets more common and comes in bigger veins the deeper you dig, for about five times the ore in a world; the shallow layers stay as they are
report: Removed coordinate stretching and balanced thresholds per ore. Medium seed 424242 gives coal 170437 (5.09×), copper 127298 (4.80×), iron 361683 (4.66×), zinc 53527 (4.54×), tin 38551 (4.99×), silver 12166 (3.34×), gold 5858 (3.48×): 769520 total (4.73× the original 162519). Shallow ore positions/types match the original hash. Pixel screenshot /tmp/t3-balanced-ore.png inspected: rounded lenses, no diagonal ore streaks. Build/map regenerated; run_all.py --browser all passed (world 46, play 244, smooth 8 checks). Part 1 awaits owner review; remaining parts stay open.
- [x] Waste rock pays: granite and basalt are worth grinding for ore, and builders buy them in bulk
- [x] A Huge world size, with saves compressed so big worlds stay small on disk
- [x] New claims: once a claim is paid off, open another for $5,000 down and a bigger debt, keeping money, bag, upgrades and knowledge; travel between your claims from the notebook
+89 -1
View File
@@ -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`)); await DB.op('readwrite', st => st.put({...s, savedAt: Date.now() + 1000, playTime: 4567}, `${id}:main`));
const sv = await worldNewest(id); const sv = await worldNewest(id);
bagTotal = 0; worldSeed = 0; 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); 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 # 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 = [];") ev("SET.cardSeconds = 9; orders = []; nextOrderT = 1e12; R.floaters = [];")
bot_home = ev('[bot.x, bot.y]') bot_home = ev('[bot.x, bot.y]')