v0.8.0.10 — playtest fixes: clearance rulings, the log, the map, and a save file

From the first two multiplayer playtests of v0.8.0.9, each traced before fixing.

The engine:

- A train on a card BEHIND the one departing no longer triggers a clearance
  ruling or an opposite-direction bar (#26). Reproduced from the exported
  save: X15 was held over X18 behind it, and X18 then collided into the full
  Whistle Post. Games in progress holding a ruling the engine no longer asks
  for will not resume (28 of 40 recorded four-seat games); shipped as is at
  Jesse's call.
- `mainlineModified` carries the card's previous kind, so the log can say
  what a Realignment converted (#27).

The screen:

- The turn chart and the Division map name the player whose move is on
  screen while bot turns replay, not the live actor (#25).
- The owning player's name is no longer outlined by the turn arrow's stroke,
  which made it unreadable (#24).
- A Mainline card flashes on the map when a Realignment changes it (#28).
- The history is held back with the board and revealed step by step, instead
  of arriving whole while the board is still catching up (#29).
- A ruling made by holding the office reads "Superintendent Player X" (#30),
  and no line names a player twice (#31).
- A seated player can download their own game as a save file: the play
  page's Save replay button, fed by GET /api/save?token=… (#32). The StartOS
  action cannot do this — an action result is text only.

Closes #24
Closes #25
Closes #26
Closes #27
Closes #28
Closes #29
Closes #30
Closes #31
Closes #32

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
This commit is contained in:
Jesse.Markowitz
2026-09-15 22:44:20 -04:00
co-authored by Claude Opus 5
parent 76c6e103b3
commit 4adf149ba5
17 changed files with 607 additions and 41 deletions
+104
View File
@@ -19,6 +19,110 @@ page as `v0.1.0 · <sha> · <date>`, 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 <index>` 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
+1 -1
View File
@@ -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",
+29
View File
@@ -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 });
}
+9 -1
View File
@@ -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 } : {}),
},
];
}
+7 -1
View File
@@ -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. */
+21
View File
@@ -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/<id>/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);
+24 -2
View File
@@ -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 += `<g class="bs-dcell bs-d${c.kind}${full ? ' bs-full' : ''}" data-tip="${esc(c.tip)}">`;
out += `<g class="bs-dcell bs-d${c.kind}${full ? ' bs-full' : ''}${c.flash ? ' bs-changed' : ''}" data-tip="${esc(c.tip)}">`;
out += `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${CH}" rx="5"/>`;
/**
* 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.
+18 -12
View File
@@ -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` };
}
}
+19
View File
@@ -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[] = [];
+15 -2
View File
@@ -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));
+55 -14
View File
@@ -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<void> {
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.
+43 -1
View File
@@ -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<StepQueue, 'behind' | 'busy' | 'showing'>,
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,
};
}
+72 -1
View File
@@ -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',
);
});
});
+38
View File
@@ -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');
});
});
+75 -1
View File
@@ -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');
});
});
+9 -5
View File
@@ -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', () => {
+68
View File
@@ -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(' | ')}`,
);
});
});