Sprint 13 gate 3: the front door — splash, pause, touch notice, Enter pinned

The URL is public, so this is the first thing a stranger meets.

ENTER, PINNED. The exploit shipped because the rule lived in a keydown closure
inside boot(), behind a canvas and a WebGL context — nowhere a test could
reach. That's WHY it went out: not carelessness, no seam. `enterCommits(phase,
cardOpen)` is a value now, asserted exhaustively over PHASES so a sixth phase
is covered the day it appears, and the safe default (may not advance) is the
one it gets. Mutation-checked: reverting the guard reddens forecast, storm and
aftermath.

SPLASH. A cold visitor used to land on a job sheet — an invoice-shaped card
from a business they'd never heard of, quoting money for a job nobody had
explained, controls revealed later on prep's help line. It's a good SECOND
card. One more .card, not a menu system: there is nothing to configure. Built
on E's letterhead because the masthead IS the game's name. boot({splash:false})
for benches and D's harness.

P PAUSE — and the test is the story. The pause lives in frame(), frame() is
only called by rAF, and rAF DOES NOT FIRE IN A HIDDEN TAB. My first probe drove
the real loop and reported success: simT advanced 0 while paused... and 0 while
running. It was measuring a frozen tab, and it would have passed no matter what
I wrote. So the rule is a value: accumulate(acc, raw, paused). Paused DRAINS
the accumulator rather than carrying it — a kept `acc` spends time that elapsed
while you were paused on the first frame after resume, which is a free sixtieth
of a second of storm and exactly how two runs of a deterministic sim come to
disagree. Storm-only, and cleared at every phase change: a pause flag left set
where P is inert would be a soft-lock built out of a comfort feature.

M MUTE — wired, honest, and not advertised yet. C's bus has no tap: createAudio
holds `master` inside skyfx's closure and exposes only unlockAudio(). Asked in
THREADS. `sky.setMute?.(on)` behind a key the splash promises would be D's
night-3 soft-lock with a keycap on it — `rigging.setWorld?.(world)` sat in this
same file for a sprint doing nothing, silently, because ?.() on a missing method
looks exactly like a call. So the UI asks before it advertises, and M lights up
by itself the day C lands the tap.

TOUCH NOTICE. The naive check is the bug: `(pointer: coarse)` asks what the
PRIMARY pointer is, so a touchscreen laptop answers coarse and gets locked out
of a game it can play — worse than the dead canvas it replaces. `(any-pointer:
fine)` asks whether a mouse exists at all. Detection errs toward letting people
in; unknown browsers get the game, not a lecture.

DEV LINE. Out from under B's anchor panel (both were top-left, typesetting on
each other), and off unless localhost or ?dev=1 — a stranger on partly.party was
reading "60 fps · storm 12.3s · debris 4" on the front door.

selftest 338/0/0 (was 335).
This commit is contained in:
type-two 2026-07-17 23:59:27 +10:00
parent 5859bc83fa
commit 6e5f4b5222
4 changed files with 415 additions and 22 deletions

View File

@ -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;

View File

@ -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) {
<div class="panel" id="hud-carry" style="display:none"></div>
<div id="hud-events"></div>
<div id="hud-help"></div>
<div id="hud-mute">MUTED</div>
<div id="hud-pause"><div class="word">PAUSED</div><div class="sub">P to carry on</div></div>
`;
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 = `<div class="card splash">
<div class="letterhead">
<div>
<div class="mark">${BUSINESS.mark}</div>
<div class="trade">${BUSINESS.trade}</div>
</div>
</div>
<p class="premise">Rig shade sails, then keep the client's garden alive
through the storm. Five nights, five jobs, one wallet.</p>
<div class="sect">THE CONTROLS</div>
<div class="keys">
${[
['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]) => `<div class="keyrow"><kbd>${k}</kbd><span>${v}</span></div>`).join('')}
</div>
<p class="fineprint">The forecast is a band, not a promise. Nothing you
tie to is as strong as it looks.</p>
<button class="go">START THE WEEK</button>
</div>`;
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 = `<div class="card splash">
<div class="letterhead">
<div>
<div class="mark">${BUSINESS.mark}</div>
<div class="trade">${BUSINESS.trade}</div>
</div>
</div>
<p class="premise">${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.</p>
<p class="fineprint">Grab a laptop and come back. It'll be here.</p>
</div>`;
card.classList.add('on');
hud.setVisible(false);
},
/**
* @param {number} dt
* @param {number} t wind/storm time

View File

@ -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();

View File

@ -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', () => {