Triage: always kill the body that would reach you first

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 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-11 21:32:17 +02:00
1 parent 57e96d629e
commit 6a16e77480
8 files changed
+313 -459

No files matched your search

+32
View File
@@ -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 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. 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:** **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 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 the slice removal (seventh pass) — a cut ball picking its own next victim was the main way this
+1 -1
View File
@@ -399,6 +399,6 @@
</div> </div>
</div> </div>
</div> </div>
<script type="module" src="src/main.js?v=162"></script> <script type="module" src="src/main.js?v=163"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -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. // clock and rules live in drill.js; only the transition is here, where the rule can be enforced.
export function startDrill() { export function startDrill() {
if (G.drill || G.interlude || G.horde || G.bossFight) return false; 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; 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 // ...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, // horde, an owed chapter or a due duel is a latch that keeps trying to fire behind the drill,
+27 -33
View File
@@ -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 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 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) barrelH: 1.15, // drawn height (a body is 1.8)
// ---- THE COACHING DRILL (src/drill.js) — the cognitive mode. THE SEQUENCE IS THE ZOMBIES // ---- THE TRIAGE DRILL (src/drill.js) — the cognitive mode, fourth design. ONE RULE: always
// THEMSELVES: a BATCH of distinct roster types is born on the far side and the combination is // kill the body that would reach you first. Nothing is called and nothing is shown — the court is
// called off them at once, so everything the strip asks for is already in front of you. There are // the puzzle, and the skill is reading speeds against distances (a runner at the far baseline
// no colours, signs or markers anywhere — a brute does not look like a runner, and that is the // beats a roller at the service line). The streak multiplier is the hook; the score is what you
// whole point. The danger is a separate channel: plain shambler EATERS on their own clock, never // come back to beat.
// in the alphabet, converging on you the way ordinary shamblers do. drillMaxOnCourt: 7, // bodies on court at once — the queue you are reading. Past ~7 it is noise
drillBatch: 3, // TYPES in a batch at the bottom of the ramp... drillSeed: 4, // bodies placed at the start so there is a queue to read from the first second
drillBatchMax: 6, // ...and at the top. Capped by DRILL_TYPES — one of each type on court at most drillSeedGap: 2.6,// m of depth between those seeded bodies
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
drillSpawnZ: 0.6, // z they enter at (the far baseline is 0) 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 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)... drillSpeed: 0.85, // m/s at the bottom of the ramp (x the type's vMul)...
drillSpeedMax: 1.9,// ...and at the top. Stamped per body AT SPAWN, so the ramp shows up as new drillSpeedMax: 2.0,// ...and at the top. Stamped per body AT SPAWN — constant v is also what lets
// arrivals outpacing the ones already walking, not the court accelerating // arrival() be a plain division, so the queue is honest
// The eaters are RARE on purpose. At every 7 s / 2.2 s they were a second crowd rather than an drillSpawnEvery: 3.2, // s between arrivals at the bottom of the ramp...
// interruption, and the puzzle is the point of the mode — a threat you have to break off and deal drillSpawnMin: 1.1, // ...and at the top. THE pressure dial: the queue outgrows your clearing rate
// 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
drillRampT: 210, // s to go from the bottom of every curve above to the top 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 // THE JUDGEMENT SLACK. Two bodies arriving within this of each other are BOTH legal kills —
// we're losing all the nice gore animation sprites". Both halves matter and the second is the one // without it, near-simultaneous arrivals made the rule a coin flip and a fault read as theft.
// that is easy to miss — a 1 hp body never passes through any of its WOUND STAGES, so every skin // Tightening it is the real difficulty curve for good players; below ~0.4 it starts punishing
// in ZWOUND (arm off, ribs out, still walking) is skipped entirely and the roster's best art never // reads the eye genuinely cannot separate.
// renders. The HP is also what makes the called ORDER matter: a roller is seven serves, so being drillSlack: 0.9,
// told to take it third means starting on it first. Left here as a switch, not a default. drillMultEvery: 4, // correct kills per multiplier step...
drillOneShot: 0, // >= 0.5: batch bodies AND eaters all drop to 1 hp 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 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) 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, lowPower: 1, // >= 0.5 caps RES at 3 (−44% of pixels painted per frame) — the battery saver,
+99 -170
View File
@@ -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" // ONE RULE NOW: **always kill the body that would reach you first.** Nothing is called, nothing is
// "don't let the rush in position just let them coming from the back in steady speed.. // shown — the court is the puzzle. Types walk at different speeds from different depths, so the
// try to space them not too close" // answer is NOT the closest body: a runner at the far baseline beats a roller at the service line,
// "when one combination is done just let the new one in.. in a continuous stream that // and every kill (and every new arrival) re-asks the question. That is court sense — reading speeds
// increases in speed and difficulty" / "the rounds can stay, they should be continuous" // 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 // THE HOOK is the streak: correct kills build a multiplier, a wrong kill breaks it, and the score is
// SEAM around it is gone: no walk-on to a mark, no announcement beat, no between-rounds pause, no // what you come back to beat (best kept in localStorage; the world board can carry it later if the
// deal. Bodies walk in from the far baseline at a steady speed for as long as the mode runs, and a // mode ships). Two subtleties that make it a game rather than a quiz:
// round is drawn from whoever happens to be on court. Finish one and the next is drawn on the spot, // - WOUNDS ARE FREE. Only the KILL is judged, so pre-softening a roller while it is still third
// out of the same rolling population — the bodies you were ignoring a second ago are the ones being // in line is legal and eventually necessary — the finishing blow is what must land in turn.
// called now. Speed, spawn rate and combination length all ramp with time survived, so the stream // - THE SLACK. Two bodies arriving within TUNE.drillSlack of each other are BOTH legal kills.
// IS the difficulty curve. // 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 // 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: // (CLAUDE.md rule 3). This file is the stream, the urgency rule and the scoring.
// render.js reads DRILL_COLS and draws.
import { TUNE } from './config.js'; import { TUNE, COURT } from './config.js';
import { G, ball } from './state.js'; import { G, ball } from './state.js';
import { zombies, spawnZombie } from './zombies.js'; import { zombies, spawnZombie } from './zombies.js';
import { voice } from './audio.js'; import { voice } from './audio.js';
// THE ROSTER IS THE ALPHABET. Oliver's insight, and it retires an entire subsystem: *"we don't need // The roster in the stream, weighted: shamblers are the crowd, heavies are events. vMul is the
// colors at all! we already have different zombie types.. so the sequence is the zombies themselves"*. // 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
// He is right, and it is better on every axis the colour version was struggling on. The chest sign // took, so a roller is worth chasing and a shambler is volume.
// 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.
export const DRILL_TYPES = [ export const DRILL_TYPES = [
{ v: 'f', name: 'RUNNER', vMul: 1.35 }, { v: 'a', name: 'SHAMBLER', vMul: 1.00, w: 5, pts: 20 },
{ v: 'e', name: 'BALL BOY', vMul: 1.10 }, { v: 'b', name: 'SHAMBLER', vMul: 1.00, w: 5, pts: 20 },
{ v: 'd', name: 'STEWARD', vMul: 1.00 }, { v: 'f', name: 'RUNNER', vMul: 1.45, w: 2, pts: 60 },
{ v: 'h', name: 'BOUNCER', vMul: 0.90 }, { v: 'e', name: 'BALL BOY', vMul: 1.10, w: 3, pts: 40 },
{ v: 'g', name: 'BRUTE', vMul: 0.80 }, { v: 'd', name: 'STEWARD', vMul: 1.00, w: 3, pts: 60 },
{ v: 'k', name: 'ROLLER', vMul: 0.70 }, { 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 // ---- THE RAMP. One number, 0..1 off time survived. Spawn gap and walk speed ride it; the streak
// rounds won: a round-counted ramp cannot escalate while you stand still. // 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 ramp = d => Math.min(1, d.t / TUNE.drillRampT);
const lerp = (a, b, k) => a + (b - a) * k; const lerp = (a, b, k) => a + (b - a) * k;
export const drillSpeed = d => lerp(TUNE.drillSpeed, TUNE.drillSpeedMax, ramp(d)); export const drillSpeed = d => lerp(TUNE.drillSpeed, TUNE.drillSpeedMax, ramp(d));
const batchSize = d => Math.round(lerp(TUNE.drillBatch, TUNE.drillBatchMax, ramp(d))); const spawnGap = d => lerp(TUNE.drillSpawnEvery, TUNE.drillSpawnMin, ramp(d));
const eaterGap = d => lerp(TUNE.drillEaterEvery, TUNE.drillEaterMin, ramp(d));
// TUNE.drillOneShot: strip a body down to a single hit. Applied at spawn to the batch AND the // ---- THE URGENCY RULE, and it is the whole mode. Seconds until this body crosses your baseline at
// eaters, so nothing on a drill court ever needs a second serve. // its own pace down its own lane (drill bodies hold their lane, so this is honest — nothing swerves).
const oneShot = zb => { if (TUNE.drillOneShot >= 0.5) zb.hp = zb.hpMax = 1; return zb; }; 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; }; function pickType() {
const gang = () => zombies.filter(z => z.drill); // the batch: a member's identity IS its variant const pool = DRILL_TYPES.flatMap(t => Array(t.w).fill(t));
return pool[Math.floor(Math.random() * pool.length)];
// ---- 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);
} }
// ---- THE EATERS. Plain shamblers on their own clock: never in the alphabet, no place in any // ---- THE STREAM. One body at a time, spaced (best-of-9 lanes against the back half — a retry loop
// combination, nothing to remember about them. Killing one is never a fault (drillKill ignores a // would stall the stream exactly when it should be getting harder), holding its lane all the way in.
// body that is not flagged) and letting one reach you is the ordinary caught. They exist because with the function spawnDrillBody(d) {
// puzzle made fair the mode needed its danger back from somewhere, and a threat you simply have to if (zombies.filter(z => z.drill).length >= TUNE.drillMaxOnCourt) return null;
// DEAL WITH is a cleaner source of it than a puzzle that cheats. const near = zombies.filter(z => z.drill && z.z < TUNE.drillNearZ);
// 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();
let bx = 0, bs = -1; let bx = 0, bs = -1;
for (let k = 0; k < 9; k++) { for (let k = 0; k < 9; k++) {
const x = -TUNE.drillSpread + (2 * TUNE.drillSpread) * (k / 8) + (Math.random() - 0.5) * 0.4; const x = -TUNE.drillSpread + (2 * TUNE.drillSpread) * (k / 8) + (Math.random() - 0.5) * 0.5;
const s = g.length ? Math.min(...g.map(z => Math.abs(z.x - x))) : 1e9; const s = near.length ? Math.min(...near.map(z => Math.abs(z.x - x))) : 1e9;
if (s > bs) { bs = s; bx = x; } if (s > bs) { bs = s; bx = x; }
} }
// ALWAYS A PLAIN SHAMBLER — rule 1 above doing its work: the alphabet never contains 'a'/'b', so const t = pickType();
// 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, t.v);
spawnZombie(bx, TUNE.drillSpawnZ + Math.random() * 0.8, Math.random() < 0.3 ? 'b' : 'a'); const zb = zombies[zombies.length - 1];
const zb = oneShot(zombies[zombies.length - 1]); if (TUNE.drillOneShot >= 0.5) zb.hp = zb.hpMax = 1; // the testing switch, normally off
zb.v = drillSpeed(d) * TUNE.drillEaterSpeed * (0.9 + Math.random() * 0.2); 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() { export function startDrillStream() {
const d = G.drill; if (!d) return; const d = G.drill; if (!d) return;
zombies.length = 0; 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; d.t = 0; d.spawnT = 0; d.score = 0; d.streak = 0; d.kills = 0; d.faults = 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 try { d.best = Number(localStorage.getItem('drillBest')) || 0; } catch { d.best = 0; }
// combination already called off it // 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) { export function updateDrill(dt) {
const d = G.drill; if (!d) return; const d = G.drill; if (!d) return;
d.t += dt; d.t += dt;
if (d.msg) d.msg.t -= dt; if (d.msg) d.msg.t -= dt;
// the eaters keep coming on their own clock, independently of the puzzle if ((d.spawnT -= dt) <= 0) { spawnDrillBody(d); d.spawnT = spawnGap(d) * (0.8 + Math.random() * 0.4); }
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
} }
// A drill body went down. Called from killZombie — the single kill funnel — so no weapon path can // The streak's payout curve: x1 x2 x3 x4 x5, capped — deep streaks should feel valuable without a
// miss it (the bug this codebase has had twice). // 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) { export function drillKill(zb) {
const d = G.drill; if (!d || !zb.drill) return; const d = G.drill; if (!d || !zb.drill) return;
if (zb.variant === d.seq[d.step]) { const q = drillQueue(); // zb is already spliced out — this is who is LEFT
d.step++; const t0 = arrival(zb);
// no tick here either — killZombie is already bursting the body and playing the splat, and that const legal = !q.length || t0 <= arrival(q[0]) + TUNE.drillSlack;
// burst is a far better "yes, that one" than a beep laid over the top of it if (legal) {
if (d.step >= d.seq.length) { // round complete — and the next one is already here d.streak++; d.kills++;
d.rounds++; d.score += (zb.pts || 20) * drillMult(d);
d.msg = { text: 'GOOD', t: 0.9 }; bankBest(d);
// 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);
}
return; return;
} }
// Wrong body — one never called, or one due later. It is dead either way, so drop it from the list // A misread: something out there was going to arrive first and you spent the shot here. The
// rather than leaving a slot nobody can fill: an un-winnable round reads as a broken game, and the // streak IS the punishment — no round to lose, no court sweep, the queue just keeps walking.
// fault below is the punishment already.
d.faults++; d.faults++;
d.seq = d.seq.filter((c, i) => i < d.step || c !== zb.variant); d.streak = 0;
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
}
voice('fault', 0.1); voice('fault', 0.1);
d.msg = { text: 'FAULT', t: 1.2, bad: true }; d.msg = { text: 'OUT OF TURN', t: 1.3, bad: true };
if (d.step >= d.seq.length) { d.rounds++; newRound(d); } // the wrong kill was the last one outstanding
} }
// A drill body reached you. Called from killPlayer, the single death path for BOTH damage channels. // A body reached you — by contact or by walking its lane past your baseline. The one real setback:
// Contact costs the POINT and a cleared court, never the run: permadeath is settled for the gore // court swept, streak dead, ramp handed back a little. Score survives; the run has no end.
// 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.
export function drillCaught(text = 'CAUGHT') { export function drillCaught(text = 'CAUGHT') {
const d = G.drill; const d = G.drill;
d.lost++; d.lost++;
d.streak = 0;
voice('game_opponent', 0.1); voice('game_opponent', 0.1);
d.msg = { text, t: 1.8, bad: true }; d.msg = { text, t: 1.8, bad: true };
zombies.length = 0; zombies.length = 0;
ball.active = false; // same reason as the round-complete case: the court refills up-court ball.active = false; // the court refills up-court; a live ball would meet it
d.seq = []; d.step = 0; d.faults = 0; d.spawnT = 0; d.spawnT = 0;
d.t = Math.max(0, d.t - TUNE.drillCaughtBack); // ...and the ramp backs off a little 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;
+30 -34
View File
@@ -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 { 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 { P, O, ball, score, crowd, G, court, tour } from './state.js';
import { predictLanding, previewServe, slotId, slotStock } from './gameplay.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'; import { zombies, gore, piles, parts, pellets, extras, shots, pops } from './zombies.js';
// ---------------------------------------------------------------- palette ---- // ---------------------------------------------------------------- palette ----
@@ -679,44 +680,39 @@ function drawZombie(zb) {
if (v === 'B' && zb.hpMax) drawBossHp(zb, h); 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 // THE TRIAGE READOUT: score, multiplier, best — and the calibration marker. There is no strip and
// walk frame, which is the same picture you are reading on the court: no legend to learn, no colour // no called list any more; the court is the puzzle, so the HUD only carries what the court cannot
// to map, and no new art. Done ones dim and the next wears the cream frame, so the strip doubles as // say: how much the run is worth and how hot the streak is.
// "where am I" and never has to be re-read from the start mid-round.
function drawDrillHud() { function drawDrillHud() {
const d = G.drill; if (!d) return; const d = G.drill; if (!d) return;
const n = d.seq.length; if (!n) return; const y = CARD_TOP + 4;
// 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;
}
ctx.textAlign = 'center'; 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); 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 // The boss's remaining life, over its head, in the scoreboard's own 16-bit language: a dark sunk
+4 -3
View File
@@ -104,9 +104,10 @@ export const G = {
interlude: false, // sudden-death duel vs the racket zombie: falsy, or {introT} while announcing 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 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 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}. drill: null, // the TRIAGE drill (src/drill.js): {t, spawnT, score, streak, kills, faults,
// Owns the court like every other mode — see beats.startDrill. `t` is time // lost, best, msg}. Owns the court like every other mode — see beats.startDrill.
// survived and it is the whole difficulty curve; there is no round clock. // 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) killCount: 0, // splatter: kills banked toward the next point (see TUNE.killsPerPoint)
runGames: 0, // games finished this run (either side) — the difficulty director 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 sets: [0, 0], // splatter sets won [you, them]; 6 games = a set = a horde
+119 -217
View File
@@ -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 { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill } = await import('./src/beats.js');
const { zombies, spawnZombie, pellets } = await import('./src/zombies.js'); const { zombies, spawnZombie, pellets } = await import('./src/zombies.js');
const { killPlayer } = 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 { TUNE, bossArt, COURT } = await import('./src/config.js');
const DT = 1 / 60; 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'); 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) { function drillPlaying(seconds = 1) {
startDrill(); startDrill();
for (let i = 0; i < seconds * 60; i++) update(DT); 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. // kill a specific body 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 const drillKillBody = zb => {
// take the one behind it (see PROJECT.md's open question). for (let i = 0; i < 60 && zombies.includes(zb); i++) {
// 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++) {
ball.active = true; ball.lastHit = 'P'; ball.y = 1; ball.pow = 1; ball.bounces = 0; 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; ball.x = zb.x; ball.z = zb.z; ball.vx = ball.vy = ball.vz = 0;
update(DT); update(DT);
} }
assert.ok(!zombies.includes(zb), 'the body should be down'); 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', () => { test('a drill owns the court: every other mode refuses while it runs', () => {
assert.ok(startDrill(), 'a drill starts on a clean court'); 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(); startDuel();
assert.ok(G.interlude, 'setup: the duel has the court'); assert.ok(G.interlude, 'setup: the duel has the court');
assert.equal(startDrill(), false, 'startDrill must refuse during a duel'); 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(); startDrill();
const called = G.drill.seq; zombies.length = 0; G.drill.spawnT = 999;
assert.ok(called.length >= 2, 'a combination is called at once'); for (const dz of [6, 6.3]) { // two bodies arriving ~0.3 s apart at 1 m/s
const gang = zombies.filter(z => z.drill); spawnZombie(dz > 6 ? 2 : -2, COURT.L - dz, 'a');
assert.equal(gang.length, called.length, 'exactly as many bodies in the gang as types called'); const zb = zombies[zombies.length - 1];
assert.deepEqual(gang.map(z => z.variant).sort(), called.slice().sort(), zb.drill = true; zb.pts = 20; zb.v = 1.0; zb.hp = zb.hpMax = 1;
'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');
} }
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', () => { test('wounds are free: softening a back-of-queue heavy is never judged', () => {
// They are switched off right now ("remove the fake zombies for now") but the mechanism is intact, startDrill();
// so this asserts whichever contract the knob currently describes rather than being deleted and zombies.length = 0; G.drill.spawnT = 999;
// rotting. The never-a-fault rule below is checked EITHER WAY, because that is the part that has spawnZombie(0, COURT.L - 3, 'a'); // the urgent one, 3 s out
// to still be true on the day they are switched back on. const urgent = zombies[zombies.length - 1];
const eaten = TUNE.eatenR; TUNE.eatenR = 0; // the soak trick: don't get caught mid-measurement urgent.drill = true; urgent.pts = 20; urgent.v = 1.0; urgent.hp = urgent.hpMax = 1;
try { spawnZombie(2, COURT.L - 18, 'g'); // a brute at the back, 5 hp
startDrill(); const brute = zombies[zombies.length - 1];
const seen = new Set(); brute.drill = true; brute.pts = 80; brute.v = 1.0;
let peak = 0; ball.active = true; ball.lastHit = 'P'; ball.y = 1; ball.pow = 1; ball.bounces = 0;
for (let i = 0; i < 40 * 60; i++) { ball.x = brute.x; ball.z = brute.z; ball.vx = ball.vy = ball.vz = 0;
update(DT); update(DT);
const live = zombies.filter(z => !z.drill); assert.ok(zombies.includes(brute), 'setup: the brute survived the hit');
peak = Math.max(peak, live.length); assert.ok(brute.hp < brute.hpMax, 'setup: but is wounded');
for (const z of live) { assert.equal(G.drill.faults, 0, 'a wound out of turn is NOT a fault — only the kill is judged');
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('killing a body that is not in the gang is never a fault', () => { test('the multiplier steps with the streak and caps', () => {
// The rule the eaters depend on, tested with a hand-placed shambler so it holds whether or not startDrill();
// the spawner is switched on. const d = G.drill;
drillPlaying(); d.streak = 0; assert.equal(drillMult(d), 1);
const seq = G.drill.seq.slice(), faults = G.drill.faults, step = G.drill.step; d.streak = TUNE.drillMultEvery; assert.equal(drillMult(d), 2);
spawnZombie(P.x + 2, 6, 'a'); d.streak = TUNE.drillMultEvery * 9; assert.equal(drillMult(d), TUNE.drillMultCap, 'capped');
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 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; const eaten = TUNE.eatenR; TUNE.eatenR = 0;
try { try {
startDrill(); startDrill();
const earlyBatch = G.drill.seq.length; const seeded = zombies.length;
const earlyV = avgGangSpeed(); let peak = 0;
for (let i = 0; i < 40 * 60; i++) { update(DT); } for (let i = 0; i < 18 * 60; i++) { update(DT); peak = Math.max(peak, zombies.length); }
G.drill.t = TUNE.drillRampT; // jump to the top of the ramp assert.ok(peak > seeded, 'bodies keep arriving: peak ' + peak);
zombies.length = 0; update(DT); // force a fresh batch at full difficulty assert.ok(peak <= TUNE.drillMaxOnCourt, 'never past the court cap');
const lateBatch = G.drill.seq.length, lateV = avgGangSpeed(); for (let i = 0; i < zombies.length; i++) for (let j = i + 1; j < zombies.length; j++)
assert.ok(lateBatch > earlyBatch, 'the batch grows: ' + earlyBatch + ' -> ' + lateBatch); assert.ok(Math.abs(zombies[i].x - zombies[j].x) > 0.3 || Math.abs(zombies[i].z - zombies[j].z) > 0.8,
assert.ok(lateV > earlyV * 1.4, 'and it walks faster: ' + earlyV.toFixed(2) + ' -> ' + lateV.toFixed(2)); 'two bodies ended up on top of each other');
assert.ok(TUNE.drillEaterMin < TUNE.drillEaterEvery, 'and the eaters come sooner');
} finally { TUNE.eatenR = eaten; } } 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(); drillPlaying();
const seq = G.drill.seq.slice(); G.drill.score = 900; G.drill.streak = 6;
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';
killPlayer(); 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', () => { test('a body that walks past you counts as caught, never as a free escape', () => {
// 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.
drillPlaying(); drillPlaying();
const runaway = zombies.find(z => z.drill); const runaway = zombies.find(z => z.drill);
assert.ok(runaway, 'setup: a batch is on court'); assert.ok(runaway, 'setup: the stream is on court');
runaway.x = P.x + 5; // a lane the player is nowhere near runaway.x = P.x + 5; runaway.z = COURT.L + 1;
runaway.z = COURT.L + 1; // ...and over the line
const lost = G.drill.lost; const lost = G.drill.lost;
update(DT); update(DT);
assert.equal(G.drill.lost, lost + 1, 'it costs the point'); assert.equal(G.drill.lost, lost + 1, 'it costs the point');
assert.ok(!zombies.includes(runaway), 'and it is gone'); 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', () => { test('drillOneShot is a switch, and OFF means the roster HP is intact', () => {
drillPlaying(); startDrill();
const t0 = G.drill.t; for (let i = 0; i < 30 * 60 && !zombies.some(z => 'gkhde'.includes(z.variant)); i++) update(DT);
killPlayer(); const heavy = zombies.find(z => 'gkhde'.includes(z.variant));
assert.notEqual(G.mode, 'eaten', 'permadeath stays in the gore mode'); if (!heavy) return; // this seed dealt only shamblers in the window
assert.equal(G.drill.lost, 1, 'they take the point instead'); if (TUNE.drillOneShot >= 0.5) assert.equal(heavy.hp, 1, 'one-shot on: everything is glass');
assert.equal(zombies.length, 0, 'the court is swept — the one thing that interrupts the stream'); else assert.ok(heavy.hp > 1, 'one-shot off: a ' + heavy.variant + ' keeps its ' + heavy.hp + ' hp');
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('endDrill hands the court back', () => { test('endDrill hands the court back', () => {