v0.7.9.2 — two things the table could hear that only one seat should
Both leaks were found while planning the common board (Gitea#20 step 1), and both are live multiplayer bugs with or without that display, so they are fixed now rather than with 0.8.0. `game.log` is one shared list and `linesSince(seat)` slices it with no per-seat filter, so every line reaches every player. It carried the SEED in the opening line of each multiplayer game — the whole future of the deal — and the NAME OF A CARD DRAWN BLIND from the face-down Home Office deck. Solitaire deliberately keeps both: a one-seat table has nobody to leak to, the seed is what a bug report quotes, and a player's own history naming their own draw is the record. A Department slot is face up and stays named. The drawer still learns their card through `justDrawn`, which already goes to that seat alone. Neither was found by a test. Every test in `redaction.test.ts` passes an empty log, so the whole of narration has sat outside the redaction net since the net was built. Both now have tests there; TODO #91 carries what is still owed and supersedes #78, which described a gap that had already been closed and never mentioned this one. `docs/rules/` had no current description of the game, and `content.ts` named `card-reference.md` as the file that carries what the cards say — a file whose own banner says not to use its numbers, describing the v0.4.5 deck where 3/4 is a Mail-Express with three coaches. Every file in that directory is a deliberate historical record, so none of them is rewritten. `as-built.md` is new and GENERATED from the same catalogues the engine instantiates from, with a test that re-runs the generator and fails when the checked-in file disagrees. A hand-written replacement would have drifted the same way, for the same reason. TODO.md: #32 closed — the playtest migration note did its job and the jump is made; the durable fact it carried is kept. #78 retired in favour of #91. The "play it at a table" section now records that 0.7.4-0.7.9 were test-run without change requests, and that more testing comes at the end of the 0.7.9 series. 897 tests pass, up from 891. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E3Qk7uresKCHksdZajXCLg
This commit is contained in:
co-authored by
Claude Opus 5
parent
7ade60e21f
commit
819996faa2
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* The card reference must not drift from the cards.
|
||||
*
|
||||
* `docs/rules/card-reference.md` spent several releases describing the v0.4.5 deck — twelve numbered
|
||||
* trains, "3 / 4 Mail-Express, 3 coaches" — while `content.ts` had train 3 as the Express with two
|
||||
* freight cars and a per-location freight rule. Worse, `content.ts` named that file as "the place
|
||||
* that now carries what the cards say", so the code sent readers to a table its own banner told them
|
||||
* not to trust. Nothing failed, because nothing checked.
|
||||
*
|
||||
* `docs/rules/as-built.md` is emitted from the same exported catalogues the engine instantiates
|
||||
* from, and this re-runs the generator and compares. Change a card face without regenerating and
|
||||
* this goes red — which is the whole point: a document nothing verifies is a document that will be
|
||||
* wrong, and this project's own history is the evidence.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const doc = join(root, 'docs/rules/as-built.md');
|
||||
|
||||
describe('docs/rules/as-built.md is generated, and current', () => {
|
||||
it('matches what the generator emits from content.ts today', () => {
|
||||
const before = readFileSync(doc, 'utf8');
|
||||
execFileSync(process.execPath, [join(root, 'scripts/build-card-reference.ts')], { cwd: root });
|
||||
const after = readFileSync(doc, 'utf8');
|
||||
assert.equal(
|
||||
after,
|
||||
before,
|
||||
'the checked-in card reference is stale — run `npm run build:cards` and commit the result',
|
||||
);
|
||||
});
|
||||
|
||||
it('carries the current train catalogue, not the v0.4.5 deck', () => {
|
||||
// The specific drift that went unnoticed for several releases, asserted by name so a future
|
||||
// regeneration against an old content.ts cannot quietly reintroduce it.
|
||||
const md = readFileSync(doc, 'utf8');
|
||||
assert.match(md, /Crack Limited/);
|
||||
assert.match(md, /\| 3 \| Express \|/);
|
||||
assert.ok(!/Mail-Express/.test(md), 'the superseded v0.4.5 train names are back');
|
||||
assert.ok(!/Manifest Freight/.test(md), 'the superseded v0.4.5 train names are back');
|
||||
});
|
||||
|
||||
it('says it is generated, so nobody edits it by hand', () => {
|
||||
const md = readFileSync(doc, 'utf8');
|
||||
assert.match(md, /Generated from `src\/engine\/content\.ts`/);
|
||||
assert.match(md, /Do not edit by/);
|
||||
});
|
||||
});
|
||||
+76
-1
@@ -17,7 +17,8 @@ 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';
|
||||
import { cardName, snapshot } from '../src/sim/view.ts';
|
||||
import { newGame, newMultiplayerGame, submit } from '../src/web/game.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
mode: 'competitive',
|
||||
@@ -139,3 +140,77 @@ describe('redaction — a seat\'s Frame never carries another seat\'s secrets',
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* THE OTHER HALF OF §7, AND THE HALF THAT WAS NEVER LOOKED AT.
|
||||
*
|
||||
* Every test above serializes a `Frame`, and every one of them passes `[]` for the narration log —
|
||||
* so the entire shared log has sat outside the redaction net since the net was built. It is not a
|
||||
* hypothetical hole: `game.log` is ONE list, and `linesSince(seat)` (`server/session.ts`) slices it
|
||||
* with no per-seat filter at all, so every line written into it reaches every player.
|
||||
*
|
||||
* Two things were being written into it that should never have left the seat that caused them, both
|
||||
* found while planning the public common board (Gitea#20 step 1) and both live in multiplayer today,
|
||||
* with or without that display:
|
||||
*
|
||||
* 1. the SEED, announced in the opening line of every multiplayer game — which hands every player
|
||||
* the whole future of the deal;
|
||||
* 2. the NAME OF A CARD DRAWN BLIND from the Home Office deck.
|
||||
*
|
||||
* SOLITAIRE IS DELIBERATELY LEFT ALONE in both cases. There is nobody to leak to at a one-seat
|
||||
* table, the seed in the log is what a bug report quotes, and a solo player's own history naming
|
||||
* the card they drew is the record, not a leak. The rule is "do not tell the OTHER seats", not
|
||||
* "write less down" — so both checks below assert the solitaire text is still there.
|
||||
*/
|
||||
describe('redaction — the shared narration log never carries a seat\'s secrets', () => {
|
||||
const names = ['Ann', 'Bob', 'Cy'];
|
||||
|
||||
it('never announces the seed to the table (Gitea#20 step 1)', () => {
|
||||
const g = newMultiplayerGame(550943578, config, names);
|
||||
const log = g.log.map((l) => l.text).join('\n');
|
||||
assert.ok(
|
||||
!/550943578/.test(log),
|
||||
`the seed was announced to every seat:\n${log}`,
|
||||
);
|
||||
// The opening line must still say what the game IS — the leak is the number, not the line.
|
||||
assert.match(log, /Game Begins/);
|
||||
assert.match(log, /3 players/);
|
||||
});
|
||||
|
||||
it('still tells a solitaire player their own seed — there is nobody to leak it to', () => {
|
||||
const g = newGame(550943578);
|
||||
const log = g.log.map((l) => l.text).join('\n');
|
||||
assert.match(log, /550943578/, 'a solo game stopped recording the seed its bug reports quote');
|
||||
});
|
||||
|
||||
it('never names a card drawn blind from the Home Office deck (Gitea#20 step 1)', () => {
|
||||
const g = newMultiplayerGame(4242, config, names);
|
||||
|
||||
// Drive to the first Home Office draw any seat makes, and note what it actually drew.
|
||||
let drawn: string | null = null;
|
||||
for (let i = 0; i < 400 && drawn === null; i++) {
|
||||
const actor = g.state.clock.currentActor;
|
||||
if (actor === null) break;
|
||||
const before = g.log.length;
|
||||
if (!submit(g, { type: 'localOps.choose', option: 'draw' }, actor as PlayerIndex)) continue;
|
||||
if (!submit(g, { type: 'draw.fromHomeOffice' }, actor as PlayerIndex)) continue;
|
||||
drawn = g.justDrawn;
|
||||
void before;
|
||||
}
|
||||
assert.ok(drawn, 'no seat ever drew from the Home Office deck');
|
||||
|
||||
const name = cardName(g.state, drawn!);
|
||||
const log = g.log.map((l) => l.text).join('\n');
|
||||
assert.ok(
|
||||
!log.includes(name),
|
||||
`a blind draw named "${name}" to the whole table:\n${log.split('\n').slice(-6).join('\n')}`,
|
||||
);
|
||||
// The draw itself is public — everyone saw a hand go to the deck. Only WHICH card is not.
|
||||
assert.match(log, /Home Office/i);
|
||||
|
||||
// And the drawing seat still learns what it got: `justDrawn` is the owner-only channel, and
|
||||
// `session.ts` sends it to that seat alone.
|
||||
assert.equal(g.justDrawn, drawn);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user