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

+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', () => {