v0.7.9.4 — Gitea#20 step 1, and a Red Flag you can see

Step 1 of the common board done as its own release rather than as the
first hour of 0.8.0, since both halves of it are worth having whether or
not anything is ever published to a call.

#95 — the public projection helpers. `projectDistrict(state, seat)`,
`projectDivision(state)`, `projectSharedTable(state)`,
`publicSnapshot(state)` and `currentActorOfState(state)`, with
`snapshot()` REBUILT to compose from the same helpers rather than keeping
a second copy of the shared table, so a player's frame and a spectator's
cannot come to disagree about the clock, the phase, whose turn it is or
the score. Behaviour-neutral; the 897 existing tests passing unchanged is
the proof.

The public view is composed UPWARD, never by calling `snapshot()` once
per seat. That shortcut is the trap the plan names: `snapshot` assembles
one player's view, so a public view made of player views builds every
private field and then has to remember to strip it — and it defaults its
viewer to player zero, so a careless spectator call would have served
seat 0's hand. Districts are keyed by SEAT with the player resolved
through `playerAtSeat`, because Employee Rotation moves players between
districts and a board that treated seat and player index as
interchangeable would relabel every district the first time anybody
rotated.

One plan finding is struck off rather than fixed: it warns a display
reading `clock.currentActor` could highlight the wrong district during a
decision. Measured over six seeds and 3,600 decision points, that field
and `actingPlayer` never disagreed. `currentActorOfState` exists anyway,
as one place for the next reader to ask.

#91 — the redaction net, systematically. v0.7.9.2's two leaks were found
by reading a plan, not by a test, which is the whole argument for this: a
suite made of the leaks somebody happened to notice proves nothing about
the next one. Serialise a seat's Frame, the PublicFrame a spectator gets
and the narration they receive, then search all three for every opponent
card id, every card name unique to one opponent's hand, the seed and any
private decision or menu data — across a fresh game, a blind draw,
mid-game, a pending decision, Employee Rotation before and after the
seating moves, a reconnect push (a full Frame, and its own opportunity to
leak) and a played-out game. And the allow-list, which is the plan's
stated acceptance bar rather than the tests: every property of
`publicSnapshot` is written down with its reason and compared on every
run, so adding a field fails the suite until somebody has said out loud
that a spectator may see it. Both v0.7.9.2 leaks were fields nobody had
ever asked that question about.

Proved by mutation rather than by passing: restoring the seed line fails
6 tests, restoring the blind-draw card name fails 1, adding a private
field to the public projection fails 7, and making `players[]` carry hand
contents instead of a count fails 5.

Two false failures were worth the lesson. A card NAME is a type, not an
identity — "right-hand curve" names a dozen cards and one is legitimately
a cell label the moment anybody lays track, so searching for it fails on
correct code, which is worse than not searching; a name is evidence only
when every card bearing it is in the one hand. And a one-digit seed makes
the seed check meaningless: seed 7 matched "Train 7". One item on the
plan's list has no test because it has no referent — there is no secret
objective in this game, `objectiveOf` deriving from
`config.minCombinedRevenue` and the player's own Revenue, both public.

#94 — a Red Flag standing at an Office's Limits is on the map. It is a
token set out ON the board that holds the next train arriving from that
side, and it was announced once in the log and drawn nowhere, so a train
stops short three Stages later with its only explanation scrolled out of
the panel. `DivisionView`'s office node carries `redFlag` and the map
draws a staff and pennant AT THE END IT GUARDS — west on the left, east
on the right — because which approach it covers is the whole of the
information; a flag in the middle of the cell would say one is out and
leave the reader to hover for the half that decides whether to run a
train. The tooltip leads with it, ahead of everything that merely
describes the cell.

The third of these in a row after Gitea#21 and #22: when the engine gains
something that changes what a train may do, the question to ask is where
it is drawn, not whether it works.

909 tests pass, up from 897.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ss2y7FyhxkHjGj7xnUPCgY
This commit is contained in:
Jesse.Markowitz
2026-09-07 15:00:57 -04:00
co-authored by Claude Opus 5
parent e734481d65
commit ebd16983e2
7 changed files with 720 additions and 160 deletions
+60 -89
View File
@@ -75,9 +75,9 @@ Not items. Things that are true of every change, and that have gone wrong when s
## Sections
1. **Play it at a table** — #39 #35 #42a #40
2. **The common board, and watching play happen — Gitea#20** — #13 #15 #18 #91 #75
2. **The common board, and watching play happen — Gitea#20** — #13 #15 #18 #75
3. **Multiplayer, sessions and operations** — #8 #7 #76 #77 #79
4. **The screen** — #94 #44 #81 #33 #36
4. **The screen** — #44 #81 #33 #36
5. **Replays and saved games** — #14 #47 #48 #49 #50 #51 #52
6. **Rules** — #12 #80 #82 #83 #85
7. **Play balance** — #61 #62 #63 #64 #67 #68 #69 #70 #71 #72 #73 #66 #65 #74
@@ -149,17 +149,6 @@ cheaper.
it needs the async stepped pump that Gitea#20 step 4 specifies**, which is why it lives here
rather than under The screen. See **Reference · #18**.
- [ ] **#91** — **Narration is still outside the redaction net, and the two known leaks in it are
fixed but the net is not.** `game.log` is one shared list that `linesSince` slices with no
per-seat filter, so anything written into it reaches every player. The seed and the blind-draw
card name were closed in v0.7.9.2; what has NOT been done is the systematic check the plan
asks for — serialise the log alongside the Frame and search it for every opponent's card ids
AND display names, objective names, `justDrawn` for the wrong seat, and private decision data,
across a fresh game, a pending decision, the Superintendent acting, Employee Rotation, a
reconnect and a finished game. **This is Gitea#20 step 1's starting point and its acceptance
bar** — the plan is explicit that passing redaction tests alone is insufficient and that every
public property needs an allow-list review. See **Reference · #91**.
- [ ] **#75** — Let the game join a call and talk to the table — the chat, audio and nudge half of the
idea Gitea#20 took the visual half of. Long-term. See **Reference · #75**.
@@ -195,12 +184,6 @@ happening, and the turn structure that is still solitaire-shaped.
What is drawn and where, for a player at the board. The three items settled on 2026-08-30 shipped in
v0.7.9; what is left is the history panel and the end-of-game statistics.
- [ ] **#94** — **A Red Flag standing at an Office's Limits is drawn nowhere.** It is set out on the
board, it holds the next train from that side, and after the one log line announcing it there is
nothing on screen saying it is there. Confirmed in code rather than inferred. **The same class
as Gitea#21 and #22 — the engine is right and the screen is silent — and it is already on
Gitea#20 step 1's list, so doing it now is 0.8.0 groundwork.** See **Reference · #94**.
- [ ] **#44** — How much history the panel holds should be configurable. The cap is `slice(-60)` with
no recorded reason anywhere. **Where the setting lives is an open question and the item exists
to ask it** — a StartOS action, per game, or per browser. The replay viewer already answers the
@@ -603,42 +586,6 @@ of enforced waiting per Stage, forty-eight per Day, and a player who has seen it
will want it off. Whatever this becomes probably needs a speed control, or to scale with whether
anything actually happened in the phase.
#### #91 — NARRATION IS OUTSIDE THE REDACTION NET.
**NARRATION IS OUTSIDE THE REDACTION NET.** `test/redaction.test.ts` serialises a seat's whole
`Frame` and asserts no other seat's card ids or deck order appear in it — and every one of those
tests passes `[]` for the log. So the shared narration has never been checked at all, while
`game.log` is ONE list and `linesSince(seat)` (`server/session.ts:181`) slices it with no per-seat
filter whatsoever. Every line written there reaches every player.
**Two leaks found and closed in v0.7.9.2**, both discovered while planning Gitea#20 and both live in
multiplayer with or without that display: the **seed**, announced in the opening line of every
multiplayer game, and the **name of a card drawn blind** from the Home Office deck. The tests for
them are in `redaction.test.ts` now, so narration is no longer entirely unchecked — but two specific
strings are not a net.
**Solitaire deliberately keeps both**, and that is the rule to apply to anything found next: a
one-seat table has nobody to leak to, the seed in the log is what a bug report quotes, and a solo
player's own history naming their own draw is the record. The rule is "do not tell the OTHER seats",
not "write less down".
**What is still owed** is the plan's own list (`docs/plans/jitsi-common-board.md`, Step 1 § Tests):
serialise the public frame *and* the player pushes and search for every opponent hand-card id **and
display name**, objective ids and names, `justDrawn` for the wrong player, seed values and seed
narration, and private decision/menu data — across a newly created game, a blind draw, a pending
decision, the Superintendent acting, Employee Rotation before and after ownership changes, a
reconnect push, and a finished game. **And the plan's acceptance bar is not the tests**: it requires
an allow-list review of every public property, on the grounds that passing redaction tests alone is
insufficient. That is the right bar — the two leaks above would have passed any test nobody thought
to write.
**Supersedes #78**, which said the exhaustive Frame check was "still missing… held for now,
2026-08-20". It is not missing: `test/redaction.test.ts` exists and does exactly what #78 described
— serialise a seat's Frame, assert no other seat's card ids and no deck order. #78 was written
before that file and was never revisited, so it read as live work for two weeks after it was done.
**The gap that is actually real is the log, which #78 never mentioned.**
#### #75 — Let the game join a call and talk to the table.
**Let the game join a call and talk to the table.** Long-term. If the game could join a Zoom,
@@ -802,39 +749,6 @@ and whether they took it the moment their turn arrived.
### The screen
#### #94 — A RED FLAG STANDING AT THE LIMITS IS DRAWN NOWHERE.
**A RED FLAG STANDING AT AN OFFICE'S LIMITS IS DRAWN NOWHERE.** Found 2026-09-07 while checking
which of the Gitea#20 step 1 findings were still real. It is real, and it is a play bug now rather
than a common-board one.
**What the engine does**, traced rather than assumed. `maneuver.redFlags` emits `redFlagsSet`, whose
reducer sets `node.redFlag = side` on that Office's Division node (`apply.ts:2307`). From then on
`redFlagStop` holds the next train arriving from that side — `dest.redFlag === from` → `spendFlag`,
which removes the flag and emits `redFlagSpent` (`advance.ts:1216, 1173`). So it is a standing token
on the board that stops a train, exactly as a flag on the table would be.
**What the screen does.** `narrate` announces it once — "RED FLAGS set out on the Eastern Limits" —
and then it is gone with the scroll. The Office node in `DivisionView` carries `kind`, `label`,
`trains`, `capacity`, `modifiers`, `gradeUp`, `seat`, `running` and `switching`, and **no `redFlag`
field at all**; `redFlag` appears nowhere in `board-svg.ts` and nowhere in the web layer. The map
draws the Office, its A/D tracks and the trains standing on it, and not the flag at its Limits.
**So a player who set a flag out three Stages ago has nothing telling them it is still there, and an
opponent who missed the line never knew.** Then a train stops short, and the only explanation is a
log entry that has scrolled away. That is the shape of Gitea#21 — a correct refusal with no visible
reason — and of Gitea#22 — a board that does not show what the rules are acting on.
**Why it belongs before 0.8.0 rather than in it:** the plan already lists "add the Red Flag holder to
the public player projection — it is public game state but is currently absent from `Frame`" as part
of step 1. The field has to exist on the projection either way, so every hour spent on it is 0.8.0
groundwork rather than a detour. **The drawing is the open question, not the data** — a flag at the
Limits column is the obvious rendering, and `board-svg.ts` already draws those edges (`edge()`), so
there is somewhere to hang it.
**Not fixed. Sized: small on the data, a judgement call on the picture.**
#### #44 — The history panel's 60-line cap is hard-coded, and how much history it…
**The history panel's 60-line cap is hard-coded, and how much history it holds should be
@@ -1816,11 +1730,68 @@ where it belongs, and it is still open.
Closed items, kept because several are the only record of a ruling or a lesson. Newest first within
each group.
### Shipped through v0.7.9.3, from the queue
### Shipped through v0.7.9.4, from the queue
Closed items, newest first. Kept because several of them are the only record of a ruling or a lesson;
the numbers stay so cross-references above and below still resolve.
91. ~~**Narration was outside the redaction net, and so was everything else nobody had listed.**~~ —
done 2026-09-07 in v0.7.9.4. The systematic pass Gitea#20 step 1 asks for: serialise a seat's
Frame, the public board and the narration it receives, and search all three for every opponent
card id, every card NAME that is unique to one opponent's hand, the seed, and any private
decision or menu data — across a fresh game, a blind draw, mid-game, a pending decision,
Employee Rotation before and after the seating moves, a reconnect push and a played-out game.
**And the allow-list, which is the plan's actual acceptance bar:** every property of
`publicSnapshot` is written down and compared, so adding a field fails the suite until somebody
has said out loud that a spectator may see it. That is the check that would have caught both
v0.7.9.2 leaks, since both were fields nobody had asked the question about.
**Worth knowing, and it cost two false failures to learn: a card NAME is a type, not an
identity.** "right-hand curve" names a dozen cards and one is legitimately drawn on the board as
a cell label the moment anybody lays track, so searching for it fails on correct code. A name
counts as evidence only when EVERY card bearing it is in the one hand. Ids need no such care.
**And a one-digit seed makes the seed check meaningless** — seed 7 matched "Train 7". The seeds
in this file are nine digits deliberately.
Proved by mutation rather than by passing: restoring the seed line fails 6 tests, restoring the
blind-draw name fails 1, adding a private field to the public projection fails 7, and making
`players[]` carry hand contents instead of a count fails 5.
**One item on the plan's list has no test because it has no referent:** there is no secret
objective in this game. `objectiveOf` derives from `config.minCombinedRevenue` and the player's
own Revenue, both public. Said here so the next reader does not go looking for the gap.
94. ~~**A Red Flag standing at an Office's Limits was drawn nowhere.**~~ — done 2026-09-07 in
v0.7.9.4. It is a token set out ON the board that holds the next train arriving from that side;
it was announced once in the log and then existed only in the engine, so a train would stop
short with its only explanation scrolled out of the panel. `DivisionView`'s office node carries
`redFlag` now and the map draws a staff and pennant **at the end it guards** — west on the left,
east on the right — because which approach it covers is the whole of the information; a flag in
the middle would say one is out and leave the reader to hover for the half that decides whether
to run a train. **Worth knowing:** the same class as Gitea#21 and #22, and the third in a row —
when the engine gains a thing that CHANGES what a train may do, ask where it is drawn before
asking whether it works.
95. ~~**The public projection helpers — Gitea#20 step 1's foundation.**~~ — done 2026-09-07 in
v0.7.9.4. `projectDistrict(state, seat)`, `projectDivision(state)`, `projectSharedTable(state)`,
`publicSnapshot(state)` and `currentActorOfState(state)`, with `snapshot()` **rebuilt to compose
from the same helpers** rather than keeping its own copy — so a player's frame and a spectator's
cannot come to disagree about the clock, the phase or the score. Behaviour-neutral, and the
existing 897 tests are the proof of that.
**Districts are keyed by SEAT and the player resolved through `playerAtSeat`**, because Employee
Rotation moves players between districts; a public board that assumed seat and player index were
interchangeable would relabel every district the first time anybody rotated. **And the public
view is composed upward, never by calling `snapshot()` once per seat** — that shortcut builds
every private field and then has to remember to strip it, and `snapshot` defaults its viewer to
player zero, so a careless spectator call would have served seat 0's hand.
**One plan finding struck off rather than fixed:** it warns that a public display reading
`clock.currentActor` could highlight the wrong district during a decision. Measured across six
seeds and 3,600 decision points, that field and `actingPlayer` never disagreed — both are only
consulted when somebody is genuinely acting. `currentActorOfState` exists anyway, as one place
for the next reader to ask.
32. ~~**Tell the 0.4.9 playtesters their saves are dead, before they find out.**~~ — done
2026-09-07. `PLAYTEST-0.7.4.md` was written for exactly this and did its job; Jesse, 2026-09-07:
"a temporary document to help some of the playtesters out on making the big jump, but that is no