diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4854ce6..ba117ae 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/TODO.md b/TODO.md
index e9f605f..7866d9b 100644
--- a/TODO.md
+++ b/TODO.md
@@ -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.
diff --git a/src/engine/apply.ts b/src/engine/apply.ts
index 62dedd1..45d6cf8 100644
--- a/src/engine/apply.ts
+++ b/src/engine/apply.ts
@@ -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) {
diff --git a/src/engine/legal.ts b/src/engine/legal.ts
index cf3852f..4c8ffa6 100644
--- a/src/engine/legal.ts
+++ b/src/engine/legal.ts
@@ -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;
diff --git a/src/sim/board-svg.ts b/src/sim/board-svg.ts
index 1ea4ecf..f58a568 100644
--- a/src/sim/board-svg.ts
+++ b/src/sim/board-svg.ts
@@ -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 += `${esc(cell.label)}`;
+
+ /**
+ * 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 += ``;
+ }
+ }
out += `${cell.row},${cell.col}`;
/**
@@ -503,9 +523,35 @@ export function officeSvg(
// card, the old y = H-46 baseline printed the label straight along the rail itself.
out += `${esc(cell.enhancements.join(' · '))}`;
}
- if (cell.tray) {
- out += `` +
- `${esc(cell.tray.split(' ')[0] ?? 'crew')}`;
+ 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 += ``;
+ out += `${esc(t.label)}`;
+ items.forEach((it, n) => {
+ const bx = tx + 28 + n * cw;
+ out += `` +
+ `${esc(it.label)}`;
+ });
+ out += '';
}
out += '';
}
@@ -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}
diff --git a/src/sim/narrate.ts b/src/sim/narrate.ts
index 85d2ef3..a34e55f 100644
--- a/src/sim/narrate.ts
+++ b/src/sim/narrate.ts
@@ -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,
diff --git a/src/sim/replay.ts b/src/sim/replay.ts
index 9cb54f9..c04dfe3 100644
--- a/src/sim/replay.ts
+++ b/src/sim/replay.ts
@@ -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 {
diff --git a/src/web/replays.ts b/src/web/replays.ts
index 62ab5e0..00f6ae5 100644
--- a/src/web/replays.ts
+++ b/src/web/replays.ts
@@ -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 += `
${esc(l.text)}
`;
}
}
+ // 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();
diff --git a/src/web/sound.ts b/src/web/sound.ts
index 29e1548..7f65911 100644
--- a/src/web/sound.ts
+++ b/src/web/sound.ts
@@ -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]) {
diff --git a/src/web/tooltip.ts b/src/web/tooltip.ts
index 77b275b..34989c9 100644
--- a/src/web/tooltip.ts
+++ b/src/web/tooltip.ts
@@ -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 = `
${html}
`;
+ 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}
`;
diff --git a/test/replay.test.ts b/test/replay.test.ts
index 0b5ba4c..707ce9e 100644
--- a/test/replay.test.ts
+++ b/test/replay.test.ts
@@ -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');
});
diff --git a/test/track.test.ts b/test/track.test.ts
index 1703566..ffb6382 100644
--- a/test/track.test.ts
+++ b/test/track.test.ts
@@ -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',
+ );
+ });
+});
diff --git a/test/web.test.ts b/test/web.test.ts
index 7601a83..4584964 100644
--- a/test/web.test.ts
+++ b/test/web.test.ts
@@ -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, sel: string): boolean => {
const fn = el['querySelectorAll'] as (s: string) => Record[];
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, verb: string): boolean => {
+ const fn = el['querySelectorAll'] as (s: string) => Record[];
+ const target = fn
+ .call(el, 'button.cardact')
+ .find((n) => (n['dataset'] as Record)['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): Set => {
+ const out = new Set();
+ 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(/