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
|
||||
|
||||
Reference in New Issue
Block a user