From 543584a2caa3929e423ef1b4ff754a34d0388431 Mon Sep 17 00:00:00 2001 From: Jesse Date: Wed, 5 Aug 2026 19:18:26 -0400 Subject: [PATCH] user card Hand becomes the action surface, the board shows the train, and a reverse stops turning it around, other bug fixes too. --- CHANGELOG.md | 192 ++++++++++++++++++ TODO.md | 8 + src/engine/apply.ts | 15 +- src/engine/legal.ts | 16 +- src/sim/board-svg.ts | 68 ++++++- src/sim/narrate.ts | 4 + src/sim/replay.ts | 23 ++- src/sim/turnchart.ts | 126 ++++++++++++ src/sim/view.ts | 186 +++++++++++++++-- src/web/game.ts | 184 ++++++++++++++++- src/web/main.ts | 462 +++++++++++++++++++++++++------------------ src/web/panels.ts | 225 +++++++++++++++++++++ src/web/play.html | 105 +++------- src/web/replays.html | 27 ++- src/web/replays.ts | 36 +++- src/web/sound.ts | 23 +++ src/web/tooltip.ts | 25 ++- test/replay.test.ts | 21 +- test/track.test.ts | 43 ++++ test/web.test.ts | 382 ++++++++++++++++++++++++++++++++++- 20 files changed, 1851 insertions(+), 320 deletions(-) create mode 100644 src/sim/turnchart.ts create mode 100644 src/web/panels.ts 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 { Station Master — replay seed ${rec.seed} @@ -202,12 +153,12 @@ ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:w -
-
Day —Stage ——
-
phase —
-
waiting on —
-
    -
    + +
    + +
    diff --git a/src/web/replays.html b/src/web/replays.html index ccc19a1..ff00893 100644 --- a/src/web/replays.html +++ b/src/web/replays.html @@ -99,22 +99,41 @@ input[type=range]{flex:1;min-width:180px} 0 / 0
    + +
    - — - phase: — Revenue 0 ← → to step, space to play
    +
    -

    The Division

    +

    The Division — west to east

    Office Area

    +

    What happened

    +
    +
    +

    Cards Available

    +

    Cards in Hand

    —
    +

    Department Decks (top card face up)

    —
    +
    +

    The Yards

    +

    Division Yard

    +
    +

    Classification Yard

    +
    +
    +

    Blocked — why nothing is moving

      +

      Facilities

      -

      What happened

      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 { + // 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(/