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
@@ -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