v0.8.3 — the engine half of the audit, and the deal 0.8.2 silently changed

Seven rules faults and one dealing fault, from a four-way code audit (engine, server,
client, tests) read against the code before anything was acted on. Each is pinned by a
test that failed first. CHANGELOG has the reasoning; this is the list.

THE DEAL. 0.8.2 put the Second Section card into the deck after its save check had run
and without a line in its notes. A deck one card larger shuffles differently from the same
seed, so every save on the test server refused at move 3 — the boot log shows thirteen of
thirteen — while the release notes said three would resume. `withSavedDeal` (was
`withSavedOpening`) now sets `secondSectionCard: false` for a config that predates the
setting, and the thirteen replay exactly as 0.8.2 described: three resume, ten refuse, the
same ten at the same moves.

THE RULES. `check` never tested that a switching tray was in the actor's own district, so
a rival's train could be shunted and the rival charged the Moves. Occupancy matched on
coordinates alone, so a rival's crew blocked your track. A Department draw that emptied the
deck duplicated the drawn card and destroyed the refill card. The unjam cleared the first
load rather than the one named. The collision floor could not fire in Stage 12. The
Expedite fault was charged once per clearance question rather than once per phase. A train
held at the Limits was only ever released by another arrival, never by a departure.

Docs: rules.md describes each as built (and no longer says an Expedited train departs at
Shift Change — that was v0.4.8's reading, corrected in v0.4.9's code and never in the
document); game-state.md's collision-floor note now matches the code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FrCWubm9GAftYCm2hWdKwK
This commit is contained in:
Jesse.Markowitz
2026-09-29 17:02:31 -04:00
co-authored by Claude Fable 5.1
parent 6f2a8dff09
commit 4d222a7eba
22 changed files with 627 additions and 83 deletions
+73 -2
View File
@@ -11,12 +11,12 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { advance, pump } from '../src/engine/advance.ts';
import { applyIntent, areaAtSeat, areaOf, check } from '../src/engine/apply.ts';
import { applyIntent, areaAtSeat, areaOf, check, occupancyFor } from '../src/engine/apply.ts';
import { STAGES_PER_DAY, STAGES_PER_SHIFT, crewTrayCount } from '../src/engine/content.ts';
import { createGame } from '../src/engine/setup.ts';
import { legalActions } from '../src/engine/legal.ts';
import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts';
import { coordKey, playerAtSeat, playerLeftOf, seatOf, subdivisions } from '../src/engine/state.ts';
import { coordKey, playerAtSeat, playerLeftOf, seatOf, subdivisions, turnOf } from '../src/engine/state.ts';
import { developerBot, playGame } from '../src/sim/bot.ts';
import { snapshot } from '../src/sim/view.ts';
import { divisionSvg } from '../src/sim/board-svg.ts';
@@ -979,3 +979,74 @@ describe('Employee Rotation (Appendix B)', () => {
for (const name of ['Alice', 'Bob', 'Carol']) assert.match(line, new RegExp(name));
});
});
// ---------------------------------------------------------------------------
// v0.8.3 — audit findings (2026-09-29): a player's switching stays in their own district
// ---------------------------------------------------------------------------
describe("switching is confined to the actor's own district (v0.8.3)", () => {
/**
* `check` resolved the tray with no seat test at all: the legal-move GENERATOR filtered trays by
* seat, `check` did not, and the server validates with `check` alone. Every district opens on the
* same coordinates, so a destination legal for a tray of your own at (0,0) was "legal" for a
* rival's tray at THEIR (0,0) — and `trayMoved` then charged the Moves to the rival.
*/
const placeOwn = (s: GameState, owner: PlayerIndex, id: string): void => {
const area = areaOf(s, owner);
s.trays.set(id, {
id, trainNumber: null, trainIsExtra: false, engineAt: 0, consist: [],
direction: 'east', position: { at: 'grid', seat: seatOf(s, owner), coord: area.officeCoord }, movesUsed: 0,
});
};
const switching = (s: GameState, player: PlayerIndex): void => {
s.clock.phase = 'localOps';
s.clock.currentActor = player;
applyIntent(s, player, { type: 'localOps.choose', option: 'switch' });
assert.equal(turnOf(s, player).option, 'switch', 'could not choose Switch');
};
it("refuses a switch.move on a rival's tray that would have been legal on your own", () => {
const s = game(2);
// Find a move the actor could make with a tray of THEIR OWN standing at the Office.
placeOwn(s, 0, 'mine');
switching(s, 0);
const own = legalActions(s, 0).find((i) => i.type === 'switch.move' && i.trayId === 'mine');
assert.ok(own && own.type === 'switch.move', 'no switching move to test with');
s.trays.delete('mine');
// Now the same move named against seat 1's tray, standing at seat 1's Office.
placeOwn(s, 1, 'theirs');
const movesBefore = turnOf(s, 0).movesRemaining;
const theirMovesBefore = turnOf(s, 1).movesRemaining;
const code = check(s, 0, { ...own, trayId: 'theirs' });
assert.equal(code, 'NO_SUCH_TRAY', `a rival's tray was accepted (${code ?? 'null'})`);
assert.ok(!applyIntent(s, 0, { ...own, trayId: 'theirs' }).ok, 'the move was applied');
assert.equal(turnOf(s, 0).movesRemaining, movesBefore);
assert.equal(turnOf(s, 1).movesRemaining, theirMovesBefore, "the rival's Moves were charged");
assert.deepEqual(s.trays.get('theirs')!.position, { at: 'grid', seat: seatOf(s, 1), coord: areaOf(s, 1).officeCoord });
});
it("refuses dropCars and sortConsist on a rival's tray too", () => {
const s = game(2);
placeOwn(s, 0, 'mine'); // Switch is only on offer with a tray of your own to switch
placeOwn(s, 1, 'theirs');
s.trays.get('theirs')!.consist.push({ type: 'boxcar', loaded: false });
switching(s, 0);
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 'theirs', count: 1 }), 'NO_SUCH_TRAY');
assert.equal(check(s, 0, { type: 'switch.sortConsist', trayId: 'theirs', order: [0] }), 'NO_SUCH_TRAY');
});
it("does not see a rival's crew as standing in your district", () => {
/**
* `occupancyFor().trayAt` matched on coordinates alone, so a crew at seat 0's (0,2) blocked
* seat 1's (0,2) as "another train standing here". Invisible in solitaire.
*/
const s = game(2);
const spot = { row: areaOf(s, 0).officeCoord.row, col: areaOf(s, 0).officeCoord.col + 2 };
s.trays.set('crew0', {
id: 'crew0', trainNumber: null, trainIsExtra: false, engineAt: 0, consist: [],
direction: 'east', position: { at: 'grid', seat: seatOf(s, 0), coord: spot }, movesUsed: 0,
});
assert.equal(occupancyFor(s, 0, 'other').trayAt(spot), 'crew0', 'the owner cannot see their own crew');
assert.equal(occupancyFor(s, 1, 'other').trayAt(spot), null, "a rival's crew is standing in the wrong district");
});
});