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
+109
View File
@@ -2480,3 +2480,112 @@ describe('backing up over a cut to something beyond it takes both (v0.4.9d repor
empty(s, at(0, 1), at(0, 0));
});
});
// ---------------------------------------------------------------------------
// v0.8.3 — audit findings (2026-09-29)
// ---------------------------------------------------------------------------
describe('a Department draw that empties the Home Office deck (v0.8.3)', () => {
/**
* THE DRAWN CARD WAS DUPLICATED AND THE REFILL CARD DESTROYED.
*
* `draw.fromDepartment` queued `departmentRefilled` and then asked `reshuffleIfDepleted` to sweep
* the Departments — from the state BEFORE either event had been reduced. So the sweep collected
* the card being drawn (still on its pile) and missed the refill card (still on the deck), and
* the reducers then dealt the drawn card into the new deck while the refill card, moved onto a
* pile the reshuffle immediately wiped, left the game. The Home Office path was covered by the
* tests above; this path was not.
*/
it('neither duplicates the drawn card nor loses the refill card', () => {
const s = game();
const all = [...s.decks.homeOffice];
s.decks.salvageYard = all.slice(0, 40);
s.decks.homeOffice = all.slice(40, 41); // exactly one card left: the refill card
const refill = s.decks.homeOffice[0]!;
const drawn = s.decks.departments[0]![s.decks.departments[0]!.length - 1]!;
s.decks.departments[0] = [drawn]; // a single card, so taking it empties the pile
const everywhere = (): string[] => [
...s.decks.homeOffice,
...s.decks.departments.flat(),
...s.decks.salvageYard,
...[...s.decks.hands.values()].flat(),
];
const before = everywhere().length;
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const r = applyIntent(s, 0, { type: 'draw.fromDepartment', slot: 0 });
assert.ok(r.ok);
assert.ok(r.events.some((e) => e.type === 'deckReshuffled'), 'the deck ran out and was not reshuffled');
const after = everywhere();
assert.equal(after.length, before, 'the reshuffle created or destroyed cards');
assert.equal(new Set(after).size, after.length, 'a card ended up in two places');
assert.ok(s.decks.hands.get(0)!.includes(drawn), 'the drawn card is not in hand');
assert.equal(after.filter((id) => id === drawn).length, 1, 'the drawn card was dealt back into the deck too');
assert.equal(after.filter((id) => id === refill).length, 1, 'the refill card left the game');
assert.ok(s.decks.departments.every((p) => p.length === 1), 'the Departments were not re-dealt one deep');
});
});
describe('unjamming the box the player named (v0.8.3)', () => {
/**
* `facilityUnjammed` cleared the FIRST load on MEN | AT | WORK, whatever index the intent named,
* because the event never carried the index — the same shape as the "westmost car" fault
* `unloadBegan` once had. With one load on the track it could not be seen.
*/
it('clears the named MEN | AT | WORK load, not the first one', () => {
const s = game();
const area = areaOf(s, 0);
area.grid.set('-1,0', {
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true, standing: [], standingWest: 0, modifiers: [], enhancements: [],
facility: {
kind: 'freight', subtype: 'mineTipple',
allows: { outbound: true, inbound: true },
outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 1 },
// An inbound tank load on MEN, a stranded outbound hopper on WORK.
menAtWork: [{ type: 'tank', dir: 'in' }, null, { type: 'hopper', dir: 'out' }],
industryTrack: { cars: [] },
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
} as never);
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
turnOf(s, 0).option = 'freightAgent';
const yardBefore = s.yards.classificationYard.length;
const r = applyIntent(s, 0, { type: 'freightAgent.unjam', at: { row: -1, col: 0 }, from: 'menAtWork', index: 2 });
assert.ok(r.ok, 'the jam could not be cleared');
const f = area.grid.get('-1,0')!.facility as { menAtWork: ({ type: string } | null)[] };
assert.ok(f.menAtWork[0], 'the tank load on MEN was cleared instead of the hopper on WORK');
assert.equal(f.menAtWork[2], null, 'the hopper on WORK is still there');
const returned = s.yards.classificationYard[yardBefore];
assert.equal(returned?.type, 'hopper', `a ${returned?.type} went to the Classification Yard, not the hopper`);
});
it('clears the named car in a green or red box, not the first of its type', () => {
const s = game();
const area = areaOf(s, 0);
area.grid.set('-1,0', {
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true, standing: [], standingWest: 0, modifiers: [], enhancements: [],
facility: {
kind: 'freight', subtype: 'mineTipple',
allows: { outbound: true, inbound: false },
outboundBox: [{ type: 'hopper', loaded: true, origin: 1 }, { type: 'hopper', loaded: true, origin: 2 }],
inboundBox: [], capacity: { outbound: 2, inbound: 0 },
menAtWork: [null, null, null],
industryTrack: { cars: [] },
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
} as never);
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
turnOf(s, 0).option = 'freightAgent';
const r = applyIntent(s, 0, { type: 'freightAgent.unjam', at: { row: -1, col: 0 }, from: 'outbound', index: 1 });
assert.ok(r.ok);
const f = area.grid.get('-1,0')!.facility!;
assert.deepEqual(f.outboundBox.map((c) => c.origin), [1], 'the wrong car left the box');
});
});