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.
118 lines
5.3 KiB
TypeScript
118 lines
5.3 KiB
TypeScript
/**
|
|
* §7's redaction test — "the single most important test in the plan."
|
|
*
|
|
* Everything else about `Frame` degrades gracefully; a redaction bug hands one player's hand to
|
|
* another and cannot be walked back once it has been seen. `test/multiplayer.test.ts`'s "the view
|
|
* shows one seat at a time" section already proves `snapshot(s, ..., viewer)` gives each seat its
|
|
* own hand, board and Revenue — spot-checks that today's code does the right thing. This is the
|
|
* different, exhaustive check: serialize a seat's whole `Frame` and assert none of some OTHER seat's
|
|
* actual secret data appears anywhere in it, so a future careless edit is caught rather than assumed
|
|
* safe. No server needed — `snapshot()` and a multi-player `GameState` are all this exercises.
|
|
*/
|
|
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import { pump } from '../src/engine/advance.ts';
|
|
import { createGame } from '../src/engine/setup.ts';
|
|
import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts';
|
|
import { developerBot, playGame } from '../src/sim/bot.ts';
|
|
import { snapshot } from '../src/sim/view.ts';
|
|
|
|
const config: GameConfig = {
|
|
mode: 'competitive',
|
|
days: 5,
|
|
minCombinedRevenue: 0,
|
|
maxCollisionsPerDay: 0,
|
|
maxCollisionsTotal: 0,
|
|
pvpCardsAllowed: false,
|
|
optionalRules: {
|
|
reducedVisibility: false,
|
|
employeeRotation: false,
|
|
emergencyToolbox: false,
|
|
},
|
|
};
|
|
|
|
/** Plays a real multi-player game partway — enough for every seat to hold a real, distinct hand. */
|
|
function midGame(players: number, seed: number): GameState {
|
|
const s = createGame({
|
|
id: `redact-${players}`,
|
|
seed,
|
|
config,
|
|
playerNames: Array.from({ length: players }, (_, i) => `p${i}`),
|
|
});
|
|
const r = playGame(s, developerBot, pump, 400);
|
|
// A partial or finished game both exercise real hands — either is fine for this check.
|
|
void r;
|
|
return s;
|
|
}
|
|
|
|
describe('redaction — a seat\'s Frame never carries another seat\'s secrets', () => {
|
|
it('never contains another seat\'s actual hand-card ids', () => {
|
|
for (const players of [3, 4]) {
|
|
const s = midGame(players, 1000 + players);
|
|
for (let viewer = 0 as PlayerIndex; viewer < players; viewer++) {
|
|
const serialized = JSON.stringify(snapshot(s, [], null, null, null, false, viewer));
|
|
for (let other = 0 as PlayerIndex; other < players; other++) {
|
|
if (other === viewer) continue;
|
|
for (const cardId of s.decks.hands.get(other) ?? []) {
|
|
assert.ok(
|
|
!serialized.includes(`"${cardId}"`),
|
|
`${players}p seed ${1000 + players}: seat ${viewer}'s Frame contains seat ${other}'s ` +
|
|
`hand card id "${cardId}"`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it('never contains the Home Office deck\'s order or contents, only its count', () => {
|
|
for (const players of [3, 4]) {
|
|
const s = midGame(players, 2000 + players);
|
|
// The deck's own card ids are the thing that must never leak — distinct from any hand's ids,
|
|
// since a card once dealt is removed from `homeOffice` (state.ts).
|
|
const deckIds = new Set(s.decks.homeOffice);
|
|
for (let viewer = 0 as PlayerIndex; viewer < players; viewer++) {
|
|
const frame = snapshot(s, [], null, null, null, false, viewer);
|
|
assert.equal(frame.deck, s.decks.homeOffice.length, 'deck field is not a plain count');
|
|
const serialized = JSON.stringify(frame);
|
|
for (const cardId of deckIds) {
|
|
assert.ok(
|
|
!serialized.includes(`"${cardId}"`),
|
|
`${players}p seed ${2000 + players}: seat ${viewer}'s Frame contains a Home Office deck id "${cardId}"`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it('never carries the seed or rngState — Frame has no field for either', () => {
|
|
// A structural guarantee, not a runtime one: confirmed here so a future field addition to Frame
|
|
// that reintroduces one of these is at least forced past a reader of this test, if not the type
|
|
// system directly (see Frame in src/sim/view.ts, which carries neither today).
|
|
const s = midGame(3, 3003);
|
|
const frame = snapshot(s, [], null, null, null, false, 0);
|
|
assert.ok(!('seed' in frame), 'Frame gained a seed field');
|
|
assert.ok(!('rngState' in frame), 'Frame gained an rngState field');
|
|
const serialized = JSON.stringify(frame);
|
|
assert.ok(!serialized.includes(String(s.seed)), 'the seed value leaked into the Frame some other way');
|
|
});
|
|
|
|
it('only the viewer\'s own hand and handCount are non-public — everything else matches across seats', () => {
|
|
// The redaction surface is four fields (§7), not sixty event types. Cross-check that seats agree
|
|
// on everything else a Frame carries about shared state.
|
|
const s = midGame(3, 4004);
|
|
const frames = [0, 1, 2].map((p) => snapshot(s, [], null, null, null, false, p as PlayerIndex));
|
|
for (const f of frames) {
|
|
assert.deepEqual(f.timetable, frames[0]!.timetable, 'the public timetable differs by seat');
|
|
assert.deepEqual(f.deck, frames[0]!.deck, 'the deck count differs by seat');
|
|
assert.deepEqual(
|
|
f.players.map((p) => ({ index: p.index, revenue: p.revenue, hand: p.hand })),
|
|
frames[0]!.players.map((p) => ({ index: p.index, revenue: p.revenue, hand: p.hand })),
|
|
'public standing (names, Revenue, hand COUNTS) differs by seat',
|
|
);
|
|
}
|
|
});
|
|
});
|