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');
});
});