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:
@@ -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 } : {}),
|
||||
},
|
||||
|
||||
@@ -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
@@ -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 }));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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
@@ -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
@@ -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
@@ -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
@@ -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–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 — 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 →</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 →</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 — 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
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
`;
|
||||
Reference in New Issue
Block a user