Every colour you are asked for is already on the far side
"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 <noreply@anthropic.com>
This commit is contained in:
1 parent
586e8d4e4b
commit
abd6d4f789
6 files changed
+193
-137
No files matched your search
+31
@@ -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
|
||||
|
||||
+1
-1
@@ -399,6 +399,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="src/main.js?v=156"></script>
|
||||
<script type="module" src="src/main.js?v=157"></script>
|
||||
</body>
|
||||
</html>
|
||||
+19
-18
@@ -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
|
||||
|
||||
+65
-60
@@ -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
|
||||
|
||||
+6
-1
@@ -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 —
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user