v0.7.9.5 — two answers to one question, and the copy nobody read

Both faults are in what 0.7.9.4 had just built, and both are the same
shape: a second copy of an answer that agreed with the first until it
didn't.

#96 — the §3.3 vote has no actor, and the screen named one anyway. The
vote is PARALLEL: every un-voted seat may vote at any moment, in any
order, one refusal ends it, and `apply.ts` says where it accepts one that
there is no actor to be. The turn chart named the last seat to move
before the timetable ran out — no more claim on the vote than anybody
else — directly above a tally correctly showing three seats outstanding.

The cause is worth more than the symptom. `currentActor(game)`
(`web/game.ts`) guarded on `status !== 'active'`; `currentActorOfState`
(`sim/view.ts`), added the same day in #95 and the one the frame calls,
did not, so it handed back whatever `clock.currentActor` was left
holding. The view now carries the guard and `currentActor` delegates to
it. That matters more than the tidiness: `currentActor` is what REFUSES
an intent, so a screen answering differently tells the table to wait on a
player the server would turn away.

The fourth of this class after Gitea#21, #22 and #94 — but the first
found by asking a view helper its question in a state the game is not
`active` in, which is the generalisation and is cheaper than finding the
fifth the same way.

#97 — narration reaches a seat once, by one path. `Frame.lines` carried
the whole log on every push to every seat, and nothing read it:
`RemoteSession` accumulates from `push.lines` alone and its `lines()`
returns that accumulator, so the log was serialised into every frame,
grew all game, and was discarded on arrival while `linesSince` sent the
same text correctly beside it.

The duplicate was masking a bug rather than merely wasting bandwidth.
`connect()` cleared `lastFrame` but not `sentLines`, so a reconnecting
seat was told "nothing new since your last push" while the browser it
answered had just reloaded from an EMPTY accumulator — the history panel
came back blank, mid-game, with the server holding the whole log. So the
two halves are one change, and the plan's instruction taken alone ("stop
passing the full game log into `frameFor()`") would have deleted a real
behaviour rather than a duplicate.

Every remaining reader of `Frame.lines` was checked before the field was
emptied: all of them are the solitaire and replay path, which builds
Frames through `snapshot()` directly and never goes near a session.

One test was wrong before the code was. The first draft of the reconnect
test connected inside its own fixture, so both sides of the comparison
were the empty array and it passed against the broken server. Each test
now asserts its premise is non-empty before comparing.

Also: `docs/plans/jitsi-common-board.md` is committed. It was never added
— not ignored, just missed — while TODO.md cites it twice as the plan for
all of v0.8.0 and the last two releases were built from it, so a clone
got a TODO pointing at a file that did not exist.

917 tests pass, up from 909.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5boPxP6JHRYMm8adXaF5R
This commit is contained in:
Jesse.Markowitz
2026-09-07 20:35:19 -04:00
co-authored by Claude Opus 5
parent ebd16983e2
commit d5445badcc
9 changed files with 1215 additions and 15 deletions
+68
View File
@@ -19,6 +19,74 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
---
## 0.7.9.5 — 2026-09-07
Two faults in what 0.7.9.4 had just built, both of the same shape: a second copy of an answer that
agreed with the first until it didn't.
### The §3.3 vote had no actor, and the screen named one anyway (#96)
Extended play's vote is **parallel**. Every un-voted seat may vote at any moment, in any order, and
one refusal ends it — `apply.ts` says in as many words where it accepts a vote that there is no actor
to be. So the honest answer to "whose turn is it" is nobody, and the honest answer to "who are we
waiting on" is every un-voted seat, which is exactly what the tally beside the turn chart already
drew.
The chart disagreed with the tally directly above it. It named the last seat to move before the
timetable ran out — a seat with no more claim on the vote than anybody else — while the tally
correctly showed three outstanding.
The cause is worth more than the symptom. `currentActor(game)` in `web/game.ts` guarded on
`status !== 'active'` and returned null. `currentActorOfState(state)` in `sim/view.ts` — added the
same day in #95, and the function the frame actually calls — had no such guard, so it handed back
whatever `clock.currentActor` was left holding after the game stopped being `active`. Two functions
answering one question, correct in every state anybody had looked at.
`currentActorOfState` carries the status guard now and `currentActor` delegates to it, so there is
one answer. That matters more than the tidiness: **`currentActor` is what refuses an intent**, so a
screen answering differently is telling the table to wait on a player the server would turn away.
This is the fourth of this class in a row after Gitea#21, #22 and #94. It is the first found by
asking a view helper its question in a state the game is **not** `active` in — which is the
generalisation, and cheaper than finding the fifth the same way.
### Narration reaches a seat once, by one path (#97)
`Frame.lines` carried the whole narration log on every push, to every seat, and nothing read it.
`RemoteSession` (`web/session.ts`) accumulates `lines` from `push.lines` alone, and its `lines()`
returns that accumulator — so the log was serialised into every frame, grew all game, and was
discarded on arrival, while `linesSince` sent the same text correctly beside it.
**The duplicate was masking a bug rather than merely wasting bandwidth.** `connect()` cleared
`lastFrame` but not `sentLines`, so a reconnecting seat was told "nothing new since your last push"
— while the browser it was answering had just reloaded and started from an empty accumulator. The
history panel came back blank, mid-game, with the server holding the whole log and shipping it in the
one field nobody reads.
So the two halves are one change, and the plan's instruction taken alone — "stop passing the full
game log into `frameFor()`" — would have deleted a real behaviour instead of a duplicate. A
(re)connect now resets the seat's watermark, and `Push.lines` on a connect **is** the history, which
is what lets the Frame stop carrying a second copy.
Every remaining reader of `Frame.lines` was checked before the field was emptied: all of them
(`sim/replay.ts`, `web/replays.ts`, and the sim and replay tests) are the solitaire and replay path,
which builds its Frames through `snapshot()` directly and never goes near a session.
**One test was wrong before the code was.** The first draft of the reconnect test connected inside
its own fixture, so both sides of the comparison were the empty array and it passed against the
broken server — two empty arrays are `deepEqual`. Each of these tests now asserts its premise is
non-empty before comparing.
### Also
`docs/plans/jitsi-common-board.md` is committed. It was never added — not ignored, just missed —
while `TODO.md` cites it twice as the plan for all of v0.8.0 and the last two releases were built
from it, so a clone got a TODO pointing at a file that did not exist.
917 tests pass, up from 909.
---
## 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