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 b270c4e818
97 changed files with 11710 additions and 0 deletions
+158
View File
@@ -0,0 +1,158 @@
// Per-tick machine work: steam engines, fans, belts, filters, screw tubes (machinesTick).
function engineIntake(mm) {
for (const i of mm.intake) {
if (mat[i] === STEAM) {
const T = temp[i];
mm.energy += 1 + Math.max(0, T - 100) / 120;
mat[i] = EMPTY; life[i] = 0; temp[i] = AMB[(i / W) | 0];
wakeIdx(i);
const ex = mm.dir === 0 ? mm.x + mm.w : mm.x - 1, ey = mm.y + mm.h - 1;
if (ex >= 0 && ex < W) {
const j = ey * W + ex;
if (mat[j] === EMPTY && !part[j]) { mat[j] = WATER; temp[j] = 92; life[j] = 0; flags[j] = 0; wakeXY(ex, ey); }
}
}
}
}
function fanBlow(mm, sat) {
const dir = mm.dir; // 0 right, 1 down, 2 left, 3 up
const dx = [1, 0, -1, 0][dir], dy = [0, 1, 0, -1][dir];
const cur = curAir();
for (let lane = 0; lane < 4; lane++) {
let sx, sy;
if (dir === 0) { sx = mm.x + 4; sy = mm.y + lane; }
else if (dir === 2) { sx = mm.x - 1; sy = mm.y + lane; }
else if (dir === 1) { sx = mm.x + lane; sy = mm.y + 4; }
else { sx = mm.x + lane; sy = mm.y - 1; }
let powderDepth = 0;
const range = 28;
for (let k = 0; k < range; k++) {
const x = sx + dx * k, y = sy + dy * k;
if (x < 0 || y < 0 || x >= W || y >= H) break;
const i = y * W + x, m = mat[i], ph = PH[m];
if (ph === PH_SOLID && part[i] !== P_GRATE) break;
airS[i] = cur;
if (ph === PH_POWDER) { if (++powderDepth > 16) break; }
else if (ph === PH_GAS && k < range - 1 && rnd() < sat * 0.7 && stamp[i] !== tickStamp) {
const nx = x + dx, ny = y + dy;
if (nx >= 0 && ny >= 0 && nx < W && ny < H) {
const j = ny * W + nx;
if (mat[j] === EMPTY && passable(m, PH_GAS, j, dx, dy)) move(i, j, x, y, nx, ny);
}
}
}
wakeHeatXY(Math.min(W - 1, Math.max(0, sx + dx * 4)), Math.min(H - 1, Math.max(0, sy + dy * 4)));
}
}
function machinesTick() {
if (powerDirty && tick >= nextPowerBuild) { rebuildPower(); nextPowerBuild = tick + 6; }
if (brokenMachines.length) {
const ids = brokenMachines; brokenMachines = [];
for (const id of ids) if (machines[id]) removeMachine(id, false, true);
}
for (const mm of machines) {
if (!mm) continue;
if (mm.type === 'wheel') {
const inst = mm.flow * WHEEL_K;
mm.out += (inst - mm.out) * 0.04; mm.flow = 0;
mm.angle += Math.min(0.35, mm.out * 0.012);
} else if (mm.type === 'refiner') {
refinerTick(mm);
} else if (mm.type === 'cutter') {
cutterTick(mm);
} else if (mm.type === 'silo') {
siloTick(mm);
} else if (mm.type === 'engine') {
engineIntake(mm);
mm.out += (mm.energy * ENGINE_K - mm.out) * 0.03; mm.energy = 0;
mm.angle += Math.min(0.4, mm.out * 0.01);
}
}
for (let n = 1; n < nets.length; n++) {
const net = nets[n];
let s = 0; for (const p of net.prods) s += p.out;
let d = net.nb * BELT_POWER + net.nt * TUBE_POWER;
for (const f of net.fans) if (f.on) d += FAN_POWER;
net.supply = s; net.demand = d;
net.sat = POWER_FREE ? 1 : d > 0 ? Math.min(1, s / d) : 0;
}
for (const mm of machines) {
if (!mm || mm.type !== 'fan') continue;
const net = nets[mm.net];
const sat = POWER_FREE ? 1 : net ? net.sat : 0;
mm.angle += sat * 0.5 * (mm.on ? 1 : 0);
if (mm.on && sat > 0.05) fanBlow(mm, sat);
// a fan getting close to melting says so (once every few seconds)
if ((tick + mm.id) % 30 === 0) {
let hotT = 0;
for (let y = mm.y; y < mm.y + 4; y++) for (let x = mm.x; x < mm.x + 4; x++) { const tt = temp[y * W + x]; if (tt > hotT) hotT = tt; }
if (hotT > 760 && (!mm.warnT || tick - mm.warnT > 600)) { mm.warnT = tick; emit({ t: 'toast', text: `A fan is at ${Math.round(hotT)}°C and melts at 1085°C. Move it out of the heat: outside the furnace wall, blowing in through a grate.` }); }
}
}
// belts and filters move what rests on them every other tick: half a grain per tick
if ((tick & 1) === 0) {
for (let pass = 0; pass < 2; pass++) {
const list = pass ? beltR : beltL, d = pass ? 1 : -1, bm = pass ? BELTR : BELTL;
for (let k = 0; k < list.length; k++) {
const b = list[k];
if (mat[b] !== bm || b < W) continue;
const above = mat[b - W];
if (above === BELTL || above === BELTR) continue; // only the top surface carries
if (netSat(b) <= 0) continue;
if (PH[above] === PH_LIQUID) beltDrain(b);
carryColumn(b - W, d, null);
}
}
if (filtersDirty) rebuildFilters();
for (const b of filterR) filterCarry(b, 1);
for (const b of filterL) filterCarry(b, -1);
}
// tubes: every loose grain inside steps one cell along, front of the line first, on the same
// beat as belts (every other tick), so a block-wide screw carries as much as a block-deep belt
if ((tick & 1) === 0) for (let p = P_TU; p <= P_TR; p++) {
const list = tubeLists[p], dx = TDX[p], dy = TDY[p], dj = dy * W + dx, flat = dy === 0;
for (let k = 0; k < list.length; k++) {
const i = list[k];
if (part[i] !== p) continue;
const m = mat[i];
if (m === EMPTY || PH[m] !== PH_POWDER) { if (m === EMPTY || PH[m] !== PH_SOLID) tubeIntake(i, p, dx, dy); continue; }
if (stamp[i] === tickStamp) continue;
if (temp[i] > 1000 && rnd() < 0.02) {
part[i] = 0; markPow(i, false); emit({ t: 'toast', text: 'A screw tube melted. Copper softens around 1000°C.' }); wakeIdx(i);
continue;
}
const s = netSat(i);
if (s <= 0 || rnd() > s) continue;
const x = i % W, y = (i / W) | 0;
// a sideways screw carries its load along the bottom: grains settle into empty lanes below
if (flat && y + 1 < H && part[i + W] === p && roomFor(i + W) && !occ[i + W]) { move(i, i + W, x, y, x, y + 1); continue; }
const nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
const j = i + dj;
if (occ[j]) continue;
const pj = part[j], tube = pj >= P_TU && pj <= P_TR;
if (tube && TDX[pj] === -dx && TDY[pj] === -dy) continue;
const t = mat[j];
if (!roomFor(j)) continue;
if (pj === P_GRATE || pj === P_INTAKE || (pj === P_FILTER && !filterPassAt(m, j))) continue;
if (!tube && !pj && t === EMPTY) {
// out of the mouth: the screw spits it clear, so the next grain is never blocked by the last
launchGrain(i, nx, ny, dy ? (rnd() - 0.5) * (dy < 0 ? 0.9 : 0.1) : dx * (0.42 + rnd() * 0.16), dy ? dy * (0.5 + rnd() * 0.2) : rnd() * 0.08);
stats.moved++; continue;
}
move(i, j, x, y, nx, ny); stats.moved++;
if (tube && pj !== p) {
// turning a corner: keep going across the new tube's width so every lane fills
let c = j, cx = nx, cy = ny;
const bxj = nx >> 2, byj = ny >> 2;
for (;;) {
const ex = cx + dx, ey = cy + dy;
if (ex < 0 || ey < 0 || ex >= W || ey >= H || (ex >> 2) !== bxj || (ey >> 2) !== byj) break;
const e = ey * W + ex;
if (part[e] !== pj || !roomFor(e) || occ[e]) break;
move(c, e, cx, cy, ex, ey); c = e; cx = ex; cy = ey;
}
}
}
}
}