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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S4wdbGSVzvUDHBWRjSVxfr
This commit is contained in:
wes
2026-08-28 21:17:00 -04:00
co-authored by Claude Opus 5
parent 172f5c3999
commit de620bfaac
8 changed files with 257 additions and 1 deletions
+15
View File
@@ -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
</a>
</li>
{cannons && (
<li>
<a
href="/cannons"
aria-current={navPath === '/cannons' ? 'page' : undefined}
>
Cannons
</a>
</li>
)}
</ul>
</div>
</aside>
+34
View File
@@ -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';
+134
View File
@@ -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;
---
<Base
title="Cannons"
description="A 2003 Playforia artillery applet, decompiled, reimplemented, and running in your browser with its server compiled to WebAssembly."
wide
>
<h1>Cannons</h1>
<p>
<em>Cannons</em> 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.
</p>
<p>
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.
</p>
{
build ? (
<div class="cn-wrap">
<button class="cn-start" type="button" data-src={cannonsFrameUrl}>
<span class="cn-start-label">Play</span>
<span class="cn-start-sub">
downloads about {overTheWire} MB — {kb(build.bytes['classes.js.gz'])} KB
of client and {mb(build.bytes['cannons.wasm.gz'])} MB of server
</span>
</button>
</div>
) : (
<p class="cn-missing">
The build isnt staged yet — <code>deploy/export-cannons.sh</code> in
<code>cannons_reloaded</code> hasnt run against this checkout.
</p>
)
}
<p class="cn-note">
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 <code>{build.commit}</code> on {builtAt}.</>}
</p>
</Base>
<script>
// Click-to-load rather than <iframe loading="lazy">: lazy only defers until
// the frame nears the viewport, and this frame is above the fold, so the
// whole payload would download for anyone who opened the page. Several MB
// is a lot to spend on a visitor who came here to read the paragraph.
const button = document.querySelector('.cn-start');
button?.addEventListener('click', () => {
const frame = document.createElement('iframe');
frame.className = 'cn-frame';
frame.src = button.dataset.src;
frame.title = 'Cannons';
// The client takes the keyboard for chat and aiming, and plays sound.
frame.allow = 'autoplay';
button.replaceWith(frame);
// The game only sees keystrokes once the frame has focus, and a visitor
// who just clicked Play should not have to click a second time to type.
frame.addEventListener('load', () => frame.focus(), { once: true });
});
</script>
<style>
.cn-wrap {
margin: 1.5rem 0;
}
/* 770x540 is the applet's own size; the frame is not responsive because the
game's layout is fixed pixels and scaling it would blur the terrain. */
.cn-start,
.cn-frame {
display: block;
width: 100%;
max-width: 770px;
height: 540px;
border: 1px solid var(--rule, #2a2f3d);
border-radius: 2px;
}
.cn-start {
background: #0b0d12;
color: inherit;
cursor: pointer;
font: inherit;
text-align: center;
}
.cn-start:hover {
border-color: var(--link, #7aa2f7);
}
.cn-start-label {
display: block;
font-size: 1.5rem;
letter-spacing: 0.02em;
}
.cn-start-sub {
display: block;
margin-top: 0.5rem;
font-size: 0.8rem;
color: var(--muted);
}
.cn-frame {
background: #0b0d12;
}
.cn-note,
.cn-missing {
font-size: 0.8rem;
color: var(--muted);
}
</style>