Production: https://terror-in-the-jungle.pages.dev/
This document captures how a commit becomes a live Cloudflare Pages deploy, how browser freshness is preserved for repeat players, and how to verify prod headers when users report stale assets or load failures.
The 2026-05-13 KONVEYER WebGPU/TSL master merge (PR #192, merge commit
1df141ca) did not change the build-to-deploy path. The Pages bundle now
ships the three/webgpu WebGPURenderer surface as the default, with
automatic WebGL2 fallback for browsers where navigator.gpu is unavailable
or the adapter probe fails. The three-*.js chunk under /build-assets/
grew to ~1.5 MB raw / ~403 KB gzip (from ~734 KB raw / ~187 KB gzip
pre-merge); the immutable cache rule for /build-assets/* already handles
that. Strict-mode evidence (?renderer=webgpu-strict) is a query-string
flag against the deployed shell; production users hit the default 'webgpu'
mode with the fallback gate.
Current stable-ground finding on 2026-05-02: production was healthy but stale.
master was at f99181a0bf8a6b2a8684fc1ae3796022c16aad22, while live
/asset-manifest.json reported
5f585f7d4bf5ad2c0c85450235ac4c9950988d83. Treat this as the canonical
failure mode for release drift: CI green does not imply Pages is current.
Docs checked on 2026-04-21:
- Cloudflare Pages Direct Upload with Wrangler
- Cloudflare Pages
_headers - Cloudflare Pages serving and caching defaults
- Cloudflare content compression
- Wrangler install/update
- Cloudflare R2 uploads
- Cloudflare R2 custom-domain caching
- Cloudflare Workers Static Assets
Deploy is manual. master no longer auto-deploys. CI gates still run on every push, but the actual Cloudflare Pages upload only happens when you trigger it.
push to master
-> .github/workflows/ci.yml (gates only, no deploy)
lint ---\
test ----+-- (parallel)
build ---/
smoke ---\
mobile-ui -+-- (needs: lint, test)
perf (advisory, never blocks)
manual trigger (you decide when)
-> .github/workflows/deploy.yml (workflow_dispatch)
- checkout ref (default: master)
- npm ci (links in-repo packages/* workspaces; no external clone)
- npm run build
- npm run cloudflare:assets:upload
- cloudflare/wrangler-action@v3
- command: pages deploy dist --project-name terror-in-the-jungle
Any of:
npm run deploy:prod- dispatchesdeploy.ymlagainst master's tip, clears problematic GitHub token environment variables, and watches the run.npx tsx scripts/github-workflow-run.ts deploy.yml --ref <branch-or-tag> --watch- deploy a specific ref through the same wrapper.
- GitHub web UI: Actions tab -> "Deploy" workflow -> "Run workflow" button.
Typical flow: push to master, wait for CI green, then run npm run deploy:prod when you actually want the build live. This lets you batch multiple merges into one deploy. If release proof needs an exact-HEAD CI run, use npm run ci:manual instead of calling gh workflow run ci.yml directly. The wrapper first looks for an existing same-commit CI run and watches or reuses it; it only dispatches a manual run when no exact-HEAD CI exists, such as a docs-only release-state commit skipped by the path filters.
The workflow wrappers use scripts/github-workflow-run.ts, which removes
GITHUB_TOKEN and GH_TOKEN from the child gh process. That avoids the
common agent failure where a limited PAT shadows local keyring auth and GitHub
returns Resource not accessible by personal access token.
ci.yml concurrency is scoped by event and ref
(ci-${{ github.event_name }}-${{ github.ref }}). Repeated pushes to the same
ref still cancel stale push attempts, and repeated manual runs still cancel
stale manual attempts, but a manual exact-HEAD proof cannot cancel the push
checks that GitHub shows on master.
The deploy job sets FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true so GitHub-hosted
JavaScript actions run under the upcoming Node 24 action runtime. If this
warning returns, treat it as release workflow maintenance and verify against
current Cloudflare/GitHub action guidance before changing action versions.
Key facts:
- The deploy workflow runs
cloudflare/wrangler-action@v3, not Cloudflare Pages' Git integration. Cloudflare sees only the pre-builtdist/directory. - The
@game-field-kits/*packages are vendored in-repo aspackages/*workspaces (folded back 2026-06-25);npm cilinks them with no external clone or build step. The formerGAME_FIELD_KITS_DEPLOY_KEYsecret is no longer used and can be removed from the repo settings. - The Pages project has no build step configured on Cloudflare's side. The build is reproducible from
package-lock.jsonplusnpm ciinside the GitHub runner. - The deploy workflow does a fresh checkout,
npm ci, andnpm run buildevery run. It does not rely on a CI artifact. npm run buildwrites a previewdist/asset-manifest.jsonfrom local or pinned R2 metadata so local retail previews can resolve required A Shau assets. After that, the deploy workflow runsnpm run cloudflare:assets:uploadwithTITJ_SKIP_R2_UPLOAD=1, overwrites/refreshesdist/asset-manifest.json, and validates public size/content-type/cache/CORS before Pages upload.- GitHub Actions fresh checkouts do not contain gitignored A Shau source files. For the current immutable objects, the asset script uses pinned R2 metadata in CI and validates the live object URLs. Local runs with source files present still hash and upload the real files.
- The GitHub
CLOUDFLARE_API_TOKENcurrently has enough permission for Pages Direct Upload but not R2 object writes. Update that secret to include Account -> Workers R2 Storage -> Edit before removingTITJ_SKIP_R2_UPLOAD=1. - CI
perfruns on every push, uploads artifacts, and is intentionally advisory. Inspect the perf advisory step summary before using a green CI run as release evidence; seedocs/DEVELOPMENT.mdfor why perf does not block. - PRs do not auto-deploy. Preview deploys are not currently configured; see "Open Items" below.
- The build emits gzip sidecars ONLY for the large
/data/binaries (dist/data/navmesh/*.bin.gz,dist/data/heightmaps/*.f32.gz, viascripts/compress-data-assets.tsinnpm run build) because Cloudflare does not compressapplication/octet-stream— the A Shau navmesh shipped 19.4MB raw before this (now ~6.1MB wire). The runtime prefers the sidecar throughDecompressionStreamand falls back to the plain asset (src/utils/CompressedAssetFetch.ts); dev servers serve only the plain assets and hit the fallback. For everything else Cloudflare negotiates visitor-facing compression for supported content types (JavaScript, CSS, JSON, fonts, WASM) based onAccept-Encodingand zone compression rules; no other sidecars are emitted. - Local evidence and deployed evidence are intentionally separate.
npm run evidence:atmosphere,npm run build, andnpm run build:perfprove the local preview bundle and local manifest path. They do not prove that the live Pages deployment is serving the same app shell, service worker, content-hashed build assets, Recast WASM asset, or R2 manifest/DEM URL.
Secrets used by the workflow: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID.
Wrangler status on 2026-04-22:
wrangleris installed project-locally at4.84.1.npm view wrangler versionalso returned4.84.1.- Cloudflare recommends project-local Wrangler; the workflow's
wrangler-action@v3follows the supported Direct Upload path.
The production app must satisfy these rules:
- A repeat visitor gets the newest HTML shell on the next visit after deploy.
- Content-hashed build output can be cached for a year because the filename changes when content changes.
- Non-hashed public assets, including GLBs, must revalidate and must not be pinned in Cache Storage.
- The service worker must not serve a stale shell or stale GLB ahead of the network.
- Cache rules must not overlap in ways that duplicate
Cache-Controlvalues. /asset-manifest.jsonmust report the final intended release git SHA after each manual deploy. If it reports an older SHA, production is stale even if the app appears to load.
This is the line between fast and stale:
build-assets/is Vite output with content hashes. Cache it aggressively.assets/,models/,manifest.json, andsw.jsare stable-path public assets. Revalidate them.data/navmesh/anddata/heightmaps/are seed-keyed baked data. Cache them aggressively.data/vietnam/is a local development compatibility path today. Production resolves required A Shau DEM data throughasset-manifest.json, which points at content-addressed Cloudflare R2 keys. Ifasset-manifest.jsonor the DEM URL returns HTML, any A Shau gameplay evidence is invalid; the runtime/probe should surface that as a required-asset failure, not a harmless fallback.
The repo now sets Vite build.assetsDir = 'build-assets' so generated bundle assets no longer share a URL namespace with mutable files copied from public/assets/.
Cloudflare does not build navmesh data. GitHub Actions runs npm run build,
and the build's prebuild step runs scripts/prebake-navmesh.ts only when the
registered seed assets are missing, their bake-manifest.json signatures are
stale, or --force is used. The seed-keyed assets for Open Frontier, Zone
Control, and TDM are committed under public/data/navmesh/ and
public/data/heightmaps/, copied into dist/, and served by Pages with
immutable cache headers.
Current split:
- Open Frontier: one active prebaked seed in
MapSeedRegistry(42). Older generated Open Frontier files remain in the repo, but seeds137,2718,31415, and65537are intentionally withheld from runtime selection until they have per-seed feature presets. - Zone Control: three prebaked seeds in
MapSeedRegistry. - Team Deathmatch: three prebaked seeds in
MapSeedRegistry. - AI Sandbox/combat120: procedural/small-map runtime path.
- A Shau Valley: no prebaked navmesh asset today. The DEM/rivers payloads are
resolved through the Pages-hosted
asset-manifest.jsonand immutable R2 URLs, thenNavmeshSystembuilds static-tiled navigation at startup around scenario anchors. A Shau startup intentionally fails if terrain/nav evidence is missing instead of falling back to beeline-only movement.
Do not move navmesh binaries into the R2 manifest unless their size or variant count becomes a Pages-upload problem. The current issue is not Cloudflare delivery for prebaked modes; it is runtime route-follow quality and validation, especially for A Shau.
The 2026-05-13 mode-startup investigation verified a common false lead: slow
mode selection was not caused by stale Recast WASM, bad WASM MIME, or missing
navmesh cache headers. Live checks showed content-hashed build assets, Recast
WASM, and public/data/navmesh/* already followed the immutable-cache contract.
The measured blocker was synchronous terrain surface baking after mode select.
That class of issue belongs in runtime startup architecture, not the deploy
cache contract. The active spike branch is
task/mode-startup-terrain-spike; design and evidence are in
docs/rearch/MODE_STARTUP_TERRAIN_BAKE_2026-05-13.md.
Release implication: after deployment, still verify /sw.js,
/asset-manifest.json, Recast WASM/build-asset headers, and navmesh headers,
but do not treat those checks as sufficient proof for mode-start responsiveness.
Run scripts/perf-startup-ui.ts on the deployed or production-shaped bundle.
For A Shau and other asset-heavy modes, the dev gap is usually not TypeScript logic; it is delivery shape:
- local dev may read gitignored compatibility files under
public/data/vietnam/; - local preview now reads generated
dist/asset-manifest.json/dist-perf/asset-manifest.json; - CI/deploy fresh checkouts rely on pinned R2 metadata unless the Cloudflare token can write R2;
- live production depends on Pages freshness, the service-worker update, the
content-hashed build/WASM assets under
/build-assets/, and the R2 URL in/asset-manifest.json.
Release evidence must bridge that gap. After deploy, rerun the header checks in section 7 and open the live URL for at least one A Shau smoke plus one non-A Shau mode smoke. If the live manifest, WASM, or service worker is stale, do not reinterpret a local pass as deployed truth.
Docs-only release-state commits are still release commits. If a commit changes
what the repo claims about production, run npm run ci:manual; the wrapper
will reuse any existing exact-HEAD CI run or dispatch a manual one when the
path-filtered push skipped CI. Then deploy and verify the live manifest before
closing the loop.
The authoritative source is public/_headers, which Cloudflare Pages copies to dist/_headers during npm run build.
| Path pattern | Cache-Control | Why |
|---|---|---|
/ and /index.html |
public, max-age=0, must-revalidate |
HTML must revalidate so it points at the current build's hashed files. |
/sw.js |
public, max-age=0, must-revalidate |
A service-worker update must reach repeat visitors quickly. |
/build-assets/* |
public, max-age=31536000, immutable |
Vite emits content-hashed filenames here, such as index-<hash>.js. |
/assets/* |
public, max-age=0, must-revalidate |
Public assets copied from public/assets/ are not guaranteed to be content-hashed. |
/models/* |
public, max-age=0, must-revalidate |
GLBs are stable-path assets today; correctness beats avoiding 304 round-trips. |
/data/navmesh/* |
public, max-age=31536000, immutable |
Pre-baked navmesh binaries are keyed by <mode>-<seed>.bin. |
/data/heightmaps/* |
public, max-age=31536000, immutable |
Heightmaps are seed-keyed as <mode>-<seed>.f32. |
/data/vietnam/* |
public, max-age=86400 |
Local/development compatibility only until the R2 manifest pipeline owns terrain delivery. |
/asset-manifest.json |
public, max-age=0, must-revalidate |
Small Pages-hosted manifest generated during deploy; must point at current R2 asset keys. |
Cloudflare Pages defaults unmatched static assets to revalidation with ETags. We still keep explicit rules for sw.js and models/* because stale service workers and stale GLBs are user-visible failures.
Cloudflare Pages applies every matching _headers rule. If the same header appears twice, values are joined with commas. Do not add a broad Cache-Control rule that overlaps a more specific one unless the more specific rule first detaches the old header.
This already bit /data/navmesh/* before 2026-04-16:
Cache-Control: public, max-age=31536000, immutable, public, max-age=86400
Keep cache-control path groups non-overlapping.
public/sw.js is served from /sw.js and registered from index.html on window.load.
Current cache name:
const CACHE_NAME = 'titj-v2-2026-04-21';The titj-v2-2026-04-21 bump is intentional. Activating this worker deletes the old titj-v1 Cache Storage entries that could have pinned stale GLBs and stale HTML.
Strategy per URL:
| Request | Strategy |
|---|---|
HTML navigation, /, *.html |
network-first, cached only as offline fallback |
/build-assets/<content-hash>.* |
cache-first |
/data/navmesh/*.bin |
cache-first |
/data/heightmaps/*.f32 |
cache-first |
/models/* |
network/browser HTTP cache only, no Cache Storage |
/assets/* public assets |
network/browser HTTP cache only, no Cache Storage |
/data/vietnam/* |
network/browser HTTP cache only, follows HTTP TTL; local dev fallback only |
| R2 manifest asset URLs | network/browser HTTP cache only; immutable payload cache handled by R2/Cloudflare HTTP headers |
| everything else | network/browser HTTP cache only |
Install uses skipWaiting(). Activate deletes old named caches, enables navigation preload where available, and calls clients.claim().
Hard rule: do not add a broad service-worker cache-first fallback. Cache Storage is only for content-versioned resources.
After a deploy:
- Browser requests
/. - Browser and service worker prefer the network for HTML.
- Fresh HTML references the current
/build-assets/<hash>.jsand CSS files. - Hashed build assets are cache misses if new, then cached forever by HTTP and the service worker.
- GLBs under
/models/are fetched through HTTP revalidation instead of Cache Storage, so an updated model at the same path can propagate. - Old
titj-v1Cache Storage is deleted once the v2 worker activates.
Expected result: users should not need a hard refresh for normal deploys. If a user visited before the v2 worker shipped and still reports stale behavior, first triage is DevTools -> Application -> Clear site data, then reload. That should become rare after the v2 worker has propagated.
Vite dev server, HMR, no production build. Fastest loop. Does not exercise _headers, service worker update behavior, or compression.
Serves dist/ through Vite preview. Closer to prod, but preview does not parse Cloudflare _headers.
Useful for checking that the bundled app boots and assets resolve with the same paths that prod will ship.
scripts/prod-smoke.ts serves dist/ through a local HTTP server, launches headless Chromium, clicks through title -> mode-select -> deploy, and fails on console errors, page errors, 4xx/5xx responses, or deploy-flow regressions.
This is the best local built-app gate, but it still does not validate Cloudflare response headers.
For deploys that touch lighting, atmosphere, terrain/foliage/NPC materials, the lighting rig, or exposure/fog, run the time-of-day cross-material coherence gate before deploying:
npm run build:perf # the A Shau DEM streams from R2; the "pinned R2 metadata" warning is normal
npm run check:tod-coherence # rig-on TOD sweep + NPC fixture; asserts the committed coherence bandIt sweeps A Shau across the day with the lighting rig ON, places an NPC impostor in frame, and asserts the committed tolerances from docs/rearch/LIGHTING_RIG_SPIKE_2026-06-09.md §5 (foliage and NPC luminance track terrain: corr >= 0.92, range ratio in [0.6, 1.6]; no dawn terrain white-out; all TODs measurable). It exits nonzero and writes artifacts/lighting-rig/tod-sweep/gate/verdict.json on failure. This is the gate that would have caught the original cross-material incoherence (SOL-1's channel gates never checked it).
This is a pre-deploy checklist step, not a blocking CI job: a headless GPU TOD sweep is ~5 min and CI GPU runners are starvation-prone (STABILIZAT-1), so it runs on the operator's machine before a lighting/atmosphere deploy rather than gating every push.
After a deploy, run the commands in section 7 against https://terror-in-the-jungle.pages.dev/.
For deploys that touch GLBs, service worker policy, public/assets, or index.html, manually check:
- Chrome or Edge normal profile with prior site data.
- Firefox normal profile with prior site data.
- Safari if available, especially on iOS.
- A private/incognito window as a clean-client control.
The normal-profile check matters because it exercises the old-client update path.
One-shot audit command:
BASE=https://terror-in-the-jungle.pages.dev
for URL in \
"$BASE/" \
"$BASE/sw.js" \
"$BASE/asset-manifest.json" \
"$BASE/favicon.ico" \
"$BASE/manifest.json" \
"$BASE/models/vehicles/aircraft/a1-skyraider.glb" \
"$BASE/assets/ui/icons/icon-fire.png" \
"$BASE/data/navmesh/open_frontier-42.bin" \
"$BASE/data/heightmaps/open_frontier-42.f32" \
"$BASE/data/vietnam/a-shau-rivers.json"
do
echo "=== $URL"
curl -I -s "$URL" | grep -iE '^(cache-control|content-type|content-encoding|etag|cf-cache-status)'
done
# hashed build asset URLs change per build - scrape one from the live HTML first
ASSET=$(curl -s "$BASE/" | grep -oE 'build-assets/[^"]+\.js' | head -1)
echo "=== $BASE/$ASSET"
curl -I -s "$BASE/$ASSET" | grep -iE '^(cache-control|content-type|content-encoding|etag|cf-cache-status)'
R2_ASSET=$(curl -s "$BASE/asset-manifest.json" | node -e "let s='';process.stdin.on('data',d=>s+=d);process.stdin.on('end',()=>console.log(JSON.parse(s).assets['terrain.ashau.dem'].url))")
echo "=== $R2_ASSET"
curl -I -s -H "Origin: $BASE" "$R2_ASSET" | grep -iE '^(cache-control|content-type|content-length|access-control-allow-origin|access-control-expose-headers|accept-ranges|etag)'Expected results:
/:Cache-Control: public, max-age=0, must-revalidate/sw.js:Cache-Control: public, max-age=0, must-revalidate/models/**/*.glb:Cache-Control: public, max-age=0, must-revalidate/assets/*public files :Cache-Control: public, max-age=0, must-revalidate/build-assets/<hash>.js:Cache-Control: public, max-age=31536000, immutable/data/navmesh/*.bin:Cache-Control: public, max-age=31536000, immutable/data/heightmaps/*.f32:Cache-Control: public, max-age=31536000, immutable/asset-manifest.json:Content-Type: application/jsonandCache-Control: public, max-age=0, must-revalidate- R2 A Shau DEM URL from
asset-manifest.json:Content-Type: application/octet-stream, exactContent-Length,Access-Control-Allow-Origin: *on Origin requests, andCache-Control: public, max-age=31536000, immutable
Production caveat history: the 2026-04-21 deploy ran from a fresh checkout, and
public/data/vietnam/ is gitignored. The live /data/vietnam/a-shau-rivers.json
check returned HTML, which proved A Shau runtime data was not deploy-reproducible
from GitHub. The 2026-04-22 R2 manifest pipeline fixed the delivery shape for
the primary DEM path, and the 2026-04-24 manual deploy verified the live Pages
freshness path: /asset-manifest.json served the release git SHA and R2 DEM
URL, stable shell assets revalidated, and hashed build/navmesh/WASM assets were
served immutable. This is a delivery/freshness proof; A Shau route-follow,
water/hydrology, and airfield quality still need gameplay validation.
If a header drifts from this table, inspect public/_headers, then dist/_headers, then the live Cloudflare Pages response.
Local version check:
npx wrangler --version
npm view wrangler versionManual direct upload, if needed:
npm run build
CLOUDFLARE_ACCOUNT_ID=<account-id> npx wrangler pages deploy dist --project-name terror-in-the-jungleAuthentication is only needed for live Cloudflare operations: deploying, listing projects/deployments, or inspecting account-level configuration. Repo-local validation, header policy, and service-worker fixes do not require an API token.
- Custom R2 domain. Current validated asset URLs use
r2.dev; attach a real custom domain before treating the R2 stack as final. - Expand content-hash model pipeline. Primary A Shau DEM/rivers are in R2; future GLBs/large payloads should move through the same manifest after terrain is stable.
- Cross-browser deploy gate. Add a scripted browser matrix against the live Pages URL for Chrome/Edge and Firefox, with a manual Safari/iOS line item.
- Cloudflare Pages PR previews. Current flow deploys only when manually triggered. Add branch deploys if design or QA needs shareable preview URLs.