From bc891527ed78f3f1d9e17f8141d7ecdd2671a586 Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 26 Jul 2026 22:29:59 +1000 Subject: [PATCH] [lane F] The deploy stops checking a URL nobody visits, and the frame rate gets a switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit item 14. THE DEPLOY VERIFIED THE ONE URL A PLAYER NEVER LOADS. Every check in deploy.sh appends ?v=$BUST — which is exactly what makes it bypass Cloudflare's edge — so all six could pass green while every actual player was still being served the previous build from a POP. The script's answer was a line of prose at the end asking a human to remember to purge. It now fetches js/boot.js the way a browser does, with no query string, and byte-compares it to what was just shipped; a stale edge fails the deploy and prints the purge steps. No credentials needed, and it asks the question the prose was only gesturing at. This is the same mistake as the `assets` bug and the unwinnable saliva tide: verifying the mechanism rather than the path the player actually takes through it. Third time, so it is written into the tool this time rather than into a comment. QUALITY SWITCH. renderer.setPixelRatio was hardcoded to min(dpr, 2), so a retina laptop drew four times the pixels of a 1x one with no way for its owner to decline — and this game is fill-bound, not geometry-bound (fogged tube, additive glows, a full-screen damage layer), so that is the lever that moves. `performance` caps it at 1. On the title beside difficulty, saved, same reload path (the pixel ratio is set at boot, before any card exists). save.js's two one-line setters collapsed into setOpt(key, value) now that there are two options and a third is obvious. Note on verification: the chip persists and boot reads it, confirmed live. The resolution change itself is not observable in the harness — that browser pane runs at devicePixelRatio 1, where min(1,1) and min(1,2) are the same number. Co-Authored-By: Claude Opus 4.8 --- tools/deploy.sh | 24 ++++++++++++++++++++++-- web/js/boot.js | 6 +++++- web/js/core/save.js | 11 +++++++---- web/js/ui/cards.js | 14 +++++++++++++- 4 files changed, 47 insertions(+), 8 deletions(-) diff --git a/tools/deploy.sh b/tools/deploy.sh index 71a7aba..e8c2d76 100755 --- a/tools/deploy.sh +++ b/tools/deploy.sh @@ -94,5 +94,25 @@ echo " · $LIVE (no slash) -> HTTP $REDIR (301/302 to /gutsy/ expected)" [ "$fail" = "0" ] || { echo " ✗ DEPLOY UNVERIFIED"; exit 1; } echo " ✓ deployed: $LIVE/" -echo -echo "Cloudflare fronts partly.party — purge the cache if you redeploy over existing files." + +# --------------------------------------------------------------------- +# THE CHECK ABOVE VERIFIES A URL NOBODY VISITS. +# Every check() appends ?v=$BUST, which is precisely what makes it bypass Cloudflare's edge +# cache — so all six can pass while every actual player is still being served the PREVIOUS +# build from a POP. The script's answer to that was a line of prose at the end telling a human +# to remember. This asks the question properly: fetch a module the way a browser does, with no +# query string, and compare it to the bytes we just shipped. +# --------------------------------------------------------------------- +echo "[5/5] Checking what a PLAYER gets (no cache-buster) ..." +EDGE="$(curl -fsS --max-time 20 "$LIVE/js/boot.js" 2>/dev/null || true)" +if [ -z "$EDGE" ]; then + echo " ⚠ could not fetch $LIVE/js/boot.js un-busted — cannot tell whether the edge is stale" +elif [ "$EDGE" = "$(cat "$ROOT/web/js/boot.js")" ]; then + echo " ✓ edge is serving this build" +else + echo " ✗ STALE EDGE — players are still being served the previous build." + echo " Cloudflare has partly.party/gutsy/js/*.js cached. Purge it:" + echo " Cloudflare dashboard -> partly.party -> Caching -> Configuration -> Purge Everything" + echo " (or Custom Purge on $LIVE/js/). Then re-run this script to confirm." + exit 1 +fi diff --git a/web/js/boot.js b/web/js/boot.js index 4cbb35b..a206366 100644 --- a/web/js/boot.js +++ b/web/js/boot.js @@ -45,7 +45,11 @@ addEventListener('error', (e) => fail('script error', e?.message)); addEventListener('unhandledrejection', (e) => fail('failed to load', e?.reason?.message ?? e?.reason)); const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true }); -renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); +// THE ONE PERF LEVER WORTH HAVING. Device pixel ratio is quadratic in fill cost, so a +// retina laptop was rendering 4x the pixels of a 1x one with no way for its owner to say no. +// Capped at 1 on `low`: everything else in this game is geometry-light and fill-heavy (fogged +// tube, additive glows, a full-screen damage layer), so this is the lever that moves. +renderer.setPixelRatio(Math.min(devicePixelRatio, store.load().quality === 'low' ? 1 : 2)); renderer.setSize(innerWidth, innerHeight); document.getElementById('game').appendChild(renderer.domElement); // A laptop sleeping or a display being hotplugged takes the GL context away; without this the diff --git a/web/js/core/save.js b/web/js/core/save.js index 5976496..285f68d 100644 --- a/web/js/core/save.js +++ b/web/js/core/save.js @@ -15,7 +15,7 @@ const KEY = 'guts.save'; // beats the one on file. const RANK = ['none', 'bronze', 'silver', 'gold', 'platinum']; -const EMPTY = () => ({ v: 1, diff: 'normal', cleared: 0, best: {} }); +const EMPTY = () => ({ v: 1, diff: 'normal', quality: 'high', cleared: 0, best: {} }); export function load() { try { @@ -36,7 +36,8 @@ export function save(s) { return s; } -export function setDiff(id) { const s = load(); s.diff = id; return save(s); } +/** Set one option. Generic because there are two of them now and there will be three. */ +export function setOpt(key, value) { const s = load(); s[key] = value; return save(s); } /** * File a finished run. @@ -88,9 +89,11 @@ if (typeof process !== 'undefined' && process.argv?.[1]?.endsWith('save.js') && ok(load().best.L1_mouth.medal === 'platinum', 'a better medal replaces'); ok(load().best.L1_mouth.score === 1500, 'a worse score does not'); - setDiff('hard'); + setOpt('diff', 'hard'); ok(load().diff === 'hard', 'difficulty persists'); - ok(load().best.L1_mouth, 'setDiff does not clobber records'); + ok(load().best.L1_mouth, 'setOpt does not clobber records'); + setOpt('quality', 'low'); + ok(load().quality === 'low' && load().diff === 'hard', 'options do not clobber each other'); clearCampaign(); clearCampaign(); ok(load().cleared === 2, 'clears count'); diff --git a/web/js/ui/cards.js b/web/js/ui/cards.js index 02164fe..b780a1c 100644 --- a/web/js/ui/cards.js +++ b/web/js/ui/cards.js @@ -446,11 +446,23 @@ export function createCards({ paintDiff(); dBtn.addEventListener('click', () => { S.diff = dOrder[(dOrder.indexOf(S.diff) + 1) % dOrder.length]; - store.setDiff(S.diff); + store.setOpt('diff', S.diff); paintDiff(); go({ diff: null }); // drop any ?diff= override so the save is what takes effect }); + // Quality. Same reload path as difficulty for the same reason — the renderer's pixel ratio + // is set once at boot, before anything this card can reach exists. + const qBtn = el('button', 'chip', menu); + qBtn.type = 'button'; + qBtn.style.color = C.soft; + qBtn.textContent = `quality · ${S.quality === 'low' ? 'performance' : 'full'}`; + qBtn.title = 'caps the render resolution — the one lever that moves the frame rate on a laptop'; + qBtn.addEventListener('click', () => { + store.setOpt('quality', S.quality === 'low' ? 'high' : 'low'); + go({}); + }); + // The one thing a save is FOR: proof you finished. Nothing in this game acknowledged a // completed campaign anywhere the player could see it. if (S.cleared > 0) {