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:
wuwiandClaude Opus 5 committed 2026-08-11 19:29:04 +02:00
1 parent 586e8d4e4b
commit abd6d4f789
6 files changed
+193 -137

No files matched your search

+71 -57
View File
@@ -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');
}
});