Builds "The Roster Pass" / "Two Trains, One Card" (station display for multiple trains at one Office). The Office is the one square where more than one train may legally stand at once (one per A/D track), and CellView.train had room for exactly one — a second train at a busy Station was counted in the old A/D pips and never drawn. CellView.train -> CellView.trains: TrainView[], seat-filtered and collecting every match rather than the first (fixes a latent cross-district leak in the process: trainOnCard never checked seat). The A/D pips are replaced with one roster chip per A/D track, always, free or occupied; clicking a chip sets selectedCrew, wiring the board and the action panel to the same value. standingWest moves from CrewTray to TrackCard: two trays sharing one Office card need one shared split, not one each, and there is no such thing as "west of one particular A/D track". No save migration — Save replays through the engine — and a stale value on an emptied card is inert because nothing reads a split with no train standing there. The Division map's Office cell is now sized by A/D capacity rather than occupancy, so it holds still as trains arrive and leave; chips lay into fixed slots instead of centre-spreading onto the Limits cards either side. 584 tests, 0 failures. Verified end-to-end against the built app and a direct render of a 4-train Terminal (screenshotted). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAt2YCXgd5qCjBcF2x34aK
374 lines
16 KiB
TypeScript
374 lines
16 KiB
TypeScript
/**
|
||
* §A.3 — cars occupy the track "in the same order they originally held, left-to-right".
|
||
*
|
||
* Left-to-right is west-to-east, and `TrackCard.standing` had no defined orientation at all until
|
||
* this release. Three reports came out of that one omission and each gets its own battery here:
|
||
* a cut parked in a different order depending on how many cars were set out at once; a train that
|
||
* got the identical consist whichever end it approached a cut from; and a train that drove straight
|
||
* through the cars it had just set out.
|
||
*/
|
||
|
||
import { describe, it } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
|
||
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
|
||
import { createGame } from '../src/engine/setup.ts';
|
||
import type { CrewTray, GameConfig, GameState, GridCoord, RollingStock, TrackCard } from '../src/engine/state.ts';
|
||
import { carsOn, coordKey, turnOf } from '../src/engine/state.ts';
|
||
|
||
const config: GameConfig = {
|
||
mode: 'solitaire',
|
||
victory: 'highestAfterDays',
|
||
length: 'standard',
|
||
optionalRules: {
|
||
reducedVisibility: false,
|
||
sisterTrains: false,
|
||
employeeRotation: false,
|
||
emergencyToolbox: false,
|
||
},
|
||
};
|
||
|
||
const game = (seed = 77): GameState => createGame({ id: 'g', seed, config, playerNames: ['Jesse'] });
|
||
const at = (row: number, col: number): GridCoord => ({ row, col });
|
||
const car = (type: string, loaded = false): RollingStock => ({ type, loaded }) as RollingStock;
|
||
|
||
const straight = (standing: RollingStock[] = []): TrackCard => ({
|
||
geometry: { kind: 'track', geometry: 'straight' },
|
||
baseOperationalRail: true,
|
||
standing,
|
||
standingWest: 0,
|
||
facility: null,
|
||
modifiers: [],
|
||
enhancements: [],
|
||
});
|
||
|
||
function addCard(s: GameState, coord: GridCoord, card: TrackCard): void {
|
||
areaOf(s, 0).grid.set(coordKey(coord), card);
|
||
}
|
||
|
||
/** A row of plain straights, on row 1 — row 0 is the Running Track and holds the Office. */
|
||
function row(s: GameState, n: number): void {
|
||
for (let c = 0; c < n; c++) addCard(s, at(1, c), straight());
|
||
}
|
||
|
||
function placeTray(
|
||
s: GameState,
|
||
coord: GridCoord,
|
||
consist: RollingStock[],
|
||
facing: 'e' | 'w',
|
||
engineAt = 0,
|
||
): string {
|
||
const id = s.freeTrays.pop()!;
|
||
s.trays.set(id, {
|
||
id,
|
||
trainNumber: null,
|
||
trainIsExtra: false,
|
||
engineAt,
|
||
consist,
|
||
direction: facing === 'w' ? 'west' : 'east',
|
||
facing,
|
||
railFacing: facing,
|
||
position: { at: 'grid', seat: 0, coord },
|
||
movesUsed: 0,
|
||
} as CrewTray);
|
||
return id;
|
||
}
|
||
|
||
/** Puts the game in the one phase switching intents are legal in. */
|
||
function switching(s: GameState): void {
|
||
s.clock.phase = 'localOps';
|
||
s.clock.currentActor = 0;
|
||
turnOf(s, 0).option = 'switch';
|
||
}
|
||
|
||
const types = (cars: readonly RollingStock[]): string[] => cars.map((c) => c.type);
|
||
|
||
const FOUR = (): RollingStock[] => [car('boxcar'), car('hopper'), car('reefer'), car('tank')];
|
||
|
||
const standingAt = (s: GameState, coord: GridCoord): string[] =>
|
||
types(carsOn(areaOf(s, 0).grid.get(coordKey(coord))!));
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('a cut parks in the same order however many cars come off at once', () => {
|
||
/**
|
||
* Reported: "when dropping all 4 cars, order was reversed. It worked properly if we dropped cars
|
||
* individually." Both halves are the same bug — `carsDropped` pushed onto the end of the array
|
||
* whatever end the cut came off, so the answer depended on how the player chose to spend the drop.
|
||
*
|
||
* A drop costs no Moves, so one drop of four and four drops of one are the SAME turn played two
|
||
* ways. They must park the same cars in the same order, and that is the whole property.
|
||
*/
|
||
function park(facing: 'e' | 'w', fromNose: boolean, batches: number[]): string[] {
|
||
const s = game();
|
||
row(s, 1);
|
||
// The engine goes at whichever end lets the cut come off the one being tested: cars ahead of it
|
||
// for a nose drop, behind it for a tail drop.
|
||
const id = placeTray(s, at(1, 0), FOUR(), facing, fromNose ? 4 : 0);
|
||
switching(s);
|
||
for (const count of batches) {
|
||
const r = applyIntent(s, 0, {
|
||
type: 'switch.dropCars',
|
||
trayId: id,
|
||
count,
|
||
...(fromNose ? { fromNose: true } : {}),
|
||
});
|
||
assert.ok(r.ok, `drop of ${count} refused: ${r.ok ? '' : r.code}`);
|
||
}
|
||
return standingAt(s, at(1, 0));
|
||
}
|
||
|
||
for (const facing of ['e', 'w'] as const) {
|
||
for (const fromNose of [false, true]) {
|
||
const end = fromNose ? 'nose' : 'tail';
|
||
it(`is batch-invariant off the ${end}, facing ${facing}`, () => {
|
||
const all = park(facing, fromNose, [4]);
|
||
assert.equal(all.length, 4, 'all four cars should be standing on the card');
|
||
assert.deepEqual(park(facing, fromNose, [1, 1, 1, 1]), all, 'four singles parked a different order than one four');
|
||
assert.deepEqual(park(facing, fromNose, [2, 2]), all, 'two pairs parked a different order again');
|
||
});
|
||
}
|
||
}
|
||
|
||
it('parks west-to-east, so an east-facing train sets its tail cut out behind itself', () => {
|
||
// Consist is nose first and the nose points east, so `[boxcar hopper reefer tank]` stands on the
|
||
// ground as tank, reefer, hopper, boxcar reading west to east. The tail car is the westmost.
|
||
assert.deepEqual(park('e', false, [4]), ['tank', 'reefer', 'hopper', 'boxcar']);
|
||
// Facing west the tray already reads west-to-east, so it goes down as it stands.
|
||
assert.deepEqual(park('w', false, [4]), ['boxcar', 'hopper', 'reefer', 'tank']);
|
||
});
|
||
|
||
it('keeps the two cuts apart when a train sets out off both ends on one square', () => {
|
||
// §A.3 lets a cut come off either outer end, so both can happen on one square. The engine ends
|
||
// up standing between them, which is the thing `standingWest` exists to record.
|
||
const s = game();
|
||
row(s, 1);
|
||
const id = placeTray(s, at(1, 0), FOUR(), 'e', 2);
|
||
switching(s);
|
||
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 1, fromNose: true }).ok);
|
||
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 1 }).ok);
|
||
|
||
// Facing east: the nose cut (boxcar) is set out east of the engine, the tail cut (tank) west.
|
||
assert.deepEqual(standingAt(s, at(1, 0)), ['tank', 'boxcar']);
|
||
// `standingWest` lives on the CARD now, not the tray (docs/plans/switching-paths.md — "The
|
||
// Roster Pass"): the Office is the one square more than one train may share, and a cut lies
|
||
// west or east of the block of A/D tracks rather than of any one engine.
|
||
assert.equal(
|
||
areaOf(s, 0).grid.get(coordKey(at(1, 0)))!.standingWest,
|
||
1,
|
||
'the engine should be standing between the two cuts',
|
||
);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('a parked cut mirrors depending on which end the train comes from', () => {
|
||
/**
|
||
* The consist a train ends up with must depend on which way it ran, or a run-around buys nothing.
|
||
* `couples` was accumulated in path order with no reference to the direction of travel, so both
|
||
* approaches produced the identical tray.
|
||
*/
|
||
function collect(from: 'west' | 'east'): string[] {
|
||
const s = game();
|
||
row(s, 3);
|
||
// The cut stands on the middle square, boxcar west of hopper.
|
||
addCard(s, at(1, 1), straight([car('boxcar'), car('hopper')]));
|
||
const start = from === 'west' ? at(1, 0) : at(1, 2);
|
||
const to = from === 'west' ? at(1, 2) : at(1, 0);
|
||
const id = placeTray(s, start, [], from === 'west' ? 'e' : 'w');
|
||
switching(s);
|
||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to, reverse: false });
|
||
assert.ok(r.ok, `the run was refused: ${r.ok ? '' : r.code}`);
|
||
return types(s.trays.get(id)!.consist);
|
||
}
|
||
|
||
it('gives mirrored consists, never the same one twice', () => {
|
||
const eastbound = collect('west');
|
||
const westbound = collect('east');
|
||
assert.notDeepEqual(eastbound, westbound, 'approaching from either end gave the identical consist');
|
||
assert.deepEqual(eastbound, [...westbound].reverse(), 'the two approaches are not mirror images');
|
||
});
|
||
|
||
it('puts the car the engine met LAST on the nose, running forward', () => {
|
||
// Running east onto boxcar-then-hopper, the engine pushes the boxcar ahead of it and picks the
|
||
// hopper up in front of that. Nose first, the tray reads hopper, boxcar.
|
||
assert.deepEqual(collect('west'), ['hopper', 'boxcar']);
|
||
assert.deepEqual(collect('east'), ['boxcar', 'hopper']);
|
||
});
|
||
|
||
it('couples behind the train nearest-car-first when backing up', () => {
|
||
const s = game();
|
||
row(s, 3);
|
||
addCard(s, at(1, 1), straight([car('boxcar'), car('hopper')]));
|
||
// Facing east at the east end, so backing up runs WEST into the cut: it meets the hopper first.
|
||
const id = placeTray(s, at(1, 2), [car('caboose')], 'e');
|
||
switching(s);
|
||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(1, 0), reverse: true });
|
||
assert.ok(r.ok, `the reverse move was refused: ${r.ok ? '' : r.code}`);
|
||
assert.deepEqual(types(s.trays.get(id)!.consist), ['caboose', 'hopper', 'boxcar']);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('a train may not drive through the cut on its own card (§A.4)', () => {
|
||
/**
|
||
* Reported: "if I put cars off the nose on a given track, and my next move is go forward, I need
|
||
* to couple those cars right back on." The walk started at the NEIGHBOUR of the start square and
|
||
* never read the start card at all, so the cut was simply left behind.
|
||
*
|
||
* The rule is direction-sensitive, which is the point: only the cut at the end the train pulls out
|
||
* through is in the way. That is what makes setting out off one particular end a decision.
|
||
*/
|
||
function runOut(fromNose: boolean, reverse: boolean): { consist: string[]; left: string[] } {
|
||
const s = game();
|
||
row(s, 3);
|
||
const id = placeTray(s, at(1, 1), [car('boxcar'), car('hopper')], 'e', fromNose ? 2 : 0);
|
||
switching(s);
|
||
assert.ok(
|
||
applyIntent(s, 0, {
|
||
type: 'switch.dropCars',
|
||
trayId: id,
|
||
count: 1,
|
||
...(fromNose ? { fromNose: true } : {}),
|
||
}).ok,
|
||
);
|
||
const to = reverse ? at(1, 0) : at(1, 2);
|
||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to, reverse });
|
||
assert.ok(r.ok, `the move was refused: ${r.ok ? '' : r.code}`);
|
||
return { consist: types(s.trays.get(id)!.consist), left: standingAt(s, at(1, 1)) };
|
||
}
|
||
|
||
it('picks a nose cut back up when it pulls forward through it', () => {
|
||
const out = runOut(true, false);
|
||
assert.deepEqual(out.left, [], 'the cut was left standing on the card the train pulled out of');
|
||
assert.equal(out.consist.length, 2, 'the train should have both cars back');
|
||
});
|
||
|
||
it('leaves a nose cut standing when it backs away from it instead', () => {
|
||
const out = runOut(true, true);
|
||
assert.deepEqual(out.left, ['boxcar'], 'backing away from a nose cut should leave it where it is');
|
||
assert.deepEqual(out.consist, ['hopper']);
|
||
});
|
||
|
||
it('encodes §A.5’s trailing-point step 1 — drop the back cars, then pull away from them', () => {
|
||
// "Drop the back two cars (blue and red) on Card F. Move train to Card B." The whole example
|
||
// collapses if that first move drags them along again.
|
||
const s = game();
|
||
row(s, 3);
|
||
const id = placeTray(s, at(1, 2), FOUR(), 'w');
|
||
switching(s);
|
||
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 2 }).ok);
|
||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(1, 0), reverse: false });
|
||
assert.ok(r.ok, `pulling away from the set-out cars was refused: ${r.ok ? '' : r.code}`);
|
||
assert.deepEqual(types(s.trays.get(id)!.consist), ['boxcar', 'hopper'], 'the train kept cars it had set out');
|
||
assert.deepEqual(
|
||
standingAt(s, at(1, 2)),
|
||
['reefer', 'tank'],
|
||
'the two cars set out should still be standing on the square the train left',
|
||
);
|
||
});
|
||
|
||
it('counts the own cut against the four-car limit', () => {
|
||
// A full tray that sets out one car cannot simply pull forward through it again — that would be
|
||
// five cars, and coupling is mandatory, so the MOVE is what has to be refused.
|
||
const s = game();
|
||
row(s, 3);
|
||
const id = placeTray(s, at(1, 1), FOUR(), 'e', 4);
|
||
switching(s);
|
||
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 1, fromNose: true }).ok);
|
||
// Refill the tray so the recoupled car would be a fifth.
|
||
s.trays.get(id)!.consist = FOUR();
|
||
assert.equal(check(s, 0, { type: 'switch.move', trayId: id, to: at(1, 2), reverse: false }), 'ILLEGAL_MOVE');
|
||
// Backing away from it is still legal — the cut is not at that end.
|
||
assert.equal(check(s, 0, { type: 'switch.move', trayId: id, to: at(1, 0), reverse: true }), null);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('the round trip comes back with the train it started with', () => {
|
||
/**
|
||
* Drop, run clear, back up, collect. This was already self-consistent before the fix — whatever
|
||
* order got parked came back — and it has to STAY that way now that both halves have changed, at
|
||
* both facings and every batch size.
|
||
*/
|
||
for (const facing of ['e', 'w'] as const) {
|
||
for (const batches of [[4], [1, 1, 1, 1], [2, 2]]) {
|
||
it(`survives ${batches.join('+')} cars set out, facing ${facing}`, () => {
|
||
const s = game();
|
||
row(s, 3);
|
||
const start = facing === 'e' ? at(1, 0) : at(1, 2);
|
||
const away = facing === 'e' ? at(1, 2) : at(1, 0);
|
||
const before = types(FOUR());
|
||
const id = placeTray(s, start, FOUR(), facing);
|
||
switching(s);
|
||
turnOf(s, 0).movesRemaining = 6;
|
||
|
||
for (const count of batches) {
|
||
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count }).ok);
|
||
}
|
||
assert.equal(s.trays.get(id)!.consist.length, 0, 'the whole train should be on the ground');
|
||
|
||
// Pull forward clear of the cut, then back up onto it again.
|
||
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: away, reverse: false }).ok);
|
||
const back = applyIntent(s, 0, { type: 'switch.move', trayId: id, to: start, reverse: true });
|
||
assert.ok(back.ok, `backing onto the cut was refused: ${back.ok ? '' : back.code}`);
|
||
|
||
assert.deepEqual(types(s.trays.get(id)!.consist), before, 'the train came back in a different order');
|
||
assert.deepEqual(standingAt(s, start), [], 'cars were left behind');
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('taking your own cut back is undoing the drop, not a fresh pick-up', () => {
|
||
/**
|
||
* Jesse's call, of the two answers the plan put up. Trains 3/4 print "may drop OR pick up one
|
||
* freight car at every location" and the two share one budget, so a drop off the nose would
|
||
* otherwise leave the train forbidden to pull forward past its own cars — a legal-looking move
|
||
* that is silently one-way. Same shape for X13's "drop but not pick up".
|
||
*/
|
||
it('refunds the 3/4 Express its per-location freight budget', () => {
|
||
const s = game();
|
||
row(s, 3);
|
||
const id = placeTray(s, at(1, 1), [car('boxcar'), car('hopper')], 'e', 2);
|
||
s.trays.get(id)!.trainNumber = 3;
|
||
switching(s);
|
||
turnOf(s, 0).movesRemaining = 6;
|
||
|
||
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 1, fromNose: true }).ok);
|
||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(1, 2), reverse: false });
|
||
assert.ok(r.ok, `the Express could not take its own car back: ${r.ok ? '' : r.code}`);
|
||
assert.equal(s.trays.get(id)!.consist.length, 2, 'the car it set out should be back on the train');
|
||
assert.deepEqual(
|
||
Object.values(turnOf(s, 0).freightWorked).filter((n) => n > 0),
|
||
[],
|
||
'the drop it undid is still charged against the square it was made on',
|
||
);
|
||
});
|
||
|
||
it('still charges a genuine pick-up', () => {
|
||
// The exemption is for YOUR OWN cut and nothing else: a car that was already standing there when
|
||
// the crew arrived is an ordinary pick-up and the budget still bites.
|
||
const s = game();
|
||
row(s, 3);
|
||
addCard(s, at(1, 2), straight([car('reefer')]));
|
||
const id = placeTray(s, at(1, 1), [car('boxcar'), car('hopper')], 'e', 2);
|
||
s.trays.get(id)!.trainNumber = 3;
|
||
switching(s);
|
||
turnOf(s, 0).movesRemaining = 6;
|
||
|
||
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 1, fromNose: true }).ok);
|
||
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(1, 2), reverse: false }).ok);
|
||
assert.equal(
|
||
turnOf(s, 0).freightWorked[`${id}@${coordKey(at(1, 2))}`],
|
||
1,
|
||
'only the car that was already standing there should be charged',
|
||
);
|
||
});
|
||
});
|