v0.7.2 — a leg that is part of the row, the deck the sheet prints, regions not miles per hour, and a Division you read left to right

Gitea#17 — a 45° leg is an end of the west-to-east row, so backing into a cut
through a curve's south leg no longer couples it back to front. The same
assumption left a crew's own cut standing when it pulled out through a leg,
which is the "cars left behind" report we had failed to reproduce.

Gitea#14 — every count is docs/Deck cards5.xlsx. Track halved, and the Q12
office doubling and Gap 12 industry tripling both come out with it: they were
measured against a deck with twice the track, and keeping them at the sheet's
track count wipes out the reefer chain entirely. 84 rows now match card for
card; the ten Safety, Event and Inspection cards it adds are not built and are
held out. Cards the sheet no longer lists are dealt zero copies rather than
deleted, so their rules stay implemented.

Gitea#15 — RAR reversed it: a rail may stop dead against its neighbour and the
placement is legal. What must hold is that no train crosses the gap, which was
already true and is now pinned against the reported board.

Gitea#3 — the printed speeds are scenery. A card costs one Stage per printed
region and where a train STARTS is what varies; Fast/Slow is read on Hilly
alone. Entering a one-region card behind another is a collision now, which is
what ABS exists to prevent, and ABS no longer holds trains silently.

Gitea#18 — the Division draws as one row, west to east, with no office-area
detail. East is finally always to the right.

Closes #3
Closes #14
Closes #15
Closes #17
Closes #18
This commit is contained in:
Jesse.Markowitz
2026-08-26 15:20:56 -04:00
parent 441447648d
commit 2ab25e320c
23 changed files with 4619 additions and 3264 deletions
+92 -4
View File
@@ -13,7 +13,7 @@ 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 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 = {
@@ -53,12 +53,18 @@ 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: 'e' | 'w',
facing: 'n' | 's' | 'e' | 'w',
engineAt = 0,
railFacing: 'e' | 'w' = facing === 'w' ? 'w' : 'e',
): string {
const id = s.freeTrays.pop()!;
s.trays.set(id, {
@@ -67,9 +73,9 @@ function placeTray(
trainIsExtra: false,
engineAt,
consist,
direction: facing === 'w' ? 'west' : 'east',
direction: railFacing === 'w' ? 'west' : 'east',
facing,
railFacing: facing,
railFacing,
position: { at: 'grid', seat: 0, coord },
movesUsed: 0,
} as CrewTray);
@@ -373,3 +379,85 @@ describe('taking your own cut back is undoing the drop, not a fresh pick-up', ()
);
});
});
// ---------------------------------------------------------------------------
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');
});
});