v0.4.1 - bug fixes. initial d12 rolls determine what player in which seat.
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* What the event log is, and what it is not.
|
||||
*
|
||||
* The README, four architecture documents and six source comments all claimed `state = fold(events)`
|
||||
* until v0.4.0. It was never true, and the claim was load-bearing — it was the stated justification
|
||||
* for reconnection, restart recovery and persistence, none of which were built yet. This file makes
|
||||
* the real shape checkable so the claim cannot quietly come back.
|
||||
*
|
||||
* The decision (`docs/architecture/protocol.md` §3): **the intents are canonical.** A game is
|
||||
* `{ seed, history: Intent[] }`, `fromSave` replays it exactly, and events narrate.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { fromSave, newGame, submit, toSave, view } from '../src/web/game.ts';
|
||||
import { actionGroups, currentActor } from '../src/web/game.ts';
|
||||
|
||||
const src = (rel: string): string => readFileSync(join(import.meta.dirname, '..', 'src', rel), 'utf8');
|
||||
|
||||
/** Every `type: 'x'` in the `GameEvent` union. */
|
||||
function eventTypes(): Set<string> {
|
||||
const text = src('engine/events.ts');
|
||||
const union = text.slice(text.indexOf('export type GameEvent ='));
|
||||
return new Set([...union.matchAll(/type: '([a-zA-Z]+)'/g)].map((m) => m[1]!));
|
||||
}
|
||||
|
||||
/** Every `case 'x':` inside `reduce`. */
|
||||
function reducedTypes(): Set<string> {
|
||||
const text = src('engine/apply.ts');
|
||||
const body = text.slice(text.indexOf('export function reduce'));
|
||||
return new Set([...body.matchAll(/case '([a-zA-Z]+)':/g)].map((m) => m[1]!));
|
||||
}
|
||||
|
||||
/**
|
||||
* The event types the reducer does not handle, as of v0.4.0.
|
||||
*
|
||||
* Every one is emitted by the phase driver in `advance.ts`, which mutates state and then describes
|
||||
* what it did. Read the list: it is the clock, plus the entire Mainline phase — which is to say every
|
||||
* train movement in the game. That is why folding the log rebuilds a district and not a railroad.
|
||||
*/
|
||||
const KNOWN_UNREDUCED = [
|
||||
'actorChanged',
|
||||
'carPassed',
|
||||
'clearanceRequested',
|
||||
'dispatchBonusUsed',
|
||||
'phaseBegan',
|
||||
'stageBegan',
|
||||
'trainArrived',
|
||||
'trainCompleted',
|
||||
'trainDiverted',
|
||||
'trainHeld',
|
||||
'trainHighballed',
|
||||
'trainMadeUp',
|
||||
'trainStoodStill',
|
||||
'trainsDestroyed',
|
||||
];
|
||||
|
||||
describe('the event log narrates but does not reconstruct', () => {
|
||||
it('has exactly the unreduced event types it is documented to have', () => {
|
||||
/**
|
||||
* A CHANGE-DETECTOR ON PURPOSE. If this fails because the list shrank, someone has made the
|
||||
* phase driver reduce — good, and the docs in `protocol.md` §3, `events.ts` and the README now
|
||||
* understate the engine and should be corrected in the same change. If it fails because the list
|
||||
* GREW, a new event was added on the mutate-then-describe path, which is worth knowing before it
|
||||
* becomes another thing the log cannot rebuild.
|
||||
*/
|
||||
const all = eventTypes();
|
||||
const reduced = reducedTypes();
|
||||
const unreduced = [...all].filter((t) => !reduced.has(t)).sort();
|
||||
assert.deepEqual(
|
||||
unreduced,
|
||||
KNOWN_UNREDUCED,
|
||||
'the set of events the reducer ignores has changed — see the comment above KNOWN_UNREDUCED',
|
||||
);
|
||||
});
|
||||
|
||||
it('never folds events in the phase driver, which is what makes the above true', () => {
|
||||
// `advance.ts` mutating directly is the whole mechanism. If it ever starts calling `reduce`,
|
||||
// the claim becomes recoverable and this file should be rewritten rather than relaxed.
|
||||
assert.doesNotMatch(
|
||||
src('engine/advance.ts'),
|
||||
/\breduce\(s[,)]/,
|
||||
'the phase driver now folds events — reconsider the canonical-record decision',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the intents are what reconstructs a game', () => {
|
||||
it('replays a partly-played game to exactly the same position', () => {
|
||||
// The property that actually holds, stated as a test rather than as a comment. This is what
|
||||
// save, share, undo, restart recovery and post-game replay all rest on.
|
||||
const game = newGame(31337);
|
||||
for (let i = 0; i < 120; i++) {
|
||||
if (currentActor(game) === null) break;
|
||||
const { options } = actionGroups(game);
|
||||
if (options.length === 0) break;
|
||||
if (!submit(game, options[0]!)) break;
|
||||
}
|
||||
assert.ok(game.history.length > 20, 'the driver did not play far enough to be a real test');
|
||||
|
||||
const back = fromSave(toSave(game));
|
||||
assert.deepEqual(view(back).cells, view(game).cells, 'the board differs after replay');
|
||||
assert.deepEqual(
|
||||
back.state.players.map((p) => p.revenue),
|
||||
game.state.players.map((p) => p.revenue),
|
||||
'the score differs after replay',
|
||||
);
|
||||
assert.equal(back.state.clock.day, game.state.clock.day);
|
||||
assert.equal(back.state.clock.stage, game.state.clock.stage);
|
||||
assert.equal(back.state.clock.phase, game.state.clock.phase);
|
||||
// The trains are the part folding the log would have lost, so check them specifically.
|
||||
assert.deepEqual(
|
||||
[...back.state.trays.keys()].sort(),
|
||||
[...game.state.trays.keys()].sort(),
|
||||
'the crews differ after replay',
|
||||
);
|
||||
});
|
||||
|
||||
it('carries no state in the save beyond the seed and the intents', () => {
|
||||
// If anything else ever creeps into `Save`, the claim above weakens: the game would no longer be
|
||||
// reconstructible from decisions alone, and persistence would have a schema to migrate.
|
||||
const game = newGame(7);
|
||||
assert.deepEqual(Object.keys(toSave(game)).sort(), ['history', 'seed']);
|
||||
});
|
||||
});
|
||||
+86
-41
@@ -11,7 +11,7 @@ import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { advance, pump } from '../src/engine/advance.ts';
|
||||
import { areaOf } from '../src/engine/apply.ts';
|
||||
import { areaAtSeat, areaOf } from '../src/engine/apply.ts';
|
||||
import { STAGES_PER_SHIFT, crewTrayCount } from '../src/engine/content.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts';
|
||||
@@ -42,7 +42,7 @@ function readyToLeave(s: GameState, owner: PlayerIndex, id: string, direction: '
|
||||
const area = areaOf(s, owner);
|
||||
s.trays.set(id, {
|
||||
id, trainNumber: 9, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction, position: { at: 'grid', seat: owner, coord: area.officeCoord }, movesUsed: 0,
|
||||
direction, position: { at: 'grid', seat: seatOf(s, owner), coord: area.officeCoord }, movesUsed: 0,
|
||||
});
|
||||
area.adOccupancy.push(id);
|
||||
}
|
||||
@@ -93,8 +93,11 @@ describe('multi-player games run at all', () => {
|
||||
for (const players of [2, 3, 4]) {
|
||||
const s = game(players);
|
||||
assert.equal(s.officeAreas.size, players, `${players}p office areas`);
|
||||
for (let p = 0; p < players; p++) {
|
||||
assert.equal(areaOf(s, p).seat, p, 'an Office Area is owned by the wrong seat');
|
||||
// Every seat is occupied by exactly one player, and that player's district is that seat's.
|
||||
// Which player sits where is decided by §4.4's D12, so it is a permutation, not the identity.
|
||||
assert.deepEqual([...s.seating].sort((a, b) => a - b), [...Array(players).keys()]);
|
||||
for (let seat = 0; seat < players; seat++) {
|
||||
assert.equal(areaOf(s, playerAtSeat(s, seat)).seat, seat, 'a seat holds the wrong district');
|
||||
}
|
||||
// §7 — trays are scarce on purpose, and the count is per player count.
|
||||
assert.equal(s.freeTrays.length, crewTrayCount(players), `${players}p crew trays`);
|
||||
@@ -107,8 +110,10 @@ describe('the Fedora goes round the table', () => {
|
||||
* Watches the Superintendent while the BOT plays the game.
|
||||
*
|
||||
* `advance` stops and asks for input rather than driving itself, so calling it in a loop never
|
||||
* moves the clock — the game has to actually be played. A spy policy records the seat holding the
|
||||
* Fedora each time a decision is asked for.
|
||||
* moves the clock — the game has to actually be played. A spy policy records the SEAT holding the
|
||||
* Fedora each time a decision is asked for: §5 passes it round the table, and since §4.4's D12
|
||||
* decides who sits where, the sequence of player indices is a permutation while the sequence of
|
||||
* seats is the plain 0, 1, 2, … that the rule describes.
|
||||
*/
|
||||
const superintendentsSeen = (players: number): number[] => {
|
||||
const s = game(players);
|
||||
@@ -116,7 +121,7 @@ describe('the Fedora goes round the table', () => {
|
||||
const spy = {
|
||||
name: 'spy',
|
||||
choose(st: GameState, p: PlayerIndex, opts: Parameters<typeof developerBot.choose>[2]) {
|
||||
const who = st.clock.superintendent;
|
||||
const who = seatOf(st, st.clock.superintendent);
|
||||
if (seen[seen.length - 1] !== who) seen.push(who);
|
||||
return developerBot.choose(st, p, opts);
|
||||
},
|
||||
@@ -183,13 +188,16 @@ describe('Subdivisions are split by whoever is a Control Point', () => {
|
||||
const s = game(4);
|
||||
assert.equal(subdivisions(s).length, 1, 'four Whistle Posts should leave one Subdivision');
|
||||
|
||||
areaOf(s, 1).tier = 'depot';
|
||||
// BY SEAT: a Subdivision is a stretch of the physical chain, and §4.4's D12 decides which
|
||||
// player is sitting in which stretch. Upgrading "player 1's" Office would upgrade whichever
|
||||
// seat they happen to hold, which is not what this test is about.
|
||||
areaAtSeat(s, 1).tier = 'depot';
|
||||
const split = subdivisions(s);
|
||||
assert.equal(split.length, 2, 'a Control Point should cut the Division in two');
|
||||
|
||||
// The upgraded Office is a BOUNDARY, so it appears in neither group; the others still sit inside.
|
||||
const officeIndex = (owner: number): number =>
|
||||
s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === owner);
|
||||
const officeIndex = (seat: number): number =>
|
||||
s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === seat);
|
||||
const all = split.flat();
|
||||
assert.ok(!all.includes(officeIndex(1)), 'the Control Point is still inside a Subdivision');
|
||||
for (const other of [0, 2, 3]) {
|
||||
@@ -199,7 +207,7 @@ describe('Subdivisions are split by whoever is a Control Point', () => {
|
||||
|
||||
it('gives every Office its own Subdivision once they are all Control Points', () => {
|
||||
const s = game(4);
|
||||
for (let p = 0; p < 4; p++) areaOf(s, p).tier = 'terminal';
|
||||
for (let seat = 0; seat < 4; seat++) areaAtSeat(s, seat).tier = 'terminal';
|
||||
// Five Mainline cards, each now bounded by a Control Point or a Division Point.
|
||||
assert.equal(subdivisions(s).length, 5, 'each Mainline card should be its own Subdivision');
|
||||
});
|
||||
@@ -268,18 +276,41 @@ describe("one player's train blocks another's", () => {
|
||||
});
|
||||
|
||||
describe('a seat is a place, a player is a person', () => {
|
||||
it('starts with the identity mapping, so the split changes nothing today', () => {
|
||||
// The whole seat/player split is behaviour-neutral until something rotates `seating`. This is
|
||||
// what makes that claim checkable rather than asserted.
|
||||
for (const players of [1, 2, 3, 4]) {
|
||||
const s = players === 1
|
||||
? createGame({ id: 's', seed: 4242, config: { ...competitive, mode: 'solitaire' }, playerNames: ['a'] })
|
||||
: game(players);
|
||||
assert.deepEqual(s.seating, [...Array(players).keys()], `${players}p seating is not the identity`);
|
||||
it('seats everyone exactly once, and seats them by the §4.4 roll', () => {
|
||||
/**
|
||||
* `seating` was the identity mapping until §4.4's D12 was wired up, which meant the whole
|
||||
* seat/player distinction was untested at runtime — every mix-up of the two was silently
|
||||
* correct. It is a real permutation now, and this is what says so.
|
||||
*
|
||||
* Solitaire is the exception and must stay one: with a single player there is one seat, the
|
||||
* permutation is trivially the identity, and every replay depends on that.
|
||||
*/
|
||||
for (const players of [2, 3, 4]) {
|
||||
const s = game(players);
|
||||
assert.deepEqual([...s.seating].sort((a, b) => a - b), [...Array(players).keys()], 'not a permutation');
|
||||
for (let p = 0; p < players; p++) {
|
||||
assert.equal(seatOf(s, p), p);
|
||||
assert.equal(playerAtSeat(s, p), p);
|
||||
assert.equal(areaOf(s, p).seat, p);
|
||||
assert.equal(playerAtSeat(s, seatOf(s, p)), p, 'seatOf and playerAtSeat disagree');
|
||||
assert.equal(areaOf(s, p).seat, seatOf(s, p), 'a player is looking at the wrong district');
|
||||
}
|
||||
}
|
||||
|
||||
const solo = createGame({ id: 's', seed: 4242, config: { ...competitive, mode: 'solitaire' }, playerNames: ['a'] });
|
||||
assert.deepEqual(solo.seating, [0], 'solitaire seating must stay the identity — replays depend on it');
|
||||
});
|
||||
|
||||
it('seats the highest roller at the eastern end of the chain', () => {
|
||||
// §4.4 — "highest is the Eastern Division Point". `buildDivision` lays west-to-east, so the
|
||||
// eastern end is the LAST seat. Checked over many seeds rather than one, because a single deal
|
||||
// could satisfy this by luck.
|
||||
for (let seed = 1; seed <= 40; seed++) {
|
||||
const s = game(3, seed);
|
||||
const rolls = s.openingRolls.division;
|
||||
const east = s.seating[s.seating.length - 1]!;
|
||||
const best = Math.max(...rolls);
|
||||
assert.equal(rolls[east], best, `seed ${seed}: the easternmost seat is not the highest roll`);
|
||||
// And the chain runs low-to-high west to east, so nobody east of you rolled lower.
|
||||
for (let i = 1; i < s.seating.length; i++) {
|
||||
assert.ok(rolls[s.seating[i - 1]!]! <= rolls[s.seating[i]!]!, `seed ${seed}: the chain is not ordered`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -293,19 +324,26 @@ describe('a seat is a place, a player is a person', () => {
|
||||
*/
|
||||
const s = game(3);
|
||||
const officeOf = (p: PlayerIndex): number => areaOf(s, p).seat;
|
||||
assert.deepEqual([0, 1, 2].map(officeOf), [0, 1, 2]);
|
||||
const before = [0, 1, 2].map(officeOf);
|
||||
|
||||
// Mark each Office so we can see which one a player is looking at.
|
||||
for (let seat = 0; seat < 3; seat++) areaOf(s, seat).tier = (['depot', 'station', 'terminal'] as const)[seat]!;
|
||||
// Mark each Office so we can see which one a player is looking at. BY SEAT — the Offices are
|
||||
// the furniture, and the point of the test is that the furniture stays put.
|
||||
const tiers = ['depot', 'station', 'terminal'] as const;
|
||||
for (let seat = 0; seat < 3; seat++) areaAtSeat(s, seat).tier = tiers[seat]!;
|
||||
const tierOf = (p: PlayerIndex): string => areaOf(s, p).tier;
|
||||
assert.deepEqual([0, 1, 2].map(tierOf), ['depot', 'station', 'terminal']);
|
||||
assert.deepEqual([0, 1, 2].map(tierOf), before.map((seat) => tiers[seat]!));
|
||||
|
||||
// Everyone shuffles one chair along. The Offices do not move; the people do.
|
||||
s.seating = [2, 0, 1];
|
||||
// Everyone shuffles one chair along: whoever was at seat n is now at seat n+1.
|
||||
const rotated = [...s.seating];
|
||||
rotated.unshift(rotated.pop()!);
|
||||
s.seating = rotated;
|
||||
|
||||
assert.deepEqual([0, 1, 2].map(officeOf), [1, 2, 0], 'players did not move seats');
|
||||
assert.deepEqual([0, 1, 2].map(tierOf), ['station', 'terminal', 'depot'], 'the Offices moved with them');
|
||||
assert.equal(playerAtSeat(s, 0), 2, 'seat 0 should now be occupied by player 2');
|
||||
assert.deepEqual([0, 1, 2].map(officeOf), before.map((seat) => (seat + 1) % 3), 'players did not move seats');
|
||||
assert.deepEqual(
|
||||
[0, 1, 2].map(tierOf),
|
||||
before.map((seat) => tiers[(seat + 1) % 3]!),
|
||||
'the Offices moved with them instead of staying put',
|
||||
);
|
||||
// Revenue belongs to the person and must NOT have moved with the chair.
|
||||
assert.equal(s.players[0]!.index, 0, 'a player index changed when the seating rotated');
|
||||
});
|
||||
@@ -335,19 +373,26 @@ describe('a seat is a place, a player is a person', () => {
|
||||
* their old chair, naming squares that are not on the board in front of them.
|
||||
*/
|
||||
const s = game(3);
|
||||
const area = areaOf(s, 1);
|
||||
// Put it in a SEAT, and work out who is sitting there.
|
||||
const area = areaAtSeat(s, 1);
|
||||
area.grid.set(coordKey({ row: area.runningRow - 1, col: 0 }), idleIndustry());
|
||||
const occupant = playerAtSeat(s, 1);
|
||||
|
||||
const withImpediments = (): PlayerIndex[] =>
|
||||
[0, 1, 2].filter((p) => impediments(s, p).length > 0);
|
||||
assert.deepEqual(withImpediments(), [1], 'the industry is not reported to its own player');
|
||||
assert.deepEqual(withImpediments(), [occupant], 'the industry is not reported to its own player');
|
||||
|
||||
// Everyone shuffles one chair along: player 0 now sits at seat 1, so the industry is theirs.
|
||||
s.seating = [2, 0, 1];
|
||||
assert.deepEqual(withImpediments(), [0], 'the impediment did not follow the chair');
|
||||
// Everyone shuffles one chair along, so seat 1 changes hands.
|
||||
const rotated = [...s.seating];
|
||||
rotated.unshift(rotated.pop()!);
|
||||
s.seating = rotated;
|
||||
const newOccupant = playerAtSeat(s, 1);
|
||||
assert.notEqual(newOccupant, occupant, 'the rotation did not move anybody into seat 1');
|
||||
|
||||
assert.deepEqual(withImpediments(), [newOccupant], 'the impediment did not follow the chair');
|
||||
assert.deepEqual(
|
||||
[0, 1, 2].map((p) => snapshot(s, [], null, null, null, false, p).blocked.length > 0),
|
||||
[true, false, false],
|
||||
[0, 1, 2].map((p) => p === newOccupant),
|
||||
'the Frame disagrees with impediments after a rotation',
|
||||
);
|
||||
});
|
||||
@@ -523,13 +568,13 @@ describe('scoring lands on the right seat', () => {
|
||||
// A card laid in one player's district must not appear in another's — the areas are separate maps
|
||||
// and it would be easy for a shared reference to make every district the same district.
|
||||
const s = game(3);
|
||||
const mine = areaOf(s, 1);
|
||||
const sizeBefore = s.officeAreas.get(2)!.grid.size;
|
||||
const mine = areaAtSeat(s, 1);
|
||||
const sizeBefore = areaAtSeat(s, 2).grid.size;
|
||||
mine.grid.set(coordKey({ row: mine.runningRow - 1, col: 0 }), {
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
});
|
||||
assert.equal(s.officeAreas.get(2)!.grid.size, sizeBefore, "one player's track appeared in another's district");
|
||||
assert.notEqual(areaOf(s, 1), areaOf(s, 2), 'two seats share one Office Area object');
|
||||
assert.equal(areaAtSeat(s, 2).grid.size, sizeBefore, "one seat's track appeared in another's district");
|
||||
assert.notEqual(areaAtSeat(s, 1), areaAtSeat(s, 2), 'two seats share one Office Area object');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user