diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 11c0455..23c4b1f 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -542,6 +542,12 @@ above, including: flow, pockets drain), with a little dust. All damping is counted per grain travelled (attenuation is per distance in real physics), retuned so rock bores feel the same as before. +- **Pulses stream loose grains back toward the bot (t92 follow-up, 2026-10-28).** Owner + playtesting found that surface bouncing did not open a channel. Keep shaking the travelled + ray path while the pulse is in flight, bias short hops back toward the bot, and keep sideways + and upward escapes when blocked. Grains fall normally after each hop. Dust must show as a few + soft puffs where grains move; the previous rubble-coloured debris squares were not visible. + ## Ideas not built yet - **Concrete**, as a stronger wall material from rubble. diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 392f6ea..94a9d4e 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -327,7 +327,7 @@ Tool helpers: reach, brushes, and plain digging. - functions: setBuildRange, inBuildRange, inReach, forBrush, drillReach, inDrillReach, toolDig - variables: BUILD_MAX, buildBox -### src/sim/drill.js (187 lines) +### src/sim/drill.js (202 lines) The resonance drill: tuned pulses that shatter one material. - 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 @@ -482,7 +482,7 @@ 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 (384 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 - variables: PING_SPEED, PING_HOLD, PING_FADE, PING_BANDS, PING_GLINTS, GLINT_MS diff --git a/docs/agent-log.md b/docs/agent-log.md index e3820ce..f30461f 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -4,6 +4,46 @@ 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-28: t92 follow-up, visible dust and a botward channel through rubble (Codex) + +- Traced shake events through `drainEvents` to `spawnDebris` and `drawParticles`. Events were + handled above the world, but rendered only two opaque, rubble-coloured 0.7-grain squares. + Added a dust flag to the existing debris event/particle pipeline: one pale radial puff, + gently rising/expanding and fading over 36 frames. Retained particles setting and particle + limit; at most four shake dust events per tick. Normal shatter debris/sparks unchanged. +- Kicks previously visited only the advancing 2.2-grain wave front, with random sideways/up + directions. Now each in-flight ray keeps shaking its travelled path, with local energy + attenuated through powder/liquid; stops at solid. Tries the reverse ray first, then lift and + side escapes. Retains seeded rnd(), one-grain move/enter hops, occupied/part/stamp guards, + sealed corners and three moves/ray/tick; gravity settles grains normally. No damping retune. +- Added steel-medium 35-grain-deep pile/rock regression and gravity-only control. At 480 ticks: + 33/35 centre-line cells empty versus 9/35; rubble centre x 72 -> 56.168 versus 70.68; + all 875 rubble grains retained. The original pulse code gives only 10/35 empty cells and + centre x 68.341, failing channel, transport, dust and travelled-path checks. An isolated + advanced-front ray checks interior botward movement; existing movement/dust caps and guards, + attenuation, repeatability and heap-fluidising tests retained. New browser check exercises + event -> visible soft pixels, expiry and particles-off. +- Stone depth/opened cells against original t92 baseline: narrow 22/181 -> 24/195, + medium 13/186 -> 13/198, wide 6/134 -> 6/133; all within 10%, including depth. + Small 480-tick pile benchmark median, five measured samples after warmup: 24.18 -> 33.88 ms + (about 0.02 ms/tick extra). This is a small fixture, not a whole-game performance profile. +- Full `python3 test/run_all.py --browser` exited 0, ALL PASSED: 32 headless suites and + gameplay 361, smooth 28, console 18, caps 7, GPU 15, goldens 28, light 71, Firefox 2. + Final pile fixture was moved into the real steel reach and its conservation assertion added + after that run; the final `python3 test/run_all.py` passes all 32 suites, drill 66 checks. + Inspected final `shots/t92b-channel.png` and `shots/t92b-channel-smooth.png`: lowered + channel, basalt notch and soft dust over the streaming rubble. First recapture timed out + starting the world; host-GPU recaptures passed. Caps confirmed NVIDIA Blackwell; no claim + that the timeout was caused by unavailable hardware. Screenshot setup: `/tmp/t92b-channel.js`, + `tools/shot.py --setup /tmp/t92b-channel.js --zoom 8 --no-light --freeze` (add smooth look). + Evidence: `/tmp/t92b-red-final.log`, `/tmp/t92b-drill.log`, `/tmp/t92b-all-tests.log`, + `/tmp/t92b-headless-final.log`. No page errors on final captures. +- Files: `src/sim/drill.js`, `src/render/overlays.js`, `src/ui/events.js`, `test/t_drill.js`, + `test/t_gpu.py`, generated `docs/PROJECT_MAP.md`, owner decision in `docs/DESIGN.md`, + t92 tracking and this log. Both feedback items ticked; t92 moved to Review. No commit. + Owner playtesting and performance in a large active world remain unverified. + ## 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 diff --git a/src/render/overlays.js b/src/render/overlays.js index 7a1df2b..96a8e8a 100644 --- a/src/render/overlays.js +++ b/src/render/overlays.js @@ -331,8 +331,13 @@ function drawBot() { ctx.beginPath(); ctx.moveTo(cx - bot.face * zp, sy + zp * 0.6 + bob); ctx.lineTo(cx - bot.face * zp * 1.6, sy - zp * 0.6 + bob); ctx.stroke(); ctx.restore(); } -function spawnDebris(x, y, m) { +function spawnDebris(x, y, m, dust = false) { if (!SET.particles || R.particles.length > 400) return; + if (dust) { + R.particles.push({ x: x + 0.5, y: y + 0.5, vx: (Math.random() - 0.5) * 0.12, + vy: -0.06 - Math.random() * 0.06, life: 36, r: 205, g: 190, b: 165, dust: true }); + return; + } for (let k = 0; k < 2; k++) R.particles.push({ x: x + Math.random(), y: y + Math.random(), vx: (Math.random() - 0.5) * 0.5, vy: -Math.random() * 0.5, life: 20 + Math.random() * 20, r: COLR[m], g: COLG[m], b: COLB[m] }); } function spawnSpark(x, y) { @@ -358,11 +363,20 @@ function drawParticles() { p.x += dx / d * sp; p.y += dy / d * sp; p.life--; if (p.life <= 0) { ps.splice(k, 1); continue; } } else { - p.x += p.vx; p.y += p.vy; p.vy += 0.04; p.life--; + p.x += p.vx; p.y += p.vy; p.vy += p.dust ? 0 : 0.04; p.life--; if (p.life <= 0) { ps.splice(k, 1); continue; } } if (SET.fog && fogSeen(p.x, p.y) < 64) continue; const s = worldToScreenPx(p.x, p.y); + if (p.dust) { + const radius = zp * (1 + (36 - p.life) * 0.035); + const puff = ctx.createRadialGradient(s.x, s.y, 0, s.x, s.y, radius); + puff.addColorStop(0, `rgba(${p.r},${p.g},${p.b},${0.32 * p.life / 36})`); + puff.addColorStop(1, `rgba(${p.r},${p.g},${p.b},0)`); + ctx.fillStyle = puff; + ctx.fillRect(s.x - radius, s.y - radius, radius * 2, radius * 2); + continue; + } ctx.fillStyle = `rgba(${p.r | 0},${p.g | 0},${p.b | 0},${Math.min(1, p.life / 10)})`; const sz = p.spark ? Math.max(1, zp * 0.5) : p.suck ? zp : Math.max(1, zp * 0.7); ctx.fillRect(s.x, s.y, sz, sz); diff --git a/src/sim/drill.js b/src/sim/drill.js index 28fc003..8e8d789 100644 --- a/src/sim/drill.js +++ b/src/sim/drill.js @@ -115,7 +115,38 @@ function pulseTick(step = 0.5) { let e = p.en[k]; 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; - let sd = p.s[k], kicks = 0; + // Keep shaking the travelled path while the wave advances, including the pile interior. + let shake = Wd.en, kicks = 0; + for (let d = 0.5; d < lim && shake >= 0.04 && kicks < 3; d++) { + const x = Math.floor(ray.x + ux * d), y = Math.floor(ray.y + uy * d); + if (x < 0 || y < 0 || x >= W || y >= H) break; + if (x >= bx0 && x <= bx1 && y >= by0 && y <= by1) continue; + const i = y * W + x, m = mat[i], ph = PH[m]; + if (ph === PH_SOLID) break; + if (ph === PH_LIQUID) { shake *= 0.970225; continue; } + if (ph !== PH_POWDER) continue; + shake *= 0.97; + if (stamp[i] !== tickStamp && !occ[i] && !part[i] && rnd() < 0.55 * shake) { + // Lead with the reverse ray; blocked grains can lift or spill to either side. + const backX = Math.abs(ux) > 0.38 ? -Math.sign(ux) : 0; + const backY = Math.abs(uy) > 0.38 ? -Math.sign(uy) : 0; + const side = rnd() < 0.5 ? -1 : 1; + for (let hop = 0; hop < 5; hop++) { + const mx = hop < 2 ? backX : hop < 4 ? side : -side; + const my = hop === 0 ? backY : hop === 1 ? -1 : -(hop & 1); + const nx = x + mx, ny = y + my; + if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue; + const j = ny * W + nx; + 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 + my * W]] === PH_SOLID) continue; + move(i, j, x, y, nx, ny); kicks++; + if (dust < 4) { emit({ t: 'debris', x, y, m, dust: true }); dust++; } + break; + } + } + } + let sd = p.s[k]; while (sd < lim && e >= 0.04) { sd += step; const x = Math.floor(ray.x + ux * sd), y = Math.floor(ray.y + uy * sd); @@ -130,22 +161,6 @@ function pulseTick(step = 0.5) { if (ph === PH_LIQUID) { e *= liquid; continue; } if (ph === PH_POWDER) { e *= loose; - // At most three one-cell hops per ray/tick; gravity settles the shaken grains. - if (kicks < 3 && stamp[i] !== tickStamp && !occ[i] && !part[i] && rnd() < 1.1 * e * step) { - const side = rnd() < 0.5 ? -1 : 1; - for (let hop = 0; hop < 4; hop++) { - const mx = hop < 2 ? side : -side, my = -(hop & 1); - const nx = x + mx, ny = y + my; - if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue; - const j = ny * W + nx; - 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; } if (builtCell(i, x, y)) { e *= built; continue; } diff --git a/src/ui/events.js b/src/ui/events.js index d8baee2..d106fa6 100644 --- a/src/ui/events.js +++ b/src/ui/events.js @@ -17,7 +17,7 @@ function drainEvents() { showCard({ kind: e.kind === 'bit' ? 'Drill bit upgraded' : 'Bag upgraded', title: e.kind === 'bit' ? BIT_TIERS[e.tier].name : TANK_TIERS[e.tier].name, accent: 'milestone', body: [e.kind === 'bit' ? `Digs hardness ${BIT_TIERS[e.tier].hard} and below.` : `Holds ${TANK_TIERS[e.tier].cap.toLocaleString('en-US')} grains.`] }); hudDirty = true; - } else if (e.t === 'debris') spawnDebris(e.x, e.y, e.m); + } else if (e.t === 'debris') spawnDebris(e.x, e.y, e.m, e.dust); else if (e.t === 'suck') spawnSuck(e.x, e.y, e.m); else if (e.t === 'warn') spawnFloater(e.x, e.y, e.text, '#ff8a72'); else if (e.t === 'sold') spawnFloater(e.x, e.y, '+' + fmtMoney(e.v)); diff --git a/test/t_drill.js b/test/t_drill.js index 435bf07..811b6ac 100644 --- a/test/t_drill.js +++ b/test/t_drill.js @@ -206,6 +206,35 @@ check('115 grains of rubble prevent basalt breaking during sustained drilling', 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`); +// A 35-grain-deep pile against basalt: compare with gravity alone, tracking only rubble. +function channelPile(fire) { + flatWorld(run, 240, 160, 110); + return run(`(() => { + RS=12345; tick=0; lastPulseTick=-99; drillPulses=[]; player.bitTier=3; + bot.x=45; bot.y=88; EV.length=0; + for(let y=85;y<110;y++) { + for(let x=55;x<90;x++) setCell(y*W+x,RSTONE,20,0); + for(let x=90;x<150;x++) setCell(y*W+x,BASALT,20,F_NAT); + } + const measure=()=>{let n=0,sx=0,empty=0;for(let i=0;ie.t==='debris' && e.dust).length; EV.length=0; + } + return {before,after:measure(),dust}; + })()`); +} +const channelRest=channelPile(false), channel=channelPile(true); +check('medium pulses open most of the centre ray through a 35-grain pile within 480 ticks', + channel.after.empty>=28 && channel.after.empty>channelRest.after.empty+15,JSON.stringify(channel)); +check('the pile streams back toward the bot rather than just bouncing on its surface', + channel.after.cx${channel.after.n}`); +check('shaking loose grains emits dust puffs',channel.dust>0,`${channel.dust} puffs`); + // Absorption over a fixed distance, isolated from gravity, kicks and cell breakage. function transmission(material, distance, step, tune = 0) { flatWorld(run, 240, 160, 150); @@ -246,6 +275,18 @@ check('pulses fluidise a settled rubble pile so its height drops', 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)); +// A wave must still shake loose grains far behind its advancing front, throughout the path. +flatWorld(run,240,160,150); +const travelled=run(`(() => { + RS=12345; bot.x=0;bot.y=0;tickStamp=1;EV.length=0; + for(const x of [25,45,65]) setCell(80*W+x,RSTONE,20,0); + const p={ox:19.5,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([60]),r:60,range:100,tune:0}; + drillPulses=[p];pulseTick(); + return [25,45,65].filter(x=>mat[80*W+x-1]===RSTONE).length; +})()`); +check('the travelled ray path pushes interior grains one cell toward the bot',travelled>=2,`${travelled}/3 botward hops (probabilistic kicks)`); + // One artificially long ray crosses many exposed grains in a single tick, exercising the caps. flatWorld(run,240,160,150); const kicks = run(`(() => { diff --git a/test/t_gpu.py b/test/t_gpu.py index e84022e..f6ba2d6 100644 --- a/test/t_gpu.py +++ b/test/t_gpu.py @@ -22,6 +22,19 @@ with launch(viewport={'width':960,'height':600}) as (_,pg,url): }""") assert startup['warm']>=0 print('PASS both looks submit behind loading screen; first visible frame ms',startup) + dust=pg.evaluate("""()=>{ + const particles=SET.particles,fogOn=SET.fog;SET.particles=true;SET.fog=false; + R.particles=[];EV.length=0;emit({t:'debris',x:bot.x,y:bot.y,m:RSTONE,dust:true});drainEvents(); + const spawned=R.particles.length===1 && R.particles[0].dust===true; + R.ctx.clearRect(0,0,R.canvas.width,R.canvas.height);drawParticles(); + const data=R.ctx.getImageData(0,0,R.canvas.width,R.canvas.height).data; + let visible=0,soft=0;for(let i=3;i4 and dust['soft']==dust['visible'] and dust['expired'] and dust['disabled'],dust + print('PASS pulse dust events draw a visible soft puff, expire and respect particles setting',dust) pg.add_script_tag(path=str(root/'tools/perf-scenes.js')) pg.evaluate("async()=>{perfScene('factory',1024,640);await startThreads();simPool.force=true;SET.look='pixel';SET.zoom=2;resizeRender();snapCamera();drawWorld();}") result=pg.evaluate('''async () => {