// THE TRIAGE DRILL — the cognitive mode, fourth design, and the first one built on a SKILL. // Settled in discussion after Oliver's verdict on the sequence version ("it feels not interesting // enough to keep me playing"): the called order tested nothing — the strip was always visible so // there was no memory, the batch was slow so there was no urgency, and what remained was a checklist. // // ONE RULE NOW: **always kill the body that would reach you first.** Nothing is called, nothing is // shown — the court is the puzzle. Types walk at different speeds from different depths, so the // answer is NOT the closest body: a runner at the far baseline beats a roller at the service line, // and every kill (and every new arrival) re-asks the question. That is court sense — reading speeds // and distances under pressure — which is both the most tennis-flavoured skill available and // exactly the target-priority read the gore mode rewards. // // THE HOOK is the streak: correct kills build a multiplier, a wrong kill breaks it, and the score is // what you come back to beat (best kept in localStorage; the world board can carry it later if the // mode ships). Two subtleties that make it a game rather than a quiz: // - WOUNDS ARE FREE. Only the KILL is judged, so pre-softening a roller while it is still third // in line is legal and eventually necessary — the finishing blow is what must land in turn. // - THE SLACK. Two bodies arriving within TUNE.drillSlack of each other are BOTH legal kills. // Without it, near-simultaneous arrivals made the rule a coin flip; with it, only a genuine // misread faults. // // THE MODE TRANSITIONS ARE NOT HERE — startDrill/endDrill live in beats.js with every other one // (CLAUDE.md rule 3). This file is the stream, the urgency rule and the scoring. import { TUNE, COURT, UI } from './config.js'; import { G, ball } from './state.js'; import { zombies, spawnZombie } from './zombies.js'; import { voice } from './audio.js'; import { showModal } from './gameplay.js'; // runtime-only cycle (gameplay imports drill), same pattern as beats // The roster in the stream, weighted: shamblers are the crowd, heavies are events. vMul is the // drill's own speed character (deliberately narrower than the roster's 3.2x..0.45x so the queue // stays coherent); `pts` is what a kill banks BEFORE the streak multiplier — roughly the effort it // took, so a roller is worth chasing and a shambler is volume. export const DRILL_TYPES = [ { v: 'a', name: 'SHAMBLER', vMul: 1.00, w: 5, pts: 20 }, { v: 'b', name: 'SHAMBLER', vMul: 1.00, w: 5, pts: 20 }, { v: 'f', name: 'RUNNER', vMul: 1.45, w: 2, pts: 60 }, { v: 'e', name: 'BALL BOY', vMul: 1.10, w: 3, pts: 40 }, { v: 'd', name: 'STEWARD', vMul: 1.00, w: 3, pts: 60 }, { v: 'h', name: 'BOUNCER', vMul: 0.90, w: 2, pts: 70 }, { v: 'g', name: 'BRUTE', vMul: 0.80, w: 2, pts: 80 }, { v: 'k', name: 'ROLLER', vMul: 0.70, w: 1, pts: 100 }, ]; // ---- THE RAMP. One number, 0..1 off time survived. Spawn gap and walk speed ride it; the streak // multiplier does NOT (skill should pay the same at minute one as at minute five). const ramp = d => Math.min(1, d.t / TUNE.drillRampT); const lerp = (a, b, k) => a + (b - a) * k; export const drillSpeed = d => lerp(TUNE.drillSpeed, TUNE.drillSpeedMax, ramp(d)); const spawnGap = d => lerp(TUNE.drillSpawnEvery, TUNE.drillSpawnMin, ramp(d)); // ---- THE URGENCY RULE, and it is the whole mode. Seconds until this body crosses your baseline at // its own pace down its own lane (drill bodies hold their lane, so this is honest — nothing swerves). export const arrival = zb => (COURT.L - zb.z) / Math.max(0.01, zb.v); // The queue, most urgent first. Exported: render marks the legal set, tests assert against it. export const drillQueue = () => zombies.filter(z => z.drill).sort((a, b) => arrival(a) - arrival(b)); // EVERY legal kill right now — the head plus anything within the slack of it. THE MARKER MUST DRAW // THIS SET, not the bare head: the rule accepts any near-tie, so a marker showing only q[0] was // STRICTER than the rule it explained, and every kill made it jump between tied bodies in a way // that read as the rule changing mid-flight. (With lanes held and speeds constant, arrival // differences never change while bodies walk — the queue only reorders on a kill or an arrival, so // marking the whole legal set is stable by construction.) export const drillLegal = () => { const q = drillQueue(); if (!q.length) return q; const t0 = arrival(q[0]); return q.filter(z => arrival(z) <= t0 + TUNE.drillSlack); }; function pickType() { const pool = DRILL_TYPES.flatMap(t => Array(t.w).fill(t)); return pool[Math.floor(Math.random() * pool.length)]; } // ---- THE STREAM. One body at a time, spaced (best-of-9 lanes against the back half — a retry loop // would stall the stream exactly when it should be getting harder), holding its lane all the way in. function spawnDrillBody(d) { if (zombies.filter(z => z.drill).length >= TUNE.drillMaxOnCourt) return null; const near = zombies.filter(z => z.drill && z.z < TUNE.drillNearZ); let bx = 0, bs = -1; for (let k = 0; k < 9; k++) { const x = -TUNE.drillSpread + (2 * TUNE.drillSpread) * (k / 8) + (Math.random() - 0.5) * 0.5; const s = near.length ? Math.min(...near.map(z => Math.abs(z.x - x))) : 1e9; if (s > bs) { bs = s; bx = x; } } const t = pickType(); spawnZombie(bx, TUNE.drillSpawnZ + Math.random() * 0.8, t.v); const zb = zombies[zombies.length - 1]; if (TUNE.drillOneShot >= 0.5) zb.hp = zb.hpMax = 1; // the testing switch, normally off zb.drill = true; // holds its lane (see updateZombies) — the lane is what makes // arrival() honest zb.pts = t.pts; // stamped at spawn, never re-read: the ramp arrives as new bodies outpacing old ones, not as the // court accelerating under you — and a constant v is what lets arrival() be a plain division zb.v = drillSpeed(d) * t.vMul * (0.92 + Math.random() * 0.16); return zb; } export function startDrillStream() { const d = G.drill; if (!d) return; zombies.length = 0; // The rule gets ONE sentence, once per run, on the sanctioned calm court (the seeded bodies are // all at the far baseline — nothing is near). Oliver's worry is right: "most urgent first" is NOT // guessable from a chevron alone, and this is the modal policy's exact use case. if (!G.seen.drillRule) { G.seen.drillRule = true; showModal({ title: 'CLEARANCE DRILL', color: UI.pick, body: ['DROP WHOEVER WOULD REACH YOU FIRST', 'FAST BEATS CLOSE · STREAKS PAY'] }); } d.t = 0; d.spawnT = 0; d.score = 0; d.streak = 0; d.kills = 0; d.faults = 0; d.lost = 0; d.msg = null; try { d.best = Number(localStorage.getItem('drillBest')) || 0; } catch { d.best = 0; } // seed a small spread so there is a queue to read from the first second for (let i = 0; i < TUNE.drillSeed; i++) { const zb = spawnDrillBody(d); if (zb) zb.z += i * TUNE.drillSeedGap; } } export function updateDrill(dt) { const d = G.drill; if (!d) return; d.t += dt; if (d.msg) d.msg.t -= dt; if ((d.spawnT -= dt) <= 0) { spawnDrillBody(d); d.spawnT = spawnGap(d) * (0.8 + Math.random() * 0.4); } } // The streak's payout curve: x1 x2 x3 x4 x5, capped — deep streaks should feel valuable without a // single fault deleting a four-digit number, which reads as theft rather than tension. export const drillMult = d => Math.min(TUNE.drillMultCap, 1 + Math.floor(d.streak / TUNE.drillMultEvery)); function bankBest(d) { if (d.score <= d.best) return; d.best = d.score; try { localStorage.setItem('drillBest', String(d.best)); } catch {} } // A drill body went down — judge the kill. Called from killZombie (the single kill funnel, so no // weapon path can miss it), AFTER the splice, so the queue we judge against is the court as it now // stands. Only the kill is judged: wound() never lands here, which is what makes pre-softening legal. export function drillKill(zb) { const d = G.drill; if (!d || !zb.drill) return; const q = drillQueue(); // zb is already spliced out — this is who is LEFT const t0 = arrival(zb); const legal = !q.length || t0 <= arrival(q[0]) + TUNE.drillSlack; if (legal) { d.streak++; d.kills++; d.score += (zb.pts || 20) * drillMult(d); bankBest(d); return; } // A misread: something out there was going to arrive first and you spent the shot here. The // streak IS the punishment — no round to lose, no court sweep, the queue just keeps walking. d.faults++; d.streak = 0; voice('fault', 0.1); d.msg = { text: 'OUT OF TURN', t: 1.3, bad: true }; } // A body reached you — by contact or by walking its lane past your baseline. The one real setback: // court swept, streak dead, ramp handed back a little. Score survives; the run has no end. export function drillCaught(text = 'CAUGHT') { const d = G.drill; d.lost++; d.streak = 0; voice('game_opponent', 0.1); d.msg = { text, t: 1.8, bad: true }; zombies.length = 0; ball.active = false; // the court refills up-court; a live ball would meet it d.spawnT = 0; d.t = Math.max(0, d.t - TUNE.drillCaughtBack); }