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:
@@ -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