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