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

+19 -18
View File
@@ -345,27 +345,28 @@ export const TUNE = {
barrelRoll: 2.6, // m/s it trundles across the far court — fast enough to have to be led
barrelHp: 1, // anything pops it: ball, pellet, bomb, flame, swish. It is a target, not a body
barrelH: 1.15, // drawn height (a body is 1.8)
// ---- THE COACHING DRILL (src/drill.js) — the cognitive mode. SECOND DESIGN: a continuous
// stream. Bodies walk in from the far baseline forever; a ROUND is a colour combination drawn from
// whoever is on court, and finishing one draws the next on the spot. No walk-on, no announcement
// beat, no pause between rounds — every seam Oliver found is gone, the round itself stays.
drillMaxOnCourt: 6, // bibs on court at once. Hard-capped by DRILL_COLS (a colour appears once)
drillSeed: 4, // bodies placed at the start so the first round is callable immediately
drillSeedGap: 2.2,// m of depth between those seeded bodies — strung out, not a rank abreast
// ---- THE COACHING DRILL (src/drill.js) — the cognitive mode. THIRD DESIGN, and the one that
// makes it winnable: a BATCH of bibbed bodies is born on the far side, its combination is called
// off them immediately, and every colour on court is in that combination — no coloured decoys, so
// the strip can never name something you cannot see. Kill the last of a batch and the next walks
// in on the same frame. The danger is a separate channel: unbibbed EATERS on their own clock that
// converge on you the way ordinary shamblers do.
drillBatch: 3, // colours in a batch at the bottom of the ramp...
drillBatchMax: 6, // ...and at the top. Capped by DRILL_COLS — a colour is on court at most once
drillBatchDepth: 3.0, // m of depth a batch is staggered over. All of it still on the far side,
// which is the promise: you see every colour before you are asked for it
drillSpawnZ: 0.6, // z they enter at (the far baseline is 0)
drillNearZ: 9.0, // spacing is measured against bodies nearer than this — the ones still bunched
drillSpread: 3.4, // m either side of the centre line the lanes fan across
drillSeq: 3, // colours in a round at the start of the ramp...
drillSeqMax: 5, // ...and at the top of it
drillSpawnEvery: 3.4, // s between arrivals at the start...
drillSpawnMin: 1.2, // ...and at the top. THE difficulty dial: the court fills faster than you clear
drillSpeed: 0.80, // m/s they walk at the start...
drillSpeed: 0.80, // m/s a bibbed body walks at the bottom of the ramp...
drillSpeedMax: 1.9,// ...and at the top. Stamped per body AT SPAWN, so the ramp shows up as new
// arrivals outpacing the ones already walking, not as the court accelerating
drillRampT: 210, // s to go from the bottom of every curve above to the top. Time survived, not
// rounds won — a round-counted ramp cannot escalate while you stand still
drillCaughtBack: 45, // s of ramp handed back when one reaches you. The setback is real but not a reset
drillTones: 1, // volume of the colour arpeggio (0 = silent). It replaced the spoken call
// arrivals outpacing the ones already walking, not the court accelerating
drillEaterEvery: 7.0, // s between unbibbed eaters at the bottom of the ramp...
drillEaterMin: 2.2, // ...and at the top. THE difficulty dial now that the puzzle plays fair
drillEaterMax: 4, // eaters allowed on court at once — past this the puzzle is unreadable
drillEaterSpeed: 1.15,// they walk a touch faster than the batch: the batch is a list, this is a threat
drillRampT: 210, // s to go from the bottom of every curve above to the top
drillCaughtBack: 45, // s of ramp handed back when one reaches you. A real setback, not a reset
drillTones: 1, // volume of the colour arpeggio (0 = silent). It replaced the spoken call
fps: 60, // frame cap — 30 halves battery/heat but Oliver felt it; smoothness wins (0 = uncapped)
lowPower: 1, // >= 0.5 caps RES at 3 (−44% of pixels painted per frame) — the battery saver,
// flippable live from the T panel. DEFAULT ON since 2026-08-08: Oliver judged
+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
+6 -1
View File
@@ -477,7 +477,12 @@ function killZombie(i, quiet) {
const zb = zombies[i];
// The drill's ONE rule, read at the single point every weapon routes through — hooking the ball
// path instead would have the swish, the pellets and the blast silently exempt from the order.
if (G.drill && zb.bib !== undefined) { drillKill(zb); zombies.splice(i, 1); return; }
// SPLICE FIRST, THEN RESOLVE. drillKill can draw a new combination on the spot (a double fault
// re-calls the survivors, an emptied batch brings in the next one) and it draws from whatever is
// standing in `zombies` — so running it before the splice let the new combination name the body
// that was in the middle of dying. That is a round asking for a colour which is not on court,
// i.e. exactly the un-winnable state this mode is built to make impossible.
if (G.drill && zb.bib !== undefined) { zombies.splice(i, 1); drillKill(zb); return; }
const mul = GORE_MUL[zb.variant] || 1;
// BEFORE the early returns below: the ball machine is furniture but it is still a thing you put
// down, so it earns a row. The barrel is loot and scores nothing anywhere else in the game —