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
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
+1 -1
View File
@@ -399,6 +399,6 @@
</div>
</div>
</div>
<script type="module" src="src/main.js?v=162"></script>
<script type="module" src="src/main.js?v=163"></script>
</body>
</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.
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,
+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
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,
+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"
// "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;
+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 { 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
+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
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
+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 { 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', () => {