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:
co-authored by
Claude Fable 5.1
parent
6f2a8dff09
commit
4d222a7eba
@@ -1704,3 +1704,74 @@ describe('a train sorted to the nose is judged by §8.2, not by where the engine
|
||||
assert.match(why, /caboose must be at the rear/, `the hold did not say why: ${why}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v0.8.3 — audit findings (2026-09-29)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('the collision floor at the end of a Day (v0.8.3)', () => {
|
||||
it('fires on a breach reached in Stage 12, before the Day rolls over', () => {
|
||||
/**
|
||||
* `shiftChange` reset `collisionsToday` at the Day rollover and only THEN asked whether the Day
|
||||
* had breached the limit — so a third wreck in Stage 12 was read as zero, and the one Stage of
|
||||
* the Day in which the floor could not fire was the last one. The tests above all set `stage`
|
||||
* to 1, which never crosses the rollover.
|
||||
*/
|
||||
const s = game(1, { mode: 'competitive', maxCollisionsPerDay: 2 });
|
||||
s.collisionsToday = 2;
|
||||
s.clock.stage = STAGES_PER_DAY;
|
||||
s.clock.phase = 'shiftChange';
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished', 'a breach in the last Stage of the Day went unpunished');
|
||||
assert.equal(s.outcome!.result, 'loss');
|
||||
assert.equal(s.outcome!.reason, 'collisionFloor');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Expedite fault is charged once per Mainline Phase (v0.8.3)', () => {
|
||||
it('does not charge again when the phase resumes after a clearance ruling', () => {
|
||||
/**
|
||||
* The Q3 fault loop ran unguarded at the top of `mainlinePhase`, and the phase is re-entered
|
||||
* from the top after every clearance, Yard Office or Red Flag question — so an Expedited train
|
||||
* left on a siding was fined once per QUESTION rather than once per Phase.
|
||||
*/
|
||||
const s = game(7, { days: 5 });
|
||||
for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains';
|
||||
const area = areaOf(s, 0);
|
||||
// The Expedited train, parked off the station: one fault is due.
|
||||
s.trays.set('expedited', {
|
||||
id: 'expedited', trainNumber: 6, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction: 'east', facing: 'e',
|
||||
position: { at: 'grid', seat: 0, coord: { row: area.officeCoord.row, col: area.officeCoord.col + 1 } }, movesUsed: 0,
|
||||
});
|
||||
// A departing train that will put a clearance question to the Superintendent mid-phase.
|
||||
s.trays.set('leaving', {
|
||||
id: 'leaving', trainNumber: 12, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction: 'east', facing: 'e', position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
||||
});
|
||||
area.adOccupancy.push('leaving');
|
||||
const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
|
||||
const ahead = s.division.nodes.findIndex((n, i) => i > office && n.kind === 'mainline');
|
||||
const node = s.division.nodes[ahead];
|
||||
assert.equal(node?.kind, 'mainline');
|
||||
s.trays.set('ahead', {
|
||||
id: 'ahead', trainNumber: 9, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction: 'east', facing: 'e', position: { at: 'mainline', index: ahead }, movesUsed: 0,
|
||||
});
|
||||
if (node?.kind === 'mainline') node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
||||
|
||||
s.clock.phase = 'mainline';
|
||||
const before = s.players[0]!.revenue;
|
||||
const first = advance(s);
|
||||
assert.ok(first.needsInput, 'no clearance question was put, so the phase never resumed');
|
||||
assert.equal(first.events.filter((e) => e.type === 'expediteFault').length, 1);
|
||||
|
||||
// Hold the departing train, so the only Revenue that can move is the fault's.
|
||||
const ruling = legalActions(s, s.clock.superintendent).find((i) => i.type === 'mainline.clearance' && !i.allow);
|
||||
assert.ok(ruling, 'no clearance ruling on offer');
|
||||
assert.ok(applyIntent(s, s.clock.superintendent, ruling).ok);
|
||||
const resumed = advance(s);
|
||||
assert.ok(!resumed.events.some((e) => e.type === 'expediteFault'), 'the fault was charged a second time');
|
||||
assert.equal(s.players[0]!.revenue, before - EXPEDITE_FAULT_PENALTY, 'more than one fault was charged');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -941,3 +941,60 @@ describe('defensive enhancements', () => {
|
||||
assert.equal(hasDistrictEnhancement(areaOf(s, 0), 'waterColumn'), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v0.8.3 — audit findings (2026-09-29)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('a train held at the Limits is released when a track frees (v0.8.3)', () => {
|
||||
it('takes a free A/D track at the end of the Mainline Phase without waiting for another arrival', () => {
|
||||
/**
|
||||
* The only release was inside `arriveAtOffice` for a DIFFERENT train — so an Office that
|
||||
* emptied by departures alone kept the held train at its Limits for the rest of the game,
|
||||
* invisible: no transit, not in `adOccupancy`, not counted by the clearance check.
|
||||
*/
|
||||
const s = game();
|
||||
const area = areaOf(s, 0);
|
||||
const card = straight();
|
||||
card.enhancements.push('interlocking');
|
||||
addCard(s, at(0, 2), card);
|
||||
s.trays.set('waiting', {
|
||||
id: 'waiting', trainNumber: 8, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
} as never);
|
||||
area.heldAtLimits = ['waiting'];
|
||||
area.adOccupancy = []; // the Office cleared, and nothing is arriving
|
||||
s.timetable = s.timetable.map(() => null);
|
||||
|
||||
s.clock.phase = 'mainline';
|
||||
const r = advance(s);
|
||||
|
||||
assert.ok(area.adOccupancy.includes('waiting'), 'the held train is still at the Limits with the Office empty');
|
||||
assert.deepEqual(area.heldAtLimits, []);
|
||||
assert.deepEqual(s.trays.get('waiting')!.position, { at: 'grid', seat: 0, coord: area.officeCoord });
|
||||
const released = r.events.find((e) => e.type === 'trainReleasedFromLimits');
|
||||
assert.ok(released, 'the release is silent');
|
||||
const line = narrate(released as never, { playerName: () => 'A' });
|
||||
assert.match(line.text, /RELEASED from the Limits/, line.text);
|
||||
});
|
||||
|
||||
it('still waits while the Office is full', () => {
|
||||
const s = game();
|
||||
const area = areaOf(s, 0);
|
||||
s.trays.set('waiting', {
|
||||
id: 'waiting', trainNumber: 8, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
} as never);
|
||||
area.heldAtLimits = ['waiting'];
|
||||
area.adOccupancy = ['blocker'];
|
||||
s.trays.set('blocker', {
|
||||
id: 'blocker', trainNumber: null, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
||||
} as never);
|
||||
s.timetable = s.timetable.map(() => null);
|
||||
s.clock.phase = 'mainline';
|
||||
advance(s);
|
||||
assert.deepEqual(area.heldAtLimits, ['waiting']);
|
||||
assert.ok(!area.adOccupancy.includes('waiting'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ const SAMPLES: GameEvent[] = [
|
||||
{ type: 'freightAgentIdled', player: 0 },
|
||||
{ type: 'trainReleasedFromLimits', trainNumber: 8, office: 'Whistle Post', owner: 0, freedBy: 14 },
|
||||
{ type: 'inboundCleared', player: 0, at: { row: 1, col: 0 }, stock: { type: 'hopper', loaded: true } },
|
||||
{ type: 'facilityUnjammed', player: 0, at: { row: 1, col: 0 }, from: 'menAtWork', stock: { type: 'hopper', loaded: true } },
|
||||
{ type: 'facilityUnjammed', player: 0, at: { row: 1, col: 0 }, from: 'menAtWork', index: 0, stock: { type: 'hopper', loaded: true } },
|
||||
{ type: 'trainScheduled', player: 0, trainNumber: 4, roll: 7, slot: 6, rngState: 1 },
|
||||
{ type: 'carPlacedOnTrain', player: 0, trayId: 't0', stock: { type: 'coach', loaded: false }, trainNumber: 10, isExtra: false },
|
||||
{ type: 'carPassed', player: 0, trayId: 't0', trainNumber: 10, isExtra: false },
|
||||
|
||||
+26
-6
@@ -7,7 +7,7 @@ import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type { CarType } from '../src/engine/content.ts';
|
||||
import { DEFENCE_ONLY_CARDS, DEFENCE_ONLY_COPIES, MODIFIER_PROFILES, OPENING_OTHER, OPENING_TRACK, SOLITAIRE_DECK_SIZE, TRACK_CARDS, TRACK_IN_DECK, withSavedOpening } from '../src/engine/content.ts';
|
||||
import { DEFENCE_ONLY_CARDS, DEFENCE_ONLY_COPIES, MODIFIER_PROFILES, OPENING_OTHER, OPENING_TRACK, SOLITAIRE_DECK_SIZE, TRACK_CARDS, TRACK_IN_DECK, withSavedDeal } from '../src/engine/content.ts';
|
||||
import {
|
||||
DECK_SIZE,
|
||||
EXTRA_TRAINS,
|
||||
@@ -680,16 +680,36 @@ describe('the starting Office, and the deck that goes with it', () => {
|
||||
it('replays a save written before the setting as the Whistle Post game it was', () => {
|
||||
/**
|
||||
* The one house rule that changes how a game is DEALT rather than how it plays, so replaying it
|
||||
* under the wrong opening is a different railroad from intent one — silently. `withSavedOpening`
|
||||
* under the wrong opening is a different railroad from intent one — silently. `withSavedDeal`
|
||||
* fills it for a save that names other rules and cannot name this one.
|
||||
*/
|
||||
const saved: { houseRules: { startingHand: 'sixRandom'; startingOffice?: 'depot' | 'whistlePost' } } =
|
||||
const saved: { houseRules: { startingHand: 'sixRandom'; startingOffice?: 'depot' | 'whistlePost'; secondSectionCard?: boolean } } =
|
||||
{ houseRules: { startingHand: 'sixRandom' } };
|
||||
assert.equal(withSavedOpening(saved).houseRules.startingOffice, 'whistlePost');
|
||||
assert.equal(withSavedDeal(saved).houseRules.startingOffice, 'whistlePost');
|
||||
// ...and from a deck without the Second Section card, which went in at the same time (v0.8.3).
|
||||
assert.equal(withSavedDeal(saved).houseRules.secondSectionCard, false);
|
||||
|
||||
// A config that names it is left exactly as it is, in both directions.
|
||||
assert.equal(withSavedOpening({ houseRules: { startingOffice: 'depot' as const } }).houseRules.startingOffice, 'depot');
|
||||
assert.equal(withSavedDeal({ houseRules: { startingOffice: 'depot' as const } }).houseRules.startingOffice, 'depot');
|
||||
assert.ok(!('secondSectionCard' in withSavedDeal({ houseRules: { startingOffice: 'depot' as const } }).houseRules));
|
||||
// And a config with no house rules at all is a fresh game, not an old save.
|
||||
assert.deepEqual(withSavedOpening({}), {});
|
||||
assert.deepEqual(withSavedDeal({}), {});
|
||||
});
|
||||
|
||||
it('deals the same deck a pre-0.8.2 save was dealt from — no Second Section card (v0.8.3)', () => {
|
||||
/**
|
||||
* The card went into the deck in v0.8.2 after that release's save check had been run. A deck one
|
||||
* card larger shuffles into a different order from the same seed, so every save on the test
|
||||
* server refused at move 3 while the release notes said three would resume. Pinned here: the
|
||||
* legacy deal has no such card and the fresh deal has exactly one.
|
||||
*/
|
||||
const count = (g: ReturnType<typeof createGame>): number =>
|
||||
[...g.cards.values()].filter((c) => c.kind.kind === 'secondSection').length;
|
||||
const fresh = createGame({ id: 'f', seed: 7, config: { ...solitaireConfig, houseRules: {} } as never, playerNames: ['A'] });
|
||||
assert.equal(count(fresh), 1, 'a fresh deal should carry one Second Section card');
|
||||
const legacy = createGame({
|
||||
id: 'l', seed: 7, config: withSavedDeal({ ...solitaireConfig, houseRules: { startingHand: 'sixRandom' } }) as never, playerNames: ['A'],
|
||||
});
|
||||
assert.equal(count(legacy), 0, 'a pre-0.8.2 save was dealt from a deck with no Second Section card');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user