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
-47
@@ -44,7 +44,7 @@ import {
|
||||
mainlineDescription,
|
||||
} from '../engine/content.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 type { Hand, HouseRules, TrackGeometry } from '../engine/content.ts';
|
||||
import type { Port } from '../engine/track.ts';
|
||||
@@ -71,19 +71,25 @@ export type CellView = {
|
||||
* `toString()`), so it cannot reach the card catalogue itself.
|
||||
*/
|
||||
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
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
* `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.
|
||||
*/
|
||||
train: {
|
||||
trains: {
|
||||
trayId: string;
|
||||
label: string;
|
||||
cars: string[];
|
||||
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.
|
||||
*/
|
||||
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
|
||||
* the next arrival collides was invisible on the card it belongs to. Drawn as pips rather than
|
||||
* extra rails — there is no room on the card for more track, and the count is what matters.
|
||||
* Capacity, not occupancy: `board-svg.ts` draws one roster chip per track regardless of how many
|
||||
* are taken (a free one reads "free"), so occupancy is implicit in how many of `trains` land on
|
||||
* 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 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
|
||||
* 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
|
||||
* 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
|
||||
* the ones ahead, and the split is drawn as a gap the chip sits in.
|
||||
* Combined with a train's `facing` it is the answer: for an east-facing engine the cars east of it
|
||||
* 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;
|
||||
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
|
||||
* 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()) {
|
||||
if (t.position.at !== 'grid') continue;
|
||||
if (`${t.position.coord.row},${t.position.coord.col}` !== key) continue;
|
||||
return Math.max(0, Math.min(cars, t.standingWest ?? 0));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function trainOnCard(s: GameState, key: string): CellView['train'] {
|
||||
/**
|
||||
* Every train standing on this card, for THIS viewer's seat.
|
||||
*
|
||||
* KEYED BY COORDINATE, so it must be filtered by seat first — every district uses the same (row,
|
||||
* 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
|
||||
* 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 trainsOnCard(s: GameState, viewerSeat: SeatIndex, key: string): CellView['trains'] {
|
||||
const out: CellView['trains'] = [];
|
||||
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;
|
||||
return {
|
||||
out.push({
|
||||
trayId: id,
|
||||
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
|
||||
cars: t.consist.map(carLabel),
|
||||
engineAt: Math.max(0, Math.min(t.consist.length, t.engineAt)),
|
||||
facing: railFacingOf(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)? */
|
||||
@@ -975,18 +988,6 @@ export function snapshot(
|
||||
): Frame {
|
||||
const area = areaOf(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 facilities: FacilityView[] = [];
|
||||
@@ -1018,14 +1019,10 @@ export function snapshot(
|
||||
links: connectionsFor(card).map(([a, b]) => `${a}${b}`),
|
||||
enhancements: card.enhancements.map(prettyKey),
|
||||
enhancementsWhat: card.enhancements.map((k) => enhancementText(k) ?? prettyKey(k)),
|
||||
tray: trayAt.get(key) ?? null,
|
||||
train: trainOnCard(s, key),
|
||||
ad:
|
||||
card.geometry.kind === 'office'
|
||||
? { used: area.adOccupancy.length, of: officeProfile(area.tier).adTracks }
|
||||
: null,
|
||||
trains: trainsOnCard(s, viewerSeat, key),
|
||||
adTracks: card.geometry.kind === 'office' ? officeProfile(area.tier).adTracks : null,
|
||||
cars: carsOn(card).map(carLabel),
|
||||
standingWest: standingWestOn(s, key, carsOn(card).length),
|
||||
standingWest: card.standingWest,
|
||||
facility: fv,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user