asks what game you want Three queued items. The last matters most. A RELEASE NO LONGER DESTROYS EVERY GAME IN PROGRESS. Four consecutive releases killed every game on the box, one of them a release that changed only how the board is drawn. The reasoning behind the refusal was always right — a move legal under old rules may not be legal under new ones, and half-replaying a save is worse than refusing it. The TEST was wrong: it compared engineVersion for exact equality, and that stamp is the package version, which moves for a CSS fix. Whether a save still replays has an exact answer, so it is now asked directly. loadGame reads the file and judges nothing; tryResumeSession replays the intents and reports the first one the engine refuses. A save stamped with a version this server has never run resumes fine provided its moves replay — verified against a file hand-stamped 0.4.9-ancient. One that genuinely does not replay is still refused, but the log names the move rather than two version strings: "move 3 of 8 (localOps.choose) is rejected by the current rules with OPTION_ALREADY_CHOSEN". fromMultiplayerSave had to stop lying first. It has always stopped at the first unacceptable intent and done so in silence, which was survivable only because the version gate meant a doomed replay was never attempted. Now that the replay IS the check, it returns where it stopped and why. Deliberately not done: resuming a partly-replayable game at its last good move. That silently rewinds a game to a position nobody played to while every browser holding a later Frame carries on unaware. Refusing leaves the file intact, so putting the previous version back still recovers it. EMPLOYEE ROTATION IS IMPLEMENTED, SISTER TRAINS IS DELETED. Two of the four optional-rule flags were read by nothing at all. Employee Rotation is four lines in advance.ts, because the seat/player split (D9) exists for precisely this rule: seating is the only thing that moves, so Revenue, hands, the Superintendent and whose turn it is travel with the player, and the Office, district, grid and any trains standing in it stay with the chair. Inheriting the district you move into is the point of the rule, not a side effect. "Left" is seat + 1, matching playerLeftOf. Sister Trains is deleted rather than built: Q9 records that the Second Section card supersedes it, and that card exists, so the flag was a toggle for a rule the game no longer has. THE LOBBY ASKS WHAT GAME YOU WANT TO PLAY. Creating a game asked for a name, a mode and a table size; every other dial was hardcoded. A Game settings block now carries the same set the solitaire dialog does — seed, starting hand, the three revenue rates, Days, the combined-Revenue floor, both collision caps, the opponent-card toggle — plus the three surviving optional rules. Mode and table size set the defaults and everything stays editable. The seed is honoured, so a game can be reproduced or compared. Verified: 682 tests pass (679 + 3). The rotation tests were mutation-checked both ways — disabling the rotation and turning the table the wrong way each fail the suite. Live: a save stamped 0.4.9-ancient resumed, an injected illegal move was refused by name, and a create with every dial set to a non-default value came back out of game.json with all of them intact, including seed 777. Two of my own assertions were wrong on the way and the tests caught them: the Fedora legitimately passes at Stage 12 (§5) so it cannot be compared against its own earlier value, and dispatchUsedToday is cleared at every Day boundary so it cannot mark a district.
354 lines
15 KiB
TypeScript
354 lines
15 KiB
TypeScript
/**
|
|
* §7 — the operating rules printed on each train's card.
|
|
*
|
|
* Nine of these were declared on `TrainRules` and read by nothing, so a Military train could be
|
|
* worked by Porters and a Crack Limited could shunt an industry. Each is enforced at the point the
|
|
* act happens, and a LOCAL CREW (`trainNumber: null`) is exempt from all of them — it has no card.
|
|
*/
|
|
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import { advance } from '../src/engine/advance.ts';
|
|
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
|
|
import { ALL_TRAINS, trainProfile } from '../src/engine/content.ts';
|
|
import { createGame } from '../src/engine/setup.ts';
|
|
import type { GameConfig, GameState, GridCoord, RollingStock, TrackCard } from '../src/engine/state.ts';
|
|
import { coordKey, turnOf } from '../src/engine/state.ts';
|
|
import { trainRules } from '../src/sim/view.ts';
|
|
|
|
const config: GameConfig = {
|
|
mode: 'solitaire',
|
|
days: 5,
|
|
minCombinedRevenue: 0,
|
|
maxCollisionsPerDay: 0,
|
|
maxCollisionsTotal: 0,
|
|
pvpCardsAllowed: false,
|
|
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
|
};
|
|
const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
|
|
const at = (row: number, col: number): GridCoord => ({ row, col });
|
|
|
|
const straight = (standing: RollingStock[] = []): TrackCard => ({
|
|
geometry: { kind: 'track', geometry: 'straight' },
|
|
baseOperationalRail: true,
|
|
standing,
|
|
standingWest: 0,
|
|
facility: null,
|
|
modifiers: [],
|
|
enhancements: [],
|
|
});
|
|
|
|
/** A district of plain straight track west of the Office, and a train sitting on the Office square. */
|
|
function switching(
|
|
s: GameState,
|
|
trainNumber: number | null,
|
|
isExtra: boolean,
|
|
consist: RollingStock[] = [],
|
|
westCars: RollingStock[] = [],
|
|
): string {
|
|
const area = areaOf(s, 0);
|
|
const office = area.officeCoord;
|
|
// The crew stands one square west of the Office and works westward, away from the Office square's
|
|
// own special-cased drop rule (§A.4 forbids everything but a coach there, v0.5.0) — tests that want
|
|
// that exception exercise it directly instead.
|
|
const here = { row: office.row, col: office.col - 1 };
|
|
area.grid.set(coordKey(here), straight());
|
|
area.grid.set(coordKey({ row: office.row, col: office.col - 2 }), straight(westCars));
|
|
area.grid.set(coordKey({ row: office.row, col: office.col - 3 }), straight());
|
|
s.trays.set('t', {
|
|
id: 't', trainNumber, trainIsExtra: isExtra, engineAt: 0, consist,
|
|
direction: 'west', facing: 'w', position: { at: 'grid', seat: 0, coord: here }, movesUsed: 0,
|
|
});
|
|
s.clock.phase = 'localOps';
|
|
s.clock.currentActor = 0;
|
|
turnOf(s, 0).option = 'switch';
|
|
turnOf(s, 0).movesRemaining = 6;
|
|
return 't';
|
|
}
|
|
|
|
/** One square further west than the crew starts, i.e. the square carrying `westCars`. */
|
|
const west = (s: GameState, n = 2): GridCoord => {
|
|
const o = areaOf(s, 0).officeCoord;
|
|
return { row: o.row, col: o.col - n };
|
|
};
|
|
|
|
const boxcar = (loaded = false): RollingStock => ({ type: 'boxcar', loaded });
|
|
const coach = (loaded = false): RollingStock => ({ type: 'coach', loaded });
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('§7 — no switching', () => {
|
|
it('still allows a plain move — "no switching" means no adding or dropping cars', () => {
|
|
// Six cards print it: both expresses, the Light Engine, Campaign, Circus and Military trains.
|
|
// A train held at the Office may still need to clear onto Secondary Track ahead of other
|
|
// traffic, so a move that couples nothing is legal.
|
|
const s = game();
|
|
switching(s, 1, false, [boxcar()]);
|
|
assert.equal(check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }), null);
|
|
});
|
|
|
|
it('refuses a move that would couple a car, and refuses dropping and sorting outright', () => {
|
|
const s = game();
|
|
switching(s, 1, false, [boxcar()], [boxcar()]);
|
|
|
|
assert.equal(
|
|
check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }),
|
|
'PICKUP_NOT_ALLOWED',
|
|
);
|
|
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), 'NO_SWITCHING');
|
|
assert.equal(check(s, 0, { type: 'switch.sortConsist', trayId: 't', order: [0] }), 'NO_SWITCHING');
|
|
});
|
|
|
|
it('leaves a local crew alone — it has no card and therefore no printed rules', () => {
|
|
const s = game();
|
|
switching(s, null, false, [boxcar()]);
|
|
assert.equal(check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }), null);
|
|
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), null);
|
|
});
|
|
|
|
it('leaves an ordinary freight train alone', () => {
|
|
// Train 9 Heavy Freight prints no rules at all, so nothing here may touch it.
|
|
const s = game();
|
|
switching(s, 9, false, [boxcar()]);
|
|
assert.equal(check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }), null);
|
|
});
|
|
});
|
|
|
|
describe('§7 — what a train may couple', () => {
|
|
it('X13 Appleseed may drop but not pick up', () => {
|
|
// Coupling is mandatory (§A.4), so the restriction has to bite on the MOVE that would pick the
|
|
// cars up — there is no moving over them and leaving them behind.
|
|
const s = game();
|
|
switching(s, 13, true, [boxcar()], [boxcar()]);
|
|
assert.equal(
|
|
check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }),
|
|
'PICKUP_NOT_ALLOWED',
|
|
);
|
|
// Setting out is exactly what it IS allowed to do.
|
|
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), null);
|
|
});
|
|
|
|
it('X22 Pee-Dee may pick up empties and refuses a loaded car', () => {
|
|
const empty = game();
|
|
switching(empty, 22, true, [], [boxcar(false)]);
|
|
assert.equal(check(empty, 0, { type: 'switch.move', trayId: 't', to: west(empty), reverse: false }), null);
|
|
|
|
const loaded = game();
|
|
switching(loaded, 22, true, [], [boxcar(true)]);
|
|
assert.equal(
|
|
check(loaded, 0, { type: 'switch.move', trayId: 't', to: west(loaded), reverse: false }),
|
|
'EMPTIES_ONLY',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('§7 — one freight car per location (trains 3/4)', () => {
|
|
it('spends the budget per SQUARE, not per turn', () => {
|
|
// "May drop or pick up one freight car at every location" — so a second car on the same square
|
|
// is refused, and the same train may still work another car one square along.
|
|
const s = game();
|
|
switching(s, 4, false, [boxcar(), boxcar()]);
|
|
|
|
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 2 }), 'FREIGHT_WORKED_HERE');
|
|
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }).ok);
|
|
assert.equal(
|
|
check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }),
|
|
'FREIGHT_WORKED_HERE',
|
|
'a second car came off the same square',
|
|
);
|
|
|
|
// One square west, the budget is fresh.
|
|
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }).ok);
|
|
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), null);
|
|
});
|
|
|
|
it('shares one budget between dropping and picking up, as the card says "drop OR pick up"', () => {
|
|
const s = game();
|
|
switching(s, 4, false, [boxcar()], [boxcar()]);
|
|
// Drop here first, then try to take the car standing one square west… which is a new square, so
|
|
// it is allowed; the point is that the drop was charged where it happened.
|
|
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }).ok);
|
|
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), 'CONSIST_EMPTY');
|
|
});
|
|
|
|
it('does not count a caboose or a coach against it — the card says FREIGHT', () => {
|
|
const s = game();
|
|
switching(s, 4, false, [coach(), { type: 'caboose', loaded: false }]);
|
|
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 2 }), null);
|
|
});
|
|
});
|
|
|
|
describe('§7 — the Local keeps its coach (trains 7/8)', () => {
|
|
it('never sets the coach out while switching, but shunts its freight freely', () => {
|
|
/**
|
|
* "Coach must remain on station track if switching." The intended reading was "set out only at
|
|
* the Office" — but §A.4 refuses the Office square to every drop, so "only there" and "nowhere"
|
|
* are the same rule. What is enforced is the effect that matters: the Local may work its freight
|
|
* car around the district and may not abandon its coach doing it.
|
|
*/
|
|
// Consist reads nose-first with the engine at index 0, so both cars trail it and a cut comes off
|
|
// the tail: one car is the coach, two cars is the boxcar and the coach together.
|
|
const s = game();
|
|
switching(s, 7, false, [boxcar(), coach()]);
|
|
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), 'COACH_MUST_STAY');
|
|
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 2 }), 'COACH_MUST_STAY');
|
|
|
|
// The same train with only freight behind it shunts exactly as any other train would.
|
|
const freight = game();
|
|
switching(freight, 7, false, [boxcar()]);
|
|
assert.equal(check(freight, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), null);
|
|
});
|
|
|
|
it('leaves an ordinary train free to set a coach out', () => {
|
|
const s = game();
|
|
switching(s, 9, false, [boxcar(), coach()]);
|
|
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), null);
|
|
});
|
|
|
|
it('may set the coach out at the Office — the one place §A.4 now allows it (v0.5.0)', () => {
|
|
const s = game();
|
|
const area = areaOf(s, 0);
|
|
const office = area.officeCoord;
|
|
s.trays.set('t', {
|
|
id: 't',
|
|
trainNumber: 7,
|
|
trainIsExtra: false,
|
|
engineAt: 0,
|
|
consist: [boxcar(), coach()],
|
|
direction: 'west',
|
|
facing: 'w',
|
|
position: { at: 'grid', seat: 0, coord: office },
|
|
movesUsed: 0,
|
|
});
|
|
s.clock.phase = 'localOps';
|
|
s.clock.currentActor = 0;
|
|
turnOf(s, 0).option = 'switch';
|
|
turnOf(s, 0).movesRemaining = 6;
|
|
|
|
assert.equal(
|
|
check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }),
|
|
null,
|
|
'the coach may be cut loose at the Office',
|
|
);
|
|
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }).ok);
|
|
const officeCard = area.grid.get(coordKey(office))!;
|
|
assert.deepEqual(
|
|
officeCard.standing.map((c) => c.type),
|
|
['coach'],
|
|
'the coach is parked on the Office card',
|
|
);
|
|
assert.equal(s.trays.get('t')!.consist.length, 1, 'the coach left the tray');
|
|
});
|
|
});
|
|
|
|
describe('§7 — trains Porters may not work', () => {
|
|
/** An Office holding a train, with passengers waiting and an empty coach to put them in. */
|
|
function platform(s: GameState, trainNumber: number, isExtra: boolean, tier: 'depot' | 'terminal') {
|
|
const area = areaOf(s, 0);
|
|
area.tier = tier;
|
|
const office = area.grid.get(coordKey(area.officeCoord))!;
|
|
office.facility = {
|
|
kind: 'passenger', subtype: 'office',
|
|
allows: { outbound: true, inbound: true },
|
|
outboundBox: [coach(true)], inboundBox: [],
|
|
capacity: { outbound: 2, inbound: 2 },
|
|
menAtWork: null,
|
|
industryTrack: { cars: [] },
|
|
laborers: 0, porters: 2, usedThisStage: { laborers: 0, porters: 0 },
|
|
};
|
|
s.trays.set('t', {
|
|
id: 't', trainNumber, trainIsExtra: isExtra, engineAt: 0, consist: [coach(false)],
|
|
direction: 'east', position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
|
});
|
|
area.adOccupancy.push('t');
|
|
s.clock.phase = 'loadUnload';
|
|
s.clock.currentActor = 0;
|
|
return area.officeCoord;
|
|
}
|
|
|
|
it('refuses the Military train and the Director\'s car outright', () => {
|
|
for (const n of [19, 20]) {
|
|
const s = game();
|
|
const where = platform(s, n, true, 'terminal');
|
|
assert.equal(
|
|
check(s, 0, { type: 'porter.board', at: where }), 'NO_PASSENGER_WORK',
|
|
`train X${n} was worked by a Porter`,
|
|
);
|
|
}
|
|
});
|
|
|
|
it('works the Crack Limited at a Terminal and nowhere else', () => {
|
|
// "Stop at Terminals only" — it still runs into every Office and takes an A/D track, but the
|
|
// passengers it is carrying can only be worked where it is booked to stop.
|
|
const depot = game();
|
|
const atDepot = platform(depot, 2, false, 'depot');
|
|
assert.equal(check(depot, 0, { type: 'porter.board', at: atDepot }), 'NOT_A_TERMINAL');
|
|
|
|
const terminal = game();
|
|
const atTerminal = platform(terminal, 2, false, 'terminal');
|
|
assert.equal(check(terminal, 0, { type: 'porter.board', at: atTerminal }), null);
|
|
});
|
|
|
|
it('still works an ordinary train at a Depot', () => {
|
|
const s = game();
|
|
const where = platform(s, 7, false, 'depot');
|
|
assert.equal(check(s, 0, { type: 'porter.board', at: where }), null);
|
|
});
|
|
});
|
|
|
|
describe('§7 — the Campaign Train stops once, then runs expedited', () => {
|
|
it('makes its speech at the first Office and expedites afterwards', () => {
|
|
// X17 prints "one turn at station (speeches) then expedite". The first arrival is an ordinary
|
|
// stop; from then on it departs in the Stage it arrives, like any expedited train.
|
|
const s = game();
|
|
const ml = s.division.nodes[1];
|
|
if (ml?.kind === 'mainline') {
|
|
ml.card = 'plains';
|
|
ml.transits.push({ tray: 'x17', stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
|
|
}
|
|
s.trays.set('x17', {
|
|
id: 'x17', trainNumber: 17, trainIsExtra: true, engineAt: 0, consist: [],
|
|
direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
|
});
|
|
s.clock.phase = 'mainline';
|
|
s.movedThisPhase = new Set();
|
|
|
|
advance(s);
|
|
|
|
const tray = s.trays.get('x17');
|
|
assert.ok(tray, 'the Campaign Train vanished');
|
|
assert.equal(tray!.position.at, 'grid', 'it should stop at the first Office for the speeches');
|
|
assert.equal(tray!.speechMade, true, 'the speech was not recorded');
|
|
});
|
|
});
|
|
|
|
describe('§7 — the catalogue and what it claims', () => {
|
|
it('no longer tells a player a rule is unenforced', () => {
|
|
for (const t of ALL_TRAINS) {
|
|
const text = trainRules({ trainNumber: t.number, trainIsExtra: t.isExtra });
|
|
assert.doesNotMatch(text, /NOT YET ENFORCED/, `${t.name} still apologises for its own card`);
|
|
}
|
|
});
|
|
|
|
it('describes every rule a train actually carries', () => {
|
|
// Train 1 carries three at once, so the tooltip has to say all three.
|
|
const limited = trainRules({ trainNumber: 1, trainIsExtra: false });
|
|
assert.match(limited, /NO SWITCHING/);
|
|
assert.match(limited, /TERMINALS ONLY/);
|
|
assert.match(limited, /EXPEDITED/);
|
|
});
|
|
|
|
it('has dropped copiesNextScheduled, which no train ever carried', () => {
|
|
// A Second Section is a Maneuver card with its own intent (`newTrain.secondSection`), not a
|
|
// property of a train card. The flag was an unreachable second description of a working mechanic.
|
|
for (const t of ALL_TRAINS) {
|
|
assert.equal(
|
|
(t.rules as Record<string, unknown>).copiesNextScheduled, undefined,
|
|
`${t.name} carries a flag that should not exist`,
|
|
);
|
|
}
|
|
assert.ok(trainProfile(1, false), 'the catalogue should still load');
|
|
});
|
|
});
|