Phases 0-1 shipped in v0.4.0 (seat/identity split, per-player turn state, the Session boundary). This lands Phase 2 (server core, one game, no lobby) and Phase 3 (persistence and resumption) per docs/architecture/multiplayer.md §12. Phases 4-6 (lobby/reconnection, the 22 opponent-directed cards, StartOS packaging) are still ahead. Phase 2: src/server/session.ts hosts a game in pure logic (no sockets) on top of game.ts's existing Game/submit/currentActor/actionMenu; it verifies seat === currentActor(game) itself before calling submit, since submit() trusts its caller and a server can't. src/server/http.ts and index.ts add POST /api/game, GET /api/stream (SSE, per-seat), POST /api/intent, and static serving of dist/. src/sim/frame-delta.ts is a purpose-built per-seat board delta for one live push at a time. Found and fixed along the way: actionMenu(game, seat) only used seat for the hand field, so a server computing every connected seat's Menu would have handed the acting player's legal moves to a waiting seat. Verified with a live end-to-end smoke test (2-player game, two SSE streams, a rejected intent from the wrong seat, an idempotent resend) plus test/server/session.test.ts and test/redaction.test.ts. Not verified: an actual browser (none available in this environment). Phase 3: src/server/persistence.ts writes game.json and turn-timings.json, atomic-rewrite-then- rename. game.ts gained fromMultiplayerSave, fixing a narration-attribution bug found while testing it (fromSave's replay loop drops the actor argument, invisible in solitaire, unreadable the moment there's more than one seat — fromSave itself still has this gap, deliberately untouched). Verified live: server killed and restarted mid-game, both seats reconnected exactly where they left off. Two rules bugs found while building this: the New Train phase never implemented its car-placement round (every car of every train was placed by the Superintendent alone, in every mode, all along — now reads the round position off tray.consist.length); and victory conditions are now one shared, configurable GameConfig set across solitaire/competitive/coop instead of a fixed length lookup and a dead firstToTarget condition. Also folds in the three fixes already released on the patch line as v0.4.9b/c/d: a switching train's crew badge failing to draw once it left the Office square, an unload that always took the westmost car regardless of which was picked, and a legal decision that could render with zero buttons. docs/testing/0.5.0-test-plan.md and three reported-bug save files (docs/station-master-seed*.json) included for reproducibility. tools/jitsi-harness/ deliberately left untracked — unrelated side-project work, not part of this release. 635 tests, 0 failures.
192 lines
7.3 KiB
TypeScript
192 lines
7.3 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 };
|
|
let git = 'nogit';
|
|
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));
|
|
}
|
|
|
|
// 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`,
|
|
);
|