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
+159
View File
@@ -12,6 +12,7 @@ import { applyIntent, areaOf } from '../src/engine/apply.ts';
import { HAND_LIMIT, STAGES_PER_DAY, lengthProfile, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
import { legalActions } from '../src/engine/legal.ts';
import { createGame } from '../src/engine/setup.ts';
import { developerBot } from '../src/sim/bot.ts';
import type { CrewTray, GameConfig, GameState } from '../src/engine/state.ts';
import { railFacingOf } from '../src/engine/state.ts';
@@ -680,3 +681,161 @@ describe('a train on the Division points the way it is running', () => {
}
});
});
// ---------------------------------------------------------------------------
describe('the history says WHY a train moved, and says it truthfully', () => {
/**
* REPORTED from play: "some trains seem to be moving before I can switch or do other operations on
* them — it may be the rules are wrong, or it may be my perception."
*
* It was perception, but the log was actively feeding it. Every departure read alike, and the
* arrival line for an Expedited train said there was "no turn in which to work it" — which is
* false and cost the player the Cargo turn they did have. These pin the two claims the log now
* makes, so a phase-order change cannot leave the narration lying about it.
*/
/**
* Drop one named train at the Western Division Point and follow it, counting the DISTINCT phases
* it spends standing in a district — `advance` is called many times inside one phase, so a raw
* count would say nothing. Driven by the developer bot rather than a hand-rolled phase-ender,
* which deadlocks the moment Local Operations wants an option chosen before it can be ended.
*/
const phasesWith = (trainNumber: number): { localOps: number; loadUnload: number; leftIn: string } => {
const s = game(7, { length: 'standard' });
for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains';
/**
* A DIVISION WITH NOTHING ELSE ON IT. Expedite is not absolute: §8.1 can still hold the train,
* and `shiftChange` says so — "an expedited train that would need a ruling simply stays, and
* runs normally next Stage". With the bot's own traffic running, Train 6 was held four Stages
* and collected four Local Operations turns, which is correct behaviour and the opposite of what
* this test is trying to pin. Clearing the timetable isolates the rule from the traffic.
*/
s.timetable = s.timetable.map(() => null);
const id = 'watched';
s.trays.set(id, {
id, trainNumber, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'coach', loaded: true }] as never,
direction: 'east', facing: 'e',
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);
const standingIn = new Set<string>();
let reachedOffice = false;
let leftIn = '';
for (let i = 0; i < 20_000; i++) {
const phase = s.clock.phase;
const onGrid = s.trays.get(id)?.position.at === 'grid';
if (onGrid) {
reachedOffice = true;
standingIn.add(`${s.clock.day}|${s.clock.stage}|${phase}`);
}
const r = advance(s);
if (reachedOffice && onGrid && s.trays.get(id)?.position.at !== 'grid' && leftIn === '') {
leftIn = phase;
break;
}
if (s.status === 'finished') break;
if (r.needsInput) {
/**
* A PLAYER WHO DOES NOTHING. The developer bot plays train cards, and a train card rolls
* itself onto the Timetable — so clearing the Timetable above achieved nothing while the bot
* was driving, and the watched train kept meeting traffic it had to be cleared past. This
* ends every turn without playing anything, which is the only way to isolate one train.
*/
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
if (actor === null) break;
const options = legalActions(s, actor);
if (options.length === 0) break;
const pick =
options.find((x) => x.type === 'mainline.clearance') ??
options.find(
(x) =>
x.type === 'switch.end' ||
x.type === 'draw.end' ||
x.type === 'loadUnload.end' ||
x.type === 'freightAgent.end',
) ??
options.find((x) => x.type === 'localOps.choose') ??
options[0]!;
if (!applyIntent(s, actor, pick).ok) break;
}
}
const count = (phase: string): number => [...standingIn].filter((k) => k.endsWith(`|${phase}`)).length;
assert.ok(reachedOffice, `train ${trainNumber} never reached a district at all`);
return { localOps: count('localOps'), loadUnload: count('loadUnload'), leftIn };
};
it('gives an ordinary train a Local Operations turn before it goes', () => {
// Train 12 Drag Freight — no Expedite. Arrives in a Mainline Phase, stands, and the player gets
// a Local Operations turn with it in the NEXT Stage. This is what the arrival line promises.
const r = phasesWith(12);
assert.ok(r.localOps >= 1, `an ordinary train got ${r.localOps} Local Operations turns`);
assert.equal(r.leftIn, 'mainline', 'an ordinary train should leave in a Mainline Phase');
});
it('gives an Expedited train the Cargo phase but never Local Operations', () => {
// Train 6 The Sparrow — Expedite. The claim the arrival line makes is precisely this pair:
// Porters and Laborers can reach it, a switching turn never comes, and it goes at the end of
// the Stage rather than in a Mainline Phase.
const r = phasesWith(6);
assert.equal(r.localOps, 0, 'an Expedited train got a Local Operations turn after all');
assert.ok(r.loadUnload >= 1, 'an Expedited train never stood through a Cargo phase');
assert.equal(r.leftIn, 'shiftChange', 'an Expedited train should leave in Supervisor Shift');
});
it('does NOT expedite when §8.1 wants a ruling — it stays and runs normally', () => {
/**
* EXPEDITE IS CONDITIONAL, which is most of why it feels arbitrary at the table.
*
* `shiftChange` says so — "an expedited train that would need a ruling simply stays, and runs
* normally next Stage" — and with ordinary traffic on the Division it happens a lot: driven by
* the developer bot on the same seed, Train 6 was held and collected FOUR Local Operations turns
* instead of none. So the log and the card must not promise that an Expedited train can never be
* switched; they say "unless the Superintendent has to hold it", and this is why.
*/
const s = game(7, { length: 'standard' });
for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains';
const id = 'watched';
s.trays.set(id, {
id, trainNumber: 6, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'coach', loaded: true }] as never,
direction: 'east', facing: 'e',
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);
let localOps = 0;
const seen = new Set<string>();
let heldByClearance = false;
for (let i = 0; i < 20_000; i++) {
if (s.trays.get(id)?.position.at === 'grid' && s.clock.phase === 'localOps') {
const k = `${s.clock.day}|${s.clock.stage}`;
if (!seen.has(k)) { seen.add(k); localOps += 1; }
}
const r = advance(s);
if (r.events.some((e) => e.type === 'clearanceRequested' && e.trainId === id)) heldByClearance = true;
if (s.status === 'finished' || s.clock.day > 3) break;
if (r.needsInput) {
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
if (actor === null) break;
const options = legalActions(s, actor);
if (options.length === 0) break;
if (!applyIntent(s, actor, developerBot.choose(s, actor, options)).ok) break;
}
}
assert.ok(heldByClearance, 'the Expedited train was never asked for a clearance ruling');
assert.ok(
localOps > 0,
'an Expedited train held for a ruling should get the Local Operations turns it was denied',
);
});
});
+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');
});
});