Implement nose coupling so run-arounds work, and stop coupling wiping the district
This commit is contained in:
+1
-1
@@ -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
@@ -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)`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user