diff --git a/PROJECT.md b/PROJECT.md index e863483..86899af 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -739,6 +739,19 @@ Watch: **a roller in the middle of a sequence is seven aimed serves.** Oliver as robustness to be kept, so it is — but if a round ever feels like grinding rather than reading, a drill-only HP scale is the knob, not a change to the roster. +**TENTH PASS — two testing switches, and neither should be left on by drift.** +- **`TUNE.drillOneShot` (ON)**: every body on a drill court drops to 1 hp. Asked for to make testing + quick, but be aware what it removes — **the roster's HP is what makes the called ORDER matter**. A + roller is normally seven serves, so being told to take it third means starting on it first; at one + hit that whole layer of planning is gone and the sequence is pure recall. Decide it on feel. +- **`TUNE.drillEaterMax` 3 -> 0 (OFF)**: *"remove the fake zombies for now"*. The drill is the pure + puzzle with no second threat at the moment. The mechanism is untouched — `spawnEater`, the wide + entry away from the gang, the never-a-fault rule — so one number brings it back. + The test suite asserts **whichever contract the knob currently describes** (at 0: nothing but the + puzzle ever appears; above 0: they arrive, capped, always plain shamblers) rather than being + deleted and rotting, and the never-a-fault rule is now tested with a hand-placed body so it holds + either way. That is the shape to copy for any other switchable subsystem here. + **Open, and every one of them is Oliver's call after he plays it:** 1. ~~**A ball that carries through the lane can complete two steps at once.**~~ Largely CLOSED by the slice removal (seventh pass) — a cut ball picking its own next victim was the main way this diff --git a/index.html b/index.html index 34154a3..b2ec5b4 100644 --- a/index.html +++ b/index.html @@ -399,6 +399,6 @@ - + diff --git a/src/config.js b/src/config.js index 026a488..9bebfa7 100644 --- a/src/config.js +++ b/src/config.js @@ -367,9 +367,19 @@ export const TUNE = { // no business standing in front of the puzzle. drillEaterEvery: 12.0, // s between plain-shambler eaters at the bottom of the ramp... drillEaterMin: 5.0, // ...and at the top. Still THE difficulty dial, just a slower one - drillEaterMax: 3, // eaters allowed on court at once — past this the puzzle is unreadable + drillEaterMax: 0, // eaters allowed on court at once. **0 = OFF, which is where it is now** — + // Oliver: "remove the fake zombies for now", so the drill is currently the + // pure puzzle with no second threat. The whole mechanism is intact + // (spawnEater, the wide entry, the never-a-fault rule); this one number + // brings it back. Past 3 the puzzle stops being readable. 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 + // TESTING KNOB, currently ON. Every body in the drill dies to one splat-quality hit, whatever the + // roster says it should soak. Flip to 0 for the real thing — and note that this is not purely a + // convenience: the roster's HP is what makes the called ORDER matter (a roller is seven serves, so + // being told to take it third means starting on it first), and one-shot bodies delete that whole + // layer. Decide it on feel rather than leaving it on by drift. + drillOneShot: 1, // >= 0.5: batch bodies AND eaters all drop to 1 hp drillCaughtBack: 45, // s of ramp handed back when one reaches you. A real setback, not a reset 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, diff --git a/src/drill.js b/src/drill.js index b10039b..977b662 100644 --- a/src/drill.js +++ b/src/drill.js @@ -66,6 +66,10 @@ export const drillSpeed = d => lerp(TUNE.drillSpeed, TUNE.drillSpeedMax, ramp(d) const batchSize = d => Math.round(lerp(TUNE.drillBatch, TUNE.drillBatchMax, ramp(d))); const eaterGap = d => lerp(TUNE.drillEaterEvery, TUNE.drillEaterMin, ramp(d)); +// TUNE.drillOneShot: strip a body down to a single hit. Applied at spawn to the batch AND the +// eaters, so nothing on a drill court ever needs a second serve. +const oneShot = zb => { if (TUNE.drillOneShot >= 0.5) zb.hp = zb.hpMax = 1; return zb; }; + 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 gang = () => zombies.filter(z => z.drill); // the batch: a member's identity IS its variant @@ -93,7 +97,7 @@ function spawnBatch(d) { // rank walking in abreast — but every one still starts on the far side, which is the promise. spawnZombie(lanes[i] + (Math.random() - 0.5) * 0.4, TUNE.drillSpawnZ + Math.random() * TUNE.drillBatchDepth, t.v); - const zb = zombies[zombies.length - 1]; + const zb = oneShot(zombies[zombies.length - 1]); zb.drill = true; // marks it as the puzzle AND holds its lane (see updateZombies) zb.v = drillSpeed(d) * t.vMul * (0.92 + Math.random() * 0.16); }); @@ -125,7 +129,7 @@ function spawnEater(d) { // ALWAYS A PLAIN SHAMBLER — rule 1 above doing its work: the alphabet never contains 'a'/'b', so // an eater can never be mistaken for a body the sequence is asking for, with no marking on either. spawnZombie(bx, TUNE.drillSpawnZ + Math.random() * 0.8, Math.random() < 0.3 ? 'b' : 'a'); - const zb = zombies[zombies.length - 1]; + const zb = oneShot(zombies[zombies.length - 1]); zb.v = drillSpeed(d) * TUNE.drillEaterSpeed * (0.9 + Math.random() * 0.2); } diff --git a/test.js b/test.js index c64d284..80ad580 100644 --- a/test.js +++ b/test.js @@ -358,13 +358,14 @@ test('the puzzle is WINNABLE: every called TYPE is on the far side, and nothing } }); -test('the eaters are the danger: plain shamblers, homing, and killing one is never a fault', () => { +test('the eaters obey drillEaterMax — including 0, which is OFF', () => { + // They are switched off right now ("remove the fake zombies for now") but the mechanism is intact, + // so this asserts whichever contract the knob currently describes rather than being deleted and + // rotting. The never-a-fault rule below is checked EITHER WAY, because that is the part that has + // to still be true on the day they are switched back on. const eaten = TUNE.eatenR; TUNE.eatenR = 0; // the soak trick: don't get caught mid-measurement try { startDrill(); - // COUNT ARRIVALS, NOT SURVIVORS. An eater crosses the whole court in about 24 s and then - // despawns, so a snapshot taken at 40 s can legitimately show an empty court — that read as - // "the eaters never came" and made this test fail about one run in five. const seen = new Set(); let peak = 0; for (let i = 0; i < 40 * 60; i++) { @@ -377,24 +378,33 @@ test('the eaters are the danger: plain shamblers, homing, and killing one is nev 'an eater is ALWAYS a plain shambler — that is what keeps a called type unambiguous'); } } - assert.ok(seen.size > 0, 'plain shamblers arrive on their own clock'); - assert.ok(peak <= TUNE.drillEaterMax, 'and never more than ' + TUNE.drillEaterMax + ' at once, saw ' + peak); - const eaters = [...seen].filter(z => zombies.includes(z)); - if (!eaters.length) return; // all of them have walked off again: nothing left to shoot at - // 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'); + assert.ok(peak <= TUNE.drillEaterMax, 'never more than ' + TUNE.drillEaterMax + ' at once, saw ' + peak); + if (TUNE.drillEaterMax === 0) + assert.equal(seen.size, 0, 'at 0 the court carries nothing but the puzzle'); + else + assert.ok(seen.size > 0, 'otherwise plain shamblers arrive on their own clock'); } finally { TUNE.eatenR = eaten; } }); +test('killing a body that is not in the gang is never a fault', () => { + // The rule the eaters depend on, tested with a hand-placed shambler so it holds whether or not + // the spawner is switched on. + drillPlaying(); + const seq = G.drill.seq.slice(), faults = G.drill.faults, step = G.drill.step; + spawnZombie(P.x + 2, 6, 'a'); + const stray = zombies[zombies.length - 1]; + assert.ok(!stray.drill, 'setup: a hand-placed body is not part of the gang'); + for (let i = 0; i < 40 && zombies.includes(stray); i++) { + ball.active = true; ball.lastHit = 'P'; ball.y = 1; ball.pow = 1; ball.bounces = 0; + ball.x = stray.x; ball.z = stray.z; ball.vx = ball.vy = ball.vz = 0; + update(DT); + } + assert.ok(!zombies.includes(stray), 'setup: it is down'); + assert.deepEqual(G.drill.seq, seq, 'the combination is untouched'); + assert.equal(G.drill.faults, faults, 'and it costs no fault'); + assert.equal(G.drill.step, step, 'and does not advance the sequence'); +}); + test('the ramp raises the batch, the walk and the eater rate', () => { const eaten = TUNE.eatenR; TUNE.eatenR = 0; try { @@ -480,6 +490,19 @@ test('a wrong kill never leaves an un-winnable round', () => { } }); +test('drillOneShot drops every body on the drill court to a single hit', () => { + startDrill(); + assert.ok(TUNE.drillOneShot >= 0.5, 'setup: the testing knob is on'); + for (const z of zombies) assert.equal(z.hp, 1, z.variant + ' should be one-shot, saw hp ' + z.hp); + // and it really dies to ONE splat-quality ball, roster HP notwithstanding + const tank = zombies.find(z => z.drill && z.variant === 'k') + || zombies.find(z => z.drill && z.variant === 'g') || zombies.find(z => z.drill); + ball.active = true; ball.lastHit = 'P'; ball.y = 1; ball.pow = 1; ball.bounces = 0; + ball.x = tank.x; ball.z = tank.z; ball.vx = ball.vy = ball.vz = 0; + update(DT); + assert.ok(!zombies.includes(tank), 'one hit put down a ' + tank.variant); +}); + 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