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
+82
View File
@@ -19,6 +19,88 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
---
## 0.7.9.4 — 2026-09-07
Gitea#20 step 1, done as its own release rather than as the first hour of 0.8.0 — and a Red Flag you
can now see.
### A Red Flag standing at the Limits is on the map (#94)
`maneuver.redFlags` sets a flag on an Office's Division node, and from then on the next train
arriving from that side is held short until the flag is spent. It is a token standing on the board —
the same kind of object as a train — and it was announced once in the log and drawn nowhere. Three
Stages later a train stops and the only explanation has 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, since east is right on this map. Which
approach it covers is the whole of the information: a flag in the middle of the cell would say a
flag 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.
### The public projection helpers (#95)
`projectDistrict(state, seat)`, `projectDivision(state)`, `projectSharedTable(state)`,
`publicSnapshot(state)` and `currentActorOfState(state)` — and **`snapshot()` rebuilt to compose
from the same helpers** rather than keeping a second copy of the shared table. A player's frame and
a spectator's now cannot come to disagree about the clock, the phase, whose turn it is or the score.
Behaviour-neutral: the existing 897 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 warns about: `snapshot` exists to assemble one player's view, so a public view
made of player views starts by building every private field and then has to remember to strip it —
and it defaults its viewer to player zero, so a careless spectator call today would have served seat
0's hand. Composing upward means a private field cannot arrive by accident; it would have to be
added to a projection that has no business holding one.
**Districts are keyed by seat, with the player resolved through `playerAtSeat`.** Employee Rotation
moves players between districts, so seat and player index are not interchangeable — a board that
assumed they were would relabel every district the first time anybody rotated.
One finding from the plan is struck off rather than fixed: it warns that a display reading
`clock.currentActor` could highlight the wrong district during a decision, since that field is null
while an interruption stands. Measured across six seeds and 3,600 decision points, it 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.
### The redaction net, systematically (#91)
v0.7.9.2 closed two leaks. Both were found by reading a plan rather than 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, not
a delta, 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 the reason it is public 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.
**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 drawn as a cell label the moment anybody lays
track, so searching for it fails on correct code — which is worse than not searching. A name counts
as 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" and reported a leak that was not one. The seeds
here are nine digits deliberately.
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.
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. Recorded so the next reader does not go looking for the gap.
909 tests pass, up from 897.
---
## 0.7.9.3 — 2026-09-07
Documentation and the build script behind it. No engine change; 897 tests pass, unchanged.