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
+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}
`;