Six reports from the Day 2-3 playtest of v0.8.0.13. GAMES IN PROGRESS DO NOT SURVIVE THIS ONE. Modifiers are now bounded by the Limits, which makes a once-legal move illegal, so a save holding one is refused at that move: whistle-6945.day3.stage10 stops at intent 528 of 539. Jesse's call, knowing it strands the game on the box. The file is untouched and v0.8.0.13 still finishes it. The Sparrow running empty and Tom unable to unload his passengers are the same shortage from opposite ends, and both are the rules working as printed. §9.2 boarding discards the emptied coach into the CLASSIFICATION yard, detraining draws a fresh empty out of the DIVISION yard, and §2.2 sends Classification back only when the Division Yard runs bare — so coaches move one way. Measured over the save: sixteen in the Division Yard at setup, zero from Day 2 Stage 8 to the end, fifteen piled in Classification, the Division Yard steady at 46-47 freight cars with no prospect of going bare. Jesse's ruling is Gitea#2's: the shortage stays and the game says so. A train made up short now reports what its card wanted and why none is coming (`makeUpShort` — `trainNeedingCars` answered null for "done" and for "cannot be done" alike, so the phase moved on in silence); the yard panel warns while the condition lasts; the Depot's blocked panel was right all along. The modifier outside the Limits was working as designed and the design was Jesse's own call, now reversed. What decided it is what the board shows — a card beyond your own sign, in territory §8.1 and §10 reason about. The case that motivated the exemption was checked on the reported move rather than argued away: the Power Plant sat at (-1,3) against a sign at column 3 and two spots inside were free, legal and adjacent. Switching filled the history with coordinates — a line per move, plus one per mandatory coupling. It is still LOGGED in full; what the panel draws is the line saying somebody switched, the first move, work at an INDUSTRY (named, not a coordinate), the Small Yard sort, and a closing summary. The suppressed lines are still WRITTEN, marked `trace`: dropping them outright was the first attempt and the step-queue suite caught it, because dwellForStep pays nothing for a step that said nothing, so the board stopped replaying switching at all. The last move rides in the closing line rather than being kept in place — nothing knows a move was the last until the turn is over, by which time the line has been streamed to every client and cannot be revised. Two things fell out of reading those lines: every move ended with a tutorial sentence the opener already gives, and the move count said "of 6" with the six hardcoded, which is wrong on a night Stage. Make-up lines name their train — they all read "the train being made up", so looking back for train 10 found nothing under that name — and "a empty tank" is now "an empty tank". The Small Yard's options read as the train they would build instead of `[1,2,3,0]`; the one Jesse wanted was the first of five and unreadable. Two of those five were junk: bringing the last car to the end is the identity and would have spent a Move, and a two-car reversal duplicated its only real option. Both are filtered by the resulting order, not by the case that made them. A Small Yard may now put cars AHEAD of the engine, which was Jesse's own open question. Two sources disagreed and the design notes won: the v0.4.5 card text says the sort puts the engine at the nose, implications.md says "any order, including cars ahead of the engine". `engineAt` is optional on the intent, so older saves replay to the same train. The menu did not multiply — the engine is a separate short list against the consist as it stands, eight options for a four-car train rather than twenty. §8.2 needed no new code: badlyMadeUp is deliberately direction-free, so a PUSHING train is fit to run and only a broken-backed one is held. The button warns by asking that predicate rather than copying it, and immediately earned itself — every one of train 10's eight options is refused, the one asked for at the table included, because that train carries a caboose and each sort moves it off the rear. That is the right answer rather than a gap: the train is already made up, so every offer would break it, and the labels say which is which. A made-up order is always on the menu for a train that needs one, because "bring car k to the tail" is enumerated for every car and the caboose is one of them. Labels read WEST TO EAST, with the engine drawn as the board's own ◀ / ▶ arrow. "Front to back" is not a direction a table can read — which end is the front depends on which way the train points — and board-svg has reversed east-facing consists since v0.8.0, so the button now describes the same train as the picture. The Freight Agent, Porter and Laborer groups now say what the role is for, where the role is chosen. Tom reached for the Freight Agent to detrain passengers, which is a Porter's action in the Cargo phase; both halves were working and neither was visible. TODO closes #107 (the nose sort) and gains #108 (the coach ratchet, with the measurement, to revisit on a second game's data). 999 fast tests and 35 sim tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DmdqqCNoiqE7GBo6wthBnR
625 lines
29 KiB
TypeScript
625 lines
29 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 { legalActions } from '../src/engine/legal.ts';
|
||
import { describeIntent } from '../src/sim/view.ts';
|
||
import { badlyMadeUp } from '../src/engine/advance.ts';
|
||
import { createGame } from '../src/engine/setup.ts';
|
||
import type { CrewTray, GameConfig, GameState, GridCoord, RollingStock, TrackArc, TrackCard } from '../src/engine/state.ts';
|
||
import { carsOn, coordKey, turnOf } from '../src/engine/state.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 = 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());
|
||
}
|
||
|
||
/**
|
||
* `facing` is the PORT the engine points out through, which is not always an east-west one: a train
|
||
* standing on a curve points along its 45° leg. `railFacing` carries the east-west sense the train
|
||
* arrived with, so it keeps a straight answer whatever port the nose is on (`railFacingOf`).
|
||
*/
|
||
function placeTray(
|
||
s: GameState,
|
||
coord: GridCoord,
|
||
consist: RollingStock[],
|
||
facing: 'n' | 's' | 'e' | 'w',
|
||
engineAt = 0,
|
||
railFacing: 'e' | 'w' = facing === 'w' ? 'w' : 'e',
|
||
): string {
|
||
const id = s.freeTrays.pop()!;
|
||
s.trays.set(id, {
|
||
id,
|
||
trainNumber: null,
|
||
trainIsExtra: false,
|
||
engineAt,
|
||
consist,
|
||
direction: railFacing === 'w' ? 'west' : 'east',
|
||
facing,
|
||
railFacing,
|
||
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',
|
||
);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('a 45° leg is part of the west-to-east row, not outside it (Gitea#17)', () => {
|
||
/**
|
||
* Reported: "Cars were West to East Caboose, Loaded boxcar, Loaded boxcar, Loaded boxcar. After
|
||
* backing into that square cars were attached to the train Loaded boxcar, Loaded boxcar, Loaded
|
||
* boxcar, Caboose, Engine." The caboose came back next to the engine instead of at the far end,
|
||
* which also leaves the train badly made up under §8.2.
|
||
*
|
||
* The square was a `sw` CURVE and the train backed in through its SOUTH leg. `standing` runs west
|
||
* to east, and the two places that walk it both asked the PORT which end of the row they were at:
|
||
* `exploreMoves` reversed the row for an 'e' entry and for nothing else, and `cutTowards` answered
|
||
* "you meet nothing" for a north or south exit. Neither is a property of the port.
|
||
*
|
||
* A 45° leg leaves through the MIDDLE of its edge, so its end of the run is whichever end the arc
|
||
* does not reach: the south leg of a `sw` curve is the row's EAST end, and the south leg of an
|
||
* `se` curve is its WEST end. Same port, opposite answers — which is why `rowEndAt` has to ask the
|
||
* card.
|
||
*/
|
||
const curve = (arc: TrackArc, standing: RollingStock[] = [], standingWest = 0): TrackCard => ({
|
||
geometry: { kind: 'track', geometry: 'curved', arc, hand: 'right' },
|
||
baseOperationalRail: true,
|
||
standing,
|
||
standingWest,
|
||
facility: null,
|
||
modifiers: [],
|
||
enhancements: [],
|
||
});
|
||
|
||
/**
|
||
* The reported board, minimally: a `sw` curve holding the cut, and an `ne` curve below it for the
|
||
* train to run from. Both legs lie on the `ne_sw` diagonal, so the two cards actually join.
|
||
*/
|
||
function board(standing: RollingStock[], standingWest = standing.length): GameState {
|
||
const s = game();
|
||
addCard(s, at(1, 0), curve('sw', standing, standingWest));
|
||
addCard(s, at(0, 0), curve('ne'));
|
||
switching(s);
|
||
return s;
|
||
}
|
||
|
||
it('backs into a cut through the south leg and meets the EAST end of the row first', () => {
|
||
const s = board([car('caboose', true), car('boxcar', true), car('boxcar', true), car('boxcar', true)]);
|
||
// Facing east on the `ne` curve, so reversing pulls out through its north leg and into the
|
||
// curve above through that card's south leg — the move in the reported save.
|
||
const id = placeTray(s, at(0, 0), [], 'e');
|
||
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}`);
|
||
// Coupled behind the engine nearest-car-first, and the nearest car is the one at the south end
|
||
// — the LAST of a west-to-east row on a `sw` curve. The caboose was westmost, so it ends up
|
||
// furthest from the engine, which is where §8.2 needs it.
|
||
assert.deepEqual(types(s.trays.get(id)!.consist), ['boxcar', 'boxcar', 'boxcar', 'caboose']);
|
||
});
|
||
|
||
it('meets the WEST end of the row first where the same leg belongs to an `se` curve', () => {
|
||
// The mirror, and the reason the port alone cannot answer: an `se` curve's south leg is the
|
||
// west end of its row, so the same reverse move meets the caboose first.
|
||
const s = game();
|
||
addCard(s, at(1, 0), curve('se', [car('caboose', true), car('boxcar', true)], 2));
|
||
addCard(s, at(0, 0), curve('nw'));
|
||
switching(s);
|
||
const id = placeTray(s, at(0, 0), [], 'w');
|
||
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', 'boxcar']);
|
||
});
|
||
|
||
it('takes its own cut back with it when it pulls out through the south leg (§A.4)', () => {
|
||
// The other half of the same assumption: `cutTowards` said a train leaving north or south meets
|
||
// nothing, so a crew standing on a curve drove away and left the cars beside it standing —
|
||
// exactly what mandatory coupling forbids.
|
||
const s = board([car('boxcar', true)], 0);
|
||
// `standingWest` 0 puts the boxcar EAST of the train, which on a `sw` curve is between it and
|
||
// the south leg it is about to leave by.
|
||
const id = placeTray(s, at(1, 0), [], 's');
|
||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: false });
|
||
assert.ok(r.ok, `the move off the curve was refused: ${r.ok ? '' : r.code}`);
|
||
assert.deepEqual(types(s.trays.get(id)!.consist), ['boxcar'], 'the cut beside the train was left standing');
|
||
assert.deepEqual(standingAt(s, at(1, 0)), [], 'the cars should have come off the card');
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* The Small Yard's re-order menu, reported from Day 3 of the 2026-09-17 playtest.
|
||
*
|
||
* Jesse had train 10 on his Small Yard — nose first, `[loaded boxcar, loaded hopper, empty tank,
|
||
* caboose]` — wanted the boxcar on the tail, and could not tell which button did it: every option
|
||
* read `re-order consist [1,2,3,0]`, the engine's own array indices offered to a person. The move he
|
||
* wanted was the FIRST of the five. One of the other four re-ordered nothing at all and would still
|
||
* have spent one of his six Moves.
|
||
*/
|
||
describe('the Small Yard says what each re-order would build', () => {
|
||
const smallYard = (standing: RollingStock[] = []): TrackCard => ({
|
||
...straight(standing),
|
||
enhancements: ['smallYard'],
|
||
});
|
||
|
||
/** Train 10's consist as it actually stood, on a card carrying a Small Yard. */
|
||
const onTheYard = (): { s: GameState; trayId: string } => {
|
||
const s = game();
|
||
row(s, 3);
|
||
addCard(s, at(1, 1), smallYard());
|
||
const trayId = placeTray(s, at(1, 1), [car('boxcar', true), car('hopper', true), car('tank'), car('caboose', true)], 'e');
|
||
switching(s);
|
||
return { s, trayId };
|
||
};
|
||
|
||
it('never offers a sort that changes nothing', () => {
|
||
/**
|
||
* THE PAIR IS WHAT COUNTS, since the engine position became part of a sort (2026-09-17). The
|
||
* identity car order is now a perfectly good option when it moves the ENGINE — that is the whole
|
||
* of the separate engine control — so what must never be offered is the pair that reproduces the
|
||
* train already standing there, and no pair may appear twice.
|
||
*/
|
||
const { s, trayId } = onTheYard();
|
||
const tray = s.trays.get(trayId)!;
|
||
const offered = legalActions(s, 0)
|
||
.filter((i) => i.type === 'switch.sortConsist')
|
||
.map((i) => {
|
||
const sort = i as { order: number[]; engineAt?: number };
|
||
return `${sort.order.join(',')}|${sort.engineAt ?? 0}`;
|
||
});
|
||
assert.ok(offered.length > 0, 'no sort was offered at all, so this proved nothing');
|
||
const unchanged = `${[...tray.consist.keys()].join(',')}|${tray.engineAt}`;
|
||
assert.ok(
|
||
!offered.includes(unchanged),
|
||
'the menu offered the train as it already stands — a Move spent to change nothing',
|
||
);
|
||
assert.equal(new Set(offered).size, offered.length, 'the menu offered the same sort twice');
|
||
});
|
||
|
||
it('offers the engine every position, including ahead of its own cars', () => {
|
||
// Jesse's ruling, 2026-09-17, following `implications.md` against the v0.4.5 card text: a train
|
||
// in a Small Yard "may sort itself into any order, INCLUDING cars ahead of the engine".
|
||
const { s, trayId } = onTheYard();
|
||
const n = s.trays.get(trayId)!.consist.length;
|
||
const positions = legalActions(s, 0)
|
||
.filter((i) => i.type === 'switch.sortConsist')
|
||
.map((i) => (i as { engineAt?: number }).engineAt ?? 0);
|
||
for (let k = 1; k <= n; k++) {
|
||
assert.ok(positions.includes(k), `the engine was never offered position ${k} of ${n}`);
|
||
}
|
||
});
|
||
|
||
it('always offers a made-up order to a train that is not in one', () => {
|
||
/**
|
||
* Jesse, 2026-09-17: "trains with a caboose have to offer the caboose at the back… sorting all
|
||
* the cars back in, ready to leave the station."
|
||
*
|
||
* A train with its caboose mid-consist is one §8.2 will not let out of the Office, so at least
|
||
* one offer has to put it right. It comes out of the ordinary curated set — "bring car k to the
|
||
* tail" is enumerated for every car, and the caboose is one of them.
|
||
*/
|
||
const s = game();
|
||
row(s, 3);
|
||
addCard(s, at(1, 1), smallYard());
|
||
const trayId = placeTray(s, at(1, 1), [car('boxcar', true), car('caboose', true), car('hopper', true)], 'e');
|
||
const tray = s.trays.get(trayId)!;
|
||
tray.trainNumber = 10;
|
||
switching(s);
|
||
|
||
const fit = legalActions(s, 0)
|
||
.filter((i) => i.type === 'switch.sortConsist')
|
||
.filter((i) => {
|
||
const sort = i as { order: number[]; engineAt?: number };
|
||
const after = sort.order.map((n) => tray.consist[n]!);
|
||
return badlyMadeUp({ ...tray, consist: after, engineAt: sort.engineAt ?? 0 }) === null;
|
||
});
|
||
assert.ok(
|
||
fit.length > 0,
|
||
'a train that cannot leave the Office was offered no sort that would make it up',
|
||
);
|
||
// And the one that does it says so on the button, rather than leaving it to be discovered.
|
||
assert.ok(
|
||
fit.some((i) => describeIntent(s, i).includes('MADE UP, ready to leave')),
|
||
'the sort that makes the train up does not say so',
|
||
);
|
||
});
|
||
|
||
it('lays the train out west to east, with the engine where it will be', () => {
|
||
/**
|
||
* "You specify above that the order is front to back, but on the screen, if it's eastbound or
|
||
* westbound, it may look different" — Jesse, 2026-09-17.
|
||
*
|
||
* `board-svg.ts` draws the crew strip west on the left, reversing a consist for an east-facing
|
||
* train so its nose lands at the east end. The button has to read the same way or it describes a
|
||
* different train from the one on the board.
|
||
*/
|
||
const s = game();
|
||
row(s, 3);
|
||
addCard(s, at(1, 1), smallYard());
|
||
// Engine points WEST: the consist is stored nose first, so west to east reads engine first.
|
||
const west = placeTray(s, at(1, 1), [car('boxcar', true), car('hopper', true)], 'w');
|
||
switching(s);
|
||
const westLabel = legalActions(s, 0)
|
||
.filter((i) => i.type === 'switch.sortConsist')
|
||
.map((i) => describeIntent(s, i))
|
||
.find((l) => l.startsWith('re-order'));
|
||
assert.ok(westLabel, 'no re-order was offered for the west-facing train');
|
||
assert.match(westLabel, /west to east: ◀ ENGINE · /, `a west-facing engine was not drawn leading: ${westLabel}`);
|
||
|
||
// The same train pointing EAST puts the engine at the far end of the same sentence.
|
||
s.trays.get(west)!.railFacing = 'e';
|
||
s.trays.get(west)!.facing = 'e';
|
||
const eastLabel = legalActions(s, 0)
|
||
.filter((i) => i.type === 'switch.sortConsist')
|
||
.map((i) => describeIntent(s, i))
|
||
.find((l) => l.startsWith('re-order'));
|
||
assert.ok(eastLabel, 'no re-order was offered for the east-facing train');
|
||
assert.match(eastLabel, / · ENGINE ▶($| ·)/, `an east-facing engine was not drawn at the east end: ${eastLabel}`);
|
||
});
|
||
|
||
it('labels each option with the train it would make, not with array indices', () => {
|
||
const { s } = onTheYard();
|
||
const labels = legalActions(s, 0)
|
||
.filter((i) => i.type === 'switch.sortConsist')
|
||
.map((i) => describeIntent(s, i));
|
||
|
||
assert.ok(
|
||
labels.every((l) => !/\[\d(,\d)*\]/.test(l)),
|
||
`a re-order option still reads as a permutation: ${labels.find((l) => /\[\d(,\d)*\]/.test(l))}`,
|
||
);
|
||
/**
|
||
* The one Jesse wanted, written the way the board draws it: this crew faces EAST, so the strip
|
||
* runs west to east and the engine sits at the east end with the boxcar now furthest west.
|
||
*/
|
||
assert.ok(
|
||
labels.includes('re-order — west to east: loaded boxcar · caboose · empty tank · loaded hopper · ENGINE ▶'),
|
||
`the move that puts the boxcar on the tail was not offered in words — got: ${labels.join(' | ')}`,
|
||
);
|
||
// And the engine's own positions read as what they do, not as an index.
|
||
assert.ok(
|
||
labels.some((l) => l.startsWith('put the whole consist ahead of the engine —')),
|
||
`the shoving sort was not offered in words — got: ${labels.join(' | ')}`,
|
||
);
|
||
});
|
||
});
|