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:
@@ -7,6 +7,11 @@ public/assets/
|
|||||||
# the git pull at the top of every build tick
|
# the git pull at the top of every build tick
|
||||||
notebooks-export/
|
notebooks-export/
|
||||||
public/nb/
|
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
|
# 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,
|
# 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.
|
# which is why automating the export needed this line first.
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ sig=$({
|
|||||||
git diff
|
git diff
|
||||||
find "$VAULT" -name .obsidian -prune -o -type f -printf '%T@ %p\n' | sort
|
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
|
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
|
# 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
|
# 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
|
# list — with `-o` the `-printf` binds only to the LAST clause, so an OR-chain silently
|
||||||
|
|||||||
@@ -24,6 +24,22 @@ server {
|
|||||||
add_header Cache-Control "public";
|
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/ {
|
location /_astro/ {
|
||||||
# content-hashed filenames — safe to cache hard
|
# content-hashed filenames — safe to cache hard
|
||||||
expires 365d;
|
expires 365d;
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"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"
|
"preview": "astro preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
);
|
||||||
@@ -8,6 +8,7 @@ import Toc from '../components/Toc.astro';
|
|||||||
import ReadingNow from '../components/ReadingNow.astro';
|
import ReadingNow from '../components/ReadingNow.astro';
|
||||||
import FinishedBooks from '../components/FinishedBooks.astro';
|
import FinishedBooks from '../components/FinishedBooks.astro';
|
||||||
import { loadNotebooks, notebookUrl } from '../lib/notebooks.mjs';
|
import { loadNotebooks, notebookUrl } from '../lib/notebooks.mjs';
|
||||||
|
import { loadCannons } from '../lib/cannons.mjs';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -34,6 +35,10 @@ const noteCount = entries.length;
|
|||||||
// Notebooks are a separate section, NOT injected into the garden collection:
|
// 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.
|
// they aren't vault notes, so they must not move noteCount or the sitemap.
|
||||||
const notebooks = loadNotebooks();
|
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)
|
const navPath = decodeURIComponent(Astro.url.pathname)
|
||||||
.replace(/index\.html$/, '')
|
.replace(/index\.html$/, '')
|
||||||
.replace(/\.html$/, '');
|
.replace(/\.html$/, '');
|
||||||
@@ -197,6 +202,16 @@ const pageUrl = new URL(Astro.url.pathname, Astro.site);
|
|||||||
Knoebels park map
|
Knoebels park map
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
{cannons && (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/cannons"
|
||||||
|
aria-current={navPath === '/cannons' ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
Cannons
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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 isn’t staged yet — <code>deploy/export-cannons.sh</code> in
|
||||||
|
<code>cannons_reloaded</code> hasn’t 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>
|
||||||
Reference in New Issue
Block a user