Draw the board as track, split the site into three pages, and fix curve geometry

Adds SVG board rendering shared by the game and the replay, a splash page with a
shareable replay directory, hover tooltips for reference detail, and makes curves
two-port arcs so sidings and run-arounds can finally be built.
This commit is contained in:
Jesse
2026-08-01 13:35:53 -04:00
parent f00255ce31
commit f52ff0e9ac
21 changed files with 5359 additions and 291 deletions
+95
View File
@@ -24,6 +24,8 @@ import {
neighbour,
opposite,
reachableDestinations,
connectionsFor,
variantsFor,
} from '../src/engine/track.ts';
// ---------------------------------------------------------------------------
@@ -408,3 +410,96 @@ describe('placement and drop-off', () => {
assert.ok(!canDropCarsAt(area, at(0, 1)));
});
});
describe('curves are arcs, and arcs make sidings possible', () => {
const arcCard = (arc: 'ne' | 'nw' | 'se' | 'sw', geometry: 'curved' | 'sharpCurved' = 'curved'): TrackCard => ({
geometry: { kind: 'track', geometry, arc },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
});
it('joins exactly two adjacent edges, with no through track', () => {
// The printed cards (docs/tracks.png, rows 3-4) are a single sweep from edge to edge. Modelling
// a curve as through-track PLUS a diverging leg made it a turnout, and an exact duplicate of one.
for (const arc of ['ne', 'nw', 'se', 'sw'] as const) {
const links = connectionsFor(arcCard(arc));
assert.equal(links.length, 1, `${arc} should be ONE connection, not a through track plus a leg`);
assert.deepEqual([...links[0]!].sort(), [...arc].sort(), `${arc} joins the wrong edges`);
}
});
it('is no longer a duplicate of a turnout', () => {
const norm = (c: readonly (readonly string[])[]): string =>
c.map((p) => [...p].sort().join('')).sort().join(' ');
const turnout: TrackCard = {
geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' } },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
};
for (const arc of ['ne', 'nw', 'se', 'sw'] as const) {
assert.notEqual(norm(connectionsFor(arcCard(arc))), norm(connectionsFor(turnout)),
`a ${arc} curve still has the same connections as a turnout`);
}
});
it('can reach NORTH, which nothing but an n-s straight could before', () => {
// This is the whole point. With every leg diverging south, a district could only ever be a
// vertical column — no siding, no parallel track, no way back up to the Running Track.
const reachesNorth = (['ne', 'nw'] as const).every((arc) =>
connectionsFor(arcCard(arc)).some((p) => p.includes('n')),
);
assert.ok(reachesNorth, 'a curve still cannot reach north');
});
it('offers all four rotations when placed', () => {
// A two-port arc has no handedness that survives turning — its mirror IS one of its rotations —
// so the printed hand governs supply, not what can be built.
for (const g of ['curved', 'sharpCurved'] as const) {
const arcs = variantsFor(g).map((v) => v.arc).sort();
assert.deepEqual(arcs, ['ne', 'nw', 'se', 'sw'], `${g} does not offer all four rotations`);
}
});
it('a sharp curve differs from a curve only in the Moves it costs', () => {
for (const arc of ['ne', 'nw', 'se', 'sw'] as const) {
assert.deepEqual(
connectionsFor(arcCard(arc, 'sharpCurved')),
connectionsFor(arcCard(arc, 'curved')),
'a sharp curve should be geometrically identical',
);
}
});
it('lets a crew run a siding and rejoin the main', () => {
// The shape the whole change exists for: leave the Running Track, run parallel, come back up.
// A crew must ENTER a turnout through its stem to take the diverging leg (§A.1).
const grid: Record<string, TrackCard> = {
'0,-2': { geometry: { kind: 'track', geometry: 'straight', axis: 'ew' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
'0,-1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' } }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
'-1,-1': arcCard('ne'),
'-1,0': { geometry: { kind: 'track', geometry: 'straight', axis: 'ew' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
'-1,1': arcCard('nw'),
'0,1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' } }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
'0,0': { geometry: { kind: 'track', geometry: 'straight', axis: 'ew' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
};
const area = areaFrom(grid, { row: 0, col: 0 });
const ctx = { area, occupancy: { trayAt: () => null, freeAdTracks: () => 2 }, consistSize: 0, self: 'crew' as const };
const seen = new Set<string>(['0,-2']);
const queue = [{ row: 0, col: -2 }];
for (let i = 0; i < 40 && queue.length > 0; i++) {
const at = queue.shift()!;
for (const facing of ['e', 'w', 'n', 's'] as const) {
for (const d of reachableDestinations(ctx, at, facing)) {
const key = `${d.coord.row},${d.coord.col}`;
if (!seen.has(key)) {
seen.add(key);
queue.push(d.coord);
}
}
}
}
for (const cell of ['-1,-1', '-1,0', '-1,1']) {
assert.ok(seen.has(cell), `the siding cell ${cell} is unreachable — no run-around is possible`);
}
assert.ok(seen.has('0,1'), 'the siding does not rejoin the Running Track');
});
});
+99 -8
View File
@@ -24,6 +24,9 @@ import {
view,
} from '../src/web/game.ts';
const root = join(import.meta.dirname, '..');
const dist = join(root, 'dist');
/** Play a whole game by always taking the first offered action. */
function playThrough(seed: number, maxTurns = 20_000) {
const game = newGame(seed);
@@ -490,9 +493,54 @@ describe('the page explains itself', () => {
});
});
describe('replays are saves', () => {
it('rebuilds the exact position a save describes', () => {
// A replay is `{seed, history}` — a few hundred bytes — because the engine is deterministic and
// runs in the browser. Re-submitting the same intents must land on the same board, or a shared
// replay shows something the player never saw.
const game = newGame(430);
for (let i = 0; i < 250; i++) {
if (currentActor(game) === null) break;
const { options } = actionGroups(game);
if (options.length === 0) break;
if (!submit(game, options[0]!)) break;
}
const restored = fromSave(toSave(game));
assert.equal(restored.state.players[0]!.revenue, game.state.players[0]!.revenue);
assert.equal(restored.state.clock.day, game.state.clock.day);
assert.equal(restored.state.clock.stage, game.state.clock.stage);
assert.deepEqual(view(restored).cells, view(game).cells, 'the rebuilt board differs');
assert.deepEqual(view(restored).division, view(game).division, 'the rebuilt Division differs');
});
it('stays small enough to email', () => {
const game = newGame(202);
for (let i = 0; i < 400; i++) {
if (currentActor(game) === null) break;
const { options } = actionGroups(game);
if (options.length === 0) break;
if (!submit(game, options[0]!)) break;
}
const kb = Buffer.byteLength(JSON.stringify(toSave(game))) / 1024;
assert.ok(kb < 200, `a save is ${kb.toFixed(0)} KB — too big to be worth sharing as a file`);
});
it('publishes every save in public/replays with an index', () => {
// Static hosting cannot list a directory, so the page needs a manifest or it shows nothing.
const manifestPath = join(dist, 'replays/manifest.json');
assert.ok(existsSync(manifestPath), 'no replay manifest was built');
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { file: string; title: string }[];
for (const entry of manifest) {
assert.ok(existsSync(join(dist, 'replays', entry.file)), `${entry.file} is indexed but not published`);
assert.ok(entry.title.length > 0, `${entry.file} has no title`);
const save = JSON.parse(readFileSync(join(dist, 'replays', entry.file), 'utf8')) as Record<string, unknown>;
assert.equal(typeof save['seed'], 'number', `${entry.file} has no seed`);
assert.ok(Array.isArray(save['history']), `${entry.file} has no history`);
}
});
});
describe('the static build', () => {
const root = join(import.meta.dirname, '..');
const dist = join(root, 'dist');
it('builds, and needs nothing but a static host', () => {
execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'pipe' });
@@ -543,8 +591,27 @@ describe('the static build', () => {
// would drop the handlers the page assigns — the test would then report "no button" for a
// page that works perfectly.
let cache = new Map<string, Record<string, unknown>[]>();
const found = new Map<string, Record<string, unknown>>();
const node: Record<string, unknown> = {
textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
// The board is SVG now, and highlighting works by finding a card group and adding a class.
// The stub has to model that much or it cannot see whether highlighting happened at all.
querySelector(sel: string) {
const m = /\[data-(cell|ghost)="([^"]+)"\]/.exec(sel);
if (!m) return null;
const key = `${m[1]}:${m[2]}`;
if (!html.includes(`data-${m[1]}="${m[2]}"`)) return null;
if (!found.has(key)) {
const classes = new Set<string>();
found.set(key, {
onclick: null,
classList: { add: (c: string) => void classes.add(c), has: (c: string) => classes.has(c) },
classes,
});
}
return found.get(key);
},
highlighted: () => [...found.values()].filter((f) => (f['classes'] as Set<string>).size > 0),
querySelectorAll(sel: string) {
const hit = cache.get(sel);
if (hit) return hit;
@@ -581,17 +648,22 @@ describe('the static build', () => {
// for any id asked for, so a `$('target')` left behind after removing #target from the HTML
// would pass here and throw on load in a browser — the page would simply never start.
const served = new Set(
[...readFileSync(join(dist, 'index.html'), 'utf8').matchAll(/id="([a-zA-Z]+)"/g)].map(
[...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z]+)"/g)].map(
(m) => m[1]!,
),
);
const g = globalThis as Record<string, unknown>;
// The page installs a tooltip layer on load, so the stub needs enough DOM to let it.
g['document'] = {
getElementById: (id: string) => {
if (!served.has(id)) return null;
if (!els.has(id)) els.set(id, make());
return els.get(id);
},
createElement: () => make(),
addEventListener: () => {},
body: { appendChild: () => {} },
head: { appendChild: () => {} },
};
g['location'] = { search: '?seed=555' };
const store = new Map<string, string>();
@@ -631,9 +703,11 @@ describe('the static build', () => {
assert.ok(clickFirst(actions, 'button.subj'), 'no card/track subject to pick');
const html = String(grid['innerHTML']);
assert.match(html, /class="cell[^"]*legal/, 'picking a card highlighted nothing');
assert.match(html, /data-at="/, 'highlighted squares carry no click target');
assert.match(html, /ghost/, 'no empty square was offered as a destination');
// The board draws cards as addressable groups; legality is an added class or a drawn ghost.
assert.match(html, /data-cell="/, 'the board drew no addressable cards');
const lit = (grid['highlighted'] as () => unknown[])();
const ghosts = /data-ghost="/.test(html);
assert.ok(lit.length > 0 || ghosts, 'picking a card highlighted nothing on the board');
});
it('busts the cache on every module, so a deploy cannot half-load', () => {
@@ -689,7 +763,7 @@ describe('the static build', () => {
// Getting this wrong does not degrade the page, it stops the game starting at all.
const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8');
const asked = new Set([...src.matchAll(/\$\('([a-zA-Z]+)'\)/g)].map((m) => m[1]!));
const html = readFileSync(join(dist, 'index.html'), 'utf8');
const html = readFileSync(join(dist, 'play.html'), 'utf8');
const present = new Set([...html.matchAll(/id="([a-zA-Z]+)"/g)].map((m) => m[1]!));
// `again` is created by the game-over screen before it is looked up.
present.add('again');
@@ -698,8 +772,25 @@ describe('the static build', () => {
}
});
it('serves all three pages, each stamped and cache-busted', () => {
// The splash is the front door now; the game and the replay directory are separate pages.
for (const [name, entry] of [
['index.html', 'splash'],
['play.html', 'main'],
['replays.html', 'replays'],
] as const) {
const html = readFileSync(join(dist, name), 'utf8');
assert.match(html, new RegExp(`src="\\./web/${entry}\\.js\\?v=`), `${name} is not cache-busted`);
assert.doesNotMatch(html, /__BUILD__/, `${name} was published without a build stamp`);
assert.ok(!/https?:\/\//.test(html.replace(/<!--[\s\S]*?-->/g, '')), `${name} fetches something external`);
}
const splash = readFileSync(join(dist, 'index.html'), 'utf8');
assert.match(splash, /href="\.\/play\.html"/, 'the splash does not link to the game');
assert.match(splash, /href="\.\/replays\.html"/, 'the splash does not link to the replays');
});
it('serves a page that loads the game as a module', () => {
const html = readFileSync(join(dist, 'index.html'), 'utf8');
const html = readFileSync(join(dist, 'play.html'), 'utf8');
assert.match(html, /<script type="module" src="\.\/web\/main\.js(\?v=[^"]*)?">/);
assert.ok(!/https?:\/\//.test(html.replace(/<!--[\s\S]*?-->/g, '')), 'page fetches something external');
for (const id of ['grid', 'division', 'actions', 'log', 'facs', 'hand', 'blocked']) {