Deepgrain Foundry: move from a claude.ai artifact to a local repository

The game as of artifact version 24, ready for local development with
coding agents.

- Source split from three large files into 52 small ones under src/sim,
  src/render and src/ui, joined in the order in src/manifest.json by
  build.py. The joined code is unchanged apart from comments; every
  headless suite gives the same output, and screenshots in both looks
  are pixel-identical to before.
- Light tidying with no change in behaviour: one movement scan loop,
  shared hazard-stripe and belt shader helpers, unused code removed, a
  shared check helper for the test suites.
- World files can be exported as an ordinary download outside claude.ai.
- Tests: broken old scripts removed, experiments moved to tools/,
  test/run_all.py added. Tools: shot.py (repeatable screenshots),
  where.py, map.py.
- Docs: AGENTS.md, CLAUDE.md, NOTES.md (the owner's to-do inbox),
  docs/PROJECT_MAP.md, DESIGN.md, TESTING.md, systems notes, the
  owner's requests and an agent log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9kLmYADKSEjuwUN3rFxDA
This commit is contained in:
2026-10-23 16:05:31 -04:00
co-authored by Claude Opus 5.5
commit 3f971a94c2
100 changed files with 11928 additions and 0 deletions
+284
View File
@@ -0,0 +1,284 @@
// Molds: cavities that fill with molten metal (or glass) and are knocked out as bars.
// ---------- Molds ----------
function moldCellsAt(kind, x, y) {
const md = MOLDS[kind]; const walls = [];
for (let r = 0; r < md.h; r++) for (let c = 0; c < md.w; c++) if (md.rows[r][c] === '#') walls.push((y + r) * W + x + c);
const cavs = md.cavs.map(comp => comp.map(([c, r]) => (y + r) * W + x + c));
return { walls, cavs };
}
function canPlaceMold(kind, bx, by) {
const md = MOLDS[kind];
if (bx < 0 || by < 0 || bx + md.bw > BW || by + md.bh > BH) return 'Out of bounds';
for (let y = by; y < by + md.bh; y++) for (let x = bx; x < bx + md.bw; x++) if (blockP[y * BW + x]) return 'Something is already built there';
const { walls, cavs } = moldCellsAt(kind, bx * BS, by * BS);
for (const i of walls.concat(...cavs)) if (!cellBuildable(i)) return PH[mat[i]] === PH_SOLID && (flags[i] & F_NAT) ? 'Needs open space. Clear the rock first.' : 'Something is already built there';
if (moldAt(bx * BS + 1, by * BS + 1)) return 'There is already a mold there';
return null;
}
function newMoldRecord(kind, x, y, wall) {
const md = MOLDS[kind];
return { id: (molds.length ? molds[molds.length - 1].id : 0) + 1, kind, x, y, w: md.w, h: md.h, wall, cavs: moldCellsAt(kind, x, y).cavs, ready: [], state: 'empty', metal: 0, fill: 0, flaw: '' };
}
function addMold(kind, bx, by, wallLoose, free) {
const md = MOLDS[kind];
if (!WALL_OF[wallLoose]) return 'Pick a wall material with R';
const cost = wallCost(wallLoose, md.walls);
if (!free) { if (!canAfford(cost, 1)) return needText(cost, 1); payCost(cost, 1); }
const x = bx * BS, y = by * BS;
const { walls, cavs } = moldCellsAt(kind, x, y);
evictCells(walls.concat(...cavs), y);
for (const i of walls) setCell(i, WALL_OF[wallLoose], AMB[(i / W) | 0], 0);
const mo = newMoldRecord(kind, x, y, wallLoose);
molds.push(mo);
stats.placed++;
return mo;
}
// Slag and ash that reach a mold float on the metal. They are chipped off and thrown
// away when the casting is knocked out, so they never spoil it unless metal set over them.
const DROSS = new Uint8Array(NMAT); DROSS[SLAG] = 1; DROSS[MSLAG] = 1; DROSS[ASH] = 1;
// Two metals in one casting: iron with some steel in it (air got at it) casts as plain iron;
// anything else is a mix that spoils it. Returns the metal the casting counts as, or -1.
function castMix(a, b) { return a === b ? a : (a === IRON && b === STEEL) || (a === STEEL && b === IRON) ? IRON : -1; }
function cavityState(cav) {
let solid = 0, liq = 0, metal = -1, mixed = false, dirty = false, uncast = false, dross = 0;
for (const i of cav) {
const m = mat[i];
if (SOLIDMETAL[m]) {
solid++;
if (!(flags[i] & F_CAST)) uncast = true;
if (metal < 0) metal = m; else if (metal !== m) { const c = castMix(metal, m); if (c < 0) mixed = true; else metal = c; }
} else if (CASTMETAL[m]) {
liq++;
const sm = FRZTO[m];
if (metal < 0) metal = sm; else if (metal !== sm) { const c = castMix(metal, sm); if (c < 0) mixed = true; else metal = c; }
} else if (DROSS[m]) {
dross++;
if (i >= W && SOLIDMETAL[mat[i - W]]) dirty = true; // metal set on top of it: it is stuck inside the casting
} else if (m !== EMPTY && PH[m] !== PH_GAS) dirty = true;
}
const n = cav.length;
let st;
if (liq > 0) st = 'filling';
else if (solid === 0) st = 'empty';
else if (solid === n && !mixed && !uncast) st = 'ready';
else if (dirty || mixed || uncast) st = 'flawed';
else st = 'short';
const flaw = dirty ? 'Slag or grit got stuck inside the casting. Press F to knock it out as scrap and start again.' : mixed ? 'Two different metals mixed in the casting. Press F to knock it out.' : uncast ? 'That metal was not cast here. Pour molten metal in.'
: st === 'short' ? `Not full: ${solid} of ${n} grains${dross ? ', with slag floating on top' : ''}. Fire the furnace again to top it up, or press F to knock it out as scrap.` : '';
return { st, fill: (solid + liq) / n, metal, flaw };
}
// The ingot trough: ready as soon as at least one bar's worth has set and nothing is still molten.
// It is forgiving: slag, ash or ore that got in is skimmed off when you knock the ingot out.
function troughState(mo) {
let solid = 0, liq = 0, junk = 0, metal = -1, mixed = false;
for (const i of mo.cavs[0]) {
const m = mat[i];
if (SOLIDMETAL[m]) { solid++; if (metal < 0) metal = m; else if (metal !== m) { const c = castMix(metal, m); if (c < 0) mixed = true; else metal = c; } }
else if (CASTMETAL[m]) { liq++; if (metal < 0) metal = FRZTO[m]; }
else if (m !== EMPTY && PH[m] !== PH_GAS && !DROSS[m]) junk++;
}
const bars = mixed ? 0 : Math.floor(solid / GRAINS_PER_BAR), n = mo.cavs[0].length;
const st = liq ? 'filling' : bars ? 'ready' : mixed ? 'flawed' : solid ? 'short' : junk ? 'flawed' : 'empty';
const flaw = st === 'flawed' ? (mixed ? 'Two different metals mixed in the ingot. Press F to knock it out as scrap.' : 'Only junk in the trough. Press F to knock it out.')
: st === 'short' ? `Only ${solid} grains set. A bar takes ${GRAINS_PER_BAR}. Fire again to top it up, or press F to knock it out as scrap.` : '';
return { st, bars, fill: (solid + liq) / n, metal, flaw };
}
// Empties cells: metal goes to your bag (or beside you if the bag is full) as scrap, other loose
// grains (ore, coal) as they are, and slag and ash are thrown away. Molten metal is left alone.
// Returns what went to the bag, and how much dross was thrown away as got.dross.
function clearCavity(cav) {
const got = new Map();
let dross = 0;
for (const i of cav) {
const m = mat[i];
if (m === EMPTY || PH[m] === PH_GAS || CASTMETAL[m]) continue;
if (DROSS[m]) dross++;
else {
const loose = SOLIDMETAL[m] ? (YLD[m] || SCRCU) : PH[m] === PH_SOLID ? YLD[m] : m;
if (loose) got.set(loose, (got.get(loose) || 0) + 1);
}
setCell(i, EMPTY, AMB[(i / W) | 0], 0);
}
for (const [m, n] of got) giveOrDrop(m, n, bot.x + bot.w / 2, bot.y + bot.h / 2);
got.dross = dross;
return got;
}
// Metal that set where it should not have: the open part of the mold above the cavity (overflow
// that sets as a plate over it) and the grate cells just above the mold (late drips that froze
// on the way down). Left there, it plugs the way down for the next firing. Returns the cells.
function moldPlugCells(mo) {
const { walls } = moldCellsAt(mo.kind, mo.x, mo.y), wall = new Set(walls), cav = new Set(mo.cavs.flat()), out = [];
for (let y = mo.y; y < mo.y + mo.h; y++) for (let x = mo.x; x < mo.x + mo.w; x++) {
const i = y * W + x, m = mat[i];
if (wall.has(i) || cav.has(i) || m === EMPTY || PH[m] === PH_GAS || CASTMETAL[m]) continue;
if (SOLIDMETAL[m] || PH[m] === PH_POWDER || DROSS[m]) out.push(i);
}
for (let y = Math.max(0, mo.y - 8); y < mo.y; y++) for (let x = mo.x; x < mo.x + mo.w; x++) {
const i = y * W + x;
if (part[i] === P_GRATE && SOLIDMETAL[mat[i]]) out.push(i);
}
return out;
}
// Knocking a mold out also rakes the furnace over it: slag and ash lying on the grate and in
// the charge above (up to the furnace roof) are cleared away, so the next charge sits on a
// clean grate. Unburnt coal and ore stay for the next firing. Returns how many grains it raked.
function rakeAbove(mo) {
let n = 0;
for (let x = mo.x; x < mo.x + mo.w; x++) for (let y = mo.y - 1, k = 0; y >= 0 && k < 48; y--, k++) {
const i = y * W + x, m = mat[i];
if (part[i] !== P_GRATE && (occ[i] || (PH[m] === PH_SOLID && !DROSS[m]))) break; // the roof, a wall or a machine
if (DROSS[m] && !(flags[i] & F_BURN)) { setCell(i, EMPTY, AMB[y], 0); n++; }
}
if (n) wakeXY(mo.x + (mo.w >> 1), mo.y - 8);
return n;
}
// After a knock-out: if the furnace over the mold is badly loaded, say what to change
function furnaceJunkHint(mo) {
let ore = 0, fuel = 0, open = 0;
for (let x = mo.x; x < mo.x + mo.w; x++) for (let y = mo.y - 1, k = 0; y >= 0 && k < 48; y--, k++) {
const i = y * W + x, m = mat[i];
if (part[i] === P_GRATE) continue;
if (occ[i] || PH[m] === PH_SOLID) break;
if (SMELTP[m] && PH[m] === PH_POWDER) ore++;
else if (FUEL[m] && PH[m] === PH_POWDER) fuel++;
else if (m === EMPTY || PH[m] === PH_GAS) open++;
}
if (ore > 40 && fuel < ore * 0.3) return ' A lot of ore is left and little fuel: add mostly coal next time.';
if (ore + fuel > 60 && open < (ore + fuel) * 0.5) return ' The furnace is packed too full to burn well. Leave the top third empty so air gets in.';
if (ore > 60) return ' A lot of ore did not melt: mix the next charge evenly with coal.';
return '';
}
// Knocking a mold also clears metal stuck over and above it, and rakes the furnace.
// Returns { metal: grains of metal that went to your bag, chipped: slag thrown away from the
// mold, raked: slag and ash raked out of the furnace }.
function unplugMold(mo) {
const plug = moldPlugCells(mo);
const got = plug.length ? clearCavity(plug) : new Map();
let metal = 0;
for (const [k, c] of got) if (PH[k] === PH_POWDER && MELTTO[k] && CASTMETAL[MELTTO[k]] && !SMELTP[k]) metal += c;
return { metal, chipped: got.dross || 0, raked: rakeAbove(mo) };
}
const grains = n => `${n} ${n === 1 ? 'grain' : 'grains'}`;
function checkMolds() {
for (const mo of molds) {
if (mo.kind === 'ingot') {
const r = troughState(mo);
mo.ready = []; if (r.st === 'ready') for (let k = 0; k < r.bars; k++) mo.ready.push(k);
if (r.st === 'ready' && mo.state !== 'ready') emit({ t: 'cast_ready', mold: mo.id, kind: mo.kind, metal: r.metal, bars: r.bars, x: mo.x + (mo.w >> 1), y: mo.y });
mo.state = r.st; mo.fill = r.fill; mo.metal = r.metal; mo.flaw = r.flaw;
continue;
}
const res = mo.cavs.map(cavityState);
mo.ready = [];
res.forEach((r, k) => { if (r.st === 'ready') mo.ready.push(k); });
let st;
if (mo.ready.length) st = 'ready';
else if (res.some(r => r.st === 'filling')) st = 'filling';
else if (res.some(r => r.st === 'flawed')) st = 'flawed';
else if (res.some(r => r.st === 'short')) st = 'short';
else st = 'empty';
mo.fill = res.reduce((a, r) => a + r.fill, 0) / res.length;
const ref = mo.ready.length ? res[mo.ready[0]] : res.find(r => r.metal >= 0) || res[0];
mo.flaw = (res.find(r => r.flaw) || {}).flaw || '';
if (st === 'ready' && mo.state !== 'ready') emit({ t: 'cast_ready', mold: mo.id, kind: mo.kind, metal: ref.metal, x: mo.x + (mo.w >> 1), y: mo.y });
mo.state = st; mo.metal = ref.metal;
}
}
function moldAt(x, y) {
for (const mo of molds) if (x >= mo.x && y >= mo.y && x < mo.x + mo.w && y < mo.y + mo.h) return mo;
return null;
}
// What a knock-out sent where, as one short sentence after the main message
function knockNote(scrap, other, chipped, raked) {
let s = '';
if (scrap) s += ` The leftover ${grains(scrap)} of scrap went into your bag${other ? ', with the grit that got in' : ''}.`;
else if (other) s += ' The grit that got in went into your bag.';
if (chipped && raked) s += ' The slag was chipped off and the grate raked clean.';
else if (chipped) s += ' The slag was chipped off.';
else if (raked) s += ' Slag and ash were raked off the grate.';
return s;
}
function tally(got) {
let scrap = 0, other = 0;
for (const [k, c] of got) { if (PH[k] === PH_POWDER && MELTTO[k] && CASTMETAL[MELTTO[k]] && !SMELTP[k]) scrap += c; else other += c; }
return { scrap, other, dross: got.dross || 0 };
}
function collectMold(mo) {
if (mo.state === 'filling') return 'The metal has not fully set yet.';
if (mo.state === 'empty') {
const g = tally(clearCavity(mo.cavs.flat())), u = unplugMold(mo);
const scrap = g.scrap + u.metal;
if (!scrap && !g.other && !g.dross && !u.chipped && !u.raked) return 'The mold is empty.';
emit({ t: 'sfx', s: 'ui' });
return 'Knocked the mold clean.' + knockNote(scrap, g.other, g.dross + u.chipped, u.raked);
}
let msg = '';
if (mo.kind === 'ingot') {
const cav = mo.cavs[0];
let solid = 0, T = 0;
const m = mo.metal;
if (mo.state === 'ready') for (const i of cav) if (SOLIDMETAL[mat[i]]) { solid++; T += temp[i]; }
T /= Math.max(1, solid);
const bars = Math.floor(solid / GRAINS_PER_BAR);
// the ingot snaps into bars that pop up and out of the trough; take the metal for them first
let take = bars * GRAINS_PER_BAR;
for (const i of cav.slice().sort((a, b) => b - a)) if (take > 0 && SOLIDMETAL[mat[i]] && castMix(m, mat[i]) === m) { setCell(i, EMPTY, AMB[(i / W) | 0], 0); take--; }
let n = 0;
for (let k = 0; k < bars; k++) {
const e = spawnBar(BAR_OF[m], mo.x + 1 + k * 4, mo.y - 3, (k - (bars - 1) / 2) * 0.3 + (rnd() - 0.5) * 0.15, -0.55, T);
if (e) n++; else bagAdd(BAR_OF[m], 1, true);
}
// the stub too short for a bar, and anything else that got in, are cleared out: the trough is left clean
const u = unplugMold(mo), g = tally(clearCavity(cav));
stats.bars += n; if (n) stats.cast++;
mo.state = 'empty'; mo.ready = [];
emit({ t: 'sfx', s: n ? 'cast' : 'ui' });
const word = m === GLASS ? (n === 1 ? 'glass brick' : 'glass bricks') : n === 1 ? 'bar' : 'bars';
return (n ? `Knocked out ${n} ${word}. Vacuum them up.` : 'Knocked the trough clean.') + knockNote(g.scrap + u.metal, g.other, g.dross + u.chipped, u.raked) + furnaceJunkHint(mo);
}
if (mo.state === 'short' && !(mo.knockAsk != null && tick - mo.knockAsk >= 0 && tick - mo.knockAsk < 300)) {
// an unfinished casting can still be topped up, so the first press only says so
mo.knockAsk = tick;
return (mo.flaw || 'The casting is not full yet.').replace(', or press F to knock it out as scrap.', '.') + ' Press F again to knock it out as scrap.';
}
if (mo.state === 'flawed' || mo.state === 'short') {
// a spoiled or unfinished casting is knocked out as scrap, so the mold is ready for another go
const short = mo.state === 'short', g = tally(clearCavity(mo.cavs.flat())), u = unplugMold(mo);
mo.state = 'empty'; mo.ready = [];
emit({ t: 'sfx', s: 'ui' });
const what = mo.kind === 'bit' ? 'bit' : 'casting';
return (short ? `The ${what} was not full, so it was knocked out as scrap.` : `The ${what} was spoiled, so it was knocked out as scrap.`)
+ knockNote(g.scrap + u.metal, g.other, g.dross + u.chipped, u.raked) + furnaceJunkHint(mo);
}
const tier = METAL_TIER[mo.metal];
const cav = mo.cavs[0];
if (tier <= 0) { msg = `${MD[mo.metal] ? MD[mo.metal].name : 'That metal'} is too ${mo.metal === GLASS ? 'brittle' : 'soft'} for a ${mo.kind === 'bit' ? 'drill bit' : 'tank'}. The casting broke into scrap, which went into your bag.`; }
else if (mo.kind === 'bit') {
if (tier > player.bitTier) { player.bitTier = tier; msg = `New drill bit: ${BIT_TIERS[tier].name}.`; emit({ t: 'upgrade', kind: 'bit', tier }); }
else { msg = `You already have a ${BIT_TIERS[player.bitTier].name.toLowerCase()}. The casting broke into scrap, which went into your bag.`; }
} else {
if (tier > player.tankTier) { player.tankTier = tier; msg = `New bag: ${TANK_TIERS[tier].name}, holds ${TANK_TIERS[tier].cap.toLocaleString('en-US')}.`; emit({ t: 'upgrade', kind: 'tank', tier }); }
else { msg = 'Your tank is already as good. The casting broke into scrap, which went into your bag.'; }
}
// a casting you could not use breaks into scrap, which goes to your bag
if (msg.startsWith('New')) for (const i of cav) setCell(i, EMPTY, AMB[(i / W) | 0], 0);
else clearCavity(cav);
const u = unplugMold(mo);
mo.state = 'empty'; stats.cast++;
return msg + knockNote(0, 0, u.chipped, u.raked) + furnaceJunkHint(mo);
}
function removeMold(mo) {
undoNote({ t: 'mold-', kind: mo.kind, bx: mo.x / BS, by: mo.y / BS, wall: mo.wall });
const { walls } = moldCellsAt(mo.kind, mo.x, mo.y);
let metal = 0;
for (const i of walls) {
const m = mat[i];
if (PH[m] === PH_SOLID && !(flags[i] & F_NAT) && !PARTMAT[m]) {
if (BAR_OF[m] && PH[mo.wall] === PH_ITEM) { metal++; setCell(i, EMPTY, temp[i], 0); }
else if (YLD[m] && bagAdd(YLD[m], 1, false) === 0) { setCell(i, YLD[m], temp[i], 0); droppedLast++; }
else setCell(i, EMPTY, temp[i], 0);
}
}
if (metal) giveOrDrop(BAR_OF[WALL_OF[mo.wall]] || BARCU, Math.floor(metal / GRAINS_PER_BAR), mo.x + 8, mo.y);
molds = molds.filter(m => m !== mo);
}