All four reported from a table on Day 1 of v0.8.0.16, and all the same shape. ABS SIGNALS COULD ONLY BE PLAYED ON ONE MAINLINE CARD, while its tooltip said "any Mainline card". The engine was never wrong: check accepts any node whose kind is mainline and legalActions filters by check, so all of them were legal. The failure was the LABEL — describeIntent named i.placement and never i.node, so every placement described itself as plain "play ABS Signals", and the action list drops duplicate labels. All but the lowest-index node were discarded before the menu saw them. This is the THIRD time that trap has fired and the file documents the other two three lines apart: a turnout's two rotations, and three Department discards. Same fix — name what distinguishes them. The card is also called what the card face calls it. prettyKey rendered absSignals as "Abs Signals" beside a tooltip saying ABS, an acronym no key-splitter can recover, so the authored names now win. Three of those names were transcribed in sentence case and were CORRECTED rather than adopted: the repository says "Yard Office" 36 times against "Yard office" twice. A lookup that imports its own source's typos is the drift it exists to prevent. NOTHING ON A MAINLINE CARD SHOWED WHAT WAS STANDING ON IT. Played, ABS left no mark and you found out by hovering — the same complaint the Heavy Grade wedge answered, and it matters more here because ABS decides whether a second train on that card is safe. It draws a signal mast with a lit lamp now; a signal is the literal object and needs no room for words, which is what lets it sit clear of a name as long as "Uncontrolled Siding" on a 152px cell. The Mainline modifiers draw as BRK, AIR and HLP. Realignment is deliberately not among them: reduce takes the `became` branch and changes node.card, so a realigned Trestle IS an Uncontrolled Siding afterwards. Asserted, so the absence reads as a finding. A FREIGHT AGENT TURN SAID A CAR MOVED WHEN NONE HAD. Three faults behind one line. It asserted an outcome, where §6.3 requires no action and the bot declines deliberately — unjamming a healthy box destroys a load that cost a whole action to stock. An idle Agent was then silent, which read as a dropped turn; a new freightAgentIdled event says so and why, reducing to nothing exactly like switchingEnded. And the work named a coordinate rather than the industry, though a `place` helper has existed for precisely that since the switching lines moved to it. "Loaded a loaded boxcar INTO the green Outbound box at the Freight House", with the direction in capitals because to-or-from was the question asked. THE LOG AND THE ACTION MENU SPELLED THE SAME SQUARE DIFFERENTLY. view.ts wrote (col,row) — X,Y, east/west then north/south — with a comment saying why; narrate.ts wrote the internal storage order with no comment at all. So the menu offered a move to "(1,-1)" and the log reported it at "(-1,1)", side by side. Pinned by a test that renders one square through BOTH describers and compares them to each other: a test written against either file alone would have passed. THE DOCUMENTATION IS REACHABLE FROM A RUNNING GAME, AND ALL OF IT IS PUBLISHED. v0.8.0.16 published the Quickstart and nothing it points at — its §8 links five documents by relative path and every one 404'd on the package, verified against the running container. The build publishes the full set, and the test reads the links OUT OF the guide rather than listing them. They are linked from the This Game card, where reference already lives, rather than the header that must not wrap; no mode awareness is needed, because solitaire and multiplayer are the same page on the same origin. THE REFERENCES DROPPED THE VERSION FROM THEIR NAMES. Four described v0.8.0.16 and had since the v0.8.0.15 audit; the v0.4.5 was the prototype edition they were first written against, kept only because 36 citations pointed at it — and it read as documentation five minor versions stale. They are quickstart.md, rules.md, home-deck.md, mainline-deck.md and components.md now, kept current with each release rather than published as editions. Two errors surfaced while checking them against this release, which is the argument for doing it: home-deck.md filed ABS Signals under Enhancements "played into your district" that "change what a square does" — it does neither, this release's bug written down — and mainline-deck.md, which lists everything playable onto a Mainline card, never mentioned it at all. 1010 fast tests and 35 sim tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MUizFYCMHRWhbWwXhp7WPR
262 lines
11 KiB
TypeScript
262 lines
11 KiB
TypeScript
/**
|
|
* Build the static solitaire site into `dist/`.
|
|
*
|
|
* Everything here is BUILD-time. The output is plain files on disk — upload them anywhere that
|
|
* serves static content and the game runs entirely in the visitor's browser. No server, no API, no
|
|
* network call at runtime.
|
|
*
|
|
* The one thing a browser cannot do is run TypeScript: Node 22's type-stripping is a Node feature.
|
|
* So `tsc` emits ES2022 modules, rewriting the `.ts` import extensions the engine uses into `.js`.
|
|
*/
|
|
|
|
import { execFileSync } from 'node:child_process';
|
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
/**
|
|
* Overridable so a test can point the build at an isolated directory instead of the shared
|
|
* `dist/` the rest of the suite reads — see `test/web.test.ts`'s "builds, and needs nothing but a
|
|
* static host". Must be a sibling of `dist/`, not nested inside it: this script's own `rmSync`
|
|
* below wipes the whole directory it is given, so nesting one inside the other would still race.
|
|
*/
|
|
const dist = join(root, process.env['BUILD_DIST_DIR'] ?? 'dist');
|
|
|
|
rmSync(dist, { recursive: true, force: true });
|
|
mkdirSync(dist, { recursive: true });
|
|
|
|
execFileSync(
|
|
'npx',
|
|
[
|
|
'tsc',
|
|
'--ignoreConfig',
|
|
'src/web/main.ts',
|
|
'src/web/splash.ts',
|
|
'src/web/replays.ts',
|
|
'--outDir', dist,
|
|
'--rootDir', 'src',
|
|
'--target', 'es2022',
|
|
'--module', 'es2022',
|
|
'--moduleResolution', 'bundler',
|
|
'--lib', 'es2022,dom',
|
|
'--types', '',
|
|
'--strict',
|
|
'--allowImportingTsExtensions',
|
|
'--rewriteRelativeImportExtensions',
|
|
],
|
|
{ cwd: root, stdio: 'inherit' },
|
|
);
|
|
|
|
/**
|
|
* Stamp the build into the page.
|
|
*
|
|
* Once this is published, "what is actually deployed?" stops being answerable by looking at the
|
|
* source. The stamp is written into index.html at copy time rather than generated into `src/`, so
|
|
* no generated file has to be ignored, imported, or kept in step.
|
|
*
|
|
* `-dirty` marks a build made from an uncommitted tree — the honest state of most test deploys, and
|
|
* exactly the thing you want to know when a fix appears to have had no effect.
|
|
*/
|
|
function buildStamp(): string {
|
|
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string };
|
|
/**
|
|
* THE FALLBACK HAS TO BE UNIQUE PER BUILD, because this string is also the cache-bust key.
|
|
*
|
|
* It used to be the literal `nogit`, which is exactly what the `.s9pk` build produces — the
|
|
* Dockerfile copies the working tree in without `.git`, so `git rev-parse` fails there every time.
|
|
* Every packaged release therefore published `?v=nogit`, byte-identical to the release before it,
|
|
* and a returning player's browser had no reason to refetch a single module. v0.7.5's setup screen
|
|
* and v0.7.6's fix to it both shipped correctly to `phoenix.local` and neither reached the browser
|
|
* that asked for them (Jesse, twice, 2026-08-29 — "setup did not work").
|
|
*
|
|
* A marker plus the build's own timestamp is always distinct, needs nothing from the environment,
|
|
* and stays honest: two builds of the same commit ARE two deploys, and a cache key that says so
|
|
* costs one refetch, while one that lies costs a release nobody receives.
|
|
*
|
|
* NOT THE VERSION, which is what this used to lead with. The stamp below already begins with
|
|
* `v${pkg.version}`, so on exactly the builds that take this path — every `.s9pk`, which has no
|
|
* `.git` — the header read "v0.8.0.10 · 0.8.0.10-mfq2p1 · …" and the version appeared twice
|
|
* (Jesse, playtest 2026-09-16). The timestamp alone carries the uniqueness; the version is
|
|
* already said once, properly, at the front.
|
|
*/
|
|
let git = `nogit-${Date.now().toString(36)}`;
|
|
try {
|
|
const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root })
|
|
.toString()
|
|
.trim();
|
|
const dirty =
|
|
execFileSync('git', ['status', '--porcelain'], { cwd: root }).toString().trim().length > 0;
|
|
git = sha + (dirty ? '-dirty' : '');
|
|
} catch {
|
|
// A build from a tarball with no git history is still a valid build.
|
|
}
|
|
const when = new Date().toISOString().replace('T', ' ').slice(0, 16);
|
|
return `v${pkg.version} · ${git} · ${when}Z`;
|
|
}
|
|
|
|
const stamp = buildStamp();
|
|
|
|
/**
|
|
* Cache-bust every module.
|
|
*
|
|
* A returning visitor gets a fresh index.html and STALE JavaScript, because a static host caches
|
|
* .js and the filenames never change. That is not a cosmetic staleness: it mixes new HTML with old
|
|
* code, and the first mismatch is fatal. It happened on the first real deploy — index.html had
|
|
* dropped an element that the cached main.js still asked for, so the page threw
|
|
* `missing element: target` and the game never started.
|
|
*
|
|
* Appending the build tag to every relative import makes each deploy a new set of URLs, so a
|
|
* browser cannot serve half of one build and half of another.
|
|
*/
|
|
const tag = encodeURIComponent(stamp.split(' · ')[1] ?? stamp);
|
|
|
|
function bustImports(dir: string): number {
|
|
let count = 0;
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
const full = join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
count += bustImports(full);
|
|
continue;
|
|
}
|
|
if (!entry.name.endsWith('.js')) continue;
|
|
const src = readFileSync(full, 'utf8');
|
|
const next = src.replace(
|
|
/^(\s*(?:import|export)[^'"\n]*?from\s+['"])(\.[^'"]+\.js)(['"])/gm,
|
|
(_m, head: string, spec: string, tail: string) => `${head}${spec}?v=${tag}${tail}`,
|
|
);
|
|
if (next !== src) {
|
|
writeFileSync(full, next);
|
|
count++;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
const busted = bustImports(dist);
|
|
|
|
// Three pages now: the splash, the game, and the replay directory. Each is stamped and each has
|
|
// its entry script cache-busted, or a returning visitor gets new HTML against old modules.
|
|
for (const name of ['index.html', 'play.html', 'replays.html']) {
|
|
const page = readFileSync(join(root, 'src/web', name), 'utf8')
|
|
.replaceAll('__BUILD__', stamp)
|
|
.replace(/(<script type="module" src="[^"]+?\.js)(")/, `$1?v=${tag}$2`);
|
|
writeFileSync(join(dist, name), page);
|
|
}
|
|
|
|
/**
|
|
* Publish whatever replays are in `public/replays/`, plus an index of them.
|
|
*
|
|
* Static hosting cannot list a directory, so the page needs a manifest. A replay is a save — seed
|
|
* plus moves — so these are a few hundred bytes each, not megabytes.
|
|
*/
|
|
const replaySrc = join(root, 'public/replays');
|
|
const replayOut = join(dist, 'replays');
|
|
mkdirSync(replayOut, { recursive: true });
|
|
const manifest: { file: string; title: string; note?: string; seed?: number }[] = [];
|
|
if (existsSync(replaySrc)) {
|
|
for (const f of readdirSync(replaySrc).filter((n) => n.endsWith('.json') && n !== 'manifest.json')) {
|
|
const raw = readFileSync(join(replaySrc, f), 'utf8');
|
|
try {
|
|
const save = JSON.parse(raw) as { seed?: number; title?: string; note?: string; history?: unknown[] };
|
|
if (typeof save.seed !== 'number' || !Array.isArray(save.history)) {
|
|
console.warn(` skipped ${f} — not a Station Master save`);
|
|
continue;
|
|
}
|
|
writeFileSync(join(replayOut, f), raw);
|
|
manifest.push({
|
|
file: f,
|
|
title: save.title ?? f.replace(/\.json$/, ''),
|
|
...(save.note ? { note: save.note } : {}),
|
|
seed: save.seed,
|
|
});
|
|
} catch {
|
|
console.warn(` skipped ${f} — could not be parsed`);
|
|
}
|
|
}
|
|
}
|
|
writeFileSync(join(replayOut, 'manifest.json'), JSON.stringify(manifest, null, 1));
|
|
|
|
/**
|
|
* Static images — the splash artwork and whatever joins it. Copied verbatim, unlike the replays
|
|
* above: an image needs no validation, only a place in `dist/` to be served from.
|
|
*/
|
|
const imageSrc = join(root, 'public/images');
|
|
if (existsSync(imageSrc)) {
|
|
const imageOut = join(dist, 'images');
|
|
mkdirSync(imageOut, { recursive: true });
|
|
for (const f of readdirSync(imageSrc)) copyFileSync(join(imageSrc, f), join(imageOut, f));
|
|
}
|
|
|
|
/**
|
|
* The player-facing documentation, published beside the game so a tester can reach it from the box.
|
|
*
|
|
* COPIED, NOT RE-WRITTEN. The Markdown in `docs/` is the one copy; a hand-written HTML twin would
|
|
* drift from it on the first edit, which is the whole lesson of TODO #15a and of the 2026-09-20
|
|
* documentation pass that found four references a month out of date.
|
|
*
|
|
* THE WHOLE SET, NOT ONLY THE QUICKSTART. v0.8.0.16 published the guide alone, and the guide's own
|
|
* §8 "Where to read more" links five further documents by relative path — so every one of them
|
|
* 404'd on the package (verified on the box: 5 of 6 paths missing). Publishing the guide without
|
|
* what it points at is the same broken-link failure the test below was written to catch, one hop
|
|
* further out. The names are kept exactly as the guide writes them, because those links are what
|
|
* has to resolve.
|
|
*
|
|
* SERVED AS PLAIN TEXT for now, which is honest rather than good: tables render as pipes and the
|
|
* links do not click. Rendering them into styled pages needs a small Markdown converter and is
|
|
* filed as TODO #109 — this is the version that gets the references in front of testers for this
|
|
* round rather than leaving them without any.
|
|
*
|
|
* PUBLISHED UNDER THEIR OWN NAMES, which since v0.8.0.17 carry no version: the documents are kept
|
|
* current with every release rather than published as editions, so `docs/rules.md` is served as
|
|
* `rules.md` and the splash page and the This Game card link it by that name. Four of them were
|
|
* `StationMaster-<name>-v0.4.5.md` until then — the prototype edition they were first written
|
|
* against, never the version they described.
|
|
*/
|
|
const GUIDE_DOCS: readonly string[] = [
|
|
'quickstart.md',
|
|
'rules.md',
|
|
'home-deck.md',
|
|
'mainline-deck.md',
|
|
'components.md',
|
|
// Generated by `build:cards` and checked in; a test fails when it disagrees with the code, which
|
|
// is why it is the one reference that has never drifted. Its `rules/` directory is preserved
|
|
// because that is the path the Quickstart links it by.
|
|
'rules/as-built.md',
|
|
];
|
|
|
|
for (const rel of GUIDE_DOCS) {
|
|
const src = join(root, 'docs', rel);
|
|
if (!existsSync(src)) {
|
|
// Loud rather than silent: a missing document is a broken link on a page already published, and
|
|
// the build is the only place that can still notice.
|
|
console.error(`WARNING: docs/${rel} is missing — a published link will 404`);
|
|
continue;
|
|
}
|
|
const out = join(dist, rel);
|
|
mkdirSync(dirname(out), { recursive: true });
|
|
copyFileSync(src, out);
|
|
}
|
|
|
|
// A tiny note for whoever unzips this later and wonders what it needs.
|
|
writeFileSync(
|
|
join(dist, 'README.txt'),
|
|
[
|
|
'Station Master — solitaire, static build.',
|
|
'',
|
|
'Upload the contents of this folder to any static host and open index.html.',
|
|
'There is no server component. The game runs entirely in the browser and saves',
|
|
'to localStorage. Add ?seed=1234 to the URL for a reproducible deal.',
|
|
'',
|
|
'Note: ES modules require the files to be SERVED over http(s). Opening',
|
|
'index.html directly from the filesystem will be blocked by the browser.',
|
|
'To try it locally: npx http-server dist (or any static file server)',
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
|
|
console.log(
|
|
`built -> ${dist}\n ${stamp}\n cache-busted ${busted} modules with ?v=${tag}` +
|
|
`\n ${manifest.length} replay(s) published`,
|
|
);
|