The roster is the alphabet: the sequence is the zombies themselves
Oliver's insight, and it retires a whole subsystem: "we don't need colors at
all! we already have different zombie types.. so the sequence is the zombies
themselves".
The bib and then the ground disc were both inventions bolted on to give a
body an identity it already had. A brute does not look like a runner, the
game is built on reading those silhouettes at speed, and the strip can show
the body's own walk frame instead of a swatch — no legend, no mapping, no new
art ever. It also deletes the accessibility problem outright rather than
deferring it, because a silhouette is not a hue. DRILL_COLS, drawColourRing,
drawBib, PIP_FACES, blip(), drillRingR and drillTones are all gone.
Two rules keep a type unambiguous: the plain shambler is never in the
alphabet (it is what the eaters are, so "not the puzzle" needs no marking),
and one of each type is on court at a time.
The new failure mode this created is instructive. Types move at different
speeds and drill bodies hold their lane, so one in a lane you are not in
walks the whole court and off the back untouched — stranding the sequence
asking for a body that no longer existed, i.e. the un-winnable state the mode
exists to prevent, reintroduced by a change that looked cosmetic. A batch
body crossing your baseline counts as caught now ("GOT PAST YOU"), which is
also just better design, and the speed spread is narrowed to 1.35..0.70 so a
batch stays coherent. Their real difference is HP and that is untouched: a
runner is one shot, a roller is seven.
Verified: 14400 frames of simulation, zero frames where the sequence named a
body that was not on court.
29 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3379e83efc
commit
5b2f1d55ce
7 files changed
+197
-145
No files matched your search
@@ -65,7 +65,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 } = await import('./src/drill.js');
|
||||
const { drillTarget, DRILL_TYPES } = await import('./src/drill.js');
|
||||
const { TUNE, bossArt, COURT } = await import('./src/config.js');
|
||||
|
||||
const DT = 1 / 60;
|
||||
@@ -282,8 +282,13 @@ function drillPlaying(seconds = 1) {
|
||||
// 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).
|
||||
const drillKillBib = bib => {
|
||||
const zb = zombies.find(z => z.bib === bib);
|
||||
// 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++) {
|
||||
ball.active = true; ball.lastHit = 'P'; ball.y = 1; ball.pow = 1; ball.bounces = 0;
|
||||
@@ -312,34 +317,36 @@ 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 puzzle is WINNABLE: every called colour is on the far side, and nothing else wears one', () => {
|
||||
test('the puzzle is WINNABLE: every called TYPE is on the far side, and nothing else is in the gang', () => {
|
||||
startDrill();
|
||||
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)
|
||||
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(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
|
||||
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);
|
||||
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');
|
||||
for (const z of zombies) if (z.drill)
|
||||
assert.ok(G.drill.seq.includes(z.variant), 'an uncalled type joined the gang');
|
||||
}
|
||||
});
|
||||
|
||||
test('the eaters are the danger: unbibbed, homing, and killing one is never a fault', () => {
|
||||
test('the eaters are the danger: plain shamblers, 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();
|
||||
for (let i = 0; i < 40 * 60; i++) update(DT);
|
||||
const eaters = zombies.filter(z => z.bib === undefined);
|
||||
const eaters = zombies.filter(z => !z.drill);
|
||||
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');
|
||||
assert.ok(eaters.every(z => 'ab'.includes(z.variant)),
|
||||
'an eater is ALWAYS a plain shambler — that is what keeps a called type unambiguous');
|
||||
// killing one must not touch the combination
|
||||
const seq = G.drill.seq.slice(), faults = G.drill.faults;
|
||||
const e = eaters[0];
|
||||
@@ -358,15 +365,14 @@ 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;
|
||||
const earlyBatch = G.drill.seq.length;
|
||||
const earlyV = avgGangSpeed();
|
||||
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;
|
||||
const lateBatch = G.drill.seq.length, lateV = avgGangSpeed();
|
||||
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(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');
|
||||
} finally { TUNE.eatenR = eaten; }
|
||||
});
|
||||
@@ -374,20 +380,20 @@ test('the ramp raises the batch, the walk and the eater rate', () => {
|
||||
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);
|
||||
for (const bib of seq) drillKillType(bib);
|
||||
assert.ok(G.drill.rounds >= 1, 'the round is banked');
|
||||
update(DT);
|
||||
const bibbed = zombies.filter(z => z.bib !== undefined);
|
||||
assert.ok(bibbed.length >= 2, 'a fresh batch is already walking in');
|
||||
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 bibbed) assert.ok(z.z < COURT.netZ, 'and it starts on the far side, as promised');
|
||||
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()) drillKillBib(bib);
|
||||
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');
|
||||
});
|
||||
@@ -396,16 +402,16 @@ 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);
|
||||
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) {
|
||||
drillKillBib(wrong2);
|
||||
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.bib !== undefined).map(z => z.bib).sort(),
|
||||
zombies.filter(z => z.drill).map(z => z.variant).sort(),
|
||||
'the new combination is exactly what is still standing');
|
||||
}
|
||||
});
|
||||
@@ -415,22 +421,40 @@ test('THE AUTO-AIM IS GONE: the shot goes where you point it', () => {
|
||||
// and the fault unreachable — Oliver: "no skills involved at all... there is no penalty".
|
||||
drillPlaying();
|
||||
const called = G.drill.seq[G.drill.step];
|
||||
const wrong = zombies.find(z => z.bib !== called);
|
||||
const wrong = zombies.find(z => z.drill && z.variant !== called);
|
||||
assert.ok(wrong, 'setup: something else is on court');
|
||||
const ft = flickTarget(P, { fx: Math.max(-1, Math.min(1, (wrong.x - P.x) / 9)), fy: -0.95, mag: 140 });
|
||||
assert.notEqual(ft.zt && ft.zt.bib, called, 'pointing away from the called body must not hit it');
|
||||
assert.notEqual(ft.zt && ft.zt.variant, called, 'pointing away from the called body must not hit it');
|
||||
});
|
||||
|
||||
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 && drillKillBib(later)) {
|
||||
assert.ok(!G.drill.seq.includes(later), 'the dead colour leaves the list rather than blocking it');
|
||||
assert.ok(G.drill.seq.every(b => zombies.some(z => z.bib === b)),
|
||||
'every colour still on the list is still on the court');
|
||||
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('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.
|
||||
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
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user