From 6a16e774809904ed1261ff0461663c4c2455291c Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Tue, 11 Aug 2026 21:32:17 +0200 Subject: [PATCH] Triage: always kill the body that would reach you first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sequence drill tested nothing — the strip was always visible (no memory), the batch was slow (no urgency), the aim was easy (no execution). A checklist with gore, and Oliver said so: "not interesting enough to keep me playing". Settled in discussion, he picked triage + streak. One rule: always kill the body that would reach you first. Nothing is called, nothing shown — the court is the puzzle. arrival = distance / speed down a held lane, and the roster's speed spread means the answer is not the closest body: a runner at the far baseline beats a roller at the service line. Court sense — and it trains the exact target-priority read the gore mode rewards. Wounds are free: only the kill is judged, so pre-softening a heavy while it is third in line is legal and eventually necessary. The slack (0.9s) makes near-ties legal both ways, or the rule is a coin flip. The streak is both punishment and payout: x1..x5, a misread shows OUT OF TURN and resets it, and nothing else happens — the queue keeps walking. Caught is the real setback. Best score banks to localStorage; the world board can carry it if it ships. drillMark draws a chevron over the queue head for playtest calibration. It answers the exact question the mode asks, so it must be off in anything shipped. Deleted: the alphabet, the batch, the strip, the eaters. Shamblers are first-class stream members with pts 20; a roller is 100. 30 tests, rewritten around arithmetic riggings (known speeds and distances) instead of luck; 20 seeds clean. Co-Authored-By: Claude Opus 5 --- PROJECT.md | 32 +++++ index.html | 2 +- src/beats.js | 2 +- src/config.js | 60 ++++----- src/drill.js | 269 +++++++++++++++------------------------- src/render.js | 64 +++++----- src/state.js | 7 +- test.js | 336 ++++++++++++++++++-------------------------------- 8 files changed, 313 insertions(+), 459 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index 23d5425..a5eed6e 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -762,6 +762,38 @@ drill-only HP scale is the knob, not a change to the roster. deleted and rotting, and the never-a-fault rule is now tested with a hand-placed body so it holds either way. That is the shape to copy for any other switchable subsystem here. +**ELEVENTH PASS — TRIAGE: the drill finally gets a skill.** Oliver's verdict on the sequence +version was the honest one — *"it feels not interesting enough to keep me playing.. I am really +looking for some mechanic to make this skill based"* — and the diagnosis was that it tested +nothing: the strip was always visible (no memory), the batch was slow (no urgency), the aim was +easy (no execution). A checklist with gore. Settled in discussion: options were hide-the-call +(memory), triage (judgment), clock+streak (score); he picked **triage + streak**. + +**ONE RULE: always kill the body that would reach you first.** Nothing is called, nothing shown — +the court is the puzzle. `arrival(zb) = (COURT.L - zb.z) / zb.v` down a HELD lane (that is why +drill bodies hold lanes — it makes the projection honest), and the roster's speed spread means the +answer is not the closest body: a runner at the far baseline beats a roller at the service line. +This is court sense, the most tennis-flavoured skill there is, and it trains the exact +target-priority read the gore mode rewards. The strip, the alphabet, the batch and the eaters are +all deleted — shamblers are first-class stream members now, `pts` 20 up to a roller's 100. + +The three design points that make it a game rather than a quiz: +- **WOUNDS ARE FREE.** Only the KILL is judged (drillKill sits behind the kill funnel, wound() + never reaches it), so pre-softening a heavy while it is third in line is legal and eventually + necessary. This is also why `drillOneShot` must stay off: 1 hp bodies delete the planning layer. +- **THE SLACK** (`drillSlack` 0.9 s): two bodies arriving within it are both legal. Without it a + near-tie made the rule a coin flip and a fault read as theft. Tightening it is the difficulty + curve for good players. +- **THE STREAK IS BOTH PUNISHMENT AND PAYOUT.** x1..x5 (`drillMultEvery` 4, `drillMultCap` 5), a + misread resets it and shows OUT OF TURN, and nothing else happens — no sweep, no lost round; the + queue keeps walking. Being CAUGHT is the real setback (sweep + ramp handback), score survives. + Best is banked in localStorage (`drillBest`); the world board can carry it if the mode ships. + +**`TUNE.drillMark` (ON for the playtest): a chevron over the queue head.** It exists so Oliver can +check the game's read against his own — and it answers the exact question the mode asks, so it MUST +be off in anything shipped. If his read and the marker disagree often, tune `drillSlack` before +touching `arrival`. + **Open, and every one of them is Oliver's call after he plays it:** 1. ~~**A ball that carries through the lane can complete two steps at once.**~~ Largely CLOSED by the slice removal (seventh pass) — a cut ball picking its own next victim was the main way this diff --git a/index.html b/index.html index 1c7e8ac..8e4cabc 100644 --- a/index.html +++ b/index.html @@ -399,6 +399,6 @@ - + diff --git a/src/beats.js b/src/beats.js index 6fb9674..3471a62 100644 --- a/src/beats.js +++ b/src/beats.js @@ -136,7 +136,7 @@ export function endBossFight() { // clock and rules live in drill.js; only the transition is here, where the rule can be enforced. export function startDrill() { if (G.drill || G.interlude || G.horde || G.bossFight) return false; - G.drill = { seq: [], step: 0, faults: 0, t: 0, spawnT: 0, rounds: 0, lost: 0, msg: null }; + G.drill = { t: 0, spawnT: 0, score: 0, streak: 0, kills: 0, faults: 0, lost: 0, best: 0, msg: null }; G.changeover = false; G.clearing = false; G.afterClear = null; // ...and DISARM the ordinary schedule. Taking the court is not enough on its own: a pending // horde, an owed chapter or a due duel is a latch that keeps trying to fire behind the drill, diff --git a/src/config.js b/src/config.js index 1abed20..f245e1b 100644 --- a/src/config.js +++ b/src/config.js @@ -345,42 +345,36 @@ export const TUNE = { barrelRoll: 2.6, // m/s it trundles across the far court — fast enough to have to be led barrelHp: 1, // anything pops it: ball, pellet, bomb, flame, swish. It is a target, not a body barrelH: 1.15, // drawn height (a body is 1.8) - // ---- THE COACHING DRILL (src/drill.js) — the cognitive mode. THE SEQUENCE IS THE ZOMBIES - // THEMSELVES: a BATCH of distinct roster types is born on the far side and the combination is - // called off them at once, so everything the strip asks for is already in front of you. There are - // no colours, signs or markers anywhere — a brute does not look like a runner, and that is the - // whole point. The danger is a separate channel: plain shambler EATERS on their own clock, never - // in the alphabet, converging on you the way ordinary shamblers do. - drillBatch: 3, // TYPES in a batch at the bottom of the ramp... - drillBatchMax: 6, // ...and at the top. Capped by DRILL_TYPES — one of each type on court at most - drillBatchDepth: 3.0, // m of depth a batch is staggered over. All of it still on the far side, - // which is the promise: you see every colour before you are asked for it + // ---- THE TRIAGE DRILL (src/drill.js) — the cognitive mode, fourth design. ONE RULE: always + // kill the body that would reach you first. Nothing is called and nothing is shown — the court is + // the puzzle, and the skill is reading speeds against distances (a runner at the far baseline + // beats a roller at the service line). The streak multiplier is the hook; the score is what you + // come back to beat. + drillMaxOnCourt: 7, // bodies on court at once — the queue you are reading. Past ~7 it is noise + drillSeed: 4, // bodies placed at the start so there is a queue to read from the first second + drillSeedGap: 2.6,// m of depth between those seeded bodies drillSpawnZ: 0.6, // z they enter at (the far baseline is 0) + drillNearZ: 9.0, // spawn spacing is measured against bodies nearer than this drillSpread: 3.4, // m either side of the centre line the lanes fan across - drillSpeed: 0.80, // m/s a batch body walks at the bottom of the ramp (x its type's vMul)... - drillSpeedMax: 1.9,// ...and at the top. Stamped per body AT SPAWN, so the ramp shows up as new - // arrivals outpacing the ones already walking, not the court accelerating - // The eaters are RARE on purpose. At every 7 s / 2.2 s they were a second crowd rather than an - // interruption, and the puzzle is the point of the mode — a threat you have to break off and deal - // with only works if breaking off is occasional. They also enter wide of the colour gang - // (spawnEater), because the one body out there that is neither the puzzle nor worth anything has - // no business standing in front of the puzzle. - drillEaterEvery: 12.0, // s between plain-shambler eaters at the bottom of the ramp... - drillEaterMin: 5.0, // ...and at the top. Still THE difficulty dial, just a slower one - drillEaterMax: 0, // eaters allowed on court at once. **0 = OFF, which is where it is now** — - // Oliver: "remove the fake zombies for now", so the drill is currently the - // pure puzzle with no second threat. The whole mechanism is intact - // (spawnEater, the wide entry, the never-a-fault rule); this one number - // brings it back. Past 3 the puzzle stops being readable. - drillEaterSpeed: 1.15,// they walk a touch faster than the batch: the batch is a list, this is a threat + drillSpeed: 0.85, // m/s at the bottom of the ramp (x the type's vMul)... + drillSpeedMax: 2.0,// ...and at the top. Stamped per body AT SPAWN — constant v is also what lets + // arrival() be a plain division, so the queue is honest + drillSpawnEvery: 3.2, // s between arrivals at the bottom of the ramp... + drillSpawnMin: 1.1, // ...and at the top. THE pressure dial: the queue outgrows your clearing rate drillRampT: 210, // s to go from the bottom of every curve above to the top - // TESTING KNOB, and it is OFF again after one playtest: "one-shot is bad - way too easy - also - // we're losing all the nice gore animation sprites". Both halves matter and the second is the one - // that is easy to miss — a 1 hp body never passes through any of its WOUND STAGES, so every skin - // in ZWOUND (arm off, ribs out, still walking) is skipped entirely and the roster's best art never - // renders. The HP is also what makes the called ORDER matter: a roller is seven serves, so being - // told to take it third means starting on it first. Left here as a switch, not a default. - drillOneShot: 0, // >= 0.5: batch bodies AND eaters all drop to 1 hp + // THE JUDGEMENT SLACK. Two bodies arriving within this of each other are BOTH legal kills — + // without it, near-simultaneous arrivals made the rule a coin flip and a fault read as theft. + // Tightening it is the real difficulty curve for good players; below ~0.4 it starts punishing + // reads the eye genuinely cannot separate. + drillSlack: 0.9, + drillMultEvery: 4, // correct kills per multiplier step... + drillMultCap: 5, // ...capped: a deep streak should feel valuable, not become the whole score + drillMark: 1, // >= 0.5 draws a chevron over the queue head — the CALIBRATION AID, so a + // playtest can check the game's read against the player's. Off for the real + // thing: the marker answers the exact question the mode exists to ask + drillOneShot: 0, // >= 0.5: every drill body drops to 1 hp. OFF after one playtest: "way too easy" + // AND a 1 hp body skips every ZWOUND stage, so the gore art never renders. + // Also flattens triage — pre-softening a heavy IS the planning layer drillCaughtBack: 45, // s of ramp handed back when one reaches you. A real setback, not a reset fps: 60, // frame cap — 30 halves battery/heat but Oliver felt it; smoothness wins (0 = uncapped) lowPower: 1, // >= 0.5 caps RES at 3 (−44% of pixels painted per frame) — the battery saver, diff --git a/src/drill.js b/src/drill.js index 957984c..0243a32 100644 --- a/src/drill.js +++ b/src/drill.js @@ -1,221 +1,150 @@ -// THE COACHING DRILL — the cognitive mode. Second design, after Oliver played the first: +// 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. // -// "daniel announcing is maximum annoying" -// "don't let the rush in position just let them coming from the back in steady speed.. -// try to space them not too close" -// "when one combination is done just let the new one in.. in a continuous stream that -// increases in speed and difficulty" / "the rounds can stay, they should be continuous" +// 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. // -// So the ROUND survives as the unit — a called combination, put down in order, counted — and every -// SEAM around it is gone: no walk-on to a mark, no announcement beat, no between-rounds pause, no -// deal. Bodies walk in from the far baseline at a steady speed for as long as the mode runs, and a -// round is drawn from whoever happens to be on court. Finish one and the next is drawn on the spot, -// out of the same rolling population — the bodies you were ignoring a second ago are the ones being -// called now. Speed, spawn rate and combination length all ramp with time survived, so the stream -// IS the difficulty curve. +// 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 round and the rules. It imports no renderer: -// render.js reads DRILL_COLS and draws. +// (CLAUDE.md rule 3). This file is the stream, the urgency rule and the scoring. -import { TUNE } from './config.js'; +import { TUNE, COURT } from './config.js'; import { G, ball } from './state.js'; import { zombies, spawnZombie } from './zombies.js'; import { voice } from './audio.js'; -// THE ROSTER IS THE ALPHABET. Oliver's insight, and it retires an entire subsystem: *"we don't need -// colors at all! we already have different zombie types.. so the sequence is the zombies themselves"*. -// -// He is right, and it is better on every axis the colour version was struggling on. The chest sign -// and then the ground disc were both inventions bolted on to give a body an identity it ALREADY HAD -// — a brute does not look like a runner, and the whole game is built on reading those silhouettes at -// speed. So: no colours, no signs, no discs, and no new art of any kind. It also deletes the -// accessibility problem outright rather than deferring it, because a silhouette is not a hue. -// -// TWO RULES keep a type unambiguous, and both are load-bearing: -// 1. **the plain shambler is never in the alphabet.** 'a'/'b' is what the EATERS are, so "not part -// of the puzzle" is legible at a glance and needs no marking of any kind. -// 2. **one of each type on court at a time** — guaranteed by the batch dealing distinct types and -// by eaters only ever being shamblers. "The brute" always names exactly one body. -// -// `vMul` is the drill's own speed character per type, NOT spawnZombie's (which is keyed on the gore -// mode's ramp): the roller is a slow tank you have to start early, the runner is gone if you dither. -// Their HP is left at the roster's own values on purpose — Oliver: "the existing robustness of -// zombies remains (some are easier, some more difficult to kill)" — which means a roller in the -// middle of a sequence is genuinely several aimed serves. See the open questions. -// The spread is DELIBERATELY NARROW (1.35 down to 0.70, not the roster's own 3.2x-to-0.45x). A batch -// has to stay roughly coherent: at the wide spread the runner crossed the baseline while the roller -// was still in the far third, so a sequence naming the runner last could not be finished by anyone. -// The types still read as different — and their real difference is HP, which is untouched: a runner -// is one shot, a roller is seven, so the order you are given still decides how early you must start. +// 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: 'f', name: 'RUNNER', vMul: 1.35 }, - { v: 'e', name: 'BALL BOY', vMul: 1.10 }, - { v: 'd', name: 'STEWARD', vMul: 1.00 }, - { v: 'h', name: 'BOUNCER', vMul: 0.90 }, - { v: 'g', name: 'BRUTE', vMul: 0.80 }, - { v: 'k', name: 'ROLLER', vMul: 0.70 }, + { 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 }, ]; -export const drillType = v => DRILL_TYPES.find(t => t.v === v); -// ---- THE RAMP. One number, 0..1 off time survived; everything else is a lerp on it. Time, not -// rounds won: a round-counted ramp cannot escalate while you stand still. +// ---- 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 batchSize = d => Math.round(lerp(TUNE.drillBatch, TUNE.drillBatchMax, ramp(d))); -const eaterGap = d => lerp(TUNE.drillEaterEvery, TUNE.drillEaterMin, ramp(d)); +const spawnGap = d => lerp(TUNE.drillSpawnEvery, TUNE.drillSpawnMin, ramp(d)); -// TUNE.drillOneShot: strip a body down to a single hit. Applied at spawn to the batch AND the -// eaters, so nothing on a drill court ever needs a second serve. -const oneShot = zb => { if (TUNE.drillOneShot >= 0.5) zb.hp = zb.hpMax = 1; return zb; }; +// ---- 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 head, tests assert against it. +export const drillQueue = () => zombies.filter(z => z.drill).sort((a, b) => arrival(a) - arrival(b)); -const shuffle = a => { for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; }; -const gang = () => zombies.filter(z => z.drill); // the batch: a member's identity IS its variant - -// ---- THE BATCH IS THE ROUND, AND IT IS WHAT MAKES THE MODE WINNABLE. Oliver: "make the game that -// it is actually winable meaning that all required colors are visible on the far side before when a -// color combination is shown.. for now no colors that are not in the current combination". -// -// Two rules follow from that and they are the whole shape of this file now: -// 1. every colour on court is IN the current combination. There are no coloured decoys — so a -// colour you can see is a colour you will be asked for, and the strip can never name a body -// that is not there. -// 2. a combination is only ever shown once its bodies are ALL standing on the far side, which is -// free here because the batch is born there: they walk the full length of the court at you, -// and that length is the time you are given to work through the list. -// The pressure moved to the EATERS below instead, which is a better place for it — the puzzle stays -// fair and the danger is a separate thing arriving on its own clock. -function spawnBatch(d) { - const k = Math.min(DRILL_TYPES.length, batchSize(d)); - // distinct TYPES, distinct lanes, fanned across the width and shuffled so the called order has - // nothing to do with the left-to-right order - const types = shuffle(DRILL_TYPES.slice()).slice(0, k); - const lanes = shuffle(types.map((_, i) => -TUNE.drillSpread + (2 * TUNE.drillSpread) * (i / Math.max(1, k - 1)))); - types.forEach((t, i) => { - // staggered in depth as well as across, so the batch arrives as a loose group rather than as a - // rank walking in abreast — but every one still starts on the far side, which is the promise. - spawnZombie(lanes[i] + (Math.random() - 0.5) * 0.4, - TUNE.drillSpawnZ + Math.random() * TUNE.drillBatchDepth, t.v); - const zb = oneShot(zombies[zombies.length - 1]); - zb.drill = true; // marks it as the puzzle AND holds its lane (see updateZombies) - zb.v = drillSpeed(d) * t.vMul * (0.92 + Math.random() * 0.16); - }); - newRound(d); +function pickType() { + const pool = DRILL_TYPES.flatMap(t => Array(t.w).fill(t)); + return pool[Math.floor(Math.random() * pool.length)]; } -// ---- THE EATERS. Plain shamblers on their own clock: never in the alphabet, no place in any -// combination, nothing to remember about them. Killing one is never a fault (drillKill ignores a -// body that is not flagged) and letting one reach you is the ordinary caught. They exist because with the -// puzzle made fair the mode needed its danger back from somewhere, and a threat you simply have to -// DEAL WITH is a cleaner source of it than a puzzle that cheats. -// Deliberately NOT flagged `drill`, so they keep the gore mode's homing: they converge on you while -// the bibbed batch walks its lanes, which is exactly the "stop what you are doing" interruption the -// mode was missing. -function spawnEater(d) { - if (zombies.filter(z => !z.drill).length >= TUNE.drillEaterMax) return; - // THEY ENTER WIDE OF THE COLOUR GANG. An eater walking in on top of the batch screens the very - // bodies you are trying to read, and it is the one body on court that is neither the puzzle nor - // worth points — so it has no business standing in front of the puzzle. Best-of-N: sample lanes - // across the width and take the one furthest from every coloured body. They still converge on you - // afterwards (that is what they are for); this only decides where they come IN. - const g = gang(); +// ---- 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.4; - const s = g.length ? Math.min(...g.map(z => Math.abs(z.x - x))) : 1e9; + 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; } } - // ALWAYS A PLAIN SHAMBLER — rule 1 above doing its work: the alphabet never contains 'a'/'b', so - // an eater can never be mistaken for a body the sequence is asking for, with no marking on either. - spawnZombie(bx, TUNE.drillSpawnZ + Math.random() * 0.8, Math.random() < 0.3 ? 'b' : 'a'); - const zb = oneShot(zombies[zombies.length - 1]); - zb.v = drillSpeed(d) * TUNE.drillEaterSpeed * (0.9 + Math.random() * 0.2); + 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; } -// ---- THE ROUND: the order the batch has to be taken in. Drawn from every bibbed body on court, -// which — see rule 1 above — is exactly the batch and nothing else. -function newRound(d) { - const pool = gang(); - d.step = 0; d.faults = 0; - d.seq = pool.length ? shuffle(pool.map(z => z.variant)) : []; - // (no sound: the strip IS the call — see the note on DRILL_TYPES) -} - -// Called by beats.startDrill once the court is its own. export function startDrillStream() { const d = G.drill; if (!d) return; zombies.length = 0; - d.seq = []; d.step = 0; d.faults = 0; d.t = 0; d.eaterT = TUNE.drillEaterEvery; d.rounds = 0; d.lost = 0; d.msg = null; - spawnBatch(d); // the mode has to be whole the moment it starts — a batch on the far side and a - // combination already called off it + 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; - // the eaters keep coming on their own clock, independently of the puzzle - if ((d.eaterT -= dt) <= 0) { spawnEater(d); d.eaterT = eaterGap(d) * (0.75 + Math.random() * 0.5); } - // THE CONTINUOUS PART. The batch is the round; kill the last of it and the next batch walks in on - // the same frame with its combination already called. No pause, no deal, no announcement beat — - // and because a batch is born on the far side, "all the colours are visible before you are asked - // for them" holds by construction rather than by a check that could be wrong. - if (!gang().length) { spawnBatch(d); return; } - if (!d.seq.length) newRound(d); // survivors after a double fault: same bodies, fresh order + if ((d.spawnT -= dt) <= 0) { spawnDrillBody(d); d.spawnT = spawnGap(d) * (0.8 + Math.random() * 0.4); } } -// A drill body went down. Called from killZombie — the single kill funnel — so no weapon path can -// miss it (the bug this codebase has had twice). +// 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; - if (zb.variant === d.seq[d.step]) { - d.step++; - // no tick here either — killZombie is already bursting the body and playing the splat, and that - // burst is a far better "yes, that one" than a beep laid over the top of it - if (d.step >= d.seq.length) { // round complete — and the next one is already here - d.rounds++; - d.msg = { text: 'GOOD', t: 0.9 }; - // THE SHOT THAT ENDS A ROUND IS SPENT. A ball keeps pace through a kill (TUNE.zPierce) and it - // is travelling UP-COURT — which is exactly where the next batch is about to be born. So the - // ball that finished your round flew on into bodies that did not exist when you took the shot - // and faulted you on a round you had not been shown yet. Pierce WITHIN a batch stays: that is - // honest occlusion you could have read. This is not. - ball.active = false; - newRound(d); - } + 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; } - // Wrong body — one never called, or one due later. It is dead either way, so drop it from the list - // rather than leaving a slot nobody can fill: an un-winnable round reads as a broken game, and the - // fault below is the punishment already. + // 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.seq = d.seq.filter((c, i) => i < d.step || c !== zb.variant); - if (d.faults >= 2) { - d.lost++; - voice('double_fault', 0.1); - d.msg = { text: 'DOUBLE FAULT', t: 1.6, bad: true }; - return newRound(d); // scrubbed, and a fresh one off the same court - } + d.streak = 0; voice('fault', 0.1); - d.msg = { text: 'FAULT', t: 1.2, bad: true }; - if (d.step >= d.seq.length) { d.rounds++; newRound(d); } // the wrong kill was the last one outstanding + d.msg = { text: 'OUT OF TURN', t: 1.3, bad: true }; } -// A drill body reached you. Called from killPlayer, the single death path for BOTH damage channels. -// Contact costs the POINT and a cleared court, never the run: permadeath is settled for the gore -// mode, and a memory game where one lapse deletes a long run punishes the skill it is teaching. -// This is the ONE thing that interrupts the stream, which is exactly what makes it read as a setback. +// 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; // same reason as the round-complete case: the court refills up-court - d.seq = []; d.step = 0; d.faults = 0; d.spawnT = 0; - d.t = Math.max(0, d.t - TUNE.drillCaughtBack); // ...and the ramp backs off a little + 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); } - -// Which body is owed right now — the HUD highlights it. NOT used for aiming: the auto-aim is gone. -export const drillTarget = () => zombies.find(z => G.drill && z.drill && z.variant === G.drill.seq[G.drill.step]) || null; diff --git a/src/render.js b/src/render.js index 01f8790..c053b51 100644 --- a/src/render.js +++ b/src/render.js @@ -4,6 +4,7 @@ import { cv, ctx } from './dom.js'; import { TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS } from './config.js'; import { P, O, ball, score, crowd, G, court, tour } from './state.js'; import { predictLanding, previewServe, slotId, slotStock } from './gameplay.js'; +import { drillQueue, drillMult } from './drill.js'; import { zombies, gore, piles, parts, pellets, extras, shots, pops } from './zombies.js'; // ---------------------------------------------------------------- palette ---- @@ -679,44 +680,39 @@ function drawZombie(zb) { if (v === 'B' && zb.hpMax) drawBossHp(zb, h); } -// THE CALLED LIST, on screen — and it is the ROSTER now, not a palette. Each chip is the body's own -// walk frame, which is the same picture you are reading on the court: no legend to learn, no colour -// to map, and no new art. Done ones dim and the next wears the cream frame, so the strip doubles as -// "where am I" and never has to be re-read from the start mid-round. +// THE TRIAGE READOUT: score, multiplier, best — and the calibration marker. There is no strip and +// no called list any more; the court is the puzzle, so the HUD only carries what the court cannot +// say: how much the run is worth and how hot the streak is. function drawDrillHud() { const d = G.drill; if (!d) return; - const n = d.seq.length; if (!n) return; - // Sized to the widest case the ramp can deal: six chips at 30 + five 3px gaps = 195 px, inside a - // 240 px portrait canvas with room either side. A silhouette is the whole readout here, so the - // chips take every pixel the band can spare — at 26 the steward, the ball boy and the bouncer were - // three similar green figures. - const w = 30, gap = 3, totalW = n * w + (n - 1) * gap; - let x = Math.round(W / 2 - totalW / 2); - const y = CARD_TOP + 4, h = 34; - for (let i = 0; i < n; i++, x += w + gap) { - const done = i < d.step; - ctx.globalAlpha = done ? 0.3 : 1; - ctx.fillStyle = 'rgba(8,6,10,0.82)'; ctx.fillRect(x - 1, y - 1, w + 2, h + 2); - ctx.fillStyle = 'rgba(30,26,34,0.9)'; ctx.fillRect(x, y, w, h); - // the body itself, fitted into the chip on its own aspect — a roller is wide, a runner is not, - // and that difference is exactly the thing being read - const img = SPR[SK('z_' + d.seq[i] + '1')]; - if (img) { - const pad = 2, bw = w - pad * 2, bh = h - pad * 2; - const sc = Math.min(bw / img.width, bh / img.height); - const iw = Math.round(img.width * sc), ih = Math.round(img.height * sc); - ctx.drawImage(img, Math.round(x + (w - iw) / 2), Math.round(y + h - pad - ih), iw, ih); - } - if (i === d.step) { // the one you owe them right now - ctx.strokeStyle = UI.cream; ctx.lineWidth = 2; - ctx.strokeRect(x - 2, y - 2, w + 4, h + 4); - } - ctx.globalAlpha = 1; - } + const y = CARD_TOP + 4; ctx.textAlign = 'center'; - if (d.msg && d.msg.t > 0) { ctx.font = B(20); shadowText(d.msg.text, W / 2, y + h + 22, d.msg.bad ? UI.bad : UI.pick); } + ctx.font = B(24); + shadowText(String(d.score), W / 2, y, UI.cream); + const m = drillMult(d); + ctx.font = B(14); + // the multiplier is the streak made visible — gold once it is working, dim while it rebuilds + shadowText('x' + m + ' · ' + d.streak + ' STREAK', W / 2, y + 26, m > 1 ? UI.warm : UI.dim); ctx.font = F(8); - shadowText(d.rounds + ' - ' + d.lost, W / 2, y + h + 5, UI.dim); + shadowText('BEST ' + Math.max(d.best, d.score), W / 2, y + 44, UI.dim); + if (d.msg && d.msg.t > 0) { ctx.font = B(20); shadowText(d.msg.text, W / 2, y + 60, d.msg.bad ? UI.bad : UI.pick); } + // THE CALIBRATION MARKER (TUNE.drillMark): a chevron over the body the rule says is next. For the + // playtest only — it exists so Oliver can check the game's read against his own, and it answers + // the exact question the mode is asking, so it must be OFF in anything shipped. + if (TUNE.drillMark >= 0.5) { + const head = drillQueue()[0]; + if (head) { + const p = proj(head.x, zombieH(head) + 0.5, head.z); + if (p.s > 0) { + const bob = Math.sin(G.idleT * 6) * 2; + const px = Math.round(p.x), py = Math.round(p.y + bob); + ctx.fillStyle = UI.warm; + ctx.beginPath(); + ctx.moveTo(px - 5, py - 7); ctx.lineTo(px + 5, py - 7); ctx.lineTo(px, py); + ctx.closePath(); ctx.fill(); + } + } + } } // The boss's remaining life, over its head, in the scoreboard's own 16-bit language: a dark sunk diff --git a/src/state.js b/src/state.js index 5866a84..e120536 100644 --- a/src/state.js +++ b/src/state.js @@ -104,9 +104,10 @@ export const G = { interlude: false, // sudden-death duel vs the racket zombie: falsy, or {introT} while announcing duelLerp: 0, // 0..1 — how far the court has returned to tennis colors for the duel horde: null, // active horde wave {t, spawnT, mgT} — machine-gun racket, no serving - drill: null, // the coaching drill (src/drill.js): {seq, step, faults, t, eaterT, rounds, lost, msg}. - // Owns the court like every other mode — see beats.startDrill. `t` is time - // survived and it is the whole difficulty curve; there is no round clock. + drill: null, // the TRIAGE drill (src/drill.js): {t, spawnT, score, streak, kills, faults, + // lost, best, msg}. Owns the court like every other mode — see beats.startDrill. + // One rule: always kill the body that would reach you first. `t` is time + // survived and drives the whole difficulty curve. killCount: 0, // splatter: kills banked toward the next point (see TUNE.killsPerPoint) runGames: 0, // games finished this run (either side) — the difficulty director sets: [0, 0], // splatter sets won [you, them]; 6 games = a set = a horde diff --git a/test.js b/test.js index 5bdd96e..befe43d 100644 --- a/test.js +++ b/test.js @@ -86,7 +86,7 @@ const { update, forceZombies, splatPoint, restartRun, flickTarget } = await impo const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill } = await import('./src/beats.js'); const { zombies, spawnZombie, pellets } = await import('./src/zombies.js'); const { killPlayer } = await import('./src/zombies.js'); -const { drillTarget, DRILL_TYPES } = await import('./src/drill.js'); +const { drillQueue, arrival, drillMult, DRILL_TYPES } = await import('./src/drill.js'); const { TUNE, bossArt, COURT } = await import('./src/config.js'); const DT = 1 / 60; @@ -292,33 +292,38 @@ test('a burst fires through the beat that follows the kill that emptied the cour assert.ok(G.mgT > 0, 'and its window is still open'); }); -// ---- the coaching drill: a new mode, so it owes the ownership contract a test ------------------ +// ---- the triage drill: one rule, and every part of it pinned -------------------------------- +// The rule: always kill the body that would reach you first (arrival = distance / speed down a held +// lane). Wounds are free, the slack forgives near-ties, the streak is the punishment and the payout. -// Run the stream for a while. There is no announcement beat and no between-rounds pause any more — -// a round is always live once two bodies are on court. function drillPlaying(seconds = 1) { startDrill(); for (let i = 0; i < seconds * 60; i++) update(DT); } -// kill a body the way every weapon does — through the shared funnel, via the ball's contact test. -// Returns false when it is already down: a ball carries through the lane, so a shot at one body can -// take the one behind it (see PROJECT.md's open question). -// the gang's mean speed — per-TYPE vMul means a single body is not a fair sample of the ramp -const avgGangSpeed = () => { - const g = zombies.filter(z => z.drill); - return g.reduce((a, z) => a + z.v / (DRILL_TYPES.find(t => t.v === z.variant) || { vMul: 1 }).vMul, 0) / Math.max(1, g.length); -}; -const drillKillType = v => { - const zb = zombies.find(z => z.drill && z.variant === v); - if (!zb) return false; - for (let i = 0; i < 40 && zombies.includes(zb); i++) { +// kill a specific body through the shared funnel, via the ball's contact test +const drillKillBody = zb => { + for (let i = 0; i < 60 && zombies.includes(zb); i++) { ball.active = true; ball.lastHit = 'P'; ball.y = 1; ball.pow = 1; ball.bounces = 0; ball.x = zb.x; ball.z = zb.z; ball.vx = ball.vy = ball.vz = 0; update(DT); } assert.ok(!zombies.includes(zb), 'the body should be down'); - return true; }; +// a hand-built queue with KNOWN arrivals, so a test judges against arithmetic rather than luck: +// far-but-fast arrives at t=4, near-but-slow at t=8, dawdler at t=16 +function riggedQueue() { + startDrill(); + zombies.length = 0; + G.drill.spawnT = 999; // hold the stream while the fixture stands + const rig = []; + for (const [z, v] of [[COURT.L - 8, 2.0], [COURT.L - 4, 0.5], [COURT.L - 16, 1.0]]) { + spawnZombie(0, z, 'a'); + const zb = zombies[zombies.length - 1]; + zb.drill = true; zb.pts = 20; zb.v = v; zb.hp = zb.hpMax = 1; + rig.push(zb); + } + return rig; // [first, second, third] by arrival: 4 s, 8 s, 16 s +} test('a drill owns the court: every other mode refuses while it runs', () => { assert.ok(startDrill(), 'a drill starts on a clean court'); @@ -335,234 +340,131 @@ test('a drill refuses to start while another mode owns the court', () => { startDuel(); assert.ok(G.interlude, 'setup: the duel has the court'); assert.equal(startDrill(), false, 'startDrill must refuse during a duel'); - assert.equal(G.drill, null, 'and leave no half-built round behind'); + assert.equal(G.drill, null, 'and leave no half-built state behind'); }); -test('the puzzle is WINNABLE: every called TYPE is on the far side, and nothing else is in the gang', () => { +test('the queue ranks by ARRIVAL, not distance: far-but-fast outranks near-but-slow', () => { + const [fast, slow] = riggedQueue(); + const q = drillQueue(); + assert.equal(q[0], fast, 'the 2.0 m/s body 8 m out arrives first (4 s vs 8 s)'); + assert.equal(q[1], slow, 'the 0.5 m/s body 4 m out is second'); + assert.ok(arrival(q[0]) < arrival(q[1]) && arrival(q[1]) < arrival(q[2]), 'arrivals are ordered'); +}); + +test('killing the queue head grows the streak and banks points', () => { + const [fast] = riggedQueue(); + const s0 = G.drill.score; + drillKillBody(fast); + assert.equal(G.drill.streak, 1, 'a correct kill grows the streak'); + assert.equal(G.drill.faults, 0, 'and is no fault'); + assert.ok(G.drill.score > s0, 'and banks points'); +}); + +test('killing out of turn is a fault, resets the streak, and sweeps nothing', () => { + const rig = riggedQueue(); + G.drill.streak = 7; G.drill.score = 500; + drillKillBody(rig[2]); // the 16 s dawdler, with a 4 s body still walking + assert.equal(G.drill.faults, 1, 'a misread faults'); + assert.equal(G.drill.streak, 0, 'and the streak dies'); + assert.equal(G.drill.score, 500, 'the banked score is untouched'); + assert.ok(zombies.includes(rig[0]) && zombies.includes(rig[1]), 'the court is NOT swept — the queue keeps walking'); +}); + +test('the slack forgives a near-tie', () => { startDrill(); - const called = G.drill.seq; - assert.ok(called.length >= 2, 'a combination is called at once'); - const gang = zombies.filter(z => z.drill); - assert.equal(gang.length, called.length, 'exactly as many bodies in the gang as types called'); - assert.deepEqual(gang.map(z => z.variant).sort(), called.slice().sort(), - 'the gang on court IS the combination — nothing called that is not there'); - for (const z of gang) - assert.ok(z.z < COURT.netZ, 'every called body starts on the far side, z=' + z.z.toFixed(1)); - assert.equal(new Set(gang.map(z => z.variant)).size, gang.length, 'a type names exactly one body'); - assert.ok(gang.every(z => !'ab'.includes(z.variant)), 'the plain shambler is never in the alphabet'); - // ...and it stays true as the batch walks in: no new body ever joins the gang mid-round - for (let i = 0; i < 25 * 60; i++) { - update(DT); - for (const z of zombies) if (z.drill) - assert.ok(G.drill.seq.includes(z.variant), 'an uncalled type joined the gang'); + zombies.length = 0; G.drill.spawnT = 999; + for (const dz of [6, 6.3]) { // two bodies arriving ~0.3 s apart at 1 m/s + spawnZombie(dz > 6 ? 2 : -2, COURT.L - dz, 'a'); + const zb = zombies[zombies.length - 1]; + zb.drill = true; zb.pts = 20; zb.v = 1.0; zb.hp = zb.hpMax = 1; } + const second = zombies[zombies.length - 1]; + drillKillBody(second); // kill #2 of a 0.3 s tie: inside drillSlack 0.9 + assert.equal(G.drill.faults, 0, 'inside the slack, either of a near-tie is legal'); + assert.equal(G.drill.streak, 1, 'and it counts'); }); -test('the eaters obey drillEaterMax — including 0, which is OFF', () => { - // They are switched off right now ("remove the fake zombies for now") but the mechanism is intact, - // so this asserts whichever contract the knob currently describes rather than being deleted and - // rotting. The never-a-fault rule below is checked EITHER WAY, because that is the part that has - // to still be true on the day they are switched back on. - const eaten = TUNE.eatenR; TUNE.eatenR = 0; // the soak trick: don't get caught mid-measurement - try { - startDrill(); - const seen = new Set(); - let peak = 0; - for (let i = 0; i < 40 * 60; i++) { - update(DT); - const live = zombies.filter(z => !z.drill); - peak = Math.max(peak, live.length); - for (const z of live) { - seen.add(z); - assert.ok('ab'.includes(z.variant), - 'an eater is ALWAYS a plain shambler — that is what keeps a called type unambiguous'); - } - } - assert.ok(peak <= TUNE.drillEaterMax, 'never more than ' + TUNE.drillEaterMax + ' at once, saw ' + peak); - if (TUNE.drillEaterMax === 0) - assert.equal(seen.size, 0, 'at 0 the court carries nothing but the puzzle'); - else - assert.ok(seen.size > 0, 'otherwise plain shamblers arrive on their own clock'); - } finally { TUNE.eatenR = eaten; } +test('wounds are free: softening a back-of-queue heavy is never judged', () => { + startDrill(); + zombies.length = 0; G.drill.spawnT = 999; + spawnZombie(0, COURT.L - 3, 'a'); // the urgent one, 3 s out + const urgent = zombies[zombies.length - 1]; + urgent.drill = true; urgent.pts = 20; urgent.v = 1.0; urgent.hp = urgent.hpMax = 1; + spawnZombie(2, COURT.L - 18, 'g'); // a brute at the back, 5 hp + const brute = zombies[zombies.length - 1]; + brute.drill = true; brute.pts = 80; brute.v = 1.0; + ball.active = true; ball.lastHit = 'P'; ball.y = 1; ball.pow = 1; ball.bounces = 0; + ball.x = brute.x; ball.z = brute.z; ball.vx = ball.vy = ball.vz = 0; + update(DT); + assert.ok(zombies.includes(brute), 'setup: the brute survived the hit'); + assert.ok(brute.hp < brute.hpMax, 'setup: but is wounded'); + assert.equal(G.drill.faults, 0, 'a wound out of turn is NOT a fault — only the kill is judged'); }); -test('killing a body that is not in the gang is never a fault', () => { - // The rule the eaters depend on, tested with a hand-placed shambler so it holds whether or not - // the spawner is switched on. - drillPlaying(); - const seq = G.drill.seq.slice(), faults = G.drill.faults, step = G.drill.step; - spawnZombie(P.x + 2, 6, 'a'); - const stray = zombies[zombies.length - 1]; - assert.ok(!stray.drill, 'setup: a hand-placed body is not part of the gang'); - for (let i = 0; i < 40 && zombies.includes(stray); i++) { - ball.active = true; ball.lastHit = 'P'; ball.y = 1; ball.pow = 1; ball.bounces = 0; - ball.x = stray.x; ball.z = stray.z; ball.vx = ball.vy = ball.vz = 0; - update(DT); - } - assert.ok(!zombies.includes(stray), 'setup: it is down'); - assert.deepEqual(G.drill.seq, seq, 'the combination is untouched'); - assert.equal(G.drill.faults, faults, 'and it costs no fault'); - assert.equal(G.drill.step, step, 'and does not advance the sequence'); +test('the multiplier steps with the streak and caps', () => { + startDrill(); + const d = G.drill; + d.streak = 0; assert.equal(drillMult(d), 1); + d.streak = TUNE.drillMultEvery; assert.equal(drillMult(d), 2); + d.streak = TUNE.drillMultEvery * 9; assert.equal(drillMult(d), TUNE.drillMultCap, 'capped'); }); -test('the ramp raises the batch, the walk and the eater rate', () => { +test('the best score survives in localStorage', () => { + startDrill(); + const [fast] = riggedQueue(); + G.drill.score = 777; G.drill.best = 0; + drillKillBody(fast); // any correct kill banks through bankBest + assert.ok(G.drill.best >= 777, 'the run became the best'); + assert.ok(Number(localStorage.getItem('drillBest')) >= 777, 'and it is written down'); +}); + +test('the stream keeps arriving, capped and spaced', () => { const eaten = TUNE.eatenR; TUNE.eatenR = 0; try { startDrill(); - const earlyBatch = G.drill.seq.length; - const earlyV = avgGangSpeed(); - for (let i = 0; i < 40 * 60; i++) { update(DT); } - G.drill.t = TUNE.drillRampT; // jump to the top of the ramp - zombies.length = 0; update(DT); // force a fresh batch at full difficulty - const lateBatch = G.drill.seq.length, lateV = avgGangSpeed(); - assert.ok(lateBatch > earlyBatch, 'the batch grows: ' + earlyBatch + ' -> ' + lateBatch); - assert.ok(lateV > earlyV * 1.4, 'and it walks faster: ' + earlyV.toFixed(2) + ' -> ' + lateV.toFixed(2)); - assert.ok(TUNE.drillEaterMin < TUNE.drillEaterEvery, 'and the eaters come sooner'); + const seeded = zombies.length; + let peak = 0; + for (let i = 0; i < 18 * 60; i++) { update(DT); peak = Math.max(peak, zombies.length); } + assert.ok(peak > seeded, 'bodies keep arriving: peak ' + peak); + assert.ok(peak <= TUNE.drillMaxOnCourt, 'never past the court cap'); + for (let i = 0; i < zombies.length; i++) for (let j = i + 1; j < zombies.length; j++) + assert.ok(Math.abs(zombies[i].x - zombies[j].x) > 0.3 || Math.abs(zombies[i].z - zombies[j].z) > 0.8, + 'two bodies ended up on top of each other'); } finally { TUNE.eatenR = eaten; } }); -test('finishing a batch rolls straight into the next one — no pause, no deal', () => { +test('being caught sweeps the court and kills the streak, but the score survives', () => { drillPlaying(); - const seq = G.drill.seq.slice(); - for (const bib of seq) drillKillType(bib); - assert.ok(G.drill.rounds >= 1, 'the round is banked'); - update(DT); - const gang = zombies.filter(z => z.drill); - assert.ok(gang.length >= 2, 'a fresh batch is already walking in'); - assert.ok(G.drill.seq.length >= 2, 'with its combination already called'); - assert.equal(G.drill.step, 0, 'from the top'); - for (const z of gang) assert.ok(z.z < COURT.netZ, 'and it starts on the far side, as promised'); -}); - -test('the drill keeps its own count; the tennis ladder stays out of it', () => { - drillPlaying(); - const games = score.games.slice(), pts = score.pts.slice(); - for (const bib of G.drill.seq.slice()) drillKillType(bib); - assert.deepEqual(score.games, games, 'the drill does not bank tennis games'); - assert.deepEqual(score.pts, pts, '...nor tennis points'); -}); - -test('two out of turn is a double fault, and the survivors are re-called', () => { - startDrill(); - if (G.drill.seq.length < 3) return; // needs a batch with room to be wrong twice - const wrong1 = G.drill.seq[G.drill.seq.length - 1]; // the LAST call, killed first - drillKillType(wrong1); - assert.equal(G.drill.faults, 1, 'out of turn is a fault'); - assert.equal(G.drill.lost, 0, 'but not the round'); - const wrong2 = G.drill.seq[G.drill.seq.length - 1]; - if (wrong2 !== undefined && G.drill.seq.length > 1) { - drillKillType(wrong2); - assert.equal(G.drill.lost, 1, 'the second one takes the round off you'); - assert.equal(G.drill.faults, 0, 'and the survivors are re-called with a clean slate'); - assert.deepEqual(G.drill.seq.slice().sort(), - zombies.filter(z => z.drill).map(z => z.variant).sort(), - 'the new combination is exactly what is still standing'); - } -}); - -test('THE AUTO-AIM IS GONE: the shot goes where you point it', () => { - // It used to lock onto the called body wherever the pull pointed, which made the shot unmissable - // and the fault unreachable — Oliver: "no skills involved at all... there is no penalty". - drillPlaying(); - const called = G.drill.seq[G.drill.step]; - const target = zombies.find(z => z.drill && z.variant === called); - assert.ok(target, 'setup: the called body is on court'); - // the wrong body FURTHEST from the called one in x, so the two are genuinely separable — aiming - // at a body that happens to sit in the same lane proves nothing either way - const wrong = zombies.filter(z => z.drill && z.variant !== called) - .sort((a, b) => Math.abs(b.x - target.x) - Math.abs(a.x - target.x))[0]; - if (!wrong || Math.abs(wrong.x - target.x) < 1.5) return; // nothing separable on court this deal - // ...and point the ray AT it, properly normalised. The old version pinned fy at -0.95 and scaled - // fx, so it never actually aimed at the body it claimed to. - const dx = wrong.x - P.x, dz = wrong.z - P.z, L = Math.hypot(dx, dz); - const ft = flickTarget(P, { fx: dx / L, fy: dz / L, mag: 140 }); - assert.equal(ft.zt && ft.zt.variant, wrong.variant, 'the magnet takes the body you pointed at'); - assert.notEqual(ft.zt && ft.zt.variant, called, 'and never the called one just because it is called'); -}); - -test('a wrong kill never leaves an un-winnable round', () => { - drillPlaying(); - const later = G.drill.seq[G.drill.seq.length - 1]; // kill the LAST call first - if (G.drill.seq.length > 1 && drillKillType(later)) { - assert.ok(!G.drill.seq.includes(later), 'the dead type leaves the list rather than blocking it'); - assert.ok(G.drill.seq.every(v => zombies.some(z => z.drill && z.variant === v)), - 'every type still on the list is still on the court'); - } -}); - -test('the shot that ends a round cannot carry into the next batch', () => { - // Oliver: "the last kill of a game can carry to the spawn of a new zombie of the next round". A - // ball keeps pace through a kill and travels UP-COURT, which is exactly where the next batch is - // born — so it faulted you on a round you had not been shown yet. - drillPlaying(); - const seq = G.drill.seq.slice(); - for (const v of seq) drillKillType(v); - assert.ok(G.drill.rounds >= 1, 'setup: the round completed'); - assert.equal(ball.active, false, 'the finishing shot is spent, not left in flight'); - // let the next batch walk on and confirm it arrives untouched - for (let i = 0; i < 30; i++) update(DT); - const gang = zombies.filter(z => z.drill); - assert.ok(gang.length >= 2, 'the next batch is on court'); - assert.equal(G.drill.faults, 0, 'and it did not inherit a fault from the previous round'); - assert.equal(G.drill.step, 0, 'nor a half-finished sequence'); -}); - -test('being caught also spends the ball, for the same reason', () => { - drillPlaying(); - ball.active = true; ball.lastHit = 'P'; + G.drill.score = 900; G.drill.streak = 6; killPlayer(); - assert.equal(ball.active, false, 'the court refills up-court; a live ball would meet it'); + assert.notEqual(G.mode, 'eaten', 'permadeath stays in the gore mode'); + assert.equal(G.drill.lost, 1, 'it is a loss'); + assert.equal(G.drill.streak, 0, 'the streak dies'); + assert.equal(G.drill.score, 900, 'the score does not'); + assert.equal(zombies.length, 0, 'the court is swept'); + assert.equal(ball.active, false, 'and the finishing ball is spent — the court refills up-court'); + for (let i = 0; i < 10 * 60; i++) update(DT); + assert.ok(zombies.length > 0, 'then the stream simply resumes'); }); -test('drillOneShot is a switch, and OFF means the roster HP is intact', () => { - // Off by default: one-shot bodies were "way too easy" AND skipped every wound skin in ZWOUND, - // because a 1 hp body never passes through a damage stage. Asserts whichever way the knob is set - // rather than being deleted when it was turned off. - startDrill(); - const tank = zombies.find(z => z.drill && z.variant === 'k') - || zombies.find(z => z.drill && z.variant === 'g'); - if (TUNE.drillOneShot >= 0.5) { - for (const z of zombies) assert.equal(z.hp, 1, z.variant + ' should be one-shot, saw hp ' + z.hp); - } else if (tank) { - assert.ok(tank.hp > 1, 'the heavy bodies keep the roster HP: ' + tank.variant + ' at ' + tank.hp); - // ...and that is what lets it show a wound stage on the way down - ball.active = true; ball.lastHit = 'P'; ball.y = 1; ball.pow = 1; ball.bounces = 0; - ball.x = tank.x; ball.z = tank.z; ball.vx = ball.vy = ball.vz = 0; - update(DT); - assert.ok(zombies.includes(tank), 'one hit does not put down a ' + tank.variant); - assert.ok(tank.hp < tank.hpMax, 'but it is wounded: ' + tank.hp + '/' + tank.hpMax); - } -}); - -test('a batch body that walks past you counts as caught, never as a free escape', () => { - // Types move at different speeds now, and drill bodies hold their lane — so one in a lane you are - // not standing in can cross the baseline untouched. Left as a plain despawn that stranded the - // sequence asking for a body which no longer existed: the un-winnable state the mode forbids. +test('a body that walks past you counts as caught, never as a free escape', () => { drillPlaying(); const runaway = zombies.find(z => z.drill); - assert.ok(runaway, 'setup: a batch is on court'); - runaway.x = P.x + 5; // a lane the player is nowhere near - runaway.z = COURT.L + 1; // ...and over the line + assert.ok(runaway, 'setup: the stream is on court'); + runaway.x = P.x + 5; runaway.z = COURT.L + 1; const lost = G.drill.lost; update(DT); assert.equal(G.drill.lost, lost + 1, 'it costs the point'); assert.ok(!zombies.includes(runaway), 'and it is gone'); - for (let i = 0; i < 5 * 60; i++) update(DT); - assert.ok(G.drill.seq.every(v => zombies.some(z => z.drill && z.variant === v)), - 'the sequence never names a body that is not on court'); }); -test('being caught costs the point and the court, not the run', () => { - drillPlaying(); - const t0 = G.drill.t; - killPlayer(); - assert.notEqual(G.mode, 'eaten', 'permadeath stays in the gore mode'); - assert.equal(G.drill.lost, 1, 'they take the point instead'); - assert.equal(zombies.length, 0, 'the court is swept — the one thing that interrupts the stream'); - assert.ok(G.drill.t < t0, 'and the ramp backs off'); - for (let i = 0; i < 10 * 60; i++) update(DT); - assert.ok(zombies.length > 0, 'then the stream simply resumes'); - assert.ok(G.drill.seq.length >= 2, 'and a round is live again'); +test('drillOneShot is a switch, and OFF means the roster HP is intact', () => { + startDrill(); + for (let i = 0; i < 30 * 60 && !zombies.some(z => 'gkhde'.includes(z.variant)); i++) update(DT); + const heavy = zombies.find(z => 'gkhde'.includes(z.variant)); + if (!heavy) return; // this seed dealt only shamblers in the window + if (TUNE.drillOneShot >= 0.5) assert.equal(heavy.hp, 1, 'one-shot on: everything is glass'); + else assert.ok(heavy.hp > 1, 'one-shot off: a ' + heavy.variant + ' keeps its ' + heavy.hp + ' hp'); }); test('endDrill hands the court back', () => {