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: [],
|
||||
|
||||
@@ -153,6 +153,7 @@ function buildOfficeArea(seat: SeatIndex): OfficeArea {
|
||||
geometry: { kind: 'office' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: buildPassengerFacility('whistlePost'),
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
@@ -162,6 +163,7 @@ function buildOfficeArea(seat: SeatIndex): OfficeArea {
|
||||
geometry: { kind: 'limits' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
|
||||
+27
-20
@@ -137,9 +137,35 @@ export type TrackCard = {
|
||||
* WEST-TO-EAST IS THE BOARD'S ORIENTATION, not the train's, which is exactly why it is the right
|
||||
* one: it is a property of the card, so it does not change when a different train touches it. The
|
||||
* two conversions are stated once, in `apply.ts`'s `carsDropped` and `carsCoupled` reducers, and
|
||||
* `CrewTray.standingWest` records where a train standing here sits among them.
|
||||
* `standingWest` below records where a train standing here sits among them.
|
||||
*/
|
||||
standing: RollingStock[];
|
||||
/**
|
||||
* HOW MANY OF `standing` LIE WEST OF THE TRAIN(S) STANDING HERE.
|
||||
*
|
||||
* `standing` runs west to east and a train sits somewhere IN that row: cars `[0, standingWest)`
|
||||
* are west of it, cars `[standingWest, end)` are east. A train can set out off both ends on the
|
||||
* same square — §A.3 lets a cut come off either outer end — so "which side is this cut on" is not
|
||||
* recoverable from the array alone, and it is the question every remaining rule asks: which cars
|
||||
* are AHEAD of an engine and which BEHIND (combine with `CrewTray.railFacing`), which cut a
|
||||
* departing train must couple back up (the one at the end it leaves by), and which side of the
|
||||
* chip the renderer draws the cut on.
|
||||
*
|
||||
* WITH NO TRAIN STANDING HERE, THIS NUMBER MEANS NOTHING: a card's cars are simply a cut with no
|
||||
* near or far side, and the next train to arrive couples all of them regardless — nothing reads
|
||||
* `standingWest` in that state, so a stale value left over from a train that has since moved on is
|
||||
* inert, not wrong. That reasoning is unchanged from when this lived on `CrewTray`.
|
||||
*
|
||||
* WHAT MOVED IT TO THE CARD: the Office square is the one place more than one train may stand at
|
||||
* once, and only one route lets two trays disagree about the same row of cars — a train already
|
||||
* at the Office sets out a cut in place, without moving, while a second train on another A/D track
|
||||
* still holds whatever split it arrived with. A cut lies west or east of the WHOLE block of A/D
|
||||
* tracks, never of one particular track and never between two trains, so there is exactly one
|
||||
* number for the card to carry and every train standing there reads the same one. Everywhere else
|
||||
* on the board at most one train ever stands on a card, so the two homes for this field are
|
||||
* identical in every other case.
|
||||
*/
|
||||
standingWest: number;
|
||||
facility: Facility | null;
|
||||
modifiers: ModifierKind[];
|
||||
/** Enhancement cards laid on this card (§7 of implications.md). */
|
||||
@@ -345,25 +371,6 @@ export type CrewTray = {
|
||||
* puts the engine on the other end, which is the one thing the arrow exists to show.
|
||||
*/
|
||||
railFacing?: 'e' | 'w';
|
||||
/**
|
||||
* HOW MANY OF THIS CARD'S STANDING CARS LIE WEST OF THIS TRAIN.
|
||||
*
|
||||
* `carsOn(card)` runs west to east and a train standing on the card sits somewhere IN that row:
|
||||
* cars `[0, standingWest)` are west of the engine, cars `[standingWest, end)` are east of it. A
|
||||
* train can set out off both ends on the same square — §A.3 lets a cut come off either outer end —
|
||||
* so "which side is this cut on" is not recoverable from the array alone, and it is the question
|
||||
* every remaining rule asks.
|
||||
*
|
||||
* It answers all three: which cars are AHEAD of the engine and which BEHIND (combine with
|
||||
* `railFacing`), which cut a departing train must couple back up (the one at the end it leaves
|
||||
* by), and which side of the chip the renderer draws the cut on.
|
||||
*
|
||||
* Kept on the TRAY rather than the card because it describes a relationship, not the card: with no
|
||||
* train standing there, a card's cars are simply a cut with no near or far side, and the next
|
||||
* train to arrive couples all of them regardless. Reset to 0 on every move, which is sound because
|
||||
* coupling is mandatory — a train arrives on a card it has just emptied.
|
||||
*/
|
||||
standingWest?: number;
|
||||
position: NodeRef;
|
||||
movesUsed: number;
|
||||
/**
|
||||
|
||||
+1
-10
@@ -316,15 +316,6 @@ export type MoveContext = {
|
||||
consistSize: number;
|
||||
/** The tray making the move, so it does not block itself. */
|
||||
self: TrayId;
|
||||
/**
|
||||
* How many cars on the START card stand west of this train (`CrewTray.standingWest`).
|
||||
*
|
||||
* The walk begins at `neighbour(start, initialExit)` and never looked at the start card at all, so
|
||||
* a train drove straight through the cut it had just set out — in both directions, and without
|
||||
* counting it against the four-car limit. Coupling is mandatory (§A.4) and a cut on your own
|
||||
* square is no exception: pulling out through the end it sits at picks it up again.
|
||||
*/
|
||||
standingWest?: number;
|
||||
};
|
||||
|
||||
function cardAt(area: OfficeArea, c: GridCoord): TrackCard | undefined {
|
||||
@@ -459,7 +450,7 @@ export function exploreMoves(
|
||||
*
|
||||
* Ordered nearest-first like every other card's, so it simply seeds the accumulator.
|
||||
*/
|
||||
const ownCut = cutTowards({ standingWest: ctx.standingWest }, carsOn(startCard), initialExit);
|
||||
const ownCut = cutTowards(startCard, carsOn(startCard), initialExit);
|
||||
const startKey = coordKey(start);
|
||||
const queue: Frontier[] = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user