From de620bfaac527adc5612efe2e7b5001bdb4df37a Mon Sep 17 00:00:00 2001 From: Wesley Ray Date: Fri, 28 Aug 2026 21:17:00 -0400 Subject: [PATCH] Stage the ACannons browser build at /cannons A third instance of the pattern notebooks-export/ and the reading payloads already use: an artifact built somewhere with a toolchain this repo's node:24-slim container does not have, dropped into a gitignored directory, staged into public/ by a script, and picked up by auto-build.sh's signature on the next tick. Nothing here needs a JDK, Go, or an opinion about the game -- cannons_reloaded's deploy/export-cannons.sh hands over a directory that is already ready to serve. Three things are load-bearing and easy to get wrong later: The payload is at /cn/, not /cannons/. With build.format:'file' the page builds to cannons.html, and try_files would then have a file and a directory competing for the same URL. That is exactly why the notebook exports live at /nb/ and not under /notebooks/. nginx gets gzip_static for /cn/. nginx:alpine has `gzip on` commented out in its base config, so without it the page ships 9MB instead of 3.3MB and nothing anywhere reports that. It gets no-cache rather than an expiry, because classes.js and cannons.wasm are stable filenames whose contents change on every rebuild -- a long expiry would pair a stale client with a fresh server. The page loads on click, not `loading="lazy"`. Lazy only defers until the frame nears the viewport and this frame is above the fold, so a few MB would download for anyone who opened the page to read the paragraph. A missing export is a normal state throughout: copy-cannons.mjs stages nothing, the page says so, and the sidebar link does not appear. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01S4wdbGSVzvUDHBWRjSVxfr --- .gitignore | 5 ++ deploy/auto-build.sh | 6 ++ deploy/nginx.conf | 16 +++++ package.json | 2 +- scripts/copy-cannons.mjs | 46 ++++++++++++++ src/layouts/Base.astro | 15 +++++ src/lib/cannons.mjs | 34 ++++++++++ src/pages/cannons.astro | 134 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 scripts/copy-cannons.mjs create mode 100644 src/lib/cannons.mjs create mode 100644 src/pages/cannons.astro diff --git a/.gitignore b/.gitignore index 576521f..cc49079 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,11 @@ public/assets/ # the git pull at the top of every build tick notebooks-export/ public/nb/ +# the ACannons browser build, staged by cannons_reloaded's export-cannons.sh: +# same kind of artifact, same reason. It is also ~9MB of generated JavaScript +# and WebAssembly, which has no business in this repo's history. +cannons-export/ +public/cn/ # the reading map is the same kind of artifact: library-rag-export.service regenerates it on # cyrion whenever the corpus or its located highlights move. It was tracked until 2026-08-14, # which is why automating the export needed this line first. diff --git a/deploy/auto-build.sh b/deploy/auto-build.sh index 5b772d6..2d17bd8 100755 --- a/deploy/auto-build.sh +++ b/deploy/auto-build.sh @@ -30,6 +30,12 @@ sig=$({ git diff find "$VAULT" -name .obsidian -prune -o -type f -printf '%T@ %p\n' | sort find "$REPO/notebooks-export" -type f -printf '%T@ %p\n' 2>/dev/null | sort + # the ACannons export: gitignored like the notebooks, written by a Gitea + # Action on cannons_reloaded rather than a timer here. Its own clause for + # the reason spelled out below -- with `-o` the -printf binds only to the + # LAST clause, so folding this into an OR-chain would silently print the + # other matches in a different format and stop the signature being stable. + find "$REPO/cannons-export" -type f -printf '%T@ %p\n' 2>/dev/null | sort # same deal for every reading map payload: gitignored, written by library-rag-export.service # on its own timer, so git status/diff above cannot see them change. One glob rather than a # list — with `-o` the `-printf` binds only to the LAST clause, so an OR-chain silently diff --git a/deploy/nginx.conf b/deploy/nginx.conf index a983207..4add612 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -24,6 +24,22 @@ server { add_header Cache-Control "public"; } + # The ACannons browser build. gzip_static serves the .gz siblings that + # export-cannons.sh writes, which is the difference between 3.3MB and 9MB + # on the wire; nginx:alpine ships --with-http_gzip_static_module but has + # `gzip on` COMMENTED OUT in its base config, so without this the page + # would go out uncompressed and nothing would say so. + # + # no-cache, not `expires`: classes.js and cannons.wasm are stable + # filenames whose contents change on every rebuild, so a long expiry would + # serve a stale client against a fresh server until the cache aged out. + # The browser still caches them and revalidates, which is a 304 and a few + # bytes on an unchanged build. + location /cn/ { + gzip_static on; + add_header Cache-Control "no-cache"; + } + location /_astro/ { # content-hashed filenames — safe to cache hard expires 365d; diff --git a/package.json b/package.json index 648b705..e619d66 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "astro dev", - "build": "node scripts/copy-assets.mjs && node scripts/copy-notebooks.mjs && astro build", + "build": "node scripts/copy-assets.mjs && node scripts/copy-notebooks.mjs && node scripts/copy-cannons.mjs && astro build", "preview": "astro preview" }, "dependencies": { diff --git a/scripts/copy-cannons.mjs b/scripts/copy-cannons.mjs new file mode 100644 index 0000000..6961971 --- /dev/null +++ b/scripts/copy-cannons.mjs @@ -0,0 +1,46 @@ +// Stage the ACannons browser build produced by cannons_reloaded's +// deploy/export-cannons.sh into public/, where Astro picks it up as static +// files. +// +// The export arrives ready to serve: only what the embedded page loads, res/ +// already dereferenced down to en_US, and .gz siblings alongside the two big +// files for nginx's gzip_static. So this is a plain recursive copy and +// deliberately knows nothing about the game -- everything that needs a JDK, a +// Go toolchain or an opinion about the client lives in the other repo. +// +// No export (the workflow has never run, or this is a fresh clone) is a normal +// state, not an error: the page renders a note instead of a frame. +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +const SRC_DIR = path.join(REPO, 'cannons-export'); +const OUT_DIR = path.join(REPO, 'public', 'cn'); + +fs.rmSync(OUT_DIR, { recursive: true, force: true }); + +if (!fs.existsSync(path.join(SRC_DIR, 'manifest.json'))) { + console.log('copy-cannons: no cannons-export/manifest.json — nothing to stage'); + process.exit(0); +} + +// The manifest is for the build, not the browser: it says which commit this +// is and what it weighs, and src/lib/cannons.mjs reads it from the export +// directly. Shipping it would just be a file nobody fetches. +let files = 0; +let bytes = 0; +for (const ent of fs.readdirSync(SRC_DIR, { withFileTypes: true, recursive: true })) { + if (ent.isDirectory()) continue; + const rel = path.relative(SRC_DIR, path.join(ent.parentPath, ent.name)); + if (rel === 'manifest.json') continue; + const dest = path.join(OUT_DIR, rel); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(path.join(SRC_DIR, rel), dest); + files++; + bytes += fs.statSync(dest).size; +} + +console.log( + `copy-cannons: ${files} files, ${(bytes / 1048576).toFixed(1)}MB -> ${OUT_DIR}`, +); diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index fc7dba8..3d487da 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -8,6 +8,7 @@ import Toc from '../components/Toc.astro'; import ReadingNow from '../components/ReadingNow.astro'; import FinishedBooks from '../components/FinishedBooks.astro'; import { loadNotebooks, notebookUrl } from '../lib/notebooks.mjs'; +import { loadCannons } from '../lib/cannons.mjs'; interface Props { title: string; @@ -34,6 +35,10 @@ const noteCount = entries.length; // Notebooks are a separate section, NOT injected into the garden collection: // they aren't vault notes, so they must not move noteCount or the sitemap. const notebooks = loadNotebooks(); +// Only link the game when a build is actually staged: the export is a +// gitignored artifact, so a fresh clone or a never-run workflow has none, and +// a nav link to a page that says "not staged yet" is worse than no link. +const cannons = loadCannons(); const navPath = decodeURIComponent(Astro.url.pathname) .replace(/index\.html$/, '') .replace(/\.html$/, ''); @@ -197,6 +202,16 @@ const pageUrl = new URL(Astro.url.pathname, Astro.site); Knoebels park map + {cannons && ( +
  • + + Cannons + +
  • + )} diff --git a/src/lib/cannons.mjs b/src/lib/cannons.mjs new file mode 100644 index 0000000..d367412 --- /dev/null +++ b/src/lib/cannons.mjs @@ -0,0 +1,34 @@ +// The browser build of ACannons, as staged by cannons_reloaded's +// deploy/export-cannons.sh. +// +// That script is the only thing that knows how to build the game -- two +// containers, a JDK and a Go toolchain, neither of which exists on cyrion or +// in this repo's node:24-slim build container. It hands the build a manifest +// and a directory of files. So a missing or malformed export can only ever +// mean "the game isn't staged", never a broken garden build. +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const MANIFEST = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..', + 'cannons-export', + 'manifest.json', +); + +export function loadCannons() { + try { + const parsed = JSON.parse(fs.readFileSync(MANIFEST, 'utf8')); + return parsed && parsed.commit ? parsed : null; + } catch { + return null; // never exported: the page says so and the nav link is hidden + } +} + +// The payload lives under /cn/, NOT /cannons/. With build.format:'file' the +// page builds to cannons.html, and nginx's `try_files $uri $uri.html +// $uri/index.html` would then have a file and a directory competing for the +// same URL. Exactly why the notebook exports are at /nb/ and not /notebooks/. +export const cannonsFrameUrl = '/cn/embedded.html'; diff --git a/src/pages/cannons.astro b/src/pages/cannons.astro new file mode 100644 index 0000000..6a59ebf --- /dev/null +++ b/src/pages/cannons.astro @@ -0,0 +1,134 @@ +--- +import Base from '../layouts/Base.astro'; +import { loadCannons, cannonsFrameUrl } from '../lib/cannons.mjs'; + +const build = loadCannons(); +const mb = (n) => (n / 1048576).toFixed(1); +const kb = (n) => Math.round(n / 1024); +const builtAt = build?.builtAt + ? new Date(build.builtAt).toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + timeZone: 'America/New_York', + }) + : null; +// What a visitor actually downloads: the export script sums the .gz for +// everything that has one and the file itself for the images, which are +// already-compressed formats gzip would only make bigger. +const overTheWire = build ? mb(build.wireBytes) : null; +--- + + +

    Cannons

    + +

    + Cannons was a turn-based artillery game on playray.com — a Java + applet, shipped as obfuscated bytecode, whose servers are long gone. This is + that client, decompiled and given its names back, playing against a server I + wrote from scratch by watching what the client expected. +

    + +

    + Nothing here talks to a server of mine. The Java client is compiled to + JavaScript, the Go server is compiled to WebAssembly, and both run in this + tab — so when you press play, your browser is hosting the game and a + computer opponent takes the other seat. +

    + + { + build ? ( +
    + +
    + ) : ( +

    + The build isn’t staged yet — deploy/export-cannons.sh in + cannons_reloaded hasn’t run against this checkout. +

    + ) + } + +

    + Aim with the mouse, adjust power, and mind the wind. The terrain is + destructible and generated from the round seed, which is why the server + sends four bytes instead of a map. + {builtAt && <> Built from {build.commit} on {builtAt}.} +

    + + + + +