the Roster Pass: every train at the Office visible
Builds "The Roster Pass" / "Two Trains, One Card" (station display for multiple trains at one Office). The Office is the one square where more than one train may legally stand at once (one per A/D track), and CellView.train had room for exactly one — a second train at a busy Station was counted in the old A/D pips and never drawn. CellView.train -> CellView.trains: TrainView[], seat-filtered and collecting every match rather than the first (fixes a latent cross-district leak in the process: trainOnCard never checked seat). The A/D pips are replaced with one roster chip per A/D track, always, free or occupied; clicking a chip sets selectedCrew, wiring the board and the action panel to the same value. standingWest moves from CrewTray to TrackCard: two trays sharing one Office card need one shared split, not one each, and there is no such thing as "west of one particular A/D track". No save migration — Save replays through the engine — and a stale value on an emptied card is inert because nothing reads a split with no train standing there. The Division map's Office cell is now sized by A/D capacity rather than occupancy, so it holds still as trains arrive and leave; chips lay into fixed slots instead of centre-spreading onto the Limits cards either side. 584 tests, 0 failures. Verified end-to-end against the built app and a direct render of a 4-train Terminal (screenshotted). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAt2YCXgd5qCjBcF2x34aK
This commit is contained in:
+29
-22
@@ -1240,7 +1240,6 @@ function destinationsFor(
|
||||
occupancy: occupancyFor(s, player, trayId),
|
||||
consistSize: tray.consist.length,
|
||||
self: trayId,
|
||||
...(tray.standingWest === undefined ? {} : { standingWest: tray.standingWest }),
|
||||
},
|
||||
from,
|
||||
exit,
|
||||
@@ -1261,7 +1260,8 @@ export function ownCutFor(s: GameState, player: PlayerIndex, trayId: TrayId, rev
|
||||
const here = tray.position.coord;
|
||||
const facing = facingPort(s, trayId);
|
||||
const exit: Port = reverse ? reversePort(s, player, here, facing) : facing;
|
||||
return cutTowards(tray, carsOn(areaOf(s, player).grid.get(coordKey(here)) ?? emptyCard()), exit);
|
||||
const card = areaOf(s, player).grid.get(coordKey(here)) ?? emptyCard();
|
||||
return cutTowards(card, carsOn(card), exit);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1287,7 +1287,6 @@ export function movesFor(
|
||||
occupancy: occupancyFor(s, player, trayId),
|
||||
consistSize: tray.consist.length,
|
||||
self: trayId,
|
||||
...(tray.standingWest === undefined ? {} : { standingWest: tray.standingWest }),
|
||||
};
|
||||
const facing = facingPort(s, trayId);
|
||||
const forward = exploreMoves(ctx, from, facing);
|
||||
@@ -1374,17 +1373,19 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
* from `dest.path` plus the destination, neither of which can ever contain the start — so a
|
||||
* cut recoupled off your own card was added to the consist and left standing on the board at
|
||||
* the same time, one car becoming two. The cars that stay behind (a cut set out off the
|
||||
* OTHER end) ride along on `leaves`, because by the time this is reduced the tray has moved
|
||||
* and `standingWest` no longer describes this card.
|
||||
* OTHER end) ride along on `leaves`, computed here against the card's CURRENT split — the
|
||||
* reducer applying this event goes on to zero `standingWest` on the destination card, never
|
||||
* the origin, so nothing downstream needs this snapshot to have been taken any earlier.
|
||||
*/
|
||||
const grid = areaOf(s, player).grid;
|
||||
const startCut = cutTowards(tray, carsOn(grid.get(coordKey(from)) ?? emptyCard()), exitPort);
|
||||
const startCard = grid.get(coordKey(from)) ?? emptyCard();
|
||||
const startCut = cutTowards(startCard, carsOn(startCard), exitPort);
|
||||
const lifted = [
|
||||
...(startCut.length > 0 ? [from] : []),
|
||||
...dest.path.map((step) => step.coord),
|
||||
i.to,
|
||||
].filter((c) => carsOn(grid.get(coordKey(c)) ?? emptyCard()).length > 0);
|
||||
const sides = standingSides(tray, carsOn(grid.get(coordKey(from)) ?? emptyCard()));
|
||||
const sides = standingSides(startCard, carsOn(startCard));
|
||||
const stayed = exitPort === 'e' ? sides.west : exitPort === 'w' ? sides.east : [];
|
||||
// §A.3 — "engines also have couplers on the front end, so a train can pick cars up onto
|
||||
// its nose". Running forward the engine meets cars head-on and takes them in front; backing
|
||||
@@ -1722,14 +1723,17 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
// their turn. The event carries no player of its own.
|
||||
turnOf(s, playerAtSeat(s, seat)).movesRemaining = e.movesRemaining;
|
||||
|
||||
const area = areaAtSeat(s, seat);
|
||||
|
||||
// A MOVE ENDS ON AN EMPTY CARD, always: coupling is mandatory, so anything standing on the
|
||||
// destination has just been lifted into the tray. Whatever this crew had beside it belonged to
|
||||
// the square it left, so its place in that row is gone with it.
|
||||
tray.standingWest = 0;
|
||||
// destination has just been lifted into the tray. Zero the DESTINATION card's split — the
|
||||
// origin's is left alone rather than cleared, which costs nothing: nothing reads `standingWest`
|
||||
// on a card with no train standing there, so a stale value left behind is inert, not wrong.
|
||||
const destCard = area.grid.get(coordKey(e.to));
|
||||
if (destCard) destCard.standingWest = 0;
|
||||
|
||||
// An A/D track is held only while the train is actually standing at the Office (§2.1).
|
||||
// Leaving it out of sync means the Office looks permanently full and every arrival collides.
|
||||
const area = areaAtSeat(s, seat);
|
||||
const atOffice =
|
||||
e.to.row === area.officeCoord.row && e.to.col === area.officeCoord.col;
|
||||
area.adOccupancy = area.adOccupancy.filter((t) => t !== e.trayId);
|
||||
@@ -1777,9 +1781,9 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
const card = area.grid.get(coordKey(e.leaves.at));
|
||||
if (card) carsOn(card).push(...e.leaves.stock.map((c) => ({ ...c })));
|
||||
}
|
||||
// A tray that has moved is standing on a card it has just emptied, so nothing of its own is
|
||||
// left beside it. Any cut it did leave behind is on the square it came FROM.
|
||||
tray.standingWest = 0;
|
||||
// `trayMoved`, which always precedes this for the same move, already zeroed the destination
|
||||
// card's `standingWest` — coupling is mandatory, so a tray that has just moved is standing on
|
||||
// a card it has just emptied, and nothing of its own is left beside it there.
|
||||
/**
|
||||
* Charge only the cars that were ALREADY standing where the crew ran — the own cut at the
|
||||
* front of `stock` is a drop being undone, so it is refunded on the square it was left on
|
||||
@@ -1831,20 +1835,20 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
*
|
||||
* Successive cuts off the same end stack up TOWARDS the engine — the first car set out is left
|
||||
* furthest away, and each one after it is left in the gap between the train and the last —
|
||||
* so the insertion point is the train's own position in the row, `standingWest`, from either
|
||||
* side. That is what makes the result batch-invariant: four cars at once, four singles, or two
|
||||
* pairs all park in the same order, which is the reported bug and the regression test.
|
||||
* so the insertion point is the card's own `standingWest`, read against this train's side. That
|
||||
* is what makes the result batch-invariant: four cars at once, four singles, or two pairs all
|
||||
* park in the same order, which is the reported bug and the regression test.
|
||||
*/
|
||||
if (card) {
|
||||
const cars = carsOn(card);
|
||||
const facing = railFacingOf(tray);
|
||||
const onWestSide = e.fromNose ? facing === 'w' : facing === 'e';
|
||||
const k = Math.max(0, Math.min(cars.length, tray.standingWest ?? 0));
|
||||
const k = Math.max(0, Math.min(cars.length, card.standingWest));
|
||||
const cut = trackOrder(e.stock, facing);
|
||||
cars.splice(k, 0, ...cut);
|
||||
// The train has not moved, so cars set out on its WEST side push its index along the row;
|
||||
// The train has not moved, so cars set out on its WEST side push the split along the row;
|
||||
// cars set out to the east leave it where it was.
|
||||
tray.standingWest = onWestSide ? k + cut.length : k;
|
||||
card.standingWest = onWestSide ? k + cut.length : k;
|
||||
}
|
||||
spendFreightBudget(s, playerAtSeat(s, trayySeat(tray)), tray, e.at, e.stock);
|
||||
break;
|
||||
@@ -1901,9 +1905,10 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
geometry: { kind: 'modifier', modifier: card.kind.modifier },
|
||||
baseOperationalRail: false,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
enhancements: [],
|
||||
});
|
||||
applyModifier(area, e.placement, card.kind.modifier);
|
||||
} else if (card.kind.kind !== 'office') {
|
||||
@@ -2244,7 +2249,7 @@ function protoCard(
|
||||
kind: { kind: string; geometry?: string; facility?: string; hand?: string },
|
||||
variant: number | undefined,
|
||||
): TrackCard | null {
|
||||
const base = { standing: [], facility: null, modifiers: [], enhancements: [] };
|
||||
const base = { standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [] };
|
||||
|
||||
if (kind.kind === 'track') {
|
||||
const geometry = kind.geometry as TrackGeometry;
|
||||
@@ -2452,6 +2457,7 @@ function emptyCard(): TrackCard {
|
||||
geometry: { kind: 'limits' },
|
||||
baseOperationalRail: false,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
@@ -2724,6 +2730,7 @@ function limitsCard(): TrackCard {
|
||||
geometry: { kind: 'limits' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
|
||||
Reference in New Issue
Block a user