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
+1 -1
View File
@@ -36,7 +36,7 @@ const SAMPLES: GameEvent[] = [
{ type: 'actorChanged', player: 0 },
{ type: 'localOpsOptionChosen', player: 0, option: 'switch' },
{ type: 'trayMoved', trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 },
{ type: 'carsCoupled', trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }] },
{ type: 'carsCoupled', trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }], from: [{ row: 0, col: 1 }], toNose: true },
{ type: 'carsDropped', trayId: 't0', at: { row: 1, col: 0 }, stock: [{ type: 'hopper', loaded: false }] },
{ type: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'c1' },
{ type: 'cardPlayed', player: 0, cardId: 'c1', placement: { row: 1, col: 0 }, variant: 0 },
+19 -6
View File
@@ -276,15 +276,28 @@ describe('switching accomplishes something (regression)', () => {
assert.ok(worstRun < 3, `crew oscillated ${worstRun + 1} times without doing any work`);
});
it('does not spend its whole Local Operations budget on motion', () => {
// The absolute ratio is no longer a fair test: with only 9 industries in 115 cards there is
// often nothing to switch, so `work` is legitimately near zero. What still must hold is that
// the crew is not burning all six Moves every Stage — the shuttling bug.
it('does not shuttle aimlessly — Moves have to buy something', () => {
// Counting Moves alone stopped being a fair test once the crew could do real work. Run-arounds
// and sidings are SUPPOSED to cost several Moves each: the crew leaves the main, runs the loop,
// and comes back with a different car droppable. Measured before sidings existed the crew
// coupled ~0.4 cars a game; it now couples ~8 and drops ~11.
//
// So test the thing the old threshold was a proxy for: motion must convert into work. A crew
// that shuttles for its own sake shows a high ratio here, however few or many Moves it makes.
const report = simulate({
games: 40, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot,
});
const moves = report.perGame.reduce((n, g) => n + g.actions.moves, 0) / report.perGame.length;
assert.ok(moves < 60, `${moves.toFixed(0)} Moves per game suggests aimless shuttling`);
const per = (f: (g: (typeof report.perGame)[number]) => number): number =>
report.perGame.reduce((n, g) => n + f(g), 0) / report.perGame.length;
const moves = per((g) => g.actions.moves);
const work = per((g) => g.actions.drops + g.actions.couples);
assert.ok(work > 2, `only ${work.toFixed(1)} productive acts a game — the crew is doing nothing`);
assert.ok(
moves / work < 8,
`${(moves / work).toFixed(1)} Moves per drop or coupling suggests aimless shuttling ` +
`(${moves.toFixed(0)} Moves, ${work.toFixed(1)} productive acts)`,
);
});
});
+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',
);
});
});
+11 -1
View File
@@ -585,6 +585,7 @@ describe('the static build', () => {
// came back with highlighted squares wired to handlers. Asserting the data has coordinates says
// nothing about whether the page draws them.
const els = new Map<string, Record<string, unknown>>();
const injected: Record<string, unknown>[] = [];
const make = (): Record<string, unknown> => {
let html = '';
// Cache per selector and clear it when innerHTML changes. Returning fresh objects each call
@@ -663,7 +664,10 @@ describe('the static build', () => {
createElement: () => make(),
addEventListener: () => {},
body: { appendChild: () => {} },
head: { appendChild: () => {} },
// Capture injected stylesheets. The board is SVG styled entirely by class, so a page that
// renders every card correctly and never loads BOARD_CSS draws them black on black — visibly
// broken, and invisible to a stub that ignores CSS.
head: { appendChild: (node: Record<string, unknown>) => void injected.push(node) },
};
g['location'] = { search: '?seed=555' };
const store = new Map<string, string>();
@@ -702,6 +706,12 @@ describe('the static build', () => {
assert.ok(clickFirst(actions, 'button.act'), 'no action button rendered');
assert.ok(clickFirst(actions, 'button.subj'), 'no card/track subject to pick');
// Without the board stylesheet every shape is drawn black on a near-black background: the page
// looks empty even though the markup is perfect.
const styles = injected.map((n) => String(n['textContent'] ?? '')).join('\n');
assert.match(styles, /\.bs-card\s*\{/, 'the page never loaded the board styles — cards would be invisible');
assert.match(styles, /\.bs-rail\s*\{/, 'the page never loaded the rail styles');
const html = String(grid['innerHTML']);
// The board draws cards as addressable groups; legality is an added class or a drawn ghost.
assert.match(html, /data-cell="/, 'the board drew no addressable cards');