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:
+44
-3
@@ -21,9 +21,10 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
|||||||
|
|
||||||
## 0.4.8 — 2026-08-19
|
## 0.4.8 — 2026-08-19
|
||||||
|
|
||||||
Three reports from the same game, all about squares: which ones a card may go on, which one a button
|
Four reports from the same game, all about squares: which ones a card may go on, which one a button
|
||||||
in the action list means, and — when there is more than one legal road to the same square — which
|
in the action list means, when there is more than one legal road to the same square which one a
|
||||||
one a train actually takes.
|
train actually takes, and — when more than one train is standing at the same Office — which is
|
||||||
|
which.
|
||||||
|
|
||||||
### The Limits bound the district, not just the Running Track
|
### The Limits bound the district, not just the Running Track
|
||||||
|
|
||||||
@@ -168,6 +169,46 @@ built a district where the discarded route would have mattered. 400 full games (
|
|||||||
ablation flag to run at all and this is not a bot heuristic, so paired A/B was not applicable here;
|
ablation flag to run at all and this is not a bot heuristic, so paired A/B was not applicable here;
|
||||||
the enumeration-based argument above is the actual evidence.
|
the enumeration-based argument above is the actual evidence.
|
||||||
|
|
||||||
|
### The Roster Pass — every train at the Office visible
|
||||||
|
|
||||||
|
**The Office is the one square where more than one train may legally stand at once** — it has
|
||||||
|
several A/D tracks, each holding one — and the board drew it as if only one ever could. Two trays
|
||||||
|
at a Station couple onto the identical `officeCoord`; `trainOnCard()` returned on the first match
|
||||||
|
and `CellView.train` had room for exactly one, so a second train was counted in the old A/D pips
|
||||||
|
and never drawn at all. Reported directly, and analysed in "Two Trains, One Card": the pips and the
|
||||||
|
picture were reading different fields.
|
||||||
|
|
||||||
|
**`CellView.train` is now `CellView.trains: TrainView[]`** — every train standing on the card, each
|
||||||
|
carrying its own `trayId`. The A/D pips are gone; in their place is one roster chip per A/D track,
|
||||||
|
always (`adTracks` of them, not one per train), free tracks reading a dashed, dimmed "free" rather
|
||||||
|
than vanishing. The selected train's chip is lit the same amber the crew strip and action buttons
|
||||||
|
use, and its consist is the one drawn at the rail — clicking any occupied chip sets `selectedCrew`,
|
||||||
|
the same value the "Which train are you switching?" picker already wrote, so the board and the
|
||||||
|
action panel drive one value in both directions.
|
||||||
|
|
||||||
|
**`standingWest` moved from `CrewTray` to `TrackCard`.** The split still means what it always meant
|
||||||
|
— west/east of the row of standing cars — and it is still only meaningful while an engine stands on
|
||||||
|
the card (an unattended cut has no near or far side; that reasoning did not change). What forced the
|
||||||
|
move: two trays sharing one Office card could otherwise hold two different splits of the identical
|
||||||
|
row, and there is no such thing as "west of one particular A/D track" — a cut lies west or east of
|
||||||
|
the *whole block* of A/D tracks, so there has to be exactly one number for the card to carry. No
|
||||||
|
save migration: `Save = {seed, history, rules?}` replays through the engine rather than being
|
||||||
|
loaded, and a stale value on an emptied card is inert because nothing reads a split with no train
|
||||||
|
standing there.
|
||||||
|
|
||||||
|
**The Division map stopped drawing trains on their neighbours.** The Office's Running Track cell was
|
||||||
|
a fixed 78px regardless of how many A/D tracks it had, so two chips centre-spread wider than the
|
||||||
|
cell and spilled onto the Limits cards either side. The cell is now sized by **capacity**
|
||||||
|
(`max(78, adTracks·54+12)`), not by how many tracks are occupied — sized by occupancy instead, the
|
||||||
|
board would have shifted the East Division Point sideways every time a train arrived or left — and
|
||||||
|
chips are laid into fixed slots, one per A/D track, so none can ever overhang the cell.
|
||||||
|
|
||||||
|
**Verified:** two trays at a Station are both drawn, each with its own chip; a Terminal's four
|
||||||
|
tracks all fit; a bare cut with a stale `standingWest` still draws left-aligned; every roster chip's
|
||||||
|
x-extent lies inside its Office cell at full occupancy; and a packed replay row from before this
|
||||||
|
feature — a lone train object, not an array — rehydrates into a one-train roster rather than
|
||||||
|
throwing. 584 tests, 0 failures.
|
||||||
|
|
||||||
### Also
|
### Also
|
||||||
|
|
||||||
- The three published replays were re-recorded. A save is a seed and its intents, so tightening a
|
- The three published replays were re-recorded. A save is a seed and its intents, so tightening a
|
||||||
|
|||||||
+29
-22
@@ -1240,7 +1240,6 @@ function destinationsFor(
|
|||||||
occupancy: occupancyFor(s, player, trayId),
|
occupancy: occupancyFor(s, player, trayId),
|
||||||
consistSize: tray.consist.length,
|
consistSize: tray.consist.length,
|
||||||
self: trayId,
|
self: trayId,
|
||||||
...(tray.standingWest === undefined ? {} : { standingWest: tray.standingWest }),
|
|
||||||
},
|
},
|
||||||
from,
|
from,
|
||||||
exit,
|
exit,
|
||||||
@@ -1261,7 +1260,8 @@ export function ownCutFor(s: GameState, player: PlayerIndex, trayId: TrayId, rev
|
|||||||
const here = tray.position.coord;
|
const here = tray.position.coord;
|
||||||
const facing = facingPort(s, trayId);
|
const facing = facingPort(s, trayId);
|
||||||
const exit: Port = reverse ? reversePort(s, player, here, facing) : facing;
|
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),
|
occupancy: occupancyFor(s, player, trayId),
|
||||||
consistSize: tray.consist.length,
|
consistSize: tray.consist.length,
|
||||||
self: trayId,
|
self: trayId,
|
||||||
...(tray.standingWest === undefined ? {} : { standingWest: tray.standingWest }),
|
|
||||||
};
|
};
|
||||||
const facing = facingPort(s, trayId);
|
const facing = facingPort(s, trayId);
|
||||||
const forward = exploreMoves(ctx, from, facing);
|
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
|
* 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
|
* 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
|
* 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
|
* OTHER end) ride along on `leaves`, computed here against the card's CURRENT split — the
|
||||||
* and `standingWest` no longer describes this card.
|
* 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 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 = [
|
const lifted = [
|
||||||
...(startCut.length > 0 ? [from] : []),
|
...(startCut.length > 0 ? [from] : []),
|
||||||
...dest.path.map((step) => step.coord),
|
...dest.path.map((step) => step.coord),
|
||||||
i.to,
|
i.to,
|
||||||
].filter((c) => carsOn(grid.get(coordKey(c)) ?? emptyCard()).length > 0);
|
].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 : [];
|
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
|
// §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
|
// 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.
|
// their turn. The event carries no player of its own.
|
||||||
turnOf(s, playerAtSeat(s, seat)).movesRemaining = e.movesRemaining;
|
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
|
// 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
|
// destination has just been lifted into the tray. Zero the DESTINATION card's split — the
|
||||||
// the square it left, so its place in that row is gone with it.
|
// origin's is left alone rather than cleared, which costs nothing: nothing reads `standingWest`
|
||||||
tray.standingWest = 0;
|
// 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).
|
// 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.
|
// Leaving it out of sync means the Office looks permanently full and every arrival collides.
|
||||||
const area = areaAtSeat(s, seat);
|
|
||||||
const atOffice =
|
const atOffice =
|
||||||
e.to.row === area.officeCoord.row && e.to.col === area.officeCoord.col;
|
e.to.row === area.officeCoord.row && e.to.col === area.officeCoord.col;
|
||||||
area.adOccupancy = area.adOccupancy.filter((t) => t !== e.trayId);
|
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));
|
const card = area.grid.get(coordKey(e.leaves.at));
|
||||||
if (card) carsOn(card).push(...e.leaves.stock.map((c) => ({ ...c })));
|
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
|
// `trayMoved`, which always precedes this for the same move, already zeroed the destination
|
||||||
// left beside it. Any cut it did leave behind is on the square it came FROM.
|
// card's `standingWest` — coupling is mandatory, so a tray that has just moved is standing on
|
||||||
tray.standingWest = 0;
|
// 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
|
* 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
|
* 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
|
* 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 —
|
* 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
|
* so the insertion point is the card's own `standingWest`, read against this train's side. That
|
||||||
* side. That is what makes the result batch-invariant: four cars at once, four singles, or two
|
* is what makes the result batch-invariant: four cars at once, four singles, or two pairs all
|
||||||
* pairs all park in the same order, which is the reported bug and the regression test.
|
* park in the same order, which is the reported bug and the regression test.
|
||||||
*/
|
*/
|
||||||
if (card) {
|
if (card) {
|
||||||
const cars = carsOn(card);
|
const cars = carsOn(card);
|
||||||
const facing = railFacingOf(tray);
|
const facing = railFacingOf(tray);
|
||||||
const onWestSide = e.fromNose ? facing === 'w' : facing === 'e';
|
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);
|
const cut = trackOrder(e.stock, facing);
|
||||||
cars.splice(k, 0, ...cut);
|
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.
|
// 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);
|
spendFreightBudget(s, playerAtSeat(s, trayySeat(tray)), tray, e.at, e.stock);
|
||||||
break;
|
break;
|
||||||
@@ -1901,9 +1905,10 @@ export function reduce(s: GameState, e: GameEvent): void {
|
|||||||
geometry: { kind: 'modifier', modifier: card.kind.modifier },
|
geometry: { kind: 'modifier', modifier: card.kind.modifier },
|
||||||
baseOperationalRail: false,
|
baseOperationalRail: false,
|
||||||
standing: [],
|
standing: [],
|
||||||
|
standingWest: 0,
|
||||||
facility: null,
|
facility: null,
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
enhancements: [],
|
enhancements: [],
|
||||||
});
|
});
|
||||||
applyModifier(area, e.placement, card.kind.modifier);
|
applyModifier(area, e.placement, card.kind.modifier);
|
||||||
} else if (card.kind.kind !== 'office') {
|
} else if (card.kind.kind !== 'office') {
|
||||||
@@ -2244,7 +2249,7 @@ function protoCard(
|
|||||||
kind: { kind: string; geometry?: string; facility?: string; hand?: string },
|
kind: { kind: string; geometry?: string; facility?: string; hand?: string },
|
||||||
variant: number | undefined,
|
variant: number | undefined,
|
||||||
): TrackCard | null {
|
): TrackCard | null {
|
||||||
const base = { standing: [], facility: null, modifiers: [], enhancements: [] };
|
const base = { standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [] };
|
||||||
|
|
||||||
if (kind.kind === 'track') {
|
if (kind.kind === 'track') {
|
||||||
const geometry = kind.geometry as TrackGeometry;
|
const geometry = kind.geometry as TrackGeometry;
|
||||||
@@ -2452,6 +2457,7 @@ function emptyCard(): TrackCard {
|
|||||||
geometry: { kind: 'limits' },
|
geometry: { kind: 'limits' },
|
||||||
baseOperationalRail: false,
|
baseOperationalRail: false,
|
||||||
standing: [],
|
standing: [],
|
||||||
|
standingWest: 0,
|
||||||
facility: null,
|
facility: null,
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
enhancements: [],
|
enhancements: [],
|
||||||
@@ -2724,6 +2730,7 @@ function limitsCard(): TrackCard {
|
|||||||
geometry: { kind: 'limits' },
|
geometry: { kind: 'limits' },
|
||||||
baseOperationalRail: true,
|
baseOperationalRail: true,
|
||||||
standing: [],
|
standing: [],
|
||||||
|
standingWest: 0,
|
||||||
facility: null,
|
facility: null,
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
enhancements: [],
|
enhancements: [],
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ function buildOfficeArea(seat: SeatIndex): OfficeArea {
|
|||||||
geometry: { kind: 'office' },
|
geometry: { kind: 'office' },
|
||||||
baseOperationalRail: true,
|
baseOperationalRail: true,
|
||||||
standing: [],
|
standing: [],
|
||||||
|
standingWest: 0,
|
||||||
facility: buildPassengerFacility('whistlePost'),
|
facility: buildPassengerFacility('whistlePost'),
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
enhancements: [],
|
enhancements: [],
|
||||||
@@ -162,6 +163,7 @@ function buildOfficeArea(seat: SeatIndex): OfficeArea {
|
|||||||
geometry: { kind: 'limits' },
|
geometry: { kind: 'limits' },
|
||||||
baseOperationalRail: true,
|
baseOperationalRail: true,
|
||||||
standing: [],
|
standing: [],
|
||||||
|
standingWest: 0,
|
||||||
facility: null,
|
facility: null,
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
enhancements: [],
|
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
|
* 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
|
* 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
|
* 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[];
|
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;
|
facility: Facility | null;
|
||||||
modifiers: ModifierKind[];
|
modifiers: ModifierKind[];
|
||||||
/** Enhancement cards laid on this card (§7 of implications.md). */
|
/** 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.
|
* puts the engine on the other end, which is the one thing the arrow exists to show.
|
||||||
*/
|
*/
|
||||||
railFacing?: 'e' | 'w';
|
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;
|
position: NodeRef;
|
||||||
movesUsed: number;
|
movesUsed: number;
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-10
@@ -316,15 +316,6 @@ export type MoveContext = {
|
|||||||
consistSize: number;
|
consistSize: number;
|
||||||
/** The tray making the move, so it does not block itself. */
|
/** The tray making the move, so it does not block itself. */
|
||||||
self: TrayId;
|
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 {
|
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.
|
* 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 startKey = coordKey(start);
|
||||||
const queue: Frontier[] = [
|
const queue: Frontier[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
+117
-38
@@ -51,6 +51,14 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
|||||||
const CW = { dp: 118, ml: 152, run: 78 };
|
const CW = { dp: 118, ml: 152, run: 78 };
|
||||||
const CH = 58;
|
const CH = 58;
|
||||||
const GAP = 6;
|
const GAP = 6;
|
||||||
|
/**
|
||||||
|
* ONE FIXED SLOT PER A/D TRACK, so the Office Running Track cell is drawn wide enough to hold
|
||||||
|
* them without spilling onto its neighbours (docs/plans/switching-paths.md — "The Roster Pass").
|
||||||
|
* Sized by CAPACITY, not by how many are occupied right now: a cell drawn for the trains it HAS
|
||||||
|
* holds still as they come and go, where sizing by occupancy moved the East Division Point (and
|
||||||
|
* everything past it) sideways every time an A/D track filled or cleared.
|
||||||
|
*/
|
||||||
|
const CHIP_W = 54;
|
||||||
/**
|
/**
|
||||||
* Room for the buffer stops. THE LABELS NO LONGER LIVE OUT HERE.
|
* Room for the buffer stops. THE LABELS NO LONGER LIVE OUT HERE.
|
||||||
*
|
*
|
||||||
@@ -128,7 +136,7 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
|||||||
// No regions inside a district: a crew moves by Moves there, not by Stages, so it
|
// No regions inside a district: a crew moves by Moves there, not by Stages, so it
|
||||||
// occupies a card outright rather than a part of one.
|
// occupies a card outright rather than a part of one.
|
||||||
regions: 0,
|
regions: 0,
|
||||||
w: CW.run,
|
w: isOffice && cap !== null ? Math.max(CW.run, cap * CHIP_W + 12) : CW.run,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// A crew below the Running Track has no position ON it, so it is reported against the
|
// A crew below the Running Track has no position ON it, so it is reported against the
|
||||||
@@ -301,12 +309,24 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
|||||||
const loaded = cars.filter((x) => /^loaded/.test(x) || /caboose/.test(x)).length;
|
const loaded = cars.filter((x) => /^loaded/.test(x) || /caboose/.test(x)).length;
|
||||||
const label = cars.length === 0 ? `${t.label} ${arrow}` : `${t.label} ${arrow}${cars.length}`;
|
const label = cars.length === 0 ? `${t.label} ${arrow}` : `${t.label} ${arrow}${cars.length}`;
|
||||||
|
|
||||||
const w = Math.min(c.w - 8, label.length * 6.6 + 12);
|
/**
|
||||||
|
* THE OFFICE RUNNING CELL GETS FIXED SLOTS, ONE PER A/D TRACK — never a centre spread.
|
||||||
|
*
|
||||||
|
* Centred spreading pushes its outer chips outward as MORE trains arrive, and the cell was
|
||||||
|
* sized for the cards it holds, not for its trains — so two chips at a Station used to land at
|
||||||
|
* x 215–267 and 271–316 inside a cell spanning only 230–308, spilling onto the Limits cards
|
||||||
|
* either side. A fixed slot per A/D track cannot overflow the cell at any occupancy, because
|
||||||
|
* the cell was sized for exactly that many slots (see `CHIP_W` above).
|
||||||
|
*/
|
||||||
|
const isOfficeRun = c.kind === 'run' && c.cap !== null && c.cap > 0;
|
||||||
|
const slotW = isOfficeRun ? (c.w - 12) / c.cap! : 0;
|
||||||
|
const w = isOfficeRun ? Math.min(slotW - 4, label.length * 6.6 + 12) : Math.min(c.w - 8, label.length * 6.6 + 12);
|
||||||
// A train on a Mainline card sits in ITS region; anywhere else it just sits on the card.
|
// A train on a Mainline card sits in ITS region; anywhere else it just sits on the card.
|
||||||
const inRegion = c.regions > 1 && typeof t.region === 'number';
|
const inRegion = c.regions > 1 && typeof t.region === 'number';
|
||||||
const slotX = inRegion ? c.x + 6 + RW * (t.region ?? 0) + RW / 2 : c.x + c.w / 2;
|
const tx = isOfficeRun
|
||||||
const spread = inRegion ? 0 : (k - (c.trains.length - 1) / 2) * (w + 4);
|
? c.x + 6 + slotW * (k + 0.5)
|
||||||
const tx = slotX + spread;
|
: (inRegion ? c.x + 6 + RW * (t.region ?? 0) + RW / 2 : c.x + c.w / 2) +
|
||||||
|
(inRegion ? 0 : (k - (c.trains.length - 1) / 2) * (w + 4));
|
||||||
const dir = t.direction === 'west' ? ' \u25c0 west' : t.direction === 'east' ? ' east \u25b6' : '';
|
const dir = t.direction === 'west' ? ' \u25c0 west' : t.direction === 'east' ? ' east \u25b6' : '';
|
||||||
const stages =
|
const stages =
|
||||||
typeof t.stagesLeft === 'number'
|
typeof t.stagesLeft === 'number'
|
||||||
@@ -393,6 +413,12 @@ export function officeSvg(
|
|||||||
* there until the square failed to light up.
|
* there until the square failed to light up.
|
||||||
*/
|
*/
|
||||||
limits: { west: number; east: number } | null = null,
|
limits: { west: number; east: number } | null = null,
|
||||||
|
/**
|
||||||
|
* Which train's consist is drawn at the rail, on an Office card holding more than one — the value
|
||||||
|
* the "Which train are you switching?" picker already carries. Defaults to the first train on the
|
||||||
|
* card, so every call site that has never heard of this feature keeps working unchanged.
|
||||||
|
*/
|
||||||
|
selectedTrayId: string | null = null,
|
||||||
): string {
|
): string {
|
||||||
const W = 166;
|
const W = 166;
|
||||||
const H = 96;
|
const H = 96;
|
||||||
@@ -480,10 +506,21 @@ export function officeSvg(
|
|||||||
? 'mod'
|
? 'mod'
|
||||||
: 'trk';
|
: 'trk';
|
||||||
|
|
||||||
|
// An Office card's roster band (below) takes the y-band the BOARDS + ALIGHTS flow label used to
|
||||||
|
// print in, so that explanation folds into the card's own tooltip instead of being drawn — it
|
||||||
|
// prints identically on every Office of every tier and is already implied by the card name and
|
||||||
|
// the Porter pill, so nothing is lost by reading it on the card rather than beside it.
|
||||||
|
const isOfficeRoster = cell.adTracks !== null;
|
||||||
|
const flowFold =
|
||||||
|
isOfficeRoster && cell.facility
|
||||||
|
? '\n\nBOARDS + ALIGHTS: passengers only — no freight is handled here. They wait in a green ' +
|
||||||
|
'box, are boarded onto a coach by Porters, and arrive into a red one.'
|
||||||
|
: '';
|
||||||
|
|
||||||
// Addressable so a caller can outline a square as a legal destination without re-rendering.
|
// Addressable so a caller can outline a square as a legal destination without re-rendering.
|
||||||
// The explanation rides on the group as a tooltip rather than being printed: it is reference
|
// The explanation rides on the group as a tooltip rather than being printed: it is reference
|
||||||
// detail, read once, and printing it costs the space the board itself needs.
|
// detail, read once, and printing it costs the space the board itself needs.
|
||||||
out += `<g data-cell="${cell.row},${cell.col}" data-tip="${esc(cell.label)} — ${esc(cell.what)}" transform="translate(${x},${y})">`;
|
out += `<g data-cell="${cell.row},${cell.col}" data-tip="${esc(cell.label)} — ${esc(cell.what)}${esc(flowFold)}" transform="translate(${x},${y})">`;
|
||||||
out += `<rect class="bs-card bs-${kind}${cell.running ? ' bs-run' : ''}" x="0" y="0" width="${W}" height="${H}" rx="4"/>`;
|
out += `<rect class="bs-card bs-${kind}${cell.running ? ' bs-run' : ''}" x="0" y="0" width="${W}" height="${H}" rx="4"/>`;
|
||||||
|
|
||||||
// The rails. A Modifier is not track and deliberately gets none — that is why nothing can be
|
// The rails. A Modifier is not track and deliberately gets none — that is why nothing can be
|
||||||
@@ -515,20 +552,48 @@ export function officeSvg(
|
|||||||
|
|
||||||
out += `<text class="bs-cn" x="6" y="12">${esc(cell.label)}</text>`;
|
out += `<text class="bs-cn" x="6" y="12">${esc(cell.label)}</text>`;
|
||||||
|
|
||||||
|
out += `<text class="bs-coord" x="${W - 5}" y="12" text-anchor="end">${cell.row},${cell.col}</text>`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A/D TRACKS, as pips.
|
* THE ROSTER, one chip per A/D track — not one per train.
|
||||||
*
|
*
|
||||||
* The Office tooltip said "3 A/D tracks" and the card showed nothing — so the number that
|
* The Office is the one square where more than one train may legally stand at once (its A/D
|
||||||
* decides whether the next arrival is an automatic collision (§8.3) was invisible on the card it
|
* tracks, §2.1), and the old single `cell.train` field only ever showed whichever tray
|
||||||
* belongs to. There is no room to draw more rails, and the count is what matters: one pip per
|
* `s.trays` happened to yield first: a second train at a busy Station was counted in the old
|
||||||
* track, filled for taken.
|
* A/D pips and never drawn at all. `adTracks` of them, always, whether or not a train is
|
||||||
|
* holding there — the cell is drawn for the tracks it HAS, so a free one reads a dashed,
|
||||||
|
* dimmed chip saying "free" rather than simply vanishing.
|
||||||
|
*
|
||||||
|
* Selected train's chip is lit the same amber the crew strip and action buttons already use,
|
||||||
|
* and it is the one whose consist is drawn at the rail below. Every occupied chip carries
|
||||||
|
* `data-crew`, so the play page can wire a click to switch which train that is.
|
||||||
*/
|
*/
|
||||||
if (cell.ad) {
|
let selectedTrain: CellView['trains'][number] | null = null;
|
||||||
for (let i = 0; i < cell.ad.of; i++) {
|
if (cell.adTracks !== null) {
|
||||||
out += `<circle class="bs-ad${i < cell.ad.used ? ' bs-occ' : ''}" cx="${W - 9 - i * 9}" cy="10" r="3"/>`;
|
const adTracks = cell.adTracks;
|
||||||
|
const gaps = Math.max(0, adTracks - 1);
|
||||||
|
const chipW = Math.min(52, (W - 12 - 4 * gaps) / adTracks);
|
||||||
|
selectedTrain = cell.trains.find((t) => t.trayId === selectedTrayId) ?? cell.trains[0] ?? null;
|
||||||
|
for (let i = 0; i < adTracks; i++) {
|
||||||
|
const train = cell.trains[i] ?? null;
|
||||||
|
const cx = 6 + i * (chipW + 4);
|
||||||
|
if (!train) {
|
||||||
|
out +=
|
||||||
|
`<g class="bs-adchip mt"><rect x="${cx}" y="20" width="${chipW}" height="14" rx="3"/>` +
|
||||||
|
`<text x="${cx + chipW / 2}" y="30.5" text-anchor="middle">free</text></g>`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const arrow = train.facing === 'w' ? '◀' : '▶';
|
||||||
|
const chipLabel = chipW >= 40 ? `${train.label} ${arrow}${train.cars.length}` : `${train.label}${arrow}`;
|
||||||
|
const consistWords = train.cars.length === 0 ? 'no cars' : train.cars.join(', ');
|
||||||
|
const on = train.trayId === selectedTrain?.trayId;
|
||||||
|
out +=
|
||||||
|
`<g class="bs-adchip${on ? ' on' : ''}" data-crew="${esc(train.trayId)}" ` +
|
||||||
|
`data-tip="${esc(`${train.label} — carrying ${consistWords}`)}">` +
|
||||||
|
`<rect x="${cx}" y="20" width="${chipW}" height="14" rx="3"/>` +
|
||||||
|
`<text x="${cx + chipW / 2}" y="30.5" text-anchor="middle">${esc(chipLabel)}</text></g>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out += `<text class="bs-coord" x="${W - 5}" y="12" text-anchor="end">${cell.row},${cell.col}</text>`;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* THE LOAD PIPELINE, on the card itself, DRAWN THE WAY IT FLOWS.
|
* THE LOAD PIPELINE, on the card itself, DRAWN THE WAY IT FLOWS.
|
||||||
@@ -608,15 +673,19 @@ export function officeSvg(
|
|||||||
? 'BOARDS + ALIGHTS'
|
? 'BOARDS + ALIGHTS'
|
||||||
: f.allowsOut && f.allowsIn ? 'SHIPS + RECEIVES' : f.allowsOut ? 'SHIPS OUT' : 'RECEIVES';
|
: f.allowsOut && f.allowsIn ? 'SHIPS + RECEIVES' : f.allowsOut ? 'SHIPS OUT' : 'RECEIVES';
|
||||||
const flowCls = passenger || (f.allowsOut && f.allowsIn) ? 'bs-flow-both' : f.allowsOut ? 'bs-flow-out' : 'bs-flow-in';
|
const flowCls = passenger || (f.allowsOut && f.allowsIn) ? 'bs-flow-both' : f.allowsOut ? 'bs-flow-out' : 'bs-flow-in';
|
||||||
out += `<text class="bs-flow ${flowCls}" x="${W - 6}" y="26" text-anchor="end" data-tip="${esc(
|
// An Office card's roster band now occupies this y-band; its tooltip already folded into the
|
||||||
passenger
|
// card's own data-tip above, so the label is not printed twice.
|
||||||
? 'Passengers only — no freight is handled here. They wait in a green box, are boarded onto a coach by Porters, and arrive into a red one. There is no MEN | AT | WORK sign because that is a Freight Facility fitting.'
|
if (!isOfficeRoster) {
|
||||||
: f.allowsOut && f.allowsIn
|
out += `<text class="bs-flow ${flowCls}" x="${W - 6}" y="26" text-anchor="end" data-tip="${esc(
|
||||||
? 'This industry both ships and receives, so it has green boxes AND red ones: loads leave it on a car, and loads arrive on one.'
|
passenger
|
||||||
: f.allowsOut
|
? 'Passengers only — no freight is handled here. They wait in a green box, are boarded onto a coach by Porters, and arrive into a red one. There is no MEN | AT | WORK sign because that is a Freight Facility fitting.'
|
||||||
? 'This industry only SHIPS. A load starts in a green box, is worked along MEN | AT | WORK by Laborers, and comes off onto an empty car spotted on its track. It has no red boxes because nothing arrives here.'
|
: f.allowsOut && f.allowsIn
|
||||||
: 'This industry only RECEIVES. A loaded car is spotted on its track and Laborers work the load back along WORK | AT | MEN into a red box. It has no green boxes because nothing ships from here.',
|
? 'This industry both ships and receives, so it has green boxes AND red ones: loads leave it on a car, and loads arrive on one.'
|
||||||
)}">${esc(flow)}</text>`;
|
: f.allowsOut
|
||||||
|
? 'This industry only SHIPS. A load starts in a green box, is worked along MEN | AT | WORK by Laborers, and comes off onto an empty car spotted on its track. It has no red boxes because nothing arrives here.'
|
||||||
|
: 'This industry only RECEIVES. A loaded car is spotted on its track and Laborers work the load back along WORK | AT | MEN into a red box. It has no green boxes because nothing ships from here.',
|
||||||
|
)}">${esc(flow)}</text>`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* THE WORKERS, ON THE CARD.
|
* THE WORKERS, ON THE CARD.
|
||||||
@@ -672,14 +741,15 @@ export function officeSvg(
|
|||||||
* means "ahead" or "behind" is the engine's facing, which the tooltip says in words rather than
|
* means "ahead" or "behind" is the engine's facing, which the tooltip says in words rather than
|
||||||
* leaving the player to work out from the compass.
|
* leaving the player to work out from the compass.
|
||||||
*/
|
*/
|
||||||
const split = cell.train ? Math.max(0, Math.min(cell.cars.length, cell.standingWest ?? 0)) : 0;
|
const hasTrain = cell.trains.length > 0;
|
||||||
|
const split = hasTrain ? Math.max(0, Math.min(cell.cars.length, cell.standingWest)) : 0;
|
||||||
const shown = Math.min(cell.cars.length, 4);
|
const shown = Math.min(cell.cars.length, 4);
|
||||||
for (let i = 0; i < shown; i++) {
|
for (let i = 0; i < shown; i++) {
|
||||||
// With no train on the card there is no gap to leave, so the whole cut stays left-aligned.
|
// With no train on the card there is no gap to leave, so the whole cut stays left-aligned.
|
||||||
const westOfEngine = cell.train ? i < split : true;
|
const westOfEngine = hasTrain ? i < split : true;
|
||||||
const side = !cell.train
|
const side = !selectedTrain
|
||||||
? 'standing here'
|
? 'standing here'
|
||||||
: (cell.train.facing === 'e') === westOfEngine
|
: (selectedTrain.facing === 'e') === westOfEngine
|
||||||
? 'standing BEHIND the engine — it would couple onto the tail backing up'
|
? 'standing BEHIND the engine — it would couple onto the tail backing up'
|
||||||
: 'standing AHEAD of the engine — it would couple onto the nose pulling forward';
|
: 'standing AHEAD of the engine — it would couple onto the nose pulling forward';
|
||||||
const carTip = `${cell.cars[i]} ${side}`;
|
const carTip = `${cell.cars[i]} ${side}`;
|
||||||
@@ -740,17 +810,18 @@ export function officeSvg(
|
|||||||
(cell.enhancementsWhat ?? []).join(' · '),
|
(cell.enhancementsWhat ?? []).join(' · '),
|
||||||
)}">${esc(cell.enhancements.join(' · '))}</text>`;
|
)}">${esc(cell.enhancements.join(' · '))}</text>`;
|
||||||
}
|
}
|
||||||
if (cell.train) {
|
if (selectedTrain) {
|
||||||
/**
|
/**
|
||||||
* THE TRAIN, DRAWN AS IT SITS IN THE TRAY.
|
* THE SELECTED TRAIN, DRAWN AS IT SITS IN THE TRAY.
|
||||||
*
|
*
|
||||||
* The badge used to be a name and nothing more, which makes the switching game unplayable:
|
* The badge used to be a name and nothing more, which makes the switching game unplayable:
|
||||||
* every decision is about car ORDER — which car comes off next, which end a cut couples onto —
|
* every decision is about car ORDER — which car comes off next, which end a cut couples onto —
|
||||||
* and none of it was on the board. Now the engine and its cars are drawn in sequence, nose
|
* and none of it was on the board. Now the engine and its cars are drawn in sequence, nose
|
||||||
* first, with an arrow for which way the engine points, so "set out the caboose off the back"
|
* first, with an arrow for which way the engine points, so "set out the caboose off the back"
|
||||||
* is a thing you can see before you choose it.
|
* is a thing you can see before you choose it. Where an Office holds more than one train, only
|
||||||
|
* this one is drawn here — the rest are the roster chips above, each with its own tooltip.
|
||||||
*/
|
*/
|
||||||
const t = cell.train;
|
const t = selectedTrain;
|
||||||
const items: { label: string; kind: string; what: string }[] = t.cars.map((c) => ({
|
const items: { label: string; kind: string; what: string }[] = t.cars.map((c) => ({
|
||||||
label: /caboose/.test(c) ? 'cab' : /hopper/.test(c) ? 'hop' : /boxcar/.test(c) ? 'box'
|
label: /caboose/.test(c) ? 'cab' : /hopper/.test(c) ? 'hop' : /boxcar/.test(c) ? 'box'
|
||||||
: /reefer/.test(c) ? 'rfr' : /tank/.test(c) ? 'tnk' : /coach/.test(c) ? 'cch' : '?',
|
: /reefer/.test(c) ? 'rfr' : /tank/.test(c) ? 'tnk' : /coach/.test(c) ? 'cch' : '?',
|
||||||
@@ -908,10 +979,16 @@ export const BOARD_CSS = `
|
|||||||
.bs-tcar.bs-t-mt{fill:#6b5a2e}
|
.bs-tcar.bs-t-mt{fill:#6b5a2e}
|
||||||
.bs-tcar.bs-t-eng{fill:#2b2313;stroke:#e0c060;stroke-width:1.4}
|
.bs-tcar.bs-t-eng{fill:#2b2313;stroke:#e0c060;stroke-width:1.4}
|
||||||
.bs-tcarlab{font:8px ui-monospace,monospace;fill:#2b2313;font-weight:700}
|
.bs-tcarlab{font:8px ui-monospace,monospace;fill:#2b2313;font-weight:700}
|
||||||
/* One pip per A/D track, filled when a train is holding on it. A full Office is why the next
|
/* One chip per A/D track, not one per train — a full Office is why the next arrival collides, so
|
||||||
arrival collides, so this is the count worth seeing without hovering. */
|
the count is worth seeing without hovering, and a free track reads "free" rather than vanishing.
|
||||||
.bs-ad{fill:none;stroke:#4d6fa8;stroke-width:1.2}
|
The selected train's chip is lit the same amber the crew strip and action buttons use. */
|
||||||
.bs-ad.bs-occ{fill:#5aa9e6;stroke:#8fc4ee}
|
.bs-adchip rect{fill:#20262e;stroke:#4a5361;stroke-width:1}
|
||||||
|
.bs-adchip text{fill:#cfd6e0;font:600 8px ui-monospace,monospace}
|
||||||
|
.bs-adchip.on rect{fill:#8a6d1f;stroke:#e0c060;stroke-width:1.4}
|
||||||
|
.bs-adchip.on text{fill:#fff4d6}
|
||||||
|
.bs-adchip.mt rect{fill:none;stroke:#3d4650;stroke-dasharray:3 2}
|
||||||
|
.bs-adchip.mt text{fill:#6b7480}
|
||||||
|
.bs-adchip[data-crew]{cursor:pointer}
|
||||||
.bs-t-mt + .bs-tcarlab,.bs-t-eng + .bs-tcarlab{fill:#f0e6cf}
|
.bs-t-mt + .bs-tcarlab,.bs-t-eng + .bs-tcarlab{fill:#f0e6cf}
|
||||||
.bs-tlab{fill:#eaf6ec;font:600 11px ui-monospace,monospace}
|
.bs-tlab{fill:#eaf6ec;font:600 11px ui-monospace,monospace}
|
||||||
/* The three letters sit ON the slot, so they invert with it: dark on a loaded car's solid face,
|
/* The three letters sit ON the slot, so they invert with it: dark on a loaded car's solid face,
|
||||||
@@ -968,9 +1045,11 @@ g[data-cell].bs-legal:hover .bs-card,.bs-ghost:hover rect{fill:#233246}
|
|||||||
g[data-cell].bs-point .bs-card,g[data-ghost].bs-point rect{stroke:#f2f5f8;stroke-width:3.5;stroke-dasharray:none}
|
g[data-cell].bs-point .bs-card,g[data-ghost].bs-point rect{stroke:#f2f5f8;stroke-width:3.5;stroke-dasharray:none}
|
||||||
g[data-cell].bs-point .bs-card{fill:#2b3b52}
|
g[data-cell].bs-point .bs-card{fill:#2b3b52}
|
||||||
/* WHERE THE CREW CAN GO. Amber, the colour this game spends on "you can do this", so a reachable
|
/* WHERE THE CREW CAN GO. Amber, the colour this game spends on "you can do this", so a reachable
|
||||||
card reads the same way an action button does. bs-from is where it is standing now. */
|
card reads the same way an action button does. bs-from is where it is standing now — marked on
|
||||||
|
the crew STRIP rather than the whole card, because the Office is the one square where a second
|
||||||
|
train may share that card: outlining the card would claim it belongs to both. */
|
||||||
g[data-cell].bs-focus .bs-card{stroke:#e0c060;stroke-width:2.5}
|
g[data-cell].bs-focus .bs-card{stroke:#e0c060;stroke-width:2.5}
|
||||||
g[data-cell].bs-from .bs-card{stroke:#7a6a3a;stroke-width:2;stroke-dasharray:4 3}
|
.bs-crew.bs-from rect:first-of-type{stroke:#7a6a3a;stroke-width:2;stroke-dasharray:4 3}
|
||||||
/* AND WHERE IT CANNOT. Muted red with a barred face — a card that is refusing traffic, not one that
|
/* AND WHERE IT CANNOT. Muted red with a barred face — a card that is refusing traffic, not one that
|
||||||
is merely uninteresting. The reason rides on the card's own tooltip. */
|
is merely uninteresting. The reason rides on the card's own tooltip. */
|
||||||
g[data-cell].bs-blocked .bs-card{stroke:#8a5a5a;stroke-width:2;stroke-dasharray:2 3}
|
g[data-cell].bs-blocked .bs-card{stroke:#8a5a5a;stroke-width:2;stroke-dasharray:2 3}
|
||||||
|
|||||||
+16
-11
@@ -202,9 +202,9 @@ export function compress(frames: Frame[]): Packed {
|
|||||||
// Identity, not equality: `snapshot` pushes the very same FacilityView object into both the
|
// Identity, not equality: `snapshot` pushes the very same FacilityView object into both the
|
||||||
// cell and the frame's `facilities`, so this always resolves.
|
// cell and the frame's `facilities`, so this always resolves.
|
||||||
const fi = c.facility ? f.facilities.indexOf(c.facility) : -1;
|
const fi = c.facility ? f.facilities.indexOf(c.facility) : -1;
|
||||||
// `train` rides whole rather than being interned: it changes almost every frame, so a table
|
// `trains` rides whole rather than being interned: it changes almost every frame, so a table
|
||||||
// of them would be as long as the frames are and buy nothing.
|
// of them would be as long as the frames are and buy nothing.
|
||||||
return [ci, wi, c.enhancements, c.tray, c.cars, fi, c.train, c.ad, c.enhancementsWhat, c.standingWest];
|
return [ci, wi, c.enhancements, c.cars, fi, c.trains, c.adTracks, c.enhancementsWhat, c.standingWest];
|
||||||
});
|
});
|
||||||
return { ...f, cells } as unknown as Frame;
|
return { ...f, cells } as unknown as Frame;
|
||||||
});
|
});
|
||||||
@@ -238,21 +238,26 @@ export function rehydrateCells(
|
|||||||
facs: unknown[],
|
facs: unknown[],
|
||||||
): unknown[] {
|
): unknown[] {
|
||||||
return packed.map((row) => {
|
return packed.map((row) => {
|
||||||
const p = row as [number, number, string[], string | null, string[], number, unknown, unknown, string[], number];
|
const p = row as [number, number, string[], string[], number, unknown, unknown, string[], number];
|
||||||
const c = cards[p[0]] as [number, number, string, string, boolean, string[]];
|
const c = cards[p[0]] as [number, number, string, string, boolean, string[]];
|
||||||
|
// Tolerant the same way `standingWest` below is: an array rides through as-is, a lone object
|
||||||
|
// (an older recording's singular `train`) is wrapped into a one-train roster, and null or
|
||||||
|
// undefined reads as no train at all.
|
||||||
|
const rawTrains = p[5];
|
||||||
|
const trains = Array.isArray(rawTrains) ? rawTrains : rawTrains ? [rawTrains] : [];
|
||||||
return {
|
return {
|
||||||
row: c[0], col: c[1], kind: c[2], label: c[3], running: c[4], links: c[5],
|
row: c[0], col: c[1], kind: c[2], label: c[3], running: c[4], links: c[5],
|
||||||
what: whats[p[1]], enhancements: p[2], tray: p[3], cars: p[4],
|
what: whats[p[1]], enhancements: p[2], cars: p[3],
|
||||||
facility: p[5] < 0 ? null : facs[p[5]],
|
facility: p[4] < 0 ? null : facs[p[4]],
|
||||||
train: p[6] ?? null,
|
trains,
|
||||||
ad: p[7] ?? null,
|
adTracks: (p[6] as number | undefined) ?? null,
|
||||||
// Carried rather than recomputed: this function is emitted into the page by toString() and so
|
// Carried rather than recomputed: this function is emitted into the page by toString() and so
|
||||||
// cannot reach the card catalogue that produced the text.
|
// cannot reach the card catalogue that produced the text.
|
||||||
enhancementsWhat: p[8] ?? [],
|
enhancementsWhat: p[7] ?? [],
|
||||||
// Where the train on this card stands among the cars standing on it, so the replay draws a cut
|
// Where the train(s) on this card stand among the cars standing on it, so the replay draws a
|
||||||
// ahead of or behind the engine exactly as the live board does. Absent in older recordings,
|
// cut ahead of or behind the engine exactly as the live board does. Absent in older recordings,
|
||||||
// which read as 0 — the whole cut east of the engine, which is what they used to draw anyway.
|
// which read as 0 — the whole cut east of the engine, which is what they used to draw anyway.
|
||||||
standingWest: p[9] ?? 0,
|
standingWest: p[8] ?? 0,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-47
@@ -44,7 +44,7 @@ import {
|
|||||||
mainlineDescription,
|
mainlineDescription,
|
||||||
} from '../engine/content.ts';
|
} from '../engine/content.ts';
|
||||||
import type { Intent } from '../engine/intents.ts';
|
import type { Intent } from '../engine/intents.ts';
|
||||||
import type { Facility, GameState, PlayerIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts';
|
import type { Facility, GameState, PlayerIndex, SeatIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts';
|
||||||
import { carsOn, playerAtSeat, railFacingOf, seatOf, turnOf } from '../engine/state.ts';
|
import { carsOn, playerAtSeat, railFacingOf, seatOf, turnOf } from '../engine/state.ts';
|
||||||
import type { Hand, HouseRules, TrackGeometry } from '../engine/content.ts';
|
import type { Hand, HouseRules, TrackGeometry } from '../engine/content.ts';
|
||||||
import type { Port } from '../engine/track.ts';
|
import type { Port } from '../engine/track.ts';
|
||||||
@@ -71,19 +71,25 @@ export type CellView = {
|
|||||||
* `toString()`), so it cannot reach the card catalogue itself.
|
* `toString()`), so it cannot reach the card catalogue itself.
|
||||||
*/
|
*/
|
||||||
enhancementsWhat: string[];
|
enhancementsWhat: string[];
|
||||||
tray: string | null;
|
|
||||||
/**
|
/**
|
||||||
* THE TRAIN STANDING HERE, in order, with the engine in it and which way it points.
|
* EVERY TRAIN STANDING HERE, in order, each with the engine in it and which way it points.
|
||||||
*
|
*
|
||||||
* The switching game is entirely about car ORDER — which car is next to come off, which end a cut
|
* The switching game is entirely about car ORDER — which car is next to come off, which end a cut
|
||||||
* couples onto — and the board showed a crew badge with a name and nothing else. A player could
|
* couples onto — and the board showed a crew badge with a name and nothing else. A player could
|
||||||
* not plan a move at all: "drop 1 car" tells you nothing when you cannot see what is on the back.
|
* not plan a move at all: "drop 1 car" tells you nothing when you cannot see what is on the back.
|
||||||
*
|
*
|
||||||
|
* An ARRAY because the Office square is the one place more than one train may legally stand at
|
||||||
|
* once (docs/plans/switching-paths.md — "The Roster Pass"): the old singular `train` field only
|
||||||
|
* ever showed whichever tray `s.trays` happened to yield first, so a second train at a busy
|
||||||
|
* Station was never drawn at all, only counted in the A/D pips. Empty, not absent, when the card
|
||||||
|
* is bare, so callers never need an `?? []`.
|
||||||
|
*
|
||||||
* `cars` runs nose first, matching the tray; `engineAt` is where the locomotive sits in it, and
|
* `cars` runs nose first, matching the tray; `engineAt` is where the locomotive sits in it, and
|
||||||
* `facing` is which way the engine points — EAST OR WEST, never north or south, whatever the
|
* `facing` is which way the engine points — EAST OR WEST, never north or south, whatever the
|
||||||
* track under it runs. See `railFacingOf` in state.ts for why, and why the type says so.
|
* track under it runs. See `railFacingOf` in state.ts for why, and why the type says so.
|
||||||
*/
|
*/
|
||||||
train: {
|
trains: {
|
||||||
|
trayId: string;
|
||||||
label: string;
|
label: string;
|
||||||
cars: string[];
|
cars: string[];
|
||||||
engineAt: number;
|
engineAt: number;
|
||||||
@@ -96,26 +102,31 @@ export type CellView = {
|
|||||||
* standing in front of them — the card is face down in a box somewhere by then.
|
* standing in front of them — the card is face down in a box somewhere by then.
|
||||||
*/
|
*/
|
||||||
what: string;
|
what: string;
|
||||||
} | null;
|
}[];
|
||||||
/**
|
/**
|
||||||
* Office card only: A/D tracks taken and how many the tier has.
|
* Office card only: how many A/D tracks the tier has — null everywhere else.
|
||||||
*
|
*
|
||||||
* The tooltip said "3 A/D tracks" and the card showed nothing, so the number that decides whether
|
* Capacity, not occupancy: `board-svg.ts` draws one roster chip per track regardless of how many
|
||||||
* the next arrival collides was invisible on the card it belongs to. Drawn as pips rather than
|
* are taken (a free one reads "free"), so occupancy is implicit in how many of `trains` land on
|
||||||
* extra rails — there is no room on the card for more track, and the count is what matters.
|
* it. Used to carry `{used, of}` and draw one pip per track; the pips are gone (the roster band
|
||||||
|
* takes their place) but the count is still needed to know how many chips to lay out.
|
||||||
*/
|
*/
|
||||||
ad: { used: number; of: number } | null;
|
adTracks: number | null;
|
||||||
cars: string[];
|
cars: string[];
|
||||||
/**
|
/**
|
||||||
* Cars in `cars` that stand WEST of the train on this card — 0 when no train is here.
|
* Cars in `cars` that stand WEST of the train(s) here — 0 when no train is here, and unsplit if
|
||||||
|
* so: with nobody standing on the card, a cut has no near or far side and the whole row simply
|
||||||
|
* reads west to east (`board-svg.ts` draws the split only while `trains.length > 0`).
|
||||||
*
|
*
|
||||||
* `cars` runs west to east like the state it comes from, and a train standing on the card sits
|
* `cars` runs west to east like the state it comes from, and a train standing on the card sits
|
||||||
* somewhere IN that row rather than beside it. Reported from play: "right after dropping my cars I
|
* somewhere IN that row rather than beside it. Reported from play: "right after dropping my cars I
|
||||||
* need to be able to see if those cars are ahead or behind the train" — and the board drew the
|
* need to be able to see if those cars are ahead or behind the train" — and the board drew the
|
||||||
* whole cut in one left-aligned strip at the bottom of the card, which cannot answer that at all.
|
* whole cut in one left-aligned strip at the bottom of the card, which cannot answer that at all.
|
||||||
*
|
*
|
||||||
* Combined with `train.facing` it is the answer: for an east-facing engine the cars east of it are
|
* Combined with a train's `facing` it is the answer: for an east-facing engine the cars east of it
|
||||||
* the ones ahead, and the split is drawn as a gap the chip sits in.
|
* are the ones ahead. ONE number for the whole card — every train standing here (there is at most
|
||||||
|
* one, except the Office) reads the same split, taken against the block of A/D tracks rather than
|
||||||
|
* against any one engine, straight off `TrackCard.standingWest`.
|
||||||
*/
|
*/
|
||||||
standingWest: number;
|
standingWest: number;
|
||||||
facility: FacilityView | null;
|
facility: FacilityView | null;
|
||||||
@@ -591,29 +602,31 @@ function baseOf(
|
|||||||
* Zero when there is no train, which is also the right answer for a bare cut: with nobody standing
|
* Zero when there is no train, which is also the right answer for a bare cut: with nobody standing
|
||||||
* there, a cut has no near or far side and the whole row simply reads west to east.
|
* there, a cut has no near or far side and the whole row simply reads west to east.
|
||||||
*/
|
*/
|
||||||
function standingWestOn(s: GameState, key: string, cars: number): number {
|
/**
|
||||||
for (const t of s.trays.values()) {
|
* Every train standing on this card, for THIS viewer's seat.
|
||||||
if (t.position.at !== 'grid') continue;
|
*
|
||||||
if (`${t.position.coord.row},${t.position.coord.col}` !== key) continue;
|
* KEYED BY COORDINATE, so it must be filtered by seat first — every district uses the same (row,
|
||||||
return Math.max(0, Math.min(cars, t.standingWest ?? 0));
|
* col) origin, so without the seat check a crew standing at (0,1) in one player's Office Area would
|
||||||
}
|
* be drawn onto (0,1) of every other player's board too. Collects every match rather than returning
|
||||||
return 0;
|
* on the first: the Office square is the one place more than one train may legally stand at once
|
||||||
}
|
* (docs/plans/switching-paths.md), and the old singular version silently drew only whichever tray
|
||||||
|
* `s.trays` happened to yield first.
|
||||||
function trainOnCard(s: GameState, key: string): CellView['train'] {
|
*/
|
||||||
|
function trainsOnCard(s: GameState, viewerSeat: SeatIndex, key: string): CellView['trains'] {
|
||||||
|
const out: CellView['trains'] = [];
|
||||||
for (const [id, t] of s.trays) {
|
for (const [id, t] of s.trays) {
|
||||||
if (t.position.at !== 'grid') continue;
|
if (t.position.at !== 'grid' || t.position.seat !== viewerSeat) continue;
|
||||||
if (`${t.position.coord.row},${t.position.coord.col}` !== key) continue;
|
if (`${t.position.coord.row},${t.position.coord.col}` !== key) continue;
|
||||||
return {
|
out.push({
|
||||||
|
trayId: id,
|
||||||
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
|
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
|
||||||
cars: t.consist.map(carLabel),
|
cars: t.consist.map(carLabel),
|
||||||
engineAt: Math.max(0, Math.min(t.consist.length, t.engineAt)),
|
engineAt: Math.max(0, Math.min(t.consist.length, t.engineAt)),
|
||||||
facing: railFacingOf(t),
|
facing: railFacingOf(t),
|
||||||
what: t.trainNumber === null ? 'A local crew — no timetable, no card, no special rules.' : trainRules(t),
|
what: t.trainNumber === null ? 'A local crew — no timetable, no card, no special rules.' : trainRules(t),
|
||||||
};
|
});
|
||||||
void id;
|
|
||||||
}
|
}
|
||||||
return null;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Is a car spotted that a load on WORK could actually come off onto (§9.3)? */
|
/** Is a car spotted that a load on WORK could actually come off onto (§9.3)? */
|
||||||
@@ -975,18 +988,6 @@ export function snapshot(
|
|||||||
): Frame {
|
): Frame {
|
||||||
const area = areaOf(s, viewer);
|
const area = areaOf(s, viewer);
|
||||||
const viewerSeat = seatOf(s, viewer);
|
const viewerSeat = seatOf(s, viewer);
|
||||||
const trayAt = new Map<string, string>();
|
|
||||||
for (const [id, tray] of s.trays) {
|
|
||||||
// KEYED BY COORDINATE, so it must be filtered by seat first. Every district uses the same
|
|
||||||
// (row, col) origin, so without this a crew standing at (0,1) in one player's Office Area is
|
|
||||||
// drawn onto (0,1) of every other player's board — the cells come from `area.grid`, which is
|
|
||||||
// the viewer's, but the train on them came from anybody's.
|
|
||||||
if (tray.position.at === 'grid' && tray.position.seat === viewerSeat) {
|
|
||||||
const label = tray.trainNumber === null ? 'crew' : `T${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`;
|
|
||||||
const carrying = tray.consist.length ? ` [${tray.consist.map(carLabel).join(', ')}]` : ' [empty]';
|
|
||||||
trayAt.set(`${tray.position.coord.row},${tray.position.coord.col}`, label + carrying);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cells: CellView[] = [];
|
const cells: CellView[] = [];
|
||||||
const facilities: FacilityView[] = [];
|
const facilities: FacilityView[] = [];
|
||||||
@@ -1018,14 +1019,10 @@ export function snapshot(
|
|||||||
links: connectionsFor(card).map(([a, b]) => `${a}${b}`),
|
links: connectionsFor(card).map(([a, b]) => `${a}${b}`),
|
||||||
enhancements: card.enhancements.map(prettyKey),
|
enhancements: card.enhancements.map(prettyKey),
|
||||||
enhancementsWhat: card.enhancements.map((k) => enhancementText(k) ?? prettyKey(k)),
|
enhancementsWhat: card.enhancements.map((k) => enhancementText(k) ?? prettyKey(k)),
|
||||||
tray: trayAt.get(key) ?? null,
|
trains: trainsOnCard(s, viewerSeat, key),
|
||||||
train: trainOnCard(s, key),
|
adTracks: card.geometry.kind === 'office' ? officeProfile(area.tier).adTracks : null,
|
||||||
ad:
|
|
||||||
card.geometry.kind === 'office'
|
|
||||||
? { used: area.adOccupancy.length, of: officeProfile(area.tier).adTracks }
|
|
||||||
: null,
|
|
||||||
cars: carsOn(card).map(carLabel),
|
cars: carsOn(card).map(carLabel),
|
||||||
standingWest: standingWestOn(s, key, carsOn(card).length),
|
standingWest: card.standingWest,
|
||||||
facility: fv,
|
facility: fv,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-6
@@ -110,8 +110,8 @@ const esc = (s: string): string =>
|
|||||||
*/
|
*/
|
||||||
function piecePreview(links: string[], label: string): string {
|
function piecePreview(links: string[], label: string): string {
|
||||||
const cell = {
|
const cell = {
|
||||||
row: 0, col: 0, kind: 'trk', label, running: false, enhancements: [],
|
row: 0, col: 0, kind: 'trk', label, running: false, enhancements: [], enhancementsWhat: [],
|
||||||
tray: null, cars: [], facility: null, links, what: '',
|
trains: [], adTracks: null, cars: [], standingWest: 0, facility: null, links, what: '',
|
||||||
};
|
};
|
||||||
return officeSvg([cell] as never, 99).replace(
|
return officeSvg([cell] as never, 99).replace(
|
||||||
/<text class="bs-(coord|rowlab)"[^>]*>[^<]*<\/text>/g,
|
/<text class="bs-(coord|rowlab)"[^>]*>[^<]*<\/text>/g,
|
||||||
@@ -301,7 +301,14 @@ function render(): void {
|
|||||||
: 'ATTACH TO THIS CARD';
|
: 'ATTACH TO THIS CARD';
|
||||||
return [{ row: cell.row, col: cell.col, label }];
|
return [{ row: cell.row, col: cell.col, label }];
|
||||||
});
|
});
|
||||||
grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords, legalCaps, f.limits);
|
grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords, legalCaps, f.limits, selectedCrew);
|
||||||
|
|
||||||
|
// Wire the roster chips: clicking one sets `selectedCrew`, the same value the "Which train are
|
||||||
|
// you switching?" picker writes, so the board and the action panel drive one value either way.
|
||||||
|
for (const g of Array.from(grid.querySelectorAll('g[data-crew]'))) {
|
||||||
|
const trayId = (g as HTMLElement).dataset['crew'];
|
||||||
|
if (trayId) (g as unknown as HTMLElement).onclick = () => { selectedCrew = trayId; render(); };
|
||||||
|
}
|
||||||
|
|
||||||
// Highlighting rides on top of the drawing: outline the legal squares and make them clickable.
|
// Highlighting rides on top of the drawing: outline the legal squares and make them clickable.
|
||||||
for (const [key, list] of spotsAt) {
|
for (const [key, list] of spotsAt) {
|
||||||
@@ -337,8 +344,10 @@ function render(): void {
|
|||||||
*/
|
*/
|
||||||
const crew = pickedCrew(f);
|
const crew = pickedCrew(f);
|
||||||
if (crew && !forPlay) {
|
if (crew && !forPlay) {
|
||||||
const here = grid.querySelector(`g[data-cell="${crew.from.row},${crew.from.col}"]`);
|
// Marked on the crew strip, not the whole card: the Office is the one square a second train may
|
||||||
if (here) here.classList.add('bs-from');
|
// share, and outlining the card would claim it belongs to both.
|
||||||
|
const strip = grid.querySelector(`g[data-cell="${crew.from.row},${crew.from.col}"] .bs-crew`);
|
||||||
|
if (strip) strip.classList.add('bs-from');
|
||||||
for (const c of crew.to) {
|
for (const c of crew.to) {
|
||||||
const g = grid.querySelector(`g[data-cell="${c.row},${c.col}"]`);
|
const g = grid.querySelector(`g[data-cell="${c.row},${c.col}"]`);
|
||||||
if (g) g.classList.add('bs-focus');
|
if (g) g.classList.add('bs-focus');
|
||||||
@@ -629,7 +638,9 @@ function renderDistrict(f: Frame): void {
|
|||||||
else sec.classList.add('folded');
|
else sec.classList.add('folded');
|
||||||
|
|
||||||
const cars = f.cells.reduce((n, c) => n + c.cars.length, 0);
|
const cars = f.cells.reduce((n, c) => n + c.cars.length, 0);
|
||||||
const crew = f.cells.filter((c) => c.tray).length;
|
// Trains, not cards-with-a-crew: the Office is the one card that may hold more than one, and a
|
||||||
|
// card-count silently read "1 crew on the board" with two trains standing at a busy Station.
|
||||||
|
const crew = f.cells.reduce((n, c) => n + c.trains.length, 0);
|
||||||
$('districtsummary').textContent =
|
$('districtsummary').textContent =
|
||||||
`${f.cells.length} cards · ${f.facilities.length} facilities · ${cars} cars standing` +
|
`${f.cells.length} cards · ${f.facilities.length} facilities · ${cars} cars standing` +
|
||||||
(crew > 0 ? ` · ${crew} crew on the board` : '');
|
(crew > 0 ? ` · ${crew} crew on the board` : '');
|
||||||
|
|||||||
+13
-7
@@ -56,6 +56,7 @@ const straight = (standing: TrackCard['standing'] = []): TrackCard => ({
|
|||||||
geometry: { kind: 'track', geometry: 'straight' },
|
geometry: { kind: 'track', geometry: 'straight' },
|
||||||
baseOperationalRail: true,
|
baseOperationalRail: true,
|
||||||
standing,
|
standing,
|
||||||
|
standingWest: 0,
|
||||||
facility: null,
|
facility: null,
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
enhancements: [],
|
enhancements: [],
|
||||||
@@ -445,6 +446,7 @@ describe('Freight Agent operations (§6.3)', () => {
|
|||||||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||||
baseOperationalRail: true,
|
baseOperationalRail: true,
|
||||||
standing: [],
|
standing: [],
|
||||||
|
standingWest: 0,
|
||||||
facility: {
|
facility: {
|
||||||
kind: 'freight',
|
kind: 'freight',
|
||||||
subtype: 'mineTipple',
|
subtype: 'mineTipple',
|
||||||
@@ -576,6 +578,7 @@ describe('Load/Unload: the four-action freight pipeline (§9.3)', () => {
|
|||||||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||||
baseOperationalRail: true,
|
baseOperationalRail: true,
|
||||||
standing: [],
|
standing: [],
|
||||||
|
standingWest: 0,
|
||||||
facility: {
|
facility: {
|
||||||
kind: 'freight',
|
kind: 'freight',
|
||||||
subtype: 'mineTipple',
|
subtype: 'mineTipple',
|
||||||
@@ -757,6 +760,7 @@ describe('legalActions shares its rules with apply (component 6)', () => {
|
|||||||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||||
baseOperationalRail: true,
|
baseOperationalRail: true,
|
||||||
standing: [],
|
standing: [],
|
||||||
|
standingWest: 0,
|
||||||
facility: {
|
facility: {
|
||||||
kind: 'freight',
|
kind: 'freight',
|
||||||
subtype: 'mineTipple',
|
subtype: 'mineTipple',
|
||||||
@@ -944,7 +948,7 @@ describe('the Limits bound the district, and the nine spots reach round a Facili
|
|||||||
const area = areaOf(s, 0);
|
const area = areaOf(s, 0);
|
||||||
const plain = (geometry: object): TrackCard => ({
|
const plain = (geometry: object): TrackCard => ({
|
||||||
geometry: geometry as TrackCard['geometry'],
|
geometry: geometry as TrackCard['geometry'],
|
||||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||||
});
|
});
|
||||||
const col = area.limitsEast.col;
|
const col = area.limitsEast.col;
|
||||||
area.grid.set(coordKey(at(area.runningRow, col)), plain({
|
area.grid.set(coordKey(at(area.runningRow, col)), plain({
|
||||||
@@ -1294,7 +1298,7 @@ describe('Industry cards go on a stub, and lock each other out', () => {
|
|||||||
const area = areaOf(s, 0);
|
const area = areaOf(s, 0);
|
||||||
const plain = (geometry: object): TrackCard => ({
|
const plain = (geometry: object): TrackCard => ({
|
||||||
geometry: geometry as TrackCard['geometry'],
|
geometry: geometry as TrackCard['geometry'],
|
||||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||||
});
|
});
|
||||||
const col = area.limitsEast.col;
|
const col = area.limitsEast.col;
|
||||||
area.grid.set(coordKey(at(area.runningRow, col)), plain({
|
area.grid.set(coordKey(at(area.runningRow, col)), plain({
|
||||||
@@ -1326,7 +1330,7 @@ describe('Industry cards go on a stub, and lock each other out', () => {
|
|||||||
function build(s: GameState, kind: string, coord: GridCoord): void {
|
function build(s: GameState, kind: string, coord: GridCoord): void {
|
||||||
areaOf(s, 0).grid.set(coordKey(coord), {
|
areaOf(s, 0).grid.set(coordKey(coord), {
|
||||||
geometry: { kind: 'facility', facility: kind as never },
|
geometry: { kind: 'facility', facility: kind as never },
|
||||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1621,8 +1625,9 @@ describe('the engine is drawn pointing east or west, whatever track it is standi
|
|||||||
addCard(s, at(0, 0), straight());
|
addCard(s, at(0, 0), straight());
|
||||||
reduce(s, moved(id, 's'));
|
reduce(s, moved(id, 's'));
|
||||||
const cell = snapshot(s, [], null).cells.find((c) => c.row === 0 && c.col === 0);
|
const cell = snapshot(s, [], null).cells.find((c) => c.row === 0 && c.col === 0);
|
||||||
assert.ok(cell?.train, 'the train should be on the card');
|
const train = cell?.trains[0];
|
||||||
assert.ok(cell.train.facing === 'e' || cell.train.facing === 'w', `got ${cell.train.facing}`);
|
assert.ok(train, 'the train should be on the card');
|
||||||
|
assert.ok(train.facing === 'e' || train.facing === 'w', `got ${train.facing}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1646,6 +1651,7 @@ describe('a train that rounds a curve points where the curve took it', () => {
|
|||||||
geometry: { kind: 'track', geometry: 'curved', arc, hand: 'right' },
|
geometry: { kind: 'track', geometry: 'curved', arc, hand: 'right' },
|
||||||
baseOperationalRail: true,
|
baseOperationalRail: true,
|
||||||
standing: [],
|
standing: [],
|
||||||
|
standingWest: 0,
|
||||||
facility: null,
|
facility: null,
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
enhancements: [],
|
enhancements: [],
|
||||||
@@ -1782,11 +1788,11 @@ describe('the switching job a player actually does: put a car in a siding, take
|
|||||||
*/
|
*/
|
||||||
const curve = (arc: 'ne' | 'nw' | 'se' | 'sw'): TrackCard => ({
|
const curve = (arc: 'ne' | 'nw' | 'se' | 'sw'): TrackCard => ({
|
||||||
geometry: { kind: 'track', geometry: 'curved', arc, hand: 'right' },
|
geometry: { kind: 'track', geometry: 'curved', arc, hand: 'right' },
|
||||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||||
});
|
});
|
||||||
const turnout = (o: { stem: 'n' | 's' | 'e' | 'w'; through: 'n' | 's' | 'e' | 'w'; diverge: 'n' | 's' | 'e' | 'w' }): TrackCard => ({
|
const turnout = (o: { stem: 'n' | 's' | 'e' | 'w'; through: 'n' | 's' | 'e' | 'w'; diverge: 'n' | 's' | 'e' | 'w' }): TrackCard => ({
|
||||||
geometry: { kind: 'track', geometry: 'turnout', turnout: o, hand: 'right' },
|
geometry: { kind: 'track', geometry: 'turnout', turnout: o, hand: 'right' },
|
||||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
it('takes a cut off the Running Track into a siding and sets it out', () => {
|
it('takes a cut off the Running Track into a siding and sets it out', () => {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const straight = (standing: RollingStock[] = []): TrackCard => ({
|
|||||||
geometry: { kind: 'track', geometry: 'straight' },
|
geometry: { kind: 'track', geometry: 'straight' },
|
||||||
baseOperationalRail: true,
|
baseOperationalRail: true,
|
||||||
standing,
|
standing,
|
||||||
|
standingWest: 0,
|
||||||
facility: null,
|
facility: null,
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
enhancements: [],
|
enhancements: [],
|
||||||
@@ -149,7 +150,14 @@ describe('a cut parks in the same order however many cars come off at once', ()
|
|||||||
|
|
||||||
// Facing east: the nose cut (boxcar) is set out east of the engine, the tail cut (tank) west.
|
// Facing east: the nose cut (boxcar) is set out east of the engine, the tail cut (tank) west.
|
||||||
assert.deepEqual(standingAt(s, at(1, 0)), ['tank', 'boxcar']);
|
assert.deepEqual(standingAt(s, at(1, 0)), ['tank', 'boxcar']);
|
||||||
assert.equal(s.trays.get(id)!.standingWest, 1, 'the engine should be standing between the two cuts');
|
// `standingWest` lives on the CARD now, not the tray (docs/plans/switching-paths.md — "The
|
||||||
|
// Roster Pass"): the Office is the one square more than one train may share, and a cut lies
|
||||||
|
// west or east of the block of A/D tracks rather than of any one engine.
|
||||||
|
assert.equal(
|
||||||
|
areaOf(s, 0).grid.get(coordKey(at(1, 0)))!.standingWest,
|
||||||
|
1,
|
||||||
|
'the engine should be standing between the two cuts',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const straight = (): TrackCard => ({
|
|||||||
geometry: { kind: 'track', geometry: 'straight' },
|
geometry: { kind: 'track', geometry: 'straight' },
|
||||||
baseOperationalRail: true,
|
baseOperationalRail: true,
|
||||||
standing: [],
|
standing: [],
|
||||||
|
standingWest: 0,
|
||||||
facility: null,
|
facility: null,
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
enhancements: [],
|
enhancements: [],
|
||||||
|
|||||||
@@ -313,6 +313,7 @@ describe('Flying Switch rolls a cut into an industry', () => {
|
|||||||
geometry: { kind: 'track', geometry: 'straight' },
|
geometry: { kind: 'track', geometry: 'straight' },
|
||||||
baseOperationalRail: true,
|
baseOperationalRail: true,
|
||||||
standing: [],
|
standing: [],
|
||||||
|
standingWest: 0,
|
||||||
facility: null,
|
facility: null,
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
enhancements: [],
|
enhancements: [],
|
||||||
|
|||||||
@@ -450,7 +450,7 @@ describe('the view shows one seat at a time', () => {
|
|||||||
for (let n = 0; n < p; n++) {
|
for (let n = 0; n < p; n++) {
|
||||||
area.grid.set(coordKey({ row: area.runningRow - 1, col: n }), {
|
area.grid.set(coordKey({ row: area.runningRow - 1, col: n }), {
|
||||||
geometry: { kind: 'track', geometry: 'straight' },
|
geometry: { kind: 'track', geometry: 'straight' },
|
||||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
s.decks.hands.set(p, source.slice(0, p + 1));
|
s.decks.hands.set(p, source.slice(0, p + 1));
|
||||||
@@ -527,7 +527,7 @@ describe('the view shows one seat at a time', () => {
|
|||||||
readyToLeave(s, 1, 'crew-1', 'east');
|
readyToLeave(s, 1, 'crew-1', 'east');
|
||||||
|
|
||||||
const withTray = (p: PlayerIndex): number =>
|
const withTray = (p: PlayerIndex): number =>
|
||||||
snapshot(s, [], null, null, null, false, p).cells.filter((c) => c.tray != null).length;
|
snapshot(s, [], null, null, null, false, p).cells.filter((c) => c.trains.length > 0).length;
|
||||||
assert.equal(withTray(1), 1, "the crew is not on its own seat's board");
|
assert.equal(withTray(1), 1, "the crew is not on its own seat's board");
|
||||||
assert.deepEqual([withTray(0), withTray(2)], [0, 0], "a crew was drawn onto another seat's board");
|
assert.deepEqual([withTray(0), withTray(2)], [0, 0], "a crew was drawn onto another seat's board");
|
||||||
});
|
});
|
||||||
@@ -594,7 +594,7 @@ describe('scoring lands on the right seat', () => {
|
|||||||
const sizeBefore = areaAtSeat(s, 2).grid.size;
|
const sizeBefore = areaAtSeat(s, 2).grid.size;
|
||||||
mine.grid.set(coordKey({ row: mine.runningRow - 1, col: 0 }), {
|
mine.grid.set(coordKey({ row: mine.runningRow - 1, col: 0 }), {
|
||||||
geometry: { kind: 'track', geometry: 'straight' },
|
geometry: { kind: 'track', geometry: 'straight' },
|
||||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||||
});
|
});
|
||||||
assert.equal(areaAtSeat(s, 2).grid.size, sizeBefore, "one seat's track appeared in another's district");
|
assert.equal(areaAtSeat(s, 2).grid.size, sizeBefore, "one seat's track appeared in another's district");
|
||||||
assert.notEqual(areaAtSeat(s, 1), areaAtSeat(s, 2), 'two seats share one Office Area object');
|
assert.notEqual(areaAtSeat(s, 1), areaAtSeat(s, 2), 'two seats share one Office Area object');
|
||||||
|
|||||||
@@ -368,6 +368,34 @@ describe('the replay wire format is lossless (regression)', () => {
|
|||||||
assert.ok(checked > 100, `only ${checked} frames checked`);
|
assert.ok(checked > 100, `only ${checked} frames checked`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("tolerates an older recording's singular train at the trains slot", () => {
|
||||||
|
// Before "The Roster Pass" (docs/plans/switching-paths.md), the trains slot held one train
|
||||||
|
// object or null — `rehydrateCells` has to make sense of that shape too, with no version flag
|
||||||
|
// anywhere in a packed row to key off.
|
||||||
|
const cards: unknown[] = [[0, 0, 'office', 'Whistle Post', true, ['ew']]];
|
||||||
|
const whats = ['a station'];
|
||||||
|
const facs: unknown[] = [];
|
||||||
|
const oldTrain = { trayId: 'crew', label: 'crew', cars: [], engineAt: 0, facing: 'e', what: '' };
|
||||||
|
|
||||||
|
const [withObject] = rehydrateCells([[0, 0, [], [], -1, oldTrain, 1, [], 0]], cards, whats, facs) as {
|
||||||
|
trains: unknown[];
|
||||||
|
}[];
|
||||||
|
assert.deepEqual(withObject!.trains, [oldTrain], 'a lone train object should wrap into a one-train roster');
|
||||||
|
|
||||||
|
const [withNull] = rehydrateCells([[0, 0, [], [], -1, null, 1, [], 0]], cards, whats, facs) as {
|
||||||
|
trains: unknown[];
|
||||||
|
}[];
|
||||||
|
assert.deepEqual(withNull!.trains, [], 'a null train slot should read as no train at all');
|
||||||
|
|
||||||
|
const [withArray] = rehydrateCells(
|
||||||
|
[[0, 0, [], [], -1, [oldTrain, oldTrain], 1, [], 0]],
|
||||||
|
cards,
|
||||||
|
whats,
|
||||||
|
facs,
|
||||||
|
) as { trains: unknown[] }[];
|
||||||
|
assert.equal(withArray!.trains.length, 2, 'an array should ride through as-is');
|
||||||
|
});
|
||||||
|
|
||||||
it('writes each distinct card and description once, not once per frame', () => {
|
it('writes each distinct card and description once, not once per frame', () => {
|
||||||
const rec = record(880009, 'standard', 4000);
|
const rec = record(880009, 'standard', 4000);
|
||||||
const packed = compress(rec.frames);
|
const packed = compress(rec.frames);
|
||||||
|
|||||||
+15
-6
@@ -48,6 +48,7 @@ const straight = (standing: RollingStock[] = []): TrackCard => ({
|
|||||||
geometry: { kind: 'track', geometry: 'straight' },
|
geometry: { kind: 'track', geometry: 'straight' },
|
||||||
baseOperationalRail: true,
|
baseOperationalRail: true,
|
||||||
standing,
|
standing,
|
||||||
|
standingWest: 0,
|
||||||
facility: null,
|
facility: null,
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
enhancements: [],
|
enhancements: [],
|
||||||
@@ -62,6 +63,7 @@ const curve = (arc: TrackArc, standing: RollingStock[] = []): TrackCard => ({
|
|||||||
geometry: { kind: 'track', geometry: 'curved', arc },
|
geometry: { kind: 'track', geometry: 'curved', arc },
|
||||||
baseOperationalRail: true,
|
baseOperationalRail: true,
|
||||||
standing,
|
standing,
|
||||||
|
standingWest: 0,
|
||||||
facility: null,
|
facility: null,
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
enhancements: [],
|
enhancements: [],
|
||||||
@@ -73,6 +75,7 @@ const turnout = (o?: TurnoutOrientation): TrackCard => ({
|
|||||||
: { kind: 'track', geometry: 'turnout' },
|
: { kind: 'track', geometry: 'turnout' },
|
||||||
baseOperationalRail: false, // no wheel icon — a train may pass through but not stop (§A.1)
|
baseOperationalRail: false, // no wheel icon — a train may pass through but not stop (§A.1)
|
||||||
standing: [],
|
standing: [],
|
||||||
|
standingWest: 0,
|
||||||
facility: null,
|
facility: null,
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
enhancements: [],
|
enhancements: [],
|
||||||
@@ -82,6 +85,7 @@ const officeCard = (): TrackCard => ({
|
|||||||
geometry: { kind: 'office' },
|
geometry: { kind: 'office' },
|
||||||
baseOperationalRail: true,
|
baseOperationalRail: true,
|
||||||
standing: [],
|
standing: [],
|
||||||
|
standingWest: 0,
|
||||||
facility: null,
|
facility: null,
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
enhancements: [],
|
enhancements: [],
|
||||||
@@ -91,6 +95,7 @@ const lockedFacility = (): TrackCard => ({
|
|||||||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||||
baseOperationalRail: true,
|
baseOperationalRail: true,
|
||||||
standing: [],
|
standing: [],
|
||||||
|
standingWest: 0,
|
||||||
// A load sitting on MEN|AT|WORK locks the industry track down (§9.3).
|
// A load sitting on MEN|AT|WORK locks the industry track down (§9.3).
|
||||||
facility: {
|
facility: {
|
||||||
kind: 'freight',
|
kind: 'freight',
|
||||||
@@ -114,7 +119,7 @@ const car = (): RollingStock => ({ type: 'boxcar', loaded: false });
|
|||||||
function straightCard(): TrackCard {
|
function straightCard(): TrackCard {
|
||||||
return {
|
return {
|
||||||
geometry: { kind: 'track', geometry: 'straight' },
|
geometry: { kind: 'track', geometry: 'straight' },
|
||||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,6 +215,7 @@ describe('ports and geometry', () => {
|
|||||||
geometry: { kind: 'track', geometry: 'curved', hand: 'left' },
|
geometry: { kind: 'track', geometry: 'curved', hand: 'left' },
|
||||||
baseOperationalRail: true,
|
baseOperationalRail: true,
|
||||||
standing: [],
|
standing: [],
|
||||||
|
standingWest: 0,
|
||||||
facility: null,
|
facility: null,
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
enhancements: [],
|
enhancements: [],
|
||||||
@@ -629,7 +635,7 @@ describe('placement and drop-off', () => {
|
|||||||
describe('curves are arcs, and arcs make sidings possible', () => {
|
describe('curves are arcs, and arcs make sidings possible', () => {
|
||||||
const arcCard = (arc: 'ne' | 'nw' | 'se' | 'sw', geometry: 'curved' | 'sharpCurved' = 'curved'): TrackCard => ({
|
const arcCard = (arc: 'ne' | 'nw' | 'se' | 'sw', geometry: 'curved' | 'sharpCurved' = 'curved'): TrackCard => ({
|
||||||
geometry: { kind: 'track', geometry, arc },
|
geometry: { kind: 'track', geometry, arc },
|
||||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
it('joins exactly two adjacent edges, with no through track', () => {
|
it('joins exactly two adjacent edges, with no through track', () => {
|
||||||
@@ -647,7 +653,7 @@ describe('curves are arcs, and arcs make sidings possible', () => {
|
|||||||
c.map((p) => [...p].sort().join('')).sort().join(' ');
|
c.map((p) => [...p].sort().join('')).sort().join(' ');
|
||||||
const turnout: TrackCard = {
|
const turnout: TrackCard = {
|
||||||
geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' } },
|
geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' } },
|
||||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||||
};
|
};
|
||||||
for (const arc of ['ne', 'nw', 'se', 'sw'] as const) {
|
for (const arc of ['ne', 'nw', 'se', 'sw'] as const) {
|
||||||
assert.notEqual(norm(connectionsFor(arcCard(arc))), norm(connectionsFor(turnout)),
|
assert.notEqual(norm(connectionsFor(arcCard(arc))), norm(connectionsFor(turnout)),
|
||||||
@@ -690,11 +696,11 @@ describe('curves are arcs, and arcs make sidings possible', () => {
|
|||||||
const ew = (): TrackCard => straightCard();
|
const ew = (): TrackCard => straightCard();
|
||||||
const grid: Record<string, TrackCard> = {
|
const grid: Record<string, TrackCard> = {
|
||||||
'0,-2': ew(),
|
'0,-2': ew(),
|
||||||
'0,-1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' }, hand: 'right' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
|
'0,-1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' }, hand: 'right' }, baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [] },
|
||||||
'-1,-1': arcCard('ne'),
|
'-1,-1': arcCard('ne'),
|
||||||
'-1,0': ew(),
|
'-1,0': ew(),
|
||||||
'-1,1': arcCard('nw'),
|
'-1,1': arcCard('nw'),
|
||||||
'0,1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' }, hand: 'left' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
|
'0,1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' }, hand: 'left' }, baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [] },
|
||||||
'0,0': ew(),
|
'0,0': ew(),
|
||||||
};
|
};
|
||||||
const area = areaFrom(grid, { row: 0, col: 0 });
|
const area = areaFrom(grid, { row: 0, col: 0 });
|
||||||
@@ -778,6 +784,7 @@ describe('Operational Rail — where a train may stop and leave cars (Appendix A
|
|||||||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||||
baseOperationalRail: true,
|
baseOperationalRail: true,
|
||||||
standing: [],
|
standing: [],
|
||||||
|
standingWest: 0,
|
||||||
facility: {
|
facility: {
|
||||||
kind: 'freight', subtype: 'mineTipple',
|
kind: 'freight', subtype: 'mineTipple',
|
||||||
allows: { outbound: true, inbound: false },
|
allows: { outbound: true, inbound: false },
|
||||||
@@ -919,7 +926,7 @@ describe('a turnout may be laid to face another, making a crossover', () => {
|
|||||||
/** A turnout stated the way the cards read: who may come in, and where they may go. */
|
/** A turnout stated the way the cards read: who may come in, and where they may go. */
|
||||||
const facing = (stem: Port, through: Port, diverge: Port, hand: 'left' | 'right'): TrackCard => ({
|
const facing = (stem: Port, through: Port, diverge: Port, hand: 'left' | 'right'): TrackCard => ({
|
||||||
geometry: { kind: 'track', geometry: 'turnout', turnout: { stem, through, diverge }, hand },
|
geometry: { kind: 'track', geometry: 'turnout', turnout: { stem, through, diverge }, hand },
|
||||||
baseOperationalRail: false, standing: [], facility: null, modifiers: [], enhancements: [],
|
baseOperationalRail: false, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
it('joins a south-diverging turnout to the north-diverging one beneath it', () => {
|
it('joins a south-diverging turnout to the north-diverging one beneath it', () => {
|
||||||
@@ -1027,6 +1034,7 @@ describe('why a square is NOT offered (the same walk, keeping its rejections)',
|
|||||||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||||
baseOperationalRail: true,
|
baseOperationalRail: true,
|
||||||
standing: [],
|
standing: [],
|
||||||
|
standingWest: 0,
|
||||||
facility: {
|
facility: {
|
||||||
kind: 'freight', subtype: 'mineTipple',
|
kind: 'freight', subtype: 'mineTipple',
|
||||||
allows: { outbound: true, inbound: false },
|
allows: { outbound: true, inbound: false },
|
||||||
@@ -1122,6 +1130,7 @@ describe('two routes to one square', () => {
|
|||||||
geometry: { kind: 'limits' },
|
geometry: { kind: 'limits' },
|
||||||
baseOperationalRail: true,
|
baseOperationalRail: true,
|
||||||
standing: [],
|
standing: [],
|
||||||
|
standingWest: 0,
|
||||||
facility: null,
|
facility: null,
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
enhancements: [],
|
enhancements: [],
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const straight = (standing: RollingStock[] = []): TrackCard => ({
|
|||||||
geometry: { kind: 'track', geometry: 'straight' },
|
geometry: { kind: 'track', geometry: 'straight' },
|
||||||
baseOperationalRail: true,
|
baseOperationalRail: true,
|
||||||
standing,
|
standing,
|
||||||
|
standingWest: 0,
|
||||||
facility: null,
|
facility: null,
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
enhancements: [],
|
enhancements: [],
|
||||||
|
|||||||
+138
-13
@@ -756,8 +756,8 @@ describe('the board draws the printed card (docs/tracks.png)', () => {
|
|||||||
/** One card, alone, so the measurements are in card coordinates. */
|
/** One card, alone, so the measurements are in card coordinates. */
|
||||||
const draw = (links: string[]): { x1: number; y1: number; x2: number; y2: number }[] => {
|
const draw = (links: string[]): { x1: number; y1: number; x2: number; y2: number }[] => {
|
||||||
const cell = {
|
const cell = {
|
||||||
row: 0, col: 0, kind: 'trk', label: 'x', running: true, enhancements: [],
|
row: 0, col: 0, kind: 'trk', label: 'x', running: true, enhancements: [], enhancementsWhat: [],
|
||||||
tray: null, cars: [], facility: null, links, what: '',
|
trains: [], adTracks: null, cars: [], standingWest: 0, facility: null, links, what: '',
|
||||||
};
|
};
|
||||||
const svg = officeSvg([cell] as never, 0);
|
const svg = officeSvg([cell] as never, 0);
|
||||||
return [...svg.matchAll(/<line class="bs-rail" x1="([-\d.]+)" y1="([-\d.]+)" x2="([-\d.]+)" y2="([-\d.]+)"\/>/g)]
|
return [...svg.matchAll(/<line class="bs-rail" x1="([-\d.]+)" y1="([-\d.]+)" x2="([-\d.]+)" y2="([-\d.]+)"\/>/g)]
|
||||||
@@ -931,7 +931,7 @@ describe('the page explains itself', () => {
|
|||||||
const area = game.state.officeAreas.get(0)!;
|
const area = game.state.officeAreas.get(0)!;
|
||||||
area.grid.set('-1,0', {
|
area.grid.set('-1,0', {
|
||||||
geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' } },
|
geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' } },
|
||||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||||
});
|
});
|
||||||
const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
|
const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
|
||||||
// Said the way a player would ask it: if my train comes in from over there, where can it go?
|
// Said the way a player would ask it: if my train comes in from over there, where can it go?
|
||||||
@@ -1960,10 +1960,11 @@ describe('the static build', () => {
|
|||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
const cell = view(game).cells.find((c) => c.row === area.runningRow && c.col === 0)!;
|
const cell = view(game).cells.find((c) => c.row === area.runningRow && c.col === 0)!;
|
||||||
assert.ok(cell.train, 'the card carries no train');
|
const train = cell.trains[0];
|
||||||
assert.deepEqual(cell.train!.cars, ['loaded tank', 'loaded boxcar', 'caboose'], 'car order is lost');
|
assert.ok(train, 'the card carries no train');
|
||||||
assert.equal(cell.train!.engineAt, 0, 'the engine has no place in the train');
|
assert.deepEqual(train!.cars, ['loaded tank', 'loaded boxcar', 'caboose'], 'car order is lost');
|
||||||
assert.equal(cell.train!.facing, 'e', 'which way it points is not carried');
|
assert.equal(train!.engineAt, 0, 'the engine has no place in the train');
|
||||||
|
assert.equal(train!.facing, 'e', 'which way it points is not carried');
|
||||||
|
|
||||||
const svg = officeSvg([cell], area.runningRow);
|
const svg = officeSvg([cell], area.runningRow);
|
||||||
assert.match(svg, /bs-t-eng/, 'the engine is not drawn');
|
assert.match(svg, /bs-t-eng/, 'the engine is not drawn');
|
||||||
@@ -2006,16 +2007,97 @@ describe('the static build', () => {
|
|||||||
assert.deepEqual(order('e'), ['cab', 'hop', 'hop', '▶'], 'an eastbound train is not turned round');
|
assert.deepEqual(order('e'), ['cab', 'hop', 'hop', '▶'], 'an eastbound train is not turned round');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows A/D tracks on the Office card, taken and total', () => {
|
it('shows A/D tracks on the Office card, one chip per track', () => {
|
||||||
// REPORTED: the tooltip said "3 A/D tracks" and the card showed nothing — the number that
|
// REPORTED: the tooltip said "3 A/D tracks" and the card showed nothing — the number that
|
||||||
// decides whether the next arrival is an automatic collision (§8.3).
|
// decides whether the next arrival is an automatic collision (§8.3). The Roster Pass replaced
|
||||||
|
// the pips with one roster chip per A/D track (docs/plans/switching-paths.md), free or occupied.
|
||||||
const game = newGame(555);
|
const game = newGame(555);
|
||||||
const area = game.state.officeAreas.get(0)!;
|
const area = game.state.officeAreas.get(0)!;
|
||||||
const cell = view(game).cells.find((c) => c.kind === 'office')!;
|
const cell = view(game).cells.find((c) => c.kind === 'office')!;
|
||||||
assert.ok(cell.ad, 'the Office card carries no A/D count');
|
assert.equal(cell.adTracks, 1, 'a Whistle Post has one A/D track');
|
||||||
assert.equal(cell.ad!.of, 1, 'a Whistle Post has one A/D track');
|
assert.equal(cell.trains.length, 0, 'no train is standing yet');
|
||||||
assert.equal(cell.ad!.used, 0);
|
const svg = officeSvg([cell], area.runningRow);
|
||||||
assert.match(officeSvg([cell], area.runningRow), /bs-ad/, 'no A/D pips are drawn');
|
assert.match(svg, /bs-adchip/, 'no roster chip is drawn');
|
||||||
|
assert.match(svg, />free</, 'the one A/D track should read free with nothing standing on it');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws every train standing at a busy Office, not just one', () => {
|
||||||
|
// REPORTED (docs/plans/switching-paths.md — "The Roster Pass"): the old single `train` field on
|
||||||
|
// a cell showed only whichever tray `s.trays` happened to yield first, so a second train holding
|
||||||
|
// at a Station was counted in the old A/D pips and never drawn at all.
|
||||||
|
const game = newGame(555);
|
||||||
|
const area = game.state.officeAreas.get(0)!;
|
||||||
|
area.tier = 'terminal'; // 4 A/D tracks — room for every train this test parks
|
||||||
|
const at = area.officeCoord;
|
||||||
|
const trainNumbers = [10, 12, 14, 16];
|
||||||
|
for (const n of trainNumbers) {
|
||||||
|
const id = `t${n}`;
|
||||||
|
game.state.trays.set(id, {
|
||||||
|
id, trainNumber: n, trainIsExtra: false, engineAt: 0,
|
||||||
|
consist: [{ type: 'boxcar', loaded: false }],
|
||||||
|
direction: 'east', facing: 'e',
|
||||||
|
position: { at: 'grid', seat: 0, coord: at }, movesUsed: 0,
|
||||||
|
} as never);
|
||||||
|
area.adOccupancy.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cell = view(game).cells.find((c) => c.row === at.row && c.col === at.col)!;
|
||||||
|
assert.equal(cell.trains.length, 4, 'all four trains should be on the Office card');
|
||||||
|
assert.equal(cell.adTracks, 4, 'a Terminal has four A/D tracks');
|
||||||
|
|
||||||
|
const svg = officeSvg([cell], area.runningRow);
|
||||||
|
const chipCount = [...svg.matchAll(/class="bs-adchip/g)].length;
|
||||||
|
assert.equal(chipCount, 4, `expected 4 roster chips at a full Terminal, got ${chipCount}`);
|
||||||
|
assert.doesNotMatch(svg, />free</, 'every track is taken, so nothing should read free');
|
||||||
|
for (const n of trainNumbers) {
|
||||||
|
assert.match(svg, new RegExp(`data-crew="t${n}"`), `T${n} has no roster chip to click`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws a bare cut left-aligned, whatever the card\'s stored standingWest says', () => {
|
||||||
|
// "Settled questions" in docs/plans/switching-paths.md — the split only ever predicts something
|
||||||
|
// while a train stands on the card, and an earlier draft of the plan had this backwards. A stale
|
||||||
|
// standingWest surviving on an emptied card must not draw a gap nobody would recouple.
|
||||||
|
const cell = {
|
||||||
|
row: 0, col: 0, kind: 'trk', label: 'x', running: true, enhancements: [], enhancementsWhat: [],
|
||||||
|
trains: [], adTracks: null,
|
||||||
|
cars: ['boxcar', 'loaded hopper', 'reefer'],
|
||||||
|
standingWest: 2, // stale — as if a train had once stood here and left
|
||||||
|
facility: null, links: ['ew'], what: '',
|
||||||
|
};
|
||||||
|
const svg = officeSvg([cell as never], 0);
|
||||||
|
const xs = [...svg.matchAll(/class="bs-slot[^"]*" x="([-\d.]+)"/g)].map((m) => Number(m[1]));
|
||||||
|
assert.equal(xs.length, 3, 'all three cars should be drawn');
|
||||||
|
const sorted = [...xs].sort((a, b) => a - b);
|
||||||
|
// Left-aligned means each slot sits exactly 30px past the one before it — no gap anywhere in
|
||||||
|
// the row for an engine that is not there.
|
||||||
|
for (let i = 1; i < sorted.length; i++) {
|
||||||
|
assert.equal(sorted[i]! - sorted[i - 1]!, 30, `a gap survived at index ${i} with no train present`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads one split off the shared card, not one per train', () => {
|
||||||
|
// The whole justification for moving `standingWest` onto the card rather than the tray
|
||||||
|
// (docs/plans/switching-paths.md): two trays holding at one Office must not be able to disagree
|
||||||
|
// about the same row of cars, because there is only one number for them to read.
|
||||||
|
const game = newGame(555);
|
||||||
|
const area = game.state.officeAreas.get(0)!;
|
||||||
|
area.tier = 'station'; // 3 A/D tracks
|
||||||
|
const at = area.officeCoord;
|
||||||
|
const card = area.grid.get(`${at.row},${at.col}`)!;
|
||||||
|
card.standing = [{ type: 'boxcar', loaded: false }, { type: 'hopper', loaded: false }];
|
||||||
|
card.standingWest = 1;
|
||||||
|
for (const n of [10, 12]) {
|
||||||
|
const id = `t${n}`;
|
||||||
|
game.state.trays.set(id, {
|
||||||
|
id, trainNumber: n, trainIsExtra: false, engineAt: 0, consist: [],
|
||||||
|
direction: 'east', facing: 'e',
|
||||||
|
position: { at: 'grid', seat: 0, coord: at }, movesUsed: 0,
|
||||||
|
} as never);
|
||||||
|
}
|
||||||
|
const cell = view(game).cells.find((c) => c.row === at.row && c.col === at.col)!;
|
||||||
|
assert.equal(cell.trains.length, 2, 'both trains should be on the Office card');
|
||||||
|
assert.equal(cell.standingWest, 1, "the card's split is one number, not one per train");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('names the cars a drop would set out, and which end they come off', () => {
|
it('names the cars a drop would set out, and which end they come off', () => {
|
||||||
@@ -2234,6 +2316,49 @@ describe('the Division map shows the whole route', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps every roster chip inside the Office cell it belongs to, at any occupancy', () => {
|
||||||
|
// "Two Trains, One Card": sizing the cell by OCCUPANCY moved the East Division Point sideways
|
||||||
|
// every time an A/D track filled or cleared. Sizing by CAPACITY (docs/plans/switching-paths.md)
|
||||||
|
// holds the map still, and every chip has to actually fit inside the cell that capacity bought.
|
||||||
|
const s = createEngineGame({
|
||||||
|
id: 'div-chips', seed: 7,
|
||||||
|
config: {
|
||||||
|
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
|
||||||
|
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
||||||
|
},
|
||||||
|
playerNames: ['Solitaire'],
|
||||||
|
});
|
||||||
|
const area = areaOf(s, 0);
|
||||||
|
area.tier = 'terminal'; // 4 A/D tracks
|
||||||
|
for (const n of [10, 12, 14, 16]) {
|
||||||
|
const id = `t${n}`;
|
||||||
|
s.trays.set(id, {
|
||||||
|
id, trainNumber: n, trainIsExtra: false, engineAt: 0,
|
||||||
|
consist: [{ type: 'boxcar', loaded: false }],
|
||||||
|
direction: 'east', facing: 'e',
|
||||||
|
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
||||||
|
} as never);
|
||||||
|
area.adOccupancy.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const svg = divisionSvg(snapshot(s, [], null).division);
|
||||||
|
const cells = [...svg.matchAll(/class="bs-dcell bs-drun[^"]*"[^>]*><rect x="([\d.]+)" y="([\d.]+)" width="([\d.]+)" height="([\d.]+)"/g)]
|
||||||
|
.map((m) => ({ x: +m[1]!, w: +m[3]! }));
|
||||||
|
assert.ok(cells.length > 0, 'no Running Track cell drawn');
|
||||||
|
const office = cells.reduce((a, b) => (b.w > a.w ? b : a));
|
||||||
|
assert.ok(office.w > 78, `the Office cell did not widen for its 4 A/D tracks: ${office.w}`);
|
||||||
|
|
||||||
|
const chips = [...svg.matchAll(/class="bs-train"[^>]*><rect x="([-\d.]+)" y="[-\d.]+" width="([\d.]+)"/g)]
|
||||||
|
.map((m) => ({ x: +m[1]!, w: +m[2]! }));
|
||||||
|
assert.equal(chips.length, 4, `expected 4 train chips, got ${chips.length}`);
|
||||||
|
for (const c of chips) {
|
||||||
|
assert.ok(
|
||||||
|
c.x >= office.x - 0.01 && c.x + c.w <= office.x + office.w + 0.01,
|
||||||
|
`a chip at x=${c.x} w=${c.w} spills outside the Office cell [${office.x}, ${office.x + office.w}]`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('draws the Division as a line with two ends, never as a loop', () => {
|
it('draws the Division as a line with two ends, never as a loop', () => {
|
||||||
// Seating players around a table invites exactly one misreading: that the route joins up. It
|
// Seating players around a table invites exactly one misreading: that the route joins up. It
|
||||||
// does not. Both ends are labelled as ends, not as an entrance and an exit: odd trains run west
|
// does not. Both ends are labelled as ends, not as an entrance and an exit: odd trains run west
|
||||||
|
|||||||
Reference in New Issue
Block a user