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:
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user