Sloped belts hold their load with moving cleats (t42); sloped filters are downhill shaking screens (t43)

The magic grip is gone: cleats every 8 face cells, 6 grains tall, carry
pockets uphill (about 71 grains/s against 90 flat) and overflow rolls
back. Sloped filters always run downhill; grains travel by gravity and
a shake, and sorting stays exact. Drawing needs another pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 19:47:59 -04:00
co-authored by Claude Opus 5.5
parent 1f8a89db6a
commit 578b55316b
28 changed files with 425 additions and 89 deletions
+21
View File
@@ -411,6 +411,27 @@ above, including:
shake; none climbs a 45° slope. So filters are flat or downhill shake; none climbs a 45° slope. So filters are flat or downhill
only; to sort while lifting, run a sloped belt up, then a filter. Not built yet. only; to sort while lifting, run a sloped belt up, then a filter. Not built yet.
- **Sidewall belts with moving cleats (t42, 2026-10-26).** To replace the old full-column
grip, use moving barrier lines rather than material cells: the barrier mask blocks normal
powder movement without overwriting grains, terrain or parts. Cleats stand perpendicular
to the face, spaced 8 horizontal face cells apart and 6 grains tall (integer normal reach
4); named constants keep these tunable. A finite periodic pocket envelope carries only
what fits, stepping every other tick. End pockets continue while the cleat rounds the band.
Between steps no grains are stamped: bare face slides and excess rolls back. Water drains;
bars stay level. Dark corrugated far walls sit behind visible grains; drawing uses the
actual barrier mask, including after save/load. The envelope is a discrete transport model,
not a calculation of contact forces. A generous flat feed delivers about 71 grains/s uphill
versus 90 flat; accepting that reduction remains the owner's playtest decision.
- **Inclined vibrating screens built (t43, 2026-10-26).** Diagonal filters always travel
downhill, even when dragged uphill. R leaves a picked slope unchanged; flipping a blueprint
mirrors its slope and keeps downhill travel. Placement and filter settings enforce the
same direction. Sloped filters have neither column carry nor grip: gravity moves grains,
with one mesh-contact nudge every 4 ticks (15 downhill steps/s plus gravity). Unselected
bars step on that same cadence; selected bars fall through. Flat filters retain their
linear transport. Sorting both slope shapes in either mode conserves the complete mix.
Both looks use a sturdy mesh frame with a small tick-based shake only while loaded;
idle mesh has no running belt animation. No old uphill-save migration was added.
## Ideas not built yet ## Ideas not built yet
- **Concrete**, as a stronger wall material from rubble. - **Concrete**, as a stronger wall material from rubble.
+23 -15
View File
@@ -171,7 +171,7 @@ dist/, shots/ build output and screenshots (not committed)
| Starter furnace designs | `src/sim/progress.js` (`STARTER_DESIGNS`, `smelterBlueprint`) | | Starter furnace designs | `src/sim/progress.js` (`STARTER_DESIGNS`, `smelterBlueprint`) |
| Falling sand, liquids, gases | `src/sim/cells.js` | | Falling sand, liquids, gases | `src/sim/cells.js` |
| Heat, melting, fire, reactions, alloys, steel | `src/sim/heat.js` | | Heat, melting, fire, reactions, alloys, steel | `src/sim/heat.js` |
| Belts, filters, screw conveyors | `src/sim/conveyors.js`, `src/sim/machine-tick.js` | | Belts, filters, screw conveyors | `src/sim/conveyors.js`, `src/sim/slope-conveyors.js` (cleat barriers, pocket depth and screen nudges), `src/sim/machine-tick.js` |
| Fans | `src/sim/machine-tick.js` (`fanBlow`) | | Fans | `src/sim/machine-tick.js` (`fanBlow`) |
| Refiner, Stone cutter, Silo | `src/sim/processors.js` | | Refiner, Stone cutter, Silo | `src/sim/processors.js` |
| Pipes, pumps, outlets | `src/sim/pipes.js` | | Pipes, pumps, outlets | `src/sim/pipes.js` |
@@ -205,9 +205,9 @@ Material definitions: ids, properties and metal families. ids, and drill/tank ti
Material tables: typed properties, casting groups, flags, parts and tool tiers. Typed property tables Material tables: typed properties, casting groups, flags, parts and tool tiers. Typed property tables
- variables: PH, DENS, KC, ICAP, CASTMETAL, METAL_TIER, SOLIDMETAL, F_NAT, F_CAST, F_BURN, P_NONE, P_SCREW_U, P_SCREW_D, P_SCREW_L, P_SCREW_R, P_GRATE, P_GOPEN, P_WHEEL, P_INTAKE, P_FILTER, P_PIPE, P_SCREW_UR, P_SCREW_DR, P_SCREW_DL, P_SCREW_UL, SCREW_PARTS, SCREW_DIRS, IS_SCREW, SCREW_DX, SCREW_DY, BIT_TIERS, TANK_TIERS, REACH - variables: PH, DENS, KC, ICAP, CASTMETAL, METAL_TIER, SOLIDMETAL, F_NAT, F_CAST, F_BURN, P_NONE, P_SCREW_U, P_SCREW_D, P_SCREW_L, P_SCREW_R, P_GRATE, P_GOPEN, P_WHEEL, P_INTAKE, P_FILTER, P_PIPE, P_SCREW_UR, P_SCREW_DR, P_SCREW_DL, P_SCREW_UL, SCREW_PARTS, SCREW_DIRS, IS_SCREW, SCREW_DX, SCREW_DY, BIT_TIERS, TANK_TIERS, REACH
### src/sim/pieces.js (176 lines) ### src/sim/pieces.js (177 lines)
Everything the player can build (PIECES): blocks, machines and molds, with costs and descriptions; filter settings; wall materials and their cost. Everything the player can build (PIECES): blocks, machines and molds, with costs and descriptions; filter settings; wall materials and their cost.
- functions: isSlopeBelt, isFilter, isBelt, beltGrade, slopeShape, beltFaceY, beltDirection, isSlope, isWall, slopeCell, slopeCode, isPipeCode, filterable, cleanFilterCfg, filterRec, filterPassAt, filterInfo, sameFilter, wallCost - functions: isSlopeBelt, isFilter, isBelt, beltGrade, slopeShape, beltFaceY, filterVariant, beltDirection, isSlope, isWall, slopeCell, slopeCode, isPipeCode, filterable, cleanFilterCfg, filterRec, filterPassAt, filterInfo, sameFilter, wallCost
- variables: BS, BP_WALL, BP_BELT, BP_SCREW, BP_SHAFT, BP_GRATE, BP_GATE, BP_FILTER, BP_PIPE, BP_PUMP, BP_OUTLET, BP_GRIND, BP_SAW, BP_PRESS, BP_RACK, BP_SLOPE_BL, BP_SLOPE_BR, BP_SLOPE_TL, BP_SLOPE_TR, BP_BELT_UP, BP_BELT_DOWN, BP_FILTER_UP, BP_FILTER_DOWN, filterCfg, WALL_OF, WALL_LIST, BAR_OF, GRAINS_PER_BAR, POWER_FREE, CASH, PIECES, PIECE_BY_ID, PIECE_BY_CODE, BELT_POWER, SCREW_POWER, FAN_POWER, WHEEL_K, ENGINE_K, MOLDS - variables: BS, BP_WALL, BP_BELT, BP_SCREW, BP_SHAFT, BP_GRATE, BP_GATE, BP_FILTER, BP_PIPE, BP_PUMP, BP_OUTLET, BP_GRIND, BP_SAW, BP_PRESS, BP_RACK, BP_SLOPE_BL, BP_SLOPE_BR, BP_SLOPE_TL, BP_SLOPE_TR, BP_BELT_UP, BP_BELT_DOWN, BP_FILTER_UP, BP_FILTER_DOWN, filterCfg, WALL_OF, WALL_LIST, BAR_OF, GRAINS_PER_BAR, POWER_FREE, CASH, PIECES, PIECE_BY_ID, PIECE_BY_CODE, BELT_POWER, SCREW_POWER, FAN_POWER, WHEEL_K, ENGINE_K, MOLDS
### src/sim/economy.js (152 lines) ### src/sim/economy.js (152 lines)
@@ -220,7 +220,7 @@ Starter furnace designs and the milestone list shown in the notebook.
- functions: smelterBlueprint - functions: smelterBlueprint
- variables: STARTER_DESIGNS, MILESTONES - variables: STARTER_DESIGNS, MILESTONES
### src/sim/world.js (104 lines) ### src/sim/world.js (105 lines)
World state: the per-cell typed arrays, alloc(), ambient temperature by depth, waking. World state: the per-cell typed arrays, alloc(), ambient temperature by depth, waking.
- functions: emit, bagCap, discover, alloc, depthU, depthY, meanSurface, computeAmbient, fillNoise, wakeXY, wakeHeatXY, wakeIdx, wakeHeatIdx - functions: emit, bagCap, discover, alloc, depthU, depthY, meanSurface, computeAmbient, fillNoise, wakeXY, wakeHeatXY, wakeIdx, wakeHeatIdx
- variables: W, H, N, CW, CH, NCH, mat, temp, life, flags, stamp, part, mach, digp, noiseB, airS, netId, isPow, occ, surfY, dirtY, graniteY, basaltY, magmaY, roofY, AMB, worldSeed, surfBase, underD, shallowD, lapse, BW, BH, NB, blockP, blockV, ents, bgm, flyers, ghostBlocks, ghostMolds, awake, hot, hotLvl, powderArea, roofDirty, loadWakeOrder, loadWakePending, loadWakeIndex, tick, tickStamp, machines, molds, nets, pcells, powerDirty, beltL, beltR, screwLists, brokenMachines, ghostMachines, ghostBlocked, EV, bot, bag, bagTotal, player, seen, ledger, stats - variables: W, H, N, CW, CH, NCH, mat, temp, life, flags, stamp, part, mach, digp, noiseB, airS, netId, isPow, occ, surfY, dirtY, graniteY, basaltY, magmaY, roofY, AMB, worldSeed, surfBase, underD, shallowD, lapse, BW, BH, NB, blockP, blockV, ents, bgm, flyers, ghostBlocks, ghostMolds, awake, hot, hotLvl, powderArea, roofDirty, loadWakeOrder, loadWakePending, loadWakeIndex, tick, tickStamp, machines, molds, nets, pcells, powerDirty, beltL, beltR, screwLists, brokenMachines, ghostMachines, ghostBlocked, EV, bot, bag, bagTotal, player, seen, ledger, stats
@@ -230,7 +230,7 @@ Exploration only: never changes grains or the random stream.
- functions: fogAlloc, fogMark, fogSeen, fogInit, fogLook, fogRevealAround, fogRevealAll - functions: fogAlloc, fogMark, fogSeen, fogInit, fogLook, fogRevealAround, fogRevealAll
- variables: FOG, FOG_SENSE, FOG_SENSE_FADE, fog, FW, FH - variables: FOG, FOG_SENSE, FOG_SENSE_FADE, fog, FW, FH
### src/sim/cells.js (208 lines) ### src/sim/cells.js (218 lines)
Cell helpers (setCell, move, enter) and the falling-sand movement of powders, liquids and gases (simMove). Cell helpers (setCell, move, enter) and the falling-sand movement of powders, liquids and gases (simMove).
- functions: setCell, swapCells, move, enter, powder, passable, liquid, gas, wakeBotPowder, simMove - functions: setCell, swapCells, move, enter, powder, passable, liquid, gas, wakeBotPowder, simMove
- variables: SORTP, SORTP_FUEL, LSTEP, LFILM, CORNER_SEAL - variables: SORTP, SORTP_FUEL, LSTEP, LFILM, CORNER_SEAL
@@ -260,15 +260,20 @@ Pipes, pumps and outlets: each joined line holds one liquid as a count, not as g
- functions: sideOf, pipeClosedSide, pipeJoins, rebuildPipes, pipeNetOf, pipeTakeShare, faceCell, pumpDraw, outletPour, pipesTick - 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 - variables: PIPE_CAP, PUMP_RATE, OUTLET_RATE, PUMP_MAX_T, SIDE_V, PUMP_V, OUTLET_V, pipeNets, pipeNetAt, pipesDirty
### src/sim/machine-tick.js (154 lines) ### src/sim/machine-tick.js (155 lines)
Per-tick machine work: steam engines, fans, belts, filters, screw conveyors (machinesTick). Per-tick machine work: steam engines, fans, belts, filters, screw conveyors (machinesTick).
- functions: engineIntake, fanBlow, machinesTick - functions: engineIntake, fanBlow, machinesTick
### src/sim/conveyors.js (202 lines) ### src/sim/conveyors.js (197 lines)
How belts, filters and screw conveyors move what is on or in them. What rests on a belt or filter rides along: the whole stack standing on the carrying surface steps one cell toward d together (belts run front of the line first, so the way ahead is clear). If a powder grain is blocked, the load above it stands still and the belt slips underneath. A filter's own material drops through it instead, unless it is already full. How belts, filters and screw conveyors move what is on or in them. What rests on a belt or filter rides along: the whole stack standing on the carrying surface steps one cell toward d together (belts run front of the line first, so the way ahead is clear). If a powder grain is blocked, the load above it stands still and the belt slips underneath. A filter's own material drops through it instead, unless it is already full.
- functions: conveyorStep, carryColumn, holdSlopeColumn, beltDrain, launchGrain, rebuildFilters, filterCarry, carryBeltsAndFilters, setFilter, filterRun, roomFor, screwSpread, screwIntake, diagonalScrewNext - functions: conveyorStep, carryColumn, beltDrain, launchGrain, rebuildFilters, filterCarry, carryBeltsAndFilters, setFilter, filterRun, roomFor, screwSpread, screwIntake, diagonalScrewNext
- variables: CARRY_DEPTH, filterR, filterL, filtersDirty - variables: CARRY_DEPTH, filterR, filterL, filtersDirty
### src/sim/slope-conveyors.js (51 lines)
Cleats are moving barriers, not material cells. A cleat supports a finite pocket; the bare face never holds grains.
- functions: cleatFoot, pocketDepth, slopeCleatsTick, screenNudge
- variables: CLEAT_SPACING, CLEAT_HEIGHT, CLEAT_REACH, SCREEN_NUDGE_TICKS, cleatCarrying, cleatMask, cleatCells, slopePhase
### src/sim/blocks.js (219 lines) ### src/sim/blocks.js (219 lines)
Building blocks: placing, integrity, removing, lines and rectangles of blocks. Building blocks: placing, integrity, removing, lines and rectangles of blocks.
- functions: blockOf, blockCellList, pieceCost, setSlopeSeal, slopeSeal, slopeSealOwner, wallCells, canPlaceBlock, placeBlock, blockIntegrity, removeWallCell, removeBlock, diagonalDrag, blockLine, blockRect, blockInReach, blockIs, placeBlocks - functions: blockOf, blockCellList, pieceCost, setSlopeSeal, slopeSeal, slopeSealOwner, wallCells, canPlaceBlock, placeBlock, blockIntegrity, removeWallCell, removeBlock, diagonalDrag, blockLine, blockRect, blockInReach, blockIs, placeBlocks
@@ -342,7 +347,7 @@ World generation helpers: noise and removal of tiny hard rock specks.
World generation: staged terrain, ore, caves and placement, with synchronous and async entry points. World generation: staged terrain, ore, caves and placement, with synchronous and async entry points.
- functions: generateWorld, generateWorldAsync - functions: generateWorld, generateWorldAsync
### src/sim/save.js (177 lines) ### src/sim/save.js (179 lines)
Saving and loading a world (serializeWorld / deserializeWorld) and old-save migration. Saving and loading a world (serializeWorld / deserializeWorld) and old-save migration.
- functions: serializeWorld, deserializeWorld, startLoadWake, wakeLoadArea, wakeLoadNearBot, loadWakeTick - functions: serializeWorld, deserializeWorld, startLoadWake, wakeLoadArea, wakeLoadNearBot, loadWakeTick
- variables: SAVE_VERSION, TOFF - variables: SAVE_VERSION, TOFF
@@ -356,12 +361,12 @@ Drawing (src/render/): browser only. This file: the renderer state R, colour tab
Propagated coloured light from glowing and burning cells. Propagated coloured light from glowing and burning cells.
- functions: builtForLight, looseForLight, computeLight - functions: builtForLight, looseForLight, computeLight
### src/render/slope-belts.js (127 lines) ### src/render/slope-belts.js (150 lines)
Drawing only: the face stays on its simulation cells; the band hangs below it. Drawing only: the face stays on its simulation cells; the band hangs below it.
- functions: slopeBeltConnections, flatBeltCaps, prepareSlopeBelts, slopeBeltSegment, slopeBeltCorner, slopeBeltShape, slopeBeltAt, slopeBeltColor - functions: slopeBeltConnections, flatBeltCaps, prepareSlopeBelts, slopeBeltSegment, slopeBeltCorner, slopeBeltShape, slopeBeltAt, slopeBeltColor, screenLoaded, slopeDetailAt
- variables: slopeBeltJoins, slopeBeltHalo, beltCaps, slopeAcross, slopeAlong, slopeDistance - variables: slopeBeltJoins, slopeBeltHalo, beltCaps, slopeAcross, slopeAlong, slopeDistance
### src/render/world.js (440 lines) ### src/render/world.js (447 lines)
Scale colour without changing the smooth-look class. Scale colour without changing the smooth-look class.
- functions: fogColor, diagonalScrewJoins, diagonalScrewShape, diagonalScrewBlock, drawWorld - functions: fogColor, diagonalScrewJoins, diagonalScrewShape, diagonalScrewBlock, drawWorld
- variables: SCREW_CLASS, diagonalJoins, diagonalAcross, diagonalAlong, diagonalCap - variables: SCREW_CLASS, diagonalJoins, diagonalAcross, diagonalAlong, diagonalCap
@@ -379,7 +384,7 @@ Smooth look: WebGL setup, texture uploads, drawing and adaptive resolution.
- functions: smCompile, smoothAvailable, smUpload, smoothDraw, smoothPace - functions: smCompile, smoothAvailable, smUpload, smoothDraw, smoothPace
- variables: SM, smBytes - variables: SM, smBytes
### src/render/slope-belt-shader.js (84 lines) ### src/render/slope-belt-shader.js (85 lines)
Smooth slope shading, kept beside the shared CPU geometry in slope-belts.js. Smooth slope shading, kept beside the shared CPU geometry in slope-belts.js.
- variables: SM_SLOPE_HELPERS, SM_SLOPE_DRAW - variables: SM_SLOPE_HELPERS, SM_SLOPE_DRAW
@@ -444,7 +449,7 @@ The build menu along the bottom: categories, piece tiles and their icons.
- functions: pieceIcon, catOf, selectBuildCat, cycleBuildCat, buildVariantChips, renderBuildBar, setupBuildBar - functions: pieceIcon, catOf, selectBuildCat, cycleBuildCat, buildVariantChips, renderBuildBar, setupBuildBar
- variables: BUILD_CATS, BUILD_ORDER, PIECE_ICONS, lastBuildKey, lastBuildMenu - variables: BUILD_CATS, BUILD_ORDER, PIECE_ICONS, lastBuildKey, lastBuildMenu
### src/ui/selection.js (119 lines) ### src/ui/selection.js (120 lines)
Tool selection: cycling, drill tuning, piece variants and costs. Tool selection: cycling, drill tuning, piece variants and costs.
- functions: pieceSize, pieceUsesWall, setDrillWidth, tuneUnderCursor, cycleItem, cycleWall, rotate, toggleBlow, pickFilterUnderCursor, toggleFilterMat, setBrush, pieceV, pieceExtra, filterBlockAt, chipCost, pieceCostNow, pieceCode, turnSlope - functions: pieceSize, pieceUsesWall, setDrillWidth, tuneUnderCursor, cycleItem, cycleWall, rotate, toggleBlow, pickFilterUnderCursor, toggleFilterMat, setBrush, pieceV, pieceExtra, filterBlockAt, chipCost, pieceCostNow, pieceCode, turnSlope
@@ -476,7 +481,7 @@ Gamepad input.
- functions: padPressed, pollPad - functions: padPressed, pollPad
- variables: PAD - variables: PAD
### src/ui/previews.js (234 lines) ### src/ui/previews.js (248 lines)
Cursor previews: shared grid helpers, air streaks and tool preview dispatch. Cursor previews: shared grid helpers, air streaks and tool preview dispatch.
- functions: blockRectPx, drawGrid, previewColor, drawAirStreaks, drawReachCircle, drawPreviews, previewBlock - functions: blockRectPx, drawGrid, previewColor, drawAirStreaks, drawReachCircle, drawPreviews, previewBlock
@@ -549,6 +554,8 @@ The frame loop and boot.
- `tools/experiments/flow.js`: Throughput of belts and screw conveyors: grains delivered per second. Whatever lands past the outlet is taken away, so the output pile never backs up into the line. - `tools/experiments/flow.js`: Throughput of belts and screw conveyors: grains delivered per second. Whatever lands past the outlet is taken away, so the output pile never backs up into the line.
- `tools/experiments/furnace.js`: Furnace scenarios on a flat test world: builds a chamber, charges it with ore and coal, lights it and prints temperatures, burning cells and what has melted or set at intervals. - `tools/experiments/furnace.js`: Furnace scenarios on a flat test world: builds a chamber, charges it with ore and coal, lights it and prints temperatures, burning cells and what has melted or set at intervals.
- `tools/experiments/section.js`: ASCII cross-section of a stone furnace firing, with a temperature digit per cell (0-9), printed at intervals. Usage: node tools/experiments/section.js [WALL_MATERIAL] (default STONE). - `tools/experiments/section.js`: ASCII cross-section of a stone furnace firing, with a temperature digit per cell (0-9), printed at intervals. Usage: node tools/experiments/section.js [WALL_MATERIAL] (default STONE).
- `tools/experiments/slope-cost.js`: Many loaded slopes, with a fresh deterministic world for each timing sample.
- `tools/experiments/slope-flow.js`: Reproducible generous-feed throughput and settled pile angles for t42.
- `tools/experiments/smelter.js`: Smelter design experiments, built with the same block pieces a player uses. Layout (block rows, 4x4 grains each), on flat ground at row 25: roof with a vent ........ row g-CH-1 chamber, CH blocks tall .. charge of coal + copper ore inside grate floor ............. row g = 23 (liquids pass, powders do not) ingot mold .............. row 24, sitting on the ground side walls run from the roof down past the mold. Usage: node t_smelter.js [key=value ...] - `tools/experiments/smelter.js`: Smelter design experiments, built with the same block pieces a player uses. Layout (block rows, 4x4 grains each), on flat ground at row 25: roof with a vent ........ row g-CH-1 chamber, CH blocks tall .. charge of coal + copper ore inside grate floor ............. row g = 23 (liquids pass, powders do not) ingot mold .............. row 24, sitting on the ground side walls run from the roof down past the mold. Usage: node t_smelter.js [key=value ...]
- `tools/experiments/starter.js`: Plays a starter design the way a player would: paste the blueprint, let the bot build it from the bag, blow an even ore/coal mix inside, light it, wait, knock out the castings. Usage: node tools/experiments/starter.js [design id] [ore] [coal] - `tools/experiments/starter.js`: Plays a starter design the way a player would: paste the blueprint, let the bot build it from the bag, blow an even ore/coal mix inside, light it, wait, knock out the castings. Usage: node tools/experiments/starter.js [design id] [ore] [coal]
- `tools/experiments/worldgen.js`: World generation report: time to generate, how many cells of each material, where the bot starts, and what idle ticks and ticks after digging cost. - `tools/experiments/worldgen.js`: World generation report: time to generate, how many cells of each material, where the bot starts, and what idle ticks and ticks after digging cost.
@@ -557,6 +564,7 @@ The frame loop and boot.
- `tools/scenes/clear-sky.js`: A clear work area left of the bot's starting point (the trade dock is to its right): open sky 160 grains wide and 40 tall, over a flat stone floor. Money and a bag of building stuff to build with. Sets FLOOR (the first row of the floor), BX, BY (the leftmost block of the area that stands on the floor) and CAM (the middle of the area). - `tools/scenes/clear-sky.js`: A clear work area left of the bot's starting point (the trade dock is to its right): open sky 160 grains wide and 40 tall, over a flat stone floor. Money and a bag of building stuff to build with. Sets FLOOR (the first row of the floor), BX, BY (the leftmost block of the area that stands on the floor) and CAM (the middle of the area).
- `tools/scenes/mold-labels.js`: Run after clear-sky.js: filling, blocked, set, and hot machinery in one view. - `tools/scenes/mold-labels.js`: Run after clear-sky.js: filling, blocked, set, and hot machinery in one view.
- `tools/scenes/silo.js`: Run after clear-sky.js; leave paused so the open gate keeps its demonstration charge. - `tools/scenes/silo.js`: Run after clear-sky.js; leave paused so the open gate keeps its demonstration charge.
- `tools/scenes/slope-conveyors.js`: Loaded sidewall conveyor and screen. Use --js 'slopeScene(false)' (belt) or 'slopeScene(true)' (screen).
- `tools/scenes/stone-uses.js`: The stone and sand machines: a half-full silo with its gate open over a belt, a stone cutter fed with rubble, and cut stone and glass brick walls. Run clear-sky.js first, e.g. python3 tools/shot.py /tmp/stone.png --setup tools/scenes/clear-sky.js --js "$(cat tools/scenes/stone-uses.js)" --ticks 240 --zoom 7 - `tools/scenes/stone-uses.js`: The stone and sand machines: a half-full silo with its gate open over a belt, a stone cutter fed with rubble, and cut stone and glass brick walls. Run clear-sky.js first, e.g. python3 tools/shot.py /tmp/stone.png --setup tools/scenes/clear-sky.js --js "$(cat tools/scenes/stone-uses.js)" --ticks 240 --zoom 7
- `tools/scenes/t33-ground.js`: Paused dug pocket: copper ore, rubble and dirt against stone, granite and dirt. Use --js "..." to set daylight or R.lens after this setup; CAM frames the pocket. - `tools/scenes/t33-ground.js`: Paused dug pocket: copper ore, rubble and dirt against stone, granite and dirt. Use --js "..." to set daylight or R.lens after this setup; CAM frames the pocket.
- `tools/scenes/t35-diagonal.js`: Run after clear-sky.js: a belt takes sand from a pile, up a diagonal screw to an upper belt. - `tools/scenes/t35-diagonal.js`: Run after clear-sky.js: a belt takes sand from a pile, up a diagonal screw to an upper belt.
+46
View File
@@ -4,6 +4,52 @@ 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-26: t42: sidewall belts with moving cleats (Codex)
- Continued the uncommitted prototype. `sim/slope-conveyors.js` supplies moving barrier
lines (8 face cells apart, 6 grains tall), finite periodic pocket capacity and saved phase.
Removed slope grip; natural powder movement runs between steps and surplus rolls back.
Fixed the shared slope-to-screw mouth boundary so grains cannot fall under its casing.
Pixel/smooth drawing uses the actual mask, continuous dark far walls, matching previews/icons.
- Files: manifest; sim cells/conveyors/slope-conveyors/machine-tick/items/pieces/blocks/world/save;
render slope-belts/slope-belt-shader/world; UI actions/selection/selection-pickers/previews/
build-menu/notebook; slope test suites and play.py; slope-flow/slope-cost experiments,
slope-conveyors scene, PROJECT_MAP, DESIGN and t42/t43. Removed both requested scratch files.
- Reproducible `node tools/experiments/slope-flow.js`: sand, gravel, copper ore 71.3 and
coal 71.2 grains/s uphill, each 90.0 flat; 20-second window after 220 warm-up ticks.
Measured settled pile angles 33.7 degrees for all four. Final HEAD -> current stress measurement: 0.486 -> 1.038 ms/tick for 576 loaded
slope blocks (1024x512); no-slope powder microbenchmark, 2.14M calls: 45.07 -> 65.55 ms.
Median of five samples after warm-up; scripts use DG_HARNESS for the HEAD snapshot in
/tmp/t42-baseline. Barriers skip mask reads when none exist, but still have a CPU cost.
- Opened shots/t42-belt-{pixel,smooth}-z{4,8}.png. Headless checks pass, including pocket
overflow, collision masks, unstamped bare-face sliding, joins, screw mouths, water, bars,
save phase, all eight drawing phases and unchanged seed hashes 777/77/4242.
Full python3 test/run_all.py --browser: ALL PASSED (26 headless suites, 360 play
checks, 15 smooth checks). Rebuilt dist and regenerated PROJECT_MAP. Both tasks moved to
Review. The owner still decides whether the lower rate feels
right. Pocket movement is a discrete envelope, not a calculation of individual forces.
No commit.
## 2026-10-26: t43: inclined vibrating screens (Codex)
- Diagonal filters always run downhill through all placement/settings/undo/paste paths;
R leaves a picked slope unchanged. The picker shows fixed downhill travel rather than two
misleading direction buttons. Flat filters retain their original carry. Slope grains get
gravity plus one mesh-contact nudge every 4 ticks (15 steps/s); unselected bars use the
same cadence. No whole-column carry or between-step grip on slopes.
- Pixel and smooth mesh frames shake a little while loaded, with stable idle drawing.
Updated ghosts, icons, build text, notebook and the relevant sim/UI/render files listed
in t42 above. Added real browser drag/R/picker checks and art shake checks.
- Both shapes and both sorting modes deliver the complete 64 sand + 64 gravel mix exactly.
Generous-feed discharge is 60.0 grains/s down-left and 59.3 down-right; rest passes through.
Opened shots/t43-screen-{pixel,smooth}-z{4,8}.png. Headless and smooth suites pass; the
first browser run found a hover fixture above the mesh and a later timeout. Corrected the
coordinate and reset picked slope state between fixtures. All new browser checks pass.
A subsequent run caught an existing 200 ms silo HUD timing race; wait for the expected
HUD text before the same assertion. Final full browser run: ALL PASSED, 360 play
checks plus 15 smooth checks; all 26 headless suites pass. t43 moved to Review.
No uphill-save migration. No commit; owner playtest remains.
## 2026-10-26: t44: rename screw tubes to screw conveyors (Codex) ## 2026-10-26: t44: rename screw tubes to screw conveyors (Codex)
- Player text uses Screw conveyor; code uses screw/SCREW, including the piece id, part - Player text uses Screw conveyor; code uses screw/SCREW, including the piece id, part
+1
View File
@@ -20,6 +20,7 @@
"sim/pipes.js", "sim/pipes.js",
"sim/machine-tick.js", "sim/machine-tick.js",
"sim/conveyors.js", "sim/conveyors.js",
"sim/slope-conveyors.js",
"sim/blocks.js", "sim/blocks.js",
"sim/molds.js", "sim/molds.js",
"sim/items.js", "sim/items.js",
+3 -2
View File
@@ -13,6 +13,7 @@ vec3 slopeCorner(vec2 p, vec2 a, float origin) {
return vec3(radius - 1.85, origin + d.x, 2.0 + d.y); return vec3(radius - 1.85, origin + d.x, 2.0 + d.y);
} }
vec3 slopeShape(vec2 p, vec2 worldOrigin, float grade, int joins) { vec3 slopeShape(vec2 p, vec2 worldOrigin, float grade, int joins) {
if ((joins & 64) != 0) p.y -= 0.45;
float sy = grade < 0.0 ? 4.0 : 0.0; float sy = grade < 0.0 ? 4.0 : 0.0;
vec2 a = vec2(-grade * 1.30814755, sy + 1.30814755), b = vec2(4.0 - grade * 1.30814755, 4.0 - sy + 1.30814755); vec2 a = vec2(-grade * 1.30814755, sy + 1.30814755), b = vec2(4.0 - grade * 1.30814755, 4.0 - sy + 1.30814755);
if ((joins & 1) != 0) { a.y = sy + 2.0; a.x = (a.y - sy - 2.61629509) / grade; } if ((joins & 1) != 0) { a.y = sy + 2.0; a.x = (a.y - sy - 2.61629509) / grade; }
@@ -52,11 +53,11 @@ const SM_SLOPE_DRAW = `
vec2 local = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w); vec2 local = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w);
vec3 shapePos = slopeShape(local, w - local, grade, joins); vec3 shapePos = slopeShape(local, w - local, grade, joins);
float sd = shapePos.x, axis = shapePos.y, depth = shapePos.z; float sd = shapePos.x, axis = shapePos.y, depth = shapePos.z;
float time = (joins & 16) != 0 ? uTime : 0.0; float time = !mesh && (joins & 16) != 0 ? uTime : 0.0;
vec2 lb = vec2(axis, depth), alongWorld = vec2(axis, depth); vec2 lb = vec2(axis, depth), alongWorld = vec2(axis, depth);
float rg = step(0.72, fract((axis - dir * (depth < 2.0 ? 1.0 : -1.0) * time * 30.0) * 0.5)); float rg = step(0.72, fract((axis - dir * (depth < 2.0 ? 1.0 : -1.0) * time * 30.0) * 0.5));
vec3 belt; vec3 belt;
if (-sd < 0.8) { if (-sd < (mesh ? 1.1 : 0.8)) {
belt = bandColor(lb, rg); belt = bandColor(lb, rg);
if (mesh && ((joins & 32) != 0) == (depth < 2.0) && dot(own.rgb, vec3(1)) > 0.01) if (mesh && ((joins & 32) != 0) == (depth < 2.0) && dot(own.rgb, vec3(1)) > 0.01)
belt = mix(belt, own.rgb * (depth < 2.0 ? 1.0 : 0.8), 0.75 * rg); belt = mix(belt, own.rgb * (depth < 2.0 ? 1.0 : 0.8), 0.75 * rg);
+27 -4
View File
@@ -42,8 +42,8 @@ function prepareSlopeBelts(x0, y0, x1, y1) {
if (c === BP_BELT || c === BP_FILTER) beltCaps[b] = flatBeltCaps(b); if (c === BP_BELT || c === BP_FILTER) beltCaps[b] = flatBeltCaps(b);
if (!isSlopeBelt(c)) continue; if (!isSlopeBelt(c)) continue;
const face = beltFaceY(c, bx, by, bx * BS) * W + bx * BS; const face = beltFaceY(c, bx, by, bx * BS) * W + bx * BS;
slopeBeltJoins[b] = slopeBeltConnections(bx, by, c) | (netSat(face) > 0 ? 16 : 0) | (filterCfg.get(b)?.inv ? 32 : 0); slopeBeltJoins[b] = slopeBeltConnections(bx, by, c) | (netSat(face) > 0 ? 16 : 0) | (filterCfg.get(b)?.inv ? 32 : 0) | (isFilter(c) && netSat(face) > 0 && screenLoaded(b) && (tick & 2) ? 64 : 0);
for (let yy = Math.max(0, by - 1); yy <= Math.min(BH - 1, by + 1); yy++) for (let yy = Math.max(0, by - 2); yy <= Math.min(BH - 1, by + 1); yy++)
for (let xx = Math.max(0, bx - 1); xx <= Math.min(BW - 1, bx + 1); xx++) slopeBeltHalo[yy * BW + xx] = 1; for (let xx = Math.max(0, bx - 1); xx <= Math.min(BW - 1, bx + 1); xx++) slopeBeltHalo[yy * BW + xx] = 1;
} }
} }
@@ -63,6 +63,7 @@ function slopeBeltCorner(x, y, ax, ay, origin) {
function slopeBeltShape(x, y, b, joins = slopeBeltJoins[b], code = blockP[b]) { function slopeBeltShape(x, y, b, joins = slopeBeltJoins[b], code = blockP[b]) {
const grade = beltGrade(code), ox = (b % BW) * BS, oy = ((b / BW) | 0) * BS; const grade = beltGrade(code), ox = (b % BW) * BS, oy = ((b / BW) | 0) * BS;
x -= ox; y -= oy; x -= ox; y -= oy;
if (isFilter(code) && (joins & 64)) y -= 0.45;
const offset = 1.85 * Math.SQRT2, sy = grade < 0 ? 4 : 0; const offset = 1.85 * Math.SQRT2, sy = grade < 0 ? 4 : 0;
const normal = 1.85 * Math.SQRT1_2; const normal = 1.85 * Math.SQRT1_2;
let ax = -grade * normal, ay = sy + normal, bx = 4 - grade * normal, by = 4 - sy + normal; let ax = -grade * normal, ay = sy + normal, bx = 4 - grade * normal, by = 4 - sy + normal;
@@ -107,9 +108,9 @@ function slopeBeltColor(b, x, y, anim, br, bg, bb) {
if (sd > 1e-8) return (br | 0) | ((bg | 0) << 8) | ((bb | 0) << 16); if (sd > 1e-8) return (br | 0) | ((bg | 0) << 8) | ((bb | 0) << 16);
const c = blockP[b], joins = slopeBeltJoins[b], mesh = isFilter(c); const c = blockP[b], joins = slopeBeltJoins[b], mesh = isFilter(c);
const dir = mesh ? (blockV[b] & 128 ? 1 : -1) : (blockV[b] ? 1 : -1); const dir = mesh ? (blockV[b] & 128 ? 1 : -1) : (blockV[b] ? 1 : -1);
const a = slopeAlong, d = slopeAcross, time = joins & 16 ? anim : 0; const a = slopeAlong, d = slopeAcross, time = !mesh && joins & 16 ? anim : 0;
let r = 38, g = 41, blue = 46; let r = 38, g = 41, blue = 46;
if (-sd < 0.8) { if (-sd < (mesh ? 1.1 : 0.8)) {
const stripe = (Math.floor((a - dir * (d < 2 ? 1 : -1) * (time >> 1)) / 2) & 1), k = d < 2 ? 1 : 0.72; const stripe = (Math.floor((a - dir * (d < 2 ? 1 : -1) * (time >> 1)) / 2) & 1), k = d < 2 ? 1 : 0.72;
r = (stripe ? 92 : 56) * k; g = (stripe ? 96 : 58) * k; blue = (stripe ? 104 : 66) * k; r = (stripe ? 92 : 56) * k; g = (stripe ? 96 : 58) * k; blue = (stripe ? 104 : 66) * k;
} else if (mesh) { } else if (mesh) {
@@ -125,3 +126,25 @@ function slopeBeltColor(b, x, y, anim, br, bg, bb) {
} }
return (r | 0) | ((g | 0) << 8) | ((blue | 0) << 16); return (r | 0) | ((g | 0) << 8) | ((blue | 0) << 16);
} }
function screenLoaded(b) {
const bx = b % BW, by = (b / BW) | 0, c = blockP[b];
for (let x = bx * BS; x < bx * BS + BS; x++) {
const i = (beltFaceY(c, bx, by, x) - 1) * W + x;
if (i >= 0 && (PH[mat[i]] === PH_POWDER || occ[i])) return true;
}
return false;
}
// Far sidewalls stay behind the load. The cleat raster is the simulation barrier raster.
function slopeDetailAt(i, x, y) {
if (!slopeBeltHalo[(y >> 2) * BW + (x >> 2)] && !cleatMask[i]) return 0;
if (occ[i] || mach[i] || (mat[i] !== EMPTY && PH[mat[i]] !== PH_GAS) || part[i]) return 0;
if (cleatMask[i]) return 1;
for (let by = y >> 2; by <= Math.min(BH - 1, (y + 9) >> 2); by++) {
const b = by * BW + (x >> 2), c = blockP[b];
if (!isSlopeBelt(c)) continue;
const depth = beltFaceY(c, x >> 2, by, x) - y;
if (isFilter(c) ? depth === 2 : depth >= 6 && depth <= 7 + ((x + slopePhase[b]) & 1)) return isFilter(c) ? 3 : 2;
}
return 0;
}
+7
View File
@@ -289,6 +289,12 @@ function drawWorld() {
const color = slopeBeltColor(sb, x, y, anim, br, bgc, bb); const color = slopeBeltColor(sb, x, y, anim, br, bgc, bb);
r = color & 255; g = color >> 8 & 255; b = color >> 16 & 255; r = color & 255; g = color >> 8 & 255; b = color >> 16 & 255;
} }
const slopeDetail = slopeDetailAt(i, x, y);
if (slopeDetail) {
r = slopeDetail === 1 ? 145 : slopeDetail === 3 ? 96 : 48;
g = slopeDetail === 1 ? 125 : slopeDetail === 3 ? 106 : 52;
b = slopeDetail === 1 ? 94 : slopeDetail === 3 ? 120 : 60;
}
// incandescence // incandescence
if (T > 450 && m !== EMPTY && incand) { if (T > 450 && m !== EMPTY && incand) {
const gi = glowIndex(PH[m] === PH_SOLID ? T + nz * 0.9 : m === MAGMA ? T + Math.sin(x * 0.19 + y * 0.11 + anim * 0.03 + nz * 0.04) * 55 - 30 : T), w = GLOW_W[gi]; const gi = glowIndex(PH[m] === PH_SOLID ? T + nz * 0.9 : m === MAGMA ? T + Math.sin(x * 0.19 + y * 0.11 + anim * 0.03 + nz * 0.04) * 55 - 30 : T), w = GLOW_W[gi];
@@ -339,6 +345,7 @@ function drawWorld() {
((y - ((sb / BW) | 0) * BS + 8) << 16) | ((x - (sb % BW) * BS + 8) << 8); ((y - ((sb / BW) | 0) * BS + 8) << 16) | ((x - (sb % BW) * BS + 8) << 8);
if (slopeArt) { r = fm ? COLR[fm] : 0; g = fm ? COLG[fm] : 0; b = fm ? COLB[fm] : 0; } if (slopeArt) { r = fm ? COLR[fm] : 0; g = fm ? COLG[fm] : 0; b = fm ? COLB[fm] : 0; }
} }
if (slopeDetail) cl = 4;
top = cl << 24; top = cl << 24;
fpx[row + bx] = (tb ? 0xff000000 : 0) | (cl === 5 ? 0xff0000 : 0) | (cl === 3 ? 0xff00 : 0) | (cl === 1 && !builtForLight(i, x, y) ? 0xff : 0); fpx[row + bx] = (tb ? 0xff000000 : 0) | (cl === 5 ? 0xff0000 : 0) | (cl === 3 ? 0xff00 : 0) | (cl === 1 && !builtForLight(i, x, y) ? 0xff : 0);
} }
+3 -3
View File
@@ -68,7 +68,7 @@ function placeBlock(code, v, bx, by, free, extra) {
} }
if (isPipeCode(code)) pipesDirty = true; if (isPipeCode(code)) pipesDirty = true;
blockP[by * BW + bx] = code; blockV[by * BW + bx] = v; blockP[by * BW + bx] = code; blockV[by * BW + bx] = v;
if (isFilter(code)) { blockV[by * BW + bx] = v & 128; filterCfg.set(by * BW + bx, filterRec(extra)); filtersDirty = true; } if (isFilter(code)) { blockV[by * BW + bx] = filterVariant(code, v); filterCfg.set(by * BW + bx, filterRec(extra)); filtersDirty = true; }
if (isProcCode(code)) procDirty = true; if (isProcCode(code)) procDirty = true;
if (code === BP_RACK) { if (code === BP_RACK) {
const b = by * BW + bx, s = newRack(); const b = by * BW + bx, s = newRack();
@@ -197,7 +197,7 @@ function blockInReach(bx, by) { return inReach(bx * BS + BS / 2, by * BS + BS /
// Is block b already exactly this piece (same kind, variant and, for a filter, settings)? // Is block b already exactly this piece (same kind, variant and, for a filter, settings)?
function blockIs(b, code, v, extra) { function blockIs(b, code, v, extra) {
if (blockP[b] !== code) return false; if (blockP[b] !== code) return false;
if (isFilter(code)) return (blockV[b] & 128) === (v & 128) && sameFilter(filterInfo(b), cleanFilterCfg(extra)); if (isFilter(code)) return (blockV[b] & 128) === filterVariant(code, v) && sameFilter(filterInfo(b), cleanFilterCfg(extra));
return blockV[b] === v; return blockV[b] === v;
} }
// replace: a block already built in the way is taken down (and refunded) to make room // replace: a block already built in the way is taken down (and refunded) to make room
@@ -208,7 +208,7 @@ function placeBlocks(code, v, list, extra, replace) {
const b = by * BW + bx; const b = by * BW + bx;
if (replace && blockP[b]) { if (replace && blockP[b]) {
if (blockIs(b, code, v, extra)) continue; if (blockIs(b, code, v, extra)) continue;
if (blockP[b] === code && isFilter(code)) { undoNote({ t: 'filter', bx, by, v: blockV[b], extra: filterInfo(b) }); blockV[b] = v & 128; setFilter(b, extra); filtersDirty = true; n++; continue; } // just re-set it if (blockP[b] === code && isFilter(code)) { undoNote({ t: 'filter', bx, by, v: blockV[b], extra: filterInfo(b) }); blockV[b] = filterVariant(code, v); setFilter(b, extra); filtersDirty = true; n++; continue; } // just re-set it
if (!canAfford(pieceCost(code, v), 1)) { last = needText(pieceCost(code, v), 1); break; } if (!canAfford(pieceCost(code, v), 1)) { last = needText(pieceCost(code, v), 1); break; }
removeBlock(bx, by); removeBlock(bx, by);
} }
+16 -6
View File
@@ -36,7 +36,7 @@ function move(i, j, x, y, nx, ny) {
// Can a grain of material m (phase ph) enter cell j moving by (dx,dy)? // Can a grain of material m (phase ph) enter cell j moving by (dx,dy)?
// 0 = no, 1 = into empty, 2 = by displacing what is there. // 0 = no, 1 = into empty, 2 = by displacing what is there.
function enter(m, ph, j, dx, dy) { function enter(m, ph, j, dx, dy) {
if (occ[j]) return 0; if (occ[j] || (ph === PH_POWDER && !cleatCarrying && cleatCells.length && (cleatMask[j] || (dy > 0 && cleatMask[j - dx - dy * W])))) return 0;
const p = part[j]; const p = part[j];
if (p !== 0) { if (p !== 0) {
if (IS_SCREW[p]) { if (ph === PH_POWDER && SCREW_DX[p] === -dx && SCREW_DY[p] === -dy) return 0; } // liquids and gases flow through freely if (IS_SCREW[p]) { if (ph === PH_POWDER && SCREW_DX[p] === -dx && SCREW_DY[p] === -dy) return 0; } // liquids and gases flow through freely
@@ -72,20 +72,30 @@ let LSTEP = 8, LFILM = 4;
let CORNER_SEAL = true; let CORNER_SEAL = true;
function powder(i, x, y, m) { function powder(i, x, y, m) {
if (y >= H - 1) return; if (y >= H - 1) return;
const b = i + W; const b = i + W, barriers = cleatCells.length;
if (enter(m, PH_POWDER, b, 0, 1)) { move(i, b, x, y, x, y + 1); return; } if (enter(m, PH_POWDER, b, 0, 1)) { move(i, b, x, y, x, y + 1); return; }
// a grain at least twice as dense works its way down through a loose pile. Unlit fuel // a grain at least twice as dense works its way down through a loose pile. Unlit fuel
// packs too tight for that, so a mixed furnace charge stays mixed until the fire gets in. // packs too tight for that, so a mixed furnace charge stays mixed until the fire gets in.
const t = mat[b]; const t = mat[b];
if (PH[t] === PH_POWDER && DENS[m] >= DENS[t] * 2 && (!FUEL[t] || (flags[b] & F_BURN)) && stamp[b] !== tickStamp && !occ[b] && part[b] !== P_GRATE && part[b] !== P_FILTER && rnd() < (FUEL[t] ? SORTP_FUEL : SORTP)) { move(i, b, x, y, x, y + 1); awake[(y >> 5) * CW + (x >> 5)] = 40; return; } if (PH[t] === PH_POWDER && (!barriers || (!cleatMask[i] && !cleatMask[b])) && DENS[m] >= DENS[t] * 2 && (!FUEL[t] || (flags[b] & F_BURN)) && stamp[b] !== tickStamp && !occ[b] && part[b] !== P_GRATE && part[b] !== P_FILTER && rnd() < (FUEL[t] ? SORTP_FUEL : SORTP)) { move(i, b, x, y, x, y + 1); awake[(y >> 5) * CW + (x >> 5)] = 40; return; }
// a grain lying right on a belt or filter is held by it: the surface carries it, it does not slide off the edge // a grain lying right on a belt or filter is held by it: the surface carries it, it does not slide off the edge
if (t === BELTL || t === BELTR || part[b] === P_FILTER) return; if ((t === BELTL || t === BELTR || part[b] === P_FILTER) &&
!isSlopeBelt(blockP[((y + 1) >> 2) * BW + (x >> 2)])) return;
// Feed a receiving screw mouth sideways instead of falling under its bottom casing.
if (t === BELTL || t === BELTR) {
const b = ((y + 1) >> 2) * BW + (x >> 2);
const d = isSlopeBelt(blockP[b]) ? (blockV[b] & 1 ? 1 : -1) : 0;
if (d && x + d >= 0 && x + d < W && IS_SCREW[part[i + d]]) {
if (enter(m, PH_POWDER, i + d, d, 0)) move(i, i + d, x, y, x + d, y);
return;
}
}
if (rnd() < STICK[m]) { if (rnd() < STICK[m]) {
const d = (RS & 2) ? 1 : -1; const d = (RS & 2) ? 1 : -1;
let nx = x + d; let nx = x + d;
if (nx >= 0 && nx < W && (!CORNER_SEAL || PH[mat[b]] !== PH_SOLID || PH[mat[i + d]] !== PH_SOLID) && enter(m, PH_POWDER, b + d, d, 1)) { move(i, b + d, x, y, nx, y + 1); return; } if (nx >= 0 && nx < W && (!CORNER_SEAL || (PH[mat[b]] !== PH_SOLID && (!barriers || !cleatMask[b])) || (PH[mat[i + d]] !== PH_SOLID && (!barriers || !cleatMask[i + d]))) && enter(m, PH_POWDER, b + d, d, 1)) { move(i, b + d, x, y, nx, y + 1); return; }
nx = x - d; nx = x - d;
if (nx >= 0 && nx < W && (!CORNER_SEAL || PH[mat[b]] !== PH_SOLID || PH[mat[i - d]] !== PH_SOLID) && enter(m, PH_POWDER, b - d, -d, 1)) { move(i, b - d, x, y, nx, y + 1); return; } if (nx >= 0 && nx < W && (!CORNER_SEAL || (PH[mat[b]] !== PH_SOLID && (!barriers || !cleatMask[b])) || (PH[mat[i - d]] !== PH_SOLID && (!barriers || !cleatMask[i - d]))) && enter(m, PH_POWDER, b - d, -d, 1)) { move(i, b - d, x, y, nx, y + 1); return; }
} }
} }
function passable(m, ph, j, dx, dy) { function passable(m, ph, j, dx, dy) {
+9 -14
View File
@@ -19,11 +19,11 @@ function conveyorStep(x, y, d) {
const b = (y >> 2) * BW + (x >> 2); const b = (y >> 2) * BW + (x >> 2);
return beltGrade(blockP[b]) * d; return beltGrade(blockP[b]) * d;
} }
function carryColumn(a, d, pass) { function carryColumn(a, d, pass, depth = CARRY_DEPTH) {
if (a < 0 || mat[a] === EMPTY) return; if (a < 0 || mat[a] === EMPTY) return;
const sy = ((a / W) | 0) + 1, step = conveyorStep(a % W, sy, d); const sy = ((a / W) | 0) + 1, step = conveyorStep(a % W, sy, d);
let blocked = false; let blocked = false;
for (let L = 0; L < CARRY_DEPTH; L++, a -= W) { for (let L = 0; L < depth; L++, a -= W) {
if (a < 0) return; if (a < 0) return;
const m = mat[a]; const m = mat[a];
if (m === EMPTY || (PH[m] === PH_SOLID && !part[a])) return; // the top of the heap, or a ceiling if (m === EMPTY || (PH[m] === PH_SOLID && !part[a])) return; // the top of the heap, or a ceiling
@@ -53,14 +53,6 @@ function carryColumn(a, d, pass) {
stats.moved++; stats.moved++;
} }
} }
// The running face grips its whole supported load, including on the ticks between steps.
function holdSlopeColumn(a, pass) {
for (let depth = 0; depth < CARRY_DEPTH && a >= 0; depth++, a -= W) {
const m = mat[a];
if (m === EMPTY || (PH[m] === PH_SOLID && !part[a])) return;
if (PH[m] === PH_POWDER && !occ[a] && !(pass && pass[m])) stamp[a] = tickStamp;
}
}
// Belts are perforated: liquid resting on one drips through to whatever open space is under it. // Belts are perforated: liquid resting on one drips through to whatever open space is under it.
function beltDrain(b) { function beltDrain(b) {
const a = b - W, x = b % W; const a = b - W, x = b % W;
@@ -93,8 +85,8 @@ function filterCarry(b, d, carrying = true) {
for (let k = 0; k < BS; k++) { for (let k = 0; k < BS; k++) {
const x = d > 0 ? bx * BS + BS - 1 - k : bx * BS + k, top = beltFaceY(code, bx, by, x) * W + x; const x = d > 0 ? bx * BS + BS - 1 - k : bx * BS + k, top = beltFaceY(code, bx, by, x) * W + x;
if (part[top] === P_FILTER) { if (part[top] === P_FILTER) {
if (carrying) carryColumn(top - W, d, pass); if (isSlopeBelt(code)) screenNudge(top, d, pass);
if (isSlopeBelt(code)) holdSlopeColumn(top - W, pass); else if (carrying) carryColumn(top - W, d, pass);
} }
} }
} }
@@ -114,9 +106,12 @@ function carryBeltsAndFilters(belts, filters, d, bm, carrying) {
if (above === BELTL || above === BELTR || netSat(b) <= 0) continue; if (above === BELTL || above === BELTR || netSat(b) <= 0) continue;
if (carrying) { if (carrying) {
if (PH[above] === PH_LIQUID) beltDrain(b); if (PH[above] === PH_LIQUID) beltDrain(b);
carryColumn(b - W, d, null); if (isSlopeBelt(code)) {
cleatCarrying = true;
carryColumn(b - W, d, null, pocketDepth(x, (y >> 2) * BW + (x >> 2)));
cleatCarrying = false;
} else carryColumn(b - W, d, null);
} }
if (isSlopeBelt(code)) holdSlopeColumn(b - W, null);
} }
} }
// Change what a filter does; grains inside it that no longer pass are pushed out on top. // Change what a filter does; grains inside it that no longer pass are pushed out on top.
+4 -4
View File
@@ -134,12 +134,12 @@ function entDiagonalScrewTick(e, p) {
function entSlopeBeltTick(e) { function entSlopeBeltTick(e) {
const bottom = e.iy + BAR_H; const bottom = e.iy + BAR_H;
if (bottom >= H) return false; if (bottom >= H) return false;
let d = 0, slope = false; let d = 0, slope = false, screen = false;
for (let x = e.ix; x < e.ix + BAR_W; x++) { for (let x = e.ix; x < e.ix + BAR_W; x++) {
const i = bottom * W + x, b = (bottom >> 2) * BW + (x >> 2), c = blockP[b]; const i = bottom * W + x, b = (bottom >> 2) * BW + (x >> 2), c = blockP[b];
if ((isBelt(c) || isFilter(c)) && entSolidCell(i)) { if ((isBelt(c) || isFilter(c)) && entSolidCell(i)) {
d = isFilter(c) ? (blockV[b] & 128 ? 1 : -1) : (blockV[b] ? 1 : -1); d = isFilter(c) ? (blockV[b] & 128 ? 1 : -1) : (blockV[b] ? 1 : -1);
if (isSlopeBelt(c)) slope = true; if (isSlopeBelt(c)) { slope = true; if (isFilter(c)) screen = true; }
} }
} }
if (!d) return false; if (!d) return false;
@@ -150,12 +150,12 @@ function entSlopeBeltTick(e) {
const b = (y >> 2) * BW + (x >> 2), c = blockP[b]; const b = (y >> 2) * BW + (x >> 2), c = blockP[b];
if (!(isBelt(c) || isFilter(c)) || (isFilter(c) && filterPassAt(e.m, y * W + x))) continue; if (!(isBelt(c) || isFilter(c)) || (isFilter(c) && filterPassAt(e.m, y * W + x))) continue;
if (beltFaceY(c, x >> 2, y >> 2, x) !== y) continue; if (beltFaceY(c, x >> 2, y >> 2, x) !== y) continue;
if (isSlopeBelt(c)) slope = true; if (isSlopeBelt(c)) { slope = true; if (isFilter(c)) screen = true; }
face = Math.min(face, y); face = Math.min(face, y);
} }
if (!slope) return false; if (!slope) return false;
e.x = e.ix; e.y = e.iy; e.vx = e.vy = 0; e.x = e.ix; e.y = e.iy; e.vx = e.vy = 0;
if (tick & 1) return true; if (screen ? tick % SCREEN_NUDGE_TICKS : tick & 1) return true;
const y = face === H ? e.iy : face - BAR_H, x = e.ix + d; const y = face === H ? e.iy : face - BAR_H, x = e.ix + d;
if (entFreeAt(x, y)) { e.vx = d * 0.5; e.vy = (y - e.iy) * 0.5; e.x = e.ix = x; e.y = e.iy = y; stats.moved++; } if (entFreeAt(x, y)) { e.vx = d * 0.5; e.vy = (y - e.iy) * 0.5; e.x = e.ix = x; e.y = e.iy = y; stats.moved++; }
else if (!entScrewEnter(e, d, 0)) entScrewEnter(e, d, Math.sign(y - e.iy), true); else if (!entScrewEnter(e, d, 0)) entScrewEnter(e, d, Math.sign(y - e.iy), true);
+2 -1
View File
@@ -87,10 +87,11 @@ function machinesTick() {
mm.hot = Math.round(hotT); mm.hot = Math.round(hotT);
} }
} }
// Half a grain per tick; sloped faces grip their load on the intervening ticks too. // Belts step every other tick; sloped belts carry only their finite pockets.
if (filtersDirty) rebuildFilters(); if (filtersDirty) rebuildFilters();
carryBeltsAndFilters(beltL, filterL, -1, BELTL, (tick & 1) === 0); carryBeltsAndFilters(beltL, filterL, -1, BELTL, (tick & 1) === 0);
carryBeltsAndFilters(beltR, filterR, 1, BELTR, (tick & 1) === 0); carryBeltsAndFilters(beltR, filterR, 1, BELTR, (tick & 1) === 0);
slopeCleatsTick();
// screws: every loose grain inside steps one cell along, front of the line first, on the same // screws: 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 // 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 = 1; p < IS_SCREW.length; p++) { if ((tick & 1) === 0) for (let p = 1; p < IS_SCREW.length; p++) {
+4 -3
View File
@@ -14,8 +14,9 @@ function isBelt(c) { return c === BP_BELT || c === BP_BELT_UP || c === BP_BELT_D
function beltGrade(c) { return c === BP_BELT_UP || c === BP_FILTER_UP ? -1 : c === BP_BELT_DOWN || c === BP_FILTER_DOWN ? 1 : 0; } function beltGrade(c) { return c === BP_BELT_UP || c === BP_FILTER_UP ? -1 : c === BP_BELT_DOWN || c === BP_FILTER_DOWN ? 1 : 0; }
function slopeShape(c) { return isSlopeBelt(c) ? (beltGrade(c) < 0 ? BP_SLOPE_BR : BP_SLOPE_BL) : c; } function slopeShape(c) { return isSlopeBelt(c) ? (beltGrade(c) < 0 ? BP_SLOPE_BR : BP_SLOPE_BL) : c; }
function beltFaceY(c, bx, by, x) { return by * BS + (beltGrade(c) < 0 ? 3 - (x - bx * BS) : beltGrade(c) > 0 ? x - bx * BS : 0); } function beltFaceY(c, bx, by, x) { return by * BS + (beltGrade(c) < 0 ? 3 - (x - bx * BS) : beltGrade(c) > 0 ? x - bx * BS : 0); }
function filterVariant(c, v) { return beltGrade(c) ? (beltGrade(c) > 0 ? 128 : 0) : v & 128; }
function beltDirection(c, v) { function beltDirection(c, v) {
const d = isFilter(c) ? (v & 128 ? 1 : -1) : (v ? 1 : -1), g = beltGrade(c); const d = isFilter(c) ? (filterVariant(c, v) & 128 ? 1 : -1) : (v ? 1 : -1), g = beltGrade(c);
return g ? (g * d < 0 ? 'up-' : 'down-') + (d > 0 ? 'right' : 'left') : d > 0 ? 'right' : 'left'; return g ? (g * d < 0 ? 'up-' : 'down-') + (d > 0 ? 'right' : 'left') : d > 0 ? 'right' : 'left';
} }
function isSlope(c) { return c >= BP_SLOPE_BL && c <= BP_SLOPE_TR; } function isSlope(c) { return c >= BP_SLOPE_BL && c <= BP_SLOPE_TR; }
@@ -70,11 +71,11 @@ const PIECES = [
{ id: 'wall', code: BP_WALL, name: 'Wall block', kind: 'block', variants: 'wall', { id: 'wall', code: BP_WALL, name: 'Wall block', kind: 'block', variants: 'wall',
desc: 'A solid block. Diagonal drags make a sloped face; T flips it. For one block, T cycles square and four slope corners. R picks the material: a square costs 16 grains or 2 bars; a slope costs 8 grains or 1 bar.' }, desc: 'A solid block. Diagonal drags make a sloped face; T flips it. For one block, T cycles square and four slope corners. R picks the material: a square costs 16 grains or 2 bars; a slope costs 8 grains or 1 bar.' },
{ id: 'belt', code: BP_BELT, name: 'Belt', kind: 'block', cost: [[CASH, 12]], dirs: ['left', 'right'], { id: 'belt', code: BP_BELT, name: 'Belt', kind: 'block', cost: [[CASH, 12]], dirs: ['left', 'right'],
desc: 'Carries loose grains and bars resting on top of it, left or right, in a layer up to a block deep. Water and other liquids drain through it. Drag diagonally to make a sloped belt that carries up or down.' }, desc: 'Carries loose grains and bars resting on top of it, left or right, in a layer up to a block deep. Water and other liquids drain through it. Drag diagonally for a sidewall belt. Moving cleats lift pockets of grains; extra grains roll back.' },
{ id: 'screw', code: BP_SCREW, name: 'Screw conveyor', kind: 'block', cost: [[CASH, 25]], dirs: ['up', 'right', 'down', 'left', 'up-right', 'down-right', 'down-left', 'up-left'], { id: 'screw', code: BP_SCREW, name: 'Screw conveyor', kind: 'block', cost: [[CASH, 25]], dirs: ['up', 'right', 'down', 'left', 'up-right', 'down-right', 'down-left', 'up-left'],
desc: 'A turning screw inside a tube that draws in loose grains and whole bars and pushes them along, four grains wide. Water and other liquids run straight through it; the screw never carries them. Drag a line to set its direction, including diagonals. R turns a single screw conveyor. Join either end to screw conveyors or belts. Copper softens around 1000°C.' }, desc: 'A turning screw inside a tube that draws in loose grains and whole bars and pushes them along, four grains wide. Water and other liquids run straight through it; the screw never carries them. Drag a line to set its direction, including diagonals. R turns a single screw conveyor. Join either end to screw conveyors or belts. Copper softens around 1000°C.' },
{ id: 'filter', code: BP_FILTER, name: 'Filter', kind: 'block', cost: [[CASH, 40]], dirs: ['left', 'right'], filter: true, { id: 'filter', code: BP_FILTER, name: 'Filter', kind: 'block', cost: [[CASH, 40]], dirs: ['left', 'right'], filter: true,
desc: 'A stretch of belt with a mesh in it. Put it in a line of belts and it joins them as one belt. Pick materials and a mode: drop them through and carry everything else along, or carry them and drop the rest. Liquids drain through. Drag diagonally to make a sloped filter that carries up or down.' }, desc: 'A shaking mesh that joins belts. Pick materials and a mode: drop them through and carry everything else along, or carry them and drop the rest. Liquids drain through. Drag diagonally for a downhill screen. Grains slide down while it shakes; R keeps a picked slope downhill.' },
{ id: 'shaft', code: BP_SHAFT, name: 'Shaft', kind: 'block', cost: [[LWOOD, 8]], hidden: POWER_FREE, { id: 'shaft', code: BP_SHAFT, name: 'Shaft', kind: 'block', cost: [[LWOOD, 8]], hidden: POWER_FREE,
desc: 'Carries power between machines. Wooden, so keep it away from fire.' }, desc: 'Carries power between machines. Wooden, so keep it away from fire.' },
{ id: 'grate', code: BP_GRATE, name: 'Grate', kind: 'block', cost: [[LCLAY, 8]], { id: 'grate', code: BP_GRATE, name: 'Grate', kind: 'block', cost: [[LCLAY, 8]],
+3 -1
View File
@@ -9,6 +9,7 @@ function serializeWorld() {
const t16 = new Uint16Array(N); const t16 = new Uint16Array(N);
for (let i = 0; i < N; i++) { const v = (temp[i] + TOFF) * 16; t16[i] = v < 0 ? 0 : v > 65535 ? 65535 : v; } for (let i = 0; i < N; i++) { const v = (temp[i] + TOFF) * 16; t16[i] = v < 0 ? 0 : v > 65535 ? 65535 : v; }
const meta = { const meta = {
slopePhases: Array.from(slopePhase),
v: SAVE_VERSION, seed: worldSeed, W, H, tick, surfBase, underD, shallowD, lapse, tOff: TOFF, 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]), 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 }, economy: { money, debt, earned, recent: Array.from(mkRecent, v => Math.round(v)), log: shipLog, orders, ordersDone, nextOrderT, orderSeq },
@@ -102,6 +103,7 @@ function deserializeWorld(meta, buffers) {
if (!dock()) placeDock((W >> 1) + 34); // worlds from before the economy get their trade dock too if (!dock()) placeDock((W >> 1) + 34); // worlds from before the economy get their trade dock too
ghostBlocks = new Map(meta.ghostBlocks || []); ghostMachines = meta.ghostMachines || []; ghostMolds = meta.ghostMolds || []; ghostBlocks = new Map(meta.ghostBlocks || []); ghostMachines = meta.ghostMachines || []; ghostMolds = meta.ghostMolds || [];
tick = meta.tick || 0; tick = meta.tick || 0;
if (meta.slopePhases) slopePhase.set(meta.slopePhases);
updateRoof(); resetWeather(); updateRoof(); resetWeather();
if (meta.weather) { if (meta.weather) {
const mw = meta.weather; const mw = meta.weather;
@@ -117,7 +119,7 @@ function deserializeWorld(meta, buffers) {
seen = new Uint8Array(NMAT); meta.seen.forEach((v, k) => { if (k < NMAT) seen[k] = v; }); seen = new Uint8Array(NMAT); meta.seen.forEach((v, k) => { if (k < NMAT) seen[k] = v; });
ledger = meta.ledger || {}; ledger = meta.ledger || {};
Object.assign(stats, meta.stats || {}); Object.assign(stats, meta.stats || {});
powerDirty = true; rebuildPower(); updateRoof(); powerDirty = true; rebuildPower(); slopeCleatsTick(false); updateRoof();
if (!weather.base) weather.base = worldWater(); // older saves: take the water it has now as its starting amount if (!weather.base) weather.base = worldWater(); // older saves: take the water it has now as its starting amount
if (!meta.specks) clearHardSpecks(true); // older saves: tidy away tiny specks of too-hard rock once if (!meta.specks) clearHardSpecks(true); // older saves: tidy away tiny specks of too-hard rock once
rebuildPipes(); rebuildPipes();
+51
View File
@@ -0,0 +1,51 @@
// Cleats are moving barriers, not material cells. A cleat supports a finite pocket; the bare face never holds grains.
const CLEAT_SPACING = 8, CLEAT_HEIGHT = 6; // spacing in face cells, height in grains
const CLEAT_REACH = Math.floor(CLEAT_HEIGHT * Math.SQRT1_2);
const SCREEN_NUDGE_TICKS = 4; // one downhill contact step per four ticks (15 steps/s), plus gravity
let cleatCarrying = false;
let cleatMask = new Uint8Array(0), cleatCells = [], slopePhase = new Uint8Array(0);
function cleatFoot(x, b, phase = slopePhase[b]) {
const d = blockV[b] & 1 ? 1 : -1;
return ((x - d * phase) % CLEAT_SPACING + CLEAT_SPACING) % CLEAT_SPACING === 0;
}
function pocketDepth(x, b) {
const g = beltGrade(blockP[b]), d = blockV[b] & 1 ? 1 : -1;
// A periodic pocket envelope, clipped to two vertical projections of the cleat.
// End pockets keep moving while their cleat rounds the end of the band.
const distance = ((g * (d * slopePhase[b] - x)) % CLEAT_SPACING + CLEAT_SPACING) % CLEAT_SPACING;
return Math.max(0, CLEAT_REACH * 2 - distance);
}
function slopeCleatsTick(advance = true) {
for (const i of cleatCells) { cleatMask[i] = 0; wakeIdx(i); }
cleatCells.length = 0;
// Front first, just like flat transport. Each foot belongs to exactly one face cell.
for (const list of [beltR, beltL]) for (const i of list) {
const x = i % W, y = (i / W) | 0, b = (y >> 2) * BW + (x >> 2), c = blockP[b];
if (!isBelt(c) || !isSlopeBelt(c) || beltFaceY(c, x >> 2, y >> 2, x) !== y) continue;
const d = blockV[b] & 1 ? 1 : -1, g = beltGrade(c), running = netSat(i) > 0;
const phase = (slopePhase[b] + (advance && running && !(tick & 1) ? 1 : 0)) % CLEAT_SPACING;
if (!cleatFoot(x, b, phase)) continue;
for (let h = CLEAT_REACH; h >= 0; h--) {
const xx = x + g * h, yy = y - h - 1;
if (xx < 0 || xx >= W || yy < 0 || yy >= H) continue;
const j = yy * W + xx;
cleatCells.push(j);
}
}
if (advance && !(tick & 1)) for (const list of [beltR, beltL]) for (const i of list) {
const x = i % W, y = (i / W) | 0, b = (y >> 2) * BW + (x >> 2), c = blockP[b];
if ((x & 3) === 0 && isBelt(c) && isSlopeBelt(c) && beltFaceY(c,x>>2,y>>2,x)===y && netSat(i)>0)
slopePhase[b] = (slopePhase[b] + 1) % CLEAT_SPACING;
}
for (const i of cleatCells) { cleatMask[i] = 1; wakeIdx(i); }
}
// A screen shakes only the grains touching its mesh, never the whole column above it.
function screenNudge(top, d, pass) {
if (tick % SCREEN_NUDGE_TICKS) return;
const i = top - W, m = mat[i];
if (i < 0 || PH[m] !== PH_POWDER || stamp[i] === tickStamp || occ[i]) return;
if (pass && pass[m] && enter(m, PH_POWDER, top, 0, 1)) return;
const j = i + d + W;
if (i % W + d < 0 || i % W + d >= W || j >= N) return;
if (enter(m, PH_POWDER, j, d, 1)) move(i, j, i % W, (i / W) | 0, j % W, (j / W) | 0);
}
+2 -1
View File
@@ -42,10 +42,11 @@ function alloc(w, h) {
W = w; H = h; N = w * h; W = w; H = h; N = w * h;
fogAlloc(); fogAlloc();
CW = Math.ceil(w / 32); CH = Math.ceil(h / 32); NCH = CW * CH; CW = Math.ceil(w / 32); CH = Math.ceil(h / 32); NCH = CW * CH;
cleatMask = new Uint8Array(N); cleatCells = [];
mat = new Uint8Array(N); temp = new Float32Array(N); life = new Uint8Array(N); flags = new Uint8Array(N); 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); 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); 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(); rackState.clear(); BW = Math.ceil(w / BS); BH = Math.ceil(h / BS); NB = BW * BH; blockP = new Uint8Array(NB); blockV = new Uint8Array(NB); slopePhase = new Uint8Array(NB); filtersDirty = true; filterCfg = new Map(); procDirty = true; procState.clear(); rackState.clear();
ents = []; flyers = []; ghostBlocks = new Map(); ghostMolds = []; treeBases = []; saplings = []; 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; 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); surfY = new Int16Array(w); roofY = new Int16Array(w); dirtY = new Int16Array(w); graniteY = new Int16Array(w); basaltY = new Int16Array(w); magmaY = new Int16Array(w);
+1 -1
View File
@@ -100,7 +100,7 @@ function dragDirFor(P, a, b) {
if (!a || !P.dirs || !(P.id === 'belt' || P.id === 'screw' || P.filter)) return -1; if (!a || !P.dirs || !(P.id === 'belt' || P.id === 'screw' || P.filter)) return -1;
const dx = b.bx - a.bx, dy = b.by - a.by; const dx = b.bx - a.bx, dy = b.by - a.by;
if (!dx && !dy) return -1; if (!dx && !dy) return -1;
if ((P.id === 'belt' || P.filter) && diagonalDrag(dx, dy)) return dx > 0 ? 1 : 0; if ((P.id === 'belt' || P.filter) && diagonalDrag(dx, dy)) return P.filter ? (dx * dy > 0 ? 1 : 0) : (dx > 0 ? 1 : 0);
if (P.id === 'screw' && diagonalDrag(dx, dy)) return dx > 0 ? (dy < 0 ? 4 : 5) : (dy > 0 ? 6 : 7); if (P.id === 'screw' && diagonalDrag(dx, dy)) return dx > 0 ? (dy < 0 ? 4 : 5) : (dy > 0 ? 6 : 7);
if (P.id === 'screw') return Math.abs(dx) >= Math.abs(dy) ? (dx > 0 ? 1 : 3) : (dy > 0 ? 2 : 0); if (P.id === 'screw') return Math.abs(dx) >= Math.abs(dy) ? (dx > 0 ? 1 : 3) : (dy > 0 ? 2 : 0);
return Math.abs(dx) >= Math.abs(dy) ? (dx > 0 ? 1 : 0) : -1; return Math.abs(dx) >= Math.abs(dy) ? (dx > 0 ? 1 : 0) : -1;
+2 -2
View File
@@ -12,8 +12,8 @@ const PIECE_ICONS = {
wall: '<rect x="3" y="5" width="18" height="14" rx="1"/><path d="M3 12h18M9 5v7M15 12v7"/>', wall: '<rect x="3" y="5" width="18" height="14" rx="1"/><path d="M3 12h18M9 5v7M15 12v7"/>',
grate: '<rect x="3" y="5" width="18" height="14" rx="1"/><path d="M7.5 5v14M12 5v14M16.5 5v14"/>', grate: '<rect x="3" y="5" width="18" height="14" rx="1"/><path d="M7.5 5v14M12 5v14M16.5 5v14"/>',
gate: '<rect x="3" y="5" width="18" height="14" rx="1"/><path d="M9 5v14"/><path d="M12 12h6M16 9.5l2.5 2.5-2.5 2.5"/>', gate: '<rect x="3" y="5" width="18" height="14" rx="1"/><path d="M9 5v14"/><path d="M12 12h6M16 9.5l2.5 2.5-2.5 2.5"/>',
belt: '<g transform="rotate(-45 12 12)"><rect x="2" y="7" width="20" height="10" rx="2"/><path d="M4 9h16M4 15h16"/><circle cx="6" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="18" cy="12" r="2"/></g>', belt: '<g transform="rotate(-45 12 12)"><rect x="2" y="7" width="20" height="10" rx="2"/><path d="M4 9h16M4 15h16M7 7V3M13 7V3M19 7V3"/><circle cx="6" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="18" cy="12" r="2"/></g>',
filter: '<g transform="rotate(-45 12 12)"><rect x="2" y="7" width="20" height="10" rx="2"/><path d="M4 9h16M4 15h16M6 9v6M10 9v6M14 9v6M18 9v6M4 12h16"/></g>', filter: '<g transform="rotate(35 12 12)"><rect x="2" y="7" width="20" height="10" rx="2"/><path d="M4 9h16M4 15h16M6 9v6M10 9v6M14 9v6M18 9v6M4 12h16"/></g>',
screw: '<rect x="3" y="6" width="18" height="12" rx="2"/><path d="M6.5 18l3.5-12M11.5 18l3.5-12M16.5 18l3-10"/>', screw: '<rect x="3" y="6" width="18" height="12" rx="2"/><path d="M6.5 18l3.5-12M11.5 18l3.5-12M16.5 18l3-10"/>',
pipe: '<path d="M2 10h20M2 14h20"/><path d="M6 7.5v9M18 7.5v9"/>', pipe: '<path d="M2 10h20M2 14h20"/><path d="M6 7.5v9M18 7.5v9"/>',
pump: '<circle cx="11" cy="14" r="6"/><path d="M11 14l3.5-2.5M11 14l-1 4.2M11 14l-4-1.2"/><path d="M15.5 9.5L20 5M16 5h4v4"/>', pump: '<circle cx="11" cy="14" r="6"/><path d="M11 14l3.5-2.5M11 14l-1 4.2M11 14l-4-1.2"/><path d="M15.5 9.5L20 5M16 5h4v4"/>',
+1 -1
View File
@@ -71,7 +71,7 @@ function controlsHtml() {
const tools = [ 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.'], ['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."], ['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, screw conveyors, 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. A diagonal wall drag makes a sealed sloped face, solid below; T flips it. For one wall block, T cycles square and four slope corners. Diagonal belt and filter drags make sloped runs that carry up or down, following the drag. Their faces always point up. 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, screw conveyors, 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. A diagonal wall drag makes a sealed sloped face, solid below; T flips it. For one wall block, T cycles square and four slope corners. Diagonal belts use cleats to carry pockets up or down. Extra grains roll back. Diagonal filters always screen downhill, whichever way you drag. R leaves a picked sloped filter downhill. 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.'], ['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.'], ['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.'],
]; ];
+14
View File
@@ -221,6 +221,20 @@ function previewBlock(code, x, y, w, h, stroke = false, direction = -1, bx = -1,
} }
if (stroke) { const fill = ctx.fillStyle; ctx.fillStyle = ctx.strokeStyle; ctx.fill(); ctx.fillStyle = fill; } if (stroke) { const fill = ctx.fillStyle; ctx.fillStyle = ctx.strokeStyle; ctx.fill(); ctx.fillStyle = fill; }
else ctx.fill(); else ctx.fill();
// Preview cleats stand perpendicular to the face; screens have a raised far rail.
ctx.beginPath();
for (let k = 0; k < BS; k++) {
const fy = beltFaceY(code, 0, 0, k);
if (isFilter(code)) ctx.rect(x + k * scale, y + (fy - 2) * scale, scale, scale);
else {
ctx.rect(x + k * scale, y + (fy - 7 - ((ox + k) & 1)) * scale, scale, (2 + ((ox + k) & 1)) * scale);
}
if (!isFilter(code) && (((ox + k) % CLEAT_SPACING + CLEAT_SPACING) % CLEAT_SPACING === 0))
for (let h = 0; h <= CLEAT_REACH; h++)
ctx.rect(x + (k + beltGrade(code) * h) * scale, y + (fy - h - 1) * scale, scale, scale);
}
if (stroke) { const fill = ctx.fillStyle; ctx.fillStyle = ctx.strokeStyle; ctx.fill(); ctx.fillStyle = fill; }
else ctx.fill();
return; return;
} }
code = slopeShape(code); code = slopeShape(code);
+4 -4
View File
@@ -14,19 +14,19 @@ function filterSentence(f) { const p = filterParts(f); return `drop ${p.drops} a
// Change the whole run of matching filters at once // Change the whole run of matching filters at once
function applyFilterRun(run, f) { function applyFilterRun(run, f) {
undoRecord('filter settings', () => { undoRecord('filter settings', () => {
for (const b of run) { undoNote({ t: 'filter', bx: b % BW, by: (b / BW) | 0, v: blockV[b], extra: filterInfo(b) }); blockV[b] = f.dir ? 128 : 0; setFilter(b, f); } for (const b of run) { undoNote({ t: 'filter', bx: b % BW, by: (b / BW) | 0, v: blockV[b], extra: filterInfo(b) }); blockV[b] = filterVariant(blockP[b], f.dir ? 128 : 0); setFilter(b, f); }
}); });
filtersDirty = true; filtersDirty = true;
} }
function openFilterPicker(cur, onDone, title) { function openFilterPicker(cur, onDone, title) {
const sel = new Set(cur.m); const sel = new Set(cur.m);
let inv = cur.inv ? 1 : 0, dir = cur.dir ? 1 : 0; let inv = cur.inv ? 1 : 0, dir = beltGrade(cur.code) ? (beltGrade(cur.code)>0 ? 1 : 0) : cur.dir ? 1 : 0;
const list = filterChoices(cur.m); const list = filterChoices(cur.m);
const body = `<div class="fmode" role="radiogroup" aria-label="Mode"> const body = `<div class="fmode" role="radiogroup" aria-label="Mode">
<button class="fm" role="radio" data-inv="0"><b>↓ Drop these</b><span>They fall through; the rest rides along</span></button> <button class="fm" role="radio" data-inv="0"><b>↓ Drop these</b><span>They fall through; the rest rides along</span></button>
<button class="fm" role="radio" data-inv="1"><b>→ Carry these</b><span>They ride along; the rest falls through</span></button> <button class="fm" role="radio" data-inv="1"><b>→ Carry these</b><span>They ride along; the rest falls through</span></button>
</div> </div>
<div class="fdir" role="radiogroup" aria-label="Rollers turn"><span class="label">Rollers turn</span><button class="fd" role="radio" data-dir="0">${cur.code ? esc(beltDirection(cur.code, 0)) : '← Left'}</button><button class="fd" role="radio" data-dir="1">${cur.code ? esc(beltDirection(cur.code, 128)) : 'Right →'}</button></div>` <div class="fdir" role="radiogroup" aria-label="Screen travel"><span class="label">Screen travel</span>${beltGrade(cur.code) ? `<span>${esc(beltDirection(cur.code, dir << 7))}</span>` : '<button class="fd" role="radio" data-dir="0">← Left</button><button class="fd" role="radio" data-dir="1">Right →</button>'}</div>`
+ (list.length ? `<div class="fpick">${list.map(m => `<button class="fp" role="checkbox" data-m="${m}"><span class="ck" aria-hidden="true"></span><span class="sw" style="background:${rgbOf(m)}"></span><span>${esc(MD[m].name)}</span></button>`).join('')}</div>` : '<p class="note">Find something loose first.</p>') + (list.length ? `<div class="fpick">${list.map(m => `<button class="fp" role="checkbox" data-m="${m}"><span class="ck" aria-hidden="true"></span><span class="sw" style="background:${rgbOf(m)}"></span><span>${esc(MD[m].name)}</span></button>`).join('')}</div>` : '<p class="note">Find something loose first.</p>')
+ `<p class="note fp-sum" id="fp-sum"></p>`; + `<p class="note fp-sum" id="fp-sum"></p>`;
dialog(title || 'Filter', body, [{ label: 'Clear', cls: 'ghost', fn: () => { sel.clear(); sync(); return true; } }, { label: 'Cancel' }, { label: 'Set filter', cls: 'primary', fn: () => { onDone({ m: [...sel].sort((a, b) => a - b), inv, dir }); hudDirty = true; } }]); dialog(title || 'Filter', body, [{ label: 'Clear', cls: 'ghost', fn: () => { sel.clear(); sync(); return true; } }, { label: 'Cancel' }, { label: 'Set filter', cls: 'primary', fn: () => { onDone({ m: [...sel].sort((a, b) => a - b), inv, dir }); hudDirty = true; } }]);
@@ -36,7 +36,7 @@ function openFilterPicker(cur, onDone, title) {
box.querySelectorAll('[data-dir]').forEach(b => { const on = +b.dataset.dir === dir; b.classList.toggle('on', on); b.setAttribute('aria-checked', on); }); box.querySelectorAll('[data-dir]').forEach(b => { const on = +b.dataset.dir === dir; b.classList.toggle('on', on); b.setAttribute('aria-checked', on); });
box.querySelectorAll('[data-m]').forEach(b => { const on = sel.has(+b.dataset.m); b.classList.toggle('on', on); b.setAttribute('aria-checked', on); }); box.querySelectorAll('[data-m]').forEach(b => { const on = sel.has(+b.dataset.m); b.classList.toggle('on', on); b.setAttribute('aria-checked', on); });
const f = { m: [...sel].sort((a, b) => a - b), inv }; const f = { m: [...sel].sort((a, b) => a - b), inv };
box.querySelector('#fp-sum').textContent = `${title && /in this row/.test(title) ? 'These filters' : 'This filter'} will ${filterSentence(f)}, ${dir ? 'right' : 'left'}.`; box.querySelector('#fp-sum').textContent = `${title && /in this row/.test(title) ? 'These filters' : 'This filter'} will ${filterSentence(f)}, ${beltGrade(cur.code) ? beltDirection(cur.code, dir << 7) : dir ? 'right' : 'left'}.`;
} }
box.querySelectorAll('[data-dir]').forEach(b => b.onclick = () => { dir = +b.dataset.dir; playSfx('ui'); sync(); }); box.querySelectorAll('[data-dir]').forEach(b => b.onclick = () => { dir = +b.dataset.dir; playSfx('ui'); sync(); });
box.querySelectorAll('[data-inv]').forEach(b => b.onclick = () => { inv = +b.dataset.inv; playSfx('ui'); sync(); }); box.querySelectorAll('[data-inv]').forEach(b => b.onclick = () => { inv = +b.dataset.inv; playSfx('ui'); sync(); });
+2 -1
View File
@@ -47,6 +47,7 @@ function rotate() {
else if (t === 'build') { else if (t === 'build') {
const P = piece(); const P = piece();
if (pieceUsesWall(P)) cycleWall(1); if (pieceUsesWall(P)) cycleWall(1);
else if (P.filter && beltGrade(pieceCode(P))) return;
else if (P.dirs) GAME.pieceDir[P.id] = ((GAME.pieceDir[P.id] || 0) + 1) % P.dirs.length; else if (P.dirs) GAME.pieceDir[P.id] = ((GAME.pieceDir[P.id] || 0) + 1) % P.dirs.length;
else return; else return;
} else if (t === 'drill') { tuneUnderCursor(); return; } } else if (t === 'drill') { tuneUnderCursor(); return; }
@@ -83,7 +84,7 @@ function setBrush(d) {
// materials and mode travel separately, as GAME.filterCfg) // materials and mode travel separately, as GAME.filterCfg)
function pieceV(P) { function pieceV(P) {
if (isWall(P.code)) return GAME.wallMat; if (isWall(P.code)) return GAME.wallMat;
if (P.filter) return (GAME.pieceDir[P.id] || 0) << 7; if (P.filter) return filterVariant(pieceCode(P), (GAME.pieceDir[P.id] || 0) << 7);
return GAME.pieceDir[P.id] || 0; return GAME.pieceDir[P.id] || 0;
} }
function pieceExtra(P) { return P.filter ? { m: GAME.filterCfg.m.slice(), inv: GAME.filterCfg.inv } : undefined; } function pieceExtra(P) { return P.filter ? { m: GAME.filterCfg.m.slice(), inv: GAME.filterCfg.inv } : undefined; }
+22
View File
@@ -698,6 +698,27 @@ with sync_playwright() as p:
check('Wall diagonal defaults back to solid below', ev(f'Array.from({{length:4}}, (_,k)=>blockP[({bby}-12+k)*BW+{bbx}-5+k]).every(c=>c===BP_SLOPE_BL)')) check('Wall diagonal defaults back to solid below', ev(f'Array.from({{length:4}}, (_,k)=>blockP[({bby}-12+k)*BW+{bbx}-5+k]).every(c=>c===BP_SLOPE_BL)'))
pg.keyboard.press('Control+z'); settle(60) pg.keyboard.press('Control+z'); settle(60)
# Cleated belts and downhill screens use real drags, previews, picking and R.
for piece_id in ['belt', 'filter']:
check('select ' + piece_id + ' for a diagonal drag', select('3', "piece().id === '" + piece_id + "'"))
ev('money=1e6')
drag(lambda: blk(bbx - 5, bby - 9), lambda: blk(bbx - 2, bby - 12))
code = 'BP_BELT_UP' if piece_id == 'belt' else 'BP_FILTER_UP'
check(piece_id + ' uphill drag lays the expected shape', ev(f'Array.from({{length:4}},(_,k)=>blockP[({bby}-9-k)*BW+{bbx}-5+k]).every(c=>c==={code})'))
if piece_id == 'filter':
check('uphill filter drag runs downhill left', ev(f'Array.from({{length:4}},(_,k)=>blockV[({bby}-9-k)*BW+{bbx}-5+k]).every(v=>v===0)'))
pg.mouse.move(*blk(bbx - 3, bby - 11)); settle(60)
check('screen hover says down-left', ev(f"hoverInfo(({bbx}-3)*4+1,({bby}-11)*4+2).t1.includes('down-left')"))
pg.mouse.click(*blk(bbx - 3, bby - 11), button='middle'); settle(60)
pg.keyboard.press('r'); settle(60)
check('R leaves a picked screen downhill', ev('pieceCode(piece())===BP_FILTER_UP && pieceV(piece())===0'))
pg.keyboard.press('f'); settle(100)
check('screen picker offers only downhill travel', pg.locator('#dlg-body [data-dir]').count() == 0 and 'down-left' in pg.locator('#dlg-body').inner_text())
pg.locator('#dlg-foot').get_by_role('button', name='Cancel', exact=True).click()
pg.keyboard.press('Control+z'); settle(60)
ev('GAME.pieceShape = {}; GAME.pieceDir.filter = 1; GAME.editRun = null')
# Diagonal screws use real mouse placement, preview arrows, R and picking. # Diagonal screws use real mouse placement, preview arrows, R and picking.
check('select screw conveyor', select('3', "piece().id === 'screw'")) check('select screw conveyor', select('3', "piece().id === 'screw'"))
ev('GAME.pieceDir.screw = 0') ev('GAME.pieceDir.screw = 0')
@@ -1201,6 +1222,7 @@ with sync_playwright() as p:
point = fp.evaluate(f'(() => {{ const p = worldToScreenPx(testSilo.x + {dx} + 0.5, testSilo.y + {dy} + 0.5); return [p.x * innerWidth / R.canvas.width, p.y * innerWidth / R.canvas.width]; }})()') point = fp.evaluate(f'(() => {{ const p = worldToScreenPx(testSilo.x + {dx} + 0.5, testSilo.y + {dy} + 0.5); return [p.x * innerWidth / R.canvas.width, p.y * innerWidth / R.canvas.width]; }})()')
fp.mouse.move(*point); fp.wait_for_timeout(200) fp.mouse.move(*point); fp.wait_for_timeout(200)
want = 'sand 2,000 / 4,000' if amount else 'empty' want = 'sand 2,000 / 4,000' if amount else 'empty'
fp.wait_for_function(f"$('info').textContent.includes('{('holds 2,000 / 4,000 sand' if amount else 'empty')}')")
check(f'silo {name} hover shows {want}', fp.evaluate(f"hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)).t1 === 'Silo' && hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)).contents === '{want}' && $('info').textContent.includes('{('holds 2,000 / 4,000 sand' if amount else 'empty')}')")) check(f'silo {name} hover shows {want}', fp.evaluate(f"hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)).t1 === 'Silo' && hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)).contents === '{want}' && $('info').textContent.includes('{('holds 2,000 / 4,000 sand' if amount else 'empty')}')"))
check(f'silo {name} cursor label draws {want} in normal colour', fp.evaluate("""() => { check(f'silo {name} cursor label draws {want} in normal colour', fp.evaluate("""() => {
const calls = [], ctx = R.ctx, text = ctx.fillText; const calls = [], ctx = R.ctx, text = ctx.fillText;
+20
View File
@@ -97,4 +97,24 @@ check('drawing leaves the seed, cells and saved block data unchanged', run(`(()
} }
return snapshot() === before; return snapshot() === before;
})()`)); })()`));
for (const grade of [-1,1]) {
flatWorld(run,160,120,112);
run(`for(let k=0;k<6;k++)placeBlock(${grade<0?'BP_BELT_UP':'BP_BELT_DOWN'},1,12+k,${grade<0?20:12}+k*${grade},true);`);
for(let phase=0;phase<8;phase++) {
run(`rebuildPower();slopePhase.fill(${phase});slopeCleatsTick(false);prepareSlopeBelts(0,0,W,H);`);
check(`grade ${grade} phase ${phase} draws exactly the physical cleat raster`,run(`(()=>{
let drawn=0;
for(let i=0;i<N;i++){const x=i%W,y=(i/W)|0,detail=slopeDetailAt(i,x,y);if(detail===1)drawn++;
if((detail===1)!==Boolean(cleatMask[i] && !part[i] && !occ[i] && !mach[i] && (mat[i]===EMPTY || PH[mat[i]]===PH_GAS)))return false;
}return drawn>0;
})()`));
}
}
flatWorld(run,160,120,112);
run('placeBlock(BP_FILTER_DOWN,128,12,12,true);tick=0;prepareSlopeBelts(0,0,W,H);var idleJoins=slopeBeltJoins[12*BW+12];tick=2;prepareSlopeBelts(0,0,W,H);');
check('empty screen has a stable frame',run('slopeBeltJoins[12*BW+12]===idleJoins'));
run('setCell(47*W+48,SAND,20,0);prepareSlopeBelts(0,0,W,H);');
check('loaded screen shakes the frame with the simulation tick',run('(slopeBeltJoins[12*BW+12]&64)!==0'));
run('tick=4;prepareSlopeBelts(0,0,W,H);');
check('loaded frame returns on the next shake beat',run('(slopeBeltJoins[12*BW+12]&64)===0'));
done(); done();
+53 -26
View File
@@ -10,18 +10,19 @@ fresh();
check('slope belts use new codes, without renumbering old slopes', run("typeof BP_BELT_UP !== 'undefined' && BP_BELT_UP > BP_SLOPE_TR")); check('slope belts use new codes, without renumbering old slopes', run("typeof BP_BELT_UP !== 'undefined' && BP_BELT_UP > BP_SLOPE_TR"));
function line(grade, filter = false, dir = 1) { function line(grade, filter = false, dir = 1) {
fresh(); fresh();
if (filter) dir = grade > 0 ? 1 : 0;
const by = grade < 0 ? 32 : 16, inlet = by + (grade < 0 ? 1 : 0), outlet = by + 9 * grade + (grade > 0 ? 1 : 0); const by = grade < 0 ? 32 : 16, inlet = by + (grade < 0 ? 1 : 0), outlet = by + 9 * grade + (grade > 0 ? 1 : 0);
run(`for(let k=0;k<10;k++) placeBlock(${filter ? 'BP_FILTER_UP' : 'BP_BELT_UP'}+${grade>0?1:0},${filter?dir*128:dir},16+k,${by}+k*${grade},true,{m:[SAND],inv:0}); run(`for(let k=0;k<10;k++) placeBlock(${filter ? 'BP_FILTER_UP' : 'BP_BELT_UP'}+${grade>0?1:0},${filter?dir*128:dir},16+k,${by}+k*${grade},true,{m:[SAND],inv:0});
for(let x=10;x<16;x++)placeBlock(BP_BELT,${dir},x,${inlet},true); for(let x=10;x<16;x++)placeBlock(BP_BELT,${dir},x,${inlet},true);
for(let x=26;x<32;x++)placeBlock(BP_BELT,${dir},x,${outlet},true);`); for(let x=26;x<32;x++)placeBlock(BP_BELT,${dir},x,${outlet},true);`);
return { by, inlet, outlet, grade }; return { by, inlet: filter && grade < 0 ? outlet : inlet, outlet, grade, left: filter && grade < 0, feedX: filter && grade < 0 ? 120 : 44 };
} }
function collect(s, steps, feed = false, sink = 128) { function collect(s, steps, feed = false, sink = 128) {
return run(`(()=>{let out=0,added=0;for(let k=0;k<${steps};k++){ return run(`(()=>{let out=0,added=0;for(let k=0;k<${steps};k++){
${feed ? `for(let l=1;l<=4;l++){const i=(${s.inlet}*4-l)*W+44;if(mat[i]===EMPTY){setCell(i,SAND,20,0);added++;}}` : ''} ${feed ? `for(let l=1;l<=4;l++){const i=(${s.inlet}*4-l)*W+${s.feedX || 44};if(mat[i]===EMPTY){setCell(i,SAND,20,0);added++;}}` : ''}
simTick(); simTick();
for(let i=0;i<N;i++)if(mat[i]===SAND && i%W>=${sink}){out++;setCell(i,EMPTY,20,0);} for(let i=0;i<N;i++)if(mat[i]===SAND && ${s.left ? "i%W<40" : "i%W>="+sink}){out++;setCell(i,EMPTY,20,0);}
for(let j=flyers.length-1;j>=0;j--)if(flyers[j].m===SAND && flyers[j].x>=${sink}){out++;flyers.splice(j,1);} for(let j=flyers.length-1;j>=0;j--)if(flyers[j].m===SAND && ${s.left ? "flyers[j].x<40" : "flyers[j].x>="+sink}){out++;flyers.splice(j,1);}
}return {out,added,left:mat.reduce((n,m)=>n+(m===SAND),0)+flyers.filter(f=>f.m===SAND).length};})()`); }return {out,added,left:mat.reduce((n,m)=>n+(m===SAND),0)+flyers.filter(f=>f.m===SAND).length};})()`);
} }
const rates=[]; const rates=[];
@@ -31,7 +32,7 @@ for(const grade of [0,-1,1]) {
else {fresh();run('for(let bx=10;bx<32;bx++)placeBlock(BP_BELT,1,bx,24,true);');s={inlet:24};} else {fresh();run('for(let bx=10;bx<32;bx++)placeBlock(BP_BELT,1,bx,24,true);');s={inlet:24};}
collect(s,220,true); collect(s,220,true);
const r=collect(s,600,true);rates.push(r.out/10); const r=collect(s,600,true);rates.push(r.out/10);
check(`grade ${grade} moves a four-grain load at flat belt speed`,r.out>0 && (grade===0 || Math.abs(r.out/10-rates[0])<=rates[0]*0.05),JSON.stringify(r)); check(`grade ${grade} carries a generous feed at least 60 grains/s`,r.out>0 && r.out/10>=60,JSON.stringify(r));
const end=collect(s,600); const end=collect(s,600);
check(`grade ${grade} leaves no load stuck`,end.left===0,JSON.stringify(end)); check(`grade ${grade} leaves no load stuck`,end.left===0,JSON.stringify(end));
} }
@@ -43,7 +44,7 @@ for(const grade of [-1,1]) {
const r=collect(s,1000); const r=collect(s,1000);
check(`grade ${grade} conserves and delivers every grain across both flat joins`,r.out===128 && r.left===0,JSON.stringify(r)); check(`grade ${grade} conserves and delivers every grain across both flat joins`,r.out===128 && r.left===0,JSON.stringify(r));
line(grade); line(grade);
run(`spawnBar(BARCU,44,${s.inlet*4-2},0,0);`); run(`spawnBar(BARCU,${s.feedX || 44},${s.inlet*4-2},0,0);`);
ticks(run,500); ticks(run,500);
check(`grade ${grade} carries a bar through both flat joins`,run('ents.length===1 && ents[0].ix>=128'),run('ents.map(e=>[e.ix,e.iy])')); check(`grade ${grade} carries a bar through both flat joins`,run('ents.length===1 && ents[0].ix>=128'),run('ents.map(e=>[e.ix,e.iy])'));
} }
@@ -59,14 +60,14 @@ for(const grade of [-1,1]) {
rect(run,104,0,104,s.outlet*4-1,run('STONE')); rect(run,104,0,104,s.outlet*4-1,run('STONE'));
ticks(run,300); ticks(run,300);
const held=run('mat.reduce((n,m)=>n+(m===SAND),0)+flyers.filter(f=>f.m===SAND).length'); const held=run('mat.reduce((n,m)=>n+(m===SAND),0)+flyers.filter(f=>f.m===SAND).length');
check(`blocked grade ${grade} holds every grain without slipping below its face`,held===160 && count(run,run('SAND'),0,0,63,207)===0 && count(run,run('SAND'),0,160,255,207)===0,held); check(`blocked grade ${grade} conserves its load and lets overflow roll back`,held===160 && (grade>0 || count(run,run('SAND'),0,0,63,207)>0),held);
rect(run,104,0,104,s.outlet*4-1,run('EMPTY')); rect(run,104,0,104,s.outlet*4-1,run('EMPTY'));
const released=collect(s,800); const released=collect(s,800);
check(`grade ${grade} resumes after the head clears`,released.out===160 && released.left===0,JSON.stringify(released)); check(`grade ${grade} resumes after the head clears`,released.out===160 && released.left===0,JSON.stringify(released));
line(grade); line(grade);
run(`for(let bx=26;bx<32;bx++)removeBlock(bx,${s.outlet});setCell((${s.by+9*grade}*4+${grade<0?0:3}-1)*W+103,SAND,20,0);tick=2;tickStamp++;machinesTick();`); run(`for(let bx=26;bx<32;bx++)removeBlock(bx,${s.outlet});setCell((${s.by+9*grade}*4+${grade<0?0:3}-1)*W+103,SAND,20,0);tick=2;tickStamp++;machinesTick();`);
check(`grade ${grade} launches off an open end along its face`,run(`flyers.length===1 && flyers[0].vx>0 && Math.sign(flyers[0].vy)===${grade}`)); check(`grade ${grade} launches off an open end along its face`,run(`flyers.length===1 && flyers[0].vx>0 && Math.sign(flyers[0].vy)===${grade}`));
run(`spawnBar(BARCU,44,${s.inlet*4-2},0,0);`); run(`spawnBar(BARCU,${s.feedX || 44},${s.inlet*4-2},0,0);`);
const barLaunch=run('(()=>{for(let k=0;k<400;k++){simTick();if(ents[0].ix>=104)return ents[0].vx>0;}return false;})()'); const barLaunch=run('(()=>{for(let k=0;k<400;k++){simTick();if(ents[0].ix>=104)return ents[0].vx>0;}return false;})()');
check(`grade ${grade} keeps a bar moving forward off an open end`,barLaunch); check(`grade ${grade} keeps a bar moving forward off an open end`,barLaunch);
for(const diagonal of [false,true]) { for(const diagonal of [false,true]) {
@@ -76,7 +77,7 @@ for(const grade of [-1,1]) {
rect(run,39,20,39,s.inlet*4-1,run('STONE')); rect(run,39,20,39,s.inlet*4-1,run('STONE'));
const r=collect(s,1000,false,116); const r=collect(s,1000,false,116);
check(`grade ${grade} unloads grains into a ${diagonal?'diagonal':'straight'} screw`,r.out===128 && r.left===0,JSON.stringify(r)); check(`grade ${grade} unloads grains into a ${diagonal?'diagonal':'straight'} screw`,r.out===128 && r.left===0,JSON.stringify(r));
run(`spawnBar(BARCU,44,${s.inlet*4-2},0,0);`); run(`spawnBar(BARCU,${s.feedX || 44},${s.inlet*4-2},0,0);`);
ticks(run,600); ticks(run,600);
check(`grade ${grade} unloads a bar into a ${diagonal?'diagonal':'straight'} screw`,run('ents.length===1 && ents[0].ix>=116 && !mat.some((m,i)=>occ[i] && PH[m]===PH_SOLID)'),run('ents.map(e=>[e.ix,e.iy])')); check(`grade ${grade} unloads a bar into a ${diagonal?'diagonal':'straight'} screw`,run('ents.length===1 && ents[0].ix>=116 && !mat.some((m,i)=>occ[i] && PH[m]===PH_SOLID)'),run('ents.map(e=>[e.ix,e.iy])'));
@@ -88,18 +89,18 @@ for(const grade of [-1,1]) {
collect(s,220,true); collect(s,220,true);
const r=collect(s,600,true); const r=collect(s,600,true);
console.log(`Filter throughput grade ${grade}, grains/s: ${r.out/10}`); console.log(`Filter throughput grade ${grade}, grains/s: ${r.out/10}`);
check(`carrying filter grade ${grade} matches the belt feed`,r.out/10>=rates[0]*0.95,JSON.stringify(r)); check(`downhill screen grade ${grade} discharges its generous feed`,r.out/10>=45 && r.out/10<=90,JSON.stringify(r));
} }
for(const grade of [-1,1]) for(const inv of [0,1]) { for(const grade of [-1,1]) for(const inv of [0,1]) {
const s=line(grade,true); const s=line(grade,true);
run(`for(const b of filterCfg.keys())setFilter(b,{m:[SAND],inv:${inv}});`); run(`for(const b of filterCfg.keys())setFilter(b,{m:[SAND],inv:${inv}});`);
rect(run,39,20,39,s.inlet*4-1,run('STONE')); rect(run,s.left?128:39,20,s.left?128:39,s.inlet*4-1,run('STONE'));
run(`for(let y=${s.inlet*4-8};y<${s.inlet*4};y++)for(let x=40;x<56;x++)setCell(y*W+x,(x+y)&1?SAND:GRAVEL,20,0);`); run(`for(let y=${s.inlet*4-8};y<${s.inlet*4};y++)for(let x=${s.left?112:40};x<${s.left?128:56};x++)setCell(y*W+x,(x+y)&1?SAND:GRAVEL,20,0);`);
const result=run(`(()=>{const out=[0,0],below=[0,0];for(let k=0;k<1200;k++){ const result=run(`(()=>{const out=[0,0],below=[0,0];for(let k=0;k<1200;k++){
simTick(); simTick();
for(let i=0;i<N;i++)if(mat[i]===SAND || mat[i]===GRAVEL){const m=mat[i]===SAND?0:1,x=i%W,y=(i/W)|0; for(let i=0;i<N;i++)if(mat[i]===SAND || mat[i]===GRAVEL){const m=mat[i]===SAND?0:1,x=i%W,y=(i/W)|0;
if(x>=128){out[m]++;setCell(i,EMPTY,20,0);}else if(y>=180){below[m]++;setCell(i,EMPTY,20,0);}} if(${s.left?"x<40":"x>=128"}){out[m]++;setCell(i,EMPTY,20,0);}else if(y>=180){below[m]++;setCell(i,EMPTY,20,0);}}
for(let f=flyers.length-1;f>=0;f--){const p=flyers[f];if((p.m===SAND || p.m===GRAVEL) && p.x>=128){out[p.m===SAND?0:1]++;flyers.splice(f,1);}} for(let f=flyers.length-1;f>=0;f--){const p=flyers[f];if((p.m===SAND || p.m===GRAVEL) && ${s.left?"p.x<40":"p.x>=128"}){out[p.m===SAND?0:1]++;flyers.splice(f,1);}}
}return {out,below,left:mat.reduce((n,m)=>n+(m===SAND || m===GRAVEL),0)+flyers.filter(f=>f.m===SAND || f.m===GRAVEL).length};})()`); }return {out,below,left:mat.reduce((n,m)=>n+(m===SAND || m===GRAVEL),0)+flyers.filter(f=>f.m===SAND || f.m===GRAVEL).length};})()`);
console.log(`Sorting grade ${grade} inverse ${inv}: `+JSON.stringify(result)); console.log(`Sorting grade ${grade} inverse ${inv}: `+JSON.stringify(result));
check(`grade ${grade} ${inv?'carries selected':'drops selected'} and conserves the full mix`,result.out[inv?0:1]===64 && result.below[inv?1:0]===64 && result.left===0 && result.out[inv?1:0]===0 && result.below[inv?0:1]===0,JSON.stringify(result)); check(`grade ${grade} ${inv?'carries selected':'drops selected'} and conserves the full mix`,result.out[inv?0:1]===64 && result.below[inv?1:0]===64 && result.left===0 && result.out[inv?1:0]===0 && result.below[inv?0:1]===0,JSON.stringify(result));
@@ -107,21 +108,25 @@ for(const grade of [-1,1]) for(const inv of [0,1]) {
for(const c of ['BP_BELT_UP','BP_BELT_DOWN','BP_FILTER_UP','BP_FILTER_DOWN'])for(const d of [0,1]) { for(const c of ['BP_BELT_UP','BP_BELT_DOWN','BP_FILTER_UP','BP_FILTER_DOWN'])for(const d of [0,1]) {
fresh(); fresh();
run(`placeBlock(${c},${c.includes('FILTER')?d*128:d},20,20,true,{m:[SAND,BARCU],inv:1});globalThis.saved=serializeWorld();deserializeWorld(saved.meta,saved.buffers);`); run(`placeBlock(${c},${c.includes('FILTER')?d*128:d},20,20,true,{m:[SAND,BARCU],inv:1});globalThis.saved=serializeWorld();deserializeWorld(saved.meta,saved.buffers);`);
check(`${c} ${d} saves its shape, direction and settings`,run(`blockP[20*BW+20]===${c} && blockV[20*BW+20]===${c.includes('FILTER')?d*128:d} && blockIntegrity(20*BW+20)===10 ${c.includes('FILTER')?'&& sameFilter(filterInfo(20*BW+20),{m:[SAND,BARCU].sort((a,b)=>a-b),inv:1})':''}`)); check(`${c} ${d} saves its shape, direction and settings`,run(`blockP[20*BW+20]===${c} && blockV[20*BW+20]===${c.includes('FILTER')?'filterVariant('+c+','+d+'*128)':d} && blockIntegrity(20*BW+20)===10 ${c.includes('FILTER')?'&& sameFilter(filterInfo(20*BW+20),{m:[SAND,BARCU].sort((a,b)=>a-b),inv:1})':''}`));
if(c.includes('BELT')) {
run(`slopePhase[20*BW+20]=${d?1:7};rebuildPower();slopeCleatsTick(false);globalThis.savedPhase=serializeWorld();deserializeWorld(savedPhase.meta,savedPhase.buffers);`);
check(`${c} saves the moving cleat phase and immediately restores its barriers`,run(`slopePhase[20*BW+20]===${d?1:7} && cleatCells.length>0`));
}
run(`globalThis.clip=copyRegion(80,80,83,83);bot.x=96;bot.y=80;undoRecord('paste',()=>pasteGhost(clip,24,20));ghostTick();`); run(`globalThis.clip=copyRegion(80,80,83,83);bot.x=96;bot.y=80;undoRecord('paste',()=>pasteGhost(clip,24,20));ghostTick();`);
check(`${c} ${d} copy/paste keeps all settings`,run('blockP[20*BW+24]===blockP[20*BW+20] && blockV[20*BW+24]===blockV[20*BW+20] && sameFilter(filterInfo(20*BW+20),filterInfo(20*BW+24))')); check(`${c} ${d} copy/paste keeps all settings`,run('blockP[20*BW+24]===blockP[20*BW+20] && blockV[20*BW+24]===blockV[20*BW+20] && sameFilter(filterInfo(20*BW+20),filterInfo(20*BW+24))'));
run('undoLast();'); run('undoLast();');
check(`${c} ${d} undo clears the paste including its seal`,run(`blockP[20*BW+24]===0 && blockCellList(24,20).every(i=>mat[i]===EMPTY && !part[i]) && mat[slopeSeal(${c},24,20)]===EMPTY && !part[slopeSeal(${c},24,20)]`)); check(`${c} ${d} undo clears the paste including its seal`,run(`blockP[20*BW+24]===0 && blockCellList(24,20).every(i=>mat[i]===EMPTY && !part[i]) && mat[slopeSeal(${c},24,20)]===EMPTY && !part[slopeSeal(${c},24,20)]`));
run("undoRecord('cut',()=>cutRegion(80,80,83,83));undoLast();"); run("undoRecord('cut',()=>cutRegion(80,80,83,83));undoLast();");
check(`${c} ${d} undo restores a cut`,run(`blockP[20*BW+20]===${c} && blockIntegrity(20*BW+20)===10`)); check(`${c} ${d} undo restores a cut`,run(`blockP[20*BW+20]===${c} && blockIntegrity(20*BW+20)===10`));
check(`${c} ${d} mirrors its shape and reverses horizontally`,run(`(()=>{const b=flipBlueprint(clip).blocks[0];return b[2]===${c}+${c.endsWith('UP')?1:-1} && b[3]===${c.includes('FILTER')?(1-d)*128:1-d};})()`)); check(`${c} ${d} mirrors its shape and reverses horizontally`,run(`(()=>{const b=flipBlueprint(clip).blocks[0];return b[2]===${c}+${c.endsWith('UP')?1:-1} && b[3]===${c.includes('FILTER')?'filterVariant('+c+'+'+(c.endsWith('UP')?1:-1)+',0)':1-d};})()`));
} }
for(const grade of [-1,1]) { for(const grade of [-1,1]) {
const s=line(grade,true); const s=line(grade,true);
run(`spawnBar(BARCU,44,${s.inlet*4-2},0,0);`);ticks(run,500); run(`spawnBar(BARCU,${s.feedX || 44},${s.inlet*4-2},0,0);`);ticks(run,500);
check(`sloped filter grade ${grade} carries a bar it does not pass`,run('ents.length===1 && ents[0].ix>=128')); check(`sloped filter grade ${grade} slides a bar it does not pass downhill`,run(`ents.length===1 && ${s.left?'ents[0].ix<40':'ents[0].ix>=128'}`));
line(grade,true); line(grade,true);
run(`for(const b of filterCfg.keys())setFilter(b,{m:[BARCU],inv:0});spawnBar(BARCU,44,${s.inlet*4-2},0,0);`);ticks(run,500); run(`for(const b of filterCfg.keys())setFilter(b,{m:[BARCU],inv:0});spawnBar(BARCU,${s.feedX || 44},${s.inlet*4-2},0,0);`);ticks(run,500);
check(`sloped filter grade ${grade} drops a selected bar through its open frame`,run('ents.length===1 && ents[0].ix<104 && ents[0].iy===206 && !mat.some((m,i)=>occ[i] && PH[m]===PH_SOLID)')); check(`sloped filter grade ${grade} drops a selected bar through its open frame`,run('ents.length===1 && ents[0].ix<104 && ents[0].iy===206 && !mat.some((m,i)=>occ[i] && PH[m]===PH_SOLID)'));
} }
// Reverse the motor without flipping the upward-facing triangle. // Reverse the motor without flipping the upward-facing triangle.
@@ -135,8 +140,30 @@ for(const grade of [-1,1]) {
check(`reversed grade ${grade} carries bars left across both joins`,run('ents.length===1 && ents[0].ix<40'),run('ents.map(e=>[e.ix,e.iy])')); check(`reversed grade ${grade} carries bars left across both joins`,run('ents.length===1 && ents[0].ix<40'),run('ents.map(e=>[e.ix,e.iy])'));
} }
fresh(); fresh();
run('placeBlock(BP_BELT_UP,1,20,32,true);for(let l=1;l<=CARRY_DEPTH;l++)setCell((131-l)*W+80,SAND,20,0);tickStamp++;carryColumn(130*W+80,1,null);'); run('placeBlock(BP_BELT_UP,1,20,32,true);');
check('a slope carries the same full 96-grain column as a flat belt',run('Array.from({length:CARRY_DEPTH},(_,k)=>mat[(129-k)*W+81]===SAND).every(Boolean)')); check('pocket capacity is bounded by the real cleat height',run('Array.from({length:CLEAT_SPACING},(_,k)=>pocketDepth(80+k,32*BW+20)).every(d=>d>=0 && d<=CLEAT_REACH*2)'));
// A stationary cleat blocks downhill crossings; the bare face still lets grains slide.
for (const grade of [-1,1]) {
const s=line(grade);
run('rebuildPower();slopeCleatsTick(false);');
check(`grade ${grade} cleats block every downhill crossing`,run(`cleatCells.length>0 && cleatCells.every(i=>!enter(SAND,PH_POWDER,i,${grade},1) && !enter(SAND,PH_POWDER,i+W+${grade},${grade},1))`));
check(`grade ${grade} bare face slides between steps without stamping the load`,run(`(()=>{
for(let x=72;x<96;x++){
const by=${s.by}+((x>>2)-16)*${grade}, y=beltFaceY(blockP[by*BW+(x>>2)],x>>2,by,x)-1,i=y*W+x;
if(cleatMask[i] || cleatMask[i+W+${grade}])continue;
setCell(i,SAND,20,0);tick=3;tickStamp++;machinesTick();
const unstamped=stamp[i]!==tickStamp;
for(let k=0;k<200 && mat[i]===SAND;k++)powder(i,x,y,SAND);
return unstamped && mat[i]===EMPTY;
} return false;
})()`));
}
fresh();
run('placeBlock(BP_FILTER_DOWN,128,20,20,true,{m:[],inv:0});setCell(79*W+80,GRAVEL,20,0);tickStamp++;tick=SCREEN_NUDGE_TICKS-1;screenNudge(80*W+80,1,null);');
check('screen waits between its configured nudges',run('mat[79*W+80]===GRAVEL && stamp[79*W+80]!==tickStamp'));
run('tick++;screenNudge(80*W+80,1,null);');
check('screen nudges just its mesh contact downhill at the configured rate',run('mat[80*W+81]===GRAVEL && mat[79*W+80]===EMPTY'));
fresh();run('placeBlock(BP_BELT_UP,1,20,32,true);');
run(fs.readFileSync('src/render/slope-belts.js','utf8')); run(fs.readFileSync('src/render/slope-belts.js','utf8'));
// The renderer prepares joins and power first; stripe pitch is measured along the rotated band. // The renderer prepares joins and power first; stripe pitch is measured along the rotated band.
run('prepareSlopeBelts(0,0,W,H);'); run('prepareSlopeBelts(0,0,W,H);');
@@ -147,18 +174,18 @@ run("globalThis.GAME={pieceDir:{belt:1,filter:1},filterCfg:{m:[SAND],inv:0},wall
for(const id of ['belt','filter'])for(const [dx,dy] of [[5,-4],[4,5],[-5,4],[-4,-5]]) { for(const id of ['belt','filter'])for(const [dx,dy] of [[5,-4],[4,5],[-5,4],[-4,-5]]) {
run('GAME.drag={bx:20,by:20};'); run('GAME.drag={bx:20,by:20};');
const c=(id==='belt'?19:21)+(dx*dy>0?1:0); const c=(id==='belt'?19:21)+(dx*dy>0?1:0);
check(`${id} drag ${dx},${dy} picks an upward face and runs along the drag`,run(`pieceCode(PIECE_BY_ID.${id},{bx:${20+dx},by:${20+dy}})===${c} && dragDirFor(PIECE_BY_ID.${id},GAME.drag,{bx:${20+dx},by:${20+dy}})===${dx>0?1:0}`)); check(`${id} drag ${dx},${dy} picks the slope shape and permitted travel direction`,run(`pieceCode(PIECE_BY_ID.${id},{bx:${20+dx},by:${20+dy}})===${c} && dragDirFor(PIECE_BY_ID.${id},GAME.drag,{bx:${20+dx},by:${20+dy}})===${id==='filter'?(dx*dy>0?1:0):(dx>0?1:0)}`));
} }
run("globalThis.setStatus=()=>{};globalThis.catOf=()=>0;globalThis.selectTool=()=>{};globalThis.aimBlock=()=>blockOf(GAME.aim.x,GAME.aim.y);"); run("globalThis.setStatus=()=>{};globalThis.catOf=()=>0;globalThis.selectTool=()=>{};globalThis.aimBlock=()=>blockOf(GAME.aim.x,GAME.aim.y);");
for(const id of ['belt','filter'])for(const [dx,dy] of [[3,-3],[3,3],[-3,3],[-3,-3]]) { for(const id of ['belt','filter'])for(const [dx,dy] of [[3,-3],[3,3],[-3,3],[-3,-3]]) {
fresh(); fresh();
run(`globalThis.piece=()=>PIECE_BY_ID.${id};GAME.drag={bx:20,by:20};GAME.aim={x:${(20+dx)*4},y:${(20+dy)*4}};bot.x=80;bot.y=80;primaryRelease(false);`); run(`globalThis.piece=()=>PIECE_BY_ID.${id};GAME.drag={bx:20,by:20};GAME.aim={x:${(20+dx)*4},y:${(20+dy)*4}};bot.x=80;bot.y=80;primaryRelease(false);`);
const c=(id==='belt'?19:21)+(dx*dy>0?1:0), v=dx>0?(id==='filter'?128:1):0; const c=(id==='belt'?19:21)+(dx*dy>0?1:0), v=id==='filter'?(dx*dy>0?128:0):(dx>0?1:0);
check(`${id} release really builds the diagonal in direction ${dx},${dy}`,run(`Array.from({length:4},(_,k)=>{const b=(20+k*${Math.sign(dy)})*BW+20+k*${Math.sign(dx)};return blockP[b]===${c} && blockV[b]===${v};}).every(Boolean)`)); check(`${id} release really builds the diagonal in direction ${dx},${dy}`,run(`Array.from({length:4},(_,k)=>{const b=(20+k*${Math.sign(dy)})*BW+20+k*${Math.sign(dx)};return blockP[b]===${c} && blockV[b]===${v};}).every(Boolean)`));
run('pickUnderCursor();rotate();'); run('pickUnderCursor();rotate();');
check(`${id} pick and R reverse the motor while preserving a single slope shape`,run(`pieceCode(PIECE_BY_ID.${id})===${c} && pieceV(PIECE_BY_ID.${id})===${id==='filter'?128-v:1-v}`)); check(`${id} pick and R preserve the slope and use its permitted direction`,run(`pieceCode(PIECE_BY_ID.${id})===${c} && pieceV(PIECE_BY_ID.${id})===${id==='filter'?v:1-v}`));
run('GAME.drag=aimBlock();GAME.replace=true;primaryRelease(false);GAME.replace=false;'); run('GAME.drag=aimBlock();GAME.replace=true;primaryRelease(false);GAME.replace=false;');
check(`${id} a single replacement reverses the picked slope`,run(`blockP[${20+dy}*BW+${20+dx}]===${c} && blockV[${20+dy}*BW+${20+dx}]===${id==='filter'?128-v:1-v}`)); check(`${id} a single replacement keeps the selected direction`,run(`blockP[${20+dy}*BW+${20+dx}]===${c} && blockV[${20+dy}*BW+${20+dx}]===${id==='filter'?v:1-v}`));
} }
run(fs.readFileSync('src/ui/selection-pickers.js','utf8')); run(fs.readFileSync('src/ui/selection-pickers.js','utf8'));
@@ -166,7 +193,7 @@ const filterLine=line(-1,true);
run("applyFilterRun(filterRun(32*BW+16),{m:[GRAVEL],inv:1,dir:0});"); run("applyFilterRun(filterRun(32*BW+16),{m:[GRAVEL],inv:1,dir:0});");
check('the settings UI updates the matching diagonal filter run',run('filterCfg.size===10 && [...filterCfg.keys()].every(b=>sameFilter(filterInfo(b),{m:[GRAVEL],inv:1}) && blockV[b]===0)')); check('the settings UI updates the matching diagonal filter run',run('filterCfg.size===10 && [...filterCfg.keys()].every(b=>sameFilter(filterInfo(b),{m:[GRAVEL],inv:1}) && blockV[b]===0)'));
run('undoLast();'); run('undoLast();');
check('undo restores diagonal filter settings and direction',run('filterCfg.size===10 && [...filterCfg.keys()].every(b=>sameFilter(filterInfo(b),{m:[SAND],inv:0}) && blockV[b]===128)')); check('undo restores diagonal filter settings and direction',run('filterCfg.size===10 && [...filterCfg.keys()].every(b=>sameFilter(filterInfo(b),{m:[SAND],inv:0}) && blockV[b]===0)'));
// An actual v2 fixture, before either diagonal belt or filter existed. // An actual v2 fixture, before either diagonal belt or filter existed.
const legacy=JSON.parse(require('zlib').gunzipSync(fs.readFileSync(__dirname+'/fixtures/slopes-v2.json.gz'))); const legacy=JSON.parse(require('zlib').gunzipSync(fs.readFileSync(__dirname+'/fixtures/slopes-v2.json.gz')));
globalThis.legacyBeltSave=legacy; globalThis.legacyBeltSave=legacy;
+34
View File
@@ -0,0 +1,34 @@
// Many loaded slopes, with a fresh deterministic world for each timing sample.
const { performance } = require('perf_hooks');
const { load, flatWorld } = require(process.env.DG_HARNESS || '../../test/harness');
const { run } = load();
const assert = require('node:assert/strict');
const samples=[];
for(let sample=0;sample<6;sample++) {
flatWorld(run,1024,512,496);
run(`RS=7;money=1e9;AMB.fill(20);temp.fill(20);hot.fill(0);
for(let row=0;row<6;row++)for(let col=0;col<8;col++)for(let k=0;k<12;k++){
const bx=col*30+4+k,by=row*18+35-k;
placeBlock(BP_BELT_UP,1,bx,by,true);
for(let x=bx*4;x<bx*4+4;x++){const y=beltFaceY(BP_BELT_UP,bx,by,x);for(let h=1;h<=4;h++)setCell((y-h)*W+x,SAND,20,0);}
}
for(let k=0;k<60;k++)simTick();`);
const start=performance.now();run('for(let k=0;k<240;k++)simTick();');
if(sample) samples.push((performance.now()-start)/240);
}
assert(samples.length===5 && samples.every(n=>Number.isFinite(n) && n>0));
samples.sort((a,b)=>a-b);
console.log(`576 loaded slope blocks, median ${samples[2].toFixed(3)} ms/tick; samples ${samples.map(v=>v.toFixed(3)).join(', ')}`);
// No slopes: isolate the extra guards in the shared powder hot loop.
flatWorld(run,512,384,376);
run('RS=7;for(let y=32;y<350;y+=2)for(let x=32;x<480;x++){mat[y*W+x]=SAND;mat[(y+1)*W+x]=STONE;}');
const powderSamples=[];
for(let sample=0;sample<6;sample++) {
const start=performance.now();
run('for(let k=0;k<30;k++)for(let y=32;y<350;y+=2)for(let x=32;x<480;x++)powder(y*W+x,x,y,SAND);');
if(sample)powderSamples.push(performance.now()-start);
}
assert(powderSamples.length===5 && powderSamples.every(n=>Number.isFinite(n) && n>0));
powderSamples.sort((a,b)=>a-b);
console.log(`2.14M powder calls without cleats, median ${powderSamples[2].toFixed(2)} ms`);
+29
View File
@@ -0,0 +1,29 @@
// Reproducible generous-feed throughput and settled pile angles for t42.
const { load, flatWorld } = require(process.env.DG_HARNESS || '../../test/harness');
const { run } = load();
const assert = require('node:assert/strict');
for (const material of ['SAND','GRAVEL','CUORE','COAL']) {
const rates=[];
for (const grade of [0,-1]) {
flatWorld(run,256,224,208);
run(`RS=7;money=1e6;AMB.fill(20);temp.fill(20);hot.fill(0);
for(let k=0;k<10;k++)placeBlock(${grade?'BP_BELT_UP':'BP_BELT'},1,16+k,${grade?32:24}+k*${grade},true);
for(let x=10;x<16;x++)placeBlock(BP_BELT,1,x,${grade?33:24},true);
for(let x=26;x<32;x++)placeBlock(BP_BELT,1,x,${grade?23:24},true);`);
const result=run(`(()=>{let out=0;for(let k=0;k<1420;k++){
for(let l=1;l<=4;l++){const i=(${grade?132:96}-l)*W+44;if(mat[i]===EMPTY)setCell(i,${material},20,0);}
simTick();
for(let i=0;i<N;i++)if(mat[i]===${material} && i%W>=128){if(k>=220)out++;setCell(i,EMPTY,20,0);}
for(let j=flyers.length-1;j>=0;j--)if(flyers[j].m===${material} && flyers[j].x>=128){if(k>=220)out++;flyers.splice(j,1);}
}return out/20;})()`);
rates.push(result);
}
flatWorld(run,256,224,208);
run(`RS=7;AMB.fill(20);temp.fill(20);hot.fill(0);
for(let y=120;y<160;y++)for(let x=118;x<138;x++)setCell(y*W+x,${material},20,0);
for(let k=0;k<1200;k++)simTick();`);
// Apparent settled angle: heap height divided by half its footprint width.
const angle=run(`(()=>{let top=H,left=W,right=0;for(let i=0;i<N;i++)if(mat[i]===${material}){top=Math.min(top,(i/W)|0);left=Math.min(left,i%W);right=Math.max(right,i%W);}return Math.atan2(208-top,(right-left+1)/2)*180/Math.PI;})()`);
assert(rates[1]>=60 && rates[1]<=rates[0]*1.1, `${material} slope rate outside the target`);
console.log(`${material}: flat ${rates[0].toFixed(1)}, uphill ${rates[1].toFixed(1)} grains/s; pile angle ${angle.toFixed(1)} degrees`);
}
+21
View File
@@ -0,0 +1,21 @@
// Loaded sidewall conveyor and screen. Use --js 'slopeScene(false)' (belt) or 'slopeScene(true)' (screen).
globalThis.slopeScene = function(screen = false) {
const bx = Math.floor((SX - 100) / BS), by = Math.floor((SY + 12) / BS);
const left = (bx - 12) * BS, right = (bx + 24) * BS;
for(let y=by*BS-64;y<by*BS+64;y++)for(let x=left;x<right;x++) {
const i=y*W+x;bgm[i]=0;setCell(i,y>=by*BS+56?STONE:EMPTY,20,0);
}
for(let x=left;x<right;x++){surfY[x]=by*BS+56;dirtY[x]=by*BS+64;}
money=1e6;weather.next=1e15;
const startY=screen?by-8:by, grade=screen?1:-1;
for(let k=0;k<8;k++)placeBlock(screen?BP_FILTER_DOWN:BP_BELT_UP,screen?128:1,bx+k,startY+grade*k,true,{m:[SAND],inv:0});
const inlet=startY+(screen?0:1),outlet=startY+grade*7+(screen?1:0);
for(let k=1;k<=5;k++)placeBlock(BP_BELT,1,bx-k,inlet,true);
for(let k=8;k<13;k++)placeBlock(BP_BELT,1,bx+k,outlet,true);
for(let t=0;t<300;t++) {
for(let h=1;h<=4;h++){const i=(inlet*BS-h)*W+(bx-4)*BS;if(mat[i]===EMPTY)setCell(i,screen && (t&2)?GRAVEL:SAND,20,0);}
simTick();
}
// Keep loose rejects and passing grains visible underneath the screen.
CAM=[(bx+3)*BS,(startY+grade*4)*BS+4];updateRoof();
}