Merge the drill lane fix and the rate-gate sample size into staging

index.html conflicted only on the cache-buster: staging was at 276 and the
branch at 275, so the merge takes 277 — a merge that reuses either number
leaves phones on the old build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-16 12:16:47 +02:00
commit 0c90337f2d
5 files changed
+49 -10

No files matched your search

+33 -3
View File
@@ -414,6 +414,36 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu
`RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud `RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud
FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone. FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone.
## Session log: a lane is free when its body is gone, not when it is far away (2026-08-16)
A seed-sensitive red suite (`SEED=1234 node test.js` failed on the drill's "spaced *after the walk
in*" assertion) turned out to be the assertion telling the truth: **two drill bodies genuinely walked
through each other.** The spawner measured its best-of-9 lane spacing only against bodies with
`z < drillNearZ` (9 m) — sound for a court where bodies wander, wrong for this one, where a lane is
HELD for life and speed is stamped at spawn. A body 12 m down still owns its lane; handing that lane
to a faster arrival does not create a spacing problem at spawn, it books an overtake for ten seconds
later, mid-court, as two sprites occupying the same metre. The fix is the filter: spacing is measured
against every live drill body, `drillNearZ` is deleted (it had no other reader). No retry loop, so
the stream still cannot stall.
Measured, 60 seeds × 150 s of drill each: before, **7 seeds produced overlaps, narrowest clearance
0.00 m**; after, **zero overlap frames, narrowest 0.46 m** (the test asks for > 0.3). Peak on court
is still the cap of 7, so the fan is wider, not thinner. Suite green on 20260811 / 1234 / 777 / 99 /
424242 / 8, and on 299 of seeds 1..300 — the one straggler is unrelated and still open (see below).
**The one straggler the same fuzz found, and the arithmetic to copy for any rate gate here.**
`SEED=80` failed *"opponent one returns a slid serve: 5/10"*. The receiver is innocent: measured over
200 trials a seed across eight seeds, the real return rate is **0.85..0.92**. The gate was simply too
small a sample — asking 6 of 10 at p ≈ 0.88 fails with probability ~0.004, one seed in 230, and 300
seeds duly produced one. **n is now 40 at the same 60% bar**, which puts the threshold five standard
deviations below the true rate while still failing instantly if the receiver regresses toward the
0-in-24 the test was born from. It costs nothing — a returned serve breaks the frame loop early, so
the whole suite still runs in about a tenth of a second. Green on seeds 1..400.
The general lesson: **a pass/fail gate on a random rate needs its sample size derived from the rate,
not guessed.** If the true rate is p and the bar is b, n has to put b several standard deviations
(`sqrt(p(1-p)/n)`) below p, or the gate is a slow coin flip that trains you to re-run the suite.
## Session log: THE NETPLAY LOBBY — a link to send, a code to type, a name to be (2026-08-15) ## Session log: THE NETPLAY LOBBY — a link to send, a code to type, a name to be (2026-08-15)
Oliver: *"multiplayer should be easier to setup — a share with link next to the code; also entering Oliver: *"multiplayer should be easier to setup — a share with link next to the code; also entering
@@ -1000,9 +1030,9 @@ are the ones being called now, which is a better decoy than the old fixed-batch
- **`PLACEMENTS` is deleted along with the marks.** Nothing rushes anywhere and nothing stops, so - **`PLACEMENTS` is deleted along with the marks.** Nothing rushes anywhere and nothing stops, so
there are no shapes to place — the geometry now comes from lane choice at spawn plus the depth the there are no shapes to place — the geometry now comes from lane choice at spawn plus the depth the
stream naturally strings bodies out into. That is a net DELETION of the fanciest code in the file. stream naturally strings bodies out into. That is a net DELETION of the fanciest code in the file.
- **Spacing is best-of-9 lane sampling** against everyone still in the back half, not a retry loop: - **Spacing is best-of-9 lane sampling** against every live drill body (it measured only the back
a hard minimum would silently stall the stream at exactly the moment it is meant to be getting half until 2026-08-16, see that session log), not a retry loop: a hard minimum would silently stall
harder, which reads as the mode dying. the stream at exactly the moment it is meant to be getting harder, which reads as the mode dying.
- **A DRILL BODY HOLDS ITS LANE**, and this was the fix the screenshot forced. `updateZombies` homes - **A DRILL BODY HOLDS ITS LANE**, and this was the fix the screenshot forced. `updateZombies` homes
bodies onto the player's x once they cross the net — a gore-mode behaviour — so they arrived nicely bodies onto the player's x once they cross the net — a gore-mode behaviour — so they arrived nicely
spread and then bunched into an unreadable knot right in front of you. Spawn spacing cannot survive spread and then bunched into an unreadable knot right in front of you. Spawn spacing cannot survive
+1 -1
View File
@@ -659,6 +659,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
</div> </div>
</div> </div>
</div> </div>
<script type="module" src="src/main.js?v=276"></script> <script type="module" src="src/main.js?v=277"></script>
</body> </body>
</html> </html>
-1
View File
@@ -461,7 +461,6 @@ export const TUNE = {
drillSeed: 4, // bodies placed at the start so there is a queue to read from the first second drillSeed: 4, // bodies placed at the start so there is a queue to read from the first second
drillSeedGap: 2.6,// m of depth between those seeded bodies drillSeedGap: 2.6,// m of depth between those seeded bodies
drillSpawnZ: 0.6, // z they enter at (the far baseline is 0) drillSpawnZ: 0.6, // z they enter at (the far baseline is 0)
drillNearZ: 9.0, // spawn spacing is measured against bodies nearer than this
drillSpread: 3.4, // m either side of the centre line the lanes fan across drillSpread: 3.4, // m either side of the centre line the lanes fan across
drillSpeed: 0.85, // m/s at the bottom of the ramp (x the type's vMul)... drillSpeed: 0.85, // m/s at the bottom of the ramp (x the type's vMul)...
drillSpeedMax: 2.0,// ...and at the top. Stamped per body AT SPAWN — constant v is also what lets drillSpeedMax: 2.0,// ...and at the top. Stamped per body AT SPAWN — constant v is also what lets
+8 -4
View File
@@ -73,15 +73,19 @@ function pickType() {
return pool[Math.floor(Math.random() * pool.length)]; return pool[Math.floor(Math.random() * pool.length)];
} }
// ---- THE STREAM. One body at a time, spaced (best-of-9 lanes against the back half — a retry loop // ---- THE STREAM. One body at a time, spaced (best-of-9 lanes against EVERY live body — a retry loop
// would stall the stream exactly when it should be getting harder), holding its lane all the way in. // would stall the stream exactly when it should be getting harder), holding its lane all the way in.
// The measure used to ignore anything past the back half, which was wrong for a court where lanes are
// held for life and speeds are stamped per body: a lane 12 m down is still OWNED, and handing it to a
// faster body just books the overtake for ten seconds later, where it lands as two sprites walking
// through each other mid-court. A lane is free when its body is gone, not when it is far away.
function spawnDrillBody(d) { function spawnDrillBody(d) {
if (zombies.filter(z => z.drill).length >= TUNE.drillMaxOnCourt) return null; const live = zombies.filter(z => z.drill);
const near = zombies.filter(z => z.drill && z.z < TUNE.drillNearZ); if (live.length >= TUNE.drillMaxOnCourt) return null;
let bx = 0, bs = -1; let bx = 0, bs = -1;
for (let k = 0; k < 9; k++) { for (let k = 0; k < 9; k++) {
const x = -TUNE.drillSpread + (2 * TUNE.drillSpread) * (k / 8) + (Math.random() - 0.5) * 0.5; 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; const s = live.length ? Math.min(...live.map(z => Math.abs(z.x - x))) : 1e9;
if (s > bs) { bs = s; bx = x; } if (s > bs) { bs = s; bx = x; }
} }
const t = pickType(); const t = pickType();
+7 -1
View File
@@ -1108,8 +1108,14 @@ test('a served ball with a slide on it is no longer a free kill', () => {
} }
return false; return false;
}; };
// n = 40, NOT 10, and this is the arithmetic to copy for any other rate gate here. The real return
// rate is ~0.88 (measured, 200 trials a seed across eight seeds: 0.85..0.92), so a 10-sample gate
// asking for 6 fails whenever the tail lands — p ~= 0.004, which is one seed in 230 and duly showed
// up as SEED=80. At 40 the same 60% bar sits five standard deviations below the true rate: the gate
// still fails instantly if the receiver regresses toward the 0-in-24 this test was born from, and
// it costs nothing (a returned serve breaks the frame loop early).
let got = 0; let got = 0;
const n = 10; const n = 40;
for (let i = 0; i < n; i++) if (serve(-0.5)) got++; for (let i = 0; i < n; i++) if (serve(-0.5)) got++;
assert.ok(got >= n * 0.6, `opponent one returns a slid serve: ${got}/${n} (was 0)`); assert.ok(got >= n * 0.6, `opponent one returns a slid serve: ${got}/${n} (was 0)`);
}); });