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

+65 -60
View File
@@ -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