asks what game you want Three queued items. The last matters most. A RELEASE NO LONGER DESTROYS EVERY GAME IN PROGRESS. Four consecutive releases killed every game on the box, one of them a release that changed only how the board is drawn. The reasoning behind the refusal was always right — a move legal under old rules may not be legal under new ones, and half-replaying a save is worse than refusing it. The TEST was wrong: it compared engineVersion for exact equality, and that stamp is the package version, which moves for a CSS fix. Whether a save still replays has an exact answer, so it is now asked directly. loadGame reads the file and judges nothing; tryResumeSession replays the intents and reports the first one the engine refuses. A save stamped with a version this server has never run resumes fine provided its moves replay — verified against a file hand-stamped 0.4.9-ancient. One that genuinely does not replay is still refused, but the log names the move rather than two version strings: "move 3 of 8 (localOps.choose) is rejected by the current rules with OPTION_ALREADY_CHOSEN". fromMultiplayerSave had to stop lying first. It has always stopped at the first unacceptable intent and done so in silence, which was survivable only because the version gate meant a doomed replay was never attempted. Now that the replay IS the check, it returns where it stopped and why. Deliberately not done: resuming a partly-replayable game at its last good move. That silently rewinds a game to a position nobody played to while every browser holding a later Frame carries on unaware. Refusing leaves the file intact, so putting the previous version back still recovers it. EMPLOYEE ROTATION IS IMPLEMENTED, SISTER TRAINS IS DELETED. Two of the four optional-rule flags were read by nothing at all. Employee Rotation is four lines in advance.ts, because the seat/player split (D9) exists for precisely this rule: seating is the only thing that moves, so Revenue, hands, the Superintendent and whose turn it is travel with the player, and the Office, district, grid and any trains standing in it stay with the chair. Inheriting the district you move into is the point of the rule, not a side effect. "Left" is seat + 1, matching playerLeftOf. Sister Trains is deleted rather than built: Q9 records that the Second Section card supersedes it, and that card exists, so the flag was a toggle for a rule the game no longer has. THE LOBBY ASKS WHAT GAME YOU WANT TO PLAY. Creating a game asked for a name, a mode and a table size; every other dial was hardcoded. A Game settings block now carries the same set the solitaire dialog does — seed, starting hand, the three revenue rates, Days, the combined-Revenue floor, both collision caps, the opponent-card toggle — plus the three surviving optional rules. Mode and table size set the defaults and everything stays editable. The seed is honoured, so a game can be reproduced or compared. Verified: 682 tests pass (679 + 3). The rotation tests were mutation-checked both ways — disabling the rotation and turning the table the wrong way each fail the suite. Live: a save stamped 0.4.9-ancient resumed, an injected illegal move was refused by name, and a create with every dial set to a non-default value came back out of game.json with all of them intact, including seed 777. Two of my own assertions were wrong on the way and the tests caught them: the Fedora legitimately passes at Stage 12 (§5) so it cannot be compared against its own earlier value, and dispatchUsedToday is cleared at every Day boundary so it cannot mark a district.
137 lines
5.8 KiB
TypeScript
137 lines
5.8 KiB
TypeScript
/**
|
|
* 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',
|
|
'expediteFault',
|
|
'phaseBegan',
|
|
// Employee Rotation moves `seating` in the phase driver and then describes what it did, which is
|
|
// the pattern every entry on this list follows.
|
|
'seatsRotated',
|
|
'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 POSITION in the save — the seed, the rules dealt under, 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.
|
|
//
|
|
// `rules` is the one addition, and it is not position: it is the other half of the seed. A seed
|
|
// only names a game together with the rules it was dealt under, which is why two published
|
|
// replays went dead when the rules moved (`TODO.md`) — the intents were fine, the deal was not.
|
|
const game = newGame(7);
|
|
assert.deepEqual(Object.keys(toSave(game)).sort(), ['history', 'rules', 'seed']);
|
|
});
|
|
});
|