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:
+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