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:
co-authored by
Claude Opus 5
parent
5e34c73b16
commit
228027637b
+159
-16
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user