diff --git a/web/world/index.html b/web/world/index.html
index 69be022..c78f73a 100644
--- a/web/world/index.html
+++ b/web/world/index.html
@@ -13,7 +13,14 @@
color: #eef4f8; text-shadow: 0 1px 2px #0009;
pointer-events: none; user-select: none;
}
- #dev { top: 10px; }
+ /* SPRINT13 gate 3 — the dev line moved out from under Lane B's anchor panel
+ (top:12/left:12; this was top:10/left:10, so they typeset on top of each
+ other all sprint — QA snag list). Top-RIGHT now, and hidden unless asked
+ for: see main.js's dev-line block. A stranger on partly.party was reading
+ "60 fps · storm 12.3s · debris 4" on the front door of a public game, which
+ is a dev build talking to someone who came to play. */
+ #dev { top: 10px; right: 10px; left: auto; text-align: right; display: none; }
+ #dev.on { display: block; }
#help { bottom: 10px; opacity: .75; }
#banner {
position: fixed; top: 38%; left: 0; right: 0;
diff --git a/web/world/js/hud.js b/web/world/js/hud.js
index fe4f340..e79e000 100644
--- a/web/world/js/hud.js
+++ b/web/world/js/hud.js
@@ -75,6 +75,22 @@ const CSS = `
#hud-help { position:absolute; bottom:12px; left:50%; transform:translateX(-50%);
color:#93a6b2; opacity:.85; white-space:nowrap; }
+/* SPRINT13 gate 3 — pause and mute.
+ The veil sits UNDER #hud-card (z 30) so a card always wins: pausing can never
+ hide the job sheet or the invoice. It is a veil and not a card because the
+ yard behind it is the point — you paused to LOOK at something. */
+#hud-pause { position:fixed; inset:0; z-index:20; display:none; place-items:center;
+ background:#060a0d99; backdrop-filter:blur(2px); pointer-events:none; }
+#hud-pause.on { display:grid; }
+#hud-pause .word { font:700 34px/1 ui-monospace,Menlo,monospace; letter-spacing:.34em;
+ color:#dde5ea; text-indent:.34em; }
+#hud-pause .sub { margin-top:10px; text-align:center; color:#8ba0ad; letter-spacing:.1em; }
+/* Muted has to be visible from anywhere or the player thinks the sound broke. */
+#hud-mute { position:absolute; top:12px; right:12px; display:none;
+ padding:4px 9px; border:1px solid #3a4a56; border-radius:4px;
+ color:#8ba0ad; letter-spacing:.16em; font-size:11px; }
+#hud-mute.on { display:block; }
+
#hud-card { position:fixed; inset:0; z-index:30; display:none; place-items:center;
/* SPRINT12: E's letterhead + brief + conditions made the tall cards taller
than a short window, and a RIG IT button below the fold with no scroll is a
@@ -184,6 +200,24 @@ const CSS = `
color: #55677a; border-bottom: 1px solid #1c262d; padding-bottom: 5px;
}
+/* --- the splash (SPRINT13 gate 3) ---------------------------------------
+ Built ON E's letterhead rather than beside it: the masthead is the game's
+ name, so the front door is the same sheet of paper as everything else. The
+ only rules here are the ones the letterhead doesn't already own. */
+#hud-card .splash .letterhead { border-bottom-width: 2px; padding-bottom: 12px; }
+#hud-card .splash .mark { font-size: 30px; }
+#hud-card .premise { margin: 14px 0 4px; color: #dde5ea; font-size: 14px; line-height: 1.6; }
+#hud-card .fineprint { margin: 14px 0 0; color: #6d8494; font-style: italic; line-height: 1.55; }
+#hud-card .keys { display: grid; gap: 3px; }
+#hud-card .keyrow { display: flex; gap: 12px; align-items: baseline; padding: 2px 0; }
+#hud-card .keyrow kbd {
+ flex: none; min-width: 62px; text-align: center; padding: 3px 7px;
+ background: #16222a; border: 1px solid #33454f; border-bottom-width: 2px;
+ border-radius: 4px; color: #cfe0ea; font: inherit; font-size: 11px;
+}
+#hud-card .keyrow span { color: #93a6b2; }
+#hud-card .splash .go { margin-top: 18px; width: 100%; padding: 12px; font-size: 14px; }
+
/* --- the pay schedule (job sheet) --------------------------------------
This is a QUOTE, not a receipt: it's what the night is worth if you do it
right. It reuses your .row shape so it lines up with everything else, but
@@ -302,6 +336,8 @@ export function createHud(d) {
+
MUTED
+
PAUSED
P to carry on
`;
document.body.appendChild(root);
@@ -322,6 +358,9 @@ export function createHud(d) {
const elPct = $('#hud-garden-pct'), elBar = $('#hud-bar i'), elShade = $('#hud-shade');
const elGardenLabel = $('#hud-garden-label');
const elCarry = $('#hud-carry'), elEvents = $('#hud-events'), elHelp = $('#hud-help');
+ const elPause = $('#hud-pause'), elMute = $('#hud-mute');
+ /** Set by main.js once it has asked skyfx whether the bus has a tap. */
+ let muteAvailable = false;
// --- world-anchored corner load bars ------------------------------------
// A bar per corner, floating at the corner it describes. Geometry rather than
@@ -407,6 +446,96 @@ export function createHud(d) {
setHelp(text) { elHelp.textContent = text; },
+ /** P — the pause veil. Render keeps running; main.js stops the accumulator. */
+ setPaused(on) { elPause.classList.toggle('on', !!on); },
+
+ /** M — reads the state main.js owns. Only shown once the bus is real. */
+ setMuted(on) { elMute.classList.toggle('on', !!on); },
+
+ /**
+ * Does Lane C's audio bus have a mute tap yet? main.js asks skyfx and tells
+ * us. Until it does, M is not advertised on the splash or the help line —
+ * a promised key that does nothing is worse than no key, and this game is
+ * public now. Flips itself on the day C lands `setMute`.
+ */
+ setAudioMuteAvailable(on) { muteAvailable = !!on; },
+
+ /** The comfort keys, listed only where they're real. See setAudioMuteAvailable. */
+ comfortKeysHint() { return muteAvailable ? 'P pause · M mute' : 'P pause'; },
+
+ /**
+ * THE FRONT DOOR (SPRINT13 gate 3).
+ *
+ * partly.party's arcade can send a stranger straight here, and before this
+ * they landed on a job sheet: an invoice-looking card from a business they'd
+ * never heard of, quoting money for a job nobody had explained, with the
+ * controls appearing only later on prep's help line. The premise of the game
+ * was never once stated on the glass.
+ *
+ * It's one more `.card` — not a menu system. There is nothing to configure:
+ * one week, five jobs, one button. E's letterhead does the work, because the
+ * masthead IS the game's name and the joke lands before the premise does.
+ *
+ * @param {() => void} onStart
+ */
+ showSplash(onStart) {
+ card.innerHTML = `
+
+
+
${BUSINESS.mark}
+
${BUSINESS.trade}
+
+
+
Rig shade sails, then keep the client's garden alive
+ through the storm. Five nights, five jobs, one wallet.
+
THE CONTROLS
+
+ ${[
+ ['WASD', 'walk the yard'],
+ ['mouse', 'orbit the camera'],
+ ['click', 'pick an anchor · again to unpick'],
+ ['[ ]', 'tension the rig — tighter holds shape, and breaks harder'],
+ ['S', 'buy a spare shackle'],
+ ['E', 'repair a blown corner (ladder if it is up high)'],
+ ['C', 'brace against the wind'],
+ ['Enter', 'commit the rig and start the night'],
+ ...(muteAvailable ? [['P · M', 'pause · mute']] : [['P', 'pause']]),
+ ].map(([k, v]) => `
${k}${v}
`).join('')}
+
+
The forecast is a band, not a promise. Nothing you
+ tie to is as strong as it looks.
+
+
`;
+ card.classList.add('on');
+ hud.setVisible(false);
+ card.querySelector('.go').addEventListener('click', () => { hud.hideCard(); onStart(); });
+ },
+
+ /**
+ * The phone visitor. A public URL gets phones — DESIGN.md's game is WASD and
+ * a mouse orbit, so there is no touch story to tell and pretending otherwise
+ * would waste their time. Before this they got a dead canvas and no reason.
+ *
+ * No START: this is the one card without a way through, on purpose. Sending
+ * someone into a yard they cannot walk is worse than telling them plainly.
+ */
+ showTouchNotice() {
+ card.innerHTML = `
+
+
+
${BUSINESS.mark}
+
${BUSINESS.trade}
+
+
+
${BUSINESS.mark} needs a keyboard and a mouse —
+ you walk the yard and orbit the camera to rig a sail, and there's no
+ honest way to do that with a thumb.
+
Grab a laptop and come back. It'll be here.
+
`;
+ card.classList.add('on');
+ hud.setVisible(false);
+ },
+
/**
* @param {number} dt
* @param {number} t wind/storm time
diff --git a/web/world/js/main.js b/web/world/js/main.js
index e216420..2ccfb24 100644
--- a/web/world/js/main.js
+++ b/web/world/js/main.js
@@ -59,6 +59,94 @@ export function stormsToPreload(nights = NIGHTS.map((_, i) => nightAt(i).storm))
const STORMS = stormsToPreload();
+/**
+ * May Enter commit the rig right now? The ONLY route into `game.advance()` that
+ * a key may take.
+ *
+ * A function, and exported, because it is a security-shaped rule on a PUBLIC
+ * game and the handler that used to hold it is inside boot(), behind a canvas
+ * and a WebGL context — i.e. somewhere no assert can reach. That's precisely how
+ * the exploit shipped: `addEventListener('keydown', …)` fell through to
+ * `game.advance()` in EVERY phase, so Enter DURING a storm jumped straight to a
+ * perfect invoice — the storm never ran, "every corner held", full pay, clean
+ * bonus, +$90 banked on partly.party where strangers could find it. The
+ * integrator hit it in a QA pass, not a test, because there was no seam to test.
+ *
+ * So the rule is a value now. Enter means ONE thing: commit the rig and start
+ * the night.
+ * · `prep` only — forecast and aftermath advance through their own cards, and
+ * a storm advances when it ENDS. Nothing else may move the phase machine.
+ * · not while a card is open — the card owns the keyboard, and its button is
+ * the only way through.
+ *
+ * @param {string} phase game.phase
+ * @param {boolean} cardOpen hud.cardOpen
+ */
+export function enterCommits(phase, cardOpen) {
+ return phase === 'prep' && !cardOpen;
+}
+
+/**
+ * Can this device play HARD YARDS at all? (SPRINT13 gate 3 — the touch notice.)
+ *
+ * The question is NOT "is this a touchscreen", and getting that wrong is the
+ * whole reason this is a function with a comment. `(pointer: coarse)` asks what
+ * the PRIMARY pointer is, so a touchscreen laptop — finger on the glass, mouse
+ * on the desk, perfectly able to play — answers "coarse" and gets locked out by
+ * a courtesy card. That's a worse bug than the dead canvas it replaces, because
+ * it takes the game away from someone who could play it.
+ *
+ * `(any-pointer: fine)` asks the question that actually matters: is there a
+ * mouse, trackpad or stylus attached AT ALL? A phone says no. A tablet says no.
+ * A hybrid laptop says yes and plays. Detection errs toward LETTING PEOPLE IN:
+ * an old browser that doesn't know the query returns `matches: false` for both,
+ * and the `?? true` means an unknown device gets the game rather than a lecture.
+ *
+ * Keyboard can't be feature-detected at all — no browser exposes "is there a
+ * keyboard" — so a fine pointer is the honest proxy, and the notice says what we
+ * need in words rather than pretending to know.
+ */
+/**
+ * The fixed-dt accumulator, as a value: how many steps does this frame owe, and
+ * what's left over? (SPRINT13 gate 3 — P.)
+ *
+ * Pulled out of `frame()` for the same reason `enterCommits` was pulled out of
+ * the keydown handler: `frame()` is only ever called by requestAnimationFrame,
+ * and rAF does not fire in a hidden tab — so a pause "test" driven through the
+ * real loop sits there measuring a sim that was already frozen and reports
+ * success. I wrote that probe, and it passed: simT advanced 0 while paused, and
+ * 0 while running. The pause rule is a value now, so it can be checked by
+ * something other than luck.
+ *
+ * PAUSED DRAINS THE ACCUMULATOR rather than leaving it standing. `acc` holds up
+ * to one FIXED_DT of unspent real time; carrying it across a pause spends it on
+ * the first frame after resume — a free sixtieth of a second of storm nobody
+ * asked for, which is invisible right up until two runs of a deterministic sim
+ * disagree about a gust.
+ *
+ * @param {number} acc unspent seconds carried from last frame
+ * @param {number} raw this frame's real delta, already clamped by the caller
+ * @param {boolean} paused
+ * @param {number} [max] step ceiling — a breakpoint must not run 4000 steps
+ * @returns {{steps:number, acc:number}}
+ */
+export function accumulate(acc, raw, paused, max = 60) {
+ if (paused) return { steps: 0, acc: 0 };
+ let left = acc + raw;
+ let steps = 0;
+ while (left >= FIXED_DT && steps < max) { steps++; left -= FIXED_DT; }
+ return { steps, acc: left };
+}
+
+export function canPlayHere(mm = typeof matchMedia === 'function' ? matchMedia : null) {
+ if (!mm) return true; // no matchMedia (node, old): let them in
+ try {
+ return mm('(any-pointer: fine)').matches ?? true;
+ } catch {
+ return true;
+ }
+}
+
/**
* How fast an unprotected garden dies, in HP per second at full rain.
*
@@ -535,6 +623,9 @@ export async function boot(opts = {}) {
let simT = 0;
let windT = 0;
let acc = 0;
+ /** SPRINT13 gate 3 — the front door's two comfort keys. Neither is sim state: */
+ let paused = false; // P: the accumulator stops; nothing deterministic sees a dt
+ let muted = false; // M: Lane C's bus, once it has a tap (see setMuted)
function windTime() {
if (game.phase === 'storm') return game.phaseT;
@@ -824,6 +915,17 @@ export async function boot(opts = {}) {
events.length = 0;
rigging.setActive(to === 'prep');
+ // A pause never survives the phase it was taken in. Leaving `paused` true on
+ // the way out of a storm would carry it into the next night, where P is
+ // inert (nothing but the storm has a clock) — so the flag would be stuck on,
+ // unreachable, and the following storm would open frozen with no way to
+ // unfreeze it. That's a soft-lock built out of a comfort feature, which is
+ // the same shape as the night-3 one D found: a state nothing could clear.
+ // makeSky() also hands back a fresh skyfx, so the mute has to be re-applied
+ // or muting silently expires at the phase boundary.
+ if (paused) { paused = false; hud.setPaused(false); }
+ if (muted) setMuted(true);
+
if (to === 'storm') { pondPeak = 0; pondDumped = 0; }
if (to === 'forecast') {
@@ -850,7 +952,11 @@ export async function boot(opts = {}) {
hud.setHelp('click an anchor to rig · click again to cycle hardware · shift-click to remove · [ ] tension · S spare · ENTER when you have four');
}
if (to === 'storm') {
- hud.setHelp('WASD move · shift run · E repair/pickup · C brace · RMB orbit');
+ // P is only offered where it does something (the storm is the one clock
+ // that doesn't wait), and M only once C's bus has a tap — hud owns that
+ // question, because a help line that lists a dead key is a help line that
+ // teaches a stranger the game is broken.
+ hud.setHelp(`WASD move · shift run · E repair/pickup · C brace · RMB orbit · ${hud.comfortKeysHint()}`);
}
if (to === 'aftermath') {
// The dawn comes up BEFORE the scoreboard, on E's 2.2 s ease. Their note
@@ -874,14 +980,8 @@ export async function boot(opts = {}) {
});
addEventListener('keydown', (e) => {
- if (e.key !== 'Enter' || hud.cardOpen) return;
- // Enter means ONE thing: commit the rig and start the night. It used to
- // fall through to game.advance() in every phase, which made Enter DURING
- // a storm skip straight to a perfect invoice — the storm never ran, every
- // corner "held", full pay. Found in the SPRINT12 QA pass, live on the
- // public deploy. Forecast/aftermath advance through their cards (cardOpen
- // catches those above); nothing else may advance the phase machine.
- if (game.phase !== 'prep') return;
+ if (e.key !== 'Enter') return;
+ if (!enterCommits(game.phase, hud.cardOpen)) return;
if (!rigging.commit()) return;
// Off the bank, not off START_BUDGET — see the note on showTonight().
spentThisNight = week.bank - rigging.summary.budget;
@@ -890,9 +990,72 @@ export async function boot(opts = {}) {
game.advance();
});
- // Straight into the forecast: the card is the game's front door, and it now
- // opens on night one of five rather than a difficulty menu.
- showTonight();
+ /**
+ * P — pause. Storm only, and that is not a limitation, it's the whole scope:
+ * forecast and aftermath are already paused by construction (their cards are
+ * up and the sim isn't running), and prep has no clock. The storm is the only
+ * ninety seconds in the game that don't wait for you, which is exactly why a
+ * stranger on a public URL needs a way to stop it.
+ */
+ function setPaused(on) {
+ if (game.phase !== 'storm') on = false; // nothing else has a clock to stop
+ if (on === paused) return paused;
+ paused = on;
+ hud.setPaused(paused);
+ return paused;
+ }
+
+ /**
+ * M — mute.
+ *
+ * ⚠️ The bus is Lane C's and it does not have a tap yet. `createAudio()` holds
+ * a `master` gain inside skyfx.js's closure and the only thing it exposes is
+ * `unlockAudio()`, so there is nothing here for main.js to turn down. Asked in
+ * THREADS: `setMute(on)` on the skyfx API, one line against their master gain.
+ *
+ * Until it lands this returns FALSE, and the HUD asks before it advertises M
+ * (see hud.setAudioMuteAvailable). That is deliberate and it is D's lesson from
+ * the night-3 soft-lock: `rigging.setWorld?.(world)` sat in this same file for
+ * a sprint doing NOTHING, silently, because `?.()` on a missing method is a
+ * no-op that looks like a call. Shipping `sky.setMute?.(on)` behind a key the
+ * splash promises would be the same bug with a keycap on it — on a public URL,
+ * where the person pressing M is a stranger who just wants the noise to stop.
+ * So the key is wired, the state is real, and the UI tells the truth about
+ * whether it does anything. It lights up by itself the day C lands the tap.
+ */
+ function setMuted(on) {
+ muted = !!on;
+ const bus = typeof sky?.setMute === 'function';
+ if (bus) sky.setMute(muted);
+ hud.setMuted(muted);
+ return bus;
+ }
+
+ addEventListener('keydown', (e) => {
+ if (hud.cardOpen) return; // a card owns the keyboard
+ const k = e.key.toLowerCase();
+ if (k === 'p') setPaused(!paused);
+ else if (k === 'm') setMuted(!muted);
+ });
+
+ // SPRINT13 gate 3 — the front door, in front of the forecast.
+ //
+ // The job sheet used to be the first thing a stranger saw: an invoice-shaped
+ // card from a business they'd never heard of, quoting money for a job nobody
+ // had explained. It's a good SECOND card. partly.party's arcade can drop
+ // someone here cold, so the game says what it is first.
+ //
+ // `opts.splash === false` skips it — the selftest, the dev benches and D's
+ // playtest harness all boot straight into a night, and none of them should
+ // have to click through a door.
+ hud.setAudioMuteAvailable(typeof sky?.setMute === 'function');
+ if (!canPlayHere()) {
+ hud.showTouchNotice(); // no way through, on purpose: see hud.showTouchNotice
+ } else if (opts.splash === false) {
+ showTonight();
+ } else {
+ hud.showSplash(() => showTonight());
+ }
// --- resize -------------------------------------------------------------
function resize() {
@@ -906,9 +1069,26 @@ export async function boot(opts = {}) {
// --- loop ---------------------------------------------------------------
const clock = new THREE.Clock();
- const dev = document.getElementById('dev');
let frames = 0, fpsT = 0, fps = 0;
+ /**
+ * The dev line: fps, phase, sim clock, debris count.
+ *
+ * On by default for anyone developing (localhost, and every lane clone is
+ * localhost) and for anyone who asks with `?dev=1` — D's playtests read it,
+ * and taking it away to tidy a public page would cost more than it saves.
+ * Off everywhere else, which today means partly.party: it's the only thing on
+ * the glass that talks to us instead of the player.
+ */
+ const devWanted = (() => {
+ try {
+ if (new URLSearchParams(location.search).has('dev')) return true;
+ return /^(localhost|127\.0\.0\.1|\[::1\])$/.test(location.hostname);
+ } catch { return false; }
+ })();
+ const dev = devWanted ? document.getElementById('dev') : null;
+ dev?.classList.add('on');
+
function step(dt) {
game.tick(dt);
simT += dt;
@@ -953,12 +1133,15 @@ export async function boot(opts = {}) {
// Clamped so a background tab or a breakpoint doesn't make the sim try to
// catch up over thousands of steps and lock the page.
const raw = Math.min(0.25, clock.getDelta());
- acc += raw;
- let guard = 0;
- while (acc >= FIXED_DT && guard++ < 60) {
- step(FIXED_DT);
- acc -= FIXED_DT;
- }
+
+ // SPRINT13 gate 3 — P pauses the ACCUMULATOR, not the frame: the sim stops
+ // dead (no step(), so no dt reaches anything deterministic) while the render
+ // keeps going, which is what lets the pause veil sit over a frozen yard
+ // instead of a black screen. The rule itself is `accumulate` — a value, and
+ // tested, because nothing here is reachable from a test.
+ const a = accumulate(acc, raw, paused);
+ for (let i = 0; i < a.steps; i++) step(FIXED_DT);
+ acc = a.acc;
cameraRig.update(raw, player.pos);
sailView?.update();
diff --git a/web/world/js/tests/a.test.js b/web/world/js/tests/a.test.js
index 5f639b1..aa221a3 100644
--- a/web/world/js/tests/a.test.js
+++ b/web/world/js/tests/a.test.js
@@ -4,11 +4,14 @@
*/
import * as THREE from '../../vendor/three.module.js';
-import { ANCHOR_TYPE, FIXED_DT, STORM_LEN, YARD, checkContract, createStubWind } from '../contracts.js';
+import { ANCHOR_TYPE, FIXED_DT, PHASES, STORM_LEN, YARD, checkContract, createStubWind } from '../contracts.js';
import { createWindField } from '../weather.core.js';
import { createWorld, heightAt, loadSite, validateSite } from '../world.js';
import { createCameraRig } from '../camera.js';
-import { CALM_STORM, createGame, createWindRouter, stormsToPreload, verdictFor } from '../main.js';
+import {
+ CALM_STORM, accumulate, canPlayHere, createGame, createWindRouter, enterCommits,
+ stormsToPreload, verdictFor,
+} from '../main.js';
import { orderRing } from '../sail.js';
import { loadStorm, createWind } from '../weather.js';
import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW, PAY } from '../week.js';
@@ -57,6 +60,77 @@ export default async function run(t) {
assertEq(checkContract('game', createGame()).join('; '), '');
});
+ // --- SPRINT13 gate 3: the front door of a PUBLIC game --------------------
+
+ t.test('ENTER CANNOT SKIP THE STORM — the exploit that shipped, pinned', () => {
+ // The QA pass found this live on partly.party: Enter during a storm fell
+ // through to game.advance() and paid a perfect invoice for a storm that
+ // never ran — garden 100%, "every corner held", clean bonus, +$90. It
+ // shipped because the rule lived in a keydown closure inside boot(), behind
+ // a canvas and a WebGL context, where no assert could reach it. The rule is
+ // a value now, so this can fail.
+ //
+ // Exhaustive over the phase machine rather than a spot-check on 'storm': a
+ // sixth phase added later is asserted the day it appears, and the default it
+ // gets is "may not advance", which is the safe direction.
+ for (const phase of PHASES) {
+ assertEq(enterCommits(phase, false), phase === 'prep',
+ `Enter in '${phase}' must ${phase === 'prep' ? 'commit' : 'do NOTHING'}`);
+ }
+ // The storm is the money one — name it, so a reader of a red run knows what
+ // broke without decoding the loop above.
+ assertEq(enterCommits('storm', false), false, 'Enter mid-storm cannot bank a night that never ran');
+ assertEq(enterCommits('aftermath', false), false, 'nor re-advance the invoice');
+ assertEq(enterCommits('forecast', false), false, 'nor skip the job sheet');
+
+ // A card owns the keyboard while it's up: its button is the only way through.
+ for (const phase of PHASES) {
+ assertEq(enterCommits(phase, true), false, `a card is open in '${phase}' — Enter is the card's, not the rig's`);
+ }
+ });
+
+ t.test('P stops the sim dead, and gives back no free time on resume', () => {
+ // Tested as a value, and that is the point. The pause lives in frame(),
+ // frame() is only called by requestAnimationFrame, and rAF DOES NOT FIRE IN
+ // A HIDDEN TAB — so my first probe drove the real loop, measured simT
+ // advancing 0 while paused, 0 while running, and reported success. It was
+ // measuring a frozen tab. This can actually fail.
+ const running = accumulate(0, FIXED_DT * 3.5, false);
+ assertEq(running.steps, 3, 'three whole steps out of three and a half');
+ assert(running.acc > 0 && running.acc < FIXED_DT, 'and the half-step is carried, not spent');
+
+ const paused = accumulate(running.acc, FIXED_DT * 10, true);
+ assertEq(paused.steps, 0, 'paused: the sim takes not one step');
+ assertEq(paused.acc, 0, 'and the accumulator is DRAINED — no free sixtieth on resume');
+
+ // The bug the drain prevents, stated: carry `acc` across a pause and the
+ // first frame after resume spends time that elapsed while you were paused.
+ assertEq(accumulate(0, 0, false).steps, 0, 'a zero-delta frame owes nothing');
+ assertEq(accumulate(FIXED_DT * 0.9, FIXED_DT * 0.9, false).steps, 1, 'carried time still adds up to a step');
+
+ // A breakpoint or a background tab must not run thousands of steps at once.
+ assertEq(accumulate(0, 10, false).steps, 60, 'the step ceiling holds');
+ assertEq(accumulate(0, 10, false, 5).steps, 5, 'and it is the caller\'s to set');
+ });
+
+ t.test('the touch notice locks out phones, not touchscreen laptops', () => {
+ // The bug this exists to prevent is the FIX, not the gap: `(pointer: coarse)`
+ // asks what the primary pointer is, so a laptop with a touchscreen answers
+ // "coarse" and gets a courtesy card instead of the game it can perfectly well
+ // play. Taking the game away from someone who could play it is worse than the
+ // dead canvas we're replacing.
+ const mm = (answers) => (q) => ({ matches: !!answers[q] });
+ assertEq(canPlayHere(mm({ '(any-pointer: fine)': true })), true, 'a mouse anywhere means play');
+ assertEq(canPlayHere(mm({ '(any-pointer: fine)': false })), false, 'a phone gets the notice');
+ // The hybrid: primary pointer coarse (finger), but a trackpad exists.
+ assertEq(canPlayHere(mm({ '(any-pointer: fine)': true, '(pointer: coarse)': true })), true,
+ 'touchscreen laptop plays — this is the case the naive check gets wrong');
+ // Unknown/old browsers err toward letting people in, never toward a lecture.
+ assertEq(canPlayHere(null), true, 'no matchMedia at all: let them in');
+ assertEq(canPlayHere(() => { throw new Error('nope'); }), true, 'a throwing matchMedia: let them in');
+ assertEq(canPlayHere(() => ({})), true, 'a browser that answers nothing: let them in');
+ });
+
// --- the week (SPRINT8 gate 1) -------------------------------------------
t.test('the week is five escalating nights, each a storm and a site', () => {