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