more info on train moves in history. train info added to tooltip on timetable and division map. support for multiple trains in area switching. more info on train7/8 wrt location of coach on initial train build.

This commit is contained in:
Jesse
2026-08-16 21:45:19 -04:00
parent 98f93e6691
commit 08339effba
14 changed files with 1029 additions and 40 deletions
+101 -12
View File
@@ -198,6 +198,14 @@ export type TrainChip = {
cars: string[];
engineAt: number;
facing: 'e' | 'w';
/**
* The train's printed card — what it may and may not do.
*
* The Office card has carried this for a while; the Division map chip carried only the consist, so
* a train out on the Mainline could not be asked what it was. Same text either way, from
* `trainRules`, so the two views cannot describe one train differently.
*/
what: string;
/**
* Which region of a Mainline card the train is standing in, and which way it is going. Absent
* everywhere else: a Division Point is a single queue, and inside a district a train moves by
@@ -337,11 +345,23 @@ export type Frame = {
* `blocked` comes out of the movement walk itself (`movesFor`), so a reason on screen is the rule
* that actually refused the square rather than a second guess at it.
*/
/**
* ONE ENTRY PER CREW, not one for the district.
*
* This used to be a single object built from the FIRST tray in the map, with a comment admitting
* it: "One crew. Solitaire has one, and with more the answer would depend on which is selected".
* More than one crew in a district is ordinary — trains stand on the A/D tracks while a local
* shunts — and when it happened the board highlighted one crew's squares while the action list
* offered every crew's moves, with nothing saying which was which.
*/
moves: {
trayId: string;
/** "Train 8", "the local crew" — what to call it on screen. */
label: string;
from: { row: number; col: number };
to: { row: number; col: number }[];
blocked: { coord: { row: number; col: number }; kind: string; why: string }[];
} | null;
}[];
facilities: FacilityView[];
hand: string[];
/** What each hand card does, in the same order — names alone are not a playable hand. */
@@ -382,6 +402,16 @@ export type Frame = {
};
/** 12 slots; the train number due out at each Stage, or null. */
timetable: (number | null)[];
/**
* THE TRAIN'S CARD, SLOT BY SLOT — so a card played on Day 1 can still be read on Day 4.
*
* Reported from play: "once a train card's been played, how would I see that particular train card
* again — what it's allowed to do and not allowed to do, and how it has to be loaded?" The card
* goes onto the Timetable and is then gone, and its restrictions are what decide whether a train
* can be switched, worked by Porters, or loaded at all. The Timetable is where the player already
* looks for that train, so the card rides there.
*/
timetableWhat: (string | null)[];
blocked: Impediment[];
trains: { label: string; where: string }[];
/**
@@ -697,8 +727,37 @@ export function describeIntent(s: GameState, i: Intent): string {
}
case 'freightAgent.unjam':
return `unjam ${i.from} at ${at(i.at)}`;
case 'freightAgent.clearInbound':
return `clear red box at ${at(i.at)}`;
/**
* SAY THAT IT PAYS NOTHING, because the obvious guess is that it does.
*
* Reported from play: "it wasn't obvious if that was a mechanical thing or if that's the actual
* revenue generation — I believe that's actually where you get the revenue, and that completes
* unloading the car." It is the first: the Revenue for an inbound load was already paid, one
* step earlier, when the Laborer walked it off the car into the red box (`unloadCompleted` pays
* `freightPerLoad`). Clearing the box banks nothing — it empties the one slot an inbound load
* can finish in, so the NEXT car can be unloaded, and costs the whole Freight Agent action for
* the Stage.
*
* Verified against the engine rather than read off the rules: `freightAgent.clearInbound` emits
* `inboundCleared` alone, with no `revenueChanged` beside it.
*/
case 'freightAgent.clearInbound': {
const f = areaOf(s, actor).grid.get(`${i.at.row},${i.at.col}`)?.facility ?? null;
const car = f?.inboundBox[i.index];
const full = f ? f.inboundBox.length >= f.capacity.inbound : false;
// The red box serves both halves of §9: an inbound freight load that has come off its car,
// and a coach whose passengers have detrained. Both were paid for a step earlier, and both
// sit in the box until the Freight Agent moves them on.
const paid =
f?.kind === 'passenger'
? 'the Revenue was paid when the passengers detrained'
: 'the Revenue was paid when the load reached the box';
const frees = f?.kind === 'passenger' ? 'more passengers can detrain here' : 'another car can be unloaded here';
return (
`send the ${car ? carLabel(car) : 'car'} in the red Inbound box at ${at(i.at)} to the Classification Yard` +
` — pays nothing (${paid}); it frees${full ? ' the last' : ' a'} slot so ${frees}`
);
}
case 'laborer.startLoad':
return `start a load at ${at(i.at)}`;
case 'laborer.advanceLoad':
@@ -1052,6 +1111,7 @@ export function snapshot(
classificationTotal: s.yards.classificationYard.length,
},
timetable: [...s.timetable],
timetableWhat: s.timetable.map((n) => (n === null ? null : trainRules({ trainNumber: n, trainIsExtra: false }))),
decision,
wasted,
option: turnOf(s, viewer).option,
@@ -1276,7 +1336,13 @@ export function trainRules(t: {
if (p.rules.noSwitching) parts.push('NO SWITCHING — it runs the Division and does not shunt');
if (p.rules.terminalsOnly) parts.push('TERMINALS ONLY — Porters may work it at a Terminal and nowhere else');
if (p.rules.coachStaysOnStationTrack) {
parts.push('THE COACH STAYS AT THE STATION — a cut carrying it may only be set out at the Office');
// Said "may only be set out at the Office", which reads as a place you can do it. You cannot:
// §A.4 refuses the Office square outright, so the coach can never be set out anywhere — which
// is why the make-up order decides whether this train can switch at all.
parts.push(
'THE COACH IS NEVER SET OUT — so keep it OFF the outer end of the train, or nothing can come ' +
'off at all. Add the coach before the freight car when making up.',
);
}
if (p.rules.oneFreightPerLocation) {
parts.push('ONE FREIGHT CAR PER LOCATION — dropped or picked up, one each square per turn');
@@ -1288,7 +1354,18 @@ export function trainRules(t: {
parts.push('STOPS ONCE FOR SPEECHES, then runs expedited from its next Office onward');
}
if (p.rules.expedite) parts.push('EXPEDITED — it departs in the same Stage it arrives (§7)');
if (p.rules.expedite) {
// The single most-asked question in play — "why did that train leave before I could switch it?"
// — so it names the phases rather than the Stage. It stands through Cargo and goes at the end of
// the Stage, which is after the Porters and Laborers but before the next Local Operations.
parts.push(
'EXPEDITED — it does not lay over. It stands through the Cargo phase and departs at the END OF ' +
'THE STAGE IT ARRIVES IN, so Porters and Laborers can work it but it is normally gone before ' +
'your next Local Operations turn and cannot be switched. UNLESS §8.1 wants a clearance ruling ' +
'at that moment: there is no Superintendent in Supervisor Shift to give one, so the train ' +
'stays and runs like any other from then on.',
);
}
if (p.rules.stopEarnsPoint) parts.push('EARNS A POINT for one Stage spent standing still, once');
return parts.join(' · ');
}
@@ -1556,24 +1633,35 @@ function countStock(
* Only while a switching turn is actually running and only while Moves remain: a highlight that
* survives into the Cargo phase is an invitation to click something that is no longer offered.
*
* One crew. Solitaire has one, and with more the answer would depend on which is selected — a
* question the page does not yet ask.
* EVERY crew in the district, each with its own squares. It used to return the first one it found,
* which is the same thing in solitaire's opening but not once a train is standing at the Office
* while a local shunts: the board then drew one crew's reachable squares and the action list offered
* both crews' moves, so half the highlights belonged to a train the player was not moving.
*/
function switchingMoves(s: GameState, player: PlayerIndex): Frame['moves'] {
const turn = turnOf(s, player);
if (s.clock.phase !== 'localOps' || turn.option !== 'switch') return null;
if (turn.movesRemaining < 1) return null;
if (s.clock.phase !== 'localOps' || turn.option !== 'switch') return [];
if (turn.movesRemaining < 1) return [];
const out: Frame['moves'] = [];
for (const [id, tray] of s.trays) {
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
/**
* A crew that may not switch is not a crew you can be switching.
*
* `movesFor` is pure track geometry — it answers where a train COULD roll — and six cards print
* "no switching", which `check` enforces and it does not. Offering the Circus Train in a "which
* train are you switching?" row is offering a train whose every move the rules refuse.
*/
if (trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.noSwitching) continue;
const { to, blocked } = movesFor(s, player, id);
return { from: tray.position.coord, to, blocked };
out.push({ trayId: id, label: trainName(s, id), from: tray.position.coord, to, blocked });
}
return null;
return out;
}
function trainChip(s: GameState, id: string): TrainChip {
const t = s.trays.get(id);
if (!t) return { label: id, consist: [], cars: [], engineAt: 0, facing: 'e' };
if (!t) return { label: id, consist: [], cars: [], engineAt: 0, facing: 'e', what: '' };
/**
* The engine is drawn IN the consist, at the position it occupies.
*
@@ -1592,6 +1680,7 @@ function trainChip(s: GameState, id: string): TrainChip {
cars,
engineAt: at,
facing: railFacingOf(t),
what: t.trainNumber === null ? 'A local crew — no timetable, no card, no special rules.' : trainRules(t),
};
}