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:
parent
5859bc83fa
commit
6e5f4b5222
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user