user card Hand becomes the action surface, the board shows the train, and a reverse stops turning it around, other bug fixes too.

This commit is contained in:
Jesse
2026-08-05 19:18:26 -04:00
parent 1c321d4cea
commit 543584a2ca
20 changed files with 1851 additions and 320 deletions
+192
View File
@@ -10,6 +10,198 @@ The target is 20 Revenue over 5 Days.
## Unreleased
### A playtest report, worked through
**Backing up turned the train around.** `facing` is which way the ENGINE points, and it was reset to
the direction of travel on every move — so one reverse move silently spun the train about, everything
read "forward" again, and a run-around became pointless: you could change ends for free by backing up
twice. Running forward the engine leads and points the way it went; backing up it trails, still
pointing the way it came, which is the port it arrived through. Both hold around a curve.
**"drop 1 car(s)" hid an option entirely.** It never said which car, and it read identically for a
nose drop and a tail drop — so the action list's duplicate-label filter discarded one outright, and
setting out from the front of the train could not be chosen at all. The same failure as the turnout
rotation earlier, in a feature added two commits ago to fix the make-up deadlock. Now: *"set out the
caboose off the back"*.
**ABS Signals offered the Office Area.** The card says "any Mainline card", and
`checkEnhancementPlacement` reads `placement.col` as a Division NODE index for it — while the
candidate list handed it occupied grid cells. So "(0, 1)" was accepted because node 1 happened to be
a Mainline card: the label and the meaning were different things. It now offers the Mainline cards by
name, *"on the Uncontrolled Siding, out on the Mainline"*.
**Two T10 chips on one Office.** A train standing at the Office is on the Office grid card AND on an
A/D track, so it arrived in both lists and was drawn twice.
**The train was invisible on the board**, which is what made the switching game unplayable: every
decision is about car ORDER — which car comes off next, which end a cut couples onto — and the card
showed a name badge. The train is now drawn as it sits in the tray: engine in its place with an arrow
for which way it points, cars in order, loaded solid and empty hollow. That arrow is also what makes
"(reverse)" mean something.
**Modifier effects were applied and invisible.** The Ice House and Local Small Groceries both worked
— `capacity.outbound` and `laborers` went up — but the card draws `Math.max(1, greenCap)` green
boxes, so 0 → 1 looked identical, and laborers were never drawn at all. The Facilities panel now
shows laborers, outbound and inbound as numbers with what a Modifier added (`2 +1`), names the
Modifier cards beside the industry, and says in the tooltip what the card itself prints. Laborers had
been in a hover tooltip only, which is the wrong place for the number that decides every Cargo phase.
**Coupling was silent and left no trace.** A car simply vanished from the board with only a line of
history to say where it went. There are now two new sounds — a knuckle-coupler clank for coupling and
a quieter one for setting out — alongside the whistle, bell and conductor.
**Moves left were reported only in the history panel**, which is the one place a player is not
looking while switching. Now beside the buttons, and struck red at zero.
**A/D tracks were a tooltip.** "3 A/D tracks" with nothing on the card — the number that decides
whether the next arrival is an automatic collision. Drawn as pips, filled for taken; there is no room
on the card for more rails and the count is what matters.
**The phase changed under you.** Local Operations ends the moment the last Move is spent and the
automatic phases then run themselves, so the page could change between two clicks with no notice. A
banner now names the phase it moved to.
#### What was already right, from the same report
The car type and colour on the board (read as "cab in red" without being told), the Blocked panel
explaining a full industry track, the Facilities panel's accepted car types and spotted cars, and the
Division chip's `T10 (2)` with its consist on hover. All four were reported as useful, and none of
them changed.
### Softlock: a train being made up with no visible way to make it up
Reported at Stage 10 of seed 775569289 — Train 10 at the West Division Point, history saying "now
taking cars", and **nothing at all under Your Move**.
Moving train make-up onto the Division Yard chips took the "Making up …" group out of the action
list, and everything that was not a car went with it: the heading naming the train and what its card
calls for, and the "no more cars" button. The nine cars the train could take WERE clickable on the
yard chips the whole time — nothing on screen said so, and the panel a player looks at was empty.
The panel is back, and now says where to click: *"Click a car in the Division Yard below to add it —
9 kinds it may take are highlighted there."* It carries the pass button when passing is legal, which
§7 allows only when the Division Yard is bare — "must make every effort to find a suitable car".
**The engine was never at fault.** 2065 New Train decisions across 60 campaign games, and not one
offered zero options; the pass/place pair covers the phase. The panel still words the third case
honestly rather than implying a button that is not there.
**Two guards, because the obvious one would not have caught it.** A menu-level invariant — every
option the engine offers must be reachable through something the menu exposes — passes on this bug,
because `makeUp.pass` was in the menu and correct all along. What failed was the page never reading
it. So there is also a coarse check that `main.ts` references every field the menu offers: a field
nothing reads is either dead or a control that has gone missing. Verified by putting the regression
back and watching it fail.
### Four reports from playing seed 775569289
**A curve was described as a turnout.** Both read "east-west track with a 45° leg", which is a
turnout — a road straight across the card plus a leg off it. A curve has ONE road: in from the east
or west edge, along the centre line to the frog, out at 45° through the middle of a north or south
edge, and **nothing runs past it**. Which is precisely why it may not be laid in the Running Track,
so describing it as though it had a through track contradicted the rule that stops you.
**A curve in hand showed no preview.** The shapes were read off the card's legal PLACEMENTS, so a
card with nowhere legal to go had nothing to draw — and that is exactly when a player most wants to
see what the piece is. They now come from the card itself, which is where they belong: what a piece
looks like does not depend on whether there is currently a square for it.
**"Realignment on Mainline card 3"** named a raw node index. It said nothing about which stretch of
the Division it meant or what it would do, and it had no tooltip either — because the action list
attaches one only when a label happens to contain an em-dash, which this one did not. It now reads
> Realignment on the Uncontrolled Siding — the second Mainline card west to east; converts it to
> Double Track
and any action naming a card falls back to that card's own description when its label carries no
explanation of its own. That was the actual complaint: the same card explained itself perfectly in
hand and said nothing in the action list.
**It offered only one Mainline card, and that was correct.** `REALIGNMENTS` converts Plains, Curves,
Uncontrolled Siding and Trestle; the Division on that seed is a Heavy Grade and an Uncontrolled
Siding, so only the second could be converted. The engine was right and the label was hiding it —
"Mainline card 3" gave no way to tell a considered restriction from a bug. Naming the card fixes the
report without changing the rule.
### The hand is the action surface, and the yard makes up the train
The action list reached **22 buttons**, and most of it was a cross-product. A card appeared in two
panels under two different models: as a *subject* under "Play a card from my hand", which then
highlighted squares on the board, and as one flat button per Department under "Discard a card from
my hand". Four cards times three Departments was **twelve buttons repeating the same three choices
four times**, about 290px of the list. Separately, making up a train offered up to ten buttons
reading "add loaded hopper", "add empty boxcar" — while the Division Yard sat on screen already
showing exactly those cars by type and load state.
Both are now on the objects already being looked at, using the pattern board placement always had:
**pick the thing, then pick where it goes.**
- **Every card in hand carries its own verbs.** `play` highlights the squares it may go on, exactly
as before; a card needing no square (an Office upgrade, a train, a maneuver) goes down in one
click. `discard` lights up the three Department piles as targets — they already show their top
card and their depth, which is precisely what you choose between.
- **A make-up car is picked off the Division Yard chip** that shows it. The loaded and empty counts
are separate targets, because a car of a type and a load state is exactly what the choice is.
- The action list keeps what is not about a card or a car: the Local Operations choice, drawing,
switching moves, the Freight Agent, and finishing.
**Measured over a full game of seed 430: the widest action list went from 22 buttons to 5.** A test
now walks the same game and fails if it climbs back above 8.
The rotation step stays where it was and is now the only thing the placement panel shows — the card
is picked in the hand and the square on the board, so a rotation is the one question neither of those
can ask. Its hover previews, added earlier, are unchanged.
### A note on the two replay viewers
`TODO.md` now carries an item to decide between them. The standalone `node src/sim/replay.ts` writes
a self-contained HTML file that nothing links to and that `.gitignore` excludes; the site reads JSON
saves from `public/replays/`. The standalone one carries the bot's decision trace and a timetable
panel, which is debugging material rather than something a player wants. No action taken.
### A replay now looks like the game it is a replay of
**"Extra slow" was there and did nothing.** The site's replay viewer builds its interval with
whatever the speed select held when play started, and nothing re-read it — so changing pace
mid-replay had no effect at all and the pace looked stuck. The standalone replay had always
restarted its timer on change; this viewer was missed. Both offer the same five paces, extra slow
through very fast, and a test now asserts they stay in step.
**The turn chart lived on one screen out of three.** Where you are in the Day — the five phases with
the violet "you are here" — was in `main.ts` alone, so both replays reported the Day and the phase as
two plain strings. The same position looked like a different game depending on which screen you were
on. It is now `sim/turnchart.ts`, shared exactly as the board renderers are: the playable page and
the site viewer import it, and the standalone replay embeds it by `Function.toString()` because it
is a single file with an inline script and cannot import anything.
**And the side panels were three against eight.** The viewer showed the Division, the Office Area and
the log; the play page shows those plus cards in hand, the Department decks, the yards, the blockers
and the facilities. A replay could not answer *"why is nothing moving?"* — which is most of what a
replay is for. `web/panels.ts` now renders all of them for both pages, and the duplicated CSS is gone
from `play.html`.
Three tests hold it there: both screens must call the same panel renderers, all three must use the
shared turn chart and none may keep a private copy of the phase table, and the two viewers must offer
the same paces.
### You could not rotate a turnout at all, and now you can see what you are laying
**The rotation was being thrown away before it reached the menu.** `actionGroups` drops duplicate
labels, and `describeIntent` for a card play said only `play right-hand turnout at (0, 1)` — no
rotation in it. So a turnout's two orientations produced the same label and the second was silently
discarded. The "choose a rotation" step existed and worked; it was never given more than one
rotation to choose between. The label now names the orientation, and both survive.
**And the buttons are pictures now.** Hovering a rotation draws the piece as it will land on the
board, and hovering the card itself draws every shape it could be laid as — which is how a player
sees a turnout has two orientations before picking a square at all. Rendered by `officeSvg`, the
board's own renderer, on a one-card board: the preview and the board cannot disagree about what the
piece looks like, and the rails come from the engine's `connectionsFor`, so a preview cannot promise
a shape the placement will not produce.
`Placeable.spots` carries the links for this. `data-tip-html` on the tooltip renders a figure above
the caption; it is only ever set from markup this app builds.
### Turnouts say what they do, and a curve may not break the Running Track
**"Right-hand turnout, stem east, through west, diverges north at 45°"** is three pieces of jargon
+8
View File
@@ -16,6 +16,14 @@ Ordered within each section by how much it is currently costing us.
holds 243 of which 104 are track, so every draw is diluted by 43% — precisely the pressure
those multipliers exist to relieve. Until then, read no balance conclusion from the revenue
numbers; they are a functionality signal only.
- [ ] **Review the standalone replay against the site's replay viewer.** `node src/sim/replay.ts
--seed 1234 --out replay.html` writes a self-contained HTML file; the site instead reads JSON
saves from `public/replays/`. Nothing links to the standalone one and its output is gitignored,
so it is a developer tool that happens to look like a product feature. It carries two panels
the site viewer does not — the bot's decision trace ("what it chose, why, and what it passed
over") and the timetable — which is debugging material rather than something a player wants.
Decide: fold the decision trace into the JSON viewer and delete the standalone, or keep it and
accept that it is a tool. No action for now.
- [ ] **The bot was partly living off an illegal placement.** Barring curves from the Running Track
(they have no east-west road and dead-end the main) cost it districts 28.0 → 19.7 cards and
revenue ~2.0 → 0.8. It has no plan for where a curve should go once the easy square is gone.
+14 -1
View File
@@ -719,7 +719,20 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
from,
to: i.to,
movesRemaining: s.turn.movesRemaining - 1,
facing: opposite(dest.entry),
/**
* A TRAIN THAT BACKS UP HAS NOT TURNED AROUND.
*
* `facing` is which way the ENGINE points, and this set it to the direction of travel on
* every move — so one reverse move silently spun the train about. Everything then read
* "forward" again, and a run-around became pointless: you could change ends for free by
* backing up twice.
*
* Running forward the engine leads, so it points the way the train went: `opposite(entry)`.
* Backing up it trails, still pointing the way it came, which is the port it arrived
* through. Both hold around a curve, where the compass heading changes but the engine's
* relationship to its train does not.
*/
facing: i.reverse ? dest.entry : opposite(dest.entry),
},
];
if (dest.couples.length > 0) {
+14 -2
View File
@@ -13,6 +13,7 @@
*/
import type { CarType, Hand, TrackGeometry } from './content.ts';
import { enhancementRule } from './content.ts';
import { check, areaOf, destinationsFor } from './apply.ts';
import type { Intent } from './intents.ts';
import type { GameState, GridCoord, PlayerIndex } from './state.ts';
@@ -140,8 +141,19 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
for (const cardId of s.decks.hands.get(player) ?? []) {
out.push({ type: 'card.play', cardId });
const kind = s.cards.get(cardId)?.kind;
// Enhancements ATTACH to a card already down, so their candidates are the occupied cells.
const targets = kind?.kind === 'enhancement' ? attachments : placements;
/**
* Enhancements ATTACH to a card already down, so their candidates are the occupied cells — with
* one exception. ABS Signals goes on a MAINLINE card, and `checkEnhancementPlacement` reads
* `placement.col` as a Division node index for it. Offering it grid squares meant the engine
* accepted "(0, 1)" because node 1 happened to be a Mainline card: the label said one thing and
* the placement meant another, so the card appeared to be playable all over the Office Area.
*/
const onMainline = kind?.kind === 'enhancement' && enhancementRule(kind.key)?.placement === 'mainlineCard';
const targets = onMainline
? s.division.nodes.map((_, node) => ({ row: -1, col: node }))
: kind?.kind === 'enhancement'
? attachments
: placements;
// Orientation is chosen on placement, and a printed card turns but never flips, so the widest
// variant set is TWO. Anything that is not track has a single orientation and needs one entry.
const rotations = kind?.kind === 'track' ? variantsFor(kind.geometry, kind.hand).length : 1;
+63 -5
View File
@@ -96,8 +96,14 @@ export function divisionSvg(nodes: DivisionView[]): string {
kind: 'run',
label: rc.label,
sub: isOffice ? (cap === null ? '' : `A/D ${ad.length}/${cap}`) : '',
// A train standing at the Office occupies an A/D track, which is where it is.
trains: isOffice ? [...rc.trains, ...ad] : rc.trains,
/**
* A train standing at the Office occupies an A/D track, which is where it is — but it is
* ALSO standing on the Office grid card, so it arrives here in both lists and used to be
* drawn twice. Reported as two T10 chips on one Office.
*/
trains: isOffice
? [...rc.trains, ...ad.filter((t) => !rc.trains.some((r) => r.label === t.label))]
: rc.trains,
cap: isOffice ? cap : null,
tip: `${rc.label} — ${rc.kind === 'limits' ? 'the end of this district; the Running Track runs between the Limits' : 'Running Track'}`,
owner: n.owner ?? null,
@@ -420,6 +426,20 @@ export function officeSvg(
}
out += `<text class="bs-cn" x="6" y="12">${esc(cell.label)}</text>`;
/**
* A/D TRACKS, as pips.
*
* The Office tooltip said "3 A/D tracks" and the card showed nothing — so the number that
* decides whether the next arrival is an automatic collision (§8.3) was invisible on the card it
* belongs to. There is no room to draw more rails, and the count is what matters: one pip per
* track, filled for taken.
*/
if (cell.ad) {
for (let i = 0; i < cell.ad.of; i++) {
out += `<circle class="bs-ad${i < cell.ad.used ? ' bs-occ' : ''}" cx="${W - 9 - i * 9}" cy="10" r="3"/>`;
}
}
out += `<text class="bs-coord" x="${W - 5}" y="12" text-anchor="end">${cell.row},${cell.col}</text>`;
/**
@@ -503,9 +523,35 @@ export function officeSvg(
// card, the old y = H-46 baseline printed the label straight along the rail itself.
out += `<text class="bs-enh" x="6" y="26">${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>`;
if (cell.train) {
/**
* THE TRAIN, DRAWN AS IT SITS IN THE TRAY.
*
* The badge used to be a name and nothing more, which makes the switching game unplayable:
* every decision is about car ORDER — which car comes off next, which end a cut couples onto —
* and none of it was on the board. Now the engine and its cars are drawn in sequence, nose
* first, with an arrow for which way the engine points, so "set out the caboose off the back"
* is a thing you can see before you choose it.
*/
const t = cell.train;
const items: { label: string; kind: string }[] = t.cars.map((c) => ({
label: /caboose/.test(c) ? 'cab' : /hopper/.test(c) ? 'hop' : /boxcar/.test(c) ? 'box'
: /reefer/.test(c) ? 'rfr' : /tank/.test(c) ? 'tnk' : /coach/.test(c) ? 'cch' : '?',
kind: /^loaded/.test(c) ? 'ld' : 'mt',
}));
const arrow = t.facing === 'w' ? '\u25c0' : t.facing === 'e' ? '\u25b6' : t.facing === 'n' ? '\u25b2' : '\u25bc';
items.splice(t.engineAt, 0, { label: arrow, kind: 'eng' });
const cw = 17;
const tw = Math.min(W - 8, items.length * cw + 30);
const tx = W / 2 - tw / 2;
out += `<g class="bs-crew"><rect x="${tx}" y="${RAIL - 11}" width="${tw}" height="22" rx="3"/>`;
out += `<text class="bs-tlab" x="${tx + 4}" y="${RAIL + 4}">${esc(t.label)}</text>`;
items.forEach((it, n) => {
const bx = tx + 28 + n * cw;
out += `<rect class="bs-tcar bs-t-${it.kind}" x="${bx}" y="${RAIL - 8}" width="${cw - 2}" height="16" rx="2"/>` +
`<text class="bs-tcarlab" x="${bx + (cw - 2) / 2}" y="${RAIL + 4}" text-anchor="middle">${esc(it.label)}</text>`;
});
out += '</g>';
}
out += '</g>';
}
@@ -579,6 +625,18 @@ export const BOARD_CSS = `
.bs-slot.bs-car-cab{fill:rgba(192,90,90,.42)}
.bs-train rect{fill:#2f6b3d;stroke:#8fd6a0;stroke-width:1.2}
.bs-crew rect{fill:#8a6d1f;stroke:#e0c060;stroke-width:1.2}
/* Each car in the train, in the order it is seated. Loaded is solid, empty is hollow, and the
engine is the one that carries the arrow — which is what makes "reverse" mean something. */
.bs-tcar{stroke:#3a2f14;stroke-width:.8}
.bs-tcar.bs-t-ld{fill:#e8dcc0}
.bs-tcar.bs-t-mt{fill:#6b5a2e}
.bs-tcar.bs-t-eng{fill:#2b2313;stroke:#e0c060;stroke-width:1.4}
.bs-tcarlab{font:8px ui-monospace,monospace;fill:#2b2313;font-weight:700}
/* One pip per A/D track, filled when a train is holding on it. A full Office is why the next
arrival collides, so this is the count worth seeing without hovering. */
.bs-ad{fill:none;stroke:#4d6fa8;stroke-width:1.2}
.bs-ad.bs-occ{fill:#5aa9e6;stroke:#8fc4ee}
.bs-t-mt + .bs-tcarlab,.bs-t-eng + .bs-tcarlab{fill:#f0e6cf}
.bs-tlab{fill:#eaf6ec;font:600 11px ui-monospace,monospace}
.bs-carlab{fill:#cfd6e0;font:9px ui-monospace,monospace}
.bs-wb{fill:#20262e;stroke:#4a5361;stroke-width:1}
+4
View File
@@ -547,6 +547,10 @@ export function cuesFor(events: readonly GameEvent[]): string[] {
const out: string[] = [];
for (const e of events) {
if (e.type === 'trainMadeUp') out.push('train');
// Coupling and setting out are what a switching move IS, and both were silent. A car leaving the
// board with only a line of history to say where it went is the thing that most needs a noise.
if (e.type === 'carsCoupled') out.push('couple');
if (e.type === 'carsDropped' || e.type === 'flyingSwitch') out.push('drop');
if (e.type === 'stageBegan') {
// A Stage BEGINNING is the previous one ending — except the first, which is the game opening
// and has nothing behind it. A Day boundary rings the bell only: sounding both would collide,
+15 -8
View File
@@ -37,6 +37,7 @@ import type { Frame } from './view.ts';
import type { Decision } from './view.ts';
import { cardName, describeDecision, snapshot, trainName } from './view.ts';
import { BOARD_CSS, divisionSvg, officeSvg } from './board-svg.ts';
import { TURNCHART_CSS, turnChartHtml } from './turnchart.ts';
import { playCue } from '../web/sound.ts';
// ---------------------------------------------------------------------------
@@ -201,7 +202,9 @@ export function compress(frames: Frame[]): Packed {
// Identity, not equality: `snapshot` pushes the very same FacilityView object into both the
// cell and the frame's `facilities`, so this always resolves.
const fi = c.facility ? f.facilities.indexOf(c.facility) : -1;
return [ci, wi, c.enhancements, c.tray, c.cars, fi];
// `train` rides whole rather than being interned: it changes almost every frame, so a table
// of them would be as long as the frames are and buy nothing.
return [ci, wi, c.enhancements, c.tray, c.cars, fi, c.train, c.ad];
});
return { ...f, cells } as unknown as Frame;
});
@@ -235,12 +238,14 @@ export function rehydrateCells(
facs: unknown[],
): unknown[] {
return packed.map((row) => {
const p = row as [number, number, string[], string | null, string[], number];
const p = row as [number, number, string[], string | null, string[], number, unknown, unknown];
const c = cards[p[0]] as [number, number, string, string, boolean, string[]];
return {
row: c[0], col: c[1], kind: c[2], label: c[3], running: c[4], links: c[5],
what: whats[p[1]], enhancements: p[2], tray: p[3], cars: p[4],
facility: p[5] < 0 ? null : facs[p[5]],
train: p[6] ?? null,
ad: p[7] ?? null,
};
});
}
@@ -253,6 +258,7 @@ export function renderHtml(rec: Recording): string {
<title>Station Master — replay seed ${rec.seed}</title>
<style>
${BOARD_CSS}
${TURNCHART_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}
@@ -344,15 +350,17 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
<header>
<h1>Station Master — replay · seed ${rec.seed} · ${rec.length} (target ${target.target} over ${target.days} Days) · ${esc(rec.outcome)}</h1>
<div class="bar">
<span class="big" id="when">—</span>
<span>phase <b id="phase">—</b></span>
<span class="dim">actor <span id="actor">—</span> · fedora <span id="super">—</span></span>
<span class="dim">fedora <span id="super">—</span></span>
<span>revenue <b class="big" id="rev">0</b></span>
<span class="dim">deck <span id="deck">0</span></span>
<span class="dim">frame <span id="fno">0</span>/<span id="ftot">0</span></span>
</div>
</header>
<!-- The live game's turn chart, from the same renderer, so where you are in the Day looks the same
whether you are playing or watching. -->
<div id="turnchart"></div>
<div class="wrap">
<div>
<section><h2>Division — west to east</h2><div class="div-strip" id="division"></div></section>
@@ -414,6 +422,7 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
from the same state, which is the one failure a track diagram exists to prevent. */
const divisionSvg = ${divisionSvg.toString()};
const officeSvg = ${officeSvg.toString()};
const turnChartHtml = ${turnChartHtml.toString()};
const playCue = ${playCue.toString()};
const PACKED = ${JSON.stringify(compress(rec.frames))};
@@ -458,9 +467,7 @@ function boxes(items, cap, cls) {
function render() {
const f = FRAMES[i];
$('when').textContent = 'Day ' + f.day + ' · Stage ' + f.stage + ' · ' + f.clock;
$('phase').textContent = f.phase;
$('actor').textContent = f.actor === null ? 'automatic' : 'P' + f.actor;
$('turnchart').innerHTML = turnChartHtml(f, f.actor === null ? null : 'Player ' + (f.actor + 1));
$('super').textContent = 'P' + f.superintendent;
$('rev').textContent = f.revenue;
$('rev').className = 'big ' + (f.revenue < 0 ? 't-bad' : f.revenue > 0 ? 't-good' : '');
+126
View File
@@ -0,0 +1,126 @@
/**
* The turn chart — where in the Day we are, and which of the five phases is running.
*
* SELF-CONTAINED ON PURPOSE, exactly like the two board renderers in `board-svg.ts`. The playable
* app and the site's replay viewer import it normally; the standalone replay is a single HTML file
* with an inline script and cannot import anything, so it embeds this via `Function.toString()`.
* That keeps ONE implementation across all three screens.
*
* It lived only in `main.ts` before, so a replay showed the Day and the phase as plain text while
* the live game showed the chart — and the violet "you are here" highlight, which is the single
* most-glanced-at thing on the page, appeared on one screen out of three.
*/
/** What a chart needs from a frame. A `Frame` satisfies this; so does a replay's rehydrated one. */
export type TurnChartFrame = {
day: number;
stage: number;
clock: string;
phase: string;
phaseKey: string;
actor: number | null;
};
/**
* The five phases of a Stage, in order, each with what actually happens in it.
*
* The first three are the rules text; Cargo and Supervisor Shift are written from what the engine
* does, since the recovered sheet does not spell them out.
*/
export function turnChartHtml(f: TurnChartFrame, actorName: string | null): string {
const PHASES: { key: string; label: string; tip: string; icon: string }[] = [
{
key: 'localOps',
label: 'Local Operations',
tip: 'Switch with a train, Freight Agent operations, or draw a card. One of the three, once per Stage — this is the whole of your turn.',
// a turnout: the choice of three roads
icon: '<path class="ic" d="M1 11h6l6-6h4M7 11h10"/>',
},
{
key: 'newTrain',
label: 'New Train',
tip: 'Timetabled trains for this Stage are built. New timetabled trains are randomly placed on the timetable. Held trains are built. Extra trains are built.',
// a locomotive being made up
icon: '<rect class="ic" x="2" y="6" width="9" height="7" rx="1"/><path class="ic" d="M11 9h4v4h-4"/><circle class="icf" cx="5" cy="15" r="1.5"/><circle class="icf" cx="13" cy="15" r="1.5"/>',
},
{
key: 'mainline',
label: 'Mainline',
tip: 'All trains holding at an Office or Division Point must attempt to move. This phase is automatic — you make no decisions in it, except a clearance ruling if one is called for.',
// open road
icon: '<path class="ic" d="M2 7h16M2 13h16"/><path class="ic" d="M6 7v6M12 7v6"/>',
},
{
key: 'loadUnload',
label: 'Cargo',
tip: 'Your workers act, once each per Stage. A Laborer moves a load one square across MEN | AT | WORK, or starts one, or begins an unload. A Porter boards or detrains passengers, which earns Revenue in a single action.',
// a loaded box on a flat
icon: '<rect class="ic" x="3" y="7" width="8" height="6"/><path class="ic" d="M3 10h8M7 7v6"/><path class="ic" d="M13 13h4"/>',
},
{
key: 'shiftChange',
label: 'Supervisor Shift',
tip: "End of the Stage. Every Laborer and Porter is refreshed for the next one, and every third Stage — 3, 6, 9 and 12 — the Superintendent's hat passes to the next player.",
// a pocket watch
icon: '<circle class="ic" cx="10" cy="11" r="6"/><path class="ic" d="M10 8v3l2 2M8 2h4"/>',
},
];
const esc = (t: string): string =>
String(t).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c);
const at = PHASES.findIndex((p) => p.key === f.phaseKey);
const chips = PHASES.map((p, i) => {
const state = p.key === f.phaseKey ? 'now' : i < at ? 'done' : '';
return (
`<li class="tc-phase ${state}" data-tip="${esc(p.label)} — ${esc(p.tip)}" tabindex="0">` +
`<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true">${p.icon}</svg>` +
`<span>${esc(p.label)}</span></li>`
);
}).join('');
// An automatic phase is waiting on nobody, and saying so is more use than a blank.
const who = actorName ?? 'nobody — the Division is running itself';
return (
`<div class="tc-when"><b>Day ${f.day}</b><span>Stage ${f.stage} of 12</span>` +
`<span class="dim">${esc(f.clock)}</span></div>` +
`<div class="tc-now">phase <b>${esc(f.phase)}</b></div>` +
`<div class="tc-who">waiting on <b>${esc(who)}</b></div>` +
`<ol class="tc-phases">${chips}</ol>`
);
}
/**
* Styling, shared so the three screens cannot drift.
*
* VIOLET, not the amber used for actions. The palette already spends amber on "you can click this",
* blue on "this is a legal square or a link", green on good and red on bad. The turn chart is none
* of those — it reports WHERE YOU ARE, and a player should never read it as something to press.
* Violet is the one hue not already carrying a meaning, so it cannot be confused with any of them.
*/
export const TURNCHART_CSS = `
#turnchart{display:flex;align-items:center;gap:16px;flex-wrap:wrap;
background:#161b22;border-bottom:1px solid #2c333d;padding:7px 14px}
.tc-when{display:flex;align-items:baseline;gap:10px}
.tc-when b{font-size:15px;color:#cfe0f5}
.tc-now{font-size:12px;color:#8b94a3}
.tc-now b{color:#b98cf0;font-size:13px}
/* WHO EVERYONE IS WAITING ON. Solitaire has one player, but the seat still has a name — the log
says "Player Solitaire", so the chart should agree. In multiplayer this is the thing a table
glances at most often, so it gets its own chip rather than hiding in the phase text. */
.tc-who{display:flex;align-items:center;gap:6px;font-size:12px;color:#8b94a3}
.tc-who b{color:#b98cf0;background:rgba(150,110,230,.16);border:1px solid #8b6ad0;
border-radius:11px;padding:1px 9px;font-size:12px}
ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:wrap}
.tc-phase{display:flex;align-items:center;gap:6px;border:1px solid #2c333d;border-radius:14px;
padding:3px 10px 3px 7px;font-size:11px;color:#8b94a3;background:#1a1f26;cursor:help}
.tc-phase svg{display:block;flex:0 0 auto}
.tc-phase .ic{stroke:#5f6b7a;fill:none;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}
.tc-phase .icf{fill:#5f6b7a;stroke:none}
.tc-phase.done{opacity:.55}
.tc-phase.now{border-color:#b98cf0;background:rgba(150,110,230,.15);color:#e7dcfb;font-weight:700;
box-shadow:0 0 0 2px rgba(150,110,230,.18)}
.tc-phase.now .ic{stroke:#b98cf0}
.tc-phase.now .icf{fill:#b98cf0}
`;
+170 -16
View File
@@ -17,10 +17,12 @@ import {
MAINLINE_MODIFIER_CARDS,
MAINLINE_PROFILES,
MANEUVER_CARDS,
REALIGNMENTS,
REGIONS_PER_MAINLINE_CARD,
OFFICE_ORDER,
SPACE_USE_CARDS,
industryProfile,
mainlineProfile,
modifierProfile,
lengthProfile,
officeProfile,
@@ -32,7 +34,7 @@ import type { Hand, TrackGeometry } from '../engine/content.ts';
import type { Port } from '../engine/track.ts';
import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts';
import type { Impediment } from './narrate.ts';
import { carLabel, clockTime, impediments, phaseLabel } from './narrate.ts';
import { carLabel, carsLabel, clockTime, impediments, phaseLabel } from './narrate.ts';
export type CellView = {
row: number;
@@ -47,6 +49,25 @@ export type CellView = {
*/
enhancements: string[];
tray: string | null;
/**
* THE TRAIN STANDING HERE, in order, with the engine in it and which way it points.
*
* The switching game is entirely about car ORDER — which car is next to come off, which end a cut
* couples onto — and the board showed a crew badge with a name and nothing else. A player could
* not plan a move at all: "drop 1 car" tells you nothing when you cannot see what is on the back.
*
* `cars` runs nose first, matching the tray; `engineAt` is where the locomotive sits in it, and
* `facing` is the port it points at on this card.
*/
train: { label: string; cars: string[]; engineAt: number; facing: string } | null;
/**
* Office card only: A/D tracks taken and how many the tier has.
*
* The tooltip said "3 A/D tracks" and the card showed nothing, so the number that decides whether
* the next arrival collides was invisible on the card it belongs to. Drawn as pips rather than
* extra rails — there is no room on the card for more track, and the count is what matters.
*/
ad: { used: number; of: number } | null;
cars: string[];
facility: FacilityView | null;
/**
@@ -79,6 +100,17 @@ export type FacilityView = {
trackCap: number;
laborers: string;
porters: string;
/**
* What the INDUSTRY CARD itself prints, before any Modifier beside it.
*
* A modifier's whole effect is a number going up, and the panel showed only the number — so an Ice
* House raising outbound capacity from 0 to 1 and laborers from 1 to 2 looked like nothing had
* happened. Reported after playing an Ice House and Local Small Groceries and seeing no change
* anywhere. Keeping the base lets the panel say "2 (1 + 1 from a Modifier)".
*/
base: { out: number; in: number; laborers: number };
/** The Modifier cards standing beside it, by name. */
modifiers: string[];
/**
* Can a load actually come off WORK onto a spotted car (§9.3)? A load with nowhere to go parks on
* WORK and LOCKS the industry track, blocking the very car that would clear it — the deadlock
@@ -173,6 +205,14 @@ export type Frame = {
cells: CellView[];
/** Which grid row is the Running Track — the spine the district hangs beneath. */
runningRow: number;
/**
* Moves left in this Local Operations turn, or null outside a switching turn.
*
* It was reported only in the history, which is the one panel a player is NOT looking at while
* switching — the count that decides whether a run-around is still possible belongs next to the
* moves themselves.
*/
movesLeft: number | null;
facilities: FacilityView[];
hand: string[];
/** What each hand card does, in the same order — names alone are not a playable hand. */
@@ -256,7 +296,7 @@ const FACILITY_NAMES: Record<string, string> = {
};
function facilityView(
card: { geometry: { kind: string; facility?: string }; facility: unknown },
card: { geometry: { kind: string; facility?: string }; facility: unknown; modifiers?: string[] },
officeName: string,
): FacilityView | null {
const f = (card as { facility: import('../engine/state.ts').Facility | null }).facility;
@@ -282,9 +322,42 @@ function facilityView(
porters: `${portersLeft(f)}/${f.porters}`,
canFinish: canFinishHere(f),
jammed: f.menAtWork.some((l) => l !== null) && !canFinishHere(f),
base: baseOf(card),
modifiers: (card.modifiers ?? []).map((m) => MODIFIER_NAMES[m] ?? prettyKey(m)),
};
}
/**
* What the card itself prints, before any Modifier beside it.
*
* Read from the catalogue rather than remembered on the Facility, so it cannot drift from the card
* the player is holding. A passenger facility takes its numbers from the Office tier instead.
*/
function baseOf(card: { geometry: { kind: string; facility?: string } }): { out: number; in: number; laborers: number } {
const g = card.geometry;
if (g.kind === 'facility' && g.facility) {
const p = industryProfile(g.facility as never);
return { out: p.baseOut, in: p.baseIn, laborers: p.baseLoaders };
}
return { out: 0, in: 0, laborers: 0 };
}
/** The train standing on a given grid square, drawn as it is seated in the Crew Tray. */
function trainOnCard(s: GameState, key: string): CellView['train'] {
for (const [id, t] of s.trays) {
if (t.position.at !== 'grid') continue;
if (`${t.position.coord.row},${t.position.coord.col}` !== key) continue;
return {
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
cars: t.consist.map(carLabel),
engineAt: Math.max(0, Math.min(t.consist.length, t.engineAt)),
facing: t.facing ?? (t.direction === 'west' ? 'w' : 'e'),
};
void id;
}
return null;
}
/** Is a car spotted that a load on WORK could actually come off onto (§9.3)? */
function canFinishHere(f: Facility): boolean {
const pending = f.menAtWork.find((l) => l !== null) ?? f.outboundBox[0];
@@ -344,8 +417,22 @@ export function describeIntent(s: GameState, i: Intent): string {
: i.option === 'draw'
? 'DRAW — take a card and play one, and you may lay a piece of track'
: 'FREIGHT AGENT — one car moved to or from a facility, or clear a jam';
case 'card.play':
return `play ${cardName(s, i.cardId)}${i.placement ? ` at ${at(i.placement)}` : ''}`;
case 'card.play': {
/**
* NAME THE ROTATION, or the choice disappears.
*
* The action list drops duplicate labels, and this said only "play X at (0, 1)" — so a
* turnout's two orientations produced one identical label each and the second was silently
* discarded before the menu ever saw it. The rotation is the entire decision for a turnout or
* a curve, and it could not be made.
*/
const kind = s.cards.get(i.cardId)?.kind;
const turn =
i.placement && kind?.kind === 'track'
? variantLabel(kind.geometry, i.variant, kind.hand)
: '';
return `play ${cardName(s, i.cardId)}${i.placement ? ` at ${at(i.placement)}` : ''}${turn}`;
}
case 'card.discard': {
/**
* NAME THE DEPARTMENT, and what the card would land on.
@@ -362,8 +449,23 @@ export function describeIntent(s: GameState, i: Intent): string {
}
case 'switch.move':
return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}`;
case 'switch.dropCars':
return `drop ${i.count} car(s)`;
case 'switch.dropCars': {
/**
* NAME THE CARS AND THE END THEY COME OFF.
*
* This said "drop 1 car(s)", which is two failures at once. It never said WHICH car, so a
* player who knew the caboose was on the back still had to guess; and it read identically for
* a nose drop and a tail drop, so — the action list dropping duplicate labels — setting out
* from the front of the train was silently discarded and could not be chosen at all.
*/
const tray = s.trays.get(i.trayId);
if (!tray) return `drop ${i.count} car(s)`;
const cut = i.fromNose
? tray.consist.slice(0, i.count)
: tray.consist.slice(tray.consist.length - i.count);
const end = i.fromNose ? 'off the front' : 'off the back';
return `set out ${carsLabel(cut)} ${end}`;
}
case 'switch.sortConsist':
return `re-order consist [${i.order.join(',')}]`;
case 'freightAgent.stockOutbound':
@@ -386,8 +488,35 @@ export function describeIntent(s: GameState, i: Intent): string {
// carLabel knows a caboose carries the crew, not freight. Formatting it here by hand put
// "add loaded caboose" on a button.
return `add ${carLabel({ type: i.carType, loaded: i.loaded })}`;
case 'mainline.modify':
return `${cardName(s, i.cardId)} on Mainline card ${i.node}`;
case 'mainline.modify': {
/**
* NAME THE CARD, NOT ITS INDEX, AND SAY WHAT HAPPENS TO IT.
*
* This read "Realignment on Mainline card 3" — a raw node index, which tells a player nothing
* about which stretch of the Division it means, and no clue what the card would do to it.
* Worse, the action list attaches a tooltip only when a label carries an em-dash, so this one
* silently had none at all while the same card in hand did.
*/
const node = s.division.nodes[i.node];
const key = (s.cards.get(i.cardId)?.kind as { key?: string } | undefined)?.key;
const shortWhere = node?.kind === 'mainline' ? `the ${mainlineProfile(node.card).name}` : `Mainline card ${i.node}`;
const where =
node?.kind === 'mainline'
? `the ${ordinal(mainlineIndex(s, i.node))} Mainline card west to east`
: `node ${i.node}`;
let effect = '';
if (key === 'realignment' && node?.kind === 'mainline') {
const to = REALIGNMENTS.find((r) => r.from === node.card);
effect = to ? `converts it to ${mainlineProfile(to.to).name}` : 'nothing here to convert';
} else if (key === 'brakeman' || key === 'airbrakes') {
effect = 'one Stage off the descent for a train running downhill';
} else if (key === 'helpers') {
effect = 'one Stage off the climb for a train running uphill';
}
// Short head, full detail after the em-dash — `actionButton` puts the first on the button and
// the second in the tooltip, so the list stays narrow without losing the explanation.
return `${cardName(s, i.cardId)} on ${shortWhere} — ${where}${effect ? `; ${effect}` : ''}`;
}
case 'maneuver.redFlags':
return `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`;
case 'maneuver.flyingSwitch':
@@ -495,6 +624,11 @@ export function snapshot(
links: connectionsFor(card).map(([a, b]) => `${a}${b}`),
enhancements: card.enhancements.map(prettyKey),
tray: trayAt.get(key) ?? null,
train: trainOnCard(s, key),
ad:
card.geometry.kind === 'office'
? { used: area.adOccupancy.length, of: officeProfile(area.tier).adTracks }
: null,
cars: (card.facility?.industryTrack.length ? card.facility.industryTrack.cars : card.standing).map(carLabel),
facility: fv,
});
@@ -660,6 +794,7 @@ export function snapshot(
wasted,
objective: objectiveOf(s),
runningRow: area.runningRow,
movesLeft: s.clock.phase === 'localOps' && s.turn.option === 'switch' ? s.turn.movesRemaining : null,
blocked: impediments(s, 0),
trains: [...s.trays.values()].map((t) => ({
label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
@@ -772,15 +907,24 @@ export function cardDescription(s: GameState, id: string): string {
if (k.geometry === 'straight') {
return `east-west through track · lay it anywhere the rail continues${cost}`;
}
const arcs = variantsFor(k.geometry, k.hand)
.map((v) => v.arc ?? `${v.turnout?.stem}-${v.turnout?.diverge}`)
.join(' or ');
const diagonal = k.hand === 'right' ? 'north–west / south–east' : 'north–east / south–west';
return (
`east-west track with a 45° leg through the middle of the north or south edge · ` +
`lay it as ${arcs} · its leg is on the ${diagonal} diagonal and only meets a card on the ` +
`same one${stop}${cost}`
);
const ways = variantsFor(k.geometry, k.hand)
.map((v) => (v.arc ? curvePhrase(v.arc) : v.turnout ? turnoutPhrase(v.turnout) : ''))
.join(', or turned about, ');
/**
* A CURVE IS NOT A TURNOUT, and it used to be described as one.
*
* Both said "east-west track with a 45° leg", which is a turnout: a road straight across the
* card plus a leg off it. A curve has ONE road and no choice to make — it comes in from an
* east or west edge, runs along the centre line to the frog, and leaves at 45° through the
* middle of a north or south edge. Nothing runs past it, which is exactly why it may not be
* laid in the Running Track.
*/
const what =
k.geometry === 'turnout'
? 'east-west track with a 45° leg through the middle of the north or south edge'
: 'a single road: in from the east or west edge, then out at 45° through the middle of the north or south edge — no track runs past it';
return `${what} · lay it so it ${ways} · its 45° leg is on the ${diagonal} diagonal and only meets a card on the same one${stop}${cost}`;
}
default: {
// The recovered categories carry their own prose — effect plus where it may be played.
@@ -876,6 +1020,16 @@ function cellDescription(card: TrackCard, officeName: string, onRunning: boolean
}
}
/** Which Mainline card this is, counting only the Mainline cards from the west end. */
function mainlineIndex(s: GameState, node: number): number {
let n = 0;
for (let k = 0; k <= node; k++) if (s.division.nodes[k]?.kind === 'mainline') n++;
return n;
}
const ORDINALS = ['', 'first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth'];
const ordinal = (n: number): string => ORDINALS[n] ?? `${n}th`;
const compass = (p: string): string => ({ n: 'north', s: 'south', e: 'east', w: 'west' })[p] ?? p;
/**
+178 -6
View File
@@ -33,6 +33,7 @@ import { cuesFor, narrate } from '../sim/narrate.ts';
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
// which would pull node:fs into a browser bundle.
import {
cardDescription,
cardName,
describeIntent,
geometryLabel,
@@ -40,10 +41,10 @@ import {
trainName,
variantLabel,
} from '../sim/view.ts';
import { HAND_LIMIT, trainProfile } from '../engine/content.ts';
import { HAND_LIMIT, mainlineProfile, trainProfile } from '../engine/content.ts';
import type { Hand, TrackGeometry } from '../engine/content.ts';
import type { Port } from '../engine/track.ts';
import { joins, neighbour, variantsFor } from '../engine/track.ts';
import { connectionsFor, joins, neighbour, variantsFor } from '../engine/track.ts';
import { areaOf } from '../engine/apply.ts';
import type { Frame } from '../sim/view.ts';
@@ -208,15 +209,78 @@ export type Placeable = {
* Where it may go. `coord` drives board highlighting; several spots can share one square when the
* piece has more than one legal rotation there, which is why the label carries the rotation too.
*/
spots: { label: string; index: number; coord: { row: number; col: number } }[];
spots: {
label: string;
index: number;
coord: { row: number; col: number };
/**
* The rails this placement would put on the card, as `connectionsFor` codes.
*
* So the UI can DRAW the piece as it would land rather than only describing it. Words alone are
* not enough for a turnout: "allows traffic from the east to travel west or turn to the south"
* is exact and still leaves a player working out which way the leg points on the board. Taken
* from the engine's own connections, so a preview cannot promise a shape the placement will not
* produce.
*/
links: string[];
}[];
};
/**
* A CARD IN HAND, AND WHAT MAY BE DONE WITH IT.
*
* The hand is the action surface. A card used to appear in two panels under two different models:
* as a *subject* under "Play a card from my hand", which then highlighted squares on the board, and
* as one flat button per Department under "Discard a card from my hand". One card, two mental
* models, and the discard block was a cross-product — four cards x three Departments was twelve
* buttons and about 290px of the action list, repeating the same three choices four times.
*
* Both verbs now hang off the card itself, and both use the pattern the board placement already
* had: pick the thing, then pick where it goes. A discard's "where" is a Department pile, which is
* already on screen with its top card and depth — exactly what you need to choose between them.
*/
export type HandAction = {
cardId: string;
name: string;
what: string;
/** Playable with NO placement — an Office upgrade, a train card, a maneuver. One click does it. */
playNow: number | null;
/** Playable onto the board: the `subjectKey` of the matching `placeable` item. */
placeKey: string | null;
/** How many squares it may go on, for the count on the button. */
spots: number;
/** The option index for discarding onto each Department pile, or null where that is illegal. */
discard: (number | null)[];
/**
* Every distinct shape the card could be laid as, for the hover preview.
*
* Derived from the CARD, not from its legal placements: what a piece looks like does not depend
* on whether there is currently a square for it, and a player picking through a hand needs to see
* the shape most when there is nowhere obvious to put it. Reading it off the placements meant a
* curve with no legal square showed no preview at all.
*/
shapes: string[][];
};
/** A car that may be added to the train being made up, keyed to its chip in the Division Yard. */
export type MakeUpAction = { carType: string; loaded: boolean; index: number };
export type Menu = {
options: Intent[];
/** Actions with no further choice to make. */
direct: ActionGroup[];
/** Actions needing a location, grouped under their card or track piece. */
placeable: { title: string; items: Placeable[] }[];
/** One entry per card in hand, with the verbs available to it. */
hand: HandAction[];
/**
* Making up a train: the cars that may be added, and the option to add none.
*
* Ten buttons reading "add loaded hopper", "add empty boxcar" and so on, when the Division Yard
* is already on screen showing exactly those cars by type and load state. The yard is the surface;
* these key each chip to the option that adds it.
*/
makeUp: { title: string; cars: MakeUpAction[]; pass: number | null } | null;
};
/** The action list as the page shows it: direct actions, plus subject-then-location for the rest. */
@@ -241,7 +305,7 @@ export function actionMenu(game: Game): Menu {
spots: [],
};
if (!entry.spots.some((sp) => sp.label === key.spot)) {
entry.spots.push({ label: key.spot, index: a.index, coord: key.coord });
entry.spots.push({ label: key.spot, index: a.index, coord: key.coord, links: key.links });
}
bucket.set(key.subjectKey, entry);
placeableByTitle.set(g.title, bucket);
@@ -253,16 +317,100 @@ export function actionMenu(game: Game): Menu {
title,
items: [...m.values()],
}));
return { options, direct, placeable };
/**
* THE HAND, AND WHAT EACH CARD CAN DO.
*
* Built from the same `options` everything else reads, so a verb offered here is one `check` has
* already accepted. Cards keep their order in hand rather than being regrouped: the hand is a row
* of objects the player is looking at, not a list to sort.
*/
const handIds = game.state.decks.hands.get(0) ?? [];
const hand: HandAction[] = handIds.map((cardId) => {
const place = placeable.flatMap((g) => g.items).find((it) => it.subjectKey === `card:${cardId}`);
let playNow: number | null = null;
const discard: (number | null)[] = [null, null, null];
options.forEach((i, index) => {
if (i.type === 'card.play' && i.cardId === cardId && i.placement === undefined) playNow = index;
if (i.type === 'card.discard' && i.cardId === cardId) discard[i.toSlot] = index;
});
const kind = game.state.cards.get(cardId)?.kind;
const shapes =
kind?.kind === 'track'
? variantsFor(kind.geometry, kind.hand).map((v) =>
connectionsFor({
geometry: { kind: 'track', geometry: kind.geometry, ...v, ...(kind.hand !== 'none' ? { hand: kind.hand } : {}) },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
} as never).map(([a, b]) => `${a}${b}`),
)
: [];
return {
cardId,
name: cardName(game.state, cardId),
what: cardDescription(game.state, cardId),
playNow,
placeKey: place ? place.subjectKey : null,
spots: place ? place.spots.length : 0,
discard,
shapes: [...new Map(shapes.map((l) => [l.join('|'), l])).values()],
};
});
// Making up a train. `newTrain.placeCar` carries the car; the Division Yard chip that shows that
// car is where the click belongs.
const makeUpCars: MakeUpAction[] = [];
let pass: number | null = null;
options.forEach((i, index) => {
if (i.type === 'newTrain.placeCar') makeUpCars.push({ carType: i.carType, loaded: i.loaded, index });
if (i.type === 'newTrain.passCar') pass = index;
});
const makeUp =
makeUpCars.length > 0 || pass !== null
? {
title:
consistTitle(
game,
options,
options.map((_, index) => ({ index })).filter((a) => options[a.index]?.type.startsWith('newTrain.')),
) ?? 'Making up the train',
cars: makeUpCars,
pass,
}
: null;
return { options, direct, placeable, hand, makeUp };
}
/** Split an intent into "what" and "where", or null if it needs no location. */
function subjectOf(
game: Game,
i: Intent,
): { subjectKey: string; subject: string; spot: string; coord: { row: number; col: number } } | null {
): {
subjectKey: string;
subject: string;
spot: string;
coord: { row: number; col: number };
links: string[];
} | null {
const at = (c: { row: number; col: number }): string => `(${c.row}, ${c.col})`;
if (i.type === 'card.play' && i.placement) {
/**
* ABS Signals is placed on a MAINLINE card, and the engine carries which one in `placement.col`
* as a Division node index. Showing that as a grid coordinate read as an Office Area square —
* "(0, 1)" — which is the wrong half of the board entirely.
*/
const k = game.state.cards.get(i.cardId)?.kind;
if (k?.kind === 'enhancement' && i.placement.row === -1) {
const node = game.state.division.nodes[i.placement.col];
const where = node?.kind === 'mainline' ? mainlineProfile(node.card).name : `node ${i.placement.col}`;
return {
subjectKey: `card:${i.cardId}`,
subject: cardName(game.state, i.cardId),
spot: `on the ${where}, out on the Mainline`,
coord: i.placement,
links: [],
};
}
// A track card is an ordinary card play; its rotation and what it would meet are the whole of
// the decision, so they ride on the spot rather than being left for the player to work out.
const kind = game.state.cards.get(i.cardId)?.kind;
@@ -276,11 +424,35 @@ function subjectOf(
subject: cardName(game.state, i.cardId),
spot: `${at(i.placement)}${track}`,
coord: i.placement,
links: placementLinks(game, i.cardId, i.variant),
};
}
return null;
}
/**
* The rails a card would lay, in `connectionsFor` codes — the same source the board draws from.
*
* Built by asking the engine what the card becomes, not by re-deriving it: a preview that worked out
* the shape for itself would eventually show a different piece from the one the placement lays, which
* is precisely the confusion it exists to remove.
*/
function placementLinks(game: Game, cardId: string, variant: number | undefined): string[] {
const kind = game.state.cards.get(cardId)?.kind;
if (!kind) return [];
if (kind.kind === 'track') {
const v = variantsFor(kind.geometry, kind.hand)[variant ?? 0];
if (!v) return [];
return connectionsFor({
geometry: { kind: 'track', geometry: kind.geometry, ...v, ...(kind.hand !== 'none' ? { hand: kind.hand } : {}) },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
} as never).map(([a, b]) => `${a}${b}`);
}
// Everything else that goes on the grid is a plain through track, or is not track at all.
if (kind.kind === 'freightFacility' || kind.kind === 'office') return ['ew'];
return [];
}
/**
* Rotations share a square, so the square alone does not identify the choice — and "rotation 2"
* does not tell a player which way the rail will run, which for a curve or turnout is the entire
+274 -188
View File
@@ -6,12 +6,15 @@
*/
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts';
import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, yardHtml } from './panels.ts';
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
import { playCue } from './sound.ts';
import type { Game } from './game.ts';
import { MOVES_PER_LOCAL_OPS } from '../engine/content.ts';
import { cardDescription } from '../sim/view.ts';
import {
actionMenu,
handPlayable,
currentActor,
fromSave,
newGame,
@@ -25,6 +28,15 @@ const SAVE_KEY = 'station-master.save.v1';
let game: Game;
/** Which card or track piece is picked, waiting for a location. */
let selected: string | null = null;
/**
* WHAT the picked card is being used for.
*
* The hand is the action surface, and a card has two verbs: play it onto the board, or discard it
* onto a Department. Both then ask "where?", and the answer is highlighted in place — squares on the
* board for a play, the three Department piles for a discard. Without this the two flows would need
* two selections, which is the thing being removed.
*/
let mode: 'play' | 'discard' | null = null;
/** A square picked on the board, waiting for a rotation. */
let pendingAt: string | null = null;
/**
@@ -48,6 +60,14 @@ let districtMode: 'auto' | 'open' | 'closed' = 'auto';
* before any audio may start.
*/
let soundOn = false;
/**
* The phase the page last drew, so a change of phase can be announced.
*
* Local Operations ends the moment the last Move is spent, and New Train and Mainline then run
* themselves — so a player looking at the board finds themselves in Cargo with no idea their turn
* ended or what happened in between. Reported exactly that way.
*/
let lastPhase: string | null = null;
const FOCUS_PHASES = new Set(['localOps', 'loadUnload']);
const $ = (id: string): HTMLElement => {
@@ -59,76 +79,37 @@ const $ = (id: string): HTMLElement => {
const esc = (s: string): string =>
s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c);
/**
* A DRAWING OF THE PIECE A PLACEMENT WOULD LAY.
*
* "Allows traffic from the east to travel west or turn to the south" is exact and still leaves a
* player working out which way the leg will point once the card is down. Rendered by `officeSvg` —
* the board's own renderer, on a one-card board — so the preview and the board cannot disagree about
* what the piece looks like. The coordinate stamp and the RUNNING TRACK caption are stripped: they
* belong to a position, and this is a card on its own.
*/
function piecePreview(links: string[], label: string): string {
const cell = {
row: 0, col: 0, kind: 'trk', label, running: false, enhancements: [],
tray: null, cars: [], facility: null, links, what: '',
};
return officeSvg([cell] as never, 99).replace(
/<text class="bs-(coord|rowlab)"[^>]*>[^<]*<\/text>/g,
'',
);
}
// ---------------------------------------------------------------------------
/**
* THE TURN CHART — the five phases of a Stage, in order, with the current one lit.
*
* The title bar used to say `phase: Mainline`, which names the moment and nothing around it: a
* player could not see what had already happened this Stage or what was coming. The printed game
* has a turn chart for exactly this reason.
*
* Each phase carries a tooltip describing what actually happens in it — the first three are the
* rules text; Cargo and Supervisor Shift are written from what the engine does, since the recovered
* sheet does not spell them out.
* Built by `turnChartHtml`, shared with both replay viewers, so all three screens report where you
* are in the Day the same way and with the same violet highlight. It used to live here alone.
*/
const PHASES: { key: string; label: string; tip: string; icon: string }[] = [
{
key: 'localOps',
label: 'Local Operations',
tip: 'Switch with a train, Freight Agent operations, or draw a card. One of the three, once per Stage — this is the whole of your turn.',
// a turnout: the choice of three roads
icon: '<path class="ic" d="M1 11h6l6-6h4M7 11h10"/>',
},
{
key: 'newTrain',
label: 'New Train',
tip: 'Timetabled trains for this Stage are built. New timetabled trains are randomly placed on the timetable. Held trains are built. Extra trains are built.',
// a locomotive being made up
icon: '<rect class="ic" x="2" y="6" width="9" height="7" rx="1"/><path class="ic" d="M11 9h4v4h-4"/><circle class="icf" cx="5" cy="15" r="1.5"/><circle class="icf" cx="13" cy="15" r="1.5"/>',
},
{
key: 'mainline',
label: 'Mainline',
tip: 'All trains holding at an Office or Division Point must attempt to move. This phase is automatic — you make no decisions in it, except a clearance ruling if one is called for.',
// open road
icon: '<path class="ic" d="M2 7h16M2 13h16"/><path class="ic" d="M6 7v6M12 7v6"/>',
},
{
key: 'loadUnload',
label: 'Cargo',
tip: 'Your workers act, once each per Stage. A Laborer moves a load one square across MEN | AT | WORK, or starts one, or begins an unload. A Porter boards or detrains passengers, which earns Revenue in a single action.',
// a loaded box on a flat
icon: '<rect class="ic" x="3" y="7" width="8" height="6"/><path class="ic" d="M3 10h8M7 7v6"/><path class="ic" d="M13 13h4"/>',
},
{
key: 'shiftChange',
label: 'Supervisor Shift',
tip: 'End of the Stage. Every Laborer and Porter is refreshed for the next one, and every third Stage — 3, 6, 9 and 12 — the Superintendent\'s hat passes to the next player.',
// a pocket watch
icon: '<circle class="ic" cx="10" cy="11" r="6"/><path class="ic" d="M10 8v3l2 2M8 2h4"/>',
},
];
function renderTurnChart(f: ReturnType<typeof view>): void {
$('tc-day').textContent = `Day ${f.day}`;
$('tc-stage').textContent = `Stage ${f.stage} of 12`;
$('tc-clock').textContent = f.clock;
$('tc-phase').textContent = f.phase;
// WHO THE TABLE IS WAITING ON. Solitaire has one seat, but the question is the same one a
// multiplayer table asks constantly, and an automatic phase is waiting on nobody.
const actorName = f.actor === null ? null : (game.state.players[f.actor]?.name ?? null);
$('tc-who').textContent = actorName ?? 'nobody — the Division is running itself';
const at = PHASES.findIndex((p) => p.key === f.phaseKey);
$('tc-phases').innerHTML = PHASES.map((p, i) => {
const state = p.key === f.phaseKey ? 'now' : i < at ? 'done' : '';
return (
`<li class="tc-phase ${state}" data-tip="${esc(p.label)} — ${esc(p.tip)}" tabindex="0">` +
`<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true">${p.icon}</svg>` +
`<span>${esc(p.label)}</span></li>`
);
}).join('');
$('turnchart').innerHTML = turnChartHtml(f, actorName);
}
function start(): void {
@@ -152,7 +133,13 @@ function render(): void {
// Which squares the selected card or track piece may go on. Highlighting them is what turns the
// coordinate list into a board: you pick the thing, then click where it goes.
const chosen = menu.placeable.flatMap((g) => g.items).find((it) => it.subjectKey === selected);
// Squares light up only while a card is picked FOR PLAY. `selected` names the card; the placeable
// entry that carries its squares is keyed the same way the menu keys it.
const forPlay =
mode === 'play' && selected !== null ? menu.hand.find((h) => `hand:${h.cardId}` === selected) : undefined;
const chosen = forPlay
? menu.placeable.flatMap((g) => g.items).find((it) => it.subjectKey === forPlay.placeKey)
: undefined;
const spotsAt = new Map<string, { label: string; index: number }[]>();
for (const sp of chosen?.spots ?? []) {
const key = `${sp.coord.row},${sp.coord.col}`;
@@ -212,47 +199,52 @@ function render(): void {
}
// -- facilities
$('facs').innerHTML =
f.facilities.length === 0
? '<div class="dim">no facilities yet</div>'
: f.facilities
.map(
(x) =>
`<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
.map(
(m) => `<span class="box ${m ? 'm' : 'empty'}">${m ? esc(m) : '·'}</span>`,
)
.join('') +
`</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'}" 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('');
$('facs').innerHTML = facilitiesHtml(f);
// 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.
// The crosshatch says "not yet playable"; it used to be a `not yet` caption, which had to be read
// rather than seen. The tooltip still explains WHY, which a hatch cannot.
const cardRow = (name: string, why: string, playable: boolean | null): string =>
`<div class="handcard${playable === false ? ' unplayable' : ''}"${why ? ` data-tip="${esc(why)}"` : ''} tabindex="0">` +
`<b>${esc(name)}</b></div>`;
const canPlay = handPlayable(game);
$('hand').innerHTML = f.hand.length
? f.hand.map((h, i) => cardRow(h, f.handWhat[i] ?? '', canPlay[i] ?? null)).join('')
/**
* THE HAND, AS THE ACTION SURFACE.
*
* Each card carries its own verbs. This used to be a read-only row, with the same cards appearing
* again in the action list — once as subjects to play and once as one button per Department to
* discard, which for a four-card hand was twelve buttons repeating three choices four times.
*/
$('hand').innerHTML = menu.hand.length
? menu.hand
.map((h) => {
const canPlace = h.placeKey !== null && h.spots > 0;
const canPlay = canPlace || h.playNow !== null;
const canDiscard = h.discard.some((d) => d !== null);
const picked = selected === `hand:${h.cardId}`;
const verb = (kind: string, on: boolean, text: string, tip: string): string =>
on
? `<button class="cardact ${kind}${picked && mode === kind ? ' on' : ''}" ` +
`data-card="${esc(h.cardId)}" data-verb="${kind}" data-tip="${esc(tip)}">${text}</button>`
: '';
// A track card shows what it would look like on the board, in every orientation it has —
// which is how a player sees a turnout has two at all, and what a curve actually is.
const fig = h.shapes.length
? ` data-tip-html="${esc(h.shapes.map((l) => piecePreview(l, h.name)).join(''))}"`
: '';
return (
`<div class="handcard${canPlay ? '' : ' unplayable'}${picked ? ' picked' : ''}"${fig}` +
`${h.what ? ` data-tip="${esc(h.what)}"` : ''} tabindex="0">` +
`<b>${esc(h.name)}</b>` +
`<div class="cardacts">` +
verb(
'play',
canPlay,
canPlace ? `play <span class="dim">${h.spots}</span>` : 'play',
canPlace
? `Play it onto the board — ${h.spots} square${h.spots === 1 ? '' : 's'} will light up.`
: 'Play it. This card needs no square on the board.',
) +
verb('discard', canDiscard, 'discard', 'Discard it face up on top of a Department — pick which one.') +
`</div></div>`
);
})
.join('')
: '<span class="dim">empty</span>';
/**
* THE THREE DEPARTMENT PILES, and how deep each one is.
@@ -263,47 +255,78 @@ function render(): void {
* reach. Drawn like the hand so they read as cards, dashed and unlit because taking one is a draw
* action rather than a click on the card itself.
*/
const pileCard = (label: string, top: string, depth: number, why: string, tipExtra = ''): string => {
const tip = [why, tipExtra].filter(Boolean).join(' · ');
return (
`<div class="handcard"${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
`<div class="pilehd"><span>${esc(label)}</span><span class="depth">${depth}</span></div>` +
`<b>${esc(top)}</b></div>`
);
};
$('depts').innerHTML =
f.departments
.map((d, i) => {
const depth = f.departmentDepth[i] ?? 0;
const under = depth - 1;
return pileCard(
`Dept ${i + 1}`,
d,
depth,
f.departmentsWhat[i] ?? '',
under > 0 ? `${under} card${under === 1 ? '' : 's'} buried beneath it and out of reach` : '',
);
})
.join('') +
// The Salvage Yard beside them, because §6.2 sweeps it back into the deck and it is the only
// visible sign that the reshuffle is coming.
pileCard(
'Salvage',
f.salvage.top,
f.salvage.depth,
'',
'Played cards that did not stay on the board. Swept back into the Home Office deck, with the Departments, when the deck runs out.',
);
$('depts').innerHTML = pilesHtml(f);
renderYards(f);
$('blocked').innerHTML =
f.blocked.length === 0
? '<li class="dim">nothing blocked</li>'
: f.blocked
.map((b) => `<li class="sev-${b.severity}"><b>${esc(b.where)}</b> — ${esc(b.why)}</li>`)
.join('');
// -- the hand's verbs. Picking one selects the card and says what it is for; the "where" is then
// highlighted in place, on the board or on the Department piles.
for (const b of Array.from($('hand').querySelectorAll('button.cardact'))) {
const node = b as HTMLElement;
node.onclick = () => {
const cardId = node.dataset['card']!;
const verb = node.dataset['verb'] as 'play' | 'discard';
const entry = menu.hand.find((h) => h.cardId === cardId);
if (!entry) return;
// A card that needs no square goes straight down; there is nothing to ask.
if (verb === 'play' && entry.placeKey === null && entry.playNow !== null) {
const intent = menu.options[entry.playNow];
if (intent) submit(game, intent);
selected = null;
mode = null;
pendingAt = null;
render();
return;
}
const key = `hand:${cardId}`;
const same = selected === key && mode === verb;
selected = same ? null : key;
mode = same ? null : verb;
pendingAt = null;
render();
};
}
// -- discarding: the three Department piles become the targets. They already show their top card
// and their depth, which is exactly what you choose between.
const discarding = mode === 'discard' && selected !== null
? menu.hand.find((h) => `hand:${h.cardId}` === selected)
: undefined;
if (discarding) {
for (const el of Array.from($('depts').querySelectorAll('[data-dept]'))) {
const node = el as HTMLElement;
const slot = Number(node.dataset['dept']);
const index = discarding.discard[slot];
if (index === null || index === undefined) continue;
node.classList.add('target');
node.onclick = () => {
const intent = menu.options[index];
if (intent) submit(game, intent);
selected = null;
mode = null;
render();
};
}
}
// -- making up a train: the Division Yard chip that shows the car IS the button.
if (menu.makeUp) {
for (const el of Array.from($('divyard').querySelectorAll('[data-car]'))) {
const node = el as HTMLElement;
const car = menu.makeUp!.cars.find(
(c) => c.carType === node.dataset['car'] && c.loaded === (node.dataset['loaded'] === '1'),
);
if (!car) continue;
node.classList.add('addable');
node.onclick = () => {
const intent = menu.options[car.index];
if (intent) submit(game, intent);
render();
};
}
}
$('blocked').innerHTML = blockedHtml(f);
// -- log
const log = $('log');
@@ -313,8 +336,25 @@ function render(): void {
.join('');
log.scrollTop = log.scrollHeight;
/**
* SAY WHEN THE PHASE TURNS OVER.
*
* The five phases run themselves once Local Operations ends, so the page can change out from under
* a player between one click and the next. The turn chart already shows WHERE you are; this says
* that it moved, which is the part you miss when you were looking at the board.
*/
if (lastPhase !== null && lastPhase !== f.phaseKey) {
const el = $('phasenote');
el.textContent = `${f.phase} — Day ${f.day}, Stage ${f.stage}`;
el.className = 'shown';
window.setTimeout(() => {
if (el.textContent?.startsWith(f.phase)) el.className = '';
}, 2600);
}
lastPhase = f.phaseKey;
renderDistrict(f);
renderActions(menu);
renderActions(menu, f);
// Drain whatever the last batch of events earned. Cleared either way, so turning sound on does
// not then play a backlog of everything that happened while it was off.
@@ -332,19 +372,8 @@ function render(): void {
* left, and the moment the Division Yard empties a whole pile comes back at once.
*/
function renderYards(f: ReturnType<typeof view>): void {
const row = (rows: { type: string; loaded: number; empty: number }[]): string =>
rows.length === 0
? '<span class="dim">empty</span>'
: rows
.map(
(r) =>
`<span class="stock" data-tip="${esc(r.type)} — ${r.loaded} loaded, ${r.empty} empty">` +
`<b>${esc(r.type)}</b> <span class="ld">${r.loaded}</span>/<span class="mt">${r.empty}</span></span>`,
)
.join('');
$('divyard').innerHTML = row(f.yards.division);
$('clsyard').innerHTML = row(f.yards.classification);
$('divyard').innerHTML = yardHtml(f.yards.division);
$('clsyard').innerHTML = yardHtml(f.yards.classification);
$('divtot').textContent = `${f.yards.divisionTotal} cars`;
$('clstot').textContent = `${f.yards.classificationTotal} cars`;
@@ -384,16 +413,7 @@ function renderDistrict(f: ReturnType<typeof view>): void {
};
}
function boxes(items: string[], cap: number): string {
let out = '';
for (let i = 0; i < Math.max(cap, items.length); i++) {
const v = items[i];
out += `<span class="box ${v ? 'f' : 'empty'}">${v ? esc(v) : '·'}</span>`;
}
return out || '<span class="dim">—</span>';
}
function renderActions(menu: ReturnType<typeof actionMenu>): void {
function renderActions(menu: ReturnType<typeof actionMenu>, f: ReturnType<typeof view>): void {
const el = $('actions');
if (game.state.status !== 'active') {
@@ -419,27 +439,61 @@ function renderActions(menu: ReturnType<typeof actionMenu>): void {
const intent = menu.options[index];
if (intent) submit(game, intent);
selected = null;
mode = null;
pendingAt = null;
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.
/**
* 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.
*
* AND IF THE LABEL CARRIES NO EXPLANATION, ask the card. Tooltips used to depend entirely on a
* label happening to contain an em-dash, so an action naming a card could have none at all while
* the same card in hand explained itself perfectly — reported on "Realignment on Mainline card 3",
* which had neither a name for the card it meant nor a word about what it would do.
*/
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) : '';
let rest = cut > 0 ? label.slice(cut + 3) : '';
if (!rest) {
const intent = menu.options[index];
const cardId = intent && 'cardId' in intent ? (intent as { cardId: string }).cardId : null;
if (cardId) rest = cardDescription(game.state, cardId);
}
return (
`<button class="act" data-i="${index}"${rest ? ` data-tip="${esc(rest)}"` : ''}>${esc(head)}</button>`
);
};
/**
* MOVES LEFT, WHERE THE MOVES ARE.
*
* §6.1 gives six Moves a turn and every switching decision is really "can I still get back?" — so
* the count belongs beside the buttons. It was reported only in the history panel, which is the
* one place a player is not looking while switching.
*/
const movesNote =
f.movesLeft !== null
? `<div class="moves${f.movesLeft === 0 ? ' spent' : ''}" data-tip="§6.1 — six Moves a turn. A Move runs any distance in one direction; changing direction costs another, which is why a run-around has to be planned inside the count.">` +
`${f.movesLeft} of ${MOVES_PER_LOCAL_OPS} Moves left</div>`
: '';
// A heading over the buttons, so the panel says what it is before it says what is in it. The
// list below is already phase-specific: it comes from `legalActions`, so in the Cargo phase with
// no worker able to act, the only thing offered is "End my Cargo phase".
let html = '<h3 class="actions-hd">Actions</h3>';
let html = `<h3 class="actions-hd">Actions</h3>${movesNote}`;
/**
* WHAT IS LEFT IN THE ACTION LIST.
*
* Everything about a card in hand now lives on the card, and everything about making up a train
* lives on the yard chip. What remains is the rest of the turn: the Local Operations choice,
* drawing, switching moves, the Freight Agent, and finishing.
*/
html += menu.direct
.filter((g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && !/^Making up /.test(g.title))
.map(
(g) =>
`<div class="grp"><h3>${esc(g.title)}</h3>` +
@@ -455,17 +509,46 @@ function renderActions(menu: ReturnType<typeof actionMenu>): void {
)
.join('');
// Subject first, location second. Picking a card then a square is how the choice is actually made;
// one flat list of every card-square-rotation combination was unreadable.
/**
* MAKING UP A TRAIN.
*
* The cars are added from the Division Yard chips, but the panel still has to exist: it names the
* train and what its card calls for, and it carries the "no more cars" button, which is the ONLY
* way to finish when the yard holds nothing the train may take.
*
* Moving the cars onto the yard chips without this left a train being made up with no control on
* screen at all whenever no chip was addable — a hard softlock, reported at Stage 10 of seed
* 775569289 with Train 10 waiting at the West Division Point.
*/
if (menu.makeUp) {
const addable = menu.makeUp.cars.length;
html +=
`<div class="grp"><h3>${esc(menu.makeUp.title)}</h3>` +
`<div class="dim makeup-note">` +
(addable > 0
? `Click a car in the Division Yard below to add it — ${addable} kind${addable === 1 ? '' : 's'} it may take are highlighted there.`
: menu.makeUp.pass !== null
? 'The Division Yard is bare, so there is nothing to add. Send the train out as it stands.'
: 'Nothing in the Division Yard may join this train, and §7 does not allow passing while the yard holds cars.') +
`</div>` +
(menu.makeUp.pass !== null
? `<button class="act" data-i="${menu.makeUp.pass}" data-tip="§8.2 — a train may depart with FEWER cars than its card lists, but never with the wrong ones. This sends it out as it stands.">no more cars — send it out</button>`
: '') +
`</div>`;
}
/**
* The card is picked in the hand and the square on the board, so all that is left here is the
* ROTATION — the one question neither of those can ask, and only for the card actually picked.
*/
const picked =
mode === 'play' && selected !== null ? menu.hand.find((h) => `hand:${h.cardId}` === selected) : undefined;
for (const group of menu.placeable) {
const relevant = group.items.filter((it) => picked && it.subjectKey === picked.placeKey);
if (relevant.length === 0) continue;
html += `<div class="grp"><h3>${esc(group.title)}</h3>`;
for (const item of group.items) {
const open = selected === item.subjectKey;
html +=
`<button class="subj ${open ? 'open' : ''}" data-subj="${esc(item.subjectKey)}">` +
`${esc(item.subject)} <span class="dim">${item.spots.length} spot${item.spots.length === 1 ? '' : 's'}</span>` +
`</button>`;
if (open) {
for (const item of relevant) {
{
// Once a square is picked, show only that square's rotations — the rest is noise.
const shown = pendingAt
? item.spots.filter((sp) => `${sp.coord.row},${sp.coord.col}` === pendingAt)
@@ -474,7 +557,19 @@ function renderActions(menu: ReturnType<typeof actionMenu>): void {
`<div class="spots"><div class="dim">` +
(pendingAt ? `choose a rotation for (${esc(pendingAt.replace(',', ', '))}):` : 'click a highlighted square, or:') +
`</div>` +
shown.map((sp) => `<button class="act" data-i="${sp.index}">${esc(sp.label)}</button>`).join('') +
shown
.map((sp) => {
// The picture rides on the button that would lay it, so hovering a rotation shows
// that rotation — which is the question "which of these two do I want?" answered.
const fig = sp.links.length
? ` data-tip-html="${esc(piecePreview(sp.links, item.subject))}"`
: '';
return (
`<button class="act" data-i="${sp.index}"${fig}` +
` data-tip="${esc(`${item.subject} — ${sp.label}`)}">${esc(sp.label)}</button>`
);
})
.join('') +
`</div>`;
}
}
@@ -497,15 +592,6 @@ function renderActions(menu: ReturnType<typeof actionMenu>): void {
const node = b as HTMLElement;
node.onclick = () => apply(Number(node.dataset['i']));
}
for (const b of Array.from(el.querySelectorAll('button.subj'))) {
const node = b as HTMLElement;
node.onclick = () => {
const key = node.dataset['subj'] ?? null;
selected = selected === key ? null : key;
pendingAt = null;
render();
};
}
}
// ---------------------------------------------------------------------------
@@ -559,7 +645,7 @@ function clearSave(): void {
// styled by class — without BOARD_CSS each rect falls back to a black fill on a near-black
// background, so the cards are drawn correctly and are simply invisible.
const pageStyle = document.createElement('style');
pageStyle.textContent = BOARD_CSS + TOOLTIP_CSS;
pageStyle.textContent = BOARD_CSS + TOOLTIP_CSS + TURNCHART_CSS + PANEL_CSS;
document.head.appendChild(pageStyle);
installTooltips();
+225
View File
@@ -0,0 +1,225 @@
/**
* The side panels a game is read from — cards, yards, blockers, facilities.
*
* SHARED between the playable page and the site's replay viewer, for the same reason the board
* renderers are: a replay is the game being WATCHED rather than played, so it should look like the
* game. The viewer had three panels against the play page's eight — no facilities, no blockers, no
* cards, no yards — which meant a replay could not answer "why is nothing moving?", the question a
* replay mostly exists to answer.
*
* Each function returns HTML rather than writing to the DOM, so the two pages can keep their own
* element ids and their own layout while drawing the same contents.
*/
import type { Frame } from '../sim/view.ts';
const esc = (s: string): string =>
String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c);
/**
* A card, drawn as a card.
*
* `playable === false` crosshatches it: "not yet playable" had to be read, where a hatch is seen.
* The tooltip still explains WHY, which a hatch cannot. A replay passes null throughout — nothing
* there is playable, because nothing there is being played.
*/
export function cardRow(name: string, why: string, playable: boolean | null): string {
return (
`<div class="handcard${playable === false ? ' unplayable' : ''}"${why ? ` data-tip="${esc(why)}"` : ''} tabindex="0">` +
`<b>${esc(name)}</b></div>`
);
}
export function handHtml(f: Frame, canPlay: (boolean | null)[] = []): string {
return f.hand.length
? f.hand.map((h, i) => cardRow(h, f.handWhat[i] ?? '', canPlay[i] ?? null)).join('')
: '<span class="dim">empty</span>';
}
/**
* The three Department piles and the Salvage Yard, each with its top card and its depth.
*
* Only the top card may ever be drawn, so the depth is a count and not a hint: everything below it
* is out of reach, and choosing where to discard is choosing what to put there.
*/
export function pilesHtml(f: Frame): string {
const pile = (label: string, top: string, depth: number, why: string, extra = '', slot = -1): string => {
const tip = [why, extra].filter(Boolean).join(' · ');
// A Department is a DROP TARGET for a discard. The attribute is always emitted; only the play
// page binds a click to it, and only while a card is waiting to be discarded — so the replay
// viewer draws exactly the same markup and nothing there is clickable.
const target = slot >= 0 ? ` data-dept="${slot}"` : '';
return (
`<div class="handcard"${target}${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
`<div class="pilehd"><span>${esc(label)}</span><span class="depth">${depth}</span></div>` +
`<b>${esc(top)}</b></div>`
);
};
return (
f.departments
.map((d, i) => {
const depth = f.departmentDepth[i] ?? 0;
const under = depth - 1;
return pile(
`Dept ${i + 1}`,
d,
depth,
f.departmentsWhat[i] ?? '',
under > 0 ? `${under} card${under === 1 ? '' : 's'} buried beneath it and out of reach` : '',
i,
);
})
.join('') +
pile(
'Salvage',
f.salvage.top,
f.salvage.depth,
'',
'Played cards that did not stay on the board. Swept back into the Home Office deck, with the Departments, when the deck runs out.',
)
);
}
/** One yard, by car type. Loaded and empty are separate numbers because they do different jobs. */
export function yardHtml(rows: { type: string; loaded: number; empty: number }[]): string {
return rows.length === 0
? '<span class="dim">empty</span>'
: rows
.map(
(r) =>
`<span class="stock" data-tip="${esc(r.type)} — ${r.loaded} loaded, ${r.empty} empty">` +
`<b>${esc(r.type)}</b> ` +
// Each count is its own target: making up a train is choosing a car OF A TYPE AND A LOAD
// STATE, which is exactly what these two numbers already distinguish.
`<span class="ld" data-car="${esc(r.type)}" data-loaded="1">${r.loaded}</span>/` +
`<span class="mt" data-car="${esc(r.type)}" data-loaded="0">${r.empty}</span></span>`,
)
.join('');
}
export function blockedHtml(f: Frame): string {
return f.blocked.length === 0
? '<li class="dim">nothing blocked</li>'
: f.blocked
.map((b) => `<li class="sev-${b.severity}"><b>${esc(b.where)}</b> — ${esc(b.why)}</li>`)
.join('');
}
/** The load pipeline at each industry: green → MEN | AT | WORK → red, and the siding. */
export function facilitiesHtml(f: Frame): string {
/**
* One number, with what the card printed and what a Modifier added.
*
* `laborers` arrives as "left/total" and the rest as plain totals, so the total is taken from the
* end either way and compared with the base.
*/
const stat = (label: string, shown: string, base: number, why: string): string => {
const total = Number(shown.split('/').pop() ?? shown);
const added = Number.isFinite(total) ? total - base : 0;
const delta = added > 0 ? `<span class="added">+${added}</span>` : '';
return (
`<span class="cap" data-tip="${esc(why)}${added > 0 ? ` The card prints ${base}; Modifiers beside it add ${added}.` : ''}">` +
`<span class="dim">${esc(label)}</span> <b>${esc(shown)}</b>${delta}</span>`
);
};
const boxes = (filled: string[], cap: number): string => {
let h = '';
for (let i = 0; i < Math.max(cap, filled.length); i++) {
h += `<span class="box ${i < filled.length ? 'f' : 'empty'}">${i < filled.length ? esc(filled[i] ?? '') : '·'}</span>`;
}
return h || '<span class="dim">—</span>';
};
return f.facilities.length === 0
? '<div class="dim">no facilities yet</div>'
: f.facilities
.map(
(x) =>
`<div class="fac" data-tip="${esc(x.name)} — ${esc(x.flow)} ${esc(x.commodity)}" tabindex="0">` +
`<div class="nm">${esc(x.name)} <span class="dim">${esc(x.commodity)}</span></div>` +
/**
* THE WORKERS AND THE CAPACITIES, ON THE PANEL RATHER THAN IN A TOOLTIP.
*
* Laborers were only ever in the hover text, so "how many actions can I take here this
* Stage?" — the question that decides every Cargo phase — had to be hunted for. And a
* Modifier's whole effect is one of these numbers going up, so showing the number alone
* made an Ice House look like it had done nothing. Each now says what the card prints
* and what the Modifiers beside it added.
*/
`<div class="caps">` +
stat('laborers', x.laborers, x.base.laborers, 'Actions this facility can take each Stage — one Laborer moves one load one square.') +
stat('out', String(x.greenCap), x.base.out, 'Green boxes: loads waiting to be worked onto a car.') +
stat('in', String(x.redCap), x.base.in, 'Red boxes: loads that have come off an arriving car and are waiting to be cleared.') +
(x.modifiers.length
? `<span class="mods" data-tip="Modifier cards standing beside this industry, each raising one of the numbers above.">+ ${esc(x.modifiers.join(', '))}</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.map((m) => `<span class="box ${m ? 'm' : 'empty'}">${m ? esc(m) : '·'}</span>`).join('') +
`</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'}" 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('');
}
/** Styling for all of the above, so the two pages cannot drift apart visually. */
export const PANEL_CSS = `
h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3;margin:9px 0 4px}
.cardrow{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 2px}
.handcard{background:#242c36;border:1px solid #2c333d;border-radius:5px;
padding:5px 8px;font-size:11px;min-width:96px;position:relative}
.handcard:focus{outline:2px solid #4d6fa8;outline-offset:1px}
.cardrow.ref .handcard{background:#1c2129;border-style:dashed;border-color:#39424e;color:#b6bec9}
.handcard.unplayable{color:#7d8794;border-color:#39424e}
.handcard.unplayable::after{content:"";position:absolute;inset:0;border-radius:5px;pointer-events:none;
background:repeating-linear-gradient(45deg,transparent 0 5px,rgba(150,160,175,.20) 5px 6px)}
/* A pile shows two things: which card is face up on top, and how many are under it. */
.pilehd{display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin-bottom:2px;
font-size:10px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3}
.pilehd .depth{font-variant-numeric:tabular-nums;background:#2a3038;border-radius:8px;
padding:0 6px;color:#cfe0f5}
/* Rolling stock is finite and the Classification Yard only returns it when the Division Yard is
bare, so watching the Division Yard run down is real information. */
.yard{display:flex;flex-wrap:wrap;gap:5px;margin:0 0 6px}
.stock{background:#1c2129;border:1px solid #39424e;border-radius:4px;padding:2px 7px;font-size:11px}
.stock b{color:#e6e9ee}
.stock .ld{color:#8fd6a0}
.stock .mt{color:#9fb6d8}
.stock.none{opacity:.4}
/* A pile or a yard chip that is currently a target for the thing you picked. Blue, matching the
legal-square outline on the board: the same question is being asked, so it looks the same. */
.handcard.target{border-color:#5aa9e6;background:#233246;cursor:pointer;
box-shadow:0 0 0 2px rgba(90,169,230,.20)}
.handcard.target:hover{background:#2b3d55}
.stock .ld.addable,.stock .mt.addable{cursor:pointer;border-radius:3px;padding:0 4px;
outline:1px solid #5aa9e6;background:rgba(90,169,230,.16)}
.stock .ld.addable:hover,.stock .mt.addable:hover{background:rgba(90,169,230,.34)}
ul.blocked{margin:0;padding-left:18px}
.sev-waiting{color:#8b94a3}.sev-risk{color:#e0b060}.sev-stuck{color:#e58080}
.fac{border-top:1px solid #2c333d;padding:7px 0}
.fac:first-child{border-top:0}
.fac .nm{font-size:12px;font-weight:600}
.caps{display:flex;flex-wrap:wrap;gap:9px;margin-top:3px;font-size:10px;align-items:baseline}
.cap b{color:#e6e9ee;font-variant-numeric:tabular-nums}
.cap .added{color:#8fd6a0;margin-left:2px;font-weight:700}
.caps .mods{color:#c8a04a}
.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}
`;
+28 -77
View File
@@ -27,37 +27,7 @@ header button:hover{border-color:#4d6fa8}
.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)}
/* CARDS YOU HOLD vs CARDS YOU READ.
Both are drawn as cards, because they are cards. What separates them is the frame: a card in
Your Move is clickable and carries the action border; a reference card is flat, unlit and
explicitly not a target. Previously the hand was a list of bare names and the Department slots a
second list, so nothing looked like a card at all. */
.cardrow{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 2px}
/* THE YARDS. Rolling stock is finite and the Classification Yard only comes back when the Division
Yard is bare, so a player has to be able to watch the supply run down. */
.yard{display:flex;flex-wrap:wrap;gap:5px;margin:0 0 6px}
.stock{background:#1c2129;border:1px solid #39424e;border-radius:4px;padding:2px 7px;font-size:11px}
.stock b{color:#e6e9ee}
.stock .ld{color:#8fd6a0}
.stock .mt{color:#9fb6d8}
.stock.none{opacity:.4}
.yard.bare{outline:1px dashed #e0a060;outline-offset:3px;border-radius:4px;padding:3px}
.handcard{background:#242c36;border:1px solid var(--line);border-radius:5px;
padding:5px 8px;font-size:11px;min-width:96px;position:relative}
.handcard:focus{outline:2px solid #4d6fa8;outline-offset:1px}
.cardrow.ref .handcard{background:#1c2129;border-style:dashed;border-color:#39424e;color:#b6bec9}
/* A pile shows two things: which card is face up on top, and how many are under it. The depth is a
count and not a hint — only the top card may ever be drawn, so everything below it is out of
reach, and choosing where to discard is choosing what to put there. */
.pilehd{display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin-bottom:2px;
font-size:10px;text-transform:uppercase;letter-spacing:.07em;color:var(--dim)}
.pilehd .depth{font-variant-numeric:tabular-nums;background:#2a3038;border-radius:8px;
padding:0 6px;color:#cfe0f5}
/* NOT YET PLAYABLE, drawn rather than captioned. A "not yet" tag had to be read; a crosshatch is
seen. The card stays legible underneath — it is unavailable, not irrelevant. */
.handcard.unplayable{color:#7d8794;border-color:#39424e}
.handcard.unplayable::after{content:"";position:absolute;inset:0;border-radius:5px;pointer-events:none;
background:repeating-linear-gradient(45deg,transparent 0 5px,rgba(150,160,175,.20) 5px 6px)}
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;
@@ -90,18 +60,28 @@ section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
.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}
/* THE HAND IS THE ACTION SURFACE. Each card carries its own verbs, so the twelve-button discard
block and the separate "play a card" subject list are both gone. The buttons are small and quiet
until the card is picked — the card is the thing being read, they are what you do with it. */
.handcard .cardacts{display:flex;gap:4px;margin-top:5px}
button.cardact{margin:0;padding:1px 7px;font-size:10px;background:#2f3a48;border:1px solid #46536440;
border-radius:9px;color:#cfd6e0;text-align:center}
button.cardact:hover{background:#3d4c60;border-color:#5aa9e6}
button.cardact.on{background:#2b3444;border-color:#c8912f;color:#f2e6cf;font-weight:700}
button.cardact.discard{color:#d6b48a}
.handcard.picked{border-color:#c8912f;box-shadow:0 0 0 2px rgba(200,145,47,.18)}
#phasenote{position:fixed;left:50%;top:14px;transform:translateX(-50%) translateY(-14px);z-index:40;
background:#241d33;border:1px solid var(--now);color:#e7dcfb;border-radius:14px;padding:5px 16px;
font-size:13px;font-weight:700;opacity:0;pointer-events:none;transition:opacity .18s,transform .18s;
box-shadow:0 6px 22px rgba(0,0,0,.5)}
#phasenote.shown{opacity:1;transform:translateX(-50%) translateY(0)}
@media(prefers-reduced-motion:reduce){#phasenote{transition:none}}
.makeup-note{font-size:11px;margin:0 0 5px}
/* Moves left, beside the moves. Amber like the actions, because it is a budget being spent by the
buttons underneath it; struck through in red once it is gone. */
.moves{display:inline-block;font-size:11px;margin:0 0 7px;padding:1px 8px;border-radius:10px;
background:rgba(200,145,47,.16);border:1px solid #8a6d1f;color:#f2e6cf}
.moves.spent{background:rgba(190,50,50,.20);border-color:#8a4040;color:#e58080}
/* actions */
#actions{max-height:none}
.grp{margin-bottom:6px}
@@ -154,35 +134,6 @@ h3.actions-hd{font-size:13px;text-transform:none;letter-spacing:.01em;color:#cfe
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}
/* turn chart */
#turnchart{display:flex;align-items:center;gap:16px;flex-wrap:wrap;
background:#161b22;border-bottom:1px solid var(--line);padding:7px 14px}
.tc-when{display:flex;align-items:baseline;gap:10px}
.tc-when b{font-size:15px;color:#cfe0f5}
.tc-now{font-size:12px;color:var(--dim)}
.tc-now b{color:var(--now);font-size:13px}
/* WHO EVERYONE IS WAITING ON. Solitaire has one player, but the seat still has a name — the log
says "Player Solitaire", so the chart should agree. In multiplayer this is the thing a table
glances at most often, so it gets its own chip rather than hiding in the phase text. */
.tc-who{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dim)}
.tc-who b{color:var(--now);background:rgba(150,110,230,.16);border:1px solid #8b6ad0;
border-radius:11px;padding:1px 9px;font-size:12px}
ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:wrap}
.tc-phase{display:flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:14px;
padding:3px 10px 3px 7px;font-size:11px;color:var(--dim);background:#1a1f26;cursor:help}
.tc-phase svg{display:block;flex:0 0 auto}
.tc-phase .ic{stroke:#5f6b7a;fill:none;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}
.tc-phase .icf{fill:#5f6b7a;stroke:none}
.tc-phase.done{opacity:.55}
/* VIOLET, not the amber used for actions.
The palette already spends amber on "you can click this", blue on "this is a legal square or a
link", green on good and red on bad. The turn chart is none of those — it reports WHERE YOU ARE,
and a player should never read it as something to press. Violet is the one hue not already
carrying a meaning here, so it cannot be confused with any of them. */
.tc-phase.now{border-color:var(--now);background:rgba(150,110,230,.15);color:#e7dcfb;font-weight:700;
box-shadow:0 0 0 2px rgba(150,110,230,.18)}
.tc-phase.now .ic{stroke:var(--now)}
.tc-phase.now .icf{fill:var(--now)}
</style>
</head>
<body>
@@ -202,12 +153,12 @@ ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:w
<!-- The turn chart: where in the Day we are, and which of the five phases is running. Lifted out
of the title bar, where "phase: Mainline" said nothing about what came before or after it. -->
<div id="turnchart">
<div class="tc-when"><b id="tc-day">Day —</b><span id="tc-stage">Stage —</span><span class="dim" id="tc-clock">—</span></div>
<div class="tc-now">phase <b id="tc-phase">—</b></div>
<div class="tc-who">waiting on <b id="tc-who">—</b></div>
<ol class="tc-phases" id="tc-phases"></ol>
</div>
<!-- Filled by `turnChartHtml`, shared with both replay viewers so all three screens report where
you are in the Day identically. -->
<div id="turnchart"></div>
<!-- Announces a phase CHANGE. The turn chart says where you are; this says that it moved, which is
what you miss when the automatic phases run between two clicks. -->
<div id="phasenote"></div>
<main>
<div>
+23 -4
View File
@@ -99,22 +99,41 @@ input[type=range]{flex:1;min-width:180px}
<input type="range" id="vscrub" min="0" max="0" value="0">
<span class="dim" id="vpos">0 / 0</span>
</div>
<!-- The same turn chart the live game shows, from the same renderer — where you are in the
Day should look identical whether you are playing or watching. -->
<div id="turnchart"></div>
<div class="vbar">
<span id="vclock">&mdash;</span>
<span>phase: <b id="vphase">&mdash;</b></span>
<span>Revenue <b id="vrev">0</b></span>
<span class="dim">&larr; &rarr; to step, space to play</span>
</div>
</section>
<!-- SAME PANELS, SAME ORDER as the playable page. A replay is the game being watched rather
than played, so the only things it drops are the ones that ask you to act. It used to show
three panels against the play page's eight: no facilities, no blockers, no cards, no yards,
so a replay could not answer "why is nothing moving?" — the question a replay exists for. -->
<div class="vgrid">
<div>
<section><h2>The Division</h2><div class="scroll" id="vdivision"></div></section>
<section><h2>The Division — west to east</h2><div class="scroll" id="vdivision"></div></section>
<section id="vdistrict"><h2>Office Area <button id="vdistricttoggle" class="ghost">auto-hide: on</button></h2>
<div id="vdistrictsummary" class="dim"></div>
<div class="scroll" id="vgrid"></div></section>
<section><h2>What happened</h2><div id="vlog"></div></section>
</div>
<div>
<section><h2>Cards Available</h2>
<h3>Cards in Hand</h3><div id="vhand" class="cardrow">&mdash;</div>
<h3>Department Decks (top card face up)</h3><div id="vdepts" class="cardrow ref">&mdash;</div>
</section>
<section><h2>The Yards</h2>
<h3>Division Yard <span class="dim" id="vdivtot"></span></h3>
<div id="vdivyard" class="yard"></div>
<h3>Classification Yard <span class="dim" id="vclstot"></span></h3>
<div id="vclsyard" class="yard"></div>
</section>
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="vblocked"></ul></section>
<section><h2>Facilities</h2><div id="vfacs"></div></section>
</div>
<section><h2>What happened</h2><div id="vlog"></div></section>
</div>
</div>
</main>
+33 -3
View File
@@ -12,6 +12,8 @@
*/
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts';
import { PANEL_CSS, blockedHtml, facilitiesHtml, handHtml, pilesHtml, yardHtml } from './panels.ts';
import { cuesFor } from '../sim/narrate.ts';
import { playCue } from './sound.ts';
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
@@ -109,8 +111,13 @@ function show(i: number): void {
// whistles, and stepping back would sound a Stage ending that is being un-done.
if (soundOn && at === from + 1) for (const c of step.cues) playCue(c);
$('vclock').textContent = `Day ${f.day} · Stage ${f.stage} — ${f.clock}`;
$('vphase').textContent = f.phase;
// The live game's turn chart, from the shared renderer: same five phases, same violet "you are
// here". A replay used to report the Day and phase as two plain strings, so the one thing a player
// glances at most often looked nothing like it does in play.
// A replay carries the seat names in the frame's own lines rather than a player table, so the
// solitaire seat is named directly; a multi-player replay reports the index it has.
const actorName = f.actor === null ? null : `Player ${f.actor + 1}`;
$('turnchart').innerHTML = turnChartHtml(f, actorName);
$('vrev').textContent = String(f.revenue);
$('vpos').textContent = `${at} / ${steps.length - 1}`;
($('vscrub') as HTMLInputElement).value = String(at);
@@ -139,6 +146,17 @@ function show(i: number): void {
log += `<div class="line t-${l.tone}${k === at ? ' now' : ''}">${esc(l.text)}</div>`;
}
}
// The same side panels the playable page shows, from the same renderers. A replay is the game
// being watched, so it drops only what asks you to act.
$('vhand').innerHTML = handHtml(f);
$('vdepts').innerHTML = pilesHtml(f);
$('vdivyard').innerHTML = yardHtml(f.yards.division);
$('vclsyard').innerHTML = yardHtml(f.yards.classification);
$('vdivtot').textContent = `${f.yards.divisionTotal} cars`;
$('vclstot').textContent = `${f.yards.classificationTotal} cars`;
$('vblocked').innerHTML = blockedHtml(f);
$('vfacs').innerHTML = facilitiesHtml(f);
$('vlog').innerHTML = log;
const logEl = $('vlog');
logEl.scrollTop = logEl.scrollHeight;
@@ -252,6 +270,18 @@ function wire(): void {
show(at + 1);
}, Number(($('vspeed') as HTMLSelectElement).value));
};
/**
* A SPEED CHANGE HAS TO TAKE EFFECT WHILE IT IS PLAYING.
*
* The interval is created with whatever the select held at the moment play started, and nothing
* re-read it — so choosing "extra slow" mid-replay did nothing at all and the pace looked stuck.
* The standalone replay has always restarted its timer on change; this viewer was missed.
*/
($('vspeed') as HTMLSelectElement).onchange = () => {
if (!timer) return;
$('vplay').click();
$('vplay').click();
};
$('vdistricttoggle').onclick = () => {
const f = steps[at]?.frame;
const open = districtMode === 'auto' ? (f ? FOCUS_PHASES.has(f.phaseKey) : true) : districtMode === 'open';
@@ -283,7 +313,7 @@ function wire(): void {
}
const style = document.createElement('style');
style.textContent = BOARD_CSS + TOOLTIP_CSS;
style.textContent = BOARD_CSS + TOOLTIP_CSS + TURNCHART_CSS + PANEL_CSS;
document.head.appendChild(style);
installTooltips();
wire();
+23
View File
@@ -93,6 +93,29 @@ export function playCue(cue: string): void {
return;
}
if (cue === 'couple') {
/**
* KNUCKLE COUPLERS MEETING: a low metallic clank and the slack running out behind it.
*
* Coupling a cut was reported as invisible — the car simply left the board and only history said
* where it went. It is the single most consequential thing a switching move does, so it gets the
* one sound a player will learn to listen for.
*/
for (const fg of [[86, 0.22], [131, 0.13], [197, 0.07], [61, 0.18]]) {
tone(t, fg[0]!, 0.26, fg[1]!, 'square', fg[0]! * 0.82);
}
tone(t + 0.1, 74, 0.34, 0.1, 'triangle', 58);
return;
}
if (cue === 'drop') {
// Setting out is the same act in reverse and quieter: one clank, no slack behind it.
for (const fg of [[104, 0.14], [156, 0.08]]) {
tone(t, fg[0]!, 0.18, fg[1]!, 'square', fg[0]! * 0.85);
}
return;
}
if (cue === 'day') {
// The grade-crossing bell: two strikes. Inharmonic partials separate a bell from a beep.
for (const at of [t, t + 0.22]) {
+24 -1
View File
@@ -24,7 +24,25 @@ function ensure(): HTMLElement {
function place(el: HTMLElement, text: string): void {
const t = ensure();
t.textContent = text;
/**
* A PICTURE WHERE ONE HELPS, otherwise text.
*
* `data-tip-html` carries pre-built markup — a drawing of the track piece a placement would lay,
* rendered by the board's own renderer so it cannot show a shape the placement will not produce.
* Only ever set from markup this app built; nothing here interpolates anything a player typed.
*/
const html = el.getAttribute('data-tip-html');
if (html) {
t.innerHTML = `<div class="tip-fig">${html}</div>`;
if (text) {
const p = document.createElement('div');
p.className = 'tip-txt';
p.textContent = text;
t.appendChild(p);
}
} else {
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
@@ -87,4 +105,9 @@ export const TOOLTIP_CSS = `
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}
/* A drawn piece inside a tooltip. The figure sits above its caption, and the caption keeps the
tooltip's own text treatment — the drawing is the answer, the words are the confirmation. */
#tip .tip-fig{display:flex;gap:6px;justify-content:center;margin:1px 0 6px}
#tip .tip-fig svg{width:132px;height:auto;display:block}
#tip .tip-txt{white-space:pre-wrap}
`;
+16 -5
View File
@@ -254,8 +254,12 @@ describe('replay HTML', () => {
() => run(stub, () => 0, () => undefined),
'the page threw while rendering its first frame',
);
// render() ran, so the clock element should have been filled in.
assert.match(String(els.get('when')?.textContent ?? ''), /Day \d+/);
// render() ran, so the turn chart should have been filled in — with the same Day/Stage/phase
// chart the live game draws, from the shared renderer.
const chart = String(els.get('turnchart')?.innerHTML ?? '');
assert.match(chart, /Day \d+/, 'the turn chart was not rendered');
assert.match(chart, /Stage \d+ of 12/);
assert.match(chart, /class="tc-phase now"/, 'no phase is marked as the current one');
});
it('records what the bot chose, why, and what it passed over', () => {
@@ -394,16 +398,23 @@ describe('the replay behaves like the game it is replaying', () => {
assert.ok(kinds.has('stage'), 'no Stage ever sounded');
assert.ok(kinds.has('day'), 'no Day ever sounded');
assert.ok(kinds.has('train'), 'no train was ever announced');
// Coupling and setting out are what a switching move IS, and both were silent — a car left the
// board and only history said where it went.
assert.ok(kinds.has('couple'), 'coupling a cut never made a sound');
assert.ok(kinds.has('drop'), 'setting cars out never made a sound');
// One cue per Stage boundary, the bell replacing the whistle at a Day — the same accounting the
// live game is held to.
// One CLOCK cue per Stage boundary, the bell replacing the whistle at a Day — the same
// accounting the live game is held to. The switching cues are counted separately: they follow
// what a crew does, not what the clock does.
let boundaries = 0;
for (let k = 1; k < rec.frames.length; k++) {
const a = rec.frames[k - 1]!;
const b = rec.frames[k]!;
if (a.day !== b.day || a.stage !== b.stage) boundaries++;
}
const clock = withCues.flatMap((f) => f.cues ?? []).filter((c) => c !== 'train').length;
const clock = withCues
.flatMap((f) => f.cues ?? [])
.filter((c) => c === 'stage' || c === 'day').length;
assert.equal(clock, boundaries, 'Stage boundaries and Stage-or-Day cues disagree');
});
+43
View File
@@ -901,3 +901,46 @@ describe('a turnout may be laid to face another, making a crossover', () => {
assert.ok(!has(dests, -1, 0), 'and may not stop on the turnout itself');
});
});
// ---------------------------------------------------------------------------
describe('backing up does not turn the train around', () => {
it('keeps the engine pointing the same way through a reverse move', () => {
// REPORTED as forward/reverse being scrambled. `facing` is which way the ENGINE points, and it
// was reset to the direction of travel on EVERY move — so one reverse move silently spun the
// train about, everything read "forward" again, and a run-around became pointless: you could
// change ends for free by backing up twice.
const s = gameWith(
areaFrom(
{
[coordKey(at(0, 0))]: straight(),
[coordKey(at(0, 1))]: straight(),
[coordKey(at(0, 2))]: straight(),
},
at(0, 5),
),
);
const id = 'crew';
s.trays.set(id, {
id, trainNumber: null, trainIsExtra: false, engineAt: 0,
consist: [], direction: 'east', facing: 'e',
position: { at: 'grid', owner: 0, coord: at(0, 1) }, movesUsed: 0,
} as never);
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
s.turn.option = 'switch';
s.turn.movesRemaining = 6;
// Running forward, east: the engine leads, so it still points east.
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 2), reverse: false }).ok);
assert.equal(s.trays.get(id)!.facing, 'e', 'a forward move must leave the engine leading');
// Backing up, west: the engine trails. It is still pointing east.
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: true }).ok);
assert.equal(
s.trays.get(id)!.facing,
'e',
'backing up turned the train around — a run-around would then be pointless',
);
});
});
+378 -4
View File
@@ -240,6 +240,31 @@ describe('the action menu presents choices the way they are made', () => {
assert.equal(f.salvage.top, cardName(game.state, spare), 'the top of the Salvage Yard is not named');
});
it('offers BOTH rotations of a turnout, each with the shape it would lay', () => {
// REGRESSION, and the reason rotating a turnout felt impossible. The action list drops duplicate
// labels, and `describeIntent` for a card play said only "play X at (0, 1)" — so a turnout's two
// orientations produced the same label and the second was discarded before the menu ever saw it.
// The rotation is the entire decision for a turnout, and it could not be made.
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
dealTrack(game, 'turnout', 'right');
const spots = actionMenu(game)
.placeable.flatMap((g) => g.items)
.flatMap((it) => it.spots)
.filter((sp) => sp.coord.row === 0 && sp.coord.col === 1);
assert.equal(spots.length, 2, 'both rotations must be offered on the same square');
assert.ok(spots.some((sp) => /turn to the south/.test(sp.label)), 'the south-diverging rotation is missing');
assert.ok(spots.some((sp) => /turn to the north/.test(sp.label)), 'the north-diverging rotation is missing');
// And each carries the rails it would actually lay, so the UI can draw it rather than only
// describe it. Taken from the engine's connections, so a preview cannot promise a shape the
// placement will not produce.
const shapes = spots.map((sp) => [...sp.links].sort().join('|')).sort();
assert.deepEqual(shapes, ['es|ew', 'we|wn'], `the two rotations do not carry distinct shapes: ${shapes.join(' / ')}`);
});
it('says how deep a Department pile is, so a discard can be aimed', () => {
// A discard goes ON TOP, so choosing where to put it is choosing whether to offer a card or to
// bury one a rival wants. Neither is decidable without seeing what is already stacked up.
@@ -1047,8 +1072,10 @@ describe('the static build', () => {
const actions = els.get('actions')!;
const grid = els.get('grid')!;
const hand = els.get('hand')!;
// Take the draw option, then pick the first placeable subject.
// Take the draw option, then pick a card's PLAY verb — the hand is the action surface now, so
// the subject is chosen on the card rather than in a separate list.
const clickFirst = (el: Record<string, unknown>, sel: string): boolean => {
const fn = el['querySelectorAll'] as (s: string) => Record<string, unknown>[];
const nodes = fn.call(el, sel);
@@ -1059,7 +1086,18 @@ describe('the static build', () => {
};
assert.ok(clickFirst(actions, 'button.act'), 'no action button rendered');
assert.ok(clickFirst(actions, 'button.subj'), 'no card/track subject to pick');
// Specifically the PLAY verb: a hand usually holds cards that can only be discarded, and
// discarding highlights the Department piles rather than the board.
const clickVerb = (el: Record<string, unknown>, verb: string): boolean => {
const fn = el['querySelectorAll'] as (s: string) => Record<string, unknown>[];
const target = fn
.call(el, 'button.cardact')
.find((n) => (n['dataset'] as Record<string, string>)['verb'] === verb);
if (!target) return false;
(target['onclick'] as (() => void) | null)?.();
return true;
};
assert.ok(clickVerb(hand, 'play'), 'no card in hand offers a play verb');
// Without the board stylesheet every shape is drawn black on a near-black background: the page
// looks empty even though the markup is perfect.
@@ -1137,6 +1175,342 @@ describe('the static build', () => {
}
});
it('draws the same turn chart on all three screens', () => {
// A game gets drawn in three places — the playable page, the site's replay viewer and the
// standalone replay file — and where you are in the Day is the thing a player glances at most
// often. It lived only in the playable page: a replay reported the Day and phase as two plain
// strings with none of the violet "you are here" the live game uses, so the same position looked
// like a different game depending on which screen you were on.
//
// ONE renderer, asserted here by source rather than by eye, because three copies of a chart is
// exactly the kind of thing that drifts quietly.
for (const f of ['src/web/main.ts', 'src/web/replays.ts', 'src/sim/replay.ts']) {
const src = readFileSync(join(root, f), 'utf8');
assert.match(src, /turnChartHtml/, `${f} does not use the shared turn chart`);
assert.match(src, /TURNCHART_CSS/, `${f} does not ship the shared turn-chart styling`);
}
// And no screen may keep a private copy of the phase table.
for (const f of ['src/web/main.ts', 'src/web/replays.ts', 'src/sim/replay.ts']) {
const src = readFileSync(join(root, f), 'utf8');
assert.doesNotMatch(src, /key: 'shiftChange'/, `${f} has its own copy of the phase table`);
}
});
it('puts every verb on the card it belongs to', () => {
// THE HAND IS THE ACTION SURFACE. A card used to appear twice under two different models: as a
// subject to play, and as one flat button per Department to discard. For a four-card hand that
// discard block alone was twelve buttons repeating three choices four times.
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
const menu = actionMenu(game);
const held = game.state.decks.hands.get(0) ?? [];
assert.equal(menu.hand.length, held.length, 'every card in hand needs an entry');
for (const h of menu.hand) {
assert.ok(h.name.length > 0, 'a hand entry with no name');
// A verb is only offered when the engine would accept it.
for (let slot = 0; slot < 3; slot++) {
const idx = h.discard[slot];
if (idx === null || idx === undefined) continue;
const i = menu.options[idx]!;
assert.equal(i.type, 'card.discard');
assert.equal((i as { cardId: string }).cardId, h.cardId);
assert.equal((i as { toSlot: number }).toSlot, slot, 'a discard is keyed to the wrong Department');
}
if (h.playNow !== null) {
const i = menu.options[h.playNow]!;
assert.equal(i.type, 'card.play');
assert.equal((i as { placement?: unknown }).placement, undefined, 'playNow must need no square');
}
if (h.placeKey !== null) {
const item = menu.placeable.flatMap((g) => g.items).find((it) => it.subjectKey === h.placeKey);
assert.ok(item, 'placeKey points at no placeable entry');
assert.equal(item!.spots.length, h.spots);
}
}
});
it('previews a track card in hand even when it has nowhere legal to go', () => {
// REPORTED on a left-hand curve, which showed no preview at all. The shape was read off the
// card's legal PLACEMENTS, so a card with no legal square had nothing to draw — and that is
// exactly when a player most wants to see what the piece is.
const game = newGame(775569289);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
const { cardId } = dealTrack(game, 'curved', 'left');
const entry = actionMenu(game).hand.find((h) => h.cardId === cardId);
assert.ok(entry, 'the curve is not in the hand menu');
assert.deepEqual(entry!.shapes, [['sw'], ['ne']], 'both orientations must be previewable');
assert.equal(entry!.spots, 0, 'this is the case worth guarding: nowhere legal to lay it');
});
it('describes a curve as a curve, not as a turnout', () => {
// REPORTED: the curve read "east-west track with a 45° leg", which is a turnout — a road across
// the card plus a leg off it. A curve has ONE road and nothing runs past it, which is precisely
// why it may not be laid in the Running Track.
const game = newGame(775569289);
const { cardId } = dealTrack(game, 'curved', 'left');
const what = cardDescription(game.state, cardId);
assert.doesNotMatch(what, /east-west track with/, 'a curve is still described as a turnout');
assert.match(what, /single road/);
assert.match(what, /no track runs past it/);
// And a turnout must still say it HAS a road across it.
const t = dealTrack(game, 'turnout', 'left');
assert.match(cardDescription(game.state, t.cardId), /east-west track with/);
});
it('names the Mainline card a modifier would change, and what it becomes', () => {
// REPORTED: "Realignment on Mainline card 3" — a raw node index, with no clue which stretch of
// the Division it meant or what it would do, and no tooltip either, because the action list only
// attaches one when the label happens to contain an em-dash.
const game = newGame(775569289);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
for (const [id, c] of game.state.cards) {
if (c.kind.kind === 'mainlineModifier' && c.kind.key === 'realignment') {
game.state.decks.hands.set(0, [id]);
break;
}
}
const menu = actionMenu(game);
const labels = menu.direct
.filter((g) => g.title === 'Mainline modifiers')
.flatMap((g) => g.actions)
.map((a) => a.label);
assert.ok(labels.length > 0, 'Realignment was not offered at all');
for (const l of labels) {
assert.doesNotMatch(l, /Mainline card \d/, `still naming a raw node index: ${l}`);
assert.match(l, / — /, `no tooltip half, so the button gets none: ${l}`);
assert.match(l, /converts it to|nothing here to convert/, `does not say what it does: ${l}`);
}
});
it('never strands the player with a legal move and no way to make it', () => {
// REGRESSION, and a hard softlock. Moving train make-up onto the Division Yard chips took the
// "Making up …" group out of the action list, and the PASS option went with it — so a train
// being made up when the yard held nothing it could take had no control on screen at all.
// Reported at Stage 10 of seed 775569289, Train 10 waiting at the West Division Point.
//
// The invariant is the general one: every option the engine offers must be reachable through
// SOMETHING the page renders. Menu-level, so it holds for every phase rather than the handful a
// click-through happens to visit.
const reachable = (menu: ReturnType<typeof actionMenu>): Set<number> => {
const out = new Set<number>();
for (const g of menu.direct) for (const a of g.actions) out.add(a.index);
for (const g of menu.placeable) for (const it of g.items) for (const sp of it.spots) out.add(sp.index);
for (const h of menu.hand) {
if (h.playNow !== null) out.add(h.playNow);
for (const d of h.discard) if (d !== null && d !== undefined) out.add(d);
}
if (menu.makeUp) {
for (const c of menu.makeUp.cars) out.add(c.index);
if (menu.makeUp.pass !== null) out.add(menu.makeUp.pass);
}
return out;
};
for (const seed of [775569289, 555, 430]) {
const game = newGame(seed);
for (let i = 0; i < 900 && currentActor(game) !== null; i++) {
const menu = actionMenu(game);
if (menu.options.length === 0) break;
const got = reachable(menu);
assert.ok(
got.size > 0,
`seed ${seed}: ${menu.options.length} legal options and not one of them is on screen ` +
`(phase ${game.state.clock.phase}, Day ${game.state.clock.day} Stage ${game.state.clock.stage})`,
);
const { options } = actionGroups(game);
if (options.length === 0 || !submit(game, options[0]!)) break;
}
}
});
it('renders every part of the menu it is given', () => {
// THE CHECK THAT WOULD HAVE CAUGHT THE SOFTLOCK ABOVE. The option was in the menu all along —
// `makeUp.pass` was computed correctly and simply never rendered, so the invariant on the menu
// shape passed while the page stranded the player.
//
// Coarse on purpose: it asks only that every field the menu offers is referenced by the page. A
// field nothing reads is either dead or a control that has gone missing, and both are worth a
// failing test.
const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8');
for (const field of ['makeUp.title', 'makeUp.cars', 'makeUp.pass', 'h.playNow', 'h.discard', 'h.shapes']) {
assert.match(src, new RegExp(field.replace('.', '\\.')), `main.ts never reads menu ${field}`);
}
});
it('leaves the action list to what is not a card in hand', () => {
// Measured across a full game of seed 430: the widest action list was 22 buttons, of which up to
// 12 were the discard cross-product and up to 10 were "add loaded hopper"-style make-up buttons.
// Both now live on the objects already on screen — the card, and the Division Yard chip.
const game = newGame(430);
let worst = 0;
for (let i = 0; i < 400 && currentActor(game) !== null; i++) {
const menu = actionMenu(game);
const shown = menu.direct
.filter((g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && !/^Making up /.test(g.title))
.reduce((n, g) => n + g.actions.length, 0);
worst = Math.max(worst, shown);
const { options } = actionGroups(game);
if (options.length === 0 || !submit(game, options[0]!)) break;
}
assert.ok(worst <= 8, `the action list still reaches ${worst} buttons`);
});
it('keys each make-up car to the yard chip that shows it', () => {
// Ten buttons reading "add loaded hopper" when the Division Yard is already on screen showing
// exactly those cars by type and load state. The yard is the surface.
const game = newGame(430);
for (let i = 0; i < 400 && currentActor(game) !== null; i++) {
const menu = actionMenu(game);
if (menu.makeUp && menu.makeUp.cars.length > 0) {
for (const car of menu.makeUp.cars) {
const intent = menu.options[car.index]!;
assert.equal(intent.type, 'newTrain.placeCar');
assert.equal((intent as { carType: string }).carType, car.carType);
assert.equal((intent as { loaded: boolean }).loaded, car.loaded);
}
assert.match(menu.makeUp.title, /Making up/, 'the make-up panel does not name the train');
return;
}
const { options } = actionGroups(game);
if (options.length === 0 || !submit(game, options[0]!)) break;
}
assert.fail('no train was ever made up, so this proves nothing');
});
it('offers ABS Signals the Mainline, not the Office Area', () => {
// REPORTED: the card says "any Mainline card" and every option offered was a square in the
// Office Area. `checkEnhancementPlacement` reads `placement.col` as a Division NODE index for a
// mainline-card enhancement, while the candidate list handed it occupied grid cells — so "(0,1)"
// was accepted because node 1 happened to be a Mainline card. The label and the meaning were
// different things.
const game = newGame(775569289);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
for (const [id, c] of game.state.cards) {
if (c.kind.kind === 'enhancement' && c.kind.key === 'absSignals') {
game.state.decks.hands.set(0, [id]);
break;
}
}
const spots = actionMenu(game).placeable.flatMap((g) => g.items).flatMap((it) => it.spots);
assert.ok(spots.length > 0, 'ABS Signals was not offered anywhere');
for (const sp of spots) {
assert.match(sp.label, /out on the Mainline/, `not a Mainline card: ${sp.label}`);
const node = game.state.division.nodes[sp.coord.col];
assert.equal(node?.kind, 'mainline', `spot points at a ${node?.kind}, not a Mainline card`);
}
});
it('draws a train standing on a card, in order, with which way it points', () => {
// REPORTED: with a train in the station there was no way to see which cars it held or in what
// order, so the switching game could not be planned at all — "drop 1 car" says nothing when you
// cannot see what is on the back.
const game = newGame(555);
const area = game.state.officeAreas.get(0)!;
const id = 'crew';
game.state.trays.set(id, {
id, trainNumber: 10, trainIsExtra: false, engineAt: 0,
consist: [
{ type: 'tank', loaded: true },
{ type: 'boxcar', loaded: true },
{ type: 'caboose', loaded: false },
],
direction: 'east', facing: 'e',
position: { at: 'grid', owner: 0, coord: { row: area.runningRow, col: 0 } }, movesUsed: 0,
} as never);
const cell = view(game).cells.find((c) => c.row === area.runningRow && c.col === 0)!;
assert.ok(cell.train, 'the card carries no train');
assert.deepEqual(cell.train!.cars, ['loaded tank', 'loaded boxcar', 'caboose'], 'car order is lost');
assert.equal(cell.train!.engineAt, 0, 'the engine has no place in the train');
assert.equal(cell.train!.facing, 'e', 'which way it points is not carried');
const svg = officeSvg([cell], area.runningRow);
assert.match(svg, /bs-t-eng/, 'the engine is not drawn');
assert.match(svg, /bs-t-ld/, 'a loaded car is not drawn');
assert.match(svg, /cab</, 'the caboose is not named on the board');
});
it('shows A/D tracks on the Office card, taken and total', () => {
// REPORTED: the tooltip said "3 A/D tracks" and the card showed nothing — the number that
// decides whether the next arrival is an automatic collision (§8.3).
const game = newGame(555);
const area = game.state.officeAreas.get(0)!;
const cell = view(game).cells.find((c) => c.kind === 'office')!;
assert.ok(cell.ad, 'the Office card carries no A/D count');
assert.equal(cell.ad!.of, 1, 'a Whistle Post has one A/D track');
assert.equal(cell.ad!.used, 0);
assert.match(officeSvg([cell], area.runningRow), /bs-ad/, 'no A/D pips are drawn');
});
it('names the cars a drop would set out, and which end they come off', () => {
// REPORTED: "drop 1 car(s)" — which car? And because a nose drop and a tail drop read
// identically, the action list's duplicate-label filter discarded one of them outright, so
// setting out from the front of the train could not be chosen at all.
const game = newGame(555);
const area = game.state.officeAreas.get(0)!;
const id = 'crew';
game.state.trays.set(id, {
id, trainNumber: null, trainIsExtra: false, engineAt: 1,
consist: [{ type: 'boxcar', loaded: true }, { type: 'caboose', loaded: false }],
direction: 'east', facing: 'e',
position: { at: 'grid', owner: 0, coord: { row: area.runningRow, col: 1 } }, movesUsed: 0,
} as never);
const front = describeIntent(game.state, { type: 'switch.dropCars', trayId: id, count: 1, fromNose: true });
const back = describeIntent(game.state, { type: 'switch.dropCars', trayId: id, count: 1 });
assert.match(front, /loaded boxcar/, `the front cut is not named: ${front}`);
assert.match(front, /off the front/);
assert.match(back, /caboose/, `the back cut is not named: ${back}`);
assert.match(back, /off the back/);
assert.notEqual(front, back, 'the two ends still read identically, so one is dropped from the list');
});
it('draws the side panels from one place, on both screens', () => {
// A replay is the game being WATCHED rather than played, so it should look like the game. The
// site's viewer had three panels against the play page's eight — no facilities, no blockers, no
// cards, no yards — which meant it could not answer "why is nothing moving?", the question a
// replay mostly exists to answer.
for (const f of ['src/web/main.ts', 'src/web/replays.ts']) {
const src = readFileSync(join(root, f), 'utf8');
for (const fn of ['pilesHtml', 'yardHtml', 'blockedHtml', 'facilitiesHtml']) {
assert.match(src, new RegExp(fn), `${f} does not use the shared ${fn}`);
}
assert.match(src, /PANEL_CSS/, `${f} does not ship the shared panel styling`);
}
// The HAND is the one panel that differs, and deliberately: on the play page every card carries
// its own play and discard verbs, while a replay's hand is a read-only row of what was held.
assert.match(readFileSync(join(root, 'src/web/replays.ts'), 'utf8'), /handHtml/);
assert.match(readFileSync(join(root, 'src/web/main.ts'), 'utf8'), /button class="cardact/);
// And the viewer's page must actually have somewhere to put them.
const html = readFileSync(join(dist, 'replays.html'), 'utf8');
for (const id of ['vhand', 'vdepts', 'vdivyard', 'vclsyard', 'vblocked', 'vfacs']) {
assert.match(html, new RegExp(`id="${id}"`), `the replay viewer has no #${id} panel`);
}
});
it('lets a replay change pace while it is playing', () => {
// The interval is created with whatever the speed select held when play started. Nothing re-read
// it in the site's viewer, so choosing "extra slow" mid-replay did nothing at all and the pace
// looked stuck — the standalone replay had always restarted its timer on change.
for (const [file, id] of [['src/web/replays.ts', 'vspeed'], ['src/sim/replay.ts', 'speed']]) {
const src = readFileSync(join(root, file!), 'utf8');
assert.match(
src,
new RegExp(`\\$\\('${id}'\\)[^;]*\\.onchange`),
`${file} never reacts to a speed change`,
);
}
});
it('offers the same five paces in both replay viewers', () => {
const standalone = readFileSync(join(root, 'src/sim/replay.ts'), 'utf8');
const viewer = readFileSync(join(root, 'src/web/replays.html'), 'utf8');
const paces = (src: string): string[] =>
[...src.matchAll(/<option value="\d+"[^>]*>([^<]+)<\/option>/g)].map((m) => m[1]!);
assert.deepEqual(paces(standalone), ['extra slow', 'slow', 'normal', 'fast', 'very fast']);
assert.deepEqual(paces(viewer), paces(standalone), 'the two replay viewers offer different paces');
});
it('gives every phase the engine can be in a pill on the turn chart', () => {
// The turn chart exists to say where in the Stage you are, so a phase with no pill is a hole
// exactly when the player most needs it. `Phase` is a closed union in the engine; this fails if
@@ -1147,12 +1521,12 @@ describe('the static build', () => {
const phases = [...decl![1]!.matchAll(/'([a-zA-Z]+)'/g)].map((m) => m[1]!);
assert.ok(phases.length >= 5, `only found ${phases.length} phases`);
const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8');
const src = readFileSync(join(root, 'src/sim/turnchart.ts'), 'utf8');
for (const p of phases) {
assert.match(src, new RegExp(`key: '${p}'`), `no turn-chart pill for the ${p} phase`);
}
// And every pill must carry a tooltip: an icon alone does not explain a phase.
const tips = [...src.matchAll(/key: '[a-zA-Z]+',\s*\n\s*label: '[^']+',\s*\n\s*tip: '/g)];
const tips = [...src.matchAll(/key: '[a-zA-Z]+',\s*\n\s*label: '[^']+',\s*\n\s*tip: ["']/g)];
assert.equal(tips.length, phases.length, 'a turn-chart pill has no tooltip');
const html = readFileSync(join(dist, 'play.html'), 'utf8');