Drill pulses pass through rubble and fluidise it; per-grain damping (t92)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 18:09:42 -04:00
co-authored by Claude Opus 5.5
parent 90f2d3f974
commit 729378770a
4 changed files with 172 additions and 27 deletions
+3 -3
View File
@@ -327,7 +327,7 @@ Tool helpers: reach, brushes, and plain digging.
- functions: setBuildRange, inBuildRange, inReach, forBrush, drillReach, inDrillReach, toolDig - functions: setBuildRange, inBuildRange, inReach, forBrush, drillReach, inDrillReach, toolDig
- variables: BUILD_MAX, buildBox - variables: BUILD_MAX, buildBox
### src/sim/drill.js (177 lines) ### src/sim/drill.js (187 lines)
The resonance drill: tuned pulses that shatter one material. The resonance drill: tuned pulses that shatter one material.
- functions: drillOrigin, pulseRay, builtCell, toolPulse, crumbleAround, crumbly, shatterCell, pulseTick, pingRadius, resonancePing - functions: drillOrigin, pulseRay, builtCell, toolPulse, crumbleAround, crumbly, shatterCell, pulseTick, pingRadius, resonancePing
- variables: PULSE_W, PULSE_EVERY, PULSE_SPEED, PULSE_K, drillPulses, lastPulseTick, drillWidth, THIN_BONUS, CRUMBLE_MAX, THIN_BONUS_OFF, PING_VEINS - variables: PULSE_W, PULSE_EVERY, PULSE_SPEED, PULSE_K, drillPulses, lastPulseTick, drillWidth, THIN_BONUS, CRUMBLE_MAX, THIN_BONUS_OFF, PING_VEINS
@@ -443,7 +443,7 @@ GPU sources retain the CPU roof and transmission rules; directional relaxation f
Smooth slope geometry and cleats, ported from slope-belt-shader.js. Smooth slope geometry and cleats, ported from slope-belt-shader.js.
- variables: GPU_SMOOTH_SLOPE_HELPERS, GPU_SMOOTH_SLOPE_DRAW - variables: GPU_SMOOTH_SLOPE_HELPERS, GPU_SMOOTH_SLOPE_DRAW
### src/render/gpu/smooth-shader.js (368 lines) ### src/render/gpu/smooth-shader.js (369 lines)
Literal port of the legacy smooth shader; dpdy is reversed for WebGPU's top-left origin. Literal port of the legacy smooth shader; dpdy is reversed for WebGPU's top-left origin.
- variables: GPU_SMOOTH - variables: GPU_SMOOTH
@@ -648,7 +648,7 @@ The frame loop and boot.
- `test/t_fog.js`: Fog: surface, sight, range, saves and simulation independence. - `test/t_fog.js`: Fog: surface, sight, range, saves and simulation independence.
- `test/t_furnace_repeat.js`: The starter furnaces keep working when fired again and again without cleaning, however the charge goes in: mixed, ore first with coal on top, or blown in through the vent. Each case plays furnace_play.js, which builds the design and fires it like a player. - `test/t_furnace_repeat.js`: The starter furnaces keep working when fired again and again without cleaning, however the charge goes in: mixed, ore first with coal on top, or blown in through the vent. Each case plays furnace_play.js, which builds the design and fires it like a player.
- `test/t_gpu.py`: Dirty uploads, default limits, warm pipelines, world replacement and device recovery. - `test/t_gpu.py`: Dirty uploads, default limits, warm pipelines, world replacement and device recovery.
- `test/t_gpu_golden.py`: Frozen legacy references, captured before retirement; compare the WebGPU world only. - `test/t_gpu_golden.py`: Frozen world references; seven smooth crops corrected for continuous noise in t91.
- `test/t_gpu_light.py`: The former CPU light behavioural checks, now read from the actual GPU light grid. - `test/t_gpu_light.py`: The former CPU light behavioural checks, now read from the actual GPU light grid.
- `test/t_joins.js`: The oracle states the geometric rule independently of the precomputed neighbour tables. - `test/t_joins.js`: The oracle states the geometric rule independently of the precomputed neighbour tables.
- `test/t_load.js`: Reload wakes nearby areas first, without losing distant water, fire or saved state. - `test/t_load.js`: Reload wakes nearby areas first, without losing distant water, fire or saved state.
+38
View File
@@ -4,6 +4,44 @@ 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-28: t92 drill pulses pass through rubble and fluidise it (Codex)
- Converted every pulse loss to a per-grain factor, applied with `factor ** step` outside
the ray sampling loop. Powder keeps 0.97 per grain; liquid, built, hard and tuned paths
preserve their former squared half-grain factors. Tree damping and shake now scale with
distance too. Rock damping retuned to 0.76/0.69/0.6084 per grain for narrow/medium/wide.
- Replaced the botward nudge with random sideways/upward one-cell hops through open cells,
using `rnd`, `move`, stamps, occupancy, part/cleat rules and sealed corners. At most three
moves per ray/tick. Up to four shake debris events/tick reuse the existing particles;
no new rendering or UI code, dependencies, save fields or design decisions.
- Added drill regressions: 35-grain shield failed before (0 basalt opened), now opens 78
basalt cells with the real steel medium reach; 115 grains opens none. Only the latter
fixture extends range beyond the bit's reach, isolating attenuation from range.
Exact 3% loss crosses the cutoff near 117 grains; an additional 118-grain check
confirms extinction before any basalt damage. Fixed
powder transmission is 0.4821/1.4 = 34.44%; quarter/half/whole-grain attenuation agrees
for rubble, sand, ore, coal, liquids, rock, tuned skips/hits and built material.
Seeded heap height drops 25 -> 20 in eight seconds versus control 25 -> 25, conserving
625 grains; repeatability, movement protection, three-move/ray and four-event caps pass.
- Before/after two-second copper-bit stone-wall fixture, depth/opened cells: narrow
22/181 -> 24/195 (+9.1%/+7.7%), medium 13/186 -> 13/197 (0%/+5.9%), wide
6/134 -> 6/134. Existing follow-face, width, tuning, shielding and tree checks retained.
Fixed powder-ray microbenchmark median: 21.48 -> 21.28 ms per 20,000 calls (five warm
samples); this is an isolated pulse cost check, not a whole-game performance measurement.
- Files: `src/sim/drill.js`, `test/t_drill.js`, generated `docs/PROJECT_MAP.md`, t92 task
tracking and this log. No commit. Verification: focused drill suite passes all 61 checks;
initial full run passed all 32 headless suites but seven browser suites failed under the
sandbox's unavailable WebGPU adapter. Host-GPU `python3 test/run_all.py --browser` exited
0, ALL PASSED (all 32 headless suites; gameplay 361, smooth 28, console 18, caps 7,
GPU 14, goldens 28, light 71 and Firefox 2). The additional 118-grain extinction check
was then verified in the focused 61-check suite. Logs: `/tmp/t92-drill-before.log`,
`/tmp/t92-drill-red.log`, `/tmp/t92-drill-after.log`, `/tmp/t92-all-tests-host.log`.
Inspected `shots/t92-pile-before-pixel.png`, `t92-pile-after-pixel.png` and
`t92-pile-after-smooth.png`: the drilled side is lower and flatter, with small dust puffs
close to the surface. Setup scripts retained in `/tmp/t92-scene-before.js` and
`/tmp/t92-scene-after.js`; capture with `tools/shot.py --setup FILE --zoom 10 --no-light
--freeze` (add `--look smooth` for smooth). Task moved to Review; owner playtesting remains.
## 2026-10-28: t91 sky renders as mismatched blocks (Codex) ## 2026-10-28: t91 sky renders as mismatched blocks (Codex)
- Reproduced the owner's cloud rectangles and stepped distant hills on NVIDIA Blackwell: - Reproduced the owner's cloud rectangles and stepped distant hills on NVIDIA Blackwell:
+34 -24
View File
@@ -8,14 +8,15 @@
// medium: a cone, the all-rounder // medium: a cone, the all-rounder
// wide: a broad fan that clears a big face close to the bot, but reaches only a short way // wide: a broad fan that clears a big face close to the bot, but reaches only a short way
// Tuned to one material, a pulse passes through everything else and shatters only that, and // Tuned to one material, a pulse passes through everything else and shatters only that, and
// loose grains it passes are jostled toward the bot, so a shattered vein pours out of the wall. // loose grains it passes are shaken into short hops, so piles flow and pockets drain.
// Built blocks, machines and molds soak pulses up unharmed. // Built blocks, machines and molds soak pulses up unharmed.
// half: half the spread angle; mouth: half the width of the fan where it leaves the bot; // half: half the spread angle; mouth: half the width of the fan where it leaves the bot;
// en: energy per ray; damp: energy kept after each shake (higher bores deeper); reach: x bit reach // en: energy per ray; damp: energy kept per grain (higher bores deeper); reach: x bit reach
// Narrow/medium compensate for rubble now passing pulses; wide keeps its old squared loss.
const PULSE_W = [ const PULSE_W = [
{ name: 'narrow', half: 0.03, mouth: 3.2, rays: 13, en: 1.75, damp: 0.9, reach: 1.45 }, { name: 'narrow', half: 0.03, mouth: 3.2, rays: 13, en: 1.75, damp: 0.76, reach: 1.45 },
{ name: 'medium', half: 0.24, mouth: 3, rays: 23, en: 1.4, damp: 0.84, reach: 1 }, { name: 'medium', half: 0.24, mouth: 3, rays: 23, en: 1.4, damp: 0.69, reach: 1 },
{ name: 'wide', half: 0.62, mouth: 3.4, rays: 41, en: 1.32, damp: 0.78, reach: 0.7 }, { name: 'wide', half: 0.62, mouth: 3.4, rays: 41, en: 1.32, damp: 0.6084, reach: 0.7 },
]; ];
const PULSE_EVERY = 8, PULSE_SPEED = 2.2, PULSE_K = 240; // ticks between pulses, cells per tick, shake per unit of energy const PULSE_EVERY = 8, PULSE_SPEED = 2.2, PULSE_K = 240; // ticks between pulses, cells per tick, shake per unit of energy
let drillPulses = [], lastPulseTick = -99, drillWidth = 1; let drillPulses = [], lastPulseTick = -99, drillWidth = 1;
@@ -97,49 +98,58 @@ function shatterCell(i, x, y, m) {
if ((stats.dug & 7) === 0) emit({ t: 'sfx', s: 'dig', a: HARD[m] }); if ((stats.dug & 7) === 0) emit({ t: 'sfx', s: 'dig', a: HARD[m] });
} }
// Advance every pulse in flight by one tick // Advance every pulse in flight by one tick
function pulseTick() { function pulseTick(step = 0.5) {
if (!drillPulses.length) return; if (!drillPulses.length) return;
const tier = BIT_TIERS[player.bitTier]; const tier = BIT_TIERS[player.bitTier];
// Per-grain losses; squared old half-grain factors preserve the rock/built/liquid paths.
const loose = 0.97 ** step, liquid = 0.970225 ** step, built = 0.0625 ** step;
const skip = 0.986049 ** step, hard = 0.1225 ** step, tuned = 0.9216 ** step;
const wood = 0.85 ** step, leaves = 0.97 ** step;
let dust = 0;
// cells inside the bot's body are not touched (it flies in its own little space) // cells inside the bot's body are not touched (it flies in its own little space)
const bx0 = Math.floor(bot.x), by0 = Math.floor(bot.y), bx1 = Math.ceil(bot.x + bot.w) - 1, by1 = Math.ceil(bot.y + bot.h) - 1; const bx0 = Math.floor(bot.x), by0 = Math.floor(bot.y), bx1 = Math.ceil(bot.x + bot.w) - 1, by1 = Math.ceil(bot.y + bot.h) - 1;
for (const p of drillPulses) { for (const p of drillPulses) {
p.r += PULSE_SPEED; p.r += PULSE_SPEED;
const lim = Math.min(p.r, p.range), Wd = PULSE_W[p.w]; const lim = Math.min(p.r, p.range), Wd = PULSE_W[p.w], damp = p.damp ** step;
for (let k = 0; k < p.n; k++) { for (let k = 0; k < p.n; k++) {
let e = p.en[k]; let e = p.en[k];
if (e < 0.04) continue; if (e < 0.04) continue;
const ray = pulseRay(p.ox, p.oy, p.ang, Wd, -1 + 2 * (k + p.jit) / p.n), ux = ray.ux, uy = ray.uy; const ray = pulseRay(p.ox, p.oy, p.ang, Wd, -1 + 2 * (k + p.jit) / p.n), ux = ray.ux, uy = ray.uy;
let sd = p.s[k]; let sd = p.s[k], kicks = 0;
while (sd < lim && e >= 0.04) { while (sd < lim && e >= 0.04) {
sd += 0.5; sd += step;
const x = Math.floor(ray.x + ux * sd), y = Math.floor(ray.y + uy * sd); const x = Math.floor(ray.x + ux * sd), y = Math.floor(ray.y + uy * sd);
if (x < 0 || y < 0 || x >= W || y >= H) { e = 0; break; } if (x < 0 || y < 0 || x >= W || y >= H) { e = 0; break; }
if (x >= bx0 && x <= bx1 && y >= by0 && y <= by1) continue; if (x >= bx0 && x <= bx1 && y >= by0 && y <= by1) continue;
const i = y * W + x, m = mat[i], ph = PH[m]; const i = y * W + x, m = mat[i], ph = PH[m];
if (ph === PH_EMPTY || ph === PH_GAS) { if (ph === PH_EMPTY || ph === PH_GAS) {
// standing trees in the way are shaken apart too (not by a tuned pulse) // standing trees in the way are shaken apart too (not by a tuned pulse)
if (bgm[i] && !p.tune && (sd * 2 & 1) === 0) { cutTree(i, x, y, tier, e * 0.5); e *= bgm[i] === WOOD ? 0.85 : 0.97; } if (bgm[i] && !p.tune) { const loss = bgm[i] === WOOD ? wood : leaves; cutTree(i, x, y, tier, e * step * 0.5); e *= loss; }
continue; continue;
} }
if (ph === PH_LIQUID) { e *= 0.985; continue; } if (ph === PH_LIQUID) { e *= liquid; continue; }
if (ph === PH_POWDER) { if (ph === PH_POWDER) {
// loose grains are jostled back toward the bot, so a shattered pocket drains out of the e *= loose;
// wall and a bore clears itself: straight back if there is room, else down or up a step // At most three one-cell hops per ray/tick; gravity settles the shaken grains.
e *= 0.94; if (kicks < 3 && stamp[i] !== tickStamp && !occ[i] && !part[i] && rnd() < 1.1 * e * step) {
if (stamp[i] !== tickStamp && rnd() < 0.3 * e) { const side = rnd() < 0.5 ? -1 : 1;
const sx = Math.abs(ux) > 0.3 ? -Math.sign(ux) : 0, sy = Math.abs(uy) > 0.3 ? -Math.sign(uy) : 0; for (let hop = 0; hop < 4; hop++) {
for (const [mx, my] of [[sx, sy], [sx, 1], [sx, -1], [0, sy]]) { const mx = hop < 2 ? side : -side, my = -(hop & 1);
if (!mx && !my) continue;
const nx = x + mx, ny = y + my; const nx = x + mx, ny = y + my;
if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue; if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
const j = ny * W + nx; const j = ny * W + nx;
if ((mat[j] === EMPTY || PH[mat[j]] === PH_GAS) && !occ[j] && !part[j]) { move(i, j, x, y, nx, ny); break; } if (mat[j] !== EMPTY && PH[mat[j]] !== PH_GAS) continue;
if (occ[j] || part[j] || stamp[j] === tickStamp || !enter(m, ph, j, mx, my)) continue;
if (my && CORNER_SEAL && PH[mat[i + mx]] === PH_SOLID && PH[mat[i - W]] === PH_SOLID) continue;
move(i, j, x, y, nx, ny); kicks++;
if (dust < 4) { emit({ t: 'debris', x, y, m }); dust++; }
break;
} }
} }
continue; continue;
} }
if (builtCell(i, x, y)) { e *= 0.25; continue; } if (builtCell(i, x, y)) { e *= built; continue; }
if (p.tune && YLD[m] !== p.tune) { e *= 0.993; continue; } if (p.tune && YLD[m] !== p.tune) { e *= skip; continue; }
const h = HARD[m]; const h = HARD[m];
// thin rock (a corner, a nub, a sliver with open space on two or more sides) has little // thin rock (a corner, a nub, a sliver with open space on two or more sides) has little
// to hold it and shakes apart faster than a flat face, so ragged edges get cleaned up // to hold it and shakes apart faster than a flat face, so ragged edges get cleaned up
@@ -149,10 +159,10 @@ function pulseTick() {
if (y > 0 && PH[mat[i - W]] !== PH_SOLID) open++; if (y > 0 && PH[mat[i - W]] !== PH_SOLID) open++;
if (y < H - 1 && PH[mat[i + W]] !== PH_SOLID) open++; if (y < H - 1 && PH[mat[i + W]] !== PH_SOLID) open++;
const thin = open > 1 && !THIN_BONUS_OFF ? 1 + (open - 1) * THIN_BONUS : 1; const thin = open > 1 && !THIN_BONUS_OFF ? 1 + (open - 1) * THIN_BONUS : 1;
if (h > tier.hard) { e *= 0.35; continue; } if (h > tier.hard) { e *= hard; continue; }
const q = digp[i] + e * thin * tier.speed * PULSE_K / (1 + h * h * 0.6); const q = digp[i] + e * (2 * step) * thin * tier.speed * PULSE_K / (1 + h * h * 0.6);
if (q >= 255) { shatterCell(i, x, y, m); if (!p.tune) crumbleAround(x, y); } else { digp[i] = q; renderDirtyIdx(i); } if (q >= 255) { shatterCell(i, x, y, m); if (!p.tune) crumbleAround(x, y); } else { digp[i] = q; renderDirtyIdx(i); }
e *= p.tune ? 0.96 : p.damp; e *= p.tune ? tuned : damp;
} }
p.en[k] = e; p.s[k] = sd; p.en[k] = e; p.s[k] = sd;
} }
+97
View File
@@ -35,6 +35,13 @@ wall2(); hold(150, 78, 2, 0, 2); const wideRows = rowsOf();
wall2(); hold(150, 78, 0, 0, 2); const narRows = rowsOf(); wall2(); hold(150, 78, 0, 0, 2); const narRows = rowsOf();
check('the wide cone is taller at the face than the narrow one', wideRows > narRows, `wide ${wideRows} rows, narrow ${narRows}`); check('the wide cone is taller at the face than the narrow one', wideRows > narRows, `wide ${wideRows} rows, narrow ${narRows}`);
// Fixed two-second wall fixture: measured before t92, including normal rubble/gravity.
for (const [name, got, baseline] of [['narrow', nar, {d:22,n:181}], ['medium', med2, {d:13,n:186}], ['wide', wid, {d:6,n:134}]]) {
check(`${name} stone bore stays within 10% of its depth and opened area`,
Math.abs(got.d - baseline.d) <= baseline.d * 0.1 && Math.abs(got.n - baseline.n) <= baseline.n * 0.1,
`${got.d} deep / ${got.n} opened (before ${baseline.d} / ${baseline.n})`);
}
// the reach depends on the width: a narrow beam reaches furthest, a wide fan only close by // the reach depends on the width: a narrow beam reaches furthest, a wide fan only close by
run('player.bitTier = 0;'); run('player.bitTier = 0;');
const reaches = [0, 1, 2].map(w => run(`drillReach(${w})`)); const reaches = [0, 1, 2].map(w => run(`drillReach(${w})`));
@@ -171,6 +178,96 @@ check('a mold and what sets inside it stay safe from the drill', run(`(() => {
for (const i of moldCellsAt(mo.kind, mo.x, mo.y).walls) if (crumbly(i)) return false; for (const i of moldCellsAt(mo.kind, mo.x, mo.y).walls) if (crumbly(i)) return false;
return true; return true;
})()`)); })()`));
// No gravity or kicks here: measure absorption through a fixed thickness, beyond normal reach
// for the 115-grain case. All real medium rays still cross the powder before reaching basalt.
function rubbleShield(thickness) {
flatWorld(run, 240, 160, 150);
return run(`(() => {
RS = 12345; tick = 0; lastPulseTick = -99; drillPulses = []; player.bitTier = 3;
bot.x = 10; bot.y = 76;
for (let y = 10; y < 145; y++) {
for (let x = 20; x < 20 + ${thickness}; x++) setCell(y*W+x, RSTONE, 20, 0);
for (let x = 20 + ${thickness}; x < 220; x++) setCell(y*W+x, BASALT, 20, F_NAT);
}
const before = stats.dug;
for (let t = 0; t < 240; t++) {
tick++; tickStamp = tickStamp % 255 + 1; stamp.fill(tickStamp);
toolPulse(219, 78, 1, 0);
if (${thickness} > 46) for (const p of drillPulses) p.range = 180;
pulseTick();
}
return stats.dug - before;
})()`);
}
const through35 = rubbleShield(35), through115 = rubbleShield(115);
check('steel medium pulses shatter basalt behind 35 grains of rubble', through35 > 0, `${through35} basalt opened`);
check('115 grains of rubble prevent basalt breaking during sustained drilling', through115 === 0, `${through115} basalt opened`);
// 1.4 * 0.97^distance crosses 0.04 at about 117 grains; check actual extinction too.
const through118 = rubbleShield(118);
check('118 grains extinguish the ray before it can even shake basalt', through118 === 0 && !run('digp.some(d => d > 0)'), `${through118} basalt opened`);
// Absorption over a fixed distance, isolated from gravity, kicks and cell breakage.
function transmission(material, distance, step, tune = 0) {
flatWorld(run, 240, 160, 150);
return run(`(() => {
bot.x = 0; bot.y = 0; player.bitTier = 0; stamp.fill(tickStamp);
for (let y = 70; y < 90; y++) for (let x = 10; x < 180; x++) setCell(y*W+x, ${material}, 20, F_NAT);
const p = {ox:20,oy:80.5,ang:0,w:1,jit:0.5,n:1,damp:PULSE_W[1].damp,
en:new Float32Array([1.4]),s:new Float32Array(1),r:200,range:${distance},tune:${tune}};
drillPulses = [p]; pulseTick(${step}); return p.en[0];
})()`);
}
for (const [m, distance, factor, tune] of [
['RSTONE',35,0.97,0], ['SAND',35,0.97,0], ['FEORE',35,0.97,0], ['COAL',35,0.97,0],
['WATER',10,0.985**2,0], ['GRANITE',1,0.35**2,0], ['STONE',10,0.993**2,'CUORE'],
['STONE',1,0.69,0], ['STONE',1,0.96**2,'RSTONE'], ['BELTL',1,0.25**2,0],
]) {
const energies = [0.25,0.5,1].map(step => transmission(m,distance,step,tune));
const expected = 1.4 * factor ** distance;
check(`${m} attenuation counts distance at quarter, half and whole-grain steps (tune ${tune})`,
energies.every(e => Math.abs(e-expected) < 0.00001), energies.map(e=>e.toFixed(5)).join(' / '));
}
// A settled heap must slump further than an undrilled control, conserving all its grains.
function shakenPile(pulse) {
flatWorld(run,240,160,110);
run(`RS=12345; tick=0; lastPulseTick=-99; drillPulses=[]; player.bitTier=3; bot.x=40; bot.y=88;
for(let x=65;x<116;x++) for(let y=85+Math.abs(x-90);y<110;y++) setCell(y*W+x,RSTONE,20,0);
for(let t=0;t<240;t++) simTick();`);
const height = () => run(`(() => {let top=110;for(let i=0;i<110*W;i++) if(mat[i]===RSTONE) top=Math.min(top,(i/W)|0);return 110-top;})()`);
const before=height(), grains=count(run,run('RSTONE'));
run(`for(let t=0;t<480;t++) {${pulse?'toolPulse(90,94,1,0);':''}simTick();}`);
return {before,after:height(),grains,left:count(run,run('RSTONE'))};
}
const restingPile=shakenPile(false), fluidPile=shakenPile(true);
check('pulses fluidise a settled rubble pile so its height drops',
fluidPile.after <= fluidPile.before-3 && fluidPile.after < restingPile.after-3,
`resting ${restingPile.before}->${restingPile.after}, shaken ${fluidPile.before}->${fluidPile.after}`);
check('shaking conserves the pile material',fluidPile.grains===fluidPile.left,`${fluidPile.grains}->${fluidPile.left}`);
check('repeating a seeded pile gives the same result',JSON.stringify(shakenPile(true))===JSON.stringify(fluidPile));
// One artificially long ray crosses many exposed grains in a single tick, exercising the caps.
flatWorld(run,240,160,150);
const kicks = run(`(() => {
RS=12345; bot.x=0;bot.y=0;tickStamp=1; EV.length=0;
for(let x=20;x<120;x+=2) setCell(80*W+x,RSTONE,20,0);
// Neither a covered grain nor a grain already moved this tick may be kicked.
occ[80*W+20]=1; part[80*W+22]=P_PIPE; stamp[80*W+24]=tickStamp;
// An open but owned destination and one stamped this tick are also off limits.
part[80*W+25]=P_PIPE; occ[80*W+27]=1; stamp[80*W+29]=tickStamp;
const p={ox:19.5,oy:80.5,ang:0,w:1,jit:0.5,n:8,damp:PULSE_W[1].damp,
en:new Float32Array(8).fill(1.4),s:new Float32Array(8),r:200,range:100,tune:0};
// Zero spread gives eight overlapping rays, testing the per-tick dust cap as well.
const half=PULSE_W[1].half,mouth=PULSE_W[1].mouth; PULSE_W[1].half=0;PULSE_W[1].mouth=0;
drillPulses=[p]; pulseTick(); PULSE_W[1].half=half;PULSE_W[1].mouth=mouth;
let moved=0; for(let i=0;i<N;i++) if(mat[i]===RSTONE && stamp[i]===tickStamp) moved++;
return {moved:moved-1,dust:EV.filter(e=>e.t==='debris').length,
protected:mat[80*W+20]===RSTONE && mat[80*W+22]===RSTONE && mat[80*W+24]===RSTONE,
blocked:mat[80*W+25]===EMPTY && mat[80*W+27]===EMPTY && mat[80*W+29]===EMPTY};
})()`);
check('kicks are bounded to three per ray and dust to four puffs per tick',kicks.moved>0 && kicks.moved<=24 && kicks.dust>0 && kicks.dust<=4,JSON.stringify(kicks));
check('shaking respects occupied, owned and stamped cells',kicks.protected && kicks.blocked,JSON.stringify(kicks));
// Resonance ping (t50): finds natural veins within reach of the bit, under fog too, and // Resonance ping (t50): finds natural veins within reach of the bit, under fog too, and
// nothing else. // nothing else.
flatWorld(run, 240, 160, 150); flatWorld(run, 240, 160, 150);