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:
+44
-5
@@ -507,12 +507,23 @@ function moveTrain(
|
||||
id: TrayId,
|
||||
tray: CrewTray,
|
||||
events: GameEvent[],
|
||||
/**
|
||||
* Which pass is asking. The Mainline Phase moves every train once; Supervisor Shift then asks the
|
||||
* Expedited arrivals to go again. The train cannot tell the difference, but the player must — see
|
||||
* `trainHighballed.why`.
|
||||
*/
|
||||
pass: 'mainline' | 'expedite' = 'mainline',
|
||||
): MoveOutcome {
|
||||
const dir = step(tray.direction);
|
||||
|
||||
if (tray.position.at === 'divisionPoint') {
|
||||
// Read BEFORE `enterMainline` overwrites `tray.position`: the departure line named the side the
|
||||
// train was leaving FROM, and taking it afterwards read the mainline position instead — so every
|
||||
// train reported leaving the Eastern Division Point, including the eastbound ones that had just
|
||||
// been made up at the Western one.
|
||||
const fromSide = (tray.position as { side: Direction }).side;
|
||||
const dpIndex = s.division.nodes.findIndex(
|
||||
(n) => n.kind === 'divisionPoint' && n.side === (tray.position as { side: Direction }).side,
|
||||
(n) => n.kind === 'divisionPoint' && n.side === fromSide,
|
||||
);
|
||||
const target = dpIndex + dir;
|
||||
const node = s.division.nodes[target];
|
||||
@@ -525,7 +536,15 @@ function moveTrain(
|
||||
enterMainline(s, node, id, tray, target);
|
||||
const dp = s.division.nodes[dpIndex];
|
||||
if (dp?.kind === 'divisionPoint') dp.holding = dp.holding.filter((t) => t !== id);
|
||||
events.push({ type: 'phaseBegan', phase: `train ${tray.trainNumber} highballed` });
|
||||
events.push({
|
||||
type: 'trainHighballed',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
from: `the ${fromSide === 'west' ? 'Western' : 'Eastern'} Division Point`,
|
||||
to: 'the Mainline',
|
||||
// Narrated as a phase marker until now — "▸ train 8 highballed phase" — which is not a phase
|
||||
// and told the player nothing about why the train had started its run.
|
||||
why: 'it was made up and the Subdivision ahead was clear, so its run begins',
|
||||
});
|
||||
return 'moved';
|
||||
}
|
||||
|
||||
@@ -580,6 +599,10 @@ function moveTrain(
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
from: 'the Office',
|
||||
to: 'the Mainline',
|
||||
why:
|
||||
pass === 'expedite'
|
||||
? 'its card prints EXPEDITE, so it leaves at the end of the Stage it arrived in — you got the Cargo phase with it, but no Local Operations turn to switch it'
|
||||
: 'it stood a full Stage at the Office, so §8.1 released it this Mainline Phase',
|
||||
});
|
||||
return 'moved';
|
||||
}
|
||||
@@ -1019,9 +1042,25 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
.sort(([, a], [, b]) => (a.trainNumber ?? 0) - (b.trainNumber ?? 0));
|
||||
for (const [id, tray] of expedited) {
|
||||
tray.departsThisStage = false;
|
||||
// A clearance question cannot be asked here — the Superintendent's phase is over — so an
|
||||
// expedited train that would need a ruling simply stays, and runs normally next Stage.
|
||||
if (moveTrain(s, id, tray, events) === 'needsClearance') continue;
|
||||
/**
|
||||
* An expedited train that would need a §8.1 ruling STAYS, and runs normally from then on.
|
||||
*
|
||||
* Said in the log, because it is the difference between "this train leaves at the end of the
|
||||
* Stage" and "this train is still here next turn", and a player watching one Expedited train
|
||||
* vanish and the next one linger has no way to tell why. Measured on one seed: with ordinary
|
||||
* traffic running, Train 6 was held this way and collected four Local Operations turns.
|
||||
*/
|
||||
if (moveTrain(s, id, tray, events, 'expedite') === 'needsClearance') {
|
||||
events.push({
|
||||
type: 'trainHeld',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
reason:
|
||||
'it EXPEDITES and was due out at the end of this Stage, but the Subdivision ahead needs a ' +
|
||||
'clearance ruling and Supervisor Shift has no Superintendent to give one. It stays, and ' +
|
||||
'runs like an ordinary train from now on — so you DO get a Local Operations turn with it',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// §5 — the Fedora passes every three Stages: shift changes at Stages 3, 6, 9 and 12.
|
||||
|
||||
@@ -99,7 +99,15 @@ export type GameEvent =
|
||||
| { type: 'trainHeld'; trainNumber: number; reason: string }
|
||||
/** A train whose card pays for standing still (X18 Circus) collected on it. */
|
||||
| { type: 'trainStoodStill'; trainNumber: number; where: string }
|
||||
| { type: 'trainHighballed'; trainNumber: number; from: string; to: string }
|
||||
/**
|
||||
* `why` is the RULE that let it go now, not a restatement of the move.
|
||||
*
|
||||
* Reported from play: "some trains seem to be moving before I can switch or do other operations on
|
||||
* them." Every departure looked identical in the history, so the one that mattered — an Expedited
|
||||
* train leaving at the end of the Stage it arrived, with no Local Operations turn in between —
|
||||
* read exactly like an ordinary train leaving a Stage later.
|
||||
*/
|
||||
| { type: 'trainHighballed'; trainNumber: number; from: string; to: string; why: string }
|
||||
/**
|
||||
* `expedited` because it changes what the player must do RIGHT NOW. Q3 — an expedited train
|
||||
* departs the same Stage it arrives, so there is no Local Operations turn in which to work it;
|
||||
|
||||
@@ -79,6 +79,8 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
||||
engineAt?: number;
|
||||
/** East or west, always — a Division runs east and west and so does its rolling stock. */
|
||||
facing?: 'e' | 'w';
|
||||
/** The train's printed card, so a train out on the Mainline can still be asked what it is. */
|
||||
what?: string;
|
||||
region?: number;
|
||||
direction?: string;
|
||||
stagesLeft?: number;
|
||||
@@ -311,7 +313,11 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
||||
: '';
|
||||
out += `<g class="bs-train" data-tip="${esc(t.label)} \u2014 carrying ${esc(cars.join(', ') || 'no cars')}${
|
||||
cars.length ? ` (${loaded} loaded)` : ''
|
||||
}${inRegion ? ` \u00b7 region ${(t.region ?? 0) + 1} of ${c.regions}${dir}` : ''}${esc(stages)}">` +
|
||||
}${inRegion ? ` \u00b7 region ${(t.region ?? 0) + 1} of ${c.regions}${dir}` : ''}${esc(stages)}${
|
||||
// What the card prints. A train on the Mainline is exactly where "why did that leave without
|
||||
// me?" gets asked, and EXPEDITED is the answer more often than not.
|
||||
t.what ? `\n\n${esc(t.what)}` : ''
|
||||
}">` +
|
||||
`<rect x="${tx - w / 2}" y="${c.y + 22}" width="${w}" height="19" rx="3"/>` +
|
||||
`<text class="bs-tlab" x="${tx}" y="${c.y + 35}" text-anchor="middle">${esc(label)}</text>`;
|
||||
out += '</g>';
|
||||
|
||||
+16
-8
@@ -244,24 +244,32 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
text: `Train ${e.trainNumber} was due out but is HELD — ${e.reason}`,
|
||||
};
|
||||
case 'trainHighballed':
|
||||
// The RULE that released it, not a restatement of the move. "Some trains seem to be moving
|
||||
// before I can switch them" was reported against a log in which every departure read alike.
|
||||
return {
|
||||
tone: 'plain',
|
||||
text: `Train ${e.trainNumber} HIGHBALLED — departed ${e.from} onto ${e.to}`,
|
||||
text: `Train ${e.trainNumber} HIGHBALLED — departed ${e.from} onto ${e.to}. Why now: ${e.why}`,
|
||||
};
|
||||
case 'trainArrived':
|
||||
/**
|
||||
* AN EXPEDITED TRAIN IS NOT COMING BACK ROUND.
|
||||
* AN EXPEDITED TRAIN IS NOT COMING BACK ROUND — AND IS NOT GONE YET EITHER.
|
||||
*
|
||||
* This said "it will highball again next Mainline Phase, so any work must happen now" to every
|
||||
* arrival. For an expedited train that is exactly backwards — Q3 departs it in the SAME Stage
|
||||
* it arrived, so there is no turn in which to work it — and the log then contradicted itself
|
||||
* two lines later when the train left. Reported after Train 4, the Express, did precisely that.
|
||||
* This said "it will highball again next Mainline Phase" to every arrival, which is backwards
|
||||
* for an Expedited train. Corrected once to "it leaves again this same Mainline Phase", which
|
||||
* was ALSO wrong and in a way that cost the player work: the expedited departure was moved to
|
||||
* Supervisor Shift precisely so the Porters and Laborers get their Stage with the train, so
|
||||
* Cargo IS available and only Local Operations is not. Telling a player there is "no turn in
|
||||
* which to work it" talked them out of the one turn they had.
|
||||
*
|
||||
* So both branches now name the phases: what you can still do with this train, and when it
|
||||
* goes. That is the whole of the reported confusion — "some trains seem to be moving before I
|
||||
* can switch or do other operations on them".
|
||||
*/
|
||||
return {
|
||||
tone: 'plain',
|
||||
text: e.expedited
|
||||
? `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — and EXPEDITES, so it leaves again this same Mainline Phase; there is no turn in which to work it`
|
||||
: `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — it stands until the next Mainline Phase, so any work must happen before then`,
|
||||
? `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — its card prints EXPEDITE, so it stands through the Cargo phase and departs at the END OF THIS STAGE. Porters and Laborers can work it now; it is normally gone before your next Local Operations turn, so it cannot be switched — unless §8.1 holds it for a ruling, in which case it stays and runs like any other train.`
|
||||
: `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — it stands here for the rest of this Stage. You can work it in Cargo now, switch it in the NEXT Stage's Local Operations, and it departs in that Stage's Mainline Phase.`,
|
||||
};
|
||||
case 'trainDiverted':
|
||||
return {
|
||||
|
||||
+101
-12
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+162
-6
@@ -204,25 +204,85 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro
|
||||
if (actor === null) return { options: [], groups: [] };
|
||||
|
||||
const options = legalActions(game.state, actor);
|
||||
const byKind = new Map<string, { index: number; label: string; tip?: string }[]>();
|
||||
/**
|
||||
* Entries carry the crew they belong to, rather than it being encoded into the map key.
|
||||
*
|
||||
* An earlier version keyed this map by `type + separator + trayId`, which worked and was a
|
||||
* standing invitation: the key is also what `GROUP_ORDER` prefix-matches on, so the separator had
|
||||
* to survive every edit to a line nobody would think to check. One stray byte and every crew's
|
||||
* moves silently collapsed back into a single group. The tray is data; it travels as data.
|
||||
*/
|
||||
type Entry = { index: number; label: string; tip?: string; trayId?: string };
|
||||
const byKind = new Map<string, Entry[]>();
|
||||
options.forEach((intent, index) => {
|
||||
const label = describeIntent(game.state, intent);
|
||||
const trayId = 'trayId' in intent ? intent.trayId : undefined;
|
||||
const list = byKind.get(intent.type) ?? [];
|
||||
const cardId = 'cardId' in intent ? (intent as { cardId: string }).cardId : null;
|
||||
const tip = cardId ? cardDescription(game.state, cardId) : undefined;
|
||||
// Orientation variants and duplicate copies describe identically; showing one is enough, and a
|
||||
// list of forty identical rows hides the real choice rather than presenting it.
|
||||
if (!list.some((a) => a.label === label)) list.push({ index, label, ...(tip ? { tip } : {}) });
|
||||
/**
|
||||
* Identical labels are collapsed, which is right for forty copies of the same track rotation and
|
||||
* wrong for two different trains: "move to (0, 2)" describes Train 8's move and the local crew's
|
||||
* move identically, so one of them was silently dropped and could not be chosen at all. Matching
|
||||
* on the crew as well makes the de-duplication per crew, which is what it always meant.
|
||||
*/
|
||||
if (!list.some((a) => a.label === label && a.trayId === trayId)) {
|
||||
list.push({ index, label, ...(tip ? { tip } : {}), ...(trayId ? { trayId } : {}) });
|
||||
}
|
||||
byKind.set(intent.type, list);
|
||||
});
|
||||
|
||||
/** Groups take the plain shape; the crew was only ever needed to split them. */
|
||||
const plain = (entries: Entry[]): ActionGroup['actions'] =>
|
||||
entries.map(({ index, label, tip }) => ({ index, label, ...(tip ? { tip } : {}) }));
|
||||
|
||||
const groups: ActionGroup[] = [];
|
||||
const used = new Set<string>();
|
||||
for (const { prefix, title } of GROUP_ORDER) {
|
||||
const kinds = [...byKind.keys()].filter((k) => k.startsWith(prefix) && !used.has(k));
|
||||
|
||||
/**
|
||||
* ONE GROUP PER CREW, NAMED — because more than one train can be switching in a district.
|
||||
*
|
||||
* Reported from play: with two crews on the board, every move from both of them arrived in a
|
||||
* single "Switching" list of bare coordinates, and there was no way to tell which train a button
|
||||
* belonged to. Naming the train in the heading rather than on every button keeps the buttons
|
||||
* short, and the crew's own square is in the heading so the list can be matched to the board.
|
||||
*
|
||||
* `switch.end` carries no tray and is the whole turn rather than one crew's, so it keeps its own
|
||||
* heading at the bottom.
|
||||
*/
|
||||
if (prefix === 'switch.') {
|
||||
const perCrew = new Map<string, Entry[]>();
|
||||
const loose: Entry[] = [];
|
||||
for (const k of kinds) {
|
||||
used.add(k);
|
||||
for (const entry of byKind.get(k) ?? []) {
|
||||
if (entry.trayId === undefined) loose.push(entry);
|
||||
else perCrew.set(entry.trayId, [...(perCrew.get(entry.trayId) ?? []), entry]);
|
||||
}
|
||||
}
|
||||
for (const [trayId, entries] of perCrew) {
|
||||
const tray = game.state.trays.get(trayId);
|
||||
const where =
|
||||
tray?.position.at === 'grid'
|
||||
? `, standing at (${tray.position.coord.row}, ${tray.position.coord.col})`
|
||||
: '';
|
||||
groups.push({
|
||||
kind: prefix,
|
||||
title: `Switching ${trainName(game.state, trayId)}${where}`,
|
||||
actions: plain(entries),
|
||||
});
|
||||
}
|
||||
// `switch.end` belongs to the TURN rather than to any one crew, so it gets its own heading —
|
||||
// "Switching" over a lone "End Local Operations" reads as a crew with nothing it can do.
|
||||
if (loose.length > 0) groups.push({ kind: prefix, title: 'Finish', actions: plain(loose) });
|
||||
continue;
|
||||
}
|
||||
|
||||
const actions = kinds.flatMap((k) => {
|
||||
used.add(k);
|
||||
return byKind.get(k) ?? [];
|
||||
return plain(byKind.get(k) ?? []);
|
||||
});
|
||||
if (actions.length > 0) {
|
||||
// The New Train group names the TRAIN and what its card calls for. Without it, Extra X22
|
||||
@@ -341,7 +401,17 @@ export type Menu = {
|
||||
* is already on screen showing exactly those cars by type and load state. The yard is the surface;
|
||||
* these key each chip to the option that adds it.
|
||||
*/
|
||||
makeUp: { trayId: string; title: string; cars: MakeUpAction[]; pass: number | null } | null;
|
||||
makeUp: {
|
||||
trayId: string;
|
||||
title: string;
|
||||
cars: MakeUpAction[];
|
||||
pass: number | null;
|
||||
/**
|
||||
* The order to add the cars in, when the order decides whether the train can work at all.
|
||||
* Absent for every train where it does not matter, which is nearly all of them.
|
||||
*/
|
||||
advice: { text: string; tone: 'hint' | 'warn' } | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
/** The action list as the page shows it: direct actions, plus subject-then-location for the rest. */
|
||||
@@ -467,6 +537,7 @@ export function actionMenu(game: Game, seat: PlayerIndex = 0): Menu {
|
||||
title: consistTitle(game, filling) ?? 'Making up the train',
|
||||
cars: makeUpCars,
|
||||
pass,
|
||||
advice: makeUpAdvice(game, filling, makeUpCars),
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -628,6 +699,91 @@ function consistTitle(game: Game, trayId: string): string | null {
|
||||
return `Making up ${name}: its card calls for ${calls}${note}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* THE ORDER YOU ADD THE CARS IN CAN DECIDE WHETHER THE TRAIN CAN EVER SWITCH.
|
||||
*
|
||||
* Only trains 7/8 Local, and only because of the rule printed on them: "coach must remain on
|
||||
* station track if switching", which the engine reads as "the coach is never set out". A cut always
|
||||
* comes off an OUTER end, so if the coach is on one outer end and the engine is on the other, every
|
||||
* cut on offer contains the coach and the train is locked — it cannot set out its freight car, and
|
||||
* it cannot even uncouple to run around, because that means leaving the coach standing too. Measured
|
||||
* over 60 games: 1,181 positions where a set-out should have been possible, every one refused.
|
||||
*
|
||||
* Cars are appended as they are clicked and the engine stays on the nose, so the make-up reads
|
||||
* ENGINE, first car, second car — and the LAST car added is the one on the outer end. Hence the
|
||||
* whole of the advice: do not let the coach be last.
|
||||
*
|
||||
* ENGINE coach boxcar the boxcar is on the outer end and can be set out
|
||||
* ENGINE boxcar coach locked — nothing can ever come off
|
||||
*
|
||||
* Said here rather than left to the player to discover, because the dead end is invisible until the
|
||||
* train is out on the district with no button to press and no explanation for it.
|
||||
*/
|
||||
function makeUpAdvice(
|
||||
game: Game,
|
||||
trayId: string,
|
||||
cars: readonly MakeUpAction[],
|
||||
): { text: string; tone: 'hint' | 'warn' } | null {
|
||||
const tray = game.state.trays.get(trayId);
|
||||
if (!tray) return null;
|
||||
if (!trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.coachStaysOnStationTrack) return null;
|
||||
|
||||
const hasCoach = tray.consist.some((c) => c.type === 'coach');
|
||||
const lastIsCoach = tray.consist[tray.consist.length - 1]?.type === 'coach';
|
||||
// Nothing to say about a coach that is not coming: the Division Yard may hold none, and a Local
|
||||
// made up of freight alone switches perfectly well.
|
||||
const coachToCome = cars.some((c) => c.carType === 'coach');
|
||||
if (!hasCoach && !coachToCome) return null;
|
||||
|
||||
/**
|
||||
* The coach is on the outer end — which is where it lands the moment it goes on, including for a
|
||||
* player who has just correctly put it on first. So the tone turns on whether it can still be
|
||||
* fixed: while a freight car is there to add, this is the NEXT STEP and not a mistake, and
|
||||
* colouring it as a mistake punishes the player for taking the advice.
|
||||
*/
|
||||
if (lastIsCoach) {
|
||||
const freightToCome = cars.some((c) => c.carType !== 'coach');
|
||||
return freightToCome
|
||||
? {
|
||||
tone: 'hint',
|
||||
text:
|
||||
'Now add the freight car — it takes the outer end, leaving the coach safely inside. Sent out ' +
|
||||
'as it stands, with the coach on the outer end, this train would not be able to set anything ' +
|
||||
'out at all.',
|
||||
}
|
||||
: {
|
||||
tone: 'warn',
|
||||
text:
|
||||
'The coach is on the outer end and there is nothing left to add that would take that end off ' +
|
||||
'it. This train may never set its coach out, so it will not be able to set anything out at ' +
|
||||
'all — not even to uncouple for a run-around.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!hasCoach) {
|
||||
// Nothing on yet, so the good order is still free.
|
||||
if (tray.consist.length === 0) {
|
||||
return {
|
||||
tone: 'hint',
|
||||
text:
|
||||
'Add the coach FIRST. This train may never set its coach out, and the last car added is the one ' +
|
||||
'on the outer end — so a coach added last blocks the freight car in behind it and the train can ' +
|
||||
'never switch. ENGINE, coach, freight is the order that works.',
|
||||
};
|
||||
}
|
||||
// Freight is already on, so a coach added now can only land on the outer end. Saying "add the
|
||||
// coach first" here would be advice it is too late to take.
|
||||
return {
|
||||
tone: 'warn',
|
||||
text:
|
||||
'The freight car is already on, so a coach added now would land on the outer end — and this train ' +
|
||||
'may never set its coach out, which would leave it unable to switch at all. Send it out without ' +
|
||||
'the coach if you want it to work the district.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* §6.2 — "the player must reduce his hand to no more than three cards", four while a Red Flag is
|
||||
* held. The same test the engine applies to `draw.end`, asked here so the page can DISABLE the
|
||||
|
||||
+69
-5
@@ -69,8 +69,27 @@ let soundOn = false;
|
||||
* ended or what happened in between. Reported exactly that way.
|
||||
*/
|
||||
let lastPhase: string | null = null;
|
||||
/**
|
||||
* WHICH CREW THE BOARD IS DRAWING, when the district holds more than one.
|
||||
*
|
||||
* Reported from play: "make it clear which train you are switching — it is possible to have more
|
||||
* than one train available." A train standing on an A/D track while a local shunts is ordinary, and
|
||||
* the board used to highlight whichever crew came first out of the map while the action list offered
|
||||
* every crew's moves under one heading.
|
||||
*
|
||||
* Display state, deliberately not in the game: which train a player is looking at is not a fact
|
||||
* about the railroad, and in a multiplayer game two players may reasonably be looking at different
|
||||
* ones. Cleared whenever the named crew stops being one of the choices.
|
||||
*/
|
||||
let selectedCrew: string | null = null;
|
||||
const FOCUS_PHASES = new Set(['localOps', 'loadUnload']);
|
||||
|
||||
/** The crew whose squares the board is drawing: the chosen one, or the only one there is. */
|
||||
function pickedCrew(f: Frame): Frame['moves'][number] | null {
|
||||
if (f.moves.length === 0) return null;
|
||||
return f.moves.find((m) => m.trayId === selectedCrew) ?? f.moves[0] ?? null;
|
||||
}
|
||||
|
||||
const $ = (id: string): HTMLElement => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) throw new Error(`missing element: ${id}`);
|
||||
@@ -311,14 +330,20 @@ function render(): void {
|
||||
* WHERE IT IS, WHERE IT MAY GO, AND WHY NOT THE REST. A blocked card carries its reason in its own
|
||||
* tooltip, so "why can I not get into that industry?" is answered by hovering the industry.
|
||||
*/
|
||||
if (f.moves && !forPlay) {
|
||||
const here = grid.querySelector(`g[data-cell="${f.moves.from.row},${f.moves.from.col}"]`);
|
||||
/**
|
||||
* ONE CREW'S SQUARES AT A TIME. Drawing every crew's reachable squares at once is worse than
|
||||
* drawing one crew's: the highlights merge into a single blob and stop meaning "here is where
|
||||
* THIS train can go", which is the whole reason they are on the board.
|
||||
*/
|
||||
const crew = pickedCrew(f);
|
||||
if (crew && !forPlay) {
|
||||
const here = grid.querySelector(`g[data-cell="${crew.from.row},${crew.from.col}"]`);
|
||||
if (here) here.classList.add('bs-from');
|
||||
for (const c of f.moves.to) {
|
||||
for (const c of crew.to) {
|
||||
const g = grid.querySelector(`g[data-cell="${c.row},${c.col}"]`);
|
||||
if (g) g.classList.add('bs-focus');
|
||||
}
|
||||
for (const b of f.moves.blocked) {
|
||||
for (const b of crew.blocked) {
|
||||
const g = grid.querySelector(`g[data-cell="${b.coord.row},${b.coord.col}"]`);
|
||||
if (!g) continue;
|
||||
// Two different things wearing two different marks. A turnout you cannot STOP on is not in
|
||||
@@ -711,6 +736,26 @@ function renderActions(
|
||||
// no worker able to act, the only thing offered is "End my Cargo phase".
|
||||
let html = `<h3 class="actions-hd">Actions</h3>${scheduledNote}${movesNote}`;
|
||||
|
||||
/**
|
||||
* WHICH TRAIN AM I SWITCHING? Only asked when there is more than one crew to be switching, so a
|
||||
* solitaire opening — one crew, no ambiguity — is unchanged. The chosen one is the crew whose
|
||||
* reachable squares the board draws, so this row and the highlights are the same statement.
|
||||
*/
|
||||
if (f.moves.length > 1) {
|
||||
const chosen = pickedCrew(f);
|
||||
html +=
|
||||
`<div class="grp crewpick"><h3>Which train are you switching?</h3>` +
|
||||
f.moves
|
||||
.map(
|
||||
(m) =>
|
||||
`<button class="act crew${m.trayId === chosen?.trayId ? ' on' : ''}" data-crew="${esc(m.trayId)}" ` +
|
||||
`data-tip="Draw this crew's reachable squares on the board, and show its moves below. ${esc(m.label)} is standing at (${m.from.row}, ${m.from.col}) with ${m.to.length} square${m.to.length === 1 ? '' : 's'} it can reach.">` +
|
||||
`${esc(m.label)} <span class="dim">(${m.from.row}, ${m.from.col})</span></button>`,
|
||||
)
|
||||
.join('') +
|
||||
`</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* WHAT IS LEFT IN THE ACTION LIST.
|
||||
*
|
||||
@@ -757,6 +802,17 @@ function renderActions(
|
||||
? 'The Division Yard is bare, so there is nothing to add. Send the train out as it stands.'
|
||||
: 'Nothing in the Division Yard may join this train, and §7 does not allow passing while the yard holds cars.') +
|
||||
`</div>` +
|
||||
/**
|
||||
* WHICH ORDER TO ADD THEM IN, when the order is what decides whether the train can work.
|
||||
*
|
||||
* Only ever present for a train the order can lock — 7/8 Local — so this is not a standing
|
||||
* caption a player learns to skip. Reported from play as "I can't drop a car at all": the
|
||||
* Local made up ENGINE, freight, coach cannot set anything out for the rest of the game, and
|
||||
* nothing on screen said so until the crew was out on the district with no button to press.
|
||||
*/
|
||||
(menu.makeUp.advice
|
||||
? `<div class="makeup-advice ${menu.makeUp.advice.tone}">${esc(menu.makeUp.advice.text)}</div>`
|
||||
: '') +
|
||||
(menu.makeUp.pass !== null
|
||||
? `<button class="act" data-i="${menu.makeUp.pass}" data-tip="§8.2 — a train may depart with FEWER cars than its card lists, but never with the wrong ones. This sends it out as it stands.">no more cars — send it out</button>`
|
||||
: '') +
|
||||
@@ -817,7 +873,15 @@ function renderActions(
|
||||
|
||||
for (const b of Array.from(el.querySelectorAll('button.act'))) {
|
||||
const node = b as HTMLElement;
|
||||
node.onclick = () => apply(Number(node.dataset['i']));
|
||||
// The crew buttons choose what the board draws; they submit nothing, so they must not fall
|
||||
// through to `apply` with an undefined index.
|
||||
const crewId = node.dataset['crew'];
|
||||
node.onclick = crewId
|
||||
? () => {
|
||||
selectedCrew = crewId;
|
||||
render();
|
||||
}
|
||||
: () => apply(Number(node.dataset['i']));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-1
@@ -120,9 +120,18 @@ export function timetableHtml(f: Frame, justSet: number | null): string {
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
/**
|
||||
* THE CARD ITSELF, not just the number.
|
||||
*
|
||||
* A train card is played once and then lives only as a number in a Timetable slot — but what
|
||||
* it prints is what decides whether the train can be switched, worked by Porters, or loaded at
|
||||
* all. Reported from play: "once a train card's been played, how would I see that particular
|
||||
* train card again?" This is where the player already looks for that train.
|
||||
*/
|
||||
const tip =
|
||||
t !== null
|
||||
? `Train ${t} departs at Stage ${stage}. A train's NUMBER is its seniority and direction — odd runs west, even runs east — and the Stage it leaves is set by the 1D12 roll made when its card was played, so Train 8 at Stage 6 is normal.`
|
||||
? `Train ${t} departs at Stage ${stage}. A train's NUMBER is its seniority and direction — odd runs west, even runs east — and the Stage it leaves is set by the 1D12 roll made when its card was played, so Train 8 at Stage 6 is normal.` +
|
||||
(f.timetableWhat[k] ? `\n\nITS CARD — ${f.timetableWhat[k]}` : '')
|
||||
: `Stage ${stage} — no train due out.`;
|
||||
return (
|
||||
`<div class="tt-slot ${cls}" data-tip="${esc(tip)}" tabindex="0">` +
|
||||
|
||||
@@ -127,6 +127,19 @@ button.cardact.discard{color:#d6b48a}
|
||||
.scheduled{display:block;font-size:11.5px;margin:0 0 7px;padding:3px 9px;border-radius:5px;
|
||||
background:rgba(40,140,60,.22);border:1px solid #2f6b47;color:#bfe8cd;font-weight:600}
|
||||
.makeup-note{font-size:11px;margin:0 0 5px}
|
||||
/* WHICH TRAIN AM I SWITCHING. A row of crews rather than a stacked list — they are alternatives,
|
||||
and the chosen one is the crew whose squares the board is drawing, so it wears the same violet
|
||||
"you are here" the rest of the page uses. */
|
||||
.crewpick{display:flex;flex-wrap:wrap;gap:6px;align-items:baseline}
|
||||
.crewpick h3{flex:0 0 100%;margin:0 0 2px}
|
||||
button.act.crew{width:auto;display:inline-block;margin:0}
|
||||
button.act.crew.on{border-color:var(--now);background:rgba(185,140,240,.18);color:#e9dcff}
|
||||
/* THE ORDER TO ADD THE CARS IN. Only ever drawn for a train whose make-up order can lock it (7/8
|
||||
Local), so it is worth a coloured rule down the side: a player who sees this once needs to read
|
||||
it, and will not see it again on the next train. `warn` is the train already made up badly. */
|
||||
.makeup-advice{font-size:11px;line-height:1.45;margin:0 0 6px;padding:5px 8px;border-radius:4px;
|
||||
border-left:3px solid #4d6fa8;background:rgba(60,100,170,.13)}
|
||||
.makeup-advice.warn{border-left-color:#e0a060;background:rgba(200,150,60,.15)}
|
||||
/* Moves left, beside the moves. Amber like the actions, because it is a budget being spent by the
|
||||
buttons underneath it; struck through in red once it is gone. */
|
||||
.moves{display:inline-block;font-size:11px;margin:0 0 7px;padding:1px 8px;border-radius:10px;
|
||||
|
||||
Reference in New Issue
Block a user