The step-5 check added in the previous commit failed on its first real deploy, and it was
right: cf-cache-status HIT, age 158, cache-control max-age=14400, and the edge serving the
PREVIOUS boot.js. forum-nginx sends no Cache-Control for /gutsy/ at all, so Cloudflare applies
its 4-hour default to a game that ships un-versioned ES modules with no build step — what the
edge holds is code, not assets.
tools/forum-nginx-default.conf is the container's config with a /gutsy/ block adding
Cache-Control: no-cache (store, but revalidate — the files are etag'd, so it costs a 304).
Scoped to /gutsy/; nothing else on partly.party is touched. tools/CACHE.md has the measurement
and the two commands.
NOT APPLIED. Copying a file into the live forum-nginx container is gated for me, which is the
right gate on shared infrastructure hosting partly.party — I have not tried to route around it.
Until John runs it, a deploy reaches players when the 4-hour TTL lapses or the cache is purged.
The container has no bind mount for that config, so a force-recreate wipes the block; the file
is in the repo so re-applying is the same two commands.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Audit items 9, 10 and 11 — three features whose backends were finished and whose front ends did
not exist.
DIFFICULTY WAS UNREACHABLE. levels/difficulty.js has carried three fully specified tiers since
round 2 — Endoscopy, Clinical, Terminal, with per-tier assists and a note explaining each — and
every single call site in the codebase took the default. `easy` and `hard` were a table nobody
could select. getLevel now takes the saved difficulty (?diff= still wins, so a test URL stays
absolute), and the title has the switch. Verified live: the world boots at 11 spawns on Terminal
against 8 on Clinical.
THE CAMPAIGN WAS SIX LEVELS WITH ONE WAY IN. Reaching anything but L1 meant hand-editing ?lvl=
in the address bar. The title now lists the campaign, unlocking each segment as the one before
it is filed, showing the medal you hold on each. `LS_` is C's secret prefix, so the appendix
stays a row of question marks until you have earned your way in — naming it in the menu would
have given away the one thing in this game you have to discover.
THE CAMPAIGN HAD NO ENDING. C's anal_verge gate carries `to: null` — the only gate in the game
that does, its own note calling it "the end of the campaign" — and boot's ui:continue handler
opened with `if (!e.to) return`. Finishing GUTS parked you on a medal card forever with no way
out but the address bar, and the card said "any key", the same words it uses when it has nothing
to tell you. Now the clear is filed and counted, the card says you are out, and the title carries
CANAL CLEARED.
New core/save.js is the whole persistence layer: one localStorage key, one flat object, best
time / best score / best medal kept per column (they need not come from the same run) plus an
attempt count. Every entry point is wrapped — Safari private mode throws on setItem, and a save
file must never be able to stop the game booting. Its selfcheck (`node web/js/core/save.js
--selfcheck`, now in qa.sh) covers the best-of comparisons and feeds it corrupt, future-versioned
and malformed saves.
cards.js records rather than boot: this file computes the grade, so routing it through the bus
for boot to re-derive would be two copies of the medal table waiting to disagree.
One bug found by clicking rather than reading: the first cut of the chips had no
pointer-events:auto. #ui is pointer-events:none and it inherits — the file's own header says so
— so the buttons rendered, highlighted nothing, and passed the click through to the canvas,
which on the title starts the run. It looked like a menu and behaved like wallpaper.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
I shipped a working tree earlier and had to tell John "live matches no commit in
the repo" — which is exactly the sentence a deploy should make impossible.
deploy.sh now writes the HEAD short-sha to web root as VERSION, and verifies it
came back. `curl https://partly.party/gutsy/VERSION` answers "what is online right
now" without trusting anyone's memory of the last deploy.
The dirty check is scoped to `git status --porcelain -- web/`, not the whole
tree, because only web/ ships: editing docs or this script does not make the
payload unreproducible, and a stamp that cried wolf on every NOTES edit would get
ignored. A dirty payload ships as `<sha>-dirty` and says so, loudly, rather than
refusing — mid-round lanes need to push a look at something without committing
first, and a deploy tool that blocks that will just get bypassed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
README said "target: partly.party, not wired yet". It's wired.
tools/deploy.sh follows the GAMES doctrine (deploy-map skill): QA gate -> rsync
web/ to VPS staging -> docker cp into forum-nginx -> verify. No build step, so
web/ ships as-is: vanilla ES modules + importmap with three r175 vendored, all
paths relative or resolved from import.meta.url, which is why it runs from a
subdirectory with no base rewrite. 6.6 MB. nginx.conf needed no edit — its
catch-all `root /usr/share/nginx/html` already routes any new directory, which is
how blobbo works. /gutsy (no slash) 301s to /gutsy/.
Excludes web/dev/ (ruling #5: never shipped). Verified live, not assumed: the
lane harnesses 404 through to the arcade index.
THE 200 THAT ISN'T. That same catch-all try_files means ANY missing file returns
the arcade's index.html with HTTP 200. A deploy that lost every module would
still curl 200 on all of them. So every check asserts on CONTENT, never on the
status code — this is the failure deploy-map records as ".bin data URLs serving
arcade HTML". The guard caught itself, too: it originally grepped for
`<title>MonsterRobot`, but the arcade's real title is
`~*~W3LC0M3 2 TH3 M0NST3R R0B0T P4RTY 4RC4D3~*~`, so it could never have fired.
Matched against the live box now.
DURABILITY, and it's John's call. This docker-cp's into the container's own
layer, matching blobbo/glytch: zero downtime, nothing else touched, survives
restarts and reboots (restart: unless-stopped) — but NOT `docker compose up
--force-recreate` or an nginx image bump, which wipe it. Re-running the script is
the fix. The durable alternative is a bind mount in forum/docker-compose.yml like
cratewars/roguelike have, which costs a forum-nginx recreate and brief downtime
across ALL of partly.party, so this script does not take that decision.
ship-check (README says run it before deploying — I ran it after; my miss, and it
came back clean):
1 auth N/A — static files, no endpoint/API/admin surface
2 secrets PASS — nothing secret-shaped in the payload or the diff
3 input/SSRF PASS — the only user string reaching a fetch is ?lvl=, and C
gates it on the CAMPAIGN allowlist BEFORE the load
(levels/index.js:151). Traversal tested live with
`curl --path-as-is` (plain curl normalises ../ away and proves
nothing): /etc/passwd never leaked. DEPOT_BASE in assets.js is
unused — the manifest has zero external URLs.
4 money N/A
5 deploy PASS — verified by content + booted in a real browser:
assets.misses() == [], 15 draws, 69k tris, zero console errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Generated on MODELBEAST flux_local, on-device, $0.00. Lane D's pipeline used
unchanged; **D owns these — ratify, retune or bin them.**
Why A did D's job: the six-biome tint contact sheet could only judge 3 of 6
biomes for real (oral/large_intestine/appendix had no pack and fell back to
procedural), and you cannot ratify a tint against a stand-in. All six are now
textured — the set ART_BIBLE §Biome palettes promises.
- wall_oral_a/b, wall_colon_a/b + normals. Seams 6.35/9.30/14.06/6.37 -> 1.01/
1.08/0.74/1.36 (D's "indistinguishable" band; the shipped pack's weakest is
2.39). Means normalised to ~0.50 by D's derive_maps, untouched.
- appendix wears the colon pack at its own gold tint (TEXTURE_SHARE in
world/index.js) — anatomically it IS colon tissue, and it's ART_BIBLE's
"one texture, two biomes" law for zero bytes. Distinct from the temporary
slug map; keep it when that dies.
- oral_a took three subjects. v1 named an ORGAN ("tongue") and FLUX drew the
organ's silhouette — D's "one urchin of villi" in a field's clothing. v2 named
the tissue but papillae are 3D projections, so perspective gave it a vanishing
point that tiles into starbursts. v3 asks for squamous cell pavement: flat by
construction. Rule for the kit: prompt the SURFACE, not the FEATURE.
Evidence: docs/shots/laneA/round2_oral_prompt_evolution.png
PROVENANCE DEFECT FOUND AND FIXED (-> Lane D):
batch_textures.json recorded, for 4 of 6 walls, the prompt from the framing
experiment D tried and rejected — not the prompt that made the shipped pack.
record() only runs on generation, so reverting a prompt while keeping its image
(exactly what D correctly did: "four winners kept untouched rather than churned")
drifts the record silently. Proved by regenerating esophagus_a at seed 1101 under
both prompts: the code's prompt reproduces the shipped wall, the recorded one
gives the bland wood-grain D described rejecting. Evidence:
docs/shots/laneA/round2_provenance_probe.png. Records re-pointed at the code's
prompt (images untouched — they were never wrong).
New: gen_textures.py --verify-provenance + qa.sh gate 5b, so it cannot rot again.
qa GREEN incl. the new gate; 11/11 provenance verified; all six biomes eyeballed
in engine (docs/shots/laneA/round2_tint_*.png).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wired B's player/combat + event pump, D's assets, C's level pick into boot.
Fixed qa ESM gate (node --check no-op, found by B) + added spline selfcheck.
TECH: world contract FROZEN v1.1, bus events ratified, colorspace + crest-speed
laws (CREST_FACTOR 1.6 — B proved surfing lost to throttle at 1.0). Stub complies.
Verified integrated build via 60s stepped sim: 9 draws, 0 errors, 0 asset misses;
one known gap (fiction-id spawn resolve) confirmed and assigned as B's top item.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>