Implement nose coupling so run-arounds work, and stop coupling wiping the district

This commit is contained in:
Jesse
2026-08-01 15:15:28 -04:00
parent f52ff0e9ac
commit e23d81eade
12 changed files with 366 additions and 23 deletions
+64
View File
@@ -7,6 +7,7 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import type {
GameState,
GridCoord,
OfficeArea,
RollingStock,
@@ -14,6 +15,8 @@ import type {
TurnoutOrientation,
} from '../src/engine/state.ts';
import { coordKey } from '../src/engine/state.ts';
import { createGame } from '../src/engine/setup.ts';
import { applyIntent, areaOf } from '../src/engine/apply.ts';
import type { MoveContext, Occupancy, Port } from '../src/engine/track.ts';
import {
allReachable,
@@ -95,6 +98,27 @@ const lockedFacility = (): TrackCard => ({
const car = (): RollingStock => ({ type: 'boxcar', loaded: false });
function straightCard(): TrackCard {
return {
geometry: { kind: 'track', geometry: 'straight', axis: 'ew' },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
};
}
/** A minimal game wrapped around a prepared Office Area, for engine-level switching tests. */
function gameWith(area: OfficeArea): GameState {
const s = createGame({
id: 'g', seed: 5,
config: {
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['p'],
});
s.officeAreas.set(0, area);
return s;
}
function areaFrom(cards: Record<string, TrackCard>, officeCoord: GridCoord): OfficeArea {
const grid = new Map<string, TrackCard>();
for (const [k, v] of Object.entries(cards)) grid.set(k, v);
@@ -503,3 +527,43 @@ describe('curves are arcs, and arcs make sidings possible', () => {
assert.ok(seen.has('0,1'), 'the siding does not rejoin the Running Track');
});
});
describe('coupling lifts only the cars the crew ran over', () => {
it('leaves cars standing elsewhere in the district alone', () => {
// The reducer cleared EVERY card in the Office Area on any coupling, so picking up one boxcar
// deleted every car standing anywhere — including loads worked over several Stages onto an
// industry track the crew never went near.
const grid: Record<string, TrackCard> = {
'0,-1': straightCard(),
'0,0': straightCard(),
'0,1': straightCard(),
'0,2': straightCard(),
};
grid['0,-1']!.standing.push({ type: 'boxcar', loaded: false }); // on the path
grid['0,2']!.standing.push({ type: 'hopper', loaded: true }); // nowhere near it
const area = areaFrom(grid, { row: 0, col: 0 });
const s = gameWith(area);
s.trays.set('crew', {
id: 'crew', trainNumber: 1, trainIsExtra: false, engineFront: true, consist: [],
direction: 'west', facing: 'w', position: { at: 'grid', owner: 0, coord: { row: 0, col: 0 } }, movesUsed: 0,
});
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
s.turn.option = 'switch';
s.turn.movesRemaining = 6;
const r = applyIntent(s, 0, { type: 'switch.move', trayId: 'crew', to: { row: 0, col: -1 }, reverse: false });
assert.ok(r.ok, 'the move should be legal');
assert.deepEqual(
s.trays.get('crew')!.consist.map((c: RollingStock) => c.type),
['boxcar'],
'the crew should have picked up the car it ran over',
);
assert.equal(
areaOf(s, 0).grid.get('0,2')!.standing.length,
1,
'a car standing off the path was deleted by an unrelated coupling',
);
});
});