diff --git a/src/engine/advance.ts b/src/engine/advance.ts index abd8fa3..93b9922 100644 --- a/src/engine/advance.ts +++ b/src/engine/advance.ts @@ -36,10 +36,11 @@ import type { Direction, MainlineEntry, MainlineKind } from './content.ts'; import type { GameEvent } from './events.ts'; // `trainNeedingCars` lives in apply.ts beside `check`'s copy of the same question, so the phase and // the legality test cannot disagree about which train is being assembled. -import { areaAtSeat, areaOf, trainNeedingCars } from './apply.ts'; +import { areaAtSeat, areaOf, occupancyFor, trainNeedingCars } from './apply.ts'; import { legalActions } from './legal.ts'; -import type { CrewTray, DivisionNode, GameState, Outcome, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts'; -import { cloneTally, coordKey, freshTurns, isExtendable, playerAtSeat, playerLeftOf, pooled, subdivisions, totalRevenue, turnOf } from './state.ts'; +import type { CrewTray, DivisionNode, GameState, GridCoord, Outcome, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts'; +import { cloneTally, coordKey, freshTurns, isExtendable, playerAtSeat, playerLeftOf, pooled, railFacingOf, subdivisions, totalRevenue, turnOf } from './state.ts'; +import { reachableDestinations } from './track.ts'; import { tallyEvent } from './tally.ts'; export type AdvanceResult = { @@ -965,6 +966,18 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[] // Off the end of the card: into the adjoining Limit, then straight to the Office (§8.2). const target = index + dir; const dest = s.division.nodes[target]; + + /** + * §11 (Gitea#5) — the Yard Office offer is put BEFORE the train leaves the Mainline card, for + * the same reason §8.1's clearance is: `needsClearance` unwinds the whole phase and the driver + * re-enters here from the top, so anything already mutated is mutated twice or, worse, left + * half-applied. Asking after the `transits` filter below cost the train its place on the card + * and it was never seen again — the question was asked and the answer had nowhere to land. + */ + if (dest?.kind === 'office' && yardOfficeQuestion(s, id, tray, dest.seat, events) === 'ask') { + return 'needsClearance'; + } + node.transits = node.transits.filter((t) => t.tray !== id); // Red Flags protect a train while it is stopped here; once it rolls, the flags come in. if (node.redFlagged) node.redFlagged = node.redFlagged.filter((t) => t !== id); @@ -1003,10 +1016,10 @@ function evaluateClearance( ): 'clear' | 'blocked' | 'ask' { // A ruling already given for this train is consumed here — this is what stops the driver from // re-asking the same question every time it re-evaluates the train. - const ruling = s.clock.clearanceRuling; - if (ruling && ruling.train === id) { - s.clock.clearanceRuling = null; - return ruling.allow ? 'clear' : 'blocked'; + const answer = s.clock.decisionAnswer; + if (answer && answer.kind === 'clearance' && answer.train === id) { + s.clock.decisionAnswer = null; + return answer.allow ? 'clear' : 'blocked'; } const node = s.division.nodes[targetIndex]; @@ -1102,13 +1115,114 @@ function evaluateClearance( } // Same direction — the Superintendent must rule (§8.1, fourth condition). - s.clock.pendingDecision = { train: id, occupiedBy: other }; + s.clock.pendingDecision = { kind: 'clearance', train: id, occupiedBy: other }; events.push({ type: 'clearanceRequested', trainId: id, occupiedBy: other }); return 'ask'; } return 'clear'; } +/** + * CAN THIS TRAIN REACH THE YARD OFFICE, AND IS THE LEAD CLEAR? (Gitea#5) + * + * Three answers, because Jesse's ruling (2026-08-29) splits two failures his issue describes + * separately: "if the Yard Office is not accessible in one move, you should not get the option" + * and "cars on the tracks you use to get in result in a crash". + * + * - `clear` — a route exists and nothing is standing on it. Offer it; taking it is safe. + * - `fouled` — a route exists and there are cars on it. Offer it; taking it collides. + * - `none` — no route in one move. Do not offer it, and say why in the history. + * + * WALKED WITH THE ENGINE'S OWN MOVE RULES rather than a bespoke adjacency test. `exploreMoves` + * already means exactly what the card's "in one move" means — any distance without changing + * direction, finishing on Operational Rail (§2.4, §A.1) — so using it is what makes the code and + * the card agree, which was the whole complaint. + * + * THE FOULING SIGNAL IS `couples`. The walk does not treat standing cars as obstructions: it + * COUPLES them, because that is what a switching move does (§A.4). An arriving train is not + * switching, so anything it would have coupled is instead something it is about to hit — the same + * reading §8.3 already applies to the Running Track. + * + * The walk starts at the Office square, where a standard arrival puts the train, and leaves by the + * way the train is already facing. Reversing is a separate Move (§A.5), so a Yard Office that can + * only be reached by backing up is correctly "not in one move". + */ +/** + * §11 (Gitea#5) — should the district's owner be asked about the Yard Office, and is there + * anything to ask about? + * + * Returns `ask` only when the offer is real: a coachless train, a Yard Office card in the district, + * and a route to it in one move. Everything else is `proceed`, which means the ordinary arrival. + * + * ALSO THE PLACE THE HISTORY LEARNS WHY NOT. Jesse, 2026-08-29: "make sure this is logged in + * history — why can't move so user knows why they can't get to yard." A qualifying train that is + * simply never offered the choice looks exactly like the feature being broken, which is how the + * missing reachability check went unnoticed for so long. + */ +function yardOfficeQuestion( + s: GameState, + id: TrayId, + tray: CrewTray, + seat: SeatIndex, + events: GameEvent[], +): 'ask' | 'proceed' { + // Already answered: `arriveAtOffice` consumes it. Asking again would loop the phase for ever. + const answer = s.clock.decisionAnswer; + if (answer && answer.kind === 'yardOffice' && answer.train === id) return 'proceed'; + + if (tray.consist.some((c) => c.type === 'coach')) return 'proceed'; + const area = areaAtSeat(s, seat); + if (![...area.grid.values()].some((c) => c.enhancements.includes('yardOffice'))) return 'proceed'; + + const route = yardOfficeRoute(s, seat, id, tray); + if (route.kind === 'none') { + events.push({ + type: 'trainDiverted', + trainNumber: tray.trainNumber ?? 0, + to: 'the Office', + reason: `the Yard Office could not be offered — ${route.why}`, + }); + return 'proceed'; + } + + s.clock.pendingDecision = { kind: 'yardOffice', train: id, seat }; + return 'ask'; +} + +type YardOfficeRoute = + | { kind: 'clear' | 'fouled'; coord: GridCoord } + | { kind: 'none'; why: string }; + +function yardOfficeRoute(s: GameState, seat: SeatIndex, id: TrayId, tray: CrewTray): YardOfficeRoute { + const area = areaAtSeat(s, seat); + const target = [...area.grid.entries()].find(([, card]) => card.enhancements.includes('yardOffice')); + if (!target) return { kind: 'none', why: 'there is no Yard Office in this district' }; + const [key] = target; + const [row, col] = key.split(',').map(Number); + const coord = { row: row!, col: col! }; + + const player = playerAtSeat(s, seat); + const facing = railFacingOf(tray); + const found = reachableDestinations( + { + area, + occupancy: occupancyFor(s, player, id), + consistSize: tray.consist.length, + self: id, + }, + area.officeCoord, + facing, + ).find((d) => d.coord.row === coord.row && d.coord.col === coord.col); + + if (!found) { + return { + kind: 'none', + why: 'it cannot be reached from the Office in one move, running the way this train is facing', + }; + } + return { kind: found.couples.length > 0 ? 'fouled' : 'clear', coord }; +} + /** * §8.3 — arriving at an Office. Collisions here are AUTOMATIC (Gap 2a): if the trigger holds, * the collision happens, with no die roll and no judgment. @@ -1125,22 +1239,51 @@ function arriveAtOffice( const hasEnhancement = (key: string): boolean => [...area.grid.values()].some((c) => c.enhancements.includes(key)); - // Yard Office — "an inbound train with NO COACHES that can make a single move to the yard office - // track may arrive there, not at the Train Order Office". It sidesteps the A/D track entirely. - const noCoaches = !tray.consist.some((c) => c.type === 'coach'); - if (noCoaches && hasEnhancement('yardOffice')) { - for (const [key, card] of area.grid) { - if (!card.enhancements.includes('yardOffice')) continue; - const [row, col] = key.split(',').map(Number); - tray.position = { at: 'grid', seat, coord: { row: row!, col: col! } }; + /** + * §11, THE YARD OFFICE (Gitea#5) — offered, not imposed. + * + * "Trains that are only freight (cabooses ok, no coaches allowed) that arrive in a player's area + * who has the yard office card get an extra ability. On the turn (mainline phase) that the train + * arrives the game will offer that player the option to have that train go directly to the yard + * office card instead of the standard office. They can of course still choose to have the train + * go to the standard office." + * + * WHAT THIS USED TO DO, and why all three of the rule's conditions were missing: a qualifying + * train was TELEPORTED onto the Yard Office card. The player was never asked, no route was ever + * computed — so the card's own printed text, "that can reach the yard office in one move", was + * unenforced — and because nothing was walked, nothing was ever met on the way in. + * + * The answer comes back through `pendingDecision`, so this returns `needsClearance` and is + * re-entered once the player has answered. `yardOfficeOffer` below is where the route is walked. + */ + /** + * The answer to the offer `yardOfficeQuestion` put before the train left the Mainline card. + * Absent — because the train has no Yard Office, or no route to it, or carries coaches — this + * falls straight through to the ordinary arrival below. + */ + const answer = s.clock.decisionAnswer; + if (answer && answer.kind === 'yardOffice' && answer.train === id) { + s.clock.decisionAnswer = null; + const route = answer.take ? yardOfficeRoute(s, seat, id, tray) : { kind: 'none' as const }; + if (route.kind !== 'none') { + tray.position = { at: 'grid', seat, coord: route.coord }; events.push({ type: 'trainDiverted', trainNumber: tray.trainNumber ?? 0, to: 'the Yard Office', reason: 'no coaches, so it need not occupy the Train Order Office', }); + /** + * Cars on the lead are a COLLISION, not a coupling — the same §8.3 rule that governs the + * Running Track, and the third of the three things this implementation was missing. An + * arriving train is at speed and is not expecting them (§A.4). + */ + if (route.kind === 'fouled') { + collide(s, playerAtSeat(s, seat), [id], events, 'cars fouling the lead into the Yard Office', 'the Yard Office'); + } return 'moved'; } + // Declined: fall through to the standard Office, with its own capacity and collision rules. } // Gap 2d — no room at the station is a collision, and it is the local player's fault (§10). diff --git a/src/engine/apply.ts b/src/engine/apply.ts index 94bcea6..91096ed 100644 --- a/src/engine/apply.ts +++ b/src/engine/apply.ts @@ -58,6 +58,7 @@ import { carsOn, coordKey, cutTowards, + decisionActor, isOperationalRail, playerAtSeat, pooled, @@ -124,7 +125,12 @@ function trayCoord(s: GameState, trayId: TrayId): GridCoord | null { } /** A tray sitting on the Office card occupies an A/D track (§2.1). */ -function occupancyFor(s: GameState, player: PlayerIndex, self: TrayId): Occupancy { +/** + * Exported for `advance.ts`'s Yard Office walk (Gitea#5), which has to ask the SAME occupancy + * question a switching move asks — a second copy would be free to drift into a different answer + * about which cards are free. + */ +export function occupancyFor(s: GameState, player: PlayerIndex, self: TrayId): Occupancy { const area = areaOf(s, player); return { trayAt: (c) => { @@ -792,11 +798,23 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo // The clearance ruling is the one intent that arrives out of turn order: it interrupts the // automatic Mainline Phase and goes to the Superintendent (§8.1, fourth condition). if (i.type === 'mainline.clearance') { - if (s.clock.pendingDecision === null) return 'NO_PENDING_DECISION'; + if (s.clock.pendingDecision?.kind !== 'clearance') return 'NO_PENDING_DECISION'; if (s.clock.superintendent !== player) return 'NOT_SUPERINTENDENT'; return null; } + /** + * §11 (Gitea#5) — the Yard Office offer, the second interruption of the Mainline Phase. + * + * Goes to the district's owner rather than the Superintendent, which is the whole reason + * `pendingDecision` became a union. `decisionActor` is the single place that mapping lives. + */ + if (i.type === 'mainline.yardOffice') { + if (s.clock.pendingDecision?.kind !== 'yardOffice') return 'NO_YARD_OFFICE_OFFER'; + if (decisionActor(s) !== player) return 'NOT_YOUR_TURN'; + return null; + } + if (!isActor(s, player)) return 'NOT_YOUR_TURN'; switch (i.type) { @@ -1571,6 +1589,12 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] { : [vote]; } + case 'mainline.yardOffice': { + const pending = s.clock.pendingDecision; + const trainId = pending?.kind === 'yardOffice' ? pending.train : ''; + return [{ type: 'yardOfficeRuled', player, trainId, take: i.take }]; + } + case 'localOps.choose': return [{ type: 'localOpsOptionChosen', player, option: i.option }]; @@ -2005,6 +2029,13 @@ export function reduce(s: GameState, e: GameEvent): void { s.status = 'finished'; break; + // §11 (Gitea#5) — the same shape as `clearanceGiven`: clear the question, record the answer for + // the arriving train to consume, or the driver asks again for ever. + case 'yardOfficeRuled': + s.clock.pendingDecision = null; + s.clock.decisionAnswer = { kind: 'yardOffice', train: e.trainId, take: e.take }; + break; + case 'localOpsOptionChosen': turnOf(s, e.player).option = e.option; break; @@ -2593,7 +2624,7 @@ export function reduce(s: GameState, e: GameEvent): void { case 'clearanceGiven': s.clock.pendingDecision = null; // Recorded for the asking train to consume; otherwise the driver asks again forever. - s.clock.clearanceRuling = { train: e.trainId, allow: e.allow }; + s.clock.decisionAnswer = { kind: 'clearance', train: e.trainId, allow: e.allow }; break; default: diff --git a/src/engine/events.ts b/src/engine/events.ts index 6b3bc35..b2be581 100644 --- a/src/engine/events.ts +++ b/src/engine/events.ts @@ -212,6 +212,8 @@ export type GameEvent = * ending that COULD have been played past and was not is a decision the table made, and the log * should say so rather than simply stopping. */ - | { type: 'playConcluded'; declinedBy: PlayerIndex }; + | { type: 'playConcluded'; declinedBy: PlayerIndex } + /** §11 (Gitea#5) — the district's owner answered the Yard Office offer. */ + | { type: 'yardOfficeRuled'; player: PlayerIndex; trainId: TrayId; take: boolean }; export type EventType = GameEvent['type']; diff --git a/src/engine/intents.ts b/src/engine/intents.ts index 80a824e..4d6cd10 100644 --- a/src/engine/intents.ts +++ b/src/engine/intents.ts @@ -168,7 +168,16 @@ export type Intent = * resumed server would refuse the save with `NO_ACTOR`. The server checks this against the seat * it authenticated (`NOT_YOUR_TURN`), so it is a record, never a claim. */ - | { type: 'game.extend'; player: PlayerIndex; agree: boolean }; + | { type: 'game.extend'; player: PlayerIndex; agree: boolean } + /** + * §11 (Gitea#5) — take the Yard Office, or the standard Office. + * + * Interrupts the Mainline Phase like `mainline.clearance`, and like it goes to one named player: + * whoever sits in the district the train is arriving at. Offered only when a route exists, so + * `take: true` always has somewhere to go — though it may still meet cars on the lead and crash, + * which is the point of the rule. + */ + | { type: 'mainline.yardOffice'; take: boolean }; export type IntentType = Intent['type']; @@ -301,7 +310,9 @@ export type RejectionCode = /** §3.3 (Gitea#11) — `game.extend` when the game is not waiting on an extension vote. */ | 'NOT_AWAITING_EXTENSION' /** §3.3 (Gitea#11) — this seat has already voted on this extension. */ - | 'ALREADY_VOTED'; + | 'ALREADY_VOTED' + /** §11 (Gitea#5) — answering a Yard Office offer that is not open. */ + | 'NO_YARD_OFFICE_OFFER'; export type Rejection = { code: RejectionCode; message: string }; diff --git a/src/engine/legal.ts b/src/engine/legal.ts index 96eb31e..2ac3845 100644 --- a/src/engine/legal.ts +++ b/src/engine/legal.ts @@ -85,11 +85,16 @@ function candidates(s: GameState, player: PlayerIndex): Intent[] { return out; } - // The clearance ruling arrives out of turn order and goes to the Superintendent (§8.1). - if (s.clock.pendingDecision !== null) { + // The two interruptions of the Mainline Phase. Each goes to one named player — `check` is the + // authority on which — so both are generated here and filtered there. + if (s.clock.pendingDecision?.kind === 'clearance') { out.push({ type: 'mainline.clearance', allow: true }); out.push({ type: 'mainline.clearance', allow: false }); } + if (s.clock.pendingDecision?.kind === 'yardOffice') { + out.push({ type: 'mainline.yardOffice', take: true }); + out.push({ type: 'mainline.yardOffice', take: false }); + } switch (s.clock.phase) { case 'localOps': diff --git a/src/engine/setup.ts b/src/engine/setup.ts index 7817623..d08e628 100644 --- a/src/engine/setup.ts +++ b/src/engine/setup.ts @@ -424,7 +424,7 @@ export function createGame(opts: SetupOptions): GameState { phase: 'localOps', currentActor: superintendent, pendingDecision: null, - clearanceRuling: null, + decisionAnswer: null, superintendent, actorOffset: 0, }, diff --git a/src/engine/state.ts b/src/engine/state.ts index ba07c97..fdd3fa7 100644 --- a/src/engine/state.ts +++ b/src/engine/state.ts @@ -592,10 +592,36 @@ export type Yards = { export type Phase = 'localOps' | 'newTrain' | 'mainline' | 'loadUnload' | 'shiftChange'; /** §8.1 fourth condition — the Superintendent rules on a following train. */ -export type SuperintendentClearance = { - train: TrayId; - occupiedBy: TrayId; -}; +/** + * AN INTERRUPTION TO THE AUTOMATIC MAINLINE PHASE — a question the driver cannot answer itself. + * + * There was one of these and it was hardcoded to one question asked of one player: the §8.1 + * clearance ruling, always to the Superintendent. Gitea#5 and Gitea#19 each need to stop the same + * phase and ask a DIFFERENT player something different, so the shape is a union and `decisionActor` + * below decides who answers. + * + * Every member names the `train` the question is about, because the answer has to be matched back + * to it — see `DecisionAnswer`. + */ +export type PendingDecision = + /** §8.1 — a following train in the same Subdivision. The Superintendent rules. */ + | { kind: 'clearance'; train: TrayId; occupiedBy: TrayId } + /** + * §11 (Gitea#5) — an inbound freight may take the Yard Office instead of the Train Order Office. + * Asked of whoever sits in `seat`, on the Mainline Phase the train arrives. + */ + | { kind: 'yardOffice'; train: TrayId; seat: SeatIndex }; + +/** + * The answer, waiting to be consumed by the train that asked. + * + * Without this the driver would re-evaluate the same train, ask the same question, and never + * advance. Keyed by `kind` as well as `train` so an answer can never be mistaken for the reply to a + * different question about the same train. + */ +export type DecisionAnswer = + | { kind: 'clearance'; train: TrayId; allow: boolean } + | { kind: 'yardOffice'; train: TrayId; take: boolean }; export type Clock = { day: number; @@ -604,13 +630,10 @@ export type Clock = { phase: Phase; /** Exactly one player may act at a time. Null during automatic Mainline movement. */ currentActor: PlayerIndex | null; - /** Interrupts the Mainline Phase to ask the Superintendent (§8.1). */ - pendingDecision: SuperintendentClearance | null; - /** - * The Superintendent's answer, waiting to be consumed by the train that asked. Without this the - * driver would re-evaluate the same train and ask the same question forever. - */ - clearanceRuling: { train: TrayId; allow: boolean } | null; + /** Interrupts the Mainline Phase to ask a player something (§8.1, §11). */ + pendingDecision: PendingDecision | null; + /** The answer to `pendingDecision`, waiting to be consumed by the train that asked. */ + decisionAnswer: DecisionAnswer | null; superintendent: PlayerIndex; /** * How far round the table the current phase has got. Acting order starts at the Superintendent @@ -1138,6 +1161,31 @@ export function playerAtSeat(state: GameState, seat: SeatIndex): PlayerIndex { return p; } +/** + * WHO MUST ANSWER the interruption, or null when nothing is pending. + * + * The one place that knows which player each kind of question goes to. §8.1's clearance is the + * Superintendent's ruling wherever it happens; the Yard Office is offered to whoever sits in the + * district the train is arriving at, because it is their card and their yard. + */ +export function decisionActor(state: GameState): PlayerIndex | null { + const d = state.clock.pendingDecision; + if (!d) return null; + return d.kind === 'clearance' ? state.clock.superintendent : playerAtSeat(state, d.seat); +} + +/** + * WHOSE MOVE IT IS RIGHT NOW — a pending interruption's owner if there is one, else the phase's + * own actor. + * + * Written out six times across the engine, the sim, the web client and the tests as + * `pendingDecision !== null ? superintendent : currentActor`, which stopped being right the moment + * a second kind of question existed. One copy now, so a new decision kind cannot be half-adopted. + */ +export function actingPlayer(state: GameState): PlayerIndex | null { + return decisionActor(state) ?? state.clock.currentActor; +} + /** Where this player is sitting, and therefore which Office Area is theirs. */ /** * The player `n` seats to the LEFT of this one, wrapping round the table. diff --git a/src/sim/bot.ts b/src/sim/bot.ts index 6d6551f..11c9adc 100644 --- a/src/sim/bot.ts +++ b/src/sim/bot.ts @@ -35,7 +35,7 @@ import type { Intent } from '../engine/intents.ts'; import { legalActions } from '../engine/legal.ts'; import { connectionsFor, exitsFrom, facilityVariants, hasPort, joins, neighbour, opposite, variantsFor } from '../engine/track.ts'; import type { Port } from '../engine/track.ts'; -import { coordKey, turnOf } from '../engine/state.ts'; +import { actingPlayer, coordKey, turnOf } from '../engine/state.ts'; import type { Facility, GameState, GridCoord, OfficeArea, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts'; export type BotPolicy = { @@ -154,6 +154,19 @@ export function makeDeveloperBot(tweaks: BotTweaks): BotPolicy { const clearance = ruleOnClearance(options); if (clearance) return because('the Superintendent must rule on a following train', clearance); + /** + * §11 (Gitea#5) — the bot keeps its trains at the Train Order Office. + * + * A deliberate policy, not an oversight, and the cautious half of a real choice: the Yard Office + * frees an A/D track, which is worth something on a busy district, but the lead into it may be + * fouled and the bot does not read its own yard well enough to tell (`TODO.md`, Bot + * Performance — it cannot spot a car at a stub industry either). Declining is always safe, and + * it keeps the balance harness comparable with every measurement taken before this rule existed. + * Worth revisiting when the bot can judge the lead. + */ + const yardOffice = options.find((i) => i.type === 'mainline.yardOffice' && i.take === false); + if (yardOffice) return because('the bot does not judge the lead into a yard, so it stays at the Office', yardOffice); + // Red Flags come before anything else — protection is only worth playing at the moment the // collision is actually pending, and that moment passes. const flags = worthFlagging(s, options); @@ -1904,8 +1917,7 @@ export function playGame( continue; } - const actor = - s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor; + const actor = actingPlayer(s); if (actor === null) break; const options = legalActions(s, actor); diff --git a/src/sim/narrate.ts b/src/sim/narrate.ts index f854c57..0aaf06e 100644 --- a/src/sim/narrate.ts +++ b/src/sim/narrate.ts @@ -498,6 +498,12 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration { return { tone: 'clock', text: `── The table plays on: Day ${e.day} is added to the timetable ──` }; case 'playConcluded': return { tone: 'clock', text: '── The railroad is put to bed. Final results stand. ──' }; + + // -- §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` }; } } diff --git a/src/sim/replay.ts b/src/sim/replay.ts index e9948eb..748d913 100644 --- a/src/sim/replay.ts +++ b/src/sim/replay.ts @@ -34,6 +34,7 @@ import type { Intent } from '../engine/intents.ts'; import { legalActions } from '../engine/legal.ts'; import { createGame } from '../engine/setup.ts'; import type { Facility, GameConfig, GameState } from '../engine/state.ts'; +import { actingPlayer } from '../engine/state.ts'; import { developerBot, lastChoiceReason } from './bot.ts'; import { carLabel, cuesFor, idleNote, isVisible, narrate } from './narrate.ts'; // The view-model lives in its own module so the browser build can import it without dragging in @@ -135,7 +136,7 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re if (s.status === 'finished') break; if (!r.needsInput) continue; - const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor; + const actor = actingPlayer(s); if (actor === null) break; const options = legalActions(s, actor); if (options.length === 0) break; diff --git a/src/sim/view.ts b/src/sim/view.ts index ab64f9c..04c4822 100644 --- a/src/sim/view.ts +++ b/src/sim/view.ts @@ -1044,6 +1044,15 @@ export function describeIntent(s: GameState, i: Intent): string { } case 'maneuver.redFlags': return `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`; + /** + * §11, the Yard Office (Gitea#5). The offer interrupts the Mainline Phase, so the label has to + * carry the whole question — there is no surrounding context on screen to lean on, and the + * player is being asked about a train they were not otherwise thinking about. + */ + case 'mainline.yardOffice': + return i.take + ? 'take the YARD OFFICE — straight into the yard, leaving the Train Order Office free' + : 'keep it at the Train Order Office — the ordinary arrival, onto an A/D track'; // §3.3, extended play (Gitea#11). The results screen draws its own buttons, but a bot reads its // options through this list like any other, and the label is what the history says it chose. case 'game.extend': @@ -1055,9 +1064,12 @@ export function describeIntent(s: GameState, i: Intent): string { case 'mainline.clearance': { // The §8.1 ruling is the sharpest decision in the game and read "grant clearance" — no hint // that granting it risks a rear-ender, or that refusing merely costs time. + // Narrowed to the clearance question: `pendingDecision` is a union since Gitea#5, and only + // this member names a train ahead. const pending = s.clock.pendingDecision; - const who = pending ? trainName(s, pending.train) : 'the train'; - const ahead = pending ? trainName(s, pending.occupiedBy) : 'the train ahead'; + const clearance = pending?.kind === 'clearance' ? pending : null; + const who = clearance ? trainName(s, clearance.train) : 'the train'; + const ahead = clearance ? trainName(s, clearance.occupiedBy) : 'the train ahead'; // NOT "risks a collision, −5". A rear-end on a Mainline card is described by §10 and is what // ABS Signals exists to prevent, but no such collision is implemented — granting clearance is // currently free. Saying otherwise invents a consequence the engine will never deliver. @@ -1098,6 +1110,15 @@ export function describeIntent(s: GameState, i: Intent): string { return 'play your red flag'; case 'maneuver.redFlags': return `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`; + /** + * §11, the Yard Office (Gitea#5). The offer interrupts the Mainline Phase, so the label has to + * carry the whole question — there is no surrounding context on screen to lean on, and the + * player is being asked about a train they were not otherwise thinking about. + */ + case 'mainline.yardOffice': + return i.take + ? 'take the YARD OFFICE — straight into the yard, leaving the Train Order Office free' + : 'keep it at the Train Order Office — the ordinary arrival, onto an A/D track'; // §3.3, extended play (Gitea#11). The results screen draws its own buttons, but a bot reads its // options through this list like any other, and the label is what the history says it chose. case 'game.extend': diff --git a/src/web/game.ts b/src/web/game.ts index 15ed7da..66505b5 100644 --- a/src/web/game.ts +++ b/src/web/game.ts @@ -30,6 +30,7 @@ import type { Intent } from '../engine/intents.ts'; import { legalActions } from '../engine/legal.ts'; import { createGame } from '../engine/setup.ts'; import type { CardId, GameConfig, GameState, PlayerIndex } from '../engine/state.ts'; +import { actingPlayer } from '../engine/state.ts'; import { playerAtSeat } from '../engine/state.ts'; import { cuesFor, narrate } from '../sim/narrate.ts'; // Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv, @@ -266,6 +267,7 @@ export type Game = { /** How each intent kind is introduced in the action list, in the order they should appear. */ const GROUP_ORDER: readonly { prefix: string; title: string }[] = [ { prefix: 'mainline.clearance', title: 'Superintendent — rule on this train' }, + { prefix: 'mainline.yardOffice', title: 'Where does this train arrive?' }, { prefix: 'localOps.choose', title: 'Local Operations — choose ONE' }, { prefix: 'switch.', title: 'Switching' }, // Specific before general: `startsWith` means a bare `draw.` would swallow all three, and the @@ -334,9 +336,9 @@ export function drain(game: Game): void { /** Whose turn it is, or null if the game is over or waiting on nothing. */ export function currentActor(game: Game): PlayerIndex | null { if (game.state.status !== 'active') return null; - return game.state.clock.pendingDecision !== null - ? game.state.clock.superintendent - : game.state.clock.currentActor; + // `actingPlayer` (state.ts) knows which player each kind of interruption goes to — the + // Superintendent for a §8.1 clearance, the district's owner for a Yard Office offer. + return actingPlayer(game.state); } /** Every legal action right now, grouped for display. Empty when there is nothing to decide. */ @@ -465,13 +467,26 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro */ if (prefix === 'mainline.clearance') { const pending = game.state.clock.pendingDecision; - if (pending) { + if (pending?.kind === 'clearance') { headed = `Superintendent — may ${trainName(game.state, pending.train)} follow ` + `${trainName(game.state, pending.occupiedBy)} onto the same Mainline card?`; } } + /** + * §11 (Gitea#5) — the Yard Office offer interrupts the Mainline Phase, so it arrives with no + * context around it: the player was not thinking about this train a moment ago. + */ + if (prefix === 'mainline.yardOffice') { + const pending = game.state.clock.pendingDecision; + if (pending?.kind === 'yardOffice') { + headed = + `${trainName(game.state, pending.train)} is arriving with no coaches — ` + + 'take it into the Yard Office, or hold it at the Train Order Office?'; + } + } + /** * A PENDING EXTRA IS ITS OWN QUESTION, and its own heading. * diff --git a/src/web/replays.ts b/src/web/replays.ts index 9c41ea9..4d2ce28 100644 --- a/src/web/replays.ts +++ b/src/web/replays.ts @@ -26,6 +26,7 @@ import { createGame } from '../engine/setup.ts'; import { snapshot } from '../sim/view.ts'; import type { Intent } from '../engine/intents.ts'; import { SOLO_CONFIG } from './game.ts'; +import { actingPlayer } from '../engine/state.ts'; type Save = { seed: number; history: Intent[] }; type Entry = { file: string; title: string; note?: string; seed?: number }; @@ -69,7 +70,7 @@ function rebuild(save: Save): { steps: Step[]; stoppedEarly: boolean } { push(pump(s)); let stoppedEarly = false; for (const intent of save.history) { - const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor; + const actor = actingPlayer(s); if (actor === null || s.status !== 'active') break; const r = applyIntent(s, actor, intent); if (!r.ok) { diff --git a/test/advance.test.ts b/test/advance.test.ts index bc9c052..3f2b48a 100644 --- a/test/advance.test.ts +++ b/test/advance.test.ts @@ -495,7 +495,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => { assert.equal(r.needsInput, true, 'the phase must stop and ask'); assert.notEqual(s.clock.pendingDecision, null); assert.equal(s.clock.pendingDecision!.train, 'behind'); - assert.equal(s.clock.pendingDecision!.occupiedBy, 'ahead'); + assert.equal((s.clock.pendingDecision as { occupiedBy: string }).occupiedBy, 'ahead'); }); it('does not ask when the train ahead is coming the other way — that is an absolute bar', () => { @@ -541,7 +541,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => { it('clears the decision once the Superintendent rules', () => { const s = game(); s.clock.phase = 'mainline'; - s.clock.pendingDecision = { train: 'a', occupiedBy: 'b' }; + s.clock.pendingDecision = { kind: 'clearance', train: 'a', occupiedBy: 'b' }; const r = applyIntent(s, 0, { type: 'mainline.clearance', allow: false }); assert.ok(r.ok); assert.equal(s.clock.pendingDecision, null); @@ -1404,7 +1404,7 @@ describe('an Extra starts where the player puts it (Gitea#4)', () => { const r = advance(s); assert.equal(r.needsInput, true, 'the phase must stop and ask'); assert.equal(s.clock.pendingDecision?.train, tray.id); - assert.equal(s.clock.pendingDecision?.occupiedBy, 'ahead'); + assert.equal((s.clock.pendingDecision as { occupiedBy: string } | null)?.occupiedBy, 'ahead'); // HOLD keeps it in the yard. assert.ok(applyIntent(s, s.clock.superintendent, { type: 'mainline.clearance', allow: false }).ok); diff --git a/test/apply.test.ts b/test/apply.test.ts index 1203479..207afa9 100644 --- a/test/apply.test.ts +++ b/test/apply.test.ts @@ -956,7 +956,7 @@ describe('the Superintendent clearance ruling (§8.1)', () => { const s = game(); s.clock.phase = 'mainline'; s.clock.currentActor = null; // nobody's turn — yet the Superintendent must still rule - s.clock.pendingDecision = { train: 'tray0', occupiedBy: 'tray1' }; + s.clock.pendingDecision = { kind: 'clearance', train: 'tray0', occupiedBy: 'tray1' }; const r = applyIntent(s, 0, { type: 'mainline.clearance', allow: false }); assert.ok(r.ok); assert.equal(s.clock.pendingDecision, null); @@ -964,7 +964,7 @@ describe('the Superintendent clearance ruling (§8.1)', () => { it('is refused to a player who is not the Superintendent', () => { const s = game(); - s.clock.pendingDecision = { train: 'tray0', occupiedBy: 'tray1' }; + s.clock.pendingDecision = { kind: 'clearance', train: 'tray0', occupiedBy: 'tray1' }; s.clock.superintendent = 1; assert.equal(check(s, 0, { type: 'mainline.clearance', allow: true }), 'NOT_SUPERINTENDENT'); }); diff --git a/test/enhancements.test.ts b/test/enhancements.test.ts index 1aef72e..b918f77 100644 --- a/test/enhancements.test.ts +++ b/test/enhancements.test.ts @@ -14,7 +14,7 @@ import { applyIntent, areaOf, check, hasDistrictEnhancement, isProtectedFromDera import { ENHANCEMENT_RULES, enhancementRule, trainProfile } from '../src/engine/content.ts'; import { createGame } from '../src/engine/setup.ts'; import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts'; -import { coordKey, subdivisions, turnOf } from '../src/engine/state.ts'; +import { coordKey, decisionActor, subdivisions, turnOf } from '../src/engine/state.ts'; const config: GameConfig = { mode: 'solitaire', @@ -287,17 +287,124 @@ describe('Interlocking and Yard Office relieve the Office', () => { assert.equal(s.players[0]!.revenue, -5); }); - it('diverts a coachless train to the Yard Office', () => { + /** + * §11, THE YARD OFFICE (Gitea#5) — offered, not imposed, and only down a route that exists. + * + * Jesse: "you have to ask if non-coach trains wish to go in there, rather than to the office", + * "if the Yard Office is not accessible in one move, you should not get the option", and cars on + * the way in "result in a crash". All three were missing: the train was teleported onto the card. + */ + const answer = (s: GameState, take: boolean) => { + const who = decisionActor(s); + assert.notEqual(who, null, 'nothing was pending, so there was nothing to answer'); + const r = applyIntent(s, who!, { type: 'mainline.yardOffice', take }); + assert.ok(r.ok, 'the district owner could not answer the Yard Office offer'); + advance(s); + }; + + it('OFFERS the Yard Office to the district owner rather than diverting automatically', () => { const s = game(); const card = straight(); card.enhancements.push('yardOffice'); - addCard(s, at(-1, 0), card); + addCard(s, at(0, 2), card); const id = inbound(s, [{ type: 'hopper', loaded: true }]); advance(s); + assert.equal(s.clock.pendingDecision?.kind, 'yardOffice', 'the phase did not stop to ask'); + assert.equal(decisionActor(s), 0, 'the question went to the wrong player'); const pos = s.trays.get(id)!.position; - assert.ok(pos.at === 'grid' && pos.coord.row === -1, 'arrived at the Yard Office'); - assert.ok(!areaOf(s, 0).adOccupancy.includes(id), 'did not take an A/D track'); + assert.ok(pos.at !== 'grid' || pos.coord.col !== 2, 'the train moved before anyone answered'); + }); + + it('takes the Yard Office when the owner says yes', () => { + const s = game(); + const card = straight(); + card.enhancements.push('yardOffice'); + addCard(s, at(0, 2), card); + const id = inbound(s, [{ type: 'hopper', loaded: true }]); + + advance(s); + answer(s, true); + const pos = s.trays.get(id)!.position; + assert.ok(pos.at === 'grid' && pos.coord.col === 2, 'did not arrive at the Yard Office'); + assert.ok(!areaOf(s, 0).adOccupancy.includes(id), 'took an A/D track anyway'); + assert.equal(s.players[0]!.revenue, 0, 'a clear lead should not have collided'); + }); + + it('goes to the Train Order Office when the owner says no', () => { + // "They can of course still choose to have the train go to the standard office." + const s = game(); + const card = straight(); + card.enhancements.push('yardOffice'); + addCard(s, at(0, 2), card); + const id = inbound(s, [{ type: 'hopper', loaded: true }]); + + advance(s); + answer(s, false); + assert.ok(areaOf(s, 0).adOccupancy.includes(id), 'declining did not put it on an A/D track'); + }); + + it('does not offer what cannot be reached, and says why in the history', () => { + /** + * Jesse, 2026-08-29: "make sure this is logged in history — why can't move so user knows why + * they can't get to yard." A silent absence is indistinguishable from a broken feature, which + * is how the missing reachability check survived this long. + */ + const s = game(); + const card = straight(); + card.enhancements.push('yardOffice'); + // Far off the Running Track, with nothing laid between: no route in one move. + addCard(s, at(3, 4), card); + inbound(s, [{ type: 'hopper', loaded: true }]); + + const events = advance(s).events; + assert.equal(s.clock.pendingDecision, null, 'offered a Yard Office it cannot reach'); + const said = events.find( + (e) => e.type === 'trainDiverted' && e.reason.includes('could not be offered'), + ); + assert.ok(said, `nothing in the history explains why:\n${JSON.stringify(events, null, 1)}`); + assert.match( + (said as { reason: string }).reason, + /one move/, + 'the reason does not say it is out of reach in one move', + ); + }); + + it('offers a fouled lead, and taking it collides', () => { + /** + * The third missing condition. "Just like other trains finding cars on the tracks you use to + * get into either result in a crash" — and Jesse's ruling keeps the OFFER: a route that exists + * is offered, and the consequence of taking it is the player's. §8.3 already reads cars in the + * path of an arriving train as a collision rather than a coupling. + */ + const s = game(); + const card = straight(); + card.enhancements.push('yardOffice'); + addCard(s, at(0, 2), card); + // A car standing on the lead between the Office and the yard. + areaOf(s, 0).grid.get(coordKey(at(0, 1)))!.standing = [{ type: 'boxcar', loaded: false }]; + const id = inbound(s, [{ type: 'hopper', loaded: true }]); + + advance(s); + assert.equal(s.clock.pendingDecision?.kind, 'yardOffice', 'a fouled lead was not offered at all'); + + answer(s, true); + assert.equal(s.players[0]!.revenue, -5, 'running through standing cars did not collide'); + assert.ok(!s.trays.has(id), 'the train survived the collision'); + }); + + it('declining a fouled lead is safe — the standard Office is unaffected', () => { + const s = game(); + const card = straight(); + card.enhancements.push('yardOffice'); + addCard(s, at(0, 2), card); + areaOf(s, 0).grid.get(coordKey(at(0, 1)))!.standing = [{ type: 'boxcar', loaded: false }]; + const id = inbound(s, [{ type: 'hopper', loaded: true }]); + + advance(s); + answer(s, false); + assert.equal(s.players[0]!.revenue, 0, 'declining the Yard Office still cost a collision'); + assert.ok(areaOf(s, 0).adOccupancy.includes(id), 'the train did not reach the Office'); }); it('does not divert a train carrying coaches', () => { diff --git a/test/web.test.ts b/test/web.test.ts index ea345df..0cc5e80 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -1224,7 +1224,7 @@ describe('the page explains itself', () => { id: 'tray3', trainNumber: 7, trainIsExtra: false, engineAt: 0, consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0, }); - s.clock.pendingDecision = { train: 'tray2', occupiedBy: 'tray3' }; + s.clock.pendingDecision = { kind: 'clearance', train: 'tray2', occupiedBy: 'tray3' }; const allow = describeIntent(s, { type: 'mainline.clearance', allow: true }); const hold = describeIntent(s, { type: 'mainline.clearance', allow: false });