The Yard Office is offered, reachable, and can be run into (Gitea#5)

"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… the game will
offer that player the option… They can of course still choose to have the train go
to the standard office."

It was implemented, in a stripped form missing all three conditions: a qualifying
train was TELEPORTED onto the Yard Office card. Nobody was asked, no route was
computed — so the card's own printed "that can reach the yard office in one move"
was unenforced — and because nothing was walked, nothing was ever met on the way.

All three now hold:

  - OFFERED to whoever sits in the district, interrupting the Mainline Phase on the
    turn the train arrives. Declining is an ordinary arrival onto an A/D track.
  - REACHABILITY is the engine's own move walk. `exploreMoves` already means what
    the card means — any distance without changing direction, finishing on
    Operational Rail (§2.4, §A.1) — so using it is what makes code and card agree.
    Reversing is a separate Move, so a yard that can only be reached by backing up
    is correctly out of reach.
  - CARS ON THE LEAD COLLIDE. The walk does not treat standing cars as obstacles;
    it COUPLES them, because that is what a switching move does. An arriving train
    is not switching, so what it would have coupled is what it is about to hit —
    the same reading §8.3 already applies to the Running Track. `destination.couples`
    is therefore the fouling signal, and it needed no new machinery.

Per Jesse's ruling (2026-08-29) the two failures his issue names are kept apart: no
route means no offer, with the history saying why ("make sure this is logged in
history — why can't move so user knows why they can't get to yard"); a route that
exists but is fouled IS offered, and taking it crashes. A silent absence is
indistinguishable from a broken feature, which is how the missing check survived.

THE SHARED REFACTOR THIS NEEDED. `pendingDecision` was one question asked of one
player — §8.1's clearance, always the Superintendent — and `currentActor` hardcoded
that. It is a discriminated union now, with `decisionActor` as the single place that
maps a question to whoever must answer it, and `clearanceRuling` generalised to
`decisionAnswer`. Six copies of `pendingDecision !== null ? superintendent :
currentActor` across the engine, the sim, the web client and the tests collapse into
`actingPlayer`; they had already stopped being right the moment a second kind of
question existed. Gitea#19 needs the same machinery and now only has to add a case.

A BUG THE FIRST CUT WALKED INTO, worth recording because it is a trap the next
interruption will meet too: the offer must be put BEFORE the train is taken off its
Mainline card. `needsClearance` unwinds the whole phase and the driver re-enters
from the top, so asking after the `transits` filter cost the train its place on the
card and the answer had nowhere to land. §8.1 gets this right by asking before it
commits, and the Yard Office now does the same.

The developer bot declines: the Yard Office frees an A/D track, but the lead may be
fouled and the bot cannot read its own yard well enough to tell (`TODO.md`, Bot
Performance). Declining is always safe and keeps the harness comparable with every
measurement taken before this rule existed.

851 tests pass.

Closes #5

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAgJSmeV8zrMh55Mj85ESb
This commit is contained in:
Jesse.Markowitz
2026-08-29 06:57:31 -04:00
co-authored by Claude Opus 5
parent 5e34c73b16
commit 228027637b
17 changed files with 461 additions and 58 deletions
+23 -2
View File
@@ -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':