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:
Jesse.Markowitz
2026-09-07 12:09:25 -04:00
co-authored by Claude Opus 5
parent 7ade60e21f
commit 819996faa2
10 changed files with 717 additions and 41 deletions
+5 -2
View File
@@ -481,8 +481,11 @@ export const TIMETABLED_TRAINS: readonly TrainProfile[] = [
* COACH COUNTS ON 1/2 AND 5/6 WERE SWAPPED BY JESSE (Gitea#7, v0.4.9e playtest): the Crack Limited
* drops from three coaches to two, and The Sparrow rises from two to three. A change to the card
* faces themselves, not a transcription fix — `Trains3.pdf` and the tables that transcribe it
* still print the old numbers, so `docs/rules/card-reference.md` is the place that now carries
* what the cards say.
* still print the old numbers, so `docs/rules/as-built.md` is the place that now carries what
* the cards say — GENERATED from the constants below by `scripts/build-card-reference.ts`, with
* `test/card-reference.test.ts` failing if the two disagree. This comment used to name
* `card-reference.md`, which describes the v0.4.5 deck and carries a banner saying not to use its
* numbers; the code sent readers to a table it had itself superseded.
*/
...pair(1, 'Crack Limited', 'fast', { freight: 0, coach: 2, caboose: 0 },
{ terminalsOnly: true, noSwitching: true, expedite: true, note: 'Stop at Terminals only.' }),
+34 -2
View File
@@ -333,7 +333,21 @@ export function newMultiplayerGame(seed: number, config: GameConfig, playerNames
const state = createGame({ id: `mp-${seed}`, seed, config, playerNames });
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null, justDrawn: null, announced: null };
game.log.push({ text: 'Game Begins', tone: 'start' });
game.log.push({ text: `${config.mode} · ${playerNames.length} players · seed ${seed}`, tone: 'quiet' });
/**
* NO SEED AT A TABLE WITH MORE THAN ONE SEAT (Gitea#20 step 1).
*
* `game.log` is one shared list and `linesSince(seat)` (`server/session.ts`) slices it with no
* per-seat filter, so every line here reaches every player. Announcing the seed therefore handed
* each of them the whole future of the deal — every card order, every die — in the opening line
* of the game. Found while planning the public common board; it is a multiplayer leak with or
* without that display, which is why it is fixed here rather than waiting for it.
*
* `newGame` still records it, deliberately: a solitaire table has nobody to leak to, and the seed
* in the log is what a bug report quotes. The rule is "do not tell the OTHER seats", not "write
* less down". The seed remains in `game.seed`, in every save (`session.ts` persistence) and in the
* lobby record, so nothing administrative or replayable loses it.
*/
game.log.push({ text: `${config.mode} · ${playerNames.length} players`, tone: 'quiet' });
drain(game);
return game;
}
@@ -1149,10 +1163,28 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
cardName: (id) => cardName(game.state, id),
trainName: (id) => trainName(game.state, id),
});
/**
* A BLIND DRAW IS PUBLIC; WHICH CARD CAME UP IS NOT (Gitea#20 step 1).
*
* Everybody at the table sees a hand go to the Home Office deck, so the draw itself belongs in
* the shared log. The card's NAME does not: the deck is face down, and this log goes to every
* seat unfiltered, so naming it told three opponents exactly what the fourth was holding.
*
* A DEPARTMENT SLOT IS NOT THE SAME and stays named. Those piles are face up — a discard goes
* onto one precisely so a rival can take it — so the card was public before it was drawn, and
* hiding it would lose real information for no gain.
*
* The drawing seat still learns what it got. `justDrawn` below is the owner-only channel and
* `session.ts` sends it to that seat alone, so this costs the drawer nothing. Solitaire keeps
* the name for the same reason it keeps the seed: a one-seat table has nobody to leak to, and
* a solo player's history naming their own draw is the record rather than a leak.
*/
const blindDraw = e.type === 'cardDrawn' && e.source === 'homeOffice' && game.state.players.length > 1;
const said = blindDraw ? 'Drew a card from the Home Office deck' : n.text;
// "Chose to DRAW a card" does not say WHO, which is unreadable the moment there is more than
// one seat. Only events the player caused are attributed; the Division running itself is not.
const mine = who !== null && 'player' in e;
const text = mine ? `Player ${who} ${uncapitalise(n.text)}` : n.text;
const text = mine ? `Player ${who} ${uncapitalise(said)}` : said;
game.log.push({ text, tone: mine ? 'act' : n.tone });
}