diff --git a/HANDOFF.md b/HANDOFF.md
index be0128e..616b713 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -320,11 +320,34 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu
frame entirely. `xLim = Math.max(xVis, COURT.W / 2)` — the camera limit, floored at the sideline so
every legal ball stays catchable.
+## TEST MODE — triple-tap the scoreboard
+
+A sandbox for trying new content without playing twenty games to reach it. Three taps on the
+scoreboard (the same shape as the umpire's triple-tap cheat next to it in main.js) toggles
+`G.testMode`; three more turns it off, and a SINGLE tap is still the miss-log overlay.
+
+- a fixed MEDIUM wave: `testZMax` 8 bodies, `testZSpeed` 1.15 m/s, `testSpawnEvery` 1.6 s — it
+ overrides the difficulty director rather than editing it, so the sandbox is the same whether you
+ opened it in game 1 or game 40, and `G.runGames` keeps climbing underneath.
+- `testLevel` 20 is the roster level `pickVariant` pretends to be at, so every body type can appear.
+- every barrel weapon on the rack, TOPPED BACK UP each frame (`stockTestRack`), so committing a
+ burst — which spends the whole tin — leaves it available again a moment later.
+- a barrel every `testBarrelEvery` seconds, so the pickup itself is testable.
+
+**The third tap must not toggle `logShow`.** Taps one and two already toggled it twice, so it is
+back where it started; toggling again in the triple branch leaves the miss-log overlay switched on
+behind you every time you enter the mode.
+
## Verification recipe (the regression net)
Drive the game deterministically via `window.T` with `javascript_tool`. The Browser-pane eval
context reports `document.visibilityState === 'hidden'`, so RAF is paused; halt it anyway with
`window.requestAnimationFrame = () => 0` and step with `T.step(1/60)`. `T.press('Space')` = tap.
Clear the intro first: `document.getElementById('intro')?.remove()`.
+- **The module-refresh list MUST include the VERSIONED entry point.** `index.html` loads
+ `main.js?v=N`, and `fetch('main.js', {cache:'reload'})` does not bust that URL — so main.js alone
+ stays stale through a reload while every other module is fresh. Read the src off the page:
+ `document.querySelector('script[type=module]').getAttribute('src')` and fetch that too. Symptom: a
+ change in main.js appears to do nothing at all, repeatably.
- **You MUST set `T.TUNE.autoAssist = 1.15` first** — the Browser pane reports `pointer: fine`, so
the mobile assist is off and every ball reads as "never reachable".
- Force splatter with `T.forceZombies()` then ~600 steps (in splatter it forces a HORDE instead).
diff --git a/config.js b/config.js
index 1740706..c908605 100644
--- a/config.js
+++ b/config.js
@@ -230,6 +230,15 @@ export const TUNE = {
mgPelletR: 0.5, // m of extra hit radius on a pellet: a fat round. "Bigger ball = more damage."
dmgPelletHp: 2, // hp a pellet takes off a body — shamblerHp is 2, so the horde gun one-shots them
mgPickupLen: 8, // s the machine-gun tin (4th delivery) auto-fires outside hordes
+ // ---- TEST MODE (triple-tap the scoreboard). A sandbox for trying the new content: a steady
+ // MEDIUM wave regardless of how far into a run you are, and every barrel weapon on the rack,
+ // kept topped up so nothing runs dry mid-test. It overrides the difficulty director rather than
+ // editing it — G.runGames keeps climbing underneath and the run is still a real run.
+ testZMax: 8, // bodies on court: enough to test a crowd weapon, few enough to read
+ testZSpeed: 1.15, // m/s shamble — medium, and FIXED (no per-game ramp)
+ testSpawnEvery: 1.6,// s between spawns
+ testLevel: 20, // the roster level pickVariant pretends to be at, so every body type can turn up
+ testBarrelEvery: 14,// s between barrels, so the pickup itself is testable too
// ---- THE LASER's wedge: aim and release like a serve, and a plane of light widens down the lane
beamLen: 26, // m it reaches — the whole court, so a lane you aim is a lane you clear
beamW0: 0.35, // m half-width at the racket...
diff --git a/gameplay.js b/gameplay.js
index 656d68d..0b4d893 100644
--- a/gameplay.js
+++ b/gameplay.js
@@ -1,6 +1,6 @@
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
-import { TUNE, UI, BOSS_ART, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX } from './config.js';
+import { TUNE, UI, BOSS_ART, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS } from './config.js';
import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js';
import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck, menuDuck } from './audio.js';
import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js';
@@ -548,6 +548,42 @@ export function weaponModal(id, healLine = null) {
icon: w.icon, iconH: 40, color: UI.warm });
}
+// ---- TEST MODE. Triple-tap the scoreboard. A sandbox for trying the new content without playing
+// twenty games to reach it: a steady medium wave and every barrel weapon on the rack, topped back up
+// as you spend it. It does NOT stop the run — games, duels and bosses all still happen underneath;
+// it only overrides the difficulty dials and keeps the rack stocked.
+export function toggleTestMode() {
+ G.testMode = !G.testMode;
+ if (!G.testMode) { G.flash = { text: 'TEST MODE OFF', t: 1.4 }; return false; }
+ if (G.mode === 'tennis') forceZombies(); // no zombies to test against in a tennis match
+ stockTestRack();
+ G.flash = { text: 'TEST MODE', t: 1.6 };
+ showModal({ title: 'TEST MODE', color: UI.pink,
+ body: ['A STEADY MEDIUM WAVE', 'EVERY BARREL WEAPON, KEPT FULL',
+ '', 'TRIPLE-TAP THE SCOREBOARD AGAIN TO STOP'] });
+ return true;
+}
+
+// every barrel weapon on the rack at full stock. Called on entry and then kept topped up in update,
+// so a burst you commit (which spends the whole tin) is available again a moment later.
+function stockTestRack() {
+ for (const id of Object.keys(BARREL_WEAPONS)) {
+ const have = G.rack.find(e => e.id === id);
+ if (have) have.n = Math.max(have.n, WEAPONS[id].tin);
+ else if (G.rack.length < RACK_MAX) G.rack.push({ id, n: WEAPONS[id].tin });
+ }
+}
+let testBarrelT = 0;
+export function tickTestMode(dt) {
+ if (!G.testMode) return;
+ stockTestRack();
+ if (G.mode !== 'splatter' || G.bossFight || G.interlude) return;
+ if ((testBarrelT -= dt) <= 0) {
+ testBarrelT = TUNE.testBarrelEvery;
+ if (!zombies.some(z => z.variant === 'o')) spawnBarrel();
+ }
+}
+
// ---- the interlude: order is restored, one sudden-death point vs the racket zombie.
// Tennis rules apply for exactly one point — the absurdity peak: the umpire never stopped watching.
// This is now the ONLY source of weapons in the game: win the point or leave empty-handed.
@@ -1197,6 +1233,7 @@ export function update(dt) {
// --- zombies + transition timeline
if (G.mode !== 'tennis') updateZombies(dt);
+ tickTestMode(dt); // keeps the sandbox stocked and rolls it a barrel now and then
if (G.meleeT > 0) G.meleeT -= dt;
if (G.clearing && !zombies.some(blocksClear)) proceedAfterClear(); // the won-game wave is mopped up — advance
if (G.mode === 'transition') transition(dt);
diff --git a/index.html b/index.html
index b08f571..aff33c0 100644
--- a/index.html
+++ b/index.html
@@ -179,6 +179,6 @@
-
+