From abd6d4f789184c8d063bda0cfdc69328be7a51b9 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Tue, 11 Aug 2026 19:29:04 +0200 Subject: [PATCH] Every colour you are asked for is already on the far side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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 but occasional zombies which don't have a color and are converging to the player trying to eat him". Two rules define the file now. Every colour on court is IN the current combination — no coloured decoys, so the strip can never name a body that is not there. And a combination is only shown once its bodies are all on the far side, which is free because the batch is BORN there: it is a property of the construction rather than a check that could be wrong. The pressure moved to the eaters: plain unbibbed shamblers on their own clock, deliberately not flagged `drill` so they keep the gore mode's homing and converge on you while the bibbed batch walks its lanes. Killing one is never a fault; letting one reach you is the ordinary caught. The puzzle plays fair and the danger arrives separately, which is a better trade than a puzzle that cheats. The batch is the round: kill the last of it and the next walks in on the same frame with its combination already called. One real bug, caught by the new test: killZombie resolved the drill BEFORE splicing the body, so a combination drawn on the spot could name the corpse — a round asking for a colour that is not on court, i.e. exactly the unwinnable state this pass exists to prevent. Splice first, resolve second. 28 tests. Co-Authored-By: Claude Opus 5 --- PROJECT.md | 31 ++++++++++++ index.html | 2 +- src/config.js | 37 +++++++------- src/drill.js | 125 ++++++++++++++++++++++++----------------------- src/zombies.js | 7 ++- test.js | 128 +++++++++++++++++++++++++++---------------------- 6 files changed, 193 insertions(+), 137 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index 9e359cb..609d03b 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -616,6 +616,37 @@ should not reset to the middle when firing"*. - **The dodge fix is in gameplay.js, not in the drill**, because it was never a drill bug — see the new invariant above. It changes the gore mode too, and deliberately. +**SIXTH PASS — THE MODE IS WINNABLE, AND THE DANGER MOVED.** 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 but occasional +zombies which don't have a color and are converging to the player trying to eat him"*. + +Two rules now define the file, and everything else falls out of them: +1. **Every colour on court is IN the current combination.** No coloured decoys at all. 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 shown once its bodies are all on the far side** — free, because the BATCH + IS BORN THERE. It is a property of the construction rather than a check that could be wrong: a + batch spawns at the far baseline, its combination is called off it on the same frame, and the + length of the court is the time you are given to work the list. + +**The pressure moved to the EATERS**, which is a better home for it: plain unbibbed shamblers on +their own clock (`drillEaterEvery` -> `drillEaterMin`, capped at `drillEaterMax` on court). +Deliberately NOT flagged `drill`, so they keep the gore mode's homing and converge on you while the +bibbed batch walks its lanes. Killing one is never a fault (`drillKill` ignores a body with no bib) +and letting one reach you is the ordinary caught. **The puzzle plays fair and the danger arrives on +its own clock** — much better than a puzzle that cheats. + +The batch is the round: kill the last of it and the next walks in on the same frame with its +combination already called. `drillBatch` 3 -> `drillBatchMax` 6 on the ramp, so the list grows AND +the court fills. + +**One real bug, and the new test is what caught it: `killZombie` resolved the drill BEFORE splicing +the body.** `drillKill` can draw a new combination on the spot (a double fault re-calls the +survivors; an emptied batch brings in the next one) and it draws from whatever is standing in +`zombies` — so the new combination could name the body that was in the middle of dying. That is a +round asking for a colour which is not on court: precisely the un-winnable state this pass exists to +make impossible. **Splice first, resolve second.** + **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** — measured: one serve killed bib 1 and then bib 2 standing behind it, banking two correct steps in one shot. Had the diff --git a/index.html b/index.html index 5858af0..835746c 100644 --- a/index.html +++ b/index.html @@ -399,6 +399,6 @@ - + diff --git a/src/config.js b/src/config.js index 607ea2d..014c2c4 100644 --- a/src/config.js +++ b/src/config.js @@ -345,27 +345,28 @@ 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. SECOND DESIGN: a continuous - // stream. Bodies walk in from the far baseline forever; a ROUND is a colour combination drawn from - // whoever is on court, and finishing one draws the next on the spot. No walk-on, no announcement - // beat, no pause between rounds — every seam Oliver found is gone, the round itself stays. - drillMaxOnCourt: 6, // bibs on court at once. Hard-capped by DRILL_COLS (a colour appears once) - drillSeed: 4, // bodies placed at the start so the first round is callable immediately - drillSeedGap: 2.2,// m of depth between those seeded bodies — strung out, not a rank abreast + // ---- THE COACHING DRILL (src/drill.js) — the cognitive mode. THIRD DESIGN, and the one that + // makes it winnable: a BATCH of bibbed bodies is born on the far side, its combination is called + // off them immediately, and every colour on court is in that combination — no coloured decoys, so + // the strip can never name something you cannot see. Kill the last of a batch and the next walks + // in on the same frame. The danger is a separate channel: unbibbed EATERS on their own clock that + // converge on you the way ordinary shamblers do. + drillBatch: 3, // colours in a batch at the bottom of the ramp... + drillBatchMax: 6, // ...and at the top. Capped by DRILL_COLS — a colour is on court at most once + 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) - drillNearZ: 9.0, // spacing is measured against bodies nearer than this — the ones still bunched drillSpread: 3.4, // m either side of the centre line the lanes fan across - drillSeq: 3, // colours in a round at the start of the ramp... - drillSeqMax: 5, // ...and at the top of it - drillSpawnEvery: 3.4, // s between arrivals at the start... - drillSpawnMin: 1.2, // ...and at the top. THE difficulty dial: the court fills faster than you clear - drillSpeed: 0.80, // m/s they walk at the start... + drillSpeed: 0.80, // m/s a bibbed body walks at the bottom of the ramp... 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 as the court accelerating - drillRampT: 210, // s to go from the bottom of every curve above to the top. Time survived, not - // rounds won — a round-counted ramp cannot escalate while you stand still - drillCaughtBack: 45, // s of ramp handed back when one reaches you. The setback is real but not a reset - drillTones: 1, // volume of the colour arpeggio (0 = silent). It replaced the spoken call + // arrivals outpacing the ones already walking, not the court accelerating + drillEaterEvery: 7.0, // s between unbibbed eaters at the bottom of the ramp... + drillEaterMin: 2.2, // ...and at the top. THE difficulty dial now that the puzzle plays fair + drillEaterMax: 4, // eaters allowed on court at once — past this the puzzle is unreadable + 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 + drillCaughtBack: 45, // s of ramp handed back when one reaches you. A real setback, not a reset + drillTones: 1, // volume of the colour arpeggio (0 = silent). It replaced the spoken call 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, // flippable live from the T panel. DEFAULT ON since 2026-08-08: Oliver judged diff --git a/src/drill.js b/src/drill.js index ec96829..cb28535 100644 --- a/src/drill.js +++ b/src/drill.js @@ -45,58 +45,72 @@ export const DRILL_COLS = [ { key: 'white', col: '#f2f0e8', tone: 784, name: 'WHITE' }, ]; -// ---- THE RAMP. One number, 0..1 off time survived; everything else is a lerp on it. The stream is -// the difficulty curve, so this is the only dial that matters — and it replaces the round-counted -// escalation the first design used, which could not escalate while you were standing still. +// ---- 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. const ramp = d => Math.min(1, d.t / TUNE.drillRampT); const lerp = (a, b, k) => a + (b - a) * k; export const drillSpeed = d => lerp(TUNE.drillSpeed, TUNE.drillSpeedMax, ramp(d)); -const spawnGap = d => lerp(TUNE.drillSpawnEvery, TUNE.drillSpawnMin, ramp(d)); -const comboLen = d => Math.round(lerp(TUNE.drillSeq, TUNE.drillSeqMax, 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 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 bibs = () => zombies.filter(z => z.bib !== undefined); -// ---- THE STREAM. One body at a time, from the far baseline, at whatever the ramp currently says. -// Two rules, both Oliver's: a STEADY walk in — nothing rushes to a mark and then stands there — and -// they must not arrive on top of each other. -function spawnDrillBody(d) { - const onCourt = bibs(); - if (onCourt.length >= TUNE.drillMaxOnCourt) return null; - // A colour is on court at most ONCE. Two reds and "red" stops naming a body, which makes the - // round ambiguous rather than hard. - const free = DRILL_COLS.map((_, i) => i).filter(i => !onCourt.some(z => z.bib === i)); - if (!free.length) return null; - // SPACING, per "try to space them not too close". Sample lanes across the width and take the one - // standing furthest from everyone already in the back half. Best-of-N rather than a retry loop, so - // a busy court still spawns — a hard minimum would silently stall the stream at exactly the moment - // it is supposed to be getting harder, which reads as the mode dying. - const near = onCourt.filter(z => z.z < TUNE.drillNearZ); - let bx = 0, bs = -1; - for (let k = 0; k < 9; k++) { - const x = -TUNE.drillSpread + (2 * TUNE.drillSpread) * (k / 8) + (Math.random() - 0.5) * 0.5; - const s = near.length ? Math.min(...near.map(z => Math.abs(z.x - x))) : 1e9; - if (s > bs) { bs = s; bx = x; } - } - spawnZombie(bx, TUNE.drillSpawnZ + Math.random() * 0.8, Math.random() < 0.25 ? 'b' : 'a'); - const zb = zombies[zombies.length - 1]; - zb.bib = free[Math.floor(Math.random() * free.length)]; - zb.drill = true; - // Speed is stamped at SPAWN and never re-read: a body that walked in while the drill was gentle - // keeps its gentle pace all the way, so the ramp arrives as NEW arrivals moving faster than the - // ones already there, rather than as the whole court accelerating out from under you. - zb.v = drillSpeed(d) * (0.92 + Math.random() * 0.16); - return zb; +// ---- 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_COLS.length, batchSize(d)); + // distinct colours, distinct lanes, fanned across the width and shuffled so the called order has + // nothing to do with the left-to-right order + const cols = shuffle(DRILL_COLS.map((_, i) => i)).slice(0, k); + const lanes = shuffle(cols.map((_, i) => -TUNE.drillSpread + (2 * TUNE.drillSpread) * (i / Math.max(1, k - 1)))); + cols.forEach((bib, 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 of them still starts on the far side, which is the + // promise the mode now makes. + spawnZombie(lanes[i] + (Math.random() - 0.5) * 0.4, + TUNE.drillSpawnZ + Math.random() * TUNE.drillBatchDepth, Math.random() < 0.25 ? 'b' : 'a'); + const zb = zombies[zombies.length - 1]; + zb.bib = bib; + zb.drill = true; // holds its lane (see updateZombies) — the lane is the puzzle + zb.v = drillSpeed(d) * (0.92 + Math.random() * 0.16); + }); + newRound(d); } -// ---- THE ROUND. Drawn from whoever is standing there, the instant the last one is finished. No -// pause, no deal, no announcement: the strip changes and the arpeggio plays. +// ---- THE EATERS. Plain, unbibbed shamblers on their own clock: no colour, no place in any +// combination, nothing to remember about them. Killing one is never a fault (drillKill ignores a +// body with no bib) 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.bib === undefined).length >= TUNE.drillEaterMax) return; + spawnZombie((Math.random() * 2 - 1) * TUNE.drillSpread, TUNE.drillSpawnZ + Math.random() * 0.8, + Math.random() < 0.3 ? 'b' : 'a'); + const zb = zombies[zombies.length - 1]; + zb.v = drillSpeed(d) * TUNE.drillEaterSpeed * (0.9 + Math.random() * 0.2); +} + +// ---- 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 = bibs(); d.step = 0; d.faults = 0; - if (pool.length < 2) { d.seq = []; return; } // not enough on court yet — the stream will fill it - const k = Math.max(2, Math.min(comboLen(d), pool.length)); - d.seq = shuffle(pool.map(z => z.bib)).slice(0, k); + d.seq = pool.length ? shuffle(pool.map(z => z.bib)) : []; // THE CALL, without the man: one short note per colour, in the called order. Half a second for the // whole figure. TUNE.drillTones 0 plays it silent. if (TUNE.drillTones > 0) @@ -107,32 +121,23 @@ function newRound(d) { 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.spawnT = 0; d.rounds = 0; d.lost = 0; d.msg = null; - // Seed the court, strung out in depth, so the first round is callable at once instead of after the - // stream has trickled three bodies in. - for (let i = 0; i < TUNE.drillSeed; i++) { - const zb = spawnDrillBody(d); - if (zb) zb.z += i * TUNE.drillSeedGap; - } - newRound(d); // called HERE, not on the first update: the mode has to be whole the moment it starts, - // or the strip draws empty for a frame and anything reading seq straight after - // startDrill() sees a drill with no round in it. + 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 } export function updateDrill(dt) { const d = G.drill; if (!d) return; d.t += dt; if (d.msg) d.msg.t -= dt; - // the stream never stops — this is the whole mode - if ((d.spawnT -= dt) <= 0) { spawnDrillBody(d); d.spawnT = spawnGap(d) * (0.8 + Math.random() * 0.4); } - // A round is owed whenever there isn't one: at the start, after a completion, after a double - // fault, and after everything named was killed by something else. This one line replaces the whole - // empty-court watchdog the round-based version needed — with a stream there is no state to be - // stuck in, because the next round is always just "whoever is out there now". - if (!d.seq.length) newRound(d); - // (movement is updateZombies' ordinary forward step: drill bodies carry a real zb.v now, so they - // shamble in like anything else and their stride animates off distance covered for free. The first - // design drove them by hand because they had marks to stop at. Nothing stops any more.) + // 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 (!bibs().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 diff --git a/src/zombies.js b/src/zombies.js index 4b14432..e4503ba 100644 --- a/src/zombies.js +++ b/src/zombies.js @@ -477,7 +477,12 @@ function killZombie(i, quiet) { const zb = zombies[i]; // The drill's ONE rule, read at the single point every weapon routes through — hooking the ball // path instead would have the swish, the pellets and the blast silently exempt from the order. - if (G.drill && zb.bib !== undefined) { drillKill(zb); zombies.splice(i, 1); return; } + // SPLICE FIRST, THEN RESOLVE. drillKill can draw a new combination on the spot (a double fault + // re-calls the survivors, an emptied batch brings in the next one) and it draws from whatever is + // standing in `zombies` — so running it before the splice let the new combination name the body + // that was in the middle of dying. That is a round asking for a colour which is not on court, + // i.e. exactly the un-winnable state this mode is built to make impossible. + if (G.drill && zb.bib !== undefined) { zombies.splice(i, 1); drillKill(zb); return; } const mul = GORE_MUL[zb.variant] || 1; // BEFORE the early returns below: the ball machine is furniture but it is still a thing you put // down, so it earns a row. The barrel is loot and scores nothing anywhere else in the game — diff --git a/test.js b/test.js index 820c70a..4b864af 100644 --- a/test.js +++ b/test.js @@ -66,7 +66,7 @@ const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, e const { zombies, spawnZombie, pellets } = await import('./src/zombies.js'); const { killPlayer } = await import('./src/zombies.js'); const { drillTarget } = await import('./src/drill.js'); -const { TUNE, bossArt } = await import('./src/config.js'); +const { TUNE, bossArt, COURT } = await import('./src/config.js'); const DT = 1 / 60; function freshSplatter() { @@ -312,65 +312,76 @@ test('a drill refuses to start while another mode owns the court', () => { assert.equal(G.drill, null, 'and leave no half-built round behind'); }); -test('the stream never stops, is spaced, and a round is always live', () => { +test('the puzzle is WINNABLE: every called colour is on the far side, and nothing else wears one', () => { startDrill(); - assert.ok(zombies.length >= 2, 'seeded, so the first round is callable at once'); - assert.ok(G.drill.seq.length >= 2, 'and a round is already called'); - const seeded = zombies.length; - // 18 s, deliberately short of the ~28 s it takes a body to walk the court: a test that never - // shoots WILL eventually be caught, and drillCaught sweeps the court — which is correct behaviour - // and would make this look like the stream had stalled. - for (let i = 0; i < 18 * 60; i++) update(DT); - assert.ok(zombies.length > seeded, 'bodies keep arriving: ' + seeded + ' -> ' + zombies.length); - assert.ok(zombies.length <= TUNE.drillMaxOnCourt, 'never past the court cap'); - assert.equal(new Set(zombies.map(z => z.bib)).size, zombies.length, 'a colour is on court at most once'); - // nothing rushes to a mark and stops: every body carries a real speed and keeps it - assert.ok(zombies.every(z => z.v > 0), 'every body is still walking'); - // SPACING HAS TO SURVIVE THE WALK IN, not just the spawn. Drill bodies hold their lane (the - // gore mode's homing onto the player's x bunched them into a knot right in front of you), so - // this checks every body on court, not only the ones that just arrived. - 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'); - // and the lane a body walks in on is the lane it keeps. Tracked by IDENTITY, not by index — - // the array shifts as bodies cross the baseline and despawn, so zombies[i] is not the same body - // six seconds later (this compared two different zombies and read as a 1.5 m drift). - const lanes = new Map(zombies.map(z => [z, z.x])); - for (let i = 0; i < 6 * 60; i++) update(DT); - for (const z of zombies) if (lanes.has(z)) - assert.ok(Math.abs(z.x - lanes.get(z)) < 0.5, - 'a drill body drifted out of its lane: ' + lanes.get(z).toFixed(2) + ' -> ' + z.x.toFixed(2)); + const called = G.drill.seq; + assert.ok(called.length >= 2, 'a combination is called at once'); + const bibbed = zombies.filter(z => z.bib !== undefined); + assert.equal(bibbed.length, called.length, 'exactly as many bibs on court as colours called'); + assert.deepEqual(bibbed.map(z => z.bib).sort(), called.slice().sort(), + 'the bibs on court ARE the combination — no coloured decoys'); + for (const z of bibbed) + assert.ok(z.z < COURT.netZ, 'every called body starts on the far side, z=' + z.z.toFixed(1)); + assert.equal(new Set(bibbed.map(z => z.bib)).size, bibbed.length, 'a colour names exactly one body'); + // ...and it stays true as the batch walks in: nothing new ever puts an uncalled colour on court + for (let i = 0; i < 25 * 60; i++) { + update(DT); + const live = zombies.filter(z => z.bib !== undefined).map(z => z.bib); + for (const b of live) assert.ok(G.drill.seq.includes(b), 'an uncalled colour appeared on court'); + } }); -test('the stream ramps: later arrivals are faster, come sooner, and the round gets longer', () => { - // eatenR 0 makes the player untouchable — the established soak trick (PROJECT.md). Without it a - // test that never shoots gets caught, the court is swept, and the measurement is of the reset. - const eaten = TUNE.eatenR; TUNE.eatenR = 0; +test('the eaters are the danger: unbibbed, homing, and killing one is never a fault', () => { + const eaten = TUNE.eatenR; TUNE.eatenR = 0; // the soak trick: don't get caught mid-measurement try { startDrill(); - const early = []; - for (let i = 0; i < 20 * 60; i++) { update(DT); for (const z of zombies) if (!z.__seen) { z.__seen = 1; early.push(z.v); } } - const earlyRound = G.drill.seq.length; - G.drill.t = TUNE.drillRampT; // jump to the top of the ramp - const late = []; - for (let i = 0; i < 20 * 60; i++) { update(DT); for (const z of zombies) if (!z.__seen) { z.__seen = 1; late.push(z.v); } } - const avg = a => a.reduce((x, y) => x + y, 0) / Math.max(1, a.length); - assert.ok(late.length > early.length, 'they arrive more often: ' + early.length + ' -> ' + late.length); - assert.ok(avg(late) > avg(early) * 1.5, 'and they walk faster: ' + avg(early).toFixed(2) + ' -> ' + avg(late).toFixed(2)); - G.drill.seq = []; update(DT); // draw a round at the top of the ramp - assert.ok(G.drill.seq.length > earlyRound, - 'and the round is longer: ' + earlyRound + ' -> ' + G.drill.seq.length); + for (let i = 0; i < 40 * 60; i++) update(DT); + const eaters = zombies.filter(z => z.bib === undefined); + assert.ok(eaters.length > 0, 'plain shamblers arrive on their own clock'); + assert.ok(eaters.length <= TUNE.drillEaterMax, 'and are capped at ' + TUNE.drillEaterMax); + assert.ok(eaters.every(z => !z.drill), 'they are NOT lane-holders — they converge on the player'); + // killing one must not touch the combination + const seq = G.drill.seq.slice(), faults = G.drill.faults; + const e = eaters[0]; + for (let i = 0; i < 40 && zombies.includes(e); i++) { + ball.active = true; ball.lastHit = 'P'; ball.y = 1; ball.pow = 1; ball.bounces = 0; + ball.x = e.x; ball.z = e.z; ball.vx = ball.vy = ball.vz = 0; + update(DT); + } + assert.ok(!zombies.includes(e), 'setup: the eater is down'); + assert.deepEqual(G.drill.seq, seq, 'the combination is untouched'); + assert.equal(G.drill.faults, faults, 'and it costs no fault — it never wore a colour'); } finally { TUNE.eatenR = eaten; } }); -test('finishing a round rolls straight into the next one — no pause, no deal', () => { +test('the ramp raises the batch, the walk and the eater rate', () => { + const eaten = TUNE.eatenR; TUNE.eatenR = 0; + try { + startDrill(); + const earlyBatch = G.drill.seq.length, earlyV = zombies.find(z => z.bib !== undefined).v; + let earlyEaters = 0; + for (let i = 0; i < 40 * 60; i++) { update(DT); } + earlyEaters = zombies.filter(z => z.bib === undefined).length; + 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 = zombies.find(z => z.bib !== undefined).v; + assert.ok(lateBatch > earlyBatch, 'the batch grows: ' + earlyBatch + ' -> ' + lateBatch); + assert.ok(lateV > earlyV * 1.5, 'and it walks faster: ' + earlyV.toFixed(2) + ' -> ' + lateV.toFixed(2)); + assert.ok(TUNE.drillEaterMin < TUNE.drillEaterEvery, 'and the eaters come sooner'); + } finally { TUNE.eatenR = eaten; } +}); + +test('finishing a batch rolls straight into the next one — no pause, no deal', () => { drillPlaying(); const seq = G.drill.seq.slice(); for (const bib of seq) drillKillBib(bib); assert.ok(G.drill.rounds >= 1, 'the round is banked'); - assert.ok(G.drill.seq.length >= 2, 'and the next one is ALREADY called, in the same frame'); + update(DT); + const bibbed = zombies.filter(z => z.bib !== undefined); + assert.ok(bibbed.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'); - assert.ok(zombies.length > 0, 'off the bodies still walking at you — the court is never cleared'); + for (const z of bibbed) 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', () => { @@ -381,18 +392,21 @@ test('the drill keeps its own count; the tennis ladder stays out of it', () => { assert.deepEqual(score.pts, pts, '...nor tennis points'); }); -test('two wrong bodies is a double fault, and the round is scrubbed', () => { - drillPlaying(); - const decoy = zombies.find(z => !G.drill.seq.includes(z.bib)); - assert.ok(decoy, 'setup: the court carries a body that was not called'); - drillKillBib(decoy.bib); - assert.equal(G.drill.faults, 1, 'an uncalled body is a fault'); +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 + drillKillBib(wrong1); + assert.equal(G.drill.faults, 1, 'out of turn is a fault'); assert.equal(G.drill.lost, 0, 'but not the round'); - const wrong = zombies.find(z => z.bib !== G.drill.seq[G.drill.step]); - if (wrong) { - drillKillBib(wrong.bib); + const wrong2 = G.drill.seq[G.drill.seq.length - 1]; + if (wrong2 !== undefined && G.drill.seq.length > 1) { + drillKillBib(wrong2); assert.equal(G.drill.lost, 1, 'the second one takes the round off you'); - assert.equal(G.drill.faults, 0, 'and a fresh round is called with a clean slate'); + 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.bib !== undefined).map(z => z.bib).sort(), + 'the new combination is exactly what is still standing'); } });