diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index f6d9182..3b680e2 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -216,6 +216,12 @@ above, including:
brick pattern so piles have no air gaps; a Bar rack machine (the Silo for bars) takes them from
a belt or tube, counts them and lets them out with F. The owner wanted bar storage that doesn't
waste space, like stacked ingots in real life.
+ As built: a resting bar snaps to a 2-grain grid, and one with fewer than 2 of its 4 bottom cells
+ supported tips into the gap beside it. Storage became Rack blocks rather than a machine, like
+ the t4 hoppers: 50 bars of any kind per block, a column is one rack; bars landing on top go in
+ (into the lowest block with room), F opens the gate at the bottom of the column, and bars come
+ out oldest first, one every 20 ticks, onto whatever is below. Taking a block back returns its
+ bars.
- **Hopper blocks replace hopper machines (t4).** 1-block Grinder and Saw blocks each work what
lands on them and drop it below, so a wider row is a wider, faster hopper. The Refiner and Stone
cutter leave the build menu; built ones in old saves keep working. The owner wanted hoppers of
diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md
index a486efd..6946a95 100644
--- a/docs/PROJECT_MAP.md
+++ b/docs/PROJECT_MAP.md
@@ -196,15 +196,15 @@ Deepgrain Foundry: simulation core (src/sim/). No DOM anywhere in src/sim/: plai
- functions: rnd, mulberry32, hash2
- variables: RS, PH_EMPTY, PH_SOLID, PH_POWDER, PH_LIQUID, PH_GAS, PH_ITEM
-### src/sim/materials.js (167 lines)
+### src/sim/materials.js (168 lines)
Material ids, the material table (def), per-material property arrays, cell flags and part ids, and drill/tank tiers.
- functions: def, metalFamily
- variables: EMPTY, GRASS, DIRT, LDIRT, SAND, GRAVEL, CLAY, STONE, GRANITE, BASALT, NMAT, MD, FIRETO, PH, DENS, KC, ICAP, CASTMETAL, METAL_TIER, SOLIDMETAL, F_NAT, F_CAST, F_BURN, P_NONE, P_TU, P_TD, P_TL, P_TR, P_GRATE, P_GOPEN, P_WHEEL, P_INTAKE, P_FILTER, P_PIPE, TDX, TDY, BIT_TIERS, TANK_TIERS, REACH
-### src/sim/pieces.js (141 lines)
+### src/sim/pieces.js (143 lines)
Everything the player can build (PIECES): blocks, machines and molds, with costs and descriptions; filter settings; wall materials and their cost.
- functions: isPipeCode, filterable, cleanFilterCfg, filterRec, filterPassAt, filterInfo, sameFilter, wallCost
-- variables: BS, BP_WALL, BP_BELT, BP_TUBE, BP_SHAFT, BP_GRATE, BP_GATE, BP_FILTER, BP_PIPE, BP_PUMP, BP_OUTLET, BP_GRIND, BP_SAW, BP_PRESS, filterCfg, WALL_OF, WALL_LIST, BAR_OF, GRAINS_PER_BAR, POWER_FREE, CASH, PIECES, PIECE_BY_ID, PIECE_BY_CODE, BELT_POWER, TUBE_POWER, FAN_POWER, WHEEL_K, ENGINE_K, MOLDS
+- variables: BS, BP_WALL, BP_BELT, BP_TUBE, BP_SHAFT, BP_GRATE, BP_GATE, BP_FILTER, BP_PIPE, BP_PUMP, BP_OUTLET, BP_GRIND, BP_SAW, BP_PRESS, BP_RACK, filterCfg, WALL_OF, WALL_LIST, BAR_OF, GRAINS_PER_BAR, POWER_FREE, CASH, PIECES, PIECE_BY_ID, PIECE_BY_CODE, BELT_POWER, TUBE_POWER, FAN_POWER, WHEEL_K, ENGINE_K, MOLDS
### src/sim/economy.js (152 lines)
Money, prices, market saturation, the trade dock, buyer and builder orders.
@@ -246,12 +246,17 @@ Processing: Grinder and Saw blocks, and the Silo.
- functions: isProcCode, rebuildProcessors, procBottom, procSlot, procFeed, procBlocksTick, siloTick, siloFull
- variables: REFINE, REFINE_SAND, CUT_PER_BRICK, SAW_CUTS, procState, procBlocks, procDirty, SILO_CAP, SILO_RATE
+### src/sim/racks.js (56 lines)
+Each rack block owns its bars; only the bottom of a column releases them.
+- functions: newRack, rackTop, rackBottom, rackPush, rackShift, rackIntake, rackRefill, rackTick
+- variables: RACK_CAP, rackState
+
### src/sim/pipes.js (114 lines)
Pipes, pumps and outlets: each joined line holds one liquid as a count, not as grains.
- functions: sideOf, pipeClosedSide, pipeJoins, rebuildPipes, pipeNetOf, pipeTakeShare, faceCell, pumpDraw, outletPour, pipesTick
- variables: PIPE_CAP, PUMP_RATE, OUTLET_RATE, PUMP_MAX_T, SIDE_V, PUMP_V, OUTLET_V, pipeNets, pipeNetAt, pipesDirty
-### src/sim/machine-tick.js (163 lines)
+### src/sim/machine-tick.js (164 lines)
Per-tick machine work: steam engines, fans, belts, filters, screw tubes (machinesTick).
- functions: engineIntake, fanBlow, machinesTick
@@ -260,7 +265,7 @@ How belts, filters and screw tubes move what is on or in them. What rests on a b
- functions: carryColumn, beltDrain, launchGrain, rebuildFilters, filterCarry, setFilter, filterRun, roomFor, tubeSpread, tubeIntake
- variables: CARRY_DEPTH, filterR, filterL, filtersDirty
-### src/sim/blocks.js (136 lines)
+### src/sim/blocks.js (149 lines)
Building blocks: placing, integrity, removing, lines and rectangles of blocks.
- functions: blockOf, blockCellList, pieceCost, canPlaceBlock, placeBlock, blockIntegrity, removeBlock, blockLine, blockRect, blockInReach, blockIs, placeBlocks
@@ -269,7 +274,7 @@ Molds: cavities for castings; ingot troughs empty themselves below. Removal retu
- functions: moldCellsAt, canPlaceMold, newMoldRecord, addMold, castMix, cavityState, troughState, clearCavity, moldPlugCells, rakeAbove, furnaceJunkHint, unplugMold, ejectIngot, checkMolds, moldAt, tally, collectMold, removeMold
- variables: DROSS, grains, barWord
-### src/sim/items.js (224 lines)
+### src/sim/items.js (225 lines)
Bars and other loose objects (ents), and the bag: adding, taking, costs and refunds.
- functions: entSolidCell, entSupportCount, entFreeAt, entMark, spawnBar, removeEnt, entTubeAt, entTubeFree, entTubeMove, entTubeEnter, entTubeTick, entTick, entsInCircle, bagAdd, bagTake, giveOrDrop, refundAt, canAfford, payCost, fmtMoney, costText, needText
- variables: BAR_W, BAR_H, entCurM, droppedLast
@@ -293,7 +298,7 @@ The vacuum, blowing grains out, grains in flight (flyers), and the flame tool. M
- functions: vacuumTargetAt, toolVacuum, nozzle, toolBlow, flyOpen, depositNear, bagTemp, landFlyer, flyTick, toolIgnite
- variables: blowRR, FLY_G, FLY_Q
-### src/sim/actions.js (87 lines)
+### src/sim/actions.js (92 lines)
Player actions on built things: take back, place a machine or mold, gates, F to interact. Take back whatever piece is at (x,y): machine, mold or block. Returns a message.
- functions: removePieceAt, removePieceAt0, placeMachineAt, placeMoldAt, toggleGateAt, interactAt
@@ -329,7 +334,7 @@ World generation: noise, layers, ores, caves, water, trees, and clearing tiny ha
- functions: makeNoise, fbm, clearHardSpecks, generateWorld, generateWorldAsync
- variables: SPECK_MIN
-### src/sim/save.js (126 lines)
+### src/sim/save.js (141 lines)
Saving and loading a world (serializeWorld / deserializeWorld) and old-save migration.
- functions: serializeWorld, deserializeWorld
- variables: SAVE_VERSION, TOFF
@@ -348,9 +353,9 @@ Scale colour without changing the smooth-look class.
- functions: fogColor, drawWorld
- variables: TUBE_CLASS
-### src/render/machines.js (212 lines)
+### src/render/machines.js (242 lines)
Machine overlays: fans, engines, silo and the rest, drawn on top of the world.
-- functions: cellRect, drawHazardRims, drawMachines, drawProcBlocks, drawSilo, drawSiloPixel
+- functions: cellRect, drawHazardRims, drawMachines, drawProcBlocks, drawSilo, drawSiloPixel, drawRackBlocks
### src/render/overlays.js (229 lines)
Other overlays: floating text, rain and snow, flow arrows, filter tags, molds, the bot, particles.
@@ -404,7 +409,7 @@ The notebook (N): milestones, market, wallet, blueprints, controls.
The tool hotbar and what is selected.
- functions: renderHotbar, toolId, steering, modShift, grainAt, selectTool, bagMaterials, blowList, blowAllList, blowingAll, piece
-### src/ui/build-menu.js (103 lines)
+### src/ui/build-menu.js (104 lines)
The build menu along the bottom: categories, piece tiles and their icons.
- functions: pieceIcon, catOf, selectBuildCat, cycleBuildCat, buildVariantChips, renderBuildBar, setupBuildBar
- variables: BUILD_CATS, BUILD_ORDER, PIECE_ICONS, lastBuildKey, lastBuildMenu
@@ -414,7 +419,7 @@ The selected tool and piece: R / Q / E, drill width, filter picker, the selectio
- functions: pieceUsesWall, setDrillWidth, tuneUnderCursor, cycleItem, cycleWall, rotate, toggleBlow, pickFilterUnderCursor, toggleFilterMat, setBrush, pieceV, pieceExtra, filterChoices, matList, filterParts, filterSentence, applyFilterRun, openFilterPicker, openWallPicker, filterBlockAt, chipCost, pieceCostNow, renderSelCard, renderBag
- variables: lastBagKey
-### src/ui/info.js (174 lines)
+### src/ui/info.js (186 lines)
Discovery cards, hover info (I) and the cursor label.
- functions: showCard, howText, discoveryCard, reactionsOf, hoverInfo, renderInfo, drawCursorLabel
@@ -468,6 +473,7 @@ The frame loop and boot.
- `test/t_furnace_repeat.js`: The starter furnaces keep working when fired again and again without cleaning, however the charge goes in: mixed, ore first with coal on top, or blown in through the vent. Each case plays furnace_play.js, which builds the design and fires it like a player.
- `test/t_molds.js`: Molds and furnaces stay usable: ingots drop below and keep their stubs, plugs over molds are cleared, spoiled castings can be knocked out, grains never drop through a grate by teleporting, and slag lets the fire breathe.
- `test/t_pipes.js`: Pipes, pumps and outlets move liquids; the Grinder grinds waste rock for trace ore.
+- `test/t_racks.js`: Rack intake, FIFO storage, gate, removal and saved-state validation.
- `test/t_smooth.py`: The smooth look: the WebGL pass compiles, draws, falls back cleanly, and the thermal lens still works.
- `test/t_stone.js`: Uses for waste rock and sand: glass bricks from a fan furnace, the Saw, the silo and builders' orders.
- `test/t_weather.js`: Weather: no more springs; steam rises into clouds and rains back; showers; snow when it is freezing.
diff --git a/docs/agent-log.md b/docs/agent-log.md
index a4e8e17..c05b9d7 100644
--- a/docs/agent-log.md
+++ b/docs/agent-log.md
@@ -4,6 +4,16 @@ 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
open or not verified. Keep each entry short; the git history has the details.
+## 2026-10-24: t1: bars stack neatly, Rack blocks store them (Claude Code, Codex)
+
+- `232f5af`: `entTick` snap-and-tip (`entSupportCount`, `src/sim/items.js`);
+ `test/t_bar_stacking.js` (12 bars fill a pit's bottom six rows; fails on the old code).
+- Rack blocks: `src/sim/racks.js` (`rackState` per block, FIFO runs, `rackIntake` from `entTick`,
+ `rackTick` release, `rackRefill`), `BP_RACK`/`RACKB`, F toggle in `interactAt`, removal returns
+ bars with `giveOrDrop`, saved in meta `racks` and validated on load, shelves overlay, hover,
+ Machines tab, notebook. `test/t_racks.js` (29 checks), play.py menu check.
+- Tests: `run_all.py --browser` ALL PASSED. Screenshot `shots/t1-racks.png`.
+
## 2026-10-24: Backlog combined and rewritten (Claude Code)
- On the owner's request: t12 (exotic ores) and t31 (choosing claims) merged into t13, now
diff --git a/src/manifest.json b/src/manifest.json
index c578ac8..4979fff 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -15,6 +15,7 @@
"sim/heat.js",
"sim/machines.js",
"sim/processors.js",
+ "sim/racks.js",
"sim/pipes.js",
"sim/machine-tick.js",
"sim/conveyors.js",
diff --git a/src/render/machines.js b/src/render/machines.js
index a0fd3af..9bf7879 100644
--- a/src/render/machines.js
+++ b/src/render/machines.js
@@ -76,6 +76,7 @@ function drawMachines() {
ctx.restore();
}
drawProcBlocks();
+ drawRackBlocks();
}
// Crushing rollers and saw blades sit over the shared steel housings in both looks.
function drawProcBlocks() {
@@ -210,3 +211,32 @@ function drawSiloPixel(ctx, mm, sx, sy, zp) {
ctx.restore();
}
}
+
+// Shelves show each block's fill and materials; the bottom gate belongs to the column.
+function drawRackBlocks() {
+ const ctx = R.ctx;
+ for (const [b, s] of rackState) {
+ const x = (b % BW) * BS, y = ((b / BW) | 0) * BS;
+ const [sx, sy, sw] = cellRect(x, y, BS, BS);
+ if (sx > R.canvas.width || sy > R.canvas.height || sx + sw < 0 || sy + sw < 0) continue;
+ if (SET.fog && fogSeen(x + 2, y + 2) < 64) continue;
+ ctx.save();
+ ctx.fillStyle = '#202831'; ctx.fillRect(sx + sw * 0.1, sy + sw * 0.1, sw * 0.8, sw * 0.8);
+ ctx.fillStyle = '#a5adb5';
+ for (let row = 0; row < 2; row++) ctx.fillRect(sx + sw * 0.1, sy + sw * (0.47 + row * 0.4), sw * 0.8, sw * 0.05);
+ const slots = Math.ceil(s.n / 5);
+ let run = 0, count = 0;
+ for (let k = 0; k < slots; k++) {
+ while (run + 1 < s.q.length && k * 5 >= count + s.q[run][1]) count += s.q[run++][1];
+ const m = s.q[run][0];
+ ctx.fillStyle = `rgb(${COLR[m]},${COLG[m]},${COLB[m]})`;
+ ctx.fillRect(sx + sw * (0.14 + k % 5 * 0.145), sy + sw * (0.71 - Math.floor(k / 5) * 0.4), sw * 0.12, sw * 0.12);
+ }
+ if (b + BW >= NB || blockP[b + BW] !== BP_RACK) {
+ ctx.fillStyle = s.open ? '#e0b43c' : '#c9d1d8';
+ ctx.fillRect(sx + sw * 0.1, sy + sw * 0.94, sw * (s.open ? 0.2 : 0.8), sw * 0.06);
+ if (s.open) ctx.fillRect(sx + sw * 0.7, sy + sw * 0.94, sw * 0.2, sw * 0.06);
+ }
+ ctx.restore();
+ }
+}
diff --git a/src/render/world.js b/src/render/world.js
index efa1e95..eba9b20 100644
--- a/src/render/world.js
+++ b/src/render/world.js
@@ -115,7 +115,7 @@ function drawWorld() {
}
else if (m === GATE) { if ((x & 1) === 0 && (y & 3) !== 0) { r -= 40; g -= 24; b -= 20; } }
else if (m === CUTST) { const lx = x & 3, ly = y & 3; if (ly === 1 || ly === 3 || lx === (ly < 2 ? 3 : 1)) { r -= 34; g -= 33; b -= 30; } else if (ly === 0 || ly === 2) { r += 10; g += 10; b += 9; } }
- else if (m === GRINDER || m === SAWB || m === PRESSB) {
+ else if (m === GRINDER || m === SAWB || m === PRESSB || m === RACKB) {
const lx = x & 3, ly = y & 3, frame = lx === 0 || lx === 3 || ly === 0 || ly === 3;
r = frame ? 96 : 38; g = frame ? 102 : 42; b = frame ? 112 : 50;
}
diff --git a/src/sim/actions.js b/src/sim/actions.js
index d40cd08..9d632a0 100644
--- a/src/sim/actions.js
+++ b/src/sim/actions.js
@@ -76,6 +76,11 @@ function interactAt(x, y) {
// ingot molds empty themselves: F passes them by
if (mo && mo.kind !== 'ingot') { const r = collectMold(mo); if (!r.startsWith('New') && !r.startsWith('Knocked')) emit({ t: 'sfx', s: 'ui' }); return r; }
const pb = ((y / BS) | 0) * BW + ((x / BS) | 0);
+ if (blockP[pb] === BP_RACK) {
+ const top = rackTop(pb), open = !rackState.get(top).open;
+ for (let b = top; b < NB && blockP[b] === BP_RACK; b += BW) { const s = rackState.get(b); s.open = open; s.wait = 0; }
+ return open ? 'Rack open.' : 'Rack closed.';
+ }
if (blockP[pb] === BP_PUMP || blockP[pb] === BP_OUTLET) {
blockV[pb] ^= 4; emit({ t: 'sfx', s: 'ui' });
return blockP[pb] === BP_PUMP ? (blockV[pb] & 4 ? 'Pump off.' : 'Pump on.') : (blockV[pb] & 4 ? 'Outlet closed.' : 'Outlet open.');
diff --git a/src/sim/blocks.js b/src/sim/blocks.js
index 4689f22..acd8597 100644
--- a/src/sim/blocks.js
+++ b/src/sim/blocks.js
@@ -26,6 +26,7 @@ function placeBlock(code, v, bx, by, free, extra) {
if (code === BP_WALL) setCell(i, WALL_OF[v], AMB[y], 0);
else if (code === BP_BELT) { setCell(i, v === 0 ? BELTL : BELTR, AMB[y], 0); markPow(i, true); }
else if (code === BP_SHAFT) { setCell(i, SHAFT, AMB[y], 0); markPow(i, true); }
+ else if (code === BP_RACK) setCell(i, RACKB, AMB[y], 0);
else if (isProcCode(code)) setCell(i, code === BP_GRIND ? GRINDER : code === BP_PRESS ? PRESSB : SAWB, AMB[y], 0);
else if (code === BP_GATE) setCell(i, GATE, AMB[y], 0);
else if (code === BP_GRATE) { part[i] = P_GRATE; wakeXY(x, y); }
@@ -38,6 +39,12 @@ function placeBlock(code, v, bx, by, free, extra) {
blockP[by * BW + bx] = code; blockV[by * BW + bx] = v;
if (code === BP_FILTER) { blockV[by * BW + bx] = v & 128; filterCfg.set(by * BW + bx, filterRec(extra)); filtersDirty = true; }
if (isProcCode(code)) procDirty = true;
+ if (code === BP_RACK) {
+ const b = by * BW + bx, s = newRack();
+ s.open = !!(rackState.get(b - BW)?.open || rackState.get(b + BW)?.open);
+ rackState.set(b, s);
+ for (let q = rackTop(b); q <= rackBottom(b); q += BW) rackState.get(q).open = s.open;
+ }
stats.placed++;
undoNote({ t: 'block+', bx, by, code });
return '';
@@ -53,6 +60,7 @@ function blockIntegrity(b) {
if (code === BP_WALL) { if (PH[m] === PH_SOLID && !(flags[i] & F_NAT) && !PARTMAT[m]) n++; }
else if (code === BP_BELT) { if (m === BELTL || m === BELTR) n++; }
else if (code === BP_SHAFT) { if (m === SHAFT) n++; }
+ else if (code === BP_RACK) { if (m === RACKB) n++; }
else if (isProcCode(code)) { if (m === (code === BP_GRIND ? GRINDER : code === BP_PRESS ? PRESSB : SAWB)) n++; }
else if (code === BP_GATE) { if (m === GATE || p === P_GOPEN) n++; }
else if (code === BP_GRATE) { if (p === P_GRATE) n++; }
@@ -82,8 +90,8 @@ function removeBlock(bx, by) {
} else if (isPipeCode(code)) {
if (p === P_PIPE) { part[i] = 0; wakeIdx(i); n++; }
else if (m === PIPE) { setCell(i, EMPTY, temp[i], 0); n++; }
- } else if (code === BP_BELT || code === BP_SHAFT || code === BP_GATE || isProcCode(code)) {
- if (m === BELTL || m === BELTR || m === SHAFT || m === GATE || m === GRINDER || m === SAWB || m === PRESSB) { setCell(i, EMPTY, temp[i], 0); markPow(i, false); n++; }
+ } else if (code === BP_BELT || code === BP_SHAFT || code === BP_GATE || code === BP_RACK || isProcCode(code)) {
+ if (m === BELTL || m === BELTR || m === SHAFT || m === GATE || m === GRINDER || m === SAWB || m === PRESSB || m === RACKB) { setCell(i, EMPTY, temp[i], 0); markPow(i, false); n++; }
if (p === P_GOPEN) { part[i] = 0; n++; wakeIdx(i); }
} else if (p) { part[i] = 0; markPow(i, false); wakeIdx(i); n++; }
}
@@ -91,6 +99,11 @@ function removeBlock(bx, by) {
else if (code !== BP_WALL && n >= 8) refundAt(PIECE_BY_CODE[code].cost, 1, bx * BS + 2, by * BS + 2);
if (code === BP_FILTER) { filtersDirty = true; filterCfg.delete(b); }
if (isProcCode(code)) { procDirty = true; procState.delete(b); }
+ if (code === BP_RACK) {
+ const s = rackState.get(b);
+ rackState.delete(b);
+ if (s) for (const [m, count] of s.q) giveOrDrop(m, count, bot.x, bot.y);
+ }
blockP[b] = 0; blockV[b] = 0;
if (isPipeCode(code)) {
pipesDirty = true;
diff --git a/src/sim/items.js b/src/sim/items.js
index 1bec43d..add7d55 100644
--- a/src/sim/items.js
+++ b/src/sim/items.js
@@ -153,6 +153,7 @@ function entTick() {
}
e.ix = cx; e.iy = cy;
}
+ if (rackIntake(e)) { ents.splice(k, 1); continue; }
const cx = e.ix, cy = e.iy;
// heat: the bar tends toward the temperature around it
const T = temp[Math.min(H - 1, cy + 1) * W + Math.min(W - 1, cx + 1)];
diff --git a/src/sim/machine-tick.js b/src/sim/machine-tick.js
index 8abe615..a185f04 100644
--- a/src/sim/machine-tick.js
+++ b/src/sim/machine-tick.js
@@ -46,6 +46,7 @@ function fanBlow(mm, sat) {
}
function machinesTick() {
procBlocksTick();
+ rackTick();
if (powerDirty && tick >= nextPowerBuild) { rebuildPower(); nextPowerBuild = tick + 6; }
if (brokenMachines.length) {
const ids = brokenMachines; brokenMachines = [];
diff --git a/src/sim/materials.js b/src/sim/materials.js
index 221c5fb..30b2119 100644
--- a/src/sim/materials.js
+++ b/src/sim/materials.js
@@ -11,8 +11,8 @@ const EMPTY = 0, GRASS = 1, DIRT = 2, LDIRT = 3, SAND = 4, GRAVEL = 5, CLAY = 6,
ZNV = 63, ZNORE = 64, ZINC = 65, MZN = 66, SCRZN = 67, BARZN = 68,
AGV = 69, AGORE = 70, SILVER = 71, MAG = 72, SCRAG = 73, BARAG = 74,
AUV = 75, AUORE = 76, GOLD = 77, MAU = 78, SCRAU = 79, BARAU = 80,
- BRONZE = 81, MBZ = 82, SCRBZ = 83, BARBZ = 84, BRASS = 85, MBS = 86, SCRBS = 87, BARBS = 88, DOCKW = 89, MSLAG = 90, PIPE = 91, BARGL = 92, CUTST = 93, BARSB = 94, GRINDER = 95, SAWB = 96, PLANK = 97, PRESSB = 98, GREENB = 99, BARBR = 100;
-const NMAT = 101;
+ BRONZE = 81, MBZ = 82, SCRBZ = 83, BARBZ = 84, BRASS = 85, MBS = 86, SCRBS = 87, BARBS = 88, DOCKW = 89, MSLAG = 90, PIPE = 91, BARGL = 92, CUTST = 93, BARSB = 94, GRINDER = 95, SAWB = 96, PLANK = 97, PRESSB = 98, GREENB = 99, BARBR = 100, RACKB = 101;
+const NMAT = 102;
const MD = [];
function def(id, o) {
@@ -64,6 +64,7 @@ def(SHAFT, { name: 'Shaft', col: [150, 110, 64], v: 6, k: 0.005, hard: 2, ign: 3
def(GATE, { name: 'Gate', col: [146, 74, 58], v: 6, k: 0.004, hard: 3, melt: 1750, meltTo: MAGMA, partMat: true });
def(GRINDER, { name: 'Grinder', col: [64, 70, 80], v: 4, k: 0.035, hard: 6, melt: 1450, meltTo: MST, partMat: true });
def(SAWB, { name: 'Saw', col: [74, 80, 90], v: 4, k: 0.035, hard: 6, melt: 1450, meltTo: MST, partMat: true });
+def(RACKB, { name: 'Bar rack', col: [74, 80, 90], v: 4, k: 0.035, hard: 6, melt: 1450, meltTo: MST, partMat: true });
def(PRESSB, { name: 'Brick press', col: [74, 80, 90], v: 4, k: 0.035, hard: 6, melt: 1450, meltTo: MST, partMat: true });
def(SPRING, { name: 'Spring', col: [92, 112, 128], v: 30, k: 0.012, hard: 9, yld: 0, desc: 'Groundwater seeps out of this rock whenever there is room next to it.' });
def(RSTONE, { name: 'Stone rubble', ph: PH_POWDER, col: [126, 125, 130], v: 30, dens: 1.8, k: 0.01, hard: 0, melt: 1250, meltTo: MAGMA, bag: true, desc: 'Broken stone. 16 grains make a stone block.' });
diff --git a/src/sim/pieces.js b/src/sim/pieces.js
index f7acd4c..8d9523a 100644
--- a/src/sim/pieces.js
+++ b/src/sim/pieces.js
@@ -4,7 +4,7 @@
// Everything you build snaps to a grid of 4x4-grain blocks. The world itself
// stays grain-level; only built pieces snap.
const BS = 4;
-const BP_WALL = 1, BP_BELT = 2, BP_TUBE = 3, BP_SHAFT = 4, BP_GRATE = 5, BP_GATE = 6, BP_FILTER = 7, BP_PIPE = 8, BP_PUMP = 9, BP_OUTLET = 10, BP_GRIND = 11, BP_SAW = 12, BP_PRESS = 13;
+const BP_WALL = 1, BP_BELT = 2, BP_TUBE = 3, BP_SHAFT = 4, BP_GRATE = 5, BP_GATE = 6, BP_FILTER = 7, BP_PIPE = 8, BP_PUMP = 9, BP_OUTLET = 10, BP_GRIND = 11, BP_SAW = 12, BP_PRESS = 13, BP_RACK = 14;
function isPipeCode(c) { return c === BP_PIPE || c === BP_PUMP || c === BP_OUTLET; }
// Filters: each filter block has a set of materials and a mode. In drop mode (inv 0)
// the chosen materials fall through and everything else rides along on top; in carry
@@ -61,6 +61,8 @@ const PIECES = [
desc: 'Grinds rubble, gravel, sand and dirt resting on top into sand and the odd grain of ore. Results drop out below; keep that space clear. Wider rows grind faster. Stack under a Saw to cut stone and grind the rest.' },
{ id: 'press', code: BP_PRESS, name: 'Brick press', kind: 'block', cost: [[CASH, 60]],
desc: 'Presses clay lumps resting on top into a green brick for every 8. They drop out underneath; put a belt or an open drop under it. Fire green bricks in a fire hotter than about 900°C to make Bricks. Wider rows press faster.' },
+ { id: 'rack', name: 'Bar rack', kind: 'block', code: BP_RACK, cost: [[CASH, 40]],
+ desc: 'Stores bars of any kind, 50 per block. Stack blocks into a column for more room. Bars that land on top go in; F opens the gate at the bottom and lets them out one at a time, oldest first, onto a belt below.' },
{ id: 'saw', code: BP_SAW, name: 'Saw', kind: 'block', cost: [[CASH, 90]],
desc: 'Cuts stone rubble into stone blocks and wood chunks into planks, one from every 8 grains resting on top. They drop out underneath and pile up there: put a belt or an open drop under it to carry them away. Wider rows cut faster. Stack over a Grinder to grind everything else.' },
{ id: 'wheel', name: 'Water wheel', kind: 'machine', bw: 3, bh: 3, cost: [[LWOOD, 48]], hidden: POWER_FREE,
diff --git a/src/sim/racks.js b/src/sim/racks.js
new file mode 100644
index 0000000..498f1fd
--- /dev/null
+++ b/src/sim/racks.js
@@ -0,0 +1,56 @@
+// Each rack block owns its bars; only the bottom of a column releases them.
+const RACK_CAP = 50;
+let rackState = new Map();
+function newRack() { return { q: [], n: 0, open: false, wait: 0 }; }
+function rackTop(b) { while (b >= BW && blockP[b - BW] === BP_RACK) b -= BW; return b; }
+function rackBottom(b) { while (b + BW < NB && blockP[b + BW] === BP_RACK) b += BW; return b; }
+function rackPush(s, m, n) {
+ const last = s.q[s.q.length - 1];
+ if (last && last[0] === m) last[1] += n; else s.q.push([m, n]);
+ s.n += n;
+}
+function rackShift(s, n) {
+ s.q[0][1] -= n; s.n -= n;
+ if (!s.q[0][1]) s.q.shift();
+}
+// Called with the moving bar unmarked, after its ordinary collision handling.
+function rackIntake(e) {
+ const y = e.iy + BAR_H;
+ if (e.vy !== 0 || Math.abs(e.vx) >= 0.05 || y >= H || y % BS) return false;
+ for (let x = Math.max(0, e.ix); x < Math.min(W, e.ix + BAR_W); x += BS - x % BS) {
+ const b = (y / BS) * BW + Math.floor(x / BS), left = (b % BW) * BS;
+ if (blockP[b] !== BP_RACK || b >= BW && blockP[b - BW] === BP_RACK || Math.min(e.ix + BAR_W, left + BS) - Math.max(e.ix, left) < BAR_W / 2) continue;
+ for (let q = rackBottom(b); q >= b; q -= BW) {
+ const s = rackState.get(q);
+ if (s.n < RACK_CAP) { rackPush(s, e.m, 1); return true; }
+ }
+ }
+ return false;
+}
+function rackRefill(b) {
+ const top = rackTop(b);
+ for (let q = b; q > top; q -= BW) {
+ const lower = rackState.get(q);
+ for (let above = q - BW; above >= top && lower.n < RACK_CAP; above -= BW) {
+ const upper = rackState.get(above);
+ while (lower.n < RACK_CAP && upper.n) {
+ const n = Math.min(RACK_CAP - lower.n, upper.q[0][1]);
+ rackPush(lower, upper.q[0][0], n); rackShift(upper, n);
+ }
+ }
+ }
+}
+function rackTick() {
+ for (const [b, s] of rackState) {
+ if (b + BW < NB && blockP[b + BW] === BP_RACK) continue;
+ if (!s.open) continue;
+ if (!s.n) rackRefill(b);
+ if (!s.n) continue;
+ if (s.wait < 20) s.wait++;
+ if (s.wait < 20) continue;
+ const x = (b % BW) * BS, y = (((b / BW) | 0) + 1) * BS;
+ if (!entFreeAt(x, y) || !spawnBar(s.q[0][0], x, y, 0, 0)) continue;
+ rackShift(s, 1); s.wait = 0;
+ rackRefill(b);
+ }
+}
diff --git a/src/sim/save.js b/src/sim/save.js
index 9ebadb8..716ff90 100644
--- a/src/sim/save.js
+++ b/src/sim/save.js
@@ -12,6 +12,7 @@ function serializeWorld() {
v: SAVE_VERSION, seed: worldSeed, W, H, tick, surfBase, underD, shallowD, lapse, tOff: TOFF,
saplings: saplings.map(s => [s.x, s.y, s.age, s.th, s.seed]),
economy: { money, debt, earned, recent: Array.from(mkRecent, v => Math.round(v)), log: shipLog, orders, ordersDone, nextOrderT, orderSeq },
+ racks: [...rackState].map(([b, s]) => [b, s.q.map(r => r.slice()), s.open]),
filters: [...filterCfg].map(([b, c]) => [b, c.m, c.inv]),
weather: { moist: (() => { const m = Float32Array.from(weather.moist); for (const f of flyers) if (f.rain) m[Math.min(m.length - 1, Math.max(0, (f.x / WBAND) | 0))] += 1; return Array.from(m, v => Math.round(v)); })(), raining: Array.from(weather.raining), shower: weather.shower, next: weather.next, wind: weather.wind, seen: weather.seen, base: weather.base },
bot: { x: bot.x, y: bot.y, lamp: bot.lamp },
@@ -38,6 +39,20 @@ function deserializeWorld(meta, buffers) {
mat.set(buffers.mat); life.set(buffers.life); flags.set(buffers.flags);
for (let i = 0; i < N; i++) if (mat[i] === SPRING) mat[i] = STONE; // springs are gone: older worlds keep plain rock there
blockP.set(buffers.blockP); blockV.set(buffers.blockV); filtersDirty = true; rebuildProcessors();
+ for (let b = 0; b < NB; b++) if (blockP[b] === BP_RACK) rackState.set(b, newRack());
+ for (const entry of Array.isArray(meta.racks) ? meta.racks : []) {
+ if (!Array.isArray(entry)) continue;
+ const [b, q, open] = entry;
+ if (!Number.isInteger(b) || !rackState.has(b) || !Array.isArray(q)) continue;
+ const s = newRack();
+ for (const r of q) {
+ if (!Array.isArray(r) || r.length !== 2) continue;
+ const [m, n] = r;
+ if (!Number.isInteger(m) || m <= 0 || m >= NMAT || PH[m] !== PH_ITEM || !Number.isInteger(n) || n <= 0 || n > RACK_CAP - s.n) continue;
+ rackPush(s, m, n);
+ }
+ s.open = open === true; rackState.set(b, s);
+ }
// pipes used to be solid copper blocks; now they are thin and let things past
for (let b = 0; b < NB; b++) if (blockP[b] === BP_PIPE) for (const i of blockCellList(b % BW, (b / BW) | 0)) if (mat[i] === PIPE) { mat[i] = EMPTY; part[i] = P_PIPE; }
for (const [b, m, inv] of meta.filters || []) if (blockP[b] === BP_FILTER) filterCfg.set(b, filterRec({ m, inv }));
diff --git a/src/sim/world.js b/src/sim/world.js
index 20a769f..1d4ea68 100644
--- a/src/sim/world.js
+++ b/src/sim/world.js
@@ -44,7 +44,7 @@ function alloc(w, h) {
mat = new Uint8Array(N); temp = new Float32Array(N); life = new Uint8Array(N); flags = new Uint8Array(N);
stamp = new Uint8Array(N); part = new Uint8Array(N); mach = new Uint16Array(N); digp = new Uint8Array(N);
noiseB = new Uint8Array(N); bgm = new Uint8Array(N); airS = new Uint16Array(N); netId = new Uint16Array(N); isPow = new Uint8Array(N); occ = new Uint8Array(N);
- BW = Math.ceil(w / BS); BH = Math.ceil(h / BS); NB = BW * BH; blockP = new Uint8Array(NB); blockV = new Uint8Array(NB); filtersDirty = true; filterCfg = new Map(); procDirty = true; procState.clear();
+ BW = Math.ceil(w / BS); BH = Math.ceil(h / BS); NB = BW * BH; blockP = new Uint8Array(NB); blockV = new Uint8Array(NB); filtersDirty = true; filterCfg = new Map(); procDirty = true; procState.clear(); rackState.clear();
ents = []; flyers = []; ghostBlocks = new Map(); ghostMolds = []; treeBases = []; saplings = [];
surfBase = Math.round(Math.min(h * 0.27, 175)); underD = h - surfBase; shallowD = 0; lapse = 0.36;
surfY = new Int16Array(w); roofY = new Int16Array(w); dirtY = new Int16Array(w); graniteY = new Int16Array(w); basaltY = new Int16Array(w); magmaY = new Int16Array(w);
diff --git a/src/ui/build-menu.js b/src/ui/build-menu.js
index 1b20951..edf1a18 100644
--- a/src/ui/build-menu.js
+++ b/src/ui/build-menu.js
@@ -4,7 +4,7 @@ const BUILD_CATS = [
['Structure', ['wall', 'grate', 'gate']],
['Moving', ['belt', 'filter', 'tube']],
['Liquids', ['pipe', 'pump', 'outlet']],
- ['Machines', ['fan', 'grinder', 'saw', 'press', 'silo']],
+ ['Machines', ['fan', 'grinder', 'saw', 'press', 'rack', 'silo']],
['Molds', ['ingotmold', 'bitmold', 'tankmold']],
];
const BUILD_ORDER = BUILD_CATS.flatMap(c => c[1]);
@@ -22,6 +22,7 @@ const PIECE_ICONS = {
grinder: '',
press: '',
saw: '',
+ rack: '',
silo: '',
ingotmold: '',
bitmold: '',
diff --git a/src/ui/info.js b/src/ui/info.js
index e444d0c..c57ed00 100644
--- a/src/ui/info.js
+++ b/src/ui/info.js
@@ -92,6 +92,18 @@ function hoverInfo(ax, ay) {
if (mm.type === 'wheel' || mm.type === 'engine') t2.push(`making ${mm.out.toFixed(1)} power`);
if (mm.type === 'fan') { t2.push(mm.on ? 'on' : 'off · press F'); if (mm.hot > 760) { t2.push(`${mm.hot}°C · melts at 1085°C`); warn = 'too hot'; } }
if (mm.type === 'silo') t2.push(mm.sn ? `holds ${mm.sn.toLocaleString('en-US')} / ${SILO_CAP.toLocaleString('en-US')} ${MD[mm.sm].name.toLowerCase()}` : `empty (holds ${SILO_CAP.toLocaleString('en-US')} grains of one material)`, mm.open ? 'gate open · F shuts it' : 'gate shut · F opens it');
+ } else if (b === BP_RACK) {
+ const top = rackTop((ay >> 2) * BW + (ax >> 2)), counts = new Map();
+ let n = 0, capacity = 0;
+ for (let q = top; q < NB && blockP[q] === BP_RACK; q += BW) {
+ const s = rackState.get(q); capacity += RACK_CAP; n += s.n;
+ for (const [m, count] of s.q) counts.set(m, (counts.get(m) || 0) + count);
+ }
+ t1 = 'Bar rack'; t2.push(`${n} / ${capacity} bars`);
+ t2.push(...[...counts].slice(0, 4).map(([m, count]) => `${count} ${MD[m].name.toLowerCase()}`));
+ if (counts.size > 4) t2.push('+ more');
+ t2.push(rackState.get(top).open ? 'open: letting bars out' : 'closed: F lets bars out');
+ if (n === capacity) t2.push('full');
} else if (isProcCode(b)) {
const s = procState.get((ay >> 2) * BW + (ax >> 2)), saw = b === BP_SAW, press = b === BP_PRESS;
t1 = press ? 'Brick press' : saw ? 'Saw' : 'Grinder';
diff --git a/src/ui/notebook.js b/src/ui/notebook.js
index 30b65fa..2366769 100644
--- a/src/ui/notebook.js
+++ b/src/ui/notebook.js
@@ -184,7 +184,7 @@ function controlsHtml() {
const tools = [
['1 Resonance drill', 'Hold left click to send pulses from the bot toward the cursor: rock they shake hard enough shatters and falls as rubble, ore, coal or scrap, and trees come apart into wood chunks and acorns. Q and E set the pulse width: narrow bores a tunnel fast, wide clears a big cone slowly, medium is in between. R over a material tunes the drill to it, so only that shatters (great for pulling a vein out of the rock); R again untunes. Built blocks are never harmed. Better bits dig harder rock, faster and further.'],
['2 Pneumatic nozzle', "Left drag picks up the selection, or what you click if nothing is selected. Right click blows the selection. Click a bag name or Q/E selects one; Ctrl+click mixes. Hold Ctrl to see everything found. Clear removes the selection. Bars blow alone. Blowing out the last of a material deselects it. Shift while not flying takes everything; Shift + right click blows everything loose in the bag."],
- ['3 Build', 'Places pieces on a grid of 4×4 blocks: wall blocks, belts, tubes, filters, grates, gates, pipes, pumps, outlets, Grinders, Saws and Brick presses, machines (fans and silos) and molds. Drag for a line, hold Shift while dragging for a filled rectangle. Right click takes a piece back. Your bot flies through anything you build.'],
+ ['3 Build', 'Places pieces on a grid of 4×4 blocks: wall blocks, belts, tubes, filters, grates, gates, pipes, pumps, outlets, Grinders, Saws, Brick presses and Bar racks, machines (fans and silos) and molds. Drag for a line, hold Shift while dragging for a filled rectangle. Right click takes a piece back. Your bot flies through anything you build.'],
['4 Igniter', 'Heats a small spot to 640°C, and its flame reaches a few grains into a pile. Enough to light coal and wood that touch air.'],
['Copy, cut and paste', 'Hold C (copy) or X (cut) and drag a box over what you built; let go and it is picked up, ready to paste. Cut takes it down and refunds it to your bag. Click to place ghosts: your bot builds them from your bag when it is close. R flips, V pastes again, right click or Esc stops. Ctrl+Z undoes building. The notebook (B) keeps saved blueprints and a few starter designs.'],
];
diff --git a/test/play.py b/test/play.py
index a8f3408..6c06133 100644
--- a/test/play.py
+++ b/test/play.py
@@ -782,8 +782,9 @@ with sync_playwright() as p:
check('and Ctrl+Z swaps the belt back', ev(f'blockP[({bby} + 1) * BW + {bbx} - 4]') == ev('BP_BELT') and not ev(f"machines.some(m => m && m.type === 'fan' && m.x === ({bbx} - 4) * 4)"), ev('GAME.status'))
# 7a. hopper blocks: the Machines tab has Grinder and Saw instead of the Refiner and Stone cutter; drag a row
- check('the Machines tab has Grinder and Saw but not the Refiner or Stone cutter', ev("(() => { const ids = BUILD_CATS.find(c => c[0] === 'Machines')[1]; return ids.includes('grinder') && ids.includes('saw') && ids.includes('press') && !ids.includes('refiner') && !ids.includes('cutter'); })()"))
+ check('the Machines tab has Grinder, Saw, Press and Bar rack', ev("(() => { const ids = BUILD_CATS.find(c => c[0] === 'Machines')[1]; return ids.includes('grinder') && ids.includes('saw') && ids.includes('press') && ids.includes('rack') && !ids.includes('refiner') && !ids.includes('cutter'); })()"))
ev('money = 1000')
+ check('Bar rack has a Machines menu tile', select('3', "piece().id === 'rack'") and pg.locator('[data-piece="rack"]').count() == 1)
ok = select('3', "piece().id === 'grinder'")
ev(f"for (let y = {(bby+3)*4}; y < {(bby+4)*4}; y++) for (let x = {(bbx-8)*4}; x < {(bbx+4)*4}; x++) if (!blockP[((y/4)|0)*BW + ((x/4)|0)]) setCell(y*W+x, EMPTY, 20, 0);")
drag(lambda: blk(bbx - 6, bby + 3), lambda: blk(bbx - 4, bby + 3))
@@ -802,6 +803,19 @@ with sync_playwright() as p:
}} finally {{ removeBlock(bx, by); removeBlock(bx + 1, by); rebuildProcessors(); }}
}})()'''))
+ check('rack hover shows stored kinds, capacity and gate', ev(f'''(() => {{
+ const bx = {bbx} - 6, by = {bby} + 3;
+ if (placeBlock(BP_RACK, 0, bx, by, true)) return false;
+ try {{
+ const s = rackState.get(by * BW + bx); rackPush(s, BARCU, 2); rackPush(s, BARFE, 1);
+ const before = RS; drawRackBlocks();
+ const h = hoverInfo(bx * BS, by * BS);
+ interactAt(bx * BS, by * BS);
+ const opened = hoverInfo(bx * BS, by * BS);
+ return RS === before && h.t1 === 'Bar rack' && h.t2.includes('3 / 50 bars') && h.t2.some(t => t.includes('2 copper bar')) && h.t2.some(t => t.includes('1 iron bar')) && h.t2.includes('closed: F lets bars out') && opened.t2.includes('open: letting bars out');
+ }} finally {{ removeBlock(bx, by); }}
+ }})()'''))
+
# 7b. filter: choose several materials with F, place a line, replace belts with G, edit the whole line
ev('seen[CUORE] = 1; seen[FEORE] = 1; seen[SAND] = 1; money = 1000; GAME.filterCfg = { m: [], inv: 0 };')
ok = select('3', "piece().id === 'filter'")
diff --git a/test/t_racks.js b/test/t_racks.js
new file mode 100644
index 0000000..5d91004
--- /dev/null
+++ b/test/t_racks.js
@@ -0,0 +1,76 @@
+// Rack intake, FIFO storage, gate, removal and saved-state validation.
+const { load, suite, flatWorld } = require('./harness');
+const { run } = load();
+const { check, done } = suite();
+function fresh(n = 1) {
+ flatWorld(run, 240, 160, 120);
+ run(`money = 5000; for (let y = 20; y < 20 + ${n}; y++) placeBlock(BP_RACK, 0, 10, y, true);`);
+}
+function drop(m = 'BARCU', x = 40) {
+ run(`spawnBar(${m}, ${x}, 72); for (let t = 0; t < 100; t++) entTick();`);
+}
+fresh(); drop();
+check('a falling bar enters the rack', run('ents.length === 0 && rackState.get(20 * BW + 10).n === 1'));
+fresh(); for (let n = 0; n < 51; n++) drop();
+check('full rack leaves the 51st bar on top', run('rackState.get(20 * BW + 10).n === 50 && ents.length === 1 && ents[0].iy === 78'));
+fresh(2); for (let n = 0; n < 100; n++) drop();
+check('two blocks hold 100 bars', run('[...rackState.values()].every(s => s.n === 50) && !ents.length'));
+check('F on the top opens the whole column', run("interactAt(40, 80) === 'Rack open.' && [...rackState.values()].every(s => s.open)"));
+run('for (let t = 0; t < 20; t++) rackTick();');
+check('oldest bars refill the bottom after release', run('ents.length === 1 && rackState.get(21 * BW + 10).n === 50 && rackState.get(20 * BW + 10).n === 49'));
+run('for (let t = 0; t < 100; t++) rackTick();');
+check('occupied outlet stops the flow', run('ents.length === 1'));
+fresh(); drop('BARCU'); drop('BARFE'); drop('BARCU');
+check('mixed kinds keep FIFO runs', run('JSON.stringify(rackState.get(20 * BW + 10).q) === JSON.stringify([[BARCU, 1], [BARFE, 1], [BARCU, 1]])'));
+run('interactAt(40, 80);');
+for (const m of ['BARCU', 'BARFE', 'BARCU']) {
+ run('for (let t = 0; t < 20; t++) rackTick();');
+ check('gate releases oldest ' + m, run(`ents.length === 1 && ents[0].m === ${m} && ents[0].iy === 84`));
+ run('removeEnt(ents[0]);');
+}
+check('empty rack stays open', run('rackState.get(20 * BW + 10).open'));
+drop(); run('interactAt(40, 80); for (let t = 0; t < 40; t++) rackTick();');
+check('closed gate stops output', run('ents.length === 0 && rackState.get(20 * BW + 10).n === 1'));
+fresh(2); drop('BARFE'); run('rackState.get(20 * BW + 10).q = [[BARCU, 2]]; rackState.get(20 * BW + 10).n = 2;');
+run('removeBlock(10, 21);');
+check('removal returns contents and cash without moving upper contents', run('bag[BARFE] === 1 && money === 5040 && rackState.get(20 * BW + 10).n === 2'));
+run('interactAt(40, 80); globalThis.savedRack = serializeWorld(); deserializeWorld(savedRack.meta, savedRack.buffers);');
+check('save keeps queues and gate', run('rackState.get(20 * BW + 10).n === 2 && rackState.get(20 * BW + 10).open'));
+run('savedRack.meta.racks = [null, {}, [-1, [[BARCU, 1]], true], [20 * BW + 10, [[BARCU, -1], [STONE, 3], [BARFE, 51], [BARCU, Infinity], null], true]]; deserializeWorld(savedRack.meta, savedRack.buffers);');
+check('corrupt rack entries are ignored', run('rackState.get(20 * BW + 10).n === 0'));
+fresh(); drop('BARCU', 42);
+check('half of a bar over the rack is enough', run('ents.length === 0'));
+fresh(); drop('BARCU', 43);
+check('less than half cannot enter', run('rackState.get(20 * BW + 10).n === 0 && ents.length === 1'));
+fresh(); drop();
+run('interactAt(40, 80); for (let t = 0; t < 19; t++) rackTick();');
+check('gate waits 20 ticks for each bar', run('ents.length === 0'));
+run('setCell(84 * W + 40, STONE, 20, 0); for (let t = 0; t < 40; t++) rackTick();');
+check('solid ground under the outlet also blocks it', run('ents.length === 0 && rackState.get(20 * BW + 10).n === 1'));
+run('setCell(84 * W + 40, EMPTY, 20, 0); rackTick();');
+check('clearing the outlet resumes output', run('ents.length === 1'));
+fresh(); drop('BARFE');
+run('bag.fill(0); bag[SAND] = bagCap(); bagTotal = bagCap(); removeBlock(10, 20);');
+check('a full bag drops stored bars near the bot', run('bag[BARFE] + ents.filter(e => e.m === BARFE).length === 1 && !rackState.size'));
+fresh();
+run('interactAt(40, 80); placeBlock(BP_RACK, 0, 10, 21, true);');
+check('extending an open rack keeps its gate open', run('[...rackState.values()].every(s => s.open)'));
+fresh();
+run('removeBlock(10, 20); undoRecord(\'rack\', () => placeBlock(BP_RACK, 0, 10, 20, false)); undoLast();');
+check('rack building can be undone and refunded', run('!blockP[20 * BW + 10] && !rackState.size && money === 5040'));
+fresh();
+run('globalThis.oldRack = serializeWorld(); delete oldRack.meta.racks; deserializeWorld(oldRack.meta, oldRack.buffers);');
+check('missing rack metadata defaults to empty and closed', run('rackState.get(20 * BW + 10).n === 0 && !rackState.get(20 * BW + 10).open'));
+fresh(3);
+run('rackPush(rackState.get(20 * BW + 10), BARFE, 3); rackPush(rackState.get(22 * BW + 10), BARCU, 1); interactAt(40, 84); for (let t = 0; t < 20; t++) rackTick();');
+check('refilling crosses an empty middle block without losing FIFO order', run('ents[0].m === BARCU && rackState.get(22 * BW + 10).n === 3 && rackState.get(20 * BW + 10).n === 0'));
+for (const delivery of ['belt', 'tube', 'thrown', 'mold']) {
+ fresh();
+ if (delivery === 'belt') run('for (let bx = 6; bx <= 9; bx++) placeBlock(BP_BELT, 1, bx, 20, true); spawnBar(BARCU, 30, 78);');
+ if (delivery === 'tube') run('placeBlock(BP_TUBE, 2, 10, 17, true); placeBlock(BP_TUBE, 2, 10, 18, true); spawnBar(BARCU, 40, 68);');
+ if (delivery === 'thrown') run('spawnBar(BARCU, 34, 66, 0.35, -0.3);');
+ if (delivery === 'mold') run("addMold('ingot', 10, 17, RSTONE, true); for (const i of molds[0].cavs[0].slice(0, 8)) setCell(i, COPPER, 20, F_CAST); checkMolds();");
+ run('for (let t = 0; t < 300; t++) simTick();');
+ check(delivery + ' delivery reaches storage', run('rackState.get(20 * BW + 10).n > 0'), run('JSON.stringify(ents.map(e => [e.m,e.ix,e.iy,e.vx,e.vy]))'));
+}
+done();