The umpire reads a list, and the bibs come out to meet it
The late game is repetitive because the optimal move never changes: kill the nearest thing, now. This is the first swing at a mode where it does — a coaching drill where the umpire CALLS a colour sequence aloud while bibbed bodies take their marks, and you must put them down in that order while they walk at you. Uncalled colours are decoys you have to ignore. Two faults is a double fault and the round is theirs. Back room only (DRILL TEST, or #drill): this is a feel-test for one question — tense, or just noisy? — so it keeps its own round count and touches neither tennis scoring nor the run. - voiceSeq: voice() drops a line that would start while the umpire is still talking, which is fatal for a list. Decode first, queue at accumulated offsets, and the anti-overlap rule is satisfied instead of fought. - Four colours with PIPS as well as hue — red/green alone makes the mode unplayable for ~8% of men. - drawBib is a ponytail-marked overlay that knowingly breaks the sprite rule; it is the thing to delete if the mode survives. - BOTH target pickers snap (flickTarget and zombieTarget). Snapping only the slingshot left fifteen tap-serves in a row scoring nothing. - Contact costs the point, not the run — permadeath stays in the gore mode. - startDrill disarms the ordinary schedule; updateDrill redeals and logs an INVARIANT if anything empties its court. Eight new tests (24 total) pin the ownership contract both ways, the call beat, the win, the double fault, the un-winnable-round guard and the death. PROJECT.md carries the four open questions for the playtest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c659b6882f
commit
22df96cc1d
17 files changed
+556
-19
No files matched your search
@@ -62,8 +62,10 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' });
|
||||
// ---- game modules (real code) ----------------------------------------------
|
||||
const { G, P, score, ball, setupPoint } = await import('./src/state.js');
|
||||
const { update, forceZombies, splatPoint, restartRun } = await import('./src/gameplay.js');
|
||||
const { startDuel, resolveInterlude, startBossFight, endBossFight } = await import('./src/beats.js');
|
||||
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 { TUNE, bossArt } = await import('./src/config.js');
|
||||
|
||||
const DT = 1 / 60;
|
||||
@@ -269,5 +271,110 @@ test('a burst fires through the beat that follows the kill that emptied the cour
|
||||
assert.ok(G.mgT > 0, 'and its window is still open');
|
||||
});
|
||||
|
||||
// ---- the coaching drill: a new mode, so it owes the ownership contract a test ------------------
|
||||
|
||||
// Drive a drill round past its announcement: no serving and no targets while the umpire reads.
|
||||
function drillPlaying() {
|
||||
startDrill();
|
||||
for (let i = 0; i < TUNE.drillCallT * 60 + 4; i++) update(DT);
|
||||
}
|
||||
// kill a body the way every weapon does — through the shared funnel, via the ball's contact test
|
||||
// A shambler soaks TUNE.shamblerHp splat-quality hits, so this re-arms the ball until the body is
|
||||
// actually down — the drill keeps the roster's toughness on purpose (Oliver: "some are easier, some
|
||||
// more difficult to kill"), which means a round is paced by hp as well as by order.
|
||||
const drillKillBib = bib => {
|
||||
const zb = zombies.find(z => z.bib === bib);
|
||||
assert.ok(zb, 'bib ' + bib + ' should be on court');
|
||||
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;
|
||||
ball.x = zb.x; ball.z = zb.z; ball.vx = ball.vy = ball.vz = 0;
|
||||
update(DT);
|
||||
}
|
||||
assert.ok(!zombies.includes(zb), 'the body should be down');
|
||||
};
|
||||
|
||||
test('a drill owns the court: every other mode refuses while it runs', () => {
|
||||
assert.ok(startDrill(), 'a drill starts on a clean court');
|
||||
startDuel();
|
||||
assert.equal(G.interlude, false, 'startDuel must refuse during a drill');
|
||||
forceZombies();
|
||||
assert.equal(G.horde, null, 'startHorde must refuse during a drill');
|
||||
startBossFight(1);
|
||||
assert.equal(G.bossFight, 0, 'startBossFight must refuse during a drill');
|
||||
assert.equal(startDrill(), false, 'and a drill must refuse a second drill');
|
||||
});
|
||||
|
||||
test('a drill refuses to start while another mode owns the court', () => {
|
||||
startDuel();
|
||||
assert.ok(G.interlude, 'setup: the duel has the court');
|
||||
assert.equal(startDrill(), false, 'startDrill must refuse during a duel');
|
||||
assert.equal(G.drill, null, 'and leave no half-built round behind');
|
||||
});
|
||||
|
||||
test('the call is not play: no target and no serving until the umpire finishes', () => {
|
||||
startDrill();
|
||||
assert.ok(G.drill.callT > 0, 'the walk-on is running');
|
||||
assert.equal(drillTarget(), null, 'nothing is a legal target during the call');
|
||||
assert.equal(zombies.length, TUNE.drillBodies, 'the whole court is placed up front');
|
||||
assert.equal(new Set(zombies.map(z => z.bib)).size, zombies.length, 'every bib on court is distinct');
|
||||
for (let i = 0; i < TUNE.drillCallT * 60 + 4; i++) update(DT);
|
||||
assert.ok(drillTarget(), 'and the first target arms when the call ends');
|
||||
assert.equal(drillTarget().bib, G.drill.seq[0], 'which is the first colour called');
|
||||
});
|
||||
|
||||
test('killing in the called order wins the round; the tennis ladder stays out of it', () => {
|
||||
drillPlaying();
|
||||
const seq = G.drill.seq.slice(), games = score.games.slice(), pts = score.pts.slice();
|
||||
for (const bib of seq) drillKillBib(bib);
|
||||
assert.equal(G.drill.won, 1, 'the round is won');
|
||||
assert.equal(G.drill.faults, 0, 'cleanly');
|
||||
assert.deepEqual(score.games, games, 'the drill does not bank tennis games');
|
||||
assert.deepEqual(score.pts, pts, '...nor tennis points — it keeps its own count');
|
||||
for (let i = 0; i < TUNE.drillBeat * 60 + 4; i++) update(DT);
|
||||
assert.ok(zombies.length > 0, 'and the next round places a fresh court');
|
||||
});
|
||||
|
||||
test('two wrong bodies is a double fault, and the round goes to them', () => {
|
||||
drillPlaying();
|
||||
// a colour the sequence never called is a DECOY — the body you must let walk at you
|
||||
const decoy = zombies.find(z => !G.drill.seq.includes(z.bib));
|
||||
assert.ok(decoy, 'setup: the court carries at least one decoy');
|
||||
drillKillBib(decoy.bib);
|
||||
assert.equal(G.drill.faults, 1, 'a decoy is a fault');
|
||||
assert.equal(G.drill.lost, 0, 'but not the round');
|
||||
assert.ok(G.drill.rush > 0, 'the rest lose patience — the punishment is tempo');
|
||||
const wrong = zombies.find(z => z.bib !== G.drill.seq[G.drill.step]);
|
||||
assert.ok(wrong, 'setup: something is still out of turn');
|
||||
drillKillBib(wrong.bib);
|
||||
assert.equal(G.drill.lost, 1, 'the second one takes the round off you');
|
||||
assert.equal(G.drill.won, 0);
|
||||
});
|
||||
|
||||
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
|
||||
drillKillBib(later);
|
||||
assert.equal(G.drill.faults, 1, 'out of turn is a fault');
|
||||
assert.ok(!G.drill.seq.includes(later), 'and 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');
|
||||
});
|
||||
|
||||
test('being caught costs the point, not the run', () => {
|
||||
drillPlaying();
|
||||
killPlayer();
|
||||
assert.notEqual(G.mode, 'eaten', 'permadeath stays in the gore mode');
|
||||
assert.equal(G.drill.lost, 1, 'they take the round instead');
|
||||
assert.equal(zombies.length, 0, 'and the court is cleared for the next one');
|
||||
});
|
||||
|
||||
test('endDrill hands the court back', () => {
|
||||
drillPlaying();
|
||||
endDrill();
|
||||
assert.equal(G.drill, null);
|
||||
assert.equal(zombies.length, 0, 'the drill takes its bodies with it');
|
||||
assert.ok(startDuel() !== false && G.interlude, 'and an ordinary mode can have the court again');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} tests passed`);
|
||||
process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report
|
||||
Reference in new issue
Block a user