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
+294
View File
@@ -0,0 +1,294 @@
/**
* Drawing the board.
*
* Two renderers, chosen because they fail in opposite places (docs/design/board-layout-studies.html):
*
* - `officeSvg` — the district stays a MAP. Cards on a grid with the rails actually drawn, so a
* join is rail meeting rail rather than two descriptions that happen to agree.
* - `divisionSvg`— the Division is not a map, it is a queue of sections with hard capacities, so
* it is drawn as a dispatcher's diagram: one line per track, count them to know
* what fits.
*
* SELF-CONTAINED ON PURPOSE. Both functions reference nothing outside their own bodies — no imports,
* no module-level helpers. The playable app imports them normally; the replay is a single HTML file
* with an inline script and cannot import anything, so it embeds these via `Function.toString()`.
* That keeps ONE implementation: a second copy would eventually draw a different board from the same
* state, which is the exact failure a track diagram exists to prevent.
*
* Rails come from `CellView.links`, which is the engine's own `connectionsFor`. A drawn rail can
* therefore never claim a connection the rules do not have.
*/
import type { CellView, DivisionView } from './view.ts';
/** Anything the renderers need to know about a train standing somewhere. */
export type BoardTrain = { label: string; consist: string[] };
/**
* The Division as a dispatcher would see it: one continuous line per running track, sections
* separated by thin seams, capacity legible because the lines can be counted.
*/
export function divisionSvg(nodes: DivisionView[]): string {
const COL = 190;
const TOP = 34;
const LANE = 30;
const width = Math.max(1, nodes.length) * COL;
const maxCap = nodes.reduce((n, d) => Math.max(n, d.capacity ?? 3), 1);
const height = TOP + maxCap * LANE + 54;
const esc = (t: string): string =>
String(t).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c);
const rail = (x1: number, y: number, x2: number): string => {
let out =
`<line class="bs-rail" x1="${x1}" y1="${y - 2.5}" x2="${x2}" y2="${y - 2.5}"/>` +
`<line class="bs-rail" x1="${x1}" y1="${y + 2.5}" x2="${x2}" y2="${y + 2.5}"/>`;
const n = Math.max(2, Math.floor(Math.abs(x2 - x1) / 9));
for (let i = 0; i <= n; i++) {
const tx = x1 + ((x2 - x1) * i) / n;
out += `<line class="bs-tie" x1="${tx}" y1="${y - 4.5}" x2="${tx}" y2="${y + 4.5}"/>`;
}
return out;
};
const chip = (x: number, y: number, t: BoardTrain): string => {
const w = 30 + Math.min(t.consist.length, 4) * 9;
return (
`<g class="bs-train" data-tip="${esc(t.label)} — carrying ${esc(t.consist.join(', ') || 'no cars')}">` +
`<rect x="${x - w / 2}" y="${y - 10}" width="${w}" height="20" rx="3"/>` +
`<text class="bs-tlab" x="${x}" y="${y + 4}" text-anchor="middle">${esc(t.label)}</text></g>`
);
};
let out = `<svg class="bs" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMinYMin meet">`;
nodes.forEach((node, i) => {
const x0 = i * COL;
const x1 = x0 + COL;
const cap = node.capacity;
const lanes = cap ?? 3;
const kind = node.kind === 'dp' ? 'dp' : node.kind === 'office' ? 'office' : 'ml';
out += `<rect class="bs-sec bs-${kind}" x="${x0 + 2}" y="${TOP - 16}" width="${COL - 4}" height="${lanes * LANE + 30}" rx="5"/>`;
out += `<text class="bs-name" x="${x0 + 10}" y="${TOP - 3}">${esc(node.label)}</text>`;
if (node.gradeUp) {
out += `<text class="bs-grade" x="${x1 - 10}" y="${TOP - 3}" text-anchor="end">climbs ${node.gradeUp === 'east' ? 'E ▲' : '▲ W'}</text>`;
}
// One rail per track this section can hold. Unlimited sections are drawn as three faded lines
// and labelled, rather than pretending to a number they do not have.
for (let k = 0; k < lanes; k++) {
out += `<g class="${cap === null ? 'bs-open' : ''}">${rail(x0 + 12, TOP + 14 + k * LANE, x1 - 12)}</g>`;
}
const here = node.trains.flat();
here.forEach((t, k) => {
const lane = Math.min(k, lanes - 1);
const across = here.length > lanes ? (k - lane) * 34 : 0;
out += chip(x0 + COL / 2 + across, TOP + 14 + lane * LANE, t);
});
const free = cap === null ? '∞' : `${Math.max(0, cap - here.length)} of ${cap} free`;
const warn = cap !== null && here.length >= cap ? ' bs-full' : '';
out += `<text class="bs-cap${warn}" x="${x0 + 10}" y="${TOP + lanes * LANE + 8}">${cap === null ? 'no limit — trains queue' : free}</text>`;
if (node.modifiers.length > 0) {
out += `<text class="bs-mod" x="${x0 + 10}" y="${TOP + lanes * LANE + 22}">${esc(node.modifiers.join(' · '))}</text>`;
}
});
out += '</svg>';
return out;
}
/**
* The Office Area as a map: one card per grid square, rails drawn edge to edge.
*
* The through rail sits at a constant height on every card — the alignment the printed cards use —
* so abutting cards produce one unbroken line and a gap is visibly a gap.
*/
export function officeSvg(cells: CellView[], runningRow: number): string {
const W = 132;
const H = 96;
const PAD = 3;
const RAIL = 30;
const esc = (t: string): string =>
String(t).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c);
if (cells.length === 0) return '<svg class="bs" viewBox="0 0 10 10"></svg>';
const rows = cells.map((c) => c.row);
const cols = cells.map((c) => c.col);
const r0 = Math.min(...rows);
const r1 = Math.max(...rows);
const c0 = Math.min(...cols);
const c1 = Math.max(...cols);
const width = (c1 - c0 + 1) * (W + PAD);
const height = (r1 - r0 + 1) * (H + PAD) + 4;
// Screen position of a card. Rows count DOWN from the top row, so the Running Track sits highest
// and the district hangs beneath it, as the rules describe it.
const px = (col: number): number => (col - c0) * (W + PAD);
const py = (row: number): number => (r1 - row) * (H + PAD);
const rail = (x1: number, y1: number, x2: number, y2: number): string => {
const dx = x2 - x1;
const dy = y2 - y1;
const len = Math.hypot(dx, dy);
const nx = (-dy / len) * 2.5;
const ny = (dx / len) * 2.5;
let out =
`<line class="bs-rail" x1="${x1 + nx}" y1="${y1 + ny}" x2="${x2 + nx}" y2="${y2 + ny}"/>` +
`<line class="bs-rail" x1="${x1 - nx}" y1="${y1 - ny}" x2="${x2 - nx}" y2="${y2 - ny}"/>`;
const n = Math.max(2, Math.floor(len / 9));
for (let i = 0; i <= n; i++) {
const tx = x1 + (dx * i) / n;
const ty = y1 + (dy * i) / n;
out += `<line class="bs-tie" x1="${tx + nx * 1.8}" y1="${ty + ny * 1.8}" x2="${tx - nx * 1.8}" y2="${ty - ny * 1.8}"/>`;
}
return out;
};
// Where each port meets the card edge. East and west sit at the rail height so a straight run
// stays straight across the whole row; north and south are centred.
const port = (p: string): { x: number; y: number } => {
if (p === 'e') return { x: W, y: RAIL };
if (p === 'w') return { x: 0, y: RAIL };
if (p === 'n') return { x: W / 2, y: 0 };
return { x: W / 2, y: H };
};
let out = `<svg class="bs" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMinYMin meet">`;
for (const cell of cells) {
const x = px(cell.col);
const y = py(cell.row);
const kind =
cell.kind === 'office'
? 'office'
: cell.kind === 'limits'
? 'lim'
: cell.kind === 'facility'
? 'fac'
: cell.kind === 'modifier'
? 'mod'
: 'trk';
// Addressable so a caller can outline a square as a legal destination without re-rendering.
// The explanation rides on the group as a tooltip rather than being printed: it is reference
// detail, read once, and printing it costs the space the board itself needs.
out += `<g data-cell="${cell.row},${cell.col}" data-tip="${esc(cell.label)} — ${esc(cell.what)}" transform="translate(${x},${y})">`;
out += `<rect class="bs-card bs-${kind}${cell.running ? ' bs-run' : ''}" x="0" y="0" width="${W}" height="${H}" rx="4"/>`;
// The rails. A Modifier is not track and deliberately gets none — that is why nothing can be
// routed through it, and the picture should say so.
for (const link of cell.links) {
const a = port(link[0] ?? 'e');
const b = port(link[1] ?? 'w');
const straight = (link === 'ew' || link === 'we') || (link === 'ns' || link === 'sn');
if (straight) {
out += rail(a.x, a.y, b.x, b.y);
} else {
// A diverging leg: run out from each edge and meet, which reads as a curve at this size.
const mid = { x: W / 2, y: RAIL + (H - RAIL) / 2 };
out += rail(a.x, a.y, mid.x, mid.y) + rail(mid.x, mid.y, b.x, b.y);
}
}
out += `<text class="bs-cn" x="6" y="12">${esc(cell.label)}</text>`;
out += `<text class="bs-coord" x="${W - 5}" y="12" text-anchor="end">${cell.row},${cell.col}</text>`;
// Standing room, drawn as the printed squares: filled means occupied.
const spots = cell.facility ? Math.max(1, cell.facility.trackCap) : cell.cars.length;
for (let i = 0; i < Math.min(spots, 4); i++) {
const filled = i < cell.cars.length;
out += `<rect class="bs-slot${filled ? ' bs-occ' : ''}" x="${8 + i * 30}" y="${H - 26}" width="26" height="15" rx="2"/>`;
if (filled) {
out += `<text class="bs-carlab" x="${21 + i * 30}" y="${H - 15}" text-anchor="middle">${esc((cell.cars[i] ?? '').slice(0, 3))}</text>`;
}
}
if (cell.enhancements.length > 0) {
out += `<text class="bs-enh" x="6" y="${H - 30}">${esc(cell.enhancements.join(' · '))}</text>`;
}
if (cell.tray) {
out += `<g class="bs-crew"><rect x="${W / 2 - 26}" y="${RAIL - 10}" width="52" height="20" rx="3"/>` +
`<text class="bs-tlab" x="${W / 2}" y="${RAIL + 4}" text-anchor="middle">${esc(cell.tray.split(' ')[0] ?? 'crew')}</text></g>`;
}
out += '</g>';
}
// Mark the Running Track so the spine of the district is unmistakable.
out += `<text class="bs-rowlab" x="2" y="${py(runningRow) + 92}">RUNNING TRACK</text>`;
out += '</svg>';
return out;
}
/**
* Targets for legal EMPTY squares. They have no card to outline, so the board draws a dashed
* placeholder — the commonest placement of all is onto a blank square, and without this the
* highlight would have nothing to attach to.
*/
export function ghostSvg(
spots: { row: number; col: number }[],
cells: CellView[],
runningRow: number,
): string {
const W = 132;
const H = 96;
const PAD = 3;
const all = [...cells.map((c) => ({ row: c.row, col: c.col })), ...spots];
if (all.length === 0) return '';
const r1 = Math.max(...all.map((c) => c.row));
const c0 = Math.min(...all.map((c) => c.col));
let out = '';
for (const s of spots) {
const x = (s.col - c0) * (W + PAD);
const y = (r1 - s.row) * (H + PAD);
out +=
`<g data-ghost="${s.row},${s.col}" transform="translate(${x},${y})" class="bs-ghost">` +
`<rect x="0" y="0" width="${W}" height="${H}" rx="4"/>` +
`<text x="${W / 2}" y="${H / 2}" text-anchor="middle">place here</text>` +
`<text class="bs-coord" x="${W / 2}" y="${H / 2 + 15}" text-anchor="middle">${s.row},${s.col}</text>` +
`</g>`;
}
void runningRow;
return out;
}
/** Styling for both renderers. Shared so the replay and the app cannot drift apart visually. */
export const BOARD_CSS = `
.bs{width:100%;height:auto;background:#0e1116;border-radius:6px;padding:6px;overflow:visible}
.bs-rail{stroke:#cfd6e0;stroke-width:1.6}
.bs-tie{stroke:#8d97a5;stroke-width:1.1}
.bs-open .bs-rail{stroke:#6b7686;stroke-dasharray:6 4}
.bs-open .bs-tie{stroke:#59626f}
.bs-card{fill:#161b21;stroke:#39424e;stroke-width:1}
.bs-card.bs-run{fill:#1d232c}
.bs-office{fill:#1b2534;stroke:#4d6fa8;stroke-width:2}
.bs-lim{fill:#241d1d;stroke:#8a5a5a}
.bs-fac{fill:#201c26;stroke:#7a5f9a}
.bs-mod{fill:#231d2b;stroke:#5c4a70;stroke-dasharray:4 3}
.bs-sec{fill:#161b21;stroke:#39424e}
.bs-sec.bs-dp{fill:#151f19;stroke:#3f7a52;stroke-width:2;stroke-dasharray:5 3}
.bs-sec.bs-office{fill:#1b2534;stroke:#4d6fa8;stroke-width:2}
.bs-slot{fill:none;stroke:#5f6b7a;stroke-width:1.1;stroke-dasharray:3 2}
.bs-slot.bs-occ{fill:rgba(90,169,230,.20);stroke:#5aa9e6;stroke-dasharray:none}
.bs-train rect{fill:#2f6b3d;stroke:#8fd6a0;stroke-width:1.2}
.bs-crew rect{fill:#8a6d1f;stroke:#e0c060;stroke-width:1.2}
.bs-tlab{fill:#eaf6ec;font:600 11px ui-monospace,monospace}
.bs-carlab{fill:#cfd6e0;font:9px ui-monospace,monospace}
.bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
.bs-coord{fill:#5f6b7a;font:9px ui-monospace,monospace}
.bs-name{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
.bs-cap{fill:#8b94a3;font:10px ui-monospace,monospace}
.bs-cap.bs-full{fill:#e0a060;font-weight:600}
.bs-grade{fill:#e08060;font:10px ui-monospace,monospace}
.bs-mod{font:10px ui-monospace,monospace}
text.bs-mod{fill:#c8a04a}
.bs-enh{fill:#7fb0e6;font:9px ui-monospace,monospace}
.bs-rowlab{fill:#5f6b7a;font:600 9px ui-monospace,monospace;letter-spacing:.1em}
.bs-ghost rect{fill:rgba(90,169,230,.07);stroke:#5aa9e6;stroke-width:2;stroke-dasharray:5 4}
.bs-ghost text{fill:#5aa9e6;font:11px ui-monospace,monospace}
.bs-ghost,g[data-cell].bs-legal{cursor:pointer}
g[data-cell].bs-legal .bs-card{stroke:#5aa9e6;stroke-width:2.5}
g[data-cell].bs-legal:hover .bs-card,.bs-ghost:hover rect{fill:#233246}
g[data-cell].bs-focus .bs-card{stroke:#e0c060;stroke-width:2.5}
g[data-cell].bs-from .bs-card{stroke:#7a6a3a;stroke-width:2;stroke-dasharray:4 3}
`;
+73 -6
View File
@@ -24,8 +24,9 @@ import { MAX_CONSIST, nextOfficeTier } from '../engine/content.ts';
import type { GameEvent } from '../engine/events.ts';
import type { Intent } from '../engine/intents.ts';
import { legalActions } from '../engine/legal.ts';
import { variantsFor } from '../engine/track.ts';
import { coordKey } from '../engine/state.ts';
import type { Facility, GameState, GridCoord, PlayerIndex, RollingStock } from '../engine/state.ts';
import type { Facility, GameState, GridCoord, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
export type BotPolicy = {
name: string;
@@ -289,17 +290,83 @@ function needsClearing(s: GameState, player: PlayerIndex): boolean {
function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
const area = s.officeAreas.get(player);
if (!area) return null;
const at = (row: number, col: number): TrackCard | undefined => area.grid.get(`${row},${col}`);
const below = area.runningRow - 1;
/** Does this card send a leg DOWN off the Running Track? */
const divergesSouth = (c: TrackCard | undefined): boolean =>
!!c && c.geometry.kind === 'track' && c.geometry.geometry === 'turnout';
/** A card on the siding row that runs east-west — the siding itself. */
const runsAcross = (c: TrackCard | undefined): boolean =>
!!c && c.geometry.kind === 'track' && c.geometry.geometry === 'straight' && c.geometry.axis === 'ew';
/** An arc that reaches up to the Running Track. */
const reachesUp = (c: TrackCard | undefined): boolean =>
!!c &&
c.geometry.kind === 'track' &&
(c.geometry.geometry === 'curved' || c.geometry.geometry === 'sharpCurved') &&
(c.geometry.arc === 'ne' || c.geometry.arc === 'nw');
const arcOf = (i: Extract<Intent, { type: 'track.lay' }>): string | undefined =>
variantsFor(i.geometry)[i.variant ?? 0]?.arc;
// Where the district already turns down off the main.
const turnouts = [...area.grid.entries()]
.filter(([k, c]) => Number(k.split(',')[0]) === area.runningRow && divergesSouth(c))
.map(([k]) => Number(k.split(',')[1]));
let best: Intent | null = null;
let bestScore = -Infinity;
for (const i of options) {
if (i.type !== 'track.lay') continue;
const dRow = Math.abs(i.placement.row - area.officeCoord.row);
const dCol = Math.abs(i.placement.col - area.officeCoord.col);
const { row, col } = i.placement;
const dRow = Math.abs(row - area.officeCoord.row);
const dCol = Math.abs(col - area.officeCoord.col);
let score = -(dRow * 2 + dCol);
if (i.geometry === 'straight') score += 6;
// A district needs depth to hold facilities; a Running Track that only grows sideways gives
// Enhancements somewhere to live but Freight nowhere.
/**
* BUILD A SIDING, not a stub.
*
* A turnout dropping into a dead-end column is worth nothing: the crew can shove cars down it
* but never get past them. What makes switching solvable is a siding — down off the main, along
* beside it, and ideally back up — because that is what lets a crew run around its own train and
* change the order of the cars. Scored as a sequence, so each piece is laid because the previous
* one asked for it.
*/
if (row === area.runningRow && i.geometry === 'turnout') {
// A first way down is the most valuable single piece on the board; a second closes the
// run-around. Beyond that they are just holes in the Running Track — measured at 6.4 per game
// when unrestrained, which consumed the whole 26-piece supply on ways down and none on the
// siding they were supposed to serve.
// Measured: capping this to one or two ways down cost more than the spare turnouts did
// (revenue 3.3 -> 2.5, freight 1.1 -> 0.6). More ways off the main means more industries the
// crew can actually reach, which matters more than a tidy Running Track.
score += turnouts.length === 0 ? 14 : 3;
} else if (row === below) {
const arc = arcOf(i);
const turnoutAbove = divergesSouth(at(area.runningRow, col));
if (arc && (arc === 'ne' || arc === 'nw') && turnoutAbove) {
// Turn along beneath the turnout — this is what a bare n-s stub could never do.
score += 13;
} else if (i.geometry === 'straight' && variantsFor('straight')[i.variant ?? 0]?.axis === 'ew') {
// Extend the siding beside the main, but only from something that already turned.
if (reachesUp(at(row, col - 1)) || reachesUp(at(row, col + 1)) ||
runsAcross(at(row, col - 1)) || runsAcross(at(row, col + 1))) {
score += 11;
}
} else if (arc && (arc === 'ne' || arc === 'nw') &&
(runsAcross(at(row, col - 1)) || runsAcross(at(row, col + 1)))) {
// Close the loop back up to the main: the run-around is complete.
score += 12;
}
}
if (i.geometry === 'straight') score += 2;
if (dRow > 0) score += 3;
if (score > bestScore) {
bestScore = score;
best = i;
+19 -36
View File
@@ -36,6 +36,7 @@ export { cardName, describeIntent, snapshot } from './view.ts';
import type { Frame } from './view.ts';
import type { Decision } from './view.ts';
import { cardName, describeDecision, snapshot, trainName } from './view.ts';
import { BOARD_CSS, divisionSvg, ghostSvg, officeSvg } from './board-svg.ts';
// ---------------------------------------------------------------------------
// Frame shape — only what the viewer draws
@@ -169,6 +170,8 @@ export function renderHtml(rec: Recording): string {
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Station Master — replay seed ${rec.seed}</title>
<style>
${BOARD_CSS}
:root{--bg:#14161a;--fg:#e8e6e3;--dim:#8b9199;--line:#2c3138;--panel:#1b1f25;
--good:#5fd08a;--bad:#ff7a70;--clock:#7fb8ff;--warn:#ffc46b;--green:#2f6b47;--red:#6b3230;--maw:#2a4a6b}
*{box-sizing:border-box}
@@ -313,6 +316,12 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
</footer>
<script>
/* The board renderers, shared with the playable app. Embedded as SOURCE because this page has an
inline script and cannot import a module — a second copy would eventually draw a different board
from the same state, which is the one failure a track diagram exists to prevent. */
const divisionSvg = ${divisionSvg.toString()};
const officeSvg = ${officeSvg.toString()};
const FRAMES = ${JSON.stringify(compress(rec.frames))};
/* The board changes rarely, so cells/facilities/division are emitted as null when identical to the
previous frame and resolved by walking back. Re-serialising the full grid every frame was 63% of
@@ -369,43 +378,17 @@ function render() {
const CELLS = carry(i, 'cells'), FACS = carry(i, 'facilities'), DIV = carry(i, 'division');
$('division').innerHTML = DIV.map((n) =>
'<div class="node ' + (n.kind === 'office' ? 'office' : '') + '"><div class="nm">' + esc(n.label) +
(n.gradeUp ? ' <span class="grade">' + (n.gradeUp === 'east' ? 'climbs E ▲' : '▲ W climbs') + '</span>' : '') +
'</div>' +
(n.modifiers.length ? '<div class="mlmods">' + n.modifiers.map(esc).join(' · ') + '</div>' : '') +
'<div class="regions">' + n.trains.map((r) =>
'<div class="region">' + r.map((t) =>
'<span class="chip">' + esc(t.label) + '</span>' +
'<div class="consist">' + (t.consist.length ? esc(t.consist.join(', ')) : 'empty') + '</div>'
).join('') + '</div>').join('') +
'</div></div>').join('');
const rows = CELLS.map((c) => c.row), cols = CELLS.map((c) => c.col);
const r0 = Math.min(...rows), r1 = Math.max(...rows), c0 = Math.min(...cols), c1 = Math.max(...cols);
const g = $('grid');
g.style.gridTemplateColumns = 'repeat(' + (c1 - c0 + 1) + ', minmax(110px, 1fr))';
let cellsHtml = '';
for (let r = r1; r >= r0; r--) {
for (let c = c0; c <= c1; c++) {
const cell = CELLS.find((x) => x.row === r && x.col === c);
if (!cell) { cellsHtml += '<div></div>'; continue; }
const hl = f.where && f.where.row === r && f.where.col === c;
const wasHere = f.whereFrom && f.whereFrom.row === r && f.whereFrom.col === c;
cellsHtml += '<div class="cell ' + (cell.running ? 'run ' : '') + (cell.kind === 'office' ? 'office ' : '') +
(cell.kind === 'modifier' ? 'modifier ' : '') + (hl ? 'hl ' : '') + (wasHere ? 'from' : '') + '">' +
'<div class="nm">' + esc(cell.label) + '</div>' +
'<div class="dim" style="font-size:10px">(' + r + ',' + c + ')</div>' +
(cell.enhancements.length
? '<div class="enh">' + cell.enhancements.map(esc).join(' · ') + '</div>'
: '') +
(cell.tray ? '<div class="crew">🚂 ' + esc(cell.tray) + '</div>' : '') +
(cell.facility ? miniFacility(cell.facility) : '') +
(cell.cars.length ? '<div class="cars">siding: ' + cell.cars.map(esc).join('<br>') + '</div>' : '') +
'</div>';
}
$('division').innerHTML = divisionSvg(DIV);
// The same office renderer the playable app uses, so replay and game draw one board.
$('grid').innerHTML = officeSvg(CELLS, f.runningRow);
if (f.where) {
const hit = $('grid').querySelector('g[data-cell="' + f.where.row + ',' + f.where.col + '"]');
if (hit) hit.classList.add('bs-focus');
}
if (f.whereFrom) {
const was = $('grid').querySelector('g[data-cell="' + f.whereFrom.row + ',' + f.whereFrom.col + '"]');
if (was) was.classList.add('bs-from');
}
g.innerHTML = cellsHtml;
$('facs').innerHTML = FACS.length === 0
? '<span class="dim">no freight facilities built yet</span>'
+19 -1
View File
@@ -28,7 +28,7 @@ import {
import type { Intent } from '../engine/intents.ts';
import type { Facility, GameState, TrackCard } from '../engine/state.ts';
import type { TrackGeometry } from '../engine/content.ts';
import { variantsFor } from '../engine/track.ts';
import { connectionsFor, variantsFor } from '../engine/track.ts';
import type { Impediment } from './narrate.ts';
import { carLabel, clockTime, impediments, phaseLabel } from './narrate.ts';
@@ -47,6 +47,12 @@ export type CellView = {
tray: string | null;
cars: string[];
facility: FacilityView | null;
/**
* The port pairs this card joins, as two-letter codes — 'ew', 'ns', 'es' and so on. Taken from
* the engine's own `connectionsFor`, so a drawn rail can never claim a connection the rules do
* not have.
*/
links: string[];
/**
* What this card DOES, now that it is on the board.
*
@@ -85,6 +91,11 @@ export type DivisionView = {
kind: string;
label: string;
trains: TrainChip[][];
/**
* How many trains may stand here, or null for no limit. 1 on most Mainline cards, 2 where the
* card prints "trains may pass", the A/D count at an Office, unlimited at a Division Point.
*/
capacity: number | null;
/** Modifiers laid on a Mainline card (Brakeman, Helpers, Realignment …). */
modifiers: string[];
/**
@@ -109,6 +120,8 @@ export type Frame = {
whereFrom: { row: number; col: number } | null;
division: DivisionView[];
cells: CellView[];
/** Which grid row is the Running Track — the spine the district hangs beneath. */
runningRow: number;
facilities: FacilityView[];
hand: string[];
/** What each hand card does, in the same order — names alone are not a playable hand. */
@@ -392,6 +405,7 @@ export function snapshot(
label,
running: row === area.runningRow,
what: cellDescription(card, officeProfile(area.tier).name, row === area.runningRow),
links: connectionsFor(card).map(([a, b]) => `${a}${b}`),
enhancements: card.enhancements.map(prettyKey),
tray: trayAt.get(key) ?? null,
cars: (card.facility?.industryTrack.length ? card.facility.industryTrack.cars : card.standing).map(carLabel),
@@ -405,6 +419,7 @@ export function snapshot(
kind: 'dp',
label: n.side === 'west' ? 'West DP' : 'East DP',
trains: [n.holding.map((id) => trainChip(s, id))],
capacity: null,
modifiers: [],
gradeUp: null,
};
@@ -421,6 +436,7 @@ export function snapshot(
const chip = trainChip(s, t.tray);
return { ...chip, label: `${chip.label} (${t.stagesRemaining})` };
})],
capacity: MAINLINE_PROFILES.find((m) => m.kind === n.card)?.trainsMayPass ? 2 : 1,
modifiers: [
...(n.modifiers ?? []).map(prettyKey),
...(n.absSignals ? ['ABS Signals'] : []),
@@ -432,6 +448,7 @@ export function snapshot(
kind: 'office',
label: officeProfile(areaOf(s, n.owner).tier).name,
trains: [areaOf(s, n.owner).adOccupancy.map((id) => trainChip(s, id))],
capacity: officeProfile(areaOf(s, n.owner).tier).adTracks,
modifiers: [],
gradeUp: null,
};
@@ -466,6 +483,7 @@ export function snapshot(
return { piece: `${hand === 'none' ? '' : hand + '-hand '}${geometryLabel(geometry ?? '')}`, left };
})
.sort((a, b) => a.piece.localeCompare(b.piece)),
runningRow: area.runningRow,
blocked: impediments(s, 0),
trains: [...s.trays.values()].map((t) => ({
label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,