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
+162 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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">` +
+13
View File
@@ -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;