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
+240
View File
@@ -8,6 +8,8 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { turnOf } from '../src/engine/state.ts';
import { areaOf, check } from '../src/engine/apply.ts';
import type { Game } from '../src/web/game.ts';
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
@@ -2676,3 +2678,241 @@ describe('the New Game dialog', () => {
assert.equal(nav.search, '?hand=threeRandom&passenger=1&freight=1&transit=0', 'a bad seed was carried into the URL');
});
});
// ---------------------------------------------------------------------------
describe('making up a Local says which order the cars go on in', () => {
/**
* REPORTED from play: "I can't drop a car at all — trying to get an empty to an industry, but I
* can't drop any cars on the siding first."
*
* Trains 7/8 print "coach must remain on station track if switching", which the engine reads as
* "the coach is never set out". A cut comes off an OUTER end, so a coach on one outer end with the
* engine on the other leaves every available cut containing the coach: the train cannot set out
* its freight car, and cannot uncouple to run around either, because that leaves the coach
* standing too. Measured over 60 games, 1,181 positions where a set-out should have been possible
* and every single one refused — and no other train blocked once.
*
* Cars are appended as they are clicked with the engine on the nose, so the LAST car added takes
* the outer end. The whole remedy is therefore "do not add the coach last", and the whole bug was
* that nothing said so until the crew was on the district with no button to press.
*/
const localTray = (consist: { type: string; loaded: boolean }[]): Game => {
const game = newGame(4242);
const s = game.state;
const id = s.freeTrays.pop()!;
s.trays.set(id, {
id, trainNumber: 8, trainIsExtra: false, engineAt: 0,
consist: consist as never, direction: 'east',
position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
});
const dp = s.division.nodes.find((n) => n.kind === 'divisionPoint' && n.side === 'west');
if (dp?.kind === 'divisionPoint') dp.holding.push(id);
s.clock.phase = 'newTrain';
s.clock.currentActor = 0;
return game;
};
const box = { type: 'boxcar', loaded: false };
const coach = { type: 'coach', loaded: false };
it('tells an empty Local to put the coach on first', () => {
const advice = actionMenu(localTray([])).makeUp?.advice;
assert.ok(advice, 'no advice offered while making up a Local');
assert.equal(advice.tone, 'hint');
assert.match(advice.text, /coach FIRST/);
});
it('reads the coach-on-first step as the next step, not as a mistake', () => {
// The coach lands on the outer end the moment it goes on — including for the player who has just
// been told to put it there first. Colouring that as an error punishes them for taking the
// advice, so while a freight car is still there to add this stays a hint and names it.
const advice = actionMenu(localTray([coach])).makeUp?.advice;
assert.ok(advice, 'no advice offered with the coach on the outer end');
assert.equal(advice.tone, 'hint');
assert.match(advice.text, /Now add the freight car/);
});
it('turns into a real warning once nothing is left that would fix it', () => {
// Coach on the outer end and an empty Division Yard: there is no next step, and the train is
// about to go out unable to set anything out for the rest of its run.
const game = localTray([coach]);
game.state.yards.divisionYard.length = 0;
const advice = actionMenu(game).makeUp?.advice;
assert.ok(advice, 'no advice offered with the coach stuck on the outer end');
assert.equal(advice.tone, 'warn');
assert.match(advice.text, /nothing left to add/);
});
it('does not say "coach first" once it is too late to add it first', () => {
// The freight car is already on, so a coach added now can only land on the outer end. Telling
// the player to add the coach first here would be advice they cannot take — the honest answer
// is that the coach is what would lock the train, and it may be left off.
const advice = actionMenu(localTray([box])).makeUp?.advice;
assert.ok(advice, 'no advice offered with the freight car already on');
assert.equal(advice.tone, 'warn');
assert.doesNotMatch(advice.text, /FIRST/);
assert.match(advice.text, /without the coach/);
});
it('says nothing when there is no coach coming', () => {
// A Division Yard with no coach in it, which is an ordinary state late in a game. A Local made
// up of freight alone switches perfectly well, so there is nothing to warn about.
const game = localTray([]);
for (let i = game.state.yards.divisionYard.length - 1; i >= 0; i--) {
if (game.state.yards.divisionYard[i]!.type === 'coach') game.state.yards.divisionYard.splice(i, 1);
}
assert.equal(actionMenu(game).makeUp?.advice ?? null, null);
});
it('says nothing at all for a train the order cannot lock', () => {
// Every other train in the game. A standing caption on all of them is how this one gets skipped.
const game = newGame(4242);
const s = game.state;
const id = s.freeTrays.pop()!;
s.trays.set(id, {
id, trainNumber: 10, trainIsExtra: false, engineAt: 0, // Heavy Freight — no coach rule
consist: [box] as never, direction: 'east',
position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
});
const dp = s.division.nodes.find((n) => n.kind === 'divisionPoint' && n.side === 'west');
if (dp?.kind === 'divisionPoint') dp.holding.push(id);
s.clock.phase = 'newTrain';
s.clock.currentActor = 0;
assert.equal(actionMenu(game).makeUp?.advice ?? null, null);
});
it('the order it recommends is one the rules actually allow a set-out from', () => {
// The advice is worthless if it names an arrangement that is merely a different dead end. This
// is the claim under it, checked against `check` rather than against the prose.
const s = newGame(4242).state;
const at = { row: 1, col: 1 };
areaOf(s, 0).grid.set(`${at.row},${at.col}`, {
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
} as never);
const put = (consist: { type: string; loaded: boolean }[]): string => {
const id = s.freeTrays.pop()!;
s.trays.set(id, {
id, trainNumber: 8, trainIsExtra: false, engineAt: 0,
consist: consist.map((c) => ({ ...c })) as never, direction: 'east', facing: 'e',
position: { at: 'grid', seat: 0, coord: at }, movesUsed: 0,
});
return id;
};
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
turnOf(s, 0).option = 'switch';
const canDrop = (id: string): boolean =>
[1, 2].some((count) =>
[false, true].some(
(fromNose) =>
check(s, 0, { type: 'switch.dropCars', trayId: id, count, ...(fromNose ? { fromNose } : {}) }) === null,
),
);
assert.equal(canDrop(put([coach, box])), true, 'the recommended order still cannot set out');
assert.equal(canDrop(put([box, coach])), false, 'the order being warned against is not actually a trap');
});
});
// ---------------------------------------------------------------------------
describe('two crews switching are told apart', () => {
/**
* REPORTED from play: "when switching, 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 page handled it in
* two ways that were both wrong. `Frame.moves` was built from the FIRST tray in the map, with a
* comment admitting it, so the board highlighted one crew's reachable squares while the action
* list offered every crew's moves under a single "Switching" heading of bare coordinates.
*
* Worse, identical labels were collapsed across the whole action kind: "move to (0, 2)" describes
* one crew's move exactly as it describes another's, so one of the two was silently DROPPED and
* could not be chosen at all.
*/
const twoCrews = (): Game => {
const game = newGame(4242);
const s = game.state;
const area = areaOf(s, 0);
// A row of plain track either side of the Office, so both crews have somewhere to go.
for (const col of [-2, -1, 1, 2]) {
area.grid.set(`${area.officeCoord.row},${area.officeCoord.col + col}`, {
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
} as never);
}
// Facing each other across the Office, so BOTH reach it running forward and both moves
// therefore describe identically — which is the collision this fixture exists to create.
const put = (trainNumber: number, col: number, facing: 'e' | 'w'): string => {
const id = s.freeTrays.pop()!;
s.trays.set(id, {
id, trainNumber, trainIsExtra: false, engineAt: 0, consist: [],
direction: facing === 'e' ? 'east' : 'west', facing,
position: { at: 'grid', seat: 0, coord: { row: area.officeCoord.row, col } },
movesUsed: 0,
});
return id;
};
put(10, area.officeCoord.col - 1, 'e'); // Heavy Freight, running east at the Office
put(12, area.officeCoord.col + 1, 'w'); // Drag Freight, running west at it
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
turnOf(s, 0).option = 'switch';
return game;
};
it('gives each crew its own heading, naming the train and where it stands', () => {
const titles = actionMenu(twoCrews()).direct.map((g) => g.title).filter((t) => /^Switching/.test(t));
assert.equal(titles.length, 2, `expected one heading per crew, got ${JSON.stringify(titles)}`);
assert.ok(titles.some((t) => /Train 10/.test(t)), `no heading names Train 10: ${JSON.stringify(titles)}`);
assert.ok(titles.some((t) => /Train 12/.test(t)), `no heading names Train 12: ${JSON.stringify(titles)}`);
assert.ok(titles.every((t) => /standing at \(/.test(t)), 'a heading does not say where its crew is');
});
it('keeps a move that both crews can make, instead of dropping one of them', () => {
// The two crews flank the Office, so each can reach it and both moves describe identically.
// Before, the second was collapsed into the first and one train simply could not be sent there.
const game = twoCrews();
const office = areaOf(game.state, 0).officeCoord;
const label = `move to (${office.row},${office.col})`;
const groups = actionMenu(game).direct.filter((g) => /^Switching/.test(g.title));
const offering = groups.filter((g) => g.actions.some((a) => a.label === label));
assert.equal(offering.length, 2, 'the same move is not offered for both crews');
// And they are genuinely different actions, not the same index shown twice.
const indices = offering.map((g) => g.actions.find((a) => a.label === label)!.index);
assert.notEqual(indices[0], indices[1], 'both crews were pointed at one intent');
// Each really does move its own crew.
const { options } = actionGroups(game);
const trays = indices.map((i) => (options[i] as { trayId: string }).trayId);
assert.notEqual(trays[0], trays[1], 'the two buttons move the same tray');
});
it('reports every crew on the Frame, so the board can highlight the one you picked', () => {
const f = view(twoCrews());
assert.equal(f.moves.length, 2);
assert.deepEqual(f.moves.map((m) => m.label).sort(), ['Train 10', 'Train 12']);
for (const m of f.moves) assert.ok(m.to.length > 0, `${m.label} has no reachable squares`);
// Each entry is that crew's own square, not a shared one.
assert.notDeepEqual(f.moves[0]!.from, f.moves[1]!.from);
});
it('leaves out a train the rules will not let switch at all', () => {
// `movesFor` is pure track geometry; six cards print "no switching" and `check` enforces it.
// Offering the Circus Train in a "which train are you switching?" row offers a train whose
// every move is refused.
const game = twoCrews();
const s = game.state;
const circus = [...s.trays.values()][0]!;
circus.trainNumber = 18;
circus.trainIsExtra = true; // X18 Circus Train — noSwitching
const f = view(game);
assert.equal(f.moves.length, 1, 'a no-switching train was offered as a crew to switch');
assert.equal(f.moves[0]!.label, 'Train 12');
});
});