diff --git a/CHANGELOG.md b/CHANGELOG.md index efeec6c..19a6a472 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,110 @@ page as `v0.1.0 · · `, so what is deployed can always be identifie --- +## 0.8.0.10 — 2026-09-15 + +Three reports from the first multiplayer playtest of v0.8.0.9 — one player and three bots — each traced +to its cause before it was fixed. + +### The Superintendent is no longer asked about a train BEHIND the one departing (Gitea#26) + +*"Two westbound trains: X15 is further west on Mainline cards than X18. And I still get a Superintendent +must rule... held X15 and then collision happened. X18 destroyed, but X15 fine."* + +**Reproduced by replaying the exported save.** At move 78, X15 was highballing west out of seat 3's +Office while X18, also westbound, was still crossing the card to its EAST — behind it. Every Office was a +Whistle Post, so the Subdivision ran the whole railroad, and `evaluateClearance` counted every train in it +without asking which side of the departing train it stood on. The ruling was meaningless; holding X15 +kept the Whistle Post's only A/D track full, and X18 arrived into it and was destroyed. Unasked, X15 — +the lower number, so the first to move — would have left and freed the track. + +§8.1 asks about a train the departing one would FOLLOW and one moving TOWARDS it, and both are ahead of +it. So a train on a card strictly behind the departing train's own card is no longer counted, in either +pass: a following train behind is no concern, and an oncoming one behind is moving away. A train on the +SAME card is still counted, exactly as before — which of two trains sharing a card is in front is +`entryConflict`'s region question. The other two rulings in the save (moves 37 and 530) were correct and +are unchanged. + +A test had the fault written into it: its "train ahead" of an eastbound train was the first Mainline card +in the Division, which is west of the Office. It now stands on a card the train would follow, and three +new cases pin the rule: a same-direction train behind is not put to the Superintendent, an opposite- +direction train behind does not bar the departure, and a same-direction train ahead still is. The tally +test's seed moved from 42 to 44, because the collision seed 42 was chosen for was this bug. + +### Games in progress — MANY WILL NOT RESUME + +**This release changes when the engine asks for a ruling**, so a save holding a ruling it would no longer +ask for stops replaying at that move: `mainline.clearance` is refused with `NO_PENDING_DECISION`. The +server then refuses to resume that game and logs the move it stopped at, leaving the save untouched — +putting v0.8.0.9 back would resume it. **Measured:** 40 four-seat co-op games recorded by bots under +v0.8.0.9 and replayed under this release — 28 stop, usually 5–40% of the way in, most at a ruling on a +train behind (about ten rulings a game were being asked). The exported playtest game stops at move 78. +**Jesse's call (2026-09-15): no games in progress need keeping, so the fix ships as it is,** with no +per-game switch preserving the old rule. + +### Six more from the same playtest (Gitea#27–#32) + +**A Realignment says which card it converted (#27).** *"It stated Mainline card 3 converted to plains. It +should state that the mainline card 3 curves was converted to plains."* The event carried only what the +card became, so the line could not name what it had been; `mainlineModified` now carries `from` as well — +events are derived by replaying a save and never stored, so widening one strands nothing — and it reads +"Realignment: Mainline card 3, Curves, converted to Plains". + +**And the map flashes it (#28).** *"Is it possible to flash the mainline card when it gets changed by +realignment?"* The one play that changes the Division itself was invisible on the map of it. +`changedDivisionCards` compares the two public boards the animation queue already holds — the same way a +pile is lit — so the pulse lands with the step that shows the change rather than when the intent arrived, +and it is shown to the player who made it too, unlike a lit pile. `prefers-reduced-motion` turns it off. + +**The history stops running ahead of the board (#29).** *"Does history display immediately for all bot +turns... is it possible to stall history so it stays in sync with the number behind?"* A push carries its +narration and its steps together, so every line of a bot's turn was in the panel before the board had +drawn any of it. The queue now reports how many lines belong to steps not yet shown, and the panel holds +back exactly those, revealing each as its step goes up. Skip still shows everything at once. + +**A ruling reads as the office's (#30).** *"If a player makes a move as a superintendent instead of as +themselves maybe it could say 'Superintendent Player Tom'."* The three moves made by holding the office +rather than in turn — §8.1's clearance, §11's Yard Office offer, §Q's Red Flag prompt — are prefixed that +way. `clearanceGiven` carries no player at all (the office made it, whoever holds it), so the acting seat +is what names it. + +**And it names them once (#31).** *"It gives the player's name and then their player number together."* +`record()` prefixes the acting player's NAME for every event carrying `player`, and five narration lines +embedded `Player ` themselves — phase ended, actor changed, the Red Flag ruling, the extension vote +and the Yard Office ruling. The name is the log's job; the sentence is the narration's. A test now scans a +played game's whole log for a bare player index. + +**A multiplayer game can be saved as a file (#32).** *"In the StartOS actions for the save game, I get a +string I can copy. Most of the time, I want to just save it as a JSON file."* Not possible in the action +itself: an action result member is text only — copyable, QR or masked, with nested groups — and the SDK +has no file member. So it is done where it can be: the play page's "Save replay" button was shown only in +solitaire, because a server-backed session has no local save to hand it. `GET /api/save?token=…` — the +same seat token `/api/stream` and `/api/intent` use, not the administrative secret — hands a seated player +their own game, and the button now appears in multiplayer. + +### "Waiting on" follows the move on screen (Gitea#25) + +*"Playing against 3 bots — waiting on always says me, even when it is someone else's turn."* + +The server plays every bot move the moment a human's turn ends, so the LIVE game is almost always waiting +on the human — while the screen is still replaying those bots step by step. The turn chart and the +Division map's move marker read the live actor, and contradicted the playback row naming the bot actually +moving. `actorOnScreen` (`web/step-queue.ts`) answers with the player of the step on screen while the +board is catching up, and the live actor once it has; during playback the chart also leaves off a live +"asks … ruling" note that belongs to a position the screen has not reached. Five tests pin it. + +### The name on the Division map is readable again (Gitea#24) + +*"When it shows your player, the font is unreadable... the bold font makes it look fuzzy, and the letters +blur together."* + +A name takes the class `bs-turn` while it is that player's move — and `.bs-turn` is also the Division +map's turn ARROW, which strokes its shape 2.4px grey with no fill. The name's own rule changed only the +fill, so every letter was outlined in grey. It showed for the viewing player almost constantly because of +Gitea#25: the map believed it was nearly always their move. `.bs-name` now sets `stroke:none`. + +--- + ## 0.8.0.9 — 2026-09-15 **The developer bot, re-measured decision by decision — and an engine 2.8× faster.** Across the changes diff --git a/package.json b/package.json index ab44cc7..46c48a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.8.0.9", + "version": "0.8.0.10", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/src/engine/advance.ts b/src/engine/advance.ts index 6bbc645..eca2156 100644 --- a/src/engine/advance.ts +++ b/src/engine/advance.ts @@ -94,6 +94,18 @@ function nodeIndexOfOffice(s: GameState, seat: SeatIndex): number { const step = (d: Direction): number => (d === 'east' ? 1 : -1); +/** The Division node a train is standing on — its Office, its Mainline card, or its Division Point. */ +function nodeIndexOfTray(s: GameState, tray: CrewTray): number | null { + const at = tray.position; + if (at.at === 'grid') return nodeIndexOfOffice(s, at.seat); + if (at.at === 'mainline') return at.index; + if (at.at === 'divisionPoint') { + const side = at.side; + return s.division.nodes.findIndex((n) => n.kind === 'divisionPoint' && n.side === side); + } + return null; +} + // --------------------------------------------------------------------------- // advance // --------------------------------------------------------------------------- @@ -1067,10 +1079,27 @@ function evaluateClearance( * constrained. Each Office upgrade to a Control Point splits one in two and buys capacity. */ const subdivision = subdivisions(s).find((group) => group.includes(targetIndex)) ?? [targetIndex]; + /** + * ONLY WHAT IS AHEAD (Gitea#26). §8.1 asks about a train the considered train would FOLLOW, and one + * moving TOWARDS it — both of which are ahead of it. A Subdivision runs the length of every Whistle + * Post between two Control Points, so it can hold a train BEHIND the one departing: in the reported + * game X15 highballed west from an Office while X18, also westbound, was still crossing the card to its + * east. Counting X18 put a meaningless ruling to the Superintendent; holding X15 kept the Whistle Post's + * one A/D track full, and X18 arrived into it and was destroyed. A train behind and moving away is no + * threat at all. + * + * "Behind" is strictly behind the card the departing train stands on. A train on that same card is still + * counted, exactly as before: which of two trains sharing a card is in front is `entryConflict`'s region + * question, and this is not the place to answer it. + */ + const from = nodeIndexOfTray(s, tray); + const behind = (onCard: number): boolean => + from !== null && from >= 0 && (tray.direction === 'east' ? onCard < from : onCard > from); const occupants: { tray: TrayId; onCard: number }[] = []; for (const i of subdivision) { const n = s.division.nodes[i]; if (!n || n.kind !== 'mainline') continue; + if (behind(i)) continue; for (const t of n.transits) if (t.tray) occupants.push({ tray: t.tray, onCard: i }); } diff --git a/src/engine/apply.ts b/src/engine/apply.ts index 731dd17..74d2b6e 100644 --- a/src/engine/apply.ts +++ b/src/engine/apply.ts @@ -1889,7 +1889,15 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] { ? REALIGNMENTS.find((r) => r.from === node.card)?.to : undefined; return [ - { type: 'mainlineModified', player, cardId: i.cardId, node: i.node, key, ...(became ? { became } : {}) }, + { + type: 'mainlineModified', + player, + cardId: i.cardId, + node: i.node, + key, + ...(node?.kind === 'mainline' ? { from: node.card } : {}), + ...(became ? { became } : {}), + }, ]; } diff --git a/src/engine/events.ts b/src/engine/events.ts index 4b77e04..2c82e20 100644 --- a/src/engine/events.ts +++ b/src/engine/events.ts @@ -87,7 +87,13 @@ export type GameEvent = | { type: 'deckReshuffled'; order: CardId[]; rngState: number } /** `variant` is the chosen orientation (Gap 11); it must be replayable, so it rides the event. */ | { type: 'cardPlayed'; player: PlayerIndex; cardId: CardId; placement?: GridCoord; variant?: number } - | { type: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; became?: string } + /** + * `from` is the card's kind BEFORE the change, carried so the log can say what was realigned + * rather than only what it turned into (playtest, 2026-09-15: "it should state that the mainline + * card 3 curves was converted to plains"). Events are derived by replaying a save, never stored, + * so widening one strands nothing on disk. + */ + | { type: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; from?: string; became?: string } /** §Q (Gitea#19) — a flag planted on one side of a district's Limits. */ | { type: 'redFlagsSet'; player: PlayerIndex; cardId: CardId; seat: SeatIndex; side: Direction } /** §Q (Gitea#19) — the flag stopped a train and came down with it. One card, one train. */ diff --git a/src/server/http.ts b/src/server/http.ts index db28527..807cb38 100644 --- a/src/server/http.ts +++ b/src/server/http.ts @@ -656,6 +656,27 @@ export function startServer(opts: ServerOptions): void { return; } + /** + * THIS SEAT'S OWN GAME, AS A SAVE (playtest, 2026-09-15: "most of the time, I want to go ahead and + * just save it as a JSON file in my Downloads folder"). + * + * The administrative export at `/api/games//save` is gated on the admin secret, which a player + * does not have and should not need: a save is the seed and the moves, and every one of those moves + * is already on this player's screen. So the seat's own session token is the gate, exactly as it is + * for `/api/stream` and `/api/intent` — it proves which game and which chair, and nothing else is + * disclosed. The page turns the JSON into a file (`main.ts`'s `downloadSave`). + */ + if (url.pathname === '/api/save' && req.method === 'GET') { + const ps = sessions.get(url.searchParams.get('token') ?? ''); + const session = ps ? games.get(ps.gameId) : undefined; + if (!ps || !session) { + sendJson(res, 404, { error: 'no such game' }); + return; + } + sendJson(res, 200, { gameId: ps.gameId, save: session.exportSave() }); + return; + } + if (url.pathname === '/api/stream' && req.method === 'GET') { const token = url.searchParams.get('token') ?? ''; const ps = sessions.get(token); diff --git a/src/sim/board-svg.ts b/src/sim/board-svg.ts index 8d5f1cc..d97c693 100644 --- a/src/sim/board-svg.ts +++ b/src/sim/board-svg.ts @@ -42,6 +42,12 @@ export type DivisionRoster = { actor: number | null; /** The player this map is being drawn for. */ viewer: number; + /** + * Division nodes to flash — a Mainline card that has just become a different card (Realignment). + * Playtest, 2026-09-15: the log said a card had been converted and the map said nothing, so the one + * play that changes the Division itself was invisible on the map of it. + */ + flash?: readonly number[]; }; export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | null): string { @@ -134,6 +140,8 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul }[]; cap: number | null; tip: string; + /** Set on a card that has just changed under the players' feet — drawn with a brief pulse. */ + flash?: boolean; /** Which SEAT's district this cell belongs to, or null for Mainline and Division Points. */ seat: number | null; /** Set on an Office cell when a roster was supplied: whose district this is. */ @@ -168,7 +176,11 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul cells.push({ ...c, x: 0, y: 0 }); }; + // The node's own index, so a cell can be matched against `roster.flash`. `continue` below skips the + // rest of the body, never this. + let nodeIndex = -1; for (const n of nodes) { + nodeIndex++; if (n.kind === 'office') { const cap = n.capacity; const ad = n.trains.flat(); @@ -261,6 +273,7 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul push({ kind: dp ? 'dp' : 'ml', label: n.label, + ...(roster?.flash?.includes(nodeIndex) ? { flash: true } : {}), sub: n.capacity === null ? 'no limit — trains queue' : [free, inYard.length > 0 ? `${inYard.length} in the yard` : ''].filter(Boolean).join(' · '), @@ -356,7 +369,7 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul cells.forEach((c) => { const full = c.cap !== null && c.trains.length >= c.cap; - out += ``; + out += ``; out += ``; /** * WHOSE IS IT, IS IT THEIR MOVE, AND IS IT MINE — answered by colour and one suffix rather @@ -1248,6 +1261,12 @@ export const BOARD_CSS = ` and leave at the other, and a seated layout must not be read as a ring. */ .bs-stop line{stroke:#e0a060;stroke-width:2.6;stroke-linecap:round} .bs-end{fill:#e0a060;font:10px ui-monospace,monospace;letter-spacing:.03em} +/* A card that has just BECOME a different card (Realignment). The same amber the rest of the page + spends on "it is happening here", pulsing only while the step that did it is on screen — so the + change is seen on the map rather than only read in the log. */ +.bs-dcell.bs-changed rect{stroke:#e0a060;stroke-width:2.4;animation:bs-changed-pulse 1.1s ease-in-out infinite} +@keyframes bs-changed-pulse{0%,100%{stroke-opacity:1}50%{stroke-opacity:.35}} +@media (prefers-reduced-motion: reduce){.bs-dcell.bs-changed rect{animation:none}} /* The vertical bars a Mainline card is divided into (§2.1). Drawn faint: they are the ruler the train is measured against, not something to look at instead of the train. */ .bs-region{stroke:#4a5361;stroke-width:1.2;stroke-dasharray:3 3} @@ -1329,7 +1348,10 @@ export const BOARD_CSS = ` .bs-arrow{fill:#5f6b7a;font:10px ui-monospace,monospace} .bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace} .bs-coord{fill:#5f6b7a;font:9px ui-monospace,monospace} -.bs-name{fill:#e6e9ee;font:600 11px ui-monospace,monospace} +/* stroke:none (Gitea#24). A name takes the class \`bs-turn\` while it is that player's move, and \`.bs-turn\` is + also the turn ARROW's rule, which strokes its shape 2.4px grey. Declared after it, this keeps that + outline off the letters, which it smeared into an unreadable blur. */ +.bs-name{fill:#e6e9ee;stroke:none;font:600 11px ui-monospace,monospace} .bs-name.bs-you{fill:#5aa9e6} /* Their move — wins over .bs-you when both apply, because whose turn it is changes every few seconds and which railroad is yours never does. diff --git a/src/sim/narrate.ts b/src/sim/narrate.ts index 9131300..ac90659 100644 --- a/src/sim/narrate.ts +++ b/src/sim/narrate.ts @@ -15,7 +15,7 @@ * panel cannot drift from the rules. */ -import { MAX_CONSIST, crewTrayCount } from '../engine/content.ts'; +import { MAINLINE_PROFILES, MAX_CONSIST, crewTrayCount } from '../engine/content.ts'; import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts'; import type { GameState, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from '../engine/state.ts'; import { areaOf, canAdvanceLoad, canBoard, canDetrain, canStartLoad, facilityCarType, facilityCarTypes, freightRuleSpentHere, isFreight, laborersLeft, movesFor, passengerRefusal, portersLeft } from '../engine/apply.ts'; @@ -134,7 +134,7 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration { case 'actorChanged': return { tone: 'quiet', - text: e.player === null ? 'No player acts — automatic phase' : `Player ${e.player} to act`, + text: e.player === null ? 'No player acts — automatic phase' : 'to act', }; // -- local operations @@ -211,13 +211,19 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration { ? `Played ${card(e.cardId)} onto ${at(e.placement)}` : `Played ${card(e.cardId)}`, }; - case 'mainlineModified': + case 'mainlineModified': { + // WHICH CARD, NOT JUST WHICH WAY IT WENT. "Mainline card 3 converted to plains" left the reader + // to remember what card 3 had been (playtest, 2026-09-15), and the card it WAS is the half that + // says what the play was worth. + const kindName = (k: string | undefined): string => + MAINLINE_PROFILES.find((m) => m.kind === k)?.name ?? k ?? 'that card'; return { tone: 'plain', text: e.became - ? `Realignment: Mainline card ${e.node} converted to ${e.became}` - : `Played ${e.key} on Mainline card ${e.node}`, + ? `Realignment: Mainline card ${e.node}, ${kindName(e.from)}, converted to ${kindName(e.became)}` + : `Played ${e.key} on Mainline card ${e.node}${e.from ? ` (${kindName(e.from)})` : ''}`, }; + } case 'redFlagSpent': return { tone: 'good', @@ -225,8 +231,8 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration { }; case 'redFlagRuled': return e.flag - ? { tone: 'plain', text: `Player ${e.player} flagged the approaching train` } - : { tone: 'plain', text: `Player ${e.player} waved the train through` }; + ? { tone: 'plain', text: 'Flagged the approaching train' } + : { tone: 'plain', text: 'Waved the train through' }; case 'redFlagsSet': return { tone: 'good', @@ -496,13 +502,13 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration { ? { tone: 'bad', text: `${e.reason.toUpperCase()} · ${e.delta} Revenue (now ${e.total})` } : { tone: 'good', text: `+${e.delta} Revenue (now ${e.total}) — ${e.reason}` }; case 'phaseEnded': - return { tone: 'quiet', text: `Player ${e.player} finished ${phaseLabel(e.phase)}` }; + return { tone: 'quiet', text: `Finished ${phaseLabel(e.phase)}` }; // -- §3.3, extended play (Gitea#11) case 'extensionVoted': return e.agree - ? { tone: 'plain', text: `Player ${e.player} would play one more Day` } - : { tone: 'plain', text: `Player ${e.player} called time — the game ends here` }; + ? { tone: 'plain', text: 'Would play one more Day' } + : { tone: 'plain', text: 'Called time — the game ends here' }; case 'dayExtended': return { tone: 'clock', text: `── The table plays on: Day ${e.day} is added to the timetable ──` }; case 'playConcluded': @@ -511,8 +517,8 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration { // -- §11, the Yard Office (Gitea#5) case 'yardOfficeRuled': return e.take - ? { tone: 'plain', text: `Player ${e.player} sent ${train(e.trainId)} into the Yard Office` } - : { tone: 'plain', text: `Player ${e.player} kept ${train(e.trainId)} at the Train Order Office` }; + ? { tone: 'plain', text: `Sent ${train(e.trainId)} into the Yard Office` } + : { tone: 'plain', text: `Kept ${train(e.trainId)} at the Train Order Office` }; } } diff --git a/src/sim/public-delta.ts b/src/sim/public-delta.ts index 9df4976..855a3f7 100644 --- a/src/sim/public-delta.ts +++ b/src/sim/public-delta.ts @@ -157,6 +157,25 @@ export type PileKey = 'home' | 'salvage' | `dept${number}`; * | `card.play` | `salvage`, or nothing here when it lands on the board | * | switching, new trains | nothing here — those show on the board itself | */ +/** + * Mainline cards that became a different card between two public boards — a Realignment, which is the + * one play that changes the Division itself. + * + * Playtest, 2026-09-15: *"is it possible to flash the mainline card when it gets changed by realignment? + * This would be more obvious to see what's happening on the map."* Detected the same way `changedPiles` + * detects a pile moving — by comparing the two boards the queue already holds — rather than by reading + * the event, so the flash lands with the step that shows it and not when the intent arrived. + */ +export function changedDivisionCards(before: PublicFrame | null, after: PublicFrame): number[] { + if (before === null) return []; + const out: number[] = []; + after.division.forEach((node, i) => { + const was = before.division[i]; + if (was && was.kind === 'ml' && node.kind === 'ml' && was.label !== node.label) out.push(i); + }); + return out; +} + export function changedPiles(before: PublicFrame | null, after: PublicFrame): PileKey[] { if (before === null) return []; const out: PileKey[] = []; diff --git a/src/web/game.ts b/src/web/game.ts index fbfe14e..fc339cc 100644 --- a/src/web/game.ts +++ b/src/web/game.ts @@ -1255,9 +1255,22 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul const said = blindDraw ? 'Drew a card from the Home Office deck' : n.text; // "Chose to DRAW a card" does not say WHO, which is unreadable the moment there is more than // one seat. Only events the player caused are attributed; the Division running itself is not. + /** + * A RULING IS MADE AS SUPERINTENDENT, NOT AS YOURSELF (playtest, 2026-09-15: "maybe it could say + * 'Superintendent Player Tom', so it's clear they got the move because they're Superintendent"). + * These three are the only moves a player makes out of turn, by holding the office: §8.1's + * clearance, §11's Yard Office offer and §Q's Red Flag prompt. `clearanceGiven` carries no + * player at all — the office made it, whoever holds it — so the actor is what names it. + */ + const RULINGS = ['clearanceGiven', 'yardOfficeRuled', 'redFlagRuled']; + const ruling = RULINGS.includes(e.type) && who !== null; const mine = who !== null && 'player' in e; - const text = mine ? `Player ${who} ${uncapitalise(said)}` : said; - game.log.push({ text, tone: mine ? 'act' : n.tone }); + const text = ruling + ? `Superintendent Player ${who} ${uncapitalise(said)}` + : mine + ? `Player ${who} ${uncapitalise(said)}` + : said; + game.log.push({ text, tone: mine || ruling ? 'act' : n.tone }); } game.cues.push(...cuesFor(events)); diff --git a/src/web/main.ts b/src/web/main.ts index 67415ba..c9dbd7a 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -25,7 +25,7 @@ import type { LocalSession, Session } from './session.ts'; import { createLocalSession, createRemoteSession } from './session.ts'; import type { PlayerIndex } from '../engine/state.ts'; import type { PublicDistrict } from '../sim/view.ts'; -import { createStepQueue } from './step-queue.ts'; +import { actorOnScreen, createStepQueue } from './step-queue.ts'; import { PACE_LEVELS } from '../sim/pacing.ts'; import { notice, prefillCode, runLobby } from './lobby.ts'; import type { LobbyReady } from './lobby.ts'; @@ -493,12 +493,14 @@ function piecePreview(links: string[], label: string): string { * are in the Day the same way and with the same violet highlight. It used to live here alone. */ function renderTurnChart(f: Frame): void { - const actorName = f.actor === null ? null : (f.players[f.actor]?.name ?? null); + // The move on screen, not the live one, while the board is still catching up (Gitea#25). + const { actor, replaying } = actorOnScreen(stepQueue, f.actor); + const actorName = actor === null ? null : (f.players[actor]?.name ?? null); // Named only at a table with more than one seat: in solitaire the Fedora is always yours, and a // chip that can never change is a chip to read past. const superName = f.players.length > 1 ? (f.players.find((p) => p.index === f.superintendent)?.name ?? null) : null; - $('turnchart').innerHTML = turnChartHtml(f, actorName, superName); + $('turnchart').innerHTML = turnChartHtml(replaying ? { ...f, awaiting: null } : f, actorName, superName); } /** @@ -1000,6 +1002,7 @@ function beginRemote(ready: LobbyReady, rejoining = false): void { // banner (`#presence`), and it holds a beat so the game visibly begins. openHandoff(); session = createRemoteSession(ready.token, ready.seat, abandonRemote); + remoteToken = ready.token; rejoiningRemote = rejoining; applyCapabilities(); // A LocalSession has data the instant it is constructed; a RemoteSession does not — its first @@ -1167,6 +1170,14 @@ function start(): void { * the lobby's job. Hidden rather than disabled: a greyed-out Undo in a multiplayer game invites the * question "why not?" every turn, and the honest answer is that the control does not belong there. */ +/** + * The session token of a server-backed game, or null in solitaire (playtest, 2026-09-15: "most of the + * time, I want to go ahead and just save it as a JSON file"). It is the seat's proof of identity to + * `/api/save`, exactly as it is to `/api/stream` — a save is the seed and the moves, every one of which + * is already on this player's screen. + */ +let remoteToken: string | null = null; + function applyCapabilities(): void { const c = session.capabilities; const hide = (id: string, on: boolean): void => { @@ -1174,7 +1185,7 @@ function applyCapabilities(): void { if (el) el.hidden = !on; }; hide('undo', c.undo); - hide('savefile', c.saveLocal); + hide('savefile', c.saveLocal || remoteToken !== null); hide('newgame', c.newGame); // Creating or joining ANOTHER multiplayer game from inside a running one is not a thing this // page offers — same reasoning as `newgame`, and the same capability answers both. @@ -1347,8 +1358,10 @@ function render(): void { // -- division $('division').innerHTML = divisionSvg(f.division, { players: f.players, - actor: f.actor, + actor: actorOnScreen(stepQueue, f.actor).actor, viewer: f.viewer, + // A Realignment changes the Division under everyone; flashed only while the step that did it is up. + flash: stepQueue.busy() ? stepQueue.flashing() : [], }); renderSeatingChain(f); applyZoom($('division')); @@ -1633,7 +1646,16 @@ function render(): void { // -- log const log = $('log'); - const allLines = session.lines(); + /** + * THE LOG IS HELD BACK WITH THE BOARD (playtest, 2026-09-15). + * + * A push carries its narration and its display steps together, so every line of a bot's turn was in + * this panel before the board had drawn a single move of it — the history ran ahead of the "N behind" + * counter it is meant to match. Those lines are the TAIL of the log, so exactly the ones belonging to + * steps still queued are withheld, and each appears as its step goes up. + */ + const heldBack = stepQueue.pendingLines(); + const allLines = heldBack > 0 ? session.lines().slice(0, -heldBack) : session.lines(); const shownLines = allLines.slice(-60); /** * WHERE THE GAME BEGAN. In a multiplayer game the bots move the instant the host presses Start, so @@ -2317,21 +2339,40 @@ function renderActions( * hundred bytes, so a finished game can be emailed or dropped on the site's replay directory — * where a rendered page would have been megabytes. */ -function downloadSave(): void { - // The button this fires from is hidden by `applyCapabilities()` for any session that cannot save - // (`#savefile`), but nothing stops this function being called directly, so the guard is repeated - // here rather than only trusted to the DOM. - if (!isLocal(session)) return; - const data = JSON.stringify(session.save(), null, 1); +function writeFile(name: string, data: string): void { const blob = new Blob([data], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `station-master-seed${session.seed()}-day${session.view().day}.json`; + a.download = name; a.click(); URL.revokeObjectURL(url); } +async function downloadSave(): Promise { + const f = session.view(); + const stamp = `day${f.day}-stage${f.stage}`; + /** + * A SERVER-BACKED GAME HAS NO LOCAL SAVE TO HAND OVER, so it asks the server for its own — the seat's + * token is the gate (`/api/save`), the same one the stream and every intent already use. The StartOS + * Manage Game action cannot do this: an action result is text only, with no file member in the SDK. + */ + if (!isLocal(session)) { + if (remoteToken === null) return; + try { + const res = await fetch(`/api/save?token=${encodeURIComponent(remoteToken)}`); + if (!res.ok) return; + const body = (await res.json()) as { save: unknown }; + writeFile(`station-master-${stamp}.json`, JSON.stringify(body.save, null, 1)); + } catch { + // Offline, or the game has been ended under us: the button simply does nothing, which is the + // same thing every other server call on this page does when the server is not there. + } + return; + } + writeFile(`station-master-seed${session.seed()}-${stamp}.json`, JSON.stringify(session.save(), null, 1)); +} + function save(): void { if (!isLocal(session)) return; try { @@ -2367,7 +2408,7 @@ document.head.appendChild(pageStyle); installTooltips(); const saveBtn = document.getElementById('savefile'); -if (saveBtn) saveBtn.onclick = downloadSave; +if (saveBtn) saveBtn.onclick = () => void downloadSave(); /** * Forget the saved game and deal a fresh one. diff --git a/src/web/step-queue.ts b/src/web/step-queue.ts index 42b927a..e51d340 100644 --- a/src/web/step-queue.ts +++ b/src/web/step-queue.ts @@ -19,7 +19,7 @@ import type { PublicFrame } from '../sim/view.ts'; import type { DisplayStep } from '../sim/display-step.ts'; -import { applyPublicDelta, changedPiles } from '../sim/public-delta.ts'; +import { applyPublicDelta, changedDivisionCards, changedPiles } from '../sim/public-delta.ts'; import type { PileKey } from '../sim/public-delta.ts'; import { dwellForStep } from '../sim/pacing.ts'; @@ -53,8 +53,43 @@ export type StepQueue = { lit(): readonly PileKey[]; /** True while there is anything left to show. */ busy(): boolean; + /** + * How many narrated lines belong to steps NOT yet shown. + * + * The log and the board are two different moments while the queue is behind: a push carries its + * narration and its steps together, so every line of a bot's turn is in the history panel before the + * board has drawn a single move of it (playtest, 2026-09-15: *"is it possible to stall history so it + * stays in sync with the number behind?"*). Those lines are the TAIL of the log — they arrived last — + * so the caller holds back exactly this many and reveals each as its step goes up. + */ + pendingLines(): number; + /** Division nodes whose card changed in the step now on screen, for the map to flash. */ + flashing(): readonly number[]; }; +/** + * WHOSE MOVE THE SCREEN IS SHOWING (Gitea#25). + * + * The game and the board on screen are two different moments. The server plays every bot move the + * instant a human's turn ends (`driveBots`), so the LIVE game is nearly always waiting on the human — + * while this queue is still replaying the bots, step by step. The turn chart and the Division map's + * move marker read the live actor, so a table of one person and three bots said "waiting on" that + * person throughout, against a playback row naming the bot actually moving. + * + * While the queue is behind or still showing a step, the answer is that step's player — `null` for an + * automatic phase, which is "the Division is running itself". Otherwise it is the live actor, and + * `replaying` is false so a caller can keep live-only detail, such as a ruling the game is waiting on, + * off a screen that has not caught up with it yet. + */ +export function actorOnScreen( + queue: Pick, + live: number | null, +): { actor: number | null; replaying: boolean } { + if (queue.behind() === 0 && !queue.busy()) return { actor: live, replaying: false }; + const shown = queue.showing(); + return shown === null ? { actor: live, replaying: false } : { actor: shown.player, replaying: true }; +} + /** * `pace` is read on every step rather than captured, so changing the setting takes effect at once. * @@ -74,6 +109,7 @@ export function createStepQueue( let shown: PublicFrame | null = null; let last: DisplayStep | null = null; let litPiles: readonly PileKey[] = []; + let flashedCards: readonly number[] = []; let pending: DisplayStep[] = []; /** When the step now on screen is due to give way. Null when nothing is waiting. */ let dueAt: number | null = null; @@ -92,6 +128,9 @@ export function createStepQueue( * that gives your own steps no dwell — the display is for watching everybody else. */ litPiles = step.player !== null && step.player === viewer() ? [] : changedPiles(before, shown); + // A Realignment changes the Division under everyone, so it is flashed for the player who did it + // too — unlike a pile, which only tells the drawer what they already know. + flashedCards = changedDivisionCards(before, shown); }; return { @@ -101,6 +140,7 @@ export function createStepQueue( dueAt = null; // Nothing was watched arriving at this board, so nothing on it is lit. litPiles = []; + flashedCards = []; // `last` deliberately survives: a reconnect should not blank the caption line, and the // sentence describing the most recent action is still true. }, @@ -165,6 +205,8 @@ export function createStepQueue( * `dueAt` is non-null exactly while the step on screen has time left, so the two together mean * "there is more to come, or what is up has not had its moment yet". */ + pendingLines: () => pending.reduce((n, s) => n + s.lines.length, 0), + flashing: () => flashedCards, busy: () => pending.length > 0 || dueAt !== null, }; } diff --git a/test/advance.test.ts b/test/advance.test.ts index bff91fc..51c816f 100644 --- a/test/advance.test.ts +++ b/test/advance.test.ts @@ -1141,7 +1141,12 @@ describe('the history says WHY a train moved, and says it truthfully', () => { // A train ahead of it in the same Subdivision, running the SAME way — §8.1's fourth condition, // which is the Superintendent's call rather than an absolute bar. - const ahead = s.division.nodes.findIndex((n) => n.kind === 'mainline'); + // + // AHEAD MEANS EAST OF THE OFFICE for this eastbound train. This used to take the FIRST Mainline card + // in the Division, which is west of the Office — behind the train — and still expected a ruling, + // which is exactly the fault Gitea#26 reported. The card is now one the train would actually follow. + const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0); + const ahead = s.division.nodes.findIndex((n, i) => i > office && n.kind === 'mainline'); const node = s.division.nodes[ahead]; assert.equal(node?.kind, 'mainline'); s.trays.set('ahead', { @@ -1511,3 +1516,69 @@ describe('an Extra starts where the player puts it (Gitea#4)', () => { assert.equal(check(s, 0, at), 'NO_EXTRA_PENDING'); }); }); + +// --------------------------------------------------------------------------- + +describe('§8.1 counts only trains AHEAD of the one departing (Gitea#26)', () => { + /** + * REPORTED from playtesting v0.8.0.9: two westbound Extras, X15 at an Office and X18 still crossing + * the card to its EAST. The Superintendent was asked to rule on X15 against X18 — a train behind it — + * and holding X15 kept the Whistle Post's only A/D track full, so X18 arrived into it and was + * destroyed. Reproduced by replaying the exported save; the positions below are that situation in a + * one-seat Division, where every Office is a Whistle Post and the Subdivision spans them all. + */ + const setup = (occupant: { direction: 'east' | 'west'; side: 'east' | 'west'; number: number }) => { + const s = game(7, { days: 5 }); + const area = areaOf(s, 0); + const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0); + s.trays.set('departing', { + id: 'departing', trainNumber: 15, trainIsExtra: true, engineAt: 0, consist: [], + direction: 'west', facing: 'w', + position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0, + }); + area.adOccupancy.push('departing'); + const card = s.division.nodes.findIndex((n, i) => + n.kind === 'mainline' && (occupant.side === 'east' ? i > office : i < office)); + const node = s.division.nodes[card]; + assert.equal(node?.kind, 'mainline'); + s.trays.set('other', { + id: 'other', trainNumber: occupant.number, trainIsExtra: true, engineAt: 0, consist: [], + direction: occupant.direction, facing: occupant.direction === 'east' ? 'e' : 'w', + position: { at: 'mainline', index: card }, movesUsed: 0, + }); + if (node?.kind === 'mainline') { + node.transits.push({ tray: 'other', stagesRemaining: 2, stagesTotal: 2, direction: occupant.direction }); + } + s.clock.phase = 'mainline'; + return s; + }; + + it('does not put a same-direction train BEHIND the departing one to the Superintendent', () => { + const s = setup({ direction: 'west', side: 'east', number: 18 }); + const r = advance(s); + assert.ok(!r.events.some((e) => e.type === 'clearanceRequested'), 'a train behind was put to the Superintendent'); + assert.ok( + r.events.some((e) => e.type === 'trainHighballed' && e.trainNumber === 15), + 'the departing train did not highball with nothing ahead of it', + ); + assert.ok(!r.events.some((e) => e.type === 'trainsDestroyed'), 'a train was destroyed'); + }); + + it('does not bar a departure over an opposite-direction train BEHIND it, which is moving away', () => { + const s = setup({ direction: 'east', side: 'east', number: 18 }); + const r = advance(s); + assert.ok( + r.events.some((e) => e.type === 'trainHighballed' && e.trainNumber === 15), + 'a train moving away behind it held the departure', + ); + }); + + it('still puts a same-direction train AHEAD to the Superintendent', () => { + const s = setup({ direction: 'west', side: 'west', number: 18 }); + const r = advance(s); + assert.ok( + r.events.some((e) => e.type === 'clearanceRequested' && e.trainId === 'departing'), + 'a train the departing one would follow was not put to the Superintendent', + ); + }); +}); diff --git a/test/public-delta.test.ts b/test/public-delta.test.ts index e3c6a06..02c473a 100644 --- a/test/public-delta.test.ts +++ b/test/public-delta.test.ts @@ -254,3 +254,41 @@ describe('which piles a step moved', () => { assert.deepEqual(changedPiles(null, publicSnapshot(s)), []); }); }); + +describe('a Mainline card that changed under the players (Gitea#28)', () => { + it('names the node a Realignment converted, and nothing else', async () => { + const { changedDivisionCards } = await import('../src/sim/public-delta.ts'); + const { REALIGNMENTS } = await import('../src/engine/content.ts'); + + const s = newState(4242); + const before = publicSnapshot(s); + + // Realignment converts a card to another kind (`content.ts`'s table). Applied to the state directly: + // what is being tested is the DETECTOR, not the play that reaches it — which needs the card in hand, + // the draw option taken and no train on the card. + const at = s.division.nodes.findIndex((n) => n.kind === 'mainline' && REALIGNMENTS.some((r) => r.from === n.card)); + assert.ok(at >= 0, 'no Mainline card in this Division can be realigned at all'); + const node = s.division.nodes[at]; + assert.equal(node?.kind, 'mainline'); + if (node?.kind === 'mainline') { + node.card = REALIGNMENTS.find((r) => r.from === node.card)!.to; + } + const after = publicSnapshot(s); + + assert.deepEqual(changedDivisionCards(before, after), [at], 'the realigned card was not the one reported'); + assert.deepEqual(changedDivisionCards(after, after), [], 'an unchanged Division reported a change'); + assert.deepEqual(changedDivisionCards(null, after), [], 'a first board has nothing to compare against'); + }); + + it('says nothing when only the trains on a card moved', async () => { + const { changedDivisionCards } = await import('../src/sim/public-delta.ts'); + const s = newState(1917398); + const before = publicSnapshot(s); + const at = s.division.nodes.findIndex((n) => n.kind === 'mainline'); + const node = s.division.nodes[at]; + if (node?.kind === 'mainline') { + node.transits.push({ tray: 'tray1', stagesRemaining: 1, stagesTotal: 1, direction: 'east' }); + } + assert.deepEqual(changedDivisionCards(before, publicSnapshot(s)), [], 'a train arriving flashed the card'); + }); +}); diff --git a/test/step-queue.test.ts b/test/step-queue.test.ts index dd1e743..d186b39 100644 --- a/test/step-queue.test.ts +++ b/test/step-queue.test.ts @@ -15,7 +15,7 @@ import { currentActor, newMultiplayerGame, submit } from '../src/web/game.ts'; import { publicSnapshot } from '../src/sim/view.ts'; import { takeSteps } from '../src/sim/display-step.ts'; import type { DisplayStep } from '../src/sim/display-step.ts'; -import { createStepQueue } from '../src/web/step-queue.ts'; +import { actorOnScreen, createStepQueue } from '../src/web/step-queue.ts'; import { DWELL } from '../src/sim/pacing.ts'; const config: GameConfig = { @@ -278,3 +278,77 @@ describe('the step queue', () => { assert.equal(q.showing(), null); }); }); + +describe('whose move the screen is showing (Gitea#25)', () => { + const step = (player: number | null) => ({ player }) as DisplayStep; + const queue = (behind: number, busy: boolean, showing: DisplayStep | null) => ({ + behind: () => behind, + busy: () => busy, + showing: () => showing, + }); + + it('names the live actor once the board has caught up', () => { + assert.deepEqual(actorOnScreen(queue(0, false, step(2)), 0), { actor: 0, replaying: false }); + }); + + it('names the player of the step on screen while the board is behind — not the live actor', () => { + // One human (seat 0) against bots: the live game already waits on seat 0 while bot 2's moves replay. + assert.deepEqual(actorOnScreen(queue(3, true, step(2)), 0), { actor: 2, replaying: true }); + }); + + it('keeps naming the last step while it is still on screen, after the counter reaches zero', () => { + assert.deepEqual(actorOnScreen(queue(0, true, step(1)), 0), { actor: 1, replaying: true }); + }); + + it('names nobody for an automatic phase being shown', () => { + assert.deepEqual(actorOnScreen(queue(2, true, step(null)), 0), { actor: null, replaying: true }); + }); + + it('falls back to the live actor before any step has been shown', () => { + assert.deepEqual(actorOnScreen(queue(1, true, null), 3), { actor: 3, replaying: false }); + }); +}); + +describe('the log is held back with the board, and a changed card flashes (playtest, 2026-09-15)', () => { + it('owes exactly the lines of the steps not yet shown', () => { + const { steps } = realSteps(1917398, 400); + const q = createStepQueue(); + q.reset(baseline(1917398)); + assert.equal(q.pendingLines(), 0, 'an empty queue holds nothing back'); + + q.push(steps); + const owed = steps.reduce((n, s) => n + s.lines.length, 0); + assert.equal(q.pendingLines(), owed, 'every queued step still owes its lines'); + + // Drive the clock as a render loop would; the debt falls monotonically and ends at nothing. + let now = 0; + let last = owed; + for (let i = 0; i < 20_000 && q.busy(); i++) { + q.advance(now); + const left = q.pendingLines(); + assert.ok(left <= last, 'the held-back count grew while the board caught up'); + last = left; + now += 50; + } + assert.equal(q.pendingLines(), 0, 'the board caught up but lines were still withheld'); + }); + + it('skipping reveals the whole log at once', () => { + const { steps } = realSteps(1917398, 400); + const q = createStepQueue(); + q.reset(baseline(1917398)); + q.push(steps); + q.skip(); + assert.equal(q.pendingLines(), 0, 'Skip left lines withheld — the history would stay short'); + }); + + it('flashes nothing on an ordinary step', () => { + // Realignment is rare in bot play, so this pins the quiet case: the map must not pulse at random. + const { steps } = realSteps(1917398, 400); + const q = createStepQueue(); + q.reset(baseline(1917398)); + q.push(steps.slice(0, 5)); + q.advance(0); + assert.deepEqual(q.flashing(), [], 'a step that changed no Mainline card flashed one'); + }); +}); diff --git a/test/tally.test.ts b/test/tally.test.ts index f6ff3a5..f54c815 100644 --- a/test/tally.test.ts +++ b/test/tally.test.ts @@ -151,10 +151,14 @@ describe('the tally counts every event exactly once (Gitea#16)', () => { it('splits Revenue into what was earned and what was given back', () => { // Reconciliation is the real assertion and it holds for any game, earned or not: gained minus - // lost IS the score the engine kept. Seed 42 is named because it is one where Revenue actually + // lost IS the score the engine kept. Seed 44 is named because it is one where Revenue actually // moves in both directions — it earns 1 and gives back 5 to a collision — so the two halves are // being told apart rather than both sitting at zero. - for (const seed of [1, 7, 42]) { + // + // It was seed 42 until v0.8.0.10. That game's collision was the Superintendent holding a train over + // one BEHIND it (Gitea#26); with the ruling gone the collision is too, and seed 42 now earns 5 and + // loses nothing — a better game and a vacuous test. The seed moved, not the assertion. + for (const seed of [1, 7, 44]) { const { state } = playKeepingEvents(seed); const me = state.tally.byPlayer[0]!; assert.equal( @@ -163,10 +167,10 @@ describe('the tally counts every event exactly once (Gitea#16)', () => { `seed ${seed}: gained minus lost does not reconcile with the score the engine kept`, ); } - const { state } = playKeepingEvents(42); + const { state } = playKeepingEvents(44); const me = state.tally.byPlayer[0]!; - assert.ok(me.revenueGained > 0, 'seed 42 earned nothing — the gained half is not being counted'); - assert.ok(me.revenueLost > 0, 'seed 42 lost nothing — the lost half is not being counted'); + assert.ok(me.revenueGained > 0, 'seed 44 earned nothing — the gained half is not being counted'); + assert.ok(me.revenueLost > 0, 'seed 44 lost nothing — the lost half is not being counted'); }); it('records a Circus set-up as the one-off it is, not as a streak', () => { diff --git a/test/watchable.test.ts b/test/watchable.test.ts index d5780ac..546f2fc 100644 --- a/test/watchable.test.ts +++ b/test/watchable.test.ts @@ -309,3 +309,71 @@ describe('steps reach a seated player — TODO #13', () => { assert.ok(seen > 0, 'no steps reached a push, so this proved nothing'); }); }); + +describe('the log says who acted, once, and in what capacity (Gitea#30, #31)', () => { + it('never names a player twice — no "Player Jesse player 0 finished …"', async () => { + const { newMultiplayerGame, currentActor, submit } = await import('../src/web/game.ts'); + const { legalActions } = await import('../src/engine/legal.ts'); + + const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']); + for (let i = 0; i < 400; i++) { + const actor = currentActor(game); + if (actor === null) break; + const options = legalActions(game.state, actor); + if (options.length === 0) break; + if (!submit(game, options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break; + } + + assert.ok(game.log.length > 50, 'the game barely ran, so this proved little'); + for (const line of game.log) { + // `record()` prefixes the acting player's NAME; a narration that also named them read + // "Player Alice player 0 finished Local Operations" (playtest, 2026-09-15). + assert.doesNotMatch( + line.text, + /\bplayer \d+\b/i, + `a line still carries a bare player index: ${line.text}`, + ); + } + }); + + it('attributes a clearance ruling to the office, not to the seat\'s own turn', async () => { + const { newMultiplayerGame, drain, submit } = await import('../src/web/game.ts'); + const { areaOf } = await import('../src/engine/apply.ts'); + + const game = newMultiplayerGame(7, config, ['Alice', 'Bob', 'Carol']); + const s = game.state; + const area = areaOf(s, 0); + + // A westbound train at seat 0's Office, and another westbound AHEAD of it — west of the Office — + // which is §8.1's fourth condition and the Superintendent's to rule on (see Gitea#26). + s.trays.set('departing', { + id: 'departing', trainNumber: 15, trainIsExtra: true, engineAt: 0, consist: [], + direction: 'west', facing: 'w', + position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0, + } as never); + area.adOccupancy.push('departing'); + const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0); + const card = s.division.nodes.findIndex((n, i) => i < office && n.kind === 'mainline'); + const node = s.division.nodes[card]; + assert.equal(node?.kind, 'mainline'); + s.trays.set('ahead', { + id: 'ahead', trainNumber: 9, trainIsExtra: false, engineAt: 0, consist: [], + direction: 'west', facing: 'w', position: { at: 'mainline', index: card }, movesUsed: 0, + } as never); + if (node?.kind === 'mainline') { + node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'west' }); + } + + s.clock.phase = 'mainline'; + drain(game); + assert.equal(s.clock.pendingDecision?.kind, 'clearance', 'no ruling was called for, so nothing was tested'); + + const before = game.log.length; + assert.ok(submit(game, { type: 'mainline.clearance', allow: false }, s.clock.superintendent)); + const said = game.log.slice(before).map((l) => l.text); + assert.ok( + said.some((text) => text.startsWith('Superintendent Player ')), + `a ruling did not read as the office's: ${said.join(' | ')}`, + ); + }); +});