diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 53c11e5..75597dd 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -142,7 +142,7 @@ above, including: short stubs stay for the next firing. The mold cleans itself and rakes the grate after dropping bars or clearing junk. - **Screw conveyors carry no more than belts:** they move every other tick. For more throughput, build more lines. -- **A heap on a belt moves as one**, up to 96 grains deep, not just its bottom layer. If a grain is blocked, +- **A belt carries a bed four grains deep** (one block, t45), with higher grains left loose. If a grain is blocked, the load above it stands still and the belt slips underneath; the top no longer slides over a stuck bottom. Filters still let their chosen materials drop through. - **Pick** (middle mouse) selects the piece under the cursor with its direction, @@ -447,7 +447,10 @@ above, including: 2026-10-26).** An overloaded belt line jammed against a screw conveyor above it. The owner agreed that's realistic and stays the player's problem, but wants a warning on the stalled belt and the animation stopped on everything affected, and belts limited to a realistic - load depth instead of carrying 96-grain stacks. Not built yet. + load depth instead of carrying 96-grain stacks. Built 2026-10-29: the owner chose + **4 grains (one block)**. Loaded transport warns after half a second without progress, + with a marker and advice to clear the outlet or heap. Only affected blocks stop animating; + a blocked cleated slope stops its whole run. Screens use their slower nudge cadence. - **Cleats really push their pockets (t42 feedback, 2026-10-26).** The owner found the first cleats passed through heavy loads while the material climbed as before, and wants cleats that diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index de29397..8c98ab8 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -167,6 +167,11 @@ flyers and blueprint ghosts. The GPU canvas sits behind the transparent overlay `diagonal-screws.js` and `slope-belts.js` share the simulation join geometry with pixel colours, smooth tags, ghosts and previews. +Transport backpressure lives in `src/sim/transport-status.js`: transient per-block load, +movement, jam age and animation clocks. Flat carry, screens, screws, cleats and bars report +progress; only the serial transport step updates it. Both looks use those clocks; smooth +receives a view-sized clock texture so geometry tags and shading fields remain intact. + ## The browser shell - `GAME` (`src/ui/state.js`) holds the UI state: tool, selected piece, build tab, drag, @@ -244,7 +249,7 @@ Starter furnace designs and the milestone list shown in the notebook. - functions: smelterBlueprint - variables: STARTER_DESIGNS, MILESTONES -### src/sim/world.js (124 lines) +### src/sim/world.js (125 lines) World state: the per-cell typed arrays, alloc(), ambient temperature by depth, waking. - functions: emit, bagCap, discover, sharedArray, alloc, depthU, depthY, meanSurface, computeAmbient, fillNoise, renderDirtyXY, renderDirtyChunk, renderDirtyIdx, 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, renderDirty, 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 @@ -284,7 +289,7 @@ 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 - variables: PIPE_CAP, PUMP_RATE, OUTLET_RATE, PUMP_MAX_T, SIDE_V, PUMP_V, OUTLET_V, pipeNets, pipeNetAt, pipesDirty -### src/sim/machine-tick.js (158 lines) +### src/sim/machine-tick.js (160 lines) Per-tick machine work: steam engines, fans, belts, filters, screw conveyors (machinesTick). - functions: engineIntake, fanBlow, machinesTick @@ -293,8 +298,8 @@ Travel-directed transport ends. Precomputed probes; world/plan queries allocate - functions: pieceEnds, joinCandidate, endJoin, pieceJoins, endAt, joinIsDrop, joinSnap, nearMiss - variables: JOIN_ENDS, NO_PIECE, NEAR_MISS_VIEW -### src/sim/conveyors.js (213 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. +### src/sim/conveyors.js (215 lines) +How belts, filters and screw conveyors move what is on or in them. What rests on a belt or filter rides along: the bottom four grains 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, beltDrain, launchGrain, rebuildFilters, filterCarry, carryBeltsAndFilters, setFilter, filterRun, roomFor, screwSpread, screwIntake, screwAccepts, straightScrewNext, diagonalScrewNext - variables: CARRY_DEPTH, filterR, filterL, filtersDirty @@ -303,7 +308,12 @@ Throughput for the info overlay (t49): how many grains each belt, filter and scr - functions: setFlowCounting, flowNote, flowCross, flowSlopeUnder, flowTick - variables: flowOn, flowCount, flowRate -### src/sim/slope-conveyors.js (231 lines) +### src/sim/transport-status.js (38 lines) +Local backpressure: only loaded transport that cannot advance warns after half a second. +- functions: transportNote, transportStalled, transportClock, transportBegin, transportEnd, transportCleat, transportBar +- variables: STALL_TICKS, transportState + +### src/sim/slope-conveyors.js (237 lines) Cleats are rubber ribs standing up from a sloped belt's face, one every CLEAT_SPACING face cells. Loose grains treat them as solid (cleatMask). Nothing grips the bare face: grains slide down it until a cleat stops them. Each belt step moves every cleat one face cell along the belt, and the rib pushes whatever is in its way ahead of it, so the pocket in front rides along while anything heaped above the rib is left to roll back. If a rib can't push its way, its whole run stalls. Only uphill runs have cleats: downhill, the belt carries its load like a flat one. - functions: ribMove, cleatFoot, ribCells, slopeUphill, slopeFaceAt, pushRow, scoop, pocketRoom, advanceCleat, slopeRunKey, slopeCleatsTick, rebuildCleatMask, expelFromRib, screenNudge, wakeCleatLoad - variables: CLEAT_SPACING, CLEAT_HEIGHT, CLEAT_REACH, SCREEN_NUDGE_TICKS, cleatChunks, cleatMask, cleatCells, slopePhase, ribLog @@ -317,7 +327,7 @@ Molds: cavities for castings; ingot troughs empty themselves below. Removal retu - functions: moldCellsAt, canPlaceMold, newMoldRecord, addMold, castMix, cavityState, troughState, clearCavity, moldPlugCells, rakeAbove, furnaceJunkHint, unplugMold, ejectIngot, checkMolds, moldAt, tally, collectMold, removeMold - variables: DROSS, grains, barWord -### src/sim/items.js (319 lines) +### src/sim/items.js (320 lines) Bars and other loose objects (ents), and the bag: adding, taking, costs and refunds. - functions: entSolidCell, entSupportCount, entFreeAt, entMark, spawnBar, removeEnt, entScrewAt, entScrewFree, entScrewMove, entScrewLane, entScrewEdge, entScrewEnter, entScrewTick, entDiagonalScrewTick, entBeltScrewEnter, entSlopeBeltTick, entTick, entsInCircle, bagAdd, bagTake, giveOrDrop, refundAt, canAfford, payCost, fmtMoney, costText, needText - variables: BAR_W, BAR_H, entCurM, droppedLast @@ -374,7 +384,7 @@ The same chunk schedule runs inline or in workers. Workers only own a pass at a - functions: threadWorld, threadBind, threadContext, resetChunkWork, inlineChunkWork, chunkEvent, chunkWake, chunkHeat, chunkSeed, runChunks, mergeChunkWork, chunkPasses, threadParams, threadSetParams, threadSlot, threadExecute, threadPass, threadWait, threadFailure, threadMessage - variables: simPool, chunkWork, inlineWork -### src/sim/tick.js (29 lines) +### src/sim/tick.js (31 lines) simTick: one simulation step, in order. - functions: simTick @@ -412,7 +422,7 @@ Screw casing geometry shared by both looks, ghosts and placement previews. smoot - functions: diagonalScrewJoins, straightScrewJoins, diagonalScrewShape, diagonalScrewBlock - variables: SCREW_CLASS, diagonalJoins, diagonalAcross, diagonalAlong, diagonalCap -### src/render/cell.js (276 lines) +### src/render/cell.js (284 lines) Rare-cell shading also treats built pieces as transparent surroundings. - functions: builtForShade, cellPixel @@ -443,7 +453,7 @@ GPU sources retain the CPU roof and transmission rules; directional relaxation f Smooth slope geometry and cleats, ported from slope-belt-shader.js. - variables: GPU_SMOOTH_SLOPE_HELPERS, GPU_SMOOTH_SLOPE_DRAW -### src/render/gpu/smooth-shader.js (369 lines) +### src/render/gpu/smooth-shader.js (371 lines) Literal port of the legacy smooth shader; dpdy is reversed for WebGPU's top-left origin. - variables: GPU_SMOOTH @@ -451,7 +461,7 @@ Literal port of the legacy smooth shader; dpdy is reversed for WebGPU's top-left - functions: smoothSurface - variables: SM -### src/render/gpu/smooth.js (48 lines) +### src/render/gpu/smooth.js (50 lines) Field mip levels preserve the legacy trilinear AO sample at level 2.2. - functions: gpuSmoothInit, gpuSmoothTextures, gpuSmoothBindings, gpuSmoothUpload, gpuSmoothMips - variables: GPU_FIELD_MIP @@ -469,7 +479,7 @@ Whole-world textures retain clean chunks across camera moves. Upload full chunks so scrolling within a resident chunk needs no extra copy. - functions: gpuUploadChunks, gpuUploadStrip, gpuCheckUploads -### src/render/gpu/draw.js (92 lines) +### src/render/gpu/draw.js (94 lines) Only exceptional blocks and their geometric neighbours run the CPU colour rules. - functions: gpuOverrideHas, gpuOverrides, gpuDraw, gpuPresent - variables: GPU_PATTERN @@ -482,9 +492,9 @@ Scale colour without changing the smooth-look class. Machine overlays: fans, engines, silo and the rest, drawn on top of the world. - functions: cellRect, drawHazardRims, drawMachines, drawProcBlocks, drawSilo, drawSiloPixel, drawSiloWindow, drawRackBlocks -### src/render/overlays.js (370 lines) +### src/render/overlays.js (388 lines) Other overlays: floating text, rain and snow, flow arrows, filter tags, molds, the bot, particles. -- functions: spawnFloater, drawRain, drawFlowArrows, drawJoinMark, drawJoinMarks, flowLoaded, drawFlowRates, pingImage, drawPing, drawHoveredVein, drawPingRecharge, drawRunOutline, drawFilterTags, drawFloaters, moldLabel, drawMolds, drawBot, spawnDebris, spawnSpark, spawnSuck, drawParticles +- functions: spawnFloater, drawRain, drawFlowArrows, drawJoinMark, drawJoinMarks, flowLoaded, drawFlowRates, pingImage, drawPing, drawHoveredVein, drawPingRecharge, drawRunOutline, drawFilterTags, drawFloaters, moldLabel, drawMolds, drawBot, spawnDebris, spawnSpark, spawnSuck, drawParticles, drawTransportStalls - variables: PING_SPEED, PING_HOLD, PING_FADE, PING_BANDS, PING_GLINTS, GLINT_MS ### src/render/screw-heat.js (29 lines) @@ -572,7 +582,7 @@ Hover work is shared by the cursor label and the vein outline. - functions: oreLike, bitNeeded, pingVisible, cursorVein, hoveredVein, cursorLabelAlpha - variables: CURSOR_ORES, cursorVeinCache, cursorLabelKey, cursorLabelSince -### src/ui/info.js (216 lines) +### src/ui/info.js (221 lines) Discovery cards, hover info (I) and the cursor label. - functions: showCard, howText, discoveryCard, reactionsOf, hoverInfo, renderInfo, drawCursorLabel, pipeContentsText @@ -622,7 +632,7 @@ Shared world colours, minimap and full map. Browser-only clocks; simulation code stays usable without a browser or a clock. - variables: PERF_MARKS, PERF_FRAME, perfMarks, perfTimes, perfLast -### src/ui/loop.js (170 lines) +### src/ui/loop.js (171 lines) The frame loop and boot. - functions: scanVisible, frame, boot - variables: lastT, acc, lastRender, frameMs, simMs, fpsSm, ambientT @@ -686,6 +696,8 @@ The frame loop and boot. - `test/t_slope_belts.js`: Sloped belts: transport, joins, sorting, persistence and the actual drag controls. - `test/t_slopes.js`: Sloped walls: flow, sealed joints, building, refunds and persistence. - `test/t_smooth.py`: The smooth look: the WebGPU pass compiles and draws, and the thermal lens still works. +- `test/t_stalls.js`: t45: finite carrying bed, local sustained jams, recovery, filters, screws and bars. +- `test/t_stalls.py`: t45: real transport warnings, hover, frozen animation and recovery in both looks. - `test/t_stone.js`: Uses for waste rock and sand: glass bricks from a fan furnace, the Saw, the silo and builders' orders. - `test/t_threads.js`: Shared allocation, checkerboard independence, and real worker-count determinism. - `test/t_vacuum_collapse.js`: Real nozzle cuts into settled heaps, inline and through the real Node worker pool. @@ -706,6 +718,7 @@ The frame loop and boot. - `tools/test_perf.py`: Profiler checks: headers, summaries/comparison, and unchanged pixels/simulation with clocks on. - `tools/use_venv.py`: Let browser scripts run with plain python3 by switching to the repo's .venv. - `tools/where.py`: Turn a line number in dist/index.html into the source file and line it came from. +- `tools/experiments/belt-depth.js`: t45: sustained normal (one layer) and heavy (16 layers) flat-belt feeds, open sink. - `tools/experiments/cast_sunk_mold.js`: Block-sized bit mold sunk into the ground with a stone furnace built over its funnel. - `tools/experiments/factory_cost.js`: What does a running factory cost when the player is somewhere else? Builds K self-running modules in a generated world, far from the bot, and measures simulation time per tick. Each module: a coal + copper-ore furnace burning in a stone-block box, a spring feeding a water wheel, a shaft carrying its power to a 10-block belt that hauls sand off a hopper. - `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. diff --git a/docs/TESTING.md b/docs/TESTING.md index 8b694dd..871e9af 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -14,6 +14,19 @@ prints the failing checks. ## Headless suites +For t45, `node test/t_stalls.js` checks the four-grain bed, sustained local jams, +recovery, bars, cleated runs, slower screens and an overloaded closed loop. +`test/t_threads.js` includes loaded belts and slopes, and hashes stall/animation state +as well as cell bytes with inline, 1, 2 and 8 workers (including repeats). +`python3 test/t_stalls.py` checks both looks: hover advice, always-visible warnings, +fog, actual frozen belt/filter/screw pixels and clearing the blockage. It saves +`shots/t45-stalled.png` and `shots/t45-stalled-smooth.png`. +`node tools/experiments/belt-depth.js` measures sustained one- and sixteen-layer feeds +on a flat belt: seed 7, 220 warm-up ticks and 1200 measured ticks, with an open sink. +The side-fed lift comparison in `t_conveyors.js` now uses a four-grain bed in both +fixtures rather than the old sixty-grain heap and its hard-coded delivery baseline; +it retains the 90% comparison, conservation and all-lane checks. + For t69, `node test/t_screw_heat.js` checks full-cost repair, load preservation, undo, all screw directions, the unchanged melt threshold, and saved damage/message state. `python3 test/t_screw_heat.py` is the focused browser check for both looks, hover, diff --git a/docs/agent-log.md b/docs/agent-log.md index b29bc2d..dca9b79 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -4,6 +4,23 @@ What agents have done, newest first. Add an entry for every piece of work: the d note or request it was for, what changed, the files touched, the tests run, and anything left open or not verified. Keep each entry short; the git history has the details. +## 2026-10-29: t45 belt depth and visible transport stalls (Codex) + +- Four-grain belt bed; local half-second jam warnings, hover advice and frozen/resuming + animation in both looks, including screws, screens, bars and whole cleated runs. +- Files: sim/{conveyors,machine-tick,slope-conveyors,transport-status,items,tick,world}, + render/{cell,slope-belts,overlays,gpu/{draw,smooth,smooth-shader,smooth-slope}}, + ui/{info,loop}, manifest; test/{t_stalls.js,t_stalls.py,t_conveyors,t_threads,fixtures/t45-loop}, + tools/experiments/belt-depth.js, docs/{DESIGN,TESTING,PROJECT_MAP}, task and this log. +- New checks fail on original code; all 42 headless suites pass. Browser t_stalls.py, + play.py, t_smooth.py and t_gpu.py pass with host GPU; sandbox startup timed out. + Fixed GPU pan/zoom clock-buffer recreation caught by t_gpu.py. Worker counts 0/1/2/8 + match cells and jam/clocks. Inspected shots/t45-stalled{,-smooth}.png. Map regenerated. +- Normal feed 30.0 -> 30.0, heavy 360.0 -> 90.0 grains/s; uphill 78.8. Preserved the + separate 96-cell cleat push bound. Updated lift fixture to the chosen four-grain bed; + retained its 90% comparison, conservation and all-lane checks. Worldgen untouched. + No open blockers; full browser suite not run. Ready for owner review; no commit. + ## 2026-10-29: t89 custom game cursors (Codex) - Finished interrupted work: hollow build arrow at the exact mouse, open coloured crosses for diff --git a/src/manifest.json b/src/manifest.json index 4a8dabf..0661f69 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -23,6 +23,7 @@ "sim/joins.js", "sim/conveyors.js", "sim/flow.js", + "sim/transport-status.js", "sim/slope-conveyors.js", "sim/blocks.js", "sim/molds.js", diff --git a/src/render/cell.js b/src/render/cell.js index 34dfc6c..054d9d1 100644 --- a/src/render/cell.js +++ b/src/render/cell.js @@ -15,7 +15,8 @@ function cellPixel(x, y) { return; } const useFog = SET.fog, grain = SET.grain, incand = SET.incandescence; - const anim = R.frame, lens = R.lens, tl = THERM[SET.thermalPalette] || THERM.inferno; + let anim = R.frame; + const lens = R.lens, tl = THERM[SET.thermalPalette] || THERM.inferno; const glowOn = SET.glow !== 'off' && !lens, gpx = R.glowPx, gw = R.glowW; const fy = y / FOG, fj = Math.floor(fy), ft = fy - fj, fr = fj * FW, fn = Math.min(FH - 1, fj + 1) * FW; const yw = y * W; @@ -36,6 +37,8 @@ function cellPixel(x, y) { } } const i = yw + x, m = mat[i], nz = noiseB[i] - 128; + const ownBlock = (y >> 2) * BW + (x >> 2); + if (isBelt(blockP[ownBlock]) || isFilter(blockP[ownBlock]) || blockP[ownBlock] === BP_SCREW) anim = transportClock(ownBlock); const T = temp[i]; // Also used by the thermal lens, which always draws in pixels. let looseShade = 1; @@ -138,6 +141,7 @@ function cellPixel(x, y) { const db = diagonalScrewBlock(x, y, sm); let diagonal = false; if (db >= 0) { + anim = transportClock(db); const v = blockV[db], joins = diagonalJoins[db]; const sd = diagonalScrewShape(x + 0.5, y + 0.5, db, v, joins); p = sm || sd <= 0 ? SCREW_PARTS[v] : 0; @@ -193,7 +197,7 @@ function cellPixel(x, y) { } const slopeArt = sb >= 0 && (m === EMPTY || PH[m] === PH_GAS || m === BELTL || m === BELTR); if (slopeArt && !sm) { - const color = slopeBeltColor(sb, x, y, anim, br, bgc, bb); + const color = slopeBeltColor(sb, x, y, transportClock(sb), br, bgc, bb); r = color & 255; g = color >> 8 & 255; b = color >> 16 & 255; } const slopeDetail = slopeDetailAt(i, x, y, sm); @@ -272,5 +276,9 @@ function cellPixel(x, y) { bpx[row + bx] = fogColor(c, k); tpx[row + bx] = fogColor(t, k); } } + if (sm && GPU.motionData) { + const owner = slopeDetail && slopeWallOwner >= 0 ? slopeWallOwner : sb >= 0 ? sb : db >= 0 ? db : ownBlock; + GPU.motionData[row + bx] = transportClock(owner) / 60; + } px[row + bx] = top | ((b > 255 ? 255 : b) << 16) | ((g > 255 ? 255 : g) << 8) | (r > 255 ? 255 : r); } diff --git a/src/render/gpu/draw.js b/src/render/gpu/draw.js index 60b9cb6..a180141 100644 --- a/src/render/gpu/draw.js +++ b/src/render/gpu/draw.js @@ -7,6 +7,8 @@ function gpuOverrides() { if (R.smoothOn) { if (!GPU.overrideMask || GPU.overrideMask.length !== R.px.length) GPU.overrideMask = new Uint8Array(R.px.length); GPU.overrideMask.fill(0); + if (!GPU.motionData || GPU.motionData.length !== R.px.length) GPU.motionData = new Float32Array(R.px.length); + else GPU.motionData.fill(0); } if (!GPU.blocks || GPU.blocks.length !== NB) GPU.blocks = new Uint8Array(NB); const marked = GPU.blocks; marked.fill(0); diff --git a/src/render/gpu/smooth-shader.js b/src/render/gpu/smooth-shader.js index f9e1f85..fc5584b 100644 --- a/src/render/gpu/smooth-shader.js +++ b/src/render/gpu/smooth-shader.js @@ -10,6 +10,7 @@ struct Params { view: vec4f, world: vec4f, style: vec4f, effects: vec4f, camera: @group(0) @binding(6) var tGlow: texture_2d; @group(0) @binding(7) var linear: sampler; @group(0) @binding(8) var su: vec4f; +@group(0) @binding(9) var tMotion: texture_2d; fn sampleLevel(t: texture_2d, uv: vec2f, level: f32) -> vec4f { return textureSampleLevel(t,linear,uv,level); } fn modulo(x: f32,y: f32) -> f32 { return x-y*floor(x/y); } fn h21(inputP: vec2f) -> f32 { var p=inputP; p = fract(p * vec2f(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); } @@ -51,9 +52,9 @@ fn beltLineSD(c: vec2i, lb: vec2f) -> f32 { return beltSD(lb, (caps & 1) != 0, (caps & 2) != 0); } -fn bandRidge(w: vec2f, lb: vec2f, dir: f32) -> f32 { +fn bandRidge(w: vec2f, lb: vec2f, dir: f32, time: f32) -> f32 { var run: f32 = select( -1.0, 1.0 , lb.y < 2.0 ); - return step(0.72, fract((w.x - dir * run * (su.x) * 30.0) * 0.5)); + return step(0.72, fract((w.x - dir * run * time * 30.0) * 0.5)); } fn bandColor(lb: vec2f, rg: f32) -> vec3f { return mix(vec3f(0.2, 0.21, 0.24), vec3f(0.34, 0.35, 0.39), rg) * (select( 0.72, 1.0 ,lb.y < 2.0 )); } @@ -72,6 +73,7 @@ ${GPU_SMOOTH_SLOPE_HELPERS} var qq: vec2f = q - 0.5; var c0: vec2i = vec2i(floor(qq)); var t: vec2f = qq - floor(qq); var k0: vec4f = cellAt(tCol, c0); var k1: vec4f = cellAt(tCol, c0 + vec2i(1, 0)); var k2: vec4f = cellAt(tCol, c0 + vec2i(0, 1)); var k3: vec4f = cellAt(tCol, c0 + vec2i(1, 1)); var n0: i32 = cls(k0); var n1: i32 = cls(k1); var n2: i32 = cls(k2); var n3: i32 = cls(k3); + let motionTime = cellAt(tMotion, c).r; var own: vec4f = cellAt(tCol, c); var nc: i32 = cls(own); var s: vec2f = smoothstep(vec2f(0.1), vec2f(0.9), t); var wq: vec4f = vec4f((1.0 - s.x) * (1.0 - s.y), s.x * (1.0 - s.y), (1.0 - s.x) * s.y, s.x * s.y); @@ -228,14 +230,14 @@ ${GPU_SMOOTH_SLOPE_DRAW} var edge: f32 = -sd; var bc: vec3f; if (edge < 0.8) { - bc = bandColor(lb, bandRidge(w, lb, dir)); + bc = bandColor(lb, bandRidge(w, lb, dir, motionTime)); bc *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge)); } else { var rc: vec2f = vec2f(select( 3.0, 1.0 ,lb.x < 2.0 ), 2.0); var dv: vec2f = lb - rc; var rd: f32 = length(dv); bc = vec3f(0.15, 0.16, 0.18); if (rd < 0.95) { - var ang: f32 = atan2(dv.y, dv.x) - dir * (su.x) * 30.0 / 0.95; + var ang: f32 = atan2(dv.y, dv.x) - dir * motionTime * 30.0 / 0.95; var spoke: f32 = smoothstep(0.55, 0.85, cos(ang * 3.0)); bc = mix(vec3f(0.5, 0.36, 0.25), vec3f(0.72, 0.53, 0.36), 0.5 + 0.5 * dot(normalize(dv + 1e-4), vec2f(-0.6, -0.8)) * smoothstep(0.2, 0.95, rd)); bc *= mix(1.0, 0.72, spoke * smoothstep(0.25, 0.4, rd)); @@ -294,7 +296,7 @@ ${GPU_SMOOTH_SLOPE_DRAW} tc *= mix(0.5, 1.0, smoothstep(0.0, max(px * 1.3, 0.1), min(a, 4.0 - a))); } else { var full: bool = own.r + own.g + own.b > 0.0; - var fl: f32 = fract((along + a * 0.9 - sp2 * (su.x) * 14.0) * 0.5); + var fl: f32 = fract((along + a * 0.9 - sp2 * motionTime * 14.0) * 0.5); var flight: f32 = smoothstep(0.82, 0.92, fl) * (1.0 - smoothstep(0.92, 1.0, fl)); var bore: vec3f = vec3f(0.11, 0.1, 0.11) * (0.8 + 0.4 * (1.0 - abs(a - 2.0) / 1.4)); tc = select( bore, own.rgb * (0.9 + 0.1 * vn(w * 1.3)) ,full ); @@ -343,7 +345,7 @@ ${GPU_SMOOTH_SLOPE_DRAW} var hole: f32 = 1.0 - smoothstep(0.5 - bw2 - px, 0.5 - bw2, max(cg.x, cg.y)); var fc: vec3f; if (edge < 0.8) { - var rg: f32 = bandRidge(w, lb, dir); + var rg: f32 = bandRidge(w, lb, dir, motionTime); var band: vec3f = bandColor(lb, rg); if (tinted && (carry == (lb.y < 2.0))) { band = mix(band, tag.rgb * (select( 0.8, 1.0 ,lb.y < 2.0 )), 0.75 * rg); } fc = mix(band, behind * 0.85, hole * (1.0 - rg) * 0.75); diff --git a/src/render/gpu/smooth-slope.js b/src/render/gpu/smooth-slope.js index 1042648..79b749f 100644 --- a/src/render/gpu/smooth-slope.js +++ b/src/render/gpu/smooth-slope.js @@ -76,7 +76,7 @@ const GPU_SMOOTH_SLOPE_DRAW = ` if (nc == 31 || nc == 32) { var local: vec2f = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w); var shapePos: vec3f = slopeShape(local, w - local, grade, joins); var sd: f32 = shapePos.x; var axis: f32 = shapePos.y; var depth: f32 = shapePos.z; - var time: f32 = select( 0.0, (su.x) , !mesh && (joins & 16) != 0 ); + var time: f32 = select( 0.0, motionTime , !mesh && (joins & 16) != 0 ); var lb: vec2f = vec2f(axis, depth); var alongWorld: vec2f = vec2f(axis, depth); var rg: f32 = step(0.72, fract((axis - dir * (select( -1.0, 1.0 ,depth < 2.0 )) * time * 30.0) * 0.5)); var belt: vec3f; diff --git a/src/render/gpu/smooth.js b/src/render/gpu/smooth.js index 3402c73..341c78a 100644 --- a/src/render/gpu/smooth.js +++ b/src/render/gpu/smooth.js @@ -19,16 +19,17 @@ async function gpuSmoothInit() { function gpuSmoothTextures() { const w=R.bw,h=R.bh; GPU.smoothOver=Array.from({length:3},()=>gpuTexture(w,h)); + GPU.motion=gpuTexture(w,h,'r32float'); GPU.smoothMask=gpuTexture(w,h,'r8uint'); GPU.backTex=gpuTexture(w,h,'rgba8unorm',true); GPU.treeTex=gpuTexture(w,h,'rgba8unorm',true); GPU.fieldTex=GPU.device.createTexture({size:[w,h],format:'rgba8unorm',mipLevelCount:Math.min(4,1+Math.floor(Math.log2(Math.max(w,h)))),usage:GPUTextureUsage.TEXTURE_BINDING|GPUTextureUsage.STORAGE_BINDING|GPUTextureUsage.RENDER_ATTACHMENT}); - GPU.viewTextures.push(...GPU.smoothOver,GPU.smoothMask,GPU.backTex,GPU.treeTex,GPU.fieldTex); + GPU.viewTextures.push(...GPU.smoothOver,GPU.smoothMask,GPU.motion,GPU.backTex,GPU.treeTex,GPU.fieldTex); } function gpuSmoothBindings(group,view,buffer) { const colourResources=[buffer(GPU.uniform),buffer(GPU.lut),buffer(GPU.land),...GPU.worldTextures.slice(0,9).map(view),view(GPU.override),view(GPU.overrideGlow),view(GPU.colourTex),null,view(GPU.backTex),view(GPU.treeTex),GPU.fieldTex.createView({baseMipLevel:0,mipLevelCount:1}),...GPU.smoothOver.map(view),view(GPU.smoothMask)]; GPU.bindColourSmooth=GPU.device.createBindGroup({layout:GPU.colourSmooth.getBindGroupLayout(0),entries:colourResources.map((resource,binding)=>({binding,resource})).filter(e=>e.resource)}); - GPU.bindSmooth=group(GPU.smooth,[buffer(GPU.uniform),view(GPU.colourTex),view(GPU.backTex),view(GPU.treeTex),view(GPU.fieldTex),view(GPU.lightTex),view(GPU.glowTex),GPU.sampler,buffer(GPU.smoothUniform)]); + GPU.bindSmooth=group(GPU.smooth,[buffer(GPU.uniform),view(GPU.colourTex),view(GPU.backTex),view(GPU.treeTex),view(GPU.fieldTex),view(GPU.lightTex),view(GPU.glowTex),GPU.sampler,buffer(GPU.smoothUniform),view(GPU.motion)]); GPU.fieldMips=[]; for(let i=1;i R.camX + v.w || y + BS < R.camY || y > R.camY + v.h || (SET.fog && fogSeen(x + 2, y + 2) < 64)) continue; + const p = worldToScreenPx(x, y); + ctx.fillStyle = 'rgba(255,164,48,0.25)'; ctx.fillRect(p.x, p.y, s, s); + ctx.strokeStyle = '#ffbd59'; ctx.lineWidth = Math.max(1, R.zp * 0.25); ctx.strokeRect(p.x, p.y, s, s); + ctx.fillStyle = '#ffdc85'; + ctx.fillRect(p.x + s * 0.45, p.y + s * 0.15, s * 0.1, s * 0.4); + ctx.fillRect(p.x + s * 0.45, p.y + s * 0.7, s * 0.1, s * 0.1); + } + ctx.restore(); +} diff --git a/src/render/slope-belts.js b/src/render/slope-belts.js index e63e634..898774c 100644 --- a/src/render/slope-belts.js +++ b/src/render/slope-belts.js @@ -33,7 +33,7 @@ function prepareSlopeBelts(x0, y0, x1, y1) { if (c === BP_BELT || c === BP_FILTER) beltCaps[b] = flatBeltCaps(b); if (!isSlopeBelt(c)) continue; 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) | (isFilter(c) && netSat(face) > 0 && screenLoaded(b) && (tick & 2) ? 64 : 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) && !transportStalled(b) && (tick & 2) ? 64 : 0); 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; } diff --git a/src/sim/conveyors.js b/src/sim/conveyors.js index dc146d4..9c3ab8e 100644 --- a/src/sim/conveyors.js +++ b/src/sim/conveyors.js @@ -1,9 +1,9 @@ // 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 +// What rests on a belt or filter rides along: the bottom four grains 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. -const CARRY_DEPTH = 96; +const CARRY_DEPTH = 4; // Find the next carrying face at the same height or one grain above/below it. function conveyorStep(x, y, d) { const nx = x + d; @@ -28,11 +28,12 @@ function carryColumn(a, d, pass) { const m = mat[a]; if (m === EMPTY || (PH[m] === PH_SOLID && !part[a])) return; // the top of the heap, or a ceiling if (PH[m] !== PH_POWDER || stamp[a] === tickStamp || occ[a]) continue; + transportNote(carrier); if (pass && pass[m]) { - if (enter(m, PH_POWDER, a + W, 0, 1)) continue; // falls through on its own + if (enter(m, PH_POWDER, a + W, 0, 1)) { transportNote(carrier, true); continue; } // falls through on its own // the rollers shake the load: what the filter passes works its way down through the rest const u = mat[a + W]; - if (L > 0 && !pass[u] && PH[u] === PH_POWDER && stamp[a + W] !== tickStamp && !occ[a + W] && rnd() < 0.5) { const x = a % W, y = (a / W) | 0; move(a, a + W, x, y, x, y + 1); continue; } + if (L > 0 && !pass[u] && PH[u] === PH_POWDER && stamp[a + W] !== tickStamp && !occ[a + W] && rnd() < 0.5) { const x = a % W, y = (a / W) | 0; move(a, a + W, x, y, x, y + 1); transportNote(carrier, true); continue; } } if (blocked) continue; // filters still sift above a stopped load const x = a % W, nx = x + d; @@ -50,6 +51,7 @@ function carryColumn(a, d, pass) { // carried off the end with nothing under it: it leaves at belt speed and falls if (mat[j] === EMPTY && !part[j] && bj < N && (mat[bj] === EMPTY || PH[mat[bj]] === PH_GAS) && !occ[bj] && !part[bj]) launchGrain(a, nx, y + step, d * 0.5, step * 0.5); else move(a, j, x, y, nx, y + step); + transportNote(carrier, true); stats.moved++; if (flowOn && (x >> 2) !== (nx >> 2)) flowNote(carrier); } diff --git a/src/sim/items.js b/src/sim/items.js index f2d0ae4..a0af749 100644 --- a/src/sim/items.js +++ b/src/sim/items.js @@ -192,7 +192,7 @@ function entSlopeBeltTick(e) { } function entTick() { for (let k = ents.length - 1; k >= 0; k--) { - const e = ents[k]; + const e = ents[k], oldX = e.ix, oldY = e.iy; entMark(e, 0); entCurM = e.m; let ix = e.ix; const iy = e.iy; @@ -264,6 +264,7 @@ function entTick() { continue; } } + if (!(tick & 1)) transportBar(oldX, oldY, e.ix !== oldX || e.iy !== oldY); entMark(e, 1); } entCurM = 0; diff --git a/src/sim/machine-tick.js b/src/sim/machine-tick.js index 05eee9e..6be4e53 100644 --- a/src/sim/machine-tick.js +++ b/src/sim/machine-tick.js @@ -104,6 +104,8 @@ function machinesTick() { const m = mat[i]; if (m === EMPTY || PH[m] !== PH_POWDER) { if (m === EMPTY || PH[m] !== PH_SOLID) screwIntake(i, p, dx, dy); continue; } if (stamp[i] === tickStamp) continue; + const carrier = (Math.floor(i / W) >> 2) * BW + ((i % W) >> 2); + transportNote(carrier); if (temp[i] > 1000 && rnd() < 0.02) { part[i] = 0; markPow(i, false); emit({ t: 'warn', x: i % W, y: (i / W) | 0, text: 'melted' }); wakeIdx(i); if (!ledger.screwMelt) { ledger.screwMelt = true; emit({ t: 'screw_melt' }); } @@ -113,7 +115,7 @@ function machinesTick() { if (s <= 0 || rnd() > s) continue; const x = i % W, y = (i / W) | 0; // a sideways screw carries its load along the bottom: grains settle into empty lanes below - if (flat && y + 1 < H && part[i + W] === p && roomFor(i + W) && !occ[i + W]) { move(i, i + W, x, y, x, y + 1); continue; } + if (flat && y + 1 < H && part[i + W] === p && roomFor(i + W) && !occ[i + W]) { move(i, i + W, x, y, x, y + 1); transportNote(carrier, true); continue; } if (!flat && !dx) { let own = 0, shifted = false; const end = dy < 0 ? (y & ~3) : (y | 3); @@ -126,7 +128,7 @@ function machinesTick() { for (let yy = y; dy < 0 ? yy >= end : yy <= end; yy += dy) if (PH[mat[yy * W + xx]] === PH_POWDER) load++; // Include this grain: a strict gap prevents a sideways step being undone next beat. if (own <= load + 1) continue; - move(i, j, x, y, xx, y); stats.moved++; shifted = true; break; + move(i, j, x, y, xx, y); stats.moved++; transportNote(carrier, true); shifted = true; break; } if (shifted) continue; } @@ -145,9 +147,9 @@ function machinesTick() { if (!screw && !pj && t === EMPTY) { // out of the mouth: the screw spits it clear, so the next grain is never blocked by the last launchGrain(i, nx, ny, dy && !dx ? (rnd() - 0.5) * (dy < 0 ? 0.9 : 0.1) : dx * (0.42 + rnd() * 0.16), dy ? dy * (0.5 + rnd() * 0.2) : rnd() * 0.08); - stats.moved++; if (flowOn) flowCross((y >> 2) * BW + (x >> 2), i, j); continue; + stats.moved++; transportNote(carrier, true); if (flowOn) flowCross((y >> 2) * BW + (x >> 2), i, j); continue; } - move(i, j, x, y, nx, ny); stats.moved++; + move(i, j, x, y, nx, ny); stats.moved++; transportNote(carrier, true); if (flowOn) flowCross((y >> 2) * BW + (x >> 2), i, j); if (screw && pj !== p) { // turning a corner: keep going across the new screw's width so every lane fills diff --git a/src/sim/slope-conveyors.js b/src/sim/slope-conveyors.js index 39c36d1..46cc9ac 100644 --- a/src/sim/slope-conveyors.js +++ b/src/sim/slope-conveyors.js @@ -39,7 +39,8 @@ function slopeFaceAt(x, y, g) { function pushRow(p, dx, dy) { const row = []; let q = p; - for (let k = 0; k < CARRY_DEPTH; k++) { + // A pushed chain can cross pockets; its length is independent of the flat carrying depth. + for (let k = 0; k < 96; k++) { if (PH[mat[q]] !== PH_POWDER || occ[q] || part[q] || (k && cleatMask[q])) break; // a machine moves its own contents row.push(q); const x = q % W + dx, y = ((q / W) | 0) + dy; @@ -162,8 +163,11 @@ function slopeCleatsTick(advance = true) { ribLog.length = 0; 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 && !stalled.has(slopeRunKey(b, memo))) - slopePhase[b] = (slopePhase[b] + 1) % CLEAT_SPACING; + if ((x & 3) === 0 && isBelt(c) && isSlopeBelt(c) && beltFaceY(c, x >> 2, y >> 2, x) === y && netSat(i) > 0) { + const jam = stalled.has(slopeRunKey(b, memo)); + if (slopeUphill(c, blockV[b])) transportCleat(b, jam); + if (!jam) slopePhase[b] = (slopePhase[b] + 1) % CLEAT_SPACING; + } } } rebuildCleatMask(); @@ -216,10 +220,12 @@ 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 carrier = ((top / W) >> 2) * BW + ((top % W) >> 2); + transportNote(carrier); + if (pass && pass[m] && enter(m, PH_POWDER, top, 0, 1)) { transportNote(carrier, true); 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); if (flowOn) flowCross(((top / W) >> 2) * BW + ((top % W) >> 2), i, j); } + if (enter(m, PH_POWDER, j, d, 1)) { move(i, j, i % W, (i / W) | 0, j % W, (j / W) | 0); transportNote(carrier, true); if (flowOn) flowCross(carrier, i, j); } } // Only loose load can react to a barrier appearing or disappearing. diff --git a/src/sim/tick.js b/src/sim/tick.js index 317265e..3e0fc52 100644 --- a/src/sim/tick.js +++ b/src/sim/tick.js @@ -6,10 +6,12 @@ function simTick() { if (loadWakeOrder) loadWakeTick(); if (tick % 20 === 1) updateDirtyRoof(); weatherTick(); + if (!(tick & 1)) transportBegin(); machinesTick(); pipesTick(); pulseTick(); entTick(); + if (!(tick & 1)) transportEnd(); if (flyers.length) flyTick(); if (tick % 8 === 0) wakeBotPowder(); simMove(); diff --git a/src/sim/transport-status.js b/src/sim/transport-status.js new file mode 100644 index 0000000..38968dd --- /dev/null +++ b/src/sim/transport-status.js @@ -0,0 +1,38 @@ +// Local backpressure: only loaded transport that cannot advance warns after half a second. +const STALL_TICKS = 30; +const transportState = new Map(); +function transportNote(b, moved = false) { + if (b < 0 || b >= NB) return; + let s = transportState.get(b); + if (!s) { s = { age: 0, clock: tick, loaded: false, moved: false, jam: false, code: blockP[b], v: blockV[b] }; transportState.set(b, s); } + s.loaded = true; s.moved ||= moved; +} +function transportStalled(b) { return (transportState.get(b)?.age || 0) >= STALL_TICKS; } +function transportClock(b) { return transportState.get(b)?.clock ?? tick; } +function transportBegin() { + for (const [b, s] of transportState) { + if (blockP[b] !== s.code || blockV[b] !== s.v || !pieceEnds(s.code, s.v).length) { transportState.delete(b); continue; } + if (isFilter(s.code) && isSlopeBelt(s.code) && tick % SCREEN_NUDGE_TICKS) continue; + s.loaded = s.moved = s.jam = false; + } +} +function transportEnd() { + for (const s of transportState.values()) { + const cadence = isFilter(s.code) && isSlopeBelt(s.code) ? SCREEN_NUDGE_TICKS : 2; + if (tick % cadence) continue; + s.age = s.jam || (s.loaded && !s.moved) ? Math.min(STALL_TICKS, s.age + cadence) : 0; + if (s.age < STALL_TICKS) s.clock += cadence; + } +} +// A whole cleated run rolls back together; friction or a bar must not hide that failure. +function transportCleat(b, jam) { + transportNote(b, !jam); + transportState.get(b).jam = jam; +} +function transportBar(x, y, moved) { + for (let yy = y; yy <= y + BAR_H && yy < H; yy++) for (let xx = x; xx < x + BAR_W && xx < W; xx++) { + if (xx < 0 || yy < 0) continue; + const b = (yy >> 2) * BW + (xx >> 2), c = blockP[b]; + if (c === BP_SCREW || isBelt(c) || isFilter(c)) transportNote(b, moved); + } +} diff --git a/src/sim/world.js b/src/sim/world.js index 2e5f35a..9c74e53 100644 --- a/src/sim/world.js +++ b/src/sim/world.js @@ -46,6 +46,7 @@ function sharedArray(Type, length) { } function alloc(w, h) { resetDevConsole(); + transportState.clear(); botTrailHead = botTrailCount = 0; botEscape = false; W = w; H = h; N = w * h; fogAlloc(); diff --git a/src/ui/info.js b/src/ui/info.js index 893f22f..4868a43 100644 --- a/src/ui/info.js +++ b/src/ui/info.js @@ -142,6 +142,11 @@ function hoverInfo(ax, ay) { warn = 'near heat limit'; t2.push(`${Math.round(screwLoadHeat(jb))}°C load · melts above 1000°C`); } } + const stalledBlock = slopeSealOwner(i) >= 0 ? slopeSealOwner(i) : jb; + if (transportStalled(stalledBlock)) { + warn = 'stalled'; + t2.push('Stalled: load cannot move. Clear the outlet or the top of the heap.'); + } if (GAME.infoOverlay && !id && !bar && pieceEnds(blockP[jb], blockV[jb]).length) t2.push(`moving ${(flowRate[jb] || 0).toFixed(1)} grains/s`); for (let end = 0; end < 2 && !id && !bar; end++) { const miss = nearMiss(jb % BW, (jb / BW) | 0, end); diff --git a/src/ui/loop.js b/src/ui/loop.js index 893221b..83166e0 100644 --- a/src/ui/loop.js +++ b/src/ui/loop.js @@ -80,6 +80,7 @@ function frame(now) { if (GAME.infoOverlay) { drawFlowArrows(); drawFlowRates(); } drawJoinMarks(); drawScrewHeat(); + drawTransportStalls(); drawPing(); drawHoveredVein(); drawPingRecharge(); diff --git a/test/fixtures/t45-loop.js b/test/fixtures/t45-loop.js new file mode 100644 index 0000000..07d041c --- /dev/null +++ b/test/fixtures/t45-loop.js @@ -0,0 +1,6 @@ +// Closed belt/screw loop, with a low overhead return and a deep mixed heap. +for(let bx=12;bx<28;bx++)placeBlock(BP_BELT,1,bx,32,true); +for(let by=28;by<=32;by++)placeBlock(BP_SCREW,0,28,by,true); +for(let bx=12;bx<=28;bx++)placeBlock(BP_SCREW,3,bx,27,true); +for(let by=28;by<32;by++)placeBlock(BP_SCREW,2,12,by,true); +for(let y=112;y<128;y++)for(let x=56;x<108;x++)setCell(y*W+x,y>=124?SAND:CUORE,20,0); diff --git a/test/t_conveyors.js b/test/t_conveyors.js index 82c00ba..0d57fac 100644 --- a/test/t_conveyors.js +++ b/test/t_conveyors.js @@ -56,11 +56,11 @@ for (const horizontal of [false, true]) { check(horizontal ? 'a right screw takes grains from above into every row' : 'an up screw takes grains from the side into every column', filled.join() === '3,2,1,0', filled); } -// A finite heap rides into the side of a six-block lift; remove each mouth flyer once. +// A finite four-grain bed feeds a six-block lift; compare with the same open belt. const sideSeed = run('RS'); fresh(); run('RS = 7; for (let bx = 16; bx < 32; bx++) placeBlock(BP_BELT, 1, bx, 25, true); for (let by = 19; by <= 24; by++) placeBlock(BP_SCREW, 0, 32, by, true);'); -rect(run, 64, 40, 119, 99, run('SAND')); +rect(run, 64, 96, 119, 99, run('SAND')); const sideLift = run(`(() => { let out = 0; const lanes = [0, 0, 0, 0]; const grains = () => mat.reduce((n, m) => n + (m === SAND), 0) + flyers.filter(f => f.m === SAND).length; @@ -75,15 +75,12 @@ const sideLift = run(`(() => { console.log('Side-fed lift, 3000 ticks: ' + JSON.stringify(sideLift)); check('a belt feeding a lift from the side fills all four lanes', sideLift.lanes.every(n => n > 0), sideLift.lanes); check('the side-fed lift conserves its sand, including mouth flyers', sideLift.before === sideLift.after, `${sideLift.before} -> ${sideLift.after}`); -// Before side spreading: 1459 grains left this lift in 3000 ticks (one lane, nearly full). -// The same belt and heap deliver 2243 into an open drop, the most the lift can be given. -check('a side-fed lift carries at least 90% of what its belt brings (it was 65%)', sideLift.out >= Math.floor(0.9 * 2243), sideLift.out); -// Same belt and finite heap, with a non-blocking sink at the screw side. +// Same belt and four-grain feed, with a non-blocking sink at the screw side. run(`RS = ${sideSeed}`); fresh(); run('RS = 7; for (let bx = 16; bx < 32; bx++) placeBlock(BP_BELT, 1, bx, 25, true);'); -rect(run, 64, 40, 119, 99, run('SAND')); +rect(run, 64, 96, 119, 99, run('SAND')); const beltDelivery = run(`(() => { let out = 0; for (let k = 0; k < 3000; k++) { @@ -97,6 +94,7 @@ const beltDelivery = run(`(() => { return out; })()`); console.log('Belt delivery to a non-blocking sink, 3000 ticks: ' + beltDelivery); +check('a side-fed lift carries at least 90% of what its four-grain belt brings', sideLift.out >= Math.floor(0.9 * beltDelivery), `${sideLift.out} / ${beltDelivery}`); // --- throughput fresh(); diff --git a/test/t_stalls.js b/test/t_stalls.js new file mode 100644 index 0000000..170533a --- /dev/null +++ b/test/t_stalls.js @@ -0,0 +1,50 @@ +// t45: finite carrying bed, local sustained jams, recovery, filters, screws and bars. +const fs=require('fs'); +const {load,flatWorld,suite,ticks}=require('./harness'); +const {run}=load(), {check,done}=suite(); +function fresh(){flatWorld(run,256,224,208);run('RS=7;AMB.fill(20);temp.fill(20);weather.next=1e15;nextPowerBuild=0;for(const k of Object.keys(stats))stats[k]=0;');} +fresh(); +run('placeBlock(BP_BELT,1,12,32,true);for(let l=1;l<=8;l++)setCell((128-l)*W+49,SAND,20,0);carryColumn(127*W+49,1,null);'); +check('exactly four grains ride; the upper heap stays loose',run('Array.from({length:8},(_,k)=>mat[(127-k)*W+(k<4?50:49)]===SAND).every(Boolean)')); +check('stall tracking exists',run('typeof transportStalled==="function"')); +if(run('typeof transportStalled!=="function"')) {done();process.exit();} +for(const code of ['BP_BELT','BP_FILTER','BP_SCREW']) { + fresh(); + run(`for(let bx=12;bx<20;bx++)placeBlock(${code},${code==='BP_FILTER'?128:1},bx,32,true,{m:[],inv:0}); + for(let y=120;y<132;y++)for(let x=48;x<80;x++)if(mat[y*W+x]===EMPTY)setCell(y*W+x,SAND,20,0); + for(let y=110;y<136;y++)setCell(y*W+80,STONE,20,0); + placeBlock(BP_BELT,1,30,32,true); + for(let bx=19;bx<=30;bx++)placeBlock(BP_SHAFT,0,bx,34,true); + placeBlock(BP_SHAFT,0,19,33,true);placeBlock(BP_SHAFT,0,30,33,true);`); + ticks(run,10); + check(`${code}: a brief wait is not a stall`,run('!transportStalled(32*BW+19)')); + ticks(run,240); + check(`${code}: loaded blocked tail and upstream run warn`,run('transportStalled(32*BW+19)&&transportStalled(32*BW+15)')); + check(`${code}: an unrelated empty belt stays clear (same power net for belts/screws)`,run('!transportStalled(32*BW+30)') && (code==='BP_FILTER' || run('netId[128*W+60]===netId[128*W+120]'))); + const clock=run('transportClock(32*BW+19)');ticks(run,20); + check(`${code}: animation clock freezes`,run('transportClock(32*BW+19)')===clock); + run('for(let y=110;y<136;y++)setCell(y*W+80,EMPTY,20,0);');ticks(run,30); + check(`${code}: clearing the blockage resumes motion`,run('!transportStalled(32*BW+19) && transportClock(32*BW+19)')>clock); +} +fresh();run('for(let bx=12;bx<40;bx++)placeBlock(BP_BELT,1,bx,32,true);spawnBar(BARCU,48,126,0,0);'); +check('a steadily moving bar never reports a stall between whole-grain steps',run(`(()=>{for(let k=0;k<120;k++){simTick();if([...transportState.keys()].some(transportStalled))return false;}return ents[0].ix>90;})()`)); +fresh();run('placeBlock(BP_BELT,1,12,32,true);placeBlock(BP_WALL,RSTONE,13,31,true);spawnBar(BARCU,48,126,0,0);');ticks(run,100); +check('a blocked bar also stalls its carrying belt',run('transportStalled(32*BW+12)')); +fresh(); +run(`for(let k=0;k<8;k++)placeBlock(BP_BELT_UP,1,16+k,40-k,true); + for(let y=110;y<132;y++)for(let x=96;x<100;x++)setCell(y*W+x,STONE,20,0); + for(let y=110;y<166;y++)for(let x=56;x<96;x++)if(!part[y*W+x]&&mat[y*W+x]===EMPTY)setCell(y*W+x,SAND,20,0);`); +ticks(run,100); +check('a blocked cleat stops its whole slope run',run('Array.from({length:8},(_,k)=>transportStalled((40-k)*BW+16+k)).every(Boolean)')); +const phase=run('slopePhase[40*BW+16]'), slopeClock=run('transportClock(40*BW+16)');ticks(run,20); +check('stalled cleats and stripe clocks stay still together',run('slopePhase[40*BW+16]')===phase && run('transportClock(40*BW+16)')===slopeClock); +fresh(); +run(`for(let k=0;k<8;k++)placeBlock(BP_FILTER_DOWN,128,16+k,24+k,true,{m:[],inv:0}); + for(let y=100;y<140;y++)setCell(y*W+96,STONE,20,0); + for(let x=64;x<96;x++){const by=24+(x>>2)-16,y=beltFaceY(BP_FILTER_DOWN,x>>2,by,x);for(let l=1;l<=4;l++)setCell((y-l)*W+x,SAND,20,0);}`); +ticks(run,300); +check('a jammed downhill screen warns on its slower nudge cadence',run('transportStalled(31*BW+23)')); +fresh();run(fs.readFileSync(__dirname+'/fixtures/t45-loop.js','utf8'));ticks(run,1800); +console.log('Overloaded loop: '+run('JSON.stringify({moved:stats.moved,grains:mat.reduce((n,m)=>n+(m===SAND||m===CUORE),0),flyers:flyers.length,stalled:[...transportState].filter(([b,s])=>s.age>=STALL_TICKS).length})')); +check('overloaded loop conserves its 832 grains',run('mat.reduce((n,m)=>n+(m===SAND||m===CUORE),0)+flyers.length')===832); +done(); diff --git a/test/t_stalls.py b/test/t_stalls.py new file mode 100644 index 0000000..d3a2f73 --- /dev/null +++ b/test/t_stalls.py @@ -0,0 +1,71 @@ +"""t45: real transport warnings, hover, frozen animation and recovery in both looks.""" +import pathlib +import sys +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / 'tools')) +from use_venv import ensure_playwright; ensure_playwright() +from browser import launch, consume_gpu_option +consume_gpu_option() +ROOT = pathlib.Path(__file__).resolve().parents[1] +with launch(viewport={'width': 1280, 'height': 760}, page_path=sys.argv[1] if len(sys.argv)>1 else None) as (_, page, url): + errors=[] + page.on('pageerror', lambda e: errors.append(str(e))) + page.goto(url) + page.evaluate("""() => { + const enter=enterGame;enterGame=()=>{enter();GAME.paused=true;}; + SET.look='pixel';SET.workerCap=0;SET.autosave=0;startNewWorld(7,768,600); + }""") + page.wait_for_function('GAME.running', timeout=60000) + page.evaluate("""() => { + mat.fill(EMPTY);part.fill(0);blockP.fill(0);blockV.fill(0);isPow.fill(0); + flags.fill(0);temp.fill(20);AMB.fill(20);bgm.fill(0);fog.fill(255); + machines=[null];molds=[];ents=[];flyers=[];treeBases=[];saplings=[]; + pcells.clear();beltL=[];beltR=[];screwLists=Array.from({length:IS_SCREW.length},()=>[]); + weather.next=1e15;weather.shower=null;surfY.fill(300);updateRoof(); + bot.x=320;bot.y=210;money=500;ledger={};nextPowerBuild=0; + for(const [code,v,by] of [[BP_BELT,1,46],[BP_FILTER,128,52],[BP_SCREW,1,58]]) { + for(let bx=68;bx<86;bx++)placeBlock(code,v,bx,by,true,{m:[],inv:0}); + // The return screw hangs just above the loaded belt. + if(code===BP_BELT)for(let bx=70;bx<86;bx++)placeBlock(BP_SCREW,3,bx,44,true); + for(let y=by*4-4;ye.remove());R.floaters=[]; + resizeRender();snapCamera();renderDirty.fill(1);GPU.world=null;SM.surf=-1; + }""") + assert page.evaluate("hoverInfo(330,185).warn==='stalled'"), 'jam has no hover warning' + assert page.evaluate("hoverInfo(330,185).t2.some(t=>t.includes('Clear the outlet or the top of the heap'))") + assert page.evaluate("[46,52,58].every(by=>transportStalled(by*BW+82))") + for look in ['pixel','smooth']: + page.evaluate(f"SET.look='{look}'") + page.wait_for_function(f"R.smoothOn === {'true' if look=='smooth' else 'false'}") + page.wait_for_timeout(150) + assert page.evaluate("""() => { + const p=worldToScreenPx(330,185),ctx=R.ctx; + ctx.clearRect(0,0,R.canvas.width,R.canvas.height);drawTransportStalls(); + const shown=ctx.getImageData(p.x,p.y,1,1).data[3]; + SET.fog=true;fog.fill(0);ctx.clearRect(0,0,R.canvas.width,R.canvas.height);drawTransportStalls(); + const hidden=ctx.getImageData(p.x,p.y,1,1).data[3];SET.fog=false;fog.fill(255); + return shown>0 && hidden===0; + }""") + # Compare actual built pixels while simulation time advances under the jam. + page.wait_for_timeout(60) + clips=page.evaluate("""() => [184,208,233].map(y=>{const p=worldToScreenPx(329,y);return {x:p.x,y:p.y,width:18,height:9};})""") + before=[page.screenshot(clip=c) for c in clips] + page.evaluate('for(let k=0;k<10;k++)simTick()') + page.wait_for_timeout(60) + assert [page.screenshot(clip=c) for c in clips]==before, f'{look}: stalled transport still animates' + path=ROOT / ('shots/t45-stalled.png' if look=='pixel' else 'shots/t45-stalled-smooth.png') + page.screenshot(path=str(path)) + print(f'PASS {look}: marker with overlay off, fog, hover, frozen rendered belt and screenshot') + page.evaluate("""() => { + for(const by of [46,52,58])for(let y=by*4-8;y { @@ -51,6 +56,7 @@ function hash() { setup(); const stop = n ? await enableThreads(run,n) : null; try { run('for(let k=0;k<320;k++)simTick();'); + assert(run('transportStalled(55*BW+48)'), 'worker fixture must include a loaded stalled belt'); const actual=hash(); if(!expected)expected=actual; assert.strictEqual(actual,expected,`world and effects differ with ${n} workers`); diff --git a/tools/experiments/belt-depth.js b/tools/experiments/belt-depth.js new file mode 100644 index 0000000..8fb5956 --- /dev/null +++ b/tools/experiments/belt-depth.js @@ -0,0 +1,16 @@ +// t45: sustained normal (one layer) and heavy (16 layers) flat-belt feeds, open sink. +const {load,flatWorld}=require('../../test/harness'); +const {run}=load(); +for(const depth of [1,16]) { + flatWorld(run,256,224,208); + run(`RS=7;AMB.fill(20);temp.fill(20);hot.fill(0);weather.next=1e15; + for(let bx=10;bx<32;bx++)placeBlock(BP_BELT,1,bx,24,true);`); + const start=performance.now(); + const rate=run(`(()=>{let out=0;for(let k=0;k<1420;k++){ + for(let l=1;l<=${depth};l++){const i=(96-l)*W+44;if(mat[i]===EMPTY)setCell(i,SAND,20,0);} + simTick(); + for(let y=0;y=220)out++;setCell(i,EMPTY,20,0);}} + for(let j=flyers.length-1;j>=0;j--)if(flyers[j].m===SAND&&flyers[j].x>=128){if(k>=220)out++;flyers.splice(j,1);} + }return out/20;})()`); + console.log(`${depth===1?'normal':'heavy'}: ${rate.toFixed(1)} grains/s; ${((performance.now()-start)/1420).toFixed(3)} ms/tick including feed/sink`); +}