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
+2
View File
@@ -1326,6 +1326,7 @@ function protoCard(
kind: 'track',
geometry,
...(v.axis ? { axis: v.axis } : {}),
...(v.arc ? { arc: v.arc } : {}),
...(v.turnout ? { turnout: v.turnout } : {}),
...(v.bypass ? { bypass: v.bypass } : {}),
},
@@ -1504,6 +1505,7 @@ function protoTrackCard(
kind: 'track',
geometry,
...(v.axis ? { axis: v.axis } : {}),
...(v.arc ? { arc: v.arc } : {}),
...(v.turnout ? { turnout: v.turnout } : {}),
...(hand !== 'none' ? { hand } : {}),
},
+9
View File
@@ -58,6 +58,13 @@ export type TurnoutOrientation = { stem: 'n' | 's' | 'e' | 'w'; through: 'n' | '
* how many run each way, yet a layout with no north-south straight can never use the Office's
* junction stubs at all.
*/
/**
* A curve joins two ADJACENT edges. There are exactly four such arcs, and a card can be turned to
* any of them — which is what makes a siding possible: an arc reaching NORTH is the only way back
* up to the Running Track from the district below.
*/
export type TrackArc = 'ne' | 'nw' | 'se' | 'sw';
export type TrackAxis = 'ew' | 'ns';
export type CardGeometry =
@@ -66,6 +73,8 @@ export type CardGeometry =
geometry: TrackGeometry;
turnout?: TurnoutOrientation;
axis?: TrackAxis;
/** Which two edges a CURVE joins — chosen on placement, since the card can be turned. */
arc?: TrackArc;
/** Curves are printed left- or right-handed (design supply); not chosen on placement. */
hand?: 'left' | 'right';
}
+27 -14
View File
@@ -23,6 +23,7 @@ import type {
RollingStock,
TrackCard,
TrayId,
TrackArc,
TurnoutOrientation,
} from './state.ts';
import { carsOn, coordKey, isOperationalRail, spaceOn } from './state.ts';
@@ -77,7 +78,12 @@ type PortPair = readonly [Port, Port];
* - **facility** — a through track; the industry spur is the card's own spotting capacity rather
* than a separate port.
*/
function connectionsFor(card: TrackCard): readonly PortPair[] {
/**
* Exported so the board can DRAW what the engine believes. Deriving the rails from anything else
* would let a picture disagree with the rules about whether two cards join — which is the one thing
* a track diagram exists to settle.
*/
export function connectionsFor(card: TrackCard): readonly PortPair[] {
switch (card.geometry.kind) {
// Neither a Modifier nor a Space-use card is track: nothing connects, no train may enter.
case 'modifier':
@@ -108,18 +114,22 @@ function connectionsFor(card: TrackCard): readonly PortPair[] {
[o.stem, o.diverge],
];
}
// A CURVE joins the through track to one diverging side — handedness is printed on the
// card, not chosen (the design supplies both hands). A SHARP curve is geometrically the
// same but costs two Moves to cross.
// Curves and turnouts diverge DOWNWARD from the through track (Q7); handedness decides
// which end of the through track the leg leaves from, not whether it goes up or down.
/**
* A CURVE IS AN ARC between two adjacent edges — one connection, no through track. The
* printed cards show exactly this (docs/tracks.png, rows 3-4): a single sweep from edge to
* edge with nothing running past it.
*
* It was previously modelled as a through track PLUS a diverging leg, which is a turnout —
* and made curves topologically identical duplicates of them. Worse, every leg diverged
* south, so no piece anywhere reached NORTH except an n-s straight. A district could
* therefore only ever be a vertical column: no siding, no parallel track, no run-around.
*
* A SHARP curve is the same shape and costs two Moves to cross.
*/
case 'curved':
case 'sharpCurved': {
const stem = card.geometry.hand === 'left' ? 'w' : 'e';
return [
['e', 'w'],
[stem, 's'],
];
const arc = card.geometry.arc ?? (card.geometry.hand === 'left' ? 'sw' : 'se');
return [[arc[0] as Port, arc[1] as Port]];
}
}
}
@@ -144,7 +154,10 @@ export const DEFAULT_TURNOUT: TurnoutOrientation = { stem: 'e', through: 'w', di
* §A.1's constraint is preserved: a turnout's two legs still never join each other. Only the card's
* rotation is free.
*/
const ARCS: readonly TrackArc[] = ['ne', 'nw', 'se', 'sw'];
export type TrackVariant = {
arc?: TrackArc;
axis?: 'ew' | 'ns';
turnout?: TurnoutOrientation;
bypass?: Port;
@@ -167,9 +180,9 @@ export function variantsFor(geometry: TrackGeometry): TrackVariant[] {
return TURNOUT_VARIANTS.map((t) => ({ turnout: t }));
case 'curved':
case 'sharpCurved':
// Handedness is printed on the card, and the leg always goes down (Q7), so nothing is left
// to choose — one variant.
return [{ axis: 'ew' }];
// All four rotations. 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.
return ARCS.map((arc) => ({ arc }));
}
}
+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}`,
+59 -114
View File
@@ -3,130 +3,75 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Station Master — solitaire</title>
<title>Station Master</title>
<style>
:root{--bg:#12151a;--fg:#e6e9ee;--dim:#8b94a3;--line:#2c333d;--panel:#1a1f26;--run:#3a4250}
:root{--bg:#12151a;--fg:#e6e9ee;--dim:#8b94a3;--line:#2c333d;--panel:#1a1f26}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:14px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
h1{font-size:17px;margin:0}
h2{font-size:12px;text-transform:uppercase;letter-spacing:.09em;color:var(--dim);
margin:0 0 6px;font-weight:600}
h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--dim);margin:9px 0 4px}
.dim{color:var(--dim)}
header{position:sticky;top:0;z-index:5;background:var(--panel);border-bottom:1px solid var(--line);
padding:9px 14px;display:flex;gap:20px;align-items:baseline;flex-wrap:wrap}
header b{font-size:16px}
.build{margin-left:auto;font-size:11px;opacity:.7}
.pace{font-size:12px;padding:1px 7px;border-radius:10px}
.pace.good{background:rgba(40,140,60,.32)}
.pace.behind{background:rgba(190,120,40,.28)}
.handcard{border-top:1px solid var(--line);padding:4px 0}
.handcard:first-child{border-top:0}
.handcard .what{font-size:11px;line-height:1.35}
.handcard.unplayable{opacity:.55}
.tag{font-size:10px;background:rgba(190,120,40,.3);border-radius:3px;padding:0 5px}
main{display:grid;grid-template-columns:minmax(0,1fr) 400px;gap:14px;padding:14px;align-items:start}
@media(max-width:1100px){main{grid-template-columns:1fr}}
section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
padding:10px 12px;margin-bottom:12px}
/* division strip */
#division{display:flex;gap:7px;overflow-x:auto;padding-bottom:4px}
.node{border:1px solid var(--line);border-radius:6px;padding:6px 9px;min-width:112px;flex:0 0 auto}
.node.office{border-color:#4d6fa8;background:rgba(60,100,170,.13)}
.node .nm{font-weight:600;font-size:12px}
.chip{display:inline-block;background:#2f6b3d;border-radius:4px;padding:1px 6px;margin:3px 3px 0 0;font-size:11px}
.consist{font-size:10px;color:var(--dim)}
.grade{font-size:10px;background:rgba(200,90,60,.35);border-radius:3px;padding:1px 4px;font-weight:400}
.mlmods{font-size:10px;background:rgba(200,150,60,.28);border-radius:3px;padding:1px 4px;margin-top:2px}
/* board */
#grid{display:grid;gap:5px;overflow-x:auto}
.cell{border:1px solid var(--line);border-radius:5px;padding:5px 6px;min-height:64px;background:#161b21}
.cell.run{background:var(--run)}
.cell.office{border-color:#4d6fa8}
.cell.modifier{background:#2b2333;border-style:dashed}
.cell.legal{outline:2px solid #5aa9e6;outline-offset:-2px;cursor:pointer;
box-shadow:0 0 0 3px rgba(90,169,230,.18) inset}
.cell.legal:hover{background:#233246}
.cell.ghost{border-style:dashed;background:rgba(90,169,230,.07);
display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center}
.cell.ghost .nm{font-weight:400;color:#5aa9e6;font-size:11px}
.cell .nm{font-size:12px;font-weight:600}
.cell .what{font-size:10px;line-height:1.25;margin-top:2px}
.cell{cursor:help}
.coord{font-size:10px}
.enh{font-size:10px;background:rgba(90,140,220,.30);border-radius:3px;padding:1px 4px;margin-top:2px}
.crew{font-size:11px;margin-top:3px;color:#8fd6a0}
.cars{font-size:10px;color:var(--dim)}
/* facilities */
.fac{border-top:1px solid var(--line);padding:7px 0}
.fac:first-child{border-top:0}
.boxes{display:flex;gap:3px;align-items:center;margin-top:3px;flex-wrap:wrap}
.box{display:inline-block;min-width:22px;text-align:center;border-radius:3px;padding:1px 4px;font-size:10px}
.box.empty{background:#242a32;color:#5a6472}
.box.f{background:#2f6b3d}
.box.m{background:#8a6d1f}
.fstat{margin-top:4px;font-size:11px;padding:2px 5px;border-radius:3px;display:inline-block}
.fstat.good{background:rgba(40,140,60,.28)}
.fstat.bad{background:rgba(190,50,50,.38);font-weight:700}
.fstat.idle{opacity:.6}
/* actions */
#actions{max-height:none}
.grp{margin-bottom:6px}
button{background:#2a3038;color:var(--fg);border:1px solid var(--line);border-radius:5px;
padding:5px 9px;margin:2px 3px 2px 0;cursor:pointer;font:inherit;font-size:12px;text-align:left}
button:hover{background:#39424e;border-color:#4d6fa8}
button.act{display:inline-block}
.over{padding:9px;border-radius:5px;font-weight:700;margin-bottom:8px}
.over.win{background:rgba(40,140,60,.35)}
.over.loss{background:rgba(160,60,60,.3)}
/* cards, log, blocked */
.card.gone{opacity:.35;text-decoration:line-through}
.subj{display:block;width:100%;margin:2px 0}
.subj.open{border-color:#4d6fa8;background:#39424e}
.spots{margin:0 0 6px 12px;padding-left:8px;border-left:2px solid #4d6fa8}
.card{display:inline-block;background:#242c36;border:1px solid var(--line);border-radius:4px;
padding:2px 6px;margin:2px 2px 0 0;font-size:11px}
#log{max-height:230px;overflow:auto;font-size:12px}
.line{padding:1px 0}
.t-good{color:#8fd6a0}.t-bad{color:#e58080}.t-clock{color:#9fb6d8;font-weight:600}.t-quiet{color:var(--dim)}
ul.blocked{list-style:none;margin:0;padding:0;font-size:12px}
ul.blocked li{padding:2px 0}
.sev-warn{color:#e0b060}.sev-stop{color:#e58080}
font:15px/1.6 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:28px}
main{width:100%;max-width:760px}
h1{font-size:32px;margin:0 0 2px;letter-spacing:.02em}
.tag{color:var(--dim);margin:0;font-size:14px}
.blurb{color:#c6ccd6;margin:16px 0 26px;max-width:66ch}
.rule{height:1px;background:var(--line);margin:22px 0}
.doors{display:grid;grid-template-columns:1fr 1fr;gap:14px}
@media(max-width:640px){.doors{grid-template-columns:1fr}}
a.door{display:block;text-decoration:none;color:inherit;background:var(--panel);
border:1px solid var(--line);border-radius:9px;padding:18px 18px 16px;transition:.12s}
a.door:hover{border-color:#4d6fa8;background:#1f2733;transform:translateY(-1px)}
a.door h2{font-size:17px;margin:0 0 5px;color:#9fb6d8}
a.door p{margin:0;color:var(--dim);font-size:13px;line-height:1.5}
a.door .go{display:inline-block;margin-top:11px;font-size:12px;color:#5aa9e6}
/* the rule between title and blurb is drawn as rail, not decorated */
svg.trackline{width:100%;height:24px;display:block;margin:10px 0 0}
.rail{stroke:#4a5361;stroke-width:1.6}
.tie{stroke:#39424e;stroke-width:1.1}
footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;flex-wrap:wrap}
</style>
</head>
<body>
<header>
<b>Station Master</b>
<span id="clock">—</span>
<span>phase: <b id="phase">—</b></span>
<span>Revenue <b id="revenue">0</b></span>
<span id="objective" class="pace">—</span>
<span class="dim">seed <span id="seed">—</span></span>
<span class="dim">saved in this browser · add ?seed=1234 to the address for a set deal</span>
<span class="dim build" title="what is actually deployed">__BUILD__</span>
</header>
<main>
<div>
<section><h2>The Division — west to east</h2><div id="division"></div></section>
<section><h2>Your Office Area <span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span></h2><div id="grid"></div></section>
<section><h2>History</h2><div id="log"></div></section>
<h1>Station Master</h1>
<p class="tag">Timetable-and-train-order railroading, 1840&ndash;1950.</p>
<svg class="trackline" viewBox="0 0 700 24" preserveAspectRatio="none" aria-hidden="true">
<line class="rail" x1="0" y1="10" x2="700" y2="10"/>
<line class="rail" x1="0" y1="15" x2="700" y2="15"/>
<g id="ties"></g>
</svg>
<p class="blurb">
You run a division: a Running Track between your Limits, a district of industries hanging beneath
it, and trains that arrive whether or not you are ready for them. Spot the right cars, work the
loads, and get every train away again &mdash; a train with nowhere to stand is a collision, and a
collision costs more than the freight was worth.
</p>
<div class="doors">
<a class="door" href="./play.html">
<h2>Play solitaire</h2>
<p>Five Days, twelve Stages each, and a target of 20 Revenue. Your game saves in this browser,
so you can close the tab and come back to it.</p>
<span class="go">Start a game &rarr;</span>
</a>
<a class="door" href="./replays.html">
<h2>Replays</h2>
<p>Watch a finished game back, Stage by Stage. Open one saved on this site, or a save file
somebody has sent you.</p>
<span class="go">Browse replays &rarr;</span>
</a>
</div>
<div>
<section><h2>Your move</h2><div id="actions"></div></section>
<section><h2>Cards</h2>
<div><span class="dim">hand:</span> <span id="hand">—</span></div>
<div style="margin-top:5px"><span class="dim">face-up Department slots:</span> <span id="depts">—</span></div>
</section>
<section><h2>Your track supply</h2><div id="supply"></div></section>
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section>
<section><h2>Facilities</h2><div id="facs"></div></section>
</div>
<div class="rule"></div>
<footer>
<span>build <span id="build">__BUILD__</span></span>
<span>runs entirely in your browser &mdash; nothing is sent anywhere</span>
</footer>
</main>
<script type="module" src="./web/main.js"></script>
<script type="module" src="./web/splash.js"></script>
</body>
</html>
+89 -106
View File
@@ -5,6 +5,8 @@
* is answered by the engine or by the shared view helpers.
*/
import { BOARD_CSS, divisionSvg, ghostSvg, officeSvg } from '../sim/board-svg.ts';
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
import type { Game } from './game.ts';
import {
actionMenu,
@@ -73,99 +75,46 @@ function render(): void {
$('seed').textContent = String(game.seed);
// -- division
$('division').innerHTML = f.division
.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('');
$('division').innerHTML = divisionSvg(f.division);
// -- grid
// Include the legal squares in the bounds. A placement is usually just OUTSIDE the current
// extent — that is what extending the district means — so a grid sized to the existing cards
// would highlight nothing at exactly the moment highlighting matters.
const spotCoords = [...spotsAt.keys()].map((k) => k.split(',').map(Number));
const rows = [...f.cells.map((c) => c.row), ...spotCoords.map((c) => c[0]!)];
const cols = [...f.cells.map((c) => c.col), ...spotCoords.map((c) => c[1]!)];
const r0 = Math.min(...rows);
const r1 = Math.max(...rows);
const c0 = Math.min(...cols);
const c1 = Math.max(...cols);
// -- board. Both renderers are shared with the replay so the two can never draw different
// pictures of the same position.
const grid = $('grid');
grid.style.gridTemplateColumns = `repeat(${c1 - c0 + 1}, minmax(104px, 1fr))`;
let cells = '';
for (let r = r1; r >= r0; r--) {
for (let c = c0; c <= c1; c++) {
const cell = f.cells.find((x) => x.row === r && x.col === c);
const spots = spotsAt.get(`${r},${c}`);
if (!cell) {
// An empty square is still a legal destination — render it as a target rather than a gap,
// or the most common placement of all would have nothing to click.
cells += spots
? `<div class="cell ghost legal" data-at="${r},${c}">` +
`<div class="nm">place here</div><div class="dim coord">(${r},${c})</div>` +
(spots.length > 1 ? `<div class="dim">${spots.length} rotations</div>` : '') +
`</div>`
: '<div></div>';
continue;
}
// The full explanation goes in `title` so hovering any card says what it does, with a short
// version printed on the card itself — a name alone tells a player nothing once it is played.
cells +=
`<div class="cell ${cell.running ? 'run ' : ''}${cell.kind === 'office' ? 'office ' : ''}` +
`${cell.kind === 'modifier' ? 'modifier ' : ''}${spots ? 'legal' : ''}"` +
`${spots ? ` data-at="${r},${c}"` : ''} title="${esc(cell.label)} — ${esc(cell.what)}">` +
`<div class="nm">${esc(cell.label)}</div>` +
`<div class="dim coord">(${r},${c})</div>` +
`<div class="dim what">${esc(short(cell.what))}</div>` +
(cell.enhancements.length
? `<div class="enh">${cell.enhancements.map(esc).join(' · ')}</div>`
: '') +
(cell.tray ? `<div class="crew">🚂 ${esc(cell.tray)}</div>` : '') +
(cell.cars.length ? `<div class="cars">${esc(cell.cars.join(', '))}</div>` : '') +
`</div>`;
grid.innerHTML = officeSvg(f.cells, f.runningRow);
// Highlighting rides on top of the drawing: outline the legal squares and make them clickable.
for (const [key, list] of spotsAt) {
const [gr, gc] = key.split(',').map(Number);
const g = grid.querySelector(`g[data-cell="${gr},${gc}"]`);
if (g) {
g.classList.add('bs-legal');
(g as unknown as HTMLElement).onclick = () => pick(key, list);
}
}
// A legal EMPTY square has no card to outline, so draw a target for it.
const ghosts = [...spotsAt.entries()].filter(([k]) => !f.cells.some((c) => `${c.row},${c.col}` === k));
if (ghosts.length > 0) {
const coords = ghosts.map(([k]) => {
const [gr, gc] = k.split(',').map(Number);
return { row: gr!, col: gc! };
});
grid.innerHTML = grid.innerHTML.replace('</svg>', ghostSvg(coords, f.cells, f.runningRow) + '</svg>');
for (const [key, list] of ghosts) {
const g = grid.querySelector(`g[data-ghost="${key}"]`);
if (g) (g as unknown as HTMLElement).onclick = () => pick(key, list);
}
}
grid.innerHTML = cells;
// Clicking a highlighted square plays there. With more than one rotation available it narrows to
// that square instead, so the rotation is a second click rather than a guess.
for (const el of Array.from(grid.querySelectorAll('[data-at]'))) {
const node = el as HTMLElement;
node.onclick = () => {
const spots = spotsAt.get(node.dataset['at'] ?? '') ?? [];
if (spots.length === 1) {
const intent = menu.options[spots[0]!.index];
if (intent) submit(game, intent);
selected = null;
pendingAt = null;
} else if (spots.length > 1) {
pendingAt = node.dataset['at'] ?? null;
}
render();
};
function pick(key: string, list: { label: string; index: number }[]): void {
if (list.length === 1) {
const intent = menu.options[list[0]!.index];
if (intent) submit(game, intent);
selected = null;
pendingAt = null;
} else {
pendingAt = key;
}
render();
}
// -- facilities
@@ -175,8 +124,8 @@ function render(): void {
: f.facilities
.map(
(x) =>
`<div class="fac"><div class="nm">${esc(x.name)}</div>` +
`<div class="dim">${esc(x.commodity)} · ${esc(x.flow)} · laborers ${esc(x.laborers)}</div>` +
`<div class="fac" data-tip="${esc(x.name)} — ${esc(x.flow)} ${esc(x.commodity)} · laborers ${esc(x.laborers)} · porters ${esc(x.porters)}" tabindex="0">` +
`<div class="nm">${esc(x.name)} <span class="dim">${esc(x.commodity)}</span></div>` +
`<div class="boxes"><span class="dim">green</span>${boxes(x.green, x.greenCap)}</div>` +
`<div class="boxes"><span class="dim">MEN AT WORK</span>` +
x.maw
@@ -187,21 +136,24 @@ function render(): void {
`</div>` +
`<div class="boxes"><span class="dim">red</span>${boxes(x.red, x.redCap)}</div>` +
`<div class="boxes"><span class="dim">siding</span>${boxes(x.track, x.trackCap)}</div>` +
`<div class="fstat ${x.jammed ? 'bad' : x.canFinish ? 'good' : 'idle'}">` +
(x.jammed
? 'JAMMED — a load is on WORK with no car spotted; the siding is locked'
: x.canFinish
? 'ready — a matching empty car is spotted'
: 'no car spotted — a load started here would jam') +
`<div class="fstat ${x.jammed ? 'bad' : x.canFinish ? 'good' : 'idle'}" data-tip="${
x.jammed
? 'A load is sitting on MEN|AT|WORK with no spotted car to receive it. That locks the industry track, which blocks the very car that would clear it (§9.3).'
: x.canFinish
? 'A matching empty car is spotted on the siding, so a load worked here can come off onto it.'
: 'No matching car is spotted. Starting a load here would park it on WORK and jam the facility.'
}">` +
(x.jammed ? 'JAMMED' : x.canFinish ? 'ready' : 'no car spotted') +
`</div></div>`,
)
.join('');
// Name AND effect. A hand of names alone tells a player nothing about what they can do.
// Name and status stay on the page; what the card DOES is reference detail, so it hovers.
const cardRow = (name: string, why: string, playable: boolean | null): string =>
`<div class="handcard${playable === false ? ' unplayable' : ''}"><b>${esc(name)}</b>` +
(playable === false ? ' <span class="tag">not playable yet</span>' : '') +
(why ? `<div class="dim what">${esc(why)}</div>` : '') +
`<div class="handcard${playable === false ? ' unplayable' : ''}"${why ? ` data-tip="${esc(why)}"` : ''} tabindex="0">` +
`<b>${esc(name)}</b>` +
(playable === false ? ' <span class="tag">not yet</span>' : '') +
`</div>`;
const canPlay = handPlayable(game);
@@ -241,12 +193,6 @@ function render(): void {
save();
}
/** First clause only — the cards are small, and the full text is on the tooltip. */
function short(what: string): string {
const head = what.split(' · ')[0] ?? what;
return head.length > 64 ? head.slice(0, 61) + '…' : head;
}
function boxes(items: string[], cap: number): string {
let out = '';
for (let i = 0; i < Math.max(cap, items.length); i++) {
@@ -286,11 +232,22 @@ function renderActions(menu: ReturnType<typeof actionMenu>): void {
render();
};
// A long label is two things: the action, and why it is offered. Put the first on the button and
// the second on the tooltip, or the list crowds out the board.
const actionButton = (label: string, index: number): string => {
const cut = label.indexOf(' — ');
const head = cut > 0 ? label.slice(0, cut) : label;
const rest = cut > 0 ? label.slice(cut + 3) : '';
return (
`<button class="act" data-i="${index}"${rest ? ` data-tip="${esc(rest)}"` : ''}>${esc(head)}</button>`
);
};
let html = menu.direct
.map(
(g) =>
`<div class="grp"><h3>${esc(g.title)}</h3>` +
g.actions.map((a) => `<button class="act" data-i="${a.index}">${esc(a.label)}</button>`).join('') +
g.actions.map((a) => actionButton(a.label, a.index)).join('') +
`</div>`,
)
.join('');
@@ -341,6 +298,24 @@ function renderActions(menu: ReturnType<typeof actionMenu>): void {
// Saving. localStorage only — nothing leaves the browser.
// ---------------------------------------------------------------------------
/**
* Write the game out as a file.
*
* The save IS the replay: a seed and the moves made, which the engine can replay exactly. A few
* hundred bytes, so a finished game can be emailed or dropped on the site's replay directory —
* where a rendered page would have been megabytes.
*/
function downloadSave(): void {
const data = JSON.stringify(toSave(game), null, 1);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `station-master-seed${game.seed}-day${game.state.clock.day}.json`;
a.click();
URL.revokeObjectURL(url);
}
function save(): void {
try {
localStorage.setItem(SAVE_KEY, JSON.stringify(toSave(game)));
@@ -366,4 +341,12 @@ function clearSave(): void {
}
}
const tipStyle = document.createElement('style');
tipStyle.textContent = TOOLTIP_CSS;
document.head.appendChild(tipStyle);
installTooltips();
const saveBtn = document.getElementById('savefile');
if (saveBtn) saveBtn.onclick = downloadSave;
start();
+139
View File
@@ -0,0 +1,139 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Station Master — play</title>
<style>
:root{--bg:#12151a;--fg:#e6e9ee;--dim:#8b94a3;--line:#2c333d;--panel:#1a1f26;--run:#3a4250}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:14px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
h1{font-size:17px;margin:0}
h2{font-size:12px;text-transform:uppercase;letter-spacing:.09em;color:var(--dim);
margin:0 0 6px;font-weight:600}
h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--dim);margin:9px 0 4px}
.dim{color:var(--dim)}
header{position:sticky;top:0;z-index:5;background:var(--panel);border-bottom:1px solid var(--line);
padding:9px 14px;display:flex;gap:20px;align-items:baseline;flex-wrap:wrap}
header b{font-size:16px}
header button{background:#2a3038;color:inherit;border:1px solid #2c333d;border-radius:5px;padding:3px 9px;cursor:pointer;font:inherit;font-size:12px}
header button:hover{border-color:#4d6fa8}
.build{margin-left:auto;font-size:11px;opacity:.7}
.home{color:inherit;text-decoration:none;border-bottom:1px dotted #5f6b7a}
.home:hover{color:#5aa9e6}
.pace{font-size:12px;padding:1px 7px;border-radius:10px}
.pace.good{background:rgba(40,140,60,.32)}
.pace.behind{background:rgba(190,120,40,.28)}
.handcard{border-top:1px solid var(--line);padding:4px 0}
.handcard:first-child{border-top:0}
.handcard{padding:3px 0}
.handcard:focus{outline:1px solid #4d6fa8;border-radius:3px}
.handcard.unplayable{opacity:.55}
.tag{font-size:10px;background:rgba(190,120,40,.3);border-radius:3px;padding:0 5px}
main{display:grid;grid-template-columns:minmax(0,1fr) 400px;gap:14px;padding:14px;align-items:start}
@media(max-width:1100px){main{grid-template-columns:1fr}}
section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
padding:10px 12px;margin-bottom:12px}
/* division strip */
#division{display:flex;gap:7px;overflow-x:auto;padding-bottom:4px}
.node{border:1px solid var(--line);border-radius:6px;padding:6px 9px;min-width:112px;flex:0 0 auto}
.node.office{border-color:#4d6fa8;background:rgba(60,100,170,.13)}
.node .nm{font-weight:600;font-size:12px}
.chip{display:inline-block;background:#2f6b3d;border-radius:4px;padding:1px 6px;margin:3px 3px 0 0;font-size:11px}
.consist{font-size:10px;color:var(--dim)}
.grade{font-size:10px;background:rgba(200,90,60,.35);border-radius:3px;padding:1px 4px;font-weight:400}
.mlmods{font-size:10px;background:rgba(200,150,60,.28);border-radius:3px;padding:1px 4px;margin-top:2px}
/* board */
#grid{display:grid;gap:5px;overflow-x:auto}
.cell{border:1px solid var(--line);border-radius:5px;padding:5px 6px;min-height:64px;background:#161b21}
.cell.run{background:var(--run)}
.cell.office{border-color:#4d6fa8}
.cell.modifier{background:#2b2333;border-style:dashed}
.cell.legal{outline:2px solid #5aa9e6;outline-offset:-2px;cursor:pointer;
box-shadow:0 0 0 3px rgba(90,169,230,.18) inset}
.cell.legal:hover{background:#233246}
.cell.ghost{border-style:dashed;background:rgba(90,169,230,.07);
display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center}
.cell.ghost .nm{font-weight:400;color:#5aa9e6;font-size:11px}
.cell .nm{font-size:12px;font-weight:600}
.cell .what{font-size:10px;line-height:1.25;margin-top:2px}
.cell{cursor:help}
.coord{font-size:10px}
.enh{font-size:10px;background:rgba(90,140,220,.30);border-radius:3px;padding:1px 4px;margin-top:2px}
.crew{font-size:11px;margin-top:3px;color:#8fd6a0}
.cars{font-size:10px;color:var(--dim)}
/* facilities */
.fac{border-top:1px solid var(--line);padding:7px 0}
.fac:first-child{border-top:0}
.boxes{display:flex;gap:3px;align-items:center;margin-top:3px;flex-wrap:wrap}
.box{display:inline-block;min-width:22px;text-align:center;border-radius:3px;padding:1px 4px;font-size:10px}
.box.empty{background:#242a32;color:#5a6472}
.box.f{background:#2f6b3d}
.box.m{background:#8a6d1f}
.fstat{margin-top:3px;font-size:10px;padding:1px 6px;border-radius:3px;display:inline-block}
.fstat.good{background:rgba(40,140,60,.28)}
.fstat.bad{background:rgba(190,50,50,.38);font-weight:700}
.fstat.idle{opacity:.6}
/* actions */
#actions{max-height:none}
.grp{margin-bottom:6px}
button{background:#2a3038;color:var(--fg);border:1px solid var(--line);border-radius:5px;
padding:5px 9px;margin:2px 3px 2px 0;cursor:pointer;font:inherit;font-size:12px;text-align:left}
button:hover{background:#39424e;border-color:#4d6fa8}
button.act{display:inline-block}
.over{padding:9px;border-radius:5px;font-weight:700;margin-bottom:8px}
.over.win{background:rgba(40,140,60,.35)}
.over.loss{background:rgba(160,60,60,.3)}
/* cards, log, blocked */
.card.gone{opacity:.35;text-decoration:line-through}
.subj{display:block;width:100%;margin:2px 0}
.subj.open{border-color:#4d6fa8;background:#39424e}
.spots{margin:0 0 6px 12px;padding-left:8px;border-left:2px solid #4d6fa8}
.card{display:inline-block;background:#242c36;border:1px solid var(--line);border-radius:4px;
padding:2px 6px;margin:2px 2px 0 0;font-size:11px}
#log{max-height:230px;overflow:auto;font-size:12px}
.line{padding:1px 0}
.t-good{color:#8fd6a0}.t-bad{color:#e58080}.t-clock{color:#9fb6d8;font-weight:600}.t-quiet{color:var(--dim)}
ul.blocked{list-style:none;margin:0;padding:0;font-size:12px}
ul.blocked li{padding:2px 0}
.sev-warn{color:#e0b060}.sev-stop{color:#e58080}
</style>
</head>
<body>
<header>
<b><a href="./index.html" class="home">Station Master</a></b>
<span id="clock">—</span>
<span>phase: <b id="phase">—</b></span>
<span>Revenue <b id="revenue">0</b></span>
<span id="objective" class="pace">—</span>
<span class="dim">seed <span id="seed">—</span></span>
<span class="dim">saved in this browser · add ?seed=1234 for a set deal</span>
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
<a class="home" href="./replays.html" style="font-size:12px">replays</a>
<span class="dim build" title="what is actually deployed">__BUILD__</span>
</header>
<main>
<div>
<section><h2>The Division — west to east</h2><div id="division"></div></section>
<section><h2>Your Office Area <span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span></h2><div id="grid"></div></section>
<section><h2>History</h2><div id="log"></div></section>
</div>
<div>
<section><h2>Your move</h2><div id="actions"></div></section>
<section><h2>Cards</h2>
<div><span class="dim">hand:</span> <span id="hand">—</span></div>
<div style="margin-top:5px"><span class="dim">face-up Department slots:</span> <span id="depts">—</span></div>
</section>
<section><h2>Your track supply</h2><div id="supply"></div></section>
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section>
<section><h2>Facilities</h2><div id="facs"></div></section>
</div>
</main>
<script type="module" src="./web/main.js"></script>
</body>
</html>
+241
View File
@@ -0,0 +1,241 @@
/**
* The replay directory, and the viewer that plays one back.
*
* A REPLAY IS A SAVE. `{ seed, history }` — a few hundred bytes — because the engine is
* deterministic and runs in the browser: re-submitting the same intents against the same seed
* reconstructs the position exactly. So sharing a game means sharing a small JSON file, not a
* multi-megabyte page, and a save that would describe an impossible position simply cannot be
* replayed, because every step goes through `applyIntent`.
*
* Static hosting cannot list a directory, so site-hosted replays are enumerated by
* `replays/manifest.json`, written at build time from whatever is in `public/replays/`.
*/
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
import { narrate } from '../sim/narrate.ts';
import { cardName, trainName } from '../sim/view.ts';
import type { Frame } from '../sim/view.ts';
import { applyIntent } from '../engine/apply.ts';
import { pump } from '../engine/advance.ts';
import { createGame } from '../engine/setup.ts';
import { snapshot } from '../sim/view.ts';
import type { Intent } from '../engine/intents.ts';
import { SOLO_CONFIG } from './game.ts';
type Save = { seed: number; history: Intent[] };
type Entry = { file: string; title: string; note?: string; seed?: number };
const $ = (id: string): HTMLElement => {
const el = document.getElementById(id);
if (!el) throw new Error(`missing element: ${id}`);
return el;
};
const esc = (s: string): string =>
String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c);
/** Every position the save passes through, with the lines narrated along the way. */
type Step = { frame: Frame; lines: { text: string; tone: string }[] };
/**
* Rebuild a game from a save, keeping a snapshot at each step.
*
* An intent that no longer applies stops the rebuild rather than being forced — better a short
* replay than one showing a position the rules could not produce.
*/
function rebuild(save: Save): { steps: Step[]; stoppedEarly: boolean } {
const s = createGame({ id: `replay-${save.seed}`, seed: save.seed, config: SOLO_CONFIG, playerNames: ['player'] });
const steps: Step[] = [];
const ctx = { cardName: (id: string) => cardName(s, id), trainName: (id: string) => trainName(s, id) };
const push = (events: ReturnType<typeof pump>): void => {
const lines = events
.filter((e) => e.type !== 'actorChanged')
.map((e) => {
const n = narrate(e, ctx);
return { text: n.text, tone: n.tone };
});
steps.push({ frame: snapshot(s, [], null), lines });
};
push(pump(s));
let stoppedEarly = false;
for (const intent of save.history) {
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
if (actor === null || s.status !== 'active') break;
const r = applyIntent(s, actor, intent);
if (!r.ok) {
stoppedEarly = true;
break;
}
const events = [...r.events, ...pump(s)];
push(events);
}
return { steps, stoppedEarly };
}
// ---------------------------------------------------------------------------
// Viewer
// ---------------------------------------------------------------------------
let steps: Step[] = [];
let at = 0;
let timer: ReturnType<typeof setInterval> | null = null;
function show(i: number): void {
at = Math.max(0, Math.min(steps.length - 1, i));
const step = steps[at];
if (!step) return;
const f = step.frame;
$('vclock').textContent = `Day ${f.day} · Stage ${f.stage} — ${f.clock}`;
$('vphase').textContent = f.phase;
$('vrev').textContent = String(f.revenue);
$('vpos').textContent = `${at} / ${steps.length - 1}`;
($('vscrub') as HTMLInputElement).value = String(at);
$('vdivision').innerHTML = divisionSvg(f.division);
$('vgrid').innerHTML = officeSvg(f.cells, f.runningRow);
// A window of history rather than only this step, so a jump lands in context.
let log = '';
for (let k = Math.max(0, at - 18); k <= at; k++) {
for (const l of steps[k]?.lines ?? []) {
log += `<div class="line t-${l.tone}${k === at ? ' now' : ''}">${esc(l.text)}</div>`;
}
}
$('vlog').innerHTML = log;
const logEl = $('vlog');
logEl.scrollTop = logEl.scrollHeight;
}
function openSave(save: Save, title: string): void {
const built = rebuild(save);
steps = built.steps;
$('picker').style.display = 'none';
$('viewer').style.display = 'block';
$('vtitle').textContent = title;
$('vnote').textContent = built.stoppedEarly
? 'this save stopped early — an action in it is no longer legal under the current rules'
: `${steps.length} steps · seed ${save.seed}`;
const scrub = $('vscrub') as HTMLInputElement;
scrub.max = String(Math.max(0, steps.length - 1));
show(0);
}
function parseSave(text: string, from: string): Save | null {
try {
const raw = JSON.parse(text) as Partial<Save>;
if (typeof raw.seed !== 'number' || !Array.isArray(raw.history)) {
throw new Error('not a Station Master save');
}
return { seed: raw.seed, history: raw.history as Intent[] };
} catch (e) {
$('perr').textContent = `Could not read ${from}: ${(e as Error).message}`;
return null;
}
}
// ---------------------------------------------------------------------------
// Directory
// ---------------------------------------------------------------------------
async function listHosted(): Promise<void> {
const el = $('hosted');
try {
const res = await fetch('./replays/manifest.json', { cache: 'no-cache' });
if (!res.ok) throw new Error(String(res.status));
const entries = (await res.json()) as Entry[];
if (entries.length === 0) {
el.innerHTML = '<p class="dim">No replays have been published to this site yet.</p>';
return;
}
el.innerHTML = entries
.map(
(e, i) =>
`<button class="entry" data-i="${i}"><b>${esc(e.title)}</b>` +
(e.note ? `<span class="dim"> — ${esc(e.note)}</span>` : '') +
`<div class="dim sm">${esc(e.file)}</div></button>`,
)
.join('');
for (const b of Array.from(el.querySelectorAll('button.entry'))) {
(b as HTMLElement).onclick = async () => {
const entry = entries[Number((b as HTMLElement).dataset['i'])];
if (!entry) return;
const r = await fetch(`./replays/${entry.file}`, { cache: 'no-cache' });
const save = parseSave(await r.text(), entry.file);
if (save) openSave(save, entry.title);
};
}
} catch {
el.innerHTML =
'<p class="dim">No replay index on this site yet. You can still open a save file from your computer below.</p>';
}
}
function wire(): void {
const file = $('file') as HTMLInputElement;
file.onchange = () => {
const f = file.files?.[0];
if (!f) return;
const reader = new FileReader();
reader.onload = () => {
const save = parseSave(String(reader.result), f.name);
if (save) openSave(save, f.name.replace(/\.json$/i, ''));
};
reader.readAsText(f);
};
$('vback').onclick = () => show(at - 1);
$('vfwd').onclick = () => show(at + 1);
$('vfirst').onclick = () => show(0);
$('vlast').onclick = () => show(steps.length - 1);
($('vscrub') as HTMLInputElement).oninput = (e) => show(Number((e.target as HTMLInputElement).value));
$('vstage').onclick = () => {
const now = steps[at]?.frame;
for (let k = at + 1; k < steps.length; k++) {
const f = steps[k]!.frame;
if (f.stage !== now?.stage || f.day !== now?.day) return show(k);
}
show(steps.length - 1);
};
$('vplay').onclick = () => {
if (timer) {
clearInterval(timer);
timer = null;
$('vplay').textContent = '▶ play';
return;
}
$('vplay').textContent = '⏸ pause';
timer = setInterval(() => {
if (at >= steps.length - 1) {
clearInterval(timer!);
timer = null;
$('vplay').textContent = '▶ play';
return;
}
show(at + 1);
}, Number(($('vspeed') as HTMLSelectElement).value));
};
$('vclose').onclick = () => {
if (timer) clearInterval(timer);
timer = null;
$('viewer').style.display = 'none';
$('picker').style.display = 'block';
};
document.onkeydown = (e) => {
if ($('viewer').style.display === 'none') return;
if (e.key === 'ArrowRight') show(at + 1);
else if (e.key === 'ArrowLeft') show(at - 1);
else if (e.key === ' ') {
e.preventDefault();
$('vplay').click();
}
};
}
const style = document.createElement('style');
style.textContent = BOARD_CSS + TOOLTIP_CSS;
document.head.appendChild(style);
installTooltips();
wire();
void listHosted();
+9
View File
@@ -0,0 +1,9 @@
/** The splash page. Draws ties on the rule so the line reads as track rather than a border. */
const ties = document.getElementById('ties');
if (ties) {
let out = '';
for (let x = 4; x < 700; x += 9) {
out += `<line class="tie" x1="${x}" y1="6" x2="${x}" y2="19"/>`;
}
ties.innerHTML = out;
}
+90
View File
@@ -0,0 +1,90 @@
/**
* One floating tooltip, shared by every page.
*
* Reference detail — what a card does, why an action is offered, what a limit means — is needed
* occasionally and read once. Printing all of it costs the space that the board, the hand and the
* action list actually need. So anything carrying `data-tip` shows its text on hover or focus and
* nothing the rest of the time.
*
* Not the browser's native `title`: that waits about a second, cannot be styled, wraps badly at
* this length, and never appears for keyboard users.
*/
let tip: HTMLElement | null = null;
function ensure(): HTMLElement {
if (tip) return tip;
const el = document.createElement('div');
el.id = 'tip';
el.setAttribute('role', 'tooltip');
document.body.appendChild(el);
tip = el;
return el;
}
function place(el: HTMLElement, text: string): void {
const t = ensure();
t.textContent = text;
t.style.display = 'block';
// Measure, then keep it on screen: flip above when it would fall off the bottom, and pull back
// from the right edge rather than letting the text run off it.
const r = el.getBoundingClientRect();
const box = t.getBoundingClientRect();
const margin = 8;
let left = r.left + r.width / 2 - box.width / 2;
left = Math.max(margin, Math.min(left, window.innerWidth - box.width - margin));
let top = r.bottom + 6;
if (top + box.height > window.innerHeight - margin) top = r.top - box.height - 6;
t.style.left = `${Math.round(left + window.scrollX)}px`;
t.style.top = `${Math.round(top + window.scrollY)}px`;
}
function hide(): void {
if (tip) tip.style.display = 'none';
}
/**
* Start listening. Delegated from the document, so markup replaced later — the board redraws on
* every action — needs no re-binding.
*/
export function installTooltips(): void {
const find = (node: EventTarget | null): HTMLElement | null => {
let el = node as HTMLElement | null;
while (el && el !== document.body) {
// SVG elements have no closest() in older engines, so walk manually.
if (el.getAttribute && el.getAttribute('data-tip')) return el;
el = (el.parentNode as HTMLElement) ?? null;
}
return null;
};
document.addEventListener('mouseover', (e) => {
const el = find(e.target);
if (el) place(el, el.getAttribute('data-tip') ?? '');
else hide();
});
document.addEventListener('mouseout', (e) => {
if (!find((e as MouseEvent).relatedTarget)) hide();
});
document.addEventListener('focusin', (e) => {
const el = find(e.target);
if (el) place(el, el.getAttribute('data-tip') ?? '');
});
document.addEventListener('focusout', hide);
document.addEventListener('scroll', hide, true);
// Escape closes it, which matters when one is pinned open by focus.
document.addEventListener('keydown', (e) => {
if ((e as KeyboardEvent).key === 'Escape') hide();
});
}
/** Styling, kept here so every page that installs tooltips gets the same one. */
export const TOOLTIP_CSS = `
#tip{position:absolute;z-index:9999;display:none;max-width:340px;
background:#0b0e12;color:#e6e9ee;border:1px solid #4d6fa8;border-radius:6px;
padding:7px 10px;font:12px/1.45 ui-monospace,Menlo,Consolas,monospace;
box-shadow:0 6px 22px rgba(0,0,0,.55);pointer-events:none;white-space:pre-wrap}
[data-tip]{cursor:help}
button[data-tip],a[data-tip]{cursor:pointer}
`;