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) {