Resting faces steeper than the angle of repose topple: blown sand towers collapse (t93)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 23:07:30 -04:00
co-authored by Claude Opus 5.5
parent 84dd8b8d0a
commit bd22e453ca
10 changed files with 264 additions and 24 deletions
+15
View File
@@ -570,6 +570,21 @@ above, including:
Resting heaps do not enter this path; world generation keeps its recorded settling rules Resting heaps do not enter this path; world generation keeps its recorded settling rules
and seed layouts. and seed layouts.
- **Nozzle-built sand towers topple from rest (t93 round 3, 2026-10-28).** The owner
reproduced vertical sand towers by continuously blowing straight up. Landing places grains
at or above impact; supported face grains need to release before the outer falling column
clears. A tall body now spills from a supported exposed face as well as a falling one.
Apply this to free-flowing powders (STICK=1); cohesive grains retain their stickier release
and do not inherit the outward motion. Require an open side and a steep drop, backed eight grains inward by a continuous column
at least 32 grains high (look at most 24 rows each way). This leaves short feeds and the
deliberately steeper small clay heaps on their existing settling rules. The spill is still
bounded to eight grains horizontally and vertically, checking every crossed cell.
Two cell flag bits retain the collapse direction while the released grain falls: up to two
grains outward and eight down per tick. Removing support passes that direction to the grain
immediately above, so the falling layer drains promptly. Contact below cancels this motion;
ordinary repose rules take over. Keep stamped moves, barriers, burning-bed protection and
worker independence. The existing worldgen POWDER_FAN switch disables new face releases.
## Ideas not built yet ## Ideas not built yet
- **Concrete**, as a stronger wall material from rubble. - **Concrete**, as a stronger wall material from rubble.
+7 -4
View File
@@ -225,9 +225,9 @@ Material definitions: ids, properties and metal families. ids, and drill/tank ti
- functions: def, metalFamily - functions: def, metalFamily
- variables: EMPTY, GRASS, DIRT, LDIRT, SAND, GRAVEL, CLAY, STONE, GRANITE, BASALT, NMAT, MD, FIRETO - variables: EMPTY, GRASS, DIRT, LDIRT, SAND, GRAVEL, CLAY, STONE, GRANITE, BASALT, NMAT, MD, FIRETO
### src/sim/material-tables.js (54 lines) ### src/sim/material-tables.js (55 lines)
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, F_AVALANCHE, F_AVALANCHE_R, 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 (179 lines) ### src/sim/pieces.js (179 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.
@@ -254,9 +254,9 @@ 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 (272 lines) ### src/sim/cells.js (321 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, swapCellData, move, enter, enterFree, powder, passable, liquid, liquidSearch, gas, wakeBotPowder, simMove, moveChunk - functions: setCell, swapCells, swapCellData, move, enter, enterFree, powderSpill, avalancheMove, powder, passable, liquid, liquidSearch, gas, wakeBotPowder, simMove, moveChunk
- variables: SORTP, SORTP_FUEL, LSTEP, LFILM, CORNER_SEAL, POWDER_FAN - variables: SORTP, SORTP_FUEL, LSTEP, LFILM, CORNER_SEAL, POWDER_FAN
### src/sim/heat.js (266 lines) ### src/sim/heat.js (266 lines)
@@ -630,11 +630,14 @@ The frame loop and boot.
- `test/harness.js`: Loads the simulation (the src/sim/ files listed in src/manifest.json, joined in order into one script, the same way build.py joins them) so scenarios can drive it headlessly in Node. Errors point at the real file and line (sim/pipes.js:42), not at the joined script. - `test/harness.js`: Loads the simulation (the src/sim/ files listed in src/manifest.json, joined in order into one script, the same way build.py joins them) so scenarios can drive it headlessly in Node. Errors point at the real file and line (sim/pipes.js:42), not at the joined script.
- `test/light-cases.js`: Lighting behaviour checks executed by the GPU browser harness. - `test/light-cases.js`: Lighting behaviour checks executed by the GPU browser harness.
- `test/light-readback.js`: Read float source channels, including transmittance, from the WGSL light pass. - `test/light-readback.js`: Read float source channels, including transmittance, from the WGSL light pass.
- `test/pile-blow.js`: Shared reproduction: continuously blow plain bagged sand straight above a stationary bot.
- `test/pile-cut.js`: Shared Node/browser reproduction: vacuum the foot of settled 60-grain heaps, one stroke per tick. - `test/pile-cut.js`: Shared Node/browser reproduction: vacuum the foot of settled 60-grain heaps, one stroke per tick.
- `test/play.py`: Play-test in a real browser: mouse, keyboard and a mocked gamepad drive the game; save and continue. Screenshots go to shots/play/ (or the folder given as the first argument). Pixel look unless the second argument is 'smooth'. - `test/play.py`: Play-test in a real browser: mouse, keyboard and a mocked gamepad drive the game; save and continue. Screenshots go to shots/play/ (or the folder given as the first argument). Pixel look unless the second argument is 'smooth'.
- `test/run_all.py`: Run the test suites and report. - `test/run_all.py`: Run the test suites and report.
- `test/t_bar_stacking.js`: Resting bars snap into pairs of grains and tip off narrow supports. - `test/t_bar_stacking.js`: Resting bars snap into pairs of grains and tip off narrow supports.
- `test/t_basics.js`: Basics: drilling to rubble, the vacuum and blowing, walls, the ingot mold and bars, belts, blueprints, save/load. - `test/t_basics.js`: Basics: drilling to rubble, the vacuum and blowing, walls, the ingot mold and bars, belts, blueprints, save/load.
- `test/t_blow_collapse.js`: Owner's upward nozzle recipe, inline and with real shared-memory helpers.
- `test/t_blow_collapse.py`: Straight-up bagged sand nozzle in the real pixel frame loop.
- `test/t_build.js`: Building: undo (Ctrl+Z), cut, replace a machine with Ctrl, pasting ghosts, and the fan heat warning. - `test/t_build.js`: Building: undo (Ctrl+Z), cut, replace a machine with Ctrl, pasting ghosts, and the fan heat warning.
- `test/t_caps.py`: Capability detection, title notices and the required-WebGPU startup gate. - `test/t_caps.py`: Capability detection, title notices and the required-WebGPU startup gate.
- `test/t_conveyors.js`: Screw conveyors that never carry liquid (it flows through), screw and belt throughput, filters. - `test/t_conveyors.js`: Screw conveyors that never carry liquid (it flows through), screw and belt throughput, filters.
+7
View File
@@ -86,6 +86,13 @@ checks waking within eight ticks separately from the probabilistic consumption o
bot-near/far, grain conservation, equal material bytes and forced-awake resting stability. bot-near/far, grain conservation, equal material bytes and forced-awake resting stability.
It also checks a broad 120-grain wall within 600 ticks and a spill stopped by cleats It also checks a broad 120-grain wall within 600 ticks and a spill stopped by cleats
across chunk edges, where the source chunk's one-grain barrier flag is clear. across chunk edges, where the source chunk's one-grain barrier flag is clear.
- `test/t_blow_collapse.py` continuously blows bagged sand straight above a stationary bot
at nozzle radius 6 for 1200 real frame-loop ticks, then checks the pile after 480 idle
ticks with actual helper dispatch. Pixel captures are `shots/t93c-before.png` and
`shots/t93c-after.png`. `test/t_blow_collapse.js` repeats the recipe inline and with
1/2/8 Node helpers, compares material and motion flags, conserves all bag/flying/landed
sand, and forces visits to check resting stability. It also directly checks a supported
face releasing immediately and a spill stopped by a cleat across a chunk boundary.
- `test/t_smooth.py` checks WebGPU smooth shapes, seams, joins, fog and the thermal lens. - `test/t_smooth.py` checks WebGPU smooth shapes, seams, joins, fog and the thermal lens.
- `test/t_gpu_golden.py` compares 28 cropped frozen reference PNGs (384x288, under - `test/t_gpu_golden.py` compares 28 cropped frozen reference PNGs (384x288, under
`test/fixtures/golden/`) against WebGPU: renderer, joins, sky and four light scenes, `test/fixtures/golden/`) against WebGPU: renderer, joins, sky and four light scenes,
+45
View File
@@ -6,6 +6,51 @@ open or not verified. Keep each entry short; the git history has the details.
## 2026-10-28: t93 round 3, nozzle-built resting sand faces (Codex)
- Reproduced the owner's continuous straight-up radius-6 sand nozzle recipe, not a
vacuum cut: 1200 toolBlow ticks under open sky. Matching original-code Node and real
pixel frame-loop browser controls fail the new regression. Browser, NVIDIA RTX 5070,
two active helpers: 79-grain cliff at blow end, still 61 after 480 idle ticks. Active
chunks remain; neither sticky rolls (sand already has STICK=1) nor missing wakes explain it.
- Cause: landFlyer/depositNear stack grains at or above impact, while supported exposed
grains only slide a single diagonal cell. The earlier fan rule requires empty space below
and misses the resting face. Simply extending the initial spill leaves the falling layer
blocking further release. Tall bodies now release from rest and carry a bounded outward
fall through the newly unsupported column; contact below cancels that motion. Cohesive
grains retain their existing release and do not inherit it. A continuous 32-grain backing
column eight grains inward distinguishes towers from short feeds. Read reach <=24,
write reach <=8, no per-grain allocation, existing worldgen switch retained.
- New shared nozzle fixture and headless/browser regressions cover the actual tool, direct
supported-face release, cross-chunk cleats, conservation, 0/1/2/8 helpers, exact material
and motion-flag bytes, and forced-awake stability. Larger equal-mass clay/sand check:
7200 grains, final heights 90/75. Rejected broad release and persistent motion on contact
experiments after they changed small heaps, clay or transport checks; no old test weakened.
- Final headless suite passes. Served pixel browser nozzle check passes at exactly 480 idle
ticks: cliff 32 -> 1, all 40000 grains retained in bag/flight/pile, final pile sleeps.
Saved and inspected shots/t93c-before.png and shots/t93c-after.png; matching old-rule
captures are shots/t93c-control-{before,after}.png. Final full
python3 test/run_all.py --browser: ALL PASSED, 35 headless and 10 browser suites,
including nozzle 25, collapse 17, vacuum 44, threads 12, gameplay 361, both browser
collapse regressions, original worldgen hashes and liquids. Log: /tmp/t93c-all-browser.log.
Ticked round-3 feedback with a report and moved t93 to Review. Owner playtesting
remains open. No commit.
- Isolated real-GPU requested profile, no concurrent tests: original-code control -> final
total ms/tick: settled 1.9359 -> 1.9413 (+0.3%), heat 2.6679 -> 2.7080 (+1.5%).
Versus historical tools/perf-after-t88.json: +7.5%/+3.2%; the control already measures
+7.2%/+1.7%. Awake counts match (425/494). Movement ms/tick 0.9520 -> 0.9570 and
1.3460 -> 1.3850; unchanged heat work 0.9060 -> 0.9067 and 1.2425 -> 1.2424.
Logs/JSON: /tmp/t93c-perf-{control,final}.{log,json}. Some real-loop frame-tail timings
are noisier and exceed the profiler's 10% marker; the requested isolated simulation
tick averages are within a few percent of the matching control.
- Files: src/sim/cells.js, src/sim/material-tables.js, test/pile-blow.js,
test/t_blow_collapse.{js,py}, test/run_all.py, docs/DESIGN.md, docs/TESTING.md,
regenerated docs/PROJECT_MAP.md, t93 task and this log. Further evidence:
/tmp/t93c-red.log, /tmp/t93c-browser-red.log, /tmp/t93c-browser-final.log,
/tmp/t93c-final-headless2.log.
## 2026-10-28: t93 reopened, vacuum-cut faces drain in columns (Codex) ## 2026-10-28: t93 reopened, vacuum-cut faces drain in columns (Codex)
- Read AGENTS, the t93 task/log and 807217b; inspected both new owner screenshots. - Read AGENTS, the t93 task/log and 807217b; inspected both new owner screenshots.
+68 -19
View File
@@ -78,9 +78,75 @@ const SORTP_FUEL = 0.003;
// World generation settles with both at 1 and corner sealing off, so seeds keep the same world. // World generation settles with both at 1 and corner sealing off, so seeds keep the same world.
let LSTEP = 8, LFILM = 4; let LSTEP = 8, LFILM = 4;
let CORNER_SEAL = true, POWDER_FAN = true; let CORNER_SEAL = true, POWDER_FAN = true;
// Every spill checks each crossed cell, including barriers outside the source chunk.
function powderSpill(i, x, y, m, dx, limit, avalanche) {
let reach = 0;
for (let step = 1; step <= limit; step++) {
const nx = x + dx * step, side = i + dx * step, j = side + W;
if (nx < 0 || nx >= W || mat[side] !== EMPTY || part[side] || cleatMask[side] ||
!passable(m, PH_POWDER, side, dx, 0)) break;
if (mat[j] === EMPTY && !part[j] && !cleatMask[j] && passable(m, PH_POWDER, j, dx, 1)) reach = step;
}
if (!reach) return false;
let drop = 1;
for (let step = 2; step <= limit && y + step < H; step++) {
const j = i + W * step + dx * reach;
if (mat[j] !== EMPTY || part[j] || cleatMask[j] || !passable(m, PH_POWDER, j, 0, 1)) break;
drop = step;
}
if (avalanche) avalancheMove(i, x, y, dx, reach, drop);
else move(i, i + W * drop + dx * reach, x, y, x + dx * reach, y + drop);
return true;
}
// Removing support passes the collapse direction up the column; contact cancels it.
function avalancheMove(i, x, y, dx, reach, drop) {
const fl = F_AVALANCHE | (dx > 0 ? F_AVALANCHE_R : 0);
if (y && PH[mat[i - W]] === PH_POWDER && STICK[mat[i - W]] === 1) flags[i - W] = (flags[i - W] & ~F_AVALANCHE_R) | fl;
flags[i] |= fl;
move(i, i + dx * reach + W * drop, x, y, x + dx * reach, y + drop);
}
function powder(i, x, y, m, barriers = cleatChunks[(y >> 5) * CW + (x >> 5)]) { function powder(i, x, y, m, barriers = cleatChunks[(y >> 5) * CW + (x >> 5)]) {
if (y >= H - 1) return; if (y >= H - 1) return;
const b = i + W; const b = i + W;
if (flags[i] & F_AVALANCHE) {
const dx = flags[i] & F_AVALANCHE_R ? 1 : -1;
flags[i] &= ~(F_AVALANCHE | F_AVALANCHE_R);
let reach = 0;
for (let step = 1; step <= 2 && mat[b] === EMPTY; step++) {
const nx = x + dx * step, j = i + dx * step;
if (nx < 0 || nx >= W || mat[j] !== EMPTY || part[j] || cleatMask[j] || !passable(m, PH_POWDER, j, dx, 0)) break;
reach = step;
}
let drop = 0;
for (let step = 1; step <= 8 && y + step < H; step++) {
const j = i + dx * reach + W * step;
if (mat[j] !== EMPTY || part[j] || cleatMask[j] || !passable(m, PH_POWDER, j, 0, 1)) break;
drop = step;
}
if (drop && mat[b] === EMPTY && !part[i] && !cleatMask[i] && !(flags[i] & F_BURN)) {
avalancheMove(i, x, y, dx, reach, drop); return;
}
}
// Tall free-flowing bodies topple from rest; short feeds and cohesive grains keep their repose.
if (POWDER_FAN && STICK[m] === 1 && (mat[b] === EMPTY || PH[mat[b]] === PH_POWDER) && !part[i] && !((flags[i] | flags[b]) & F_BURN) && x > 0 && x < W - 1) {
const dx = mat[i - 1] === EMPTY && PH[mat[i + 1]] === PH_POWDER ? -1 :
mat[i + 1] === EMPTY && PH[mat[i - 1]] === PH_POWDER ? 1 : 0;
if (dx && !cleatMask[i] && ((PH[mat[b]] === PH_POWDER && y + 2 < H && mat[b + W + dx] === EMPTY) ||
(y > 0 && PH[mat[i - W]] === PH_POWDER && mat[i - W + dx] === EMPTY && PH[mat[b - dx]] === PH_POWDER))) {
// Read at most 24 rows away: with eight-grain writes, same-colour chunks stay independent.
const insideX = x - dx * 8;
let depth = 0;
if (insideX >= 0 && insideX < W) {
const inside = i - dx * 8;
let up = 0;
while (up < 24 && y >= up && PH[mat[inside - W * up]] === PH_POWDER) up++;
let down = 1;
while (down <= 24 && up + down <= 32 && y + down < H && PH[mat[inside + W * down]] === PH_POWDER) down++;
depth = up + down - 1;
}
if (depth >= 32 && powderSpill(i, x, y, m, dx, 8, true)) return;
}
}
if ((!barriers || (!cleatMask[i] && !cleatMask[b])) && enterFree(m, PH_POWDER, b, 0, 1)) { if ((!barriers || (!cleatMask[i] && !cleatMask[b])) && enterFree(m, PH_POWDER, b, 0, 1)) {
// A falling face fans outward; dropping straight down keeps a packed column in its way. // A falling face fans outward; dropping straight down keeps a packed column in its way.
if (POWDER_FAN && mat[b] === EMPTY && !part[i] && !(flags[i] & F_BURN) && x > 0 && x < W - 1) { if (POWDER_FAN && mat[b] === EMPTY && !part[i] && !(flags[i] & F_BURN) && x > 0 && x < W - 1) {
@@ -91,25 +157,8 @@ function powder(i, x, y, m, barriers = cleatChunks[(y >> 5) * CW + (x >> 5)]) {
// Deep faces spill across the falling column, within the eight-grain worker halo. // Deep faces spill across the falling column, within the eight-grain worker halo.
let depth = 0; let depth = 0;
while (depth < 12 && y >= depth && PH[mat[i - dx - W * depth]] === PH_POWDER) depth++; while (depth < 12 && y >= depth && PH[mat[i - dx - W * depth]] === PH_POWDER) depth++;
let reach = 0; const reach = depth === 12 && stamp[b - dx] !== tickStamp ? 8 : 1;
for (let step = 1; step <= (depth === 12 && stamp[b - dx] !== tickStamp ? 8 : 1); step++) { if (powderSpill(i, x, y, m, dx, reach, false)) return;
const nx = x + dx * step, side = i + dx * step, j = side + W;
if (nx < 0 || nx >= W || mat[side] !== EMPTY || part[side] ||
cleatMask[side] || !passable(m, PH_POWDER, side, dx, 0)) break;
if (mat[j] === EMPTY && !part[j] && !cleatMask[j] &&
passable(m, PH_POWDER, j, dx, 1)) reach = step;
}
if (reach) {
let drop = 1;
if (depth === 12 && stamp[b - dx] !== tickStamp) {
for (let step = 2; step <= 8 && y + step < H; step++) {
const j = i + W * step + dx * reach;
if (mat[j] !== EMPTY || part[j] || cleatMask[j] || !passable(m, PH_POWDER, j, 0, 1)) break;
drop = step;
}
}
move(i, i + W * drop + dx * reach, x, y, x + dx * reach, y + drop); return;
}
} }
} }
move(i, b, x, y, x, y + 1); return; move(i, b, x, y, x, y + 1); return;
+1
View File
@@ -28,6 +28,7 @@ const SOLIDMETAL = new Uint8Array(NMAT); for (const m of [COPPER, IRON, STEEL, T
// ---------- Flags & parts ---------- // ---------- Flags & parts ----------
const F_NAT = 1, F_CAST = 2, F_BURN = 4; const F_NAT = 1, F_CAST = 2, F_BURN = 4;
const F_AVALANCHE = 8, F_AVALANCHE_R = 16; // transient outward fall, cleared on contact
const P_NONE = 0, P_SCREW_U = 1, P_SCREW_D = 2, P_SCREW_L = 3, P_SCREW_R = 4, P_GRATE = 5, P_GOPEN = 6, P_WHEEL = 7, P_INTAKE = 8, P_FILTER = 9, P_PIPE = 10, P_SCREW_UR = 11, P_SCREW_DR = 12, P_SCREW_DL = 13, P_SCREW_UL = 14; const P_NONE = 0, P_SCREW_U = 1, P_SCREW_D = 2, P_SCREW_L = 3, P_SCREW_R = 4, P_GRATE = 5, P_GOPEN = 6, P_WHEEL = 7, P_INTAKE = 8, P_FILTER = 9, P_PIPE = 10, P_SCREW_UR = 11, P_SCREW_DR = 12, P_SCREW_DL = 13, P_SCREW_UL = 14;
const SCREW_PARTS = [P_SCREW_U, P_SCREW_R, P_SCREW_D, P_SCREW_L, P_SCREW_UR, P_SCREW_DR, P_SCREW_DL, P_SCREW_UL]; const SCREW_PARTS = [P_SCREW_U, P_SCREW_R, P_SCREW_D, P_SCREW_L, P_SCREW_UR, P_SCREW_DR, P_SCREW_DL, P_SCREW_UL];
const SCREW_DIRS = [[0, -1], [1, 0], [0, 1], [-1, 0], [1, -1], [1, 1], [-1, 1], [-1, -1]]; const SCREW_DIRS = [[0, -1], [1, 0], [0, 1], [-1, 0], [1, -1], [1, 1], [-1, 1], [-1, -1]];
+21
View File
@@ -0,0 +1,21 @@
// Shared reproduction: continuously blow plain bagged sand straight above a stationary bot.
function pileBlowSetup() {
pileCutWorld();
for (let x=0;x<W;x++) { surfY[x]=480; dirtY[x]=490; }
computeAmbient();
for(let y=0;y<H;y++)for(let x=0;x<W;x++) {
const i=y*W+x;mat[i]=y>=480?STONE:EMPTY;flags[i]=y>=480?F_NAT:0;temp[i]=AMB[y];
}
bot.x=384-bot.w/2;bot.y=330-bot.h/2;bot.vx=bot.vy=0;
bag[SAND]=40000;bagTotal=40000;updateRoof();
}
function pileBlowStroke() { toolBlow(383.5,250,6,SAND); }
function pileBlowProfile() {
let cliff=0,previous=0,grains=0,height=0;
for(let x=0;x<W;x++) {
let y=0;while(y<480&&mat[y*W+x]!==SAND)y++;
const h=480-y;cliff=Math.max(cliff,Math.abs(h-previous));previous=h;height=Math.max(height,h);
for(;y<480;y++)if(mat[y*W+x]===SAND)grains++;
}
return {cliff,height,grains,flying:flyers.length,bag:bagTotal,awake:awake.filter(v=>v).length};
}
+1 -1
View File
@@ -45,7 +45,7 @@ def main():
if browser: if browser:
subprocess.run([sys.executable, str(ROOT / 'build.py')], cwd=ROOT, check=True) subprocess.run([sys.executable, str(ROOT / 'build.py')], cwd=ROOT, check=True)
# one at a time: two headless browsers at once can starve each other and flake # one at a time: two headless browsers at once can starve each other and flake
for name in ['play.py', 't_smooth.py', 't_dev_console.py', 't_caps.py', 't_gpu.py', 't_vacuum_collapse.py', 't_gpu_golden.py', 't_gpu_light.py', 'firefox_smoke.py']: for name in ['play.py', 't_smooth.py', 't_dev_console.py', 't_caps.py', 't_gpu.py', 't_vacuum_collapse.py', 't_blow_collapse.py', 't_gpu_golden.py', 't_gpu_light.py', 'firefox_smoke.py']:
results.append(run_py(name)) results.append(run_py(name))
bad = 0 bad = 0
for name, code, out, secs in results: for name, code, out, secs in results:
+48
View File
@@ -0,0 +1,48 @@
// Owner's upward nozzle recipe, inline and with real shared-memory helpers.
const fs = require('fs');
const {createHash} = require('crypto');
const {load, suite, enableThreads} = require('./harness');
const {run} = load(), {check, done} = suite();
for (const file of ['pile-cut', 'pile-blow']) run(fs.readFileSync(require.resolve('./'+file+'.js'), 'utf8'));
const hash = () => {const h=createHash('sha256');for(const a of run('[mat,flags,life]'))h.update(a);return h.digest('hex');};
// A supported grain must release immediately, without relying on gravity underneath it.
run(`pileBlowSetup();for(let y=270;y<340;y++)for(let x=356;x<376;x++)setCell(y*W+x,SAND,20,0);
powder(300*W+356,356,300,SAND);`);
check('a resting tall face topples promptly',run('mat[300*W+356]===EMPTY && mat[308*W+348]===SAND && (flags[299*W+356]&F_AVALANCHE)'));
run(`pileBlowSetup();for(let y=270;y<340;y++)for(let x=356;x<376;x++)setCell(y*W+x,SAND,20,0);
cleatMask[300*W+352]=1;powder(300*W+356,356,300,SAND);`);
check('resting face spill stops at a cross-chunk cleat',run('mat[308*W+353]===SAND && mat[308*W+348]===EMPTY'));
(async () => {
let expected;
for (const workers of [0, 1, 2, 8]) {
run('pileBlowSetup()');
const stop = workers ? await enableThreads(run, workers) : null;
try {
run('for(let k=0;k<1200;k++){pileBlowStroke();simTick();}');
const before = run('pileBlowProfile()');
check(`fast upward nozzle builds a large pile (${workers} helpers)`, before.grains > 15000 && before.height > 100, JSON.stringify(before));
run('bot.x=700;bot.y=100;for(let k=0;k<480;k++)simTick();');
const after = run('pileBlowProfile()');
check(`blown sand slumps within eight seconds (${workers} helpers)`, after.cliff <= 2, JSON.stringify(after));
check('all blown sand is retained', after.grains + after.flying + after.bag === 40000);
if (workers) {
check('helpers actually process the blown pile', run('simPool.activeMax') > 0);
check('blown collapse matches inline material and motion flags', hash() === expected);
} else expected = hash();
run('for(let k=0;k<1200;k++){awake.fill(3);simTick();}');
const resting = hash();
run('for(let k=0;k<600;k++){awake.fill(3);simTick();}for(let k=0;k<3;k++)simTick();');
check('settled blown sand stays put and sleeps', hash() === resting && run('awake.every(v=>!v)'));
} finally { if(stop) await stop(); }
}
const heights = [];
for (const material of ['SAND','LCLAY']) {
run(`pileBlowSetup();bag.fill(0);bagTotal=0;bot.x=700;
for(let y=360;y<480;y++)for(let x=350;x<410;x++)setCell(y*W+x,${material},20,0);
for(let k=0;k<1800;k++){awake.fill(3);simTick();}`);
heights.push(run(`(()=>{let height=0;for(let x=0;x<W;x++){let y=0;
while(y<480&&mat[y*W+x]!==${material})y++;height=Math.max(height,480-y);}return height;})()`));
}
check('large clay heaps remain steeper than equal sand heaps',heights[1]>heights[0],JSON.stringify(heights));
done();
})().catch(e => { console.error(e); process.exitCode = 1; });
+51
View File
@@ -0,0 +1,51 @@
"""Straight-up bagged sand nozzle in the real pixel frame loop."""
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, capabilities
consume_gpu_option()
ROOT = pathlib.Path(__file__).resolve().parents[1]
with launch(viewport={'width': 1440, 'height': 900}) as (_, page, url):
errors = []
page.on('pageerror', lambda e: errors.append(str(e)))
page.goto(url)
print('GPU', capabilities(page), flush=True)
page.evaluate("""() => {
const enter=enterGame;enterGame=()=>{enter();GAME.paused=true;};
SET.workerCap=2;SET.maxSteps=1;SET.fog=false;SET.lighting='off';SET.cameraLead=false;SET.autosave=0;
startNewWorld(7,768,600);
}""")
page.wait_for_function('GAME.running', timeout=60000)
for file in ('pile-cut', 'pile-blow'):
page.evaluate((ROOT / f'test/{file}.js').read_text())
page.evaluate("""() => {
pileBlowSetup();renderDirty.fill(1);GPU.world=null;SM.surf=-1;
document.querySelectorAll('.card,.toast').forEach(e=>e.remove());
$('hud').hidden=true;GAME.hidePreviews=true;SET.zoom=3;resizeRender();
snapCamera();updateCamera=()=>{};
}""")
page.evaluate('startThreads()')
page.evaluate("""() => {
simPool.force=true;window.blowTicks=0;window.blowEnd=0;
const tool=toolTick;toolTick=()=>{tool();if(blowTicks<1200){pileBlowStroke();blowTicks++;}};
const advance=simTick;simTick=()=>{
advance();
if(blowTicks===1200 && !blowEnd){blowEnd=tick;window.blowBefore=pileBlowProfile();GAME.paused=true;}
else if(blowEnd && tick===blowEnd+480){window.blowAfter=pileBlowProfile();GAME.paused=true;}
};
GAME.paused=false;
}""")
page.wait_for_function('window.blowBefore', timeout=120000)
print('before', page.evaluate('blowBefore'), flush=True)
page.screenshot(path=str(ROOT / 'shots/t93c-before.png'))
page.evaluate('() => {bot.x=700;bot.y=100;GAME.paused=false;}')
page.wait_for_function('window.blowAfter', timeout=60000)
result=page.evaluate('({before:blowBefore,after:blowAfter,helpers:simPool.activeMax})')
page.screenshot(path=str(ROOT / 'shots/t93c-after.png'))
print('result', result, flush=True)
assert result['before']['grains']>15000 and result['before']['height']>100, result
assert result['after']['cliff']<=2 and result['helpers']>0, result
assert sum(result['after'][k] for k in ('grains','flying','bag'))==40000, result
assert not errors, errors
print('PASS upward nozzle pile collapses within 480 real-loop ticks with active helpers')