diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs
index 4e3de65..99f0f94 100644
--- a/crates/cb-render-html/src/doc.rs
+++ b/crates/cb-render-html/src/doc.rs
@@ -255,6 +255,8 @@ h1,h2{font-size:1rem;margin:1.2rem 0 .4rem;color:#9cf}
styling alone would leave a dead control that still looks alive to
anything reading the DOM. */
.sealed{opacity:.3;cursor:default;pointer-events:none;filter:grayscale(1)}
+/* CB-WP-0024 T03: the card a seat played, in that seat's area. */
+.played{display:block;margin:.3rem 0}
.k{color:#89a}
.nil{color:#c88}
.eff{color:#8c9}
@@ -358,8 +360,8 @@ fn pile_svg(out: &mut String, x: i32, label: &str, count: usize, face: &str, not
);
}
for d in (0..depth).rev() {
- let dx = x + (d as i32) * 3;
- let dy = 14 - (d as i32) * 3;
+ let dx = x + d * 3;
+ let dy = 14 - d * 3;
let _ = write!(
out,
" String {
out
}
+/// The card a seat has played, as a card.
+///
+/// **The face-down back is a constant.** It takes no argument, because
+/// `SelectionView::Hidden` carries nothing and this function must not be
+/// able to leak what it does not receive. GR-R02/R04 hide another seat's
+/// choice until Reveal, and `view.rs`'s own test asserts the projection
+/// obeys that — but a renderer can leak what the model did not, by
+/// tinting the back with the suit or shaping it by the action. So there is
+/// exactly one back, with no data path into it.
+const CARD_BACK: &str = "";
+
+fn played_svg(out: &mut String, sel: &SelectionView) {
+ let s = match sel {
+ SelectionView::Hidden => {
+ out.push_str(CARD_BACK);
+ return;
+ }
+ SelectionView::Shown(s) => s,
+ };
+ let mut sub = String::new();
+ if let Some(t) = s.target {
+ let _ = write!(sub, "\u{2192}{}", seat_name(t));
+ }
+ if let Some(n) = s.problem {
+ if !sub.is_empty() {
+ sub.push(' ');
+ }
+ let _ = write!(sub, "#{n}");
+ }
+ let _ = write!(
+ out,
+ "",
+ label = esc(&format!("{:?}", s.action)),
+ sub = esc(&sub),
+ );
+}
+
fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView) {
let is_viewer = view.viewer == Some(id);
let _ = write!(
@@ -582,6 +629,10 @@ fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView
}
}
if let Some(sel) = view.selections.get(&id) {
+ // The picture, then the words. CB-WP-0024 T03 adds the card; the
+ // sentence stays, because the log is the record and a player
+ // reading back through it needs the same vocabulary.
+ played_svg(out, sel);
let _ = write!(
out,
"selected {} ",
@@ -929,7 +980,13 @@ fn log_section(s: &mut String, log: &[LogLine]) {
/// **This page does not auto-reload.** The old one reloaded on `ok` and
/// the reload was refused, which is how a completed game became a blank
/// tab.
-pub fn ending(view: Option<&GroundView>, message: &str, endpoint: &str, log: &[LogLine]) -> String {
+pub fn ending(
+ view: Option<&GroundView>,
+ message: &str,
+ endpoint: &str,
+ log: &[LogLine],
+ series: &[String],
+) -> String {
let mut s = String::with_capacity(4096);
let _ = write!(
s,
@@ -948,6 +1005,19 @@ pub fn ending(view: Option<&GroundView>, message: &str, endpoint: &str, log: &[L
);
}
}
+ // CB-WP-0024 T04. Above the log, because it is a result and the log is
+ // the account. Empty for a first game — one game is not a series, and
+ // a "cumulative" panel restating the outcome above it is noise.
+ if !series.is_empty() {
+ s.push_str("
this session
");
+ for (i, line) in series.iter().enumerate() {
+ if i > 0 {
+ s.push_str(" ");
+ }
+ s.push_str(&esc(line));
+ }
+ s.push_str("
");
+ }
log_section(&mut s, log);
let _ = write!(
s,
diff --git a/crates/cb-render-html/src/lib.rs b/crates/cb-render-html/src/lib.rs
index 7dd0f36..7409f5b 100644
--- a/crates/cb-render-html/src/lib.rs
+++ b/crates/cb-render-html/src/lib.rs
@@ -706,6 +706,106 @@ mod piles {
}
}
+/// CB-WP-0024 T03 — what the other seats played, drawn as cards.
+///
+/// The maintainer could follow the other players only by reading the log.
+/// The data was already projected and already rendered — as sentences.
+/// This adds the picture without touching the hiding rule, which is the
+/// only part that could do harm.
+#[cfg(test)]
+mod played_cards {
+ use cb_kernel::PlayerId;
+ use games_ground::view::{GroundView, SelectionView};
+ use games_ground::{Action, Selection};
+
+ use crate::doc::document;
+
+ fn html(v: &GroundView) -> String {
+ document(v, &[], "/command?t=x", Some(PlayerId(0)), false)
+ }
+
+ /// A seat's revealed play is drawn, not only written.
+ #[test]
+ fn a_revealed_play_is_drawn_as_a_card() {
+ let mut v = crate::testfix::view(Some(PlayerId(0)));
+ v.selections.insert(
+ PlayerId(1),
+ SelectionView::Shown(Selection {
+ action: Action::Solve,
+ target: None,
+ problem: Some(3),
+ }),
+ );
+ let doc = html(&v);
+ assert!(
+ doc.contains("played Solve"),
+ "the revealed play was not drawn as a card"
+ );
+ assert!(
+ crate::text_of(&doc).contains("selected Solve"),
+ "the sentence must survive alongside the picture — the log is the record"
+ );
+ }
+
+ /// **The leak test.** Nothing in the emitted document may vary with
+ /// another seat's hidden selection.
+ ///
+ /// The shape is `view.rs`'s own
+ /// `a_seat_never_sees_another_seats_face_down_selection`: assert the
+ /// absence, then assert the same view AFTER reveal shows it —
+ /// otherwise the first assertion passes for a renderer that draws
+ /// nothing at all.
+ #[test]
+ fn a_hidden_play_renders_identically_whatever_it_is() {
+ let render_hidden = |action, problem| {
+ let mut v = crate::testfix::view(Some(PlayerId(0)));
+ // The seat HAS chosen; the viewer may not see what.
+ v.selections.insert(PlayerId(1), SelectionView::Hidden);
+ // A different real choice underneath, which must not reach us.
+ v.selections.insert(
+ PlayerId(2),
+ SelectionView::Shown(Selection {
+ action,
+ target: None,
+ problem,
+ }),
+ );
+ html(&v)
+ };
+
+ // Two different hidden situations must produce the same markup for
+ // the hidden seat. Compare the card backs directly.
+ let a = render_hidden(Action::Solve, Some(1));
+ let b = render_hidden(Action::Attack, Some(9));
+ let back = |h: &str| {
+ let i = h
+ .find("face down")
+ .expect("a face-down card");
+ h[i.saturating_sub(200)..i + 200].to_string()
+ };
+ assert_eq!(
+ back(&a),
+ back(&b),
+ "the face-down card differed between two games — it varies with something"
+ );
+
+ // The other half: revealed, the same renderer DOES show it.
+ let mut shown = crate::testfix::view(Some(PlayerId(0)));
+ shown.selections.insert(
+ PlayerId(1),
+ SelectionView::Shown(Selection {
+ action: Action::Attack,
+ target: Some(PlayerId(2)),
+ problem: None,
+ }),
+ );
+ assert!(
+ html(&shown).contains("played Attack"),
+ "the assertion above would pass for a renderer that draws nothing"
+ );
+ }
+}
+
/// CB-WP-0024 T01 — the ending page's one control.
///
/// The maintainer reported it as *"the button says 'I need to read this'
@@ -718,7 +818,7 @@ mod ending_page {
use crate::{doc, jsrun};
fn page() -> String {
- doc::ending(None, "the game ended", "/command?t=x", &[])
+ doc::ending(None, "the game ended", "/command?t=x", &[], &[])
}
/// The label must say what the control DOES. Asserted on the rendered
@@ -772,8 +872,8 @@ mod ending_page {
#[test]
fn a_dealing_reply_leaves_the_controls_alone() {
let html = page();
- let (live, _) =
- jsrun::gesture_with_reply(&html, "again", "again", "ok: dealing").expect("run the page");
+ let (live, _) = jsrun::gesture_with_reply(&html, "again", "again", "ok: dealing")
+ .expect("run the page");
assert!(
live.contains(&"again".to_string()) && live.contains(&"done".to_string()),
"an 'ok' reply must not seal the page: {live:?}"
diff --git a/evidence/CB-EV-0023-the-table-you-can-watch.md b/evidence/CB-EV-0023-the-table-you-can-watch.md
new file mode 100644
index 0000000..3e59f14
--- /dev/null
+++ b/evidence/CB-EV-0023-the-table-you-can-watch.md
@@ -0,0 +1,173 @@
+# CB-EV-0023 — the table you can watch
+
+CB-WP-0024 T05. Tier S (structural S — renders state the projection
+already carries; chaos d8=6 → no override). Declaration 7 of chaos window
+2. Closed 2026-08-05.
+
+**Delivered:** the ending control says what it does and the page acts on
+it; draw and discard drawn as stacks; each seat's play drawn as a card;
+a session tally across games. Five remarks from play, four addressed
+(remarks 2 and 3 are CB-WP-0025).
+
+---
+
+## 1. How many of the five remarks were already implemented
+
+**Three of five were data the projection already carried, rendered as
+text.** That is the finding this pass is really about, and it was
+established by reading the code before writing the workplan rather than
+by building anything.
+
+| remark | what was actually missing |
+|---|---|
+| follow the other players | nothing in the model — `selections` project as `Shown` after Reveal and were already drawn, **as sentences** |
+| draw and discard stacks | nothing in the model — `solution_deck_len` and `solution_discard` were one line of text |
+| show the scores | nothing at all — `personal`, `mastery`, `winners`, `total`/`threshold` were already drawn |
+| the ending button | a real defect, two of them |
+| a cumulative score | genuinely absent |
+
+**So the table's problem was legibility, not content.** A renderer can
+show every field it is given and still be unreadable, and the coverage
+gate — which asserts every view field appears in the parsed document —
+passes either way. It is a completeness gate, and completeness was never
+the issue.
+
+**That is worth naming as a gap in the controls**, not as a criticism of
+them. `every_view_field_is_classified_in_the_emitted_document` proves
+nothing is silently omitted. Nothing proves it is *readable*, and nothing
+could, cheaply. The maintainer playing the game is the instrument, which
+is what makes remarks like these worth more than their length suggests.
+
+## 2. The defect that mattered was untestable, and that is why it survived
+
+The ending control was labelled *"close — I have read this"* while
+`hotseat.rs` read it as **stop the server**, and acknowledging it changed
+nothing: the tab kept a full table and a `play again` pointing at a closed
+port.
+
+The label was a wording bug. **The second half survived for a structural
+reason:**
+
+> `jsrun`'s `fetch` stub returned `{then: function () { return this; }}`,
+> which never invoked its callbacks. **Every line of the script that
+> reacts to what the server said was unreachable from every test in this
+> project.**
+
+The `ok` → reload branch, the status text, and the branch that did not yet
+exist were all equally unexercised. A page that ignores the server was
+indistinguishable, under test, from one that acts on it.
+
+CB-WP-0014 embedded a real JS engine specifically so the script could be
+executed rather than string-matched, and CB-WP-0016 found that *a stub too
+thin to express a failure is how the failure survives*. This is the same
+finding one layer deeper: the stub was thin at the **reply**, and the
+reply is where the session's ending lives.
+
+The stub now delivers a real then-chain and `gesture_with_reply` reports
+which controls survive. The seal is mutation-proven — deleting the branch
+turns exactly one test red — and a negative control asserts `ok: dealing`
+does **not** seal, because a seal that fired on every reply would pass the
+first test and silently break `play again`.
+
+### One browser-versus-stub trap, avoided by writing it down
+
+The first seal used `setAttribute('data-drop', null)`. In the stub that
+stores a real `null` and `getAttribute` returns `null`; **in a browser it
+writes the literal string `"null"`, which is truthy**, so the control would
+have stayed live while its own test called it sealed. `removeAttribute`
+is correct in both. The stub gained the method rather than the script
+gaining a workaround.
+
+## 3. The reshuffle question: already ruled, already implemented
+
+T02 required settling whether the deck-exhaustion shuffle happens before
+drawing it, and to raise a finding if it did not.
+
+**It does.** `games/ground/src/lib.rs:1419-1435` (`draw_solution`):
+deterministic reshuffle of the discard, seeded `seed ^ round`, carried in
+a `DeckReshuffled` event so replay never re-derives it; if both are empty
+the draw is skipped. That is the **U4 default**, which ground-game
+**confirmed on 2026-08-03** — a ruled rule.
+
+So nothing was raised, which is the correct outcome and the one the task
+was written to allow. The piles show the state in which the *next* draw
+triggers it, because that is derivable from the view; a claim that a
+reshuffle *has* happened is not, and is not made. The event already reads
+out in the log.
+
+**Two days earlier this question would have been raised as a finding.**
+CB-WP-0026 applied the U4 ruling on 2026-08-05; this pass consumed it the
+same day. The register paying off inside 24 hours is not proof it works,
+but it is the first time the answer to *"is this underdetermined?"* was
+one lookup instead of a message.
+
+## 4. What the controls caught
+
+**The coverage gate caught its own probe going stale.** Replacing the
+`17 remaining` text broke `solution_deck_len`'s classification — the gate
+does not care *how* a field is rendered, only that its token appears, so
+changing the rendering changed the token. Fixed by putting the count in
+the pile's ``: a stable probe, and what a screen reader announces.
+The on-canvas numeral would have been a weak probe, since a bare `17`
+could be anything on the page.
+
+**The compiler caught a misplaced doc comment** that had silently taken
+`Summary`'s `#[derive(Debug)]` with it.
+
+Neither is interesting alone. Together they are the ordinary case for
+this project: the gates fire on rendering changes, and the cost of that is
+small enough that nobody is tempted to loosen them.
+
+## 5. The cumulative-score question, and why it stayed a note
+
+The task required deciding what "cumulative" means **before** summing
+anything. The answer is that **GROUND defines one game and no series at
+all.**
+
+`personal` sums per seat; `winners` counts games won; `group_success`
+counts games the table cleared. They answer different questions, and a
+test asserts they can point at different seats — a 9/0/0 versus 0/1/1
+series leads on summed score for one seat and on games won for the other.
+
+**Both are shown and both are named.** Picking one would have made it *the*
+score by default, which is canonising a rule nobody wrote.
+
+Registered **F15, `underdetermined`, state `note`**. It stays a note on
+purpose: the test demonstrates the two tallies *can differ*, which is
+arithmetic — not evidence that the ambiguity harms play. Under GameDesign
+§3.1 a note may not be reported to ground-game until an artifact exists.
+
+**This is the note tier doing exactly what ADR-0012 D6 built it for**, on
+the first genuinely new question since it was written. The tier was
+argued as the way to admit findings from *play* that the engine cannot
+produce; its first real use is a question from *building*, which the ADR
+did not anticipate and which the tier handles unchanged.
+
+## 6. Chaos window 2
+
+**Declaration 7 of 12.** Structural S, d8 = 6, no override.
+
+Ten declarations in, **no 8 has been rolled**. At d8 the expected count
+over twelve is 1.5, so this is unremarkable — but it means window 2 will
+likely close with **zero overrides to evaluate**, and its retirement
+condition (*retire if an override changes nothing twice running*) will be
+untestable. Window 1 closed with 2 overrides at d4 and both changed the
+outcome. **The rate cut to d8 may have made the mechanism unevaluable**,
+which is a real cost that CB-EV-0015's decision did not price, and the
+window's closing evidence should say so.
+
+## 7. Cost
+
+CB-WP-0023's cost, by re-running the instrument — the pass this one is
+required to quote. Reported by `make cost`; **not inlined here as a
+literal** (§Single source of fact), and see CB-EV-0019 §4 on the chain
+breaking beyond ~4 passes.
+
+## Open after this pass
+
+- **Remarks 2 and 3** — *how could we have won* and *difficulty* — are
+ CB-WP-0025, declared and ready, tier L.
+- **F15** needs an artifact before the series question can go to
+ ground-game.
+- **Legibility has no gate** (§1) and probably should not get one; the
+ honest control is a person playing it.
diff --git a/specs/FindingRegister.md b/specs/FindingRegister.md
index d91a9cb..42be87e 100644
--- a/specs/FindingRegister.md
+++ b/specs/FindingRegister.md
@@ -42,6 +42,7 @@ kinds, states and metrics: [`GameDesign.md`](GameDesign.md). Reported by
| F12 | degenerate | note | — | — | 2026-08-01 | clay-borg |
| F13 | inconsistent | withdrawn | scenarios/ground/gr-e01-threshold-reachable-2p.yaml | counterexample | 2026-08-01 | clay-borg |
| F14 | unplayed | note | — | — | 2026-08-01 | clay-borg |
+| F15 | underdetermined | note | — | — | 2026-08-05 | clay-borg |
@@ -61,6 +62,15 @@ kinds, states and metrics: [`GameDesign.md`](GameDesign.md). Reported by
`-reachable-`. **Its reproduction is green**, which under GameDesign §1.3
is the alarm that forced the resolution. Withdrawn rather than deleted,
and the withdrawal is reported (ADR-0012 D5).
+- **F15 — the rules define one game, not a series.** `OutcomeView` gives
+ `personal` (per seat), `group_success` (per table) and `winners`. Summing
+ the first and counting the third answer different questions, and GROUND
+ says nothing about how several games combine. CB-WP-0024 T04 shows
+ **both, labelled**, rather than picking one and letting it become the
+ score by default. `note`: no artifact demonstrates that this *harms*
+ play — a unit test showing the two tallies point at different seats
+ demonstrates only that they can differ, which is arithmetic, not a design
+ defect. Under GameDesign §3.1 it may not be reported until one exists.
- **F14 — GR-E03/GR-E04 never played to the end.** Nineteen passes, never
played out. `note` until a trial game exists; GROUND-WP-0003 is the
playtest that would close it, and GameDesign §5's protocol makes the
diff --git a/tools/cb-play/src/hotseat.rs b/tools/cb-play/src/hotseat.rs
index 1385e96..c8112a8 100644
--- a/tools/cb-play/src/hotseat.rs
+++ b/tools/cb-play/src/hotseat.rs
@@ -200,6 +200,7 @@ impl Server {
final_view: Option<&games_ground::view::GroundView>,
message: &str,
linger: std::time::Duration,
+ tally: &crate::table::MatchTally,
) -> Result {
let deadline = std::time::Instant::now() + linger;
self.listener
@@ -237,6 +238,7 @@ impl Server {
message,
&self.guard.endpoint(),
&self.log_lines(),
+ &series_lines(tally),
);
respond(&mut stream, 200, "text/html; charset=utf-8", &page);
}
@@ -275,6 +277,142 @@ impl Server {
}
}
+/// The running series, phrased for a reader.
+///
+/// **Two lines, because the rules define one game and not a series**
+/// (CB-WP-0024 T04). `personal` sums per seat; `winners` counts games
+/// won; they answer different questions and GROUND says which is *the*
+/// series score nowhere at all. Both are shown and both are named, so
+/// nothing here quietly becomes canon.
+pub fn series_lines(t: &crate::table::MatchTally) -> Vec {
+ if t.games <= 1 {
+ // One game is not a series, and a "cumulative" panel over a single
+ // result would just restate the outcome above it.
+ return Vec::new();
+ }
+ let seats = |m: std::collections::BTreeMap| {
+ m.iter()
+ .map(|(p, v)| format!("P{} {v}", p.0 + 1))
+ .collect::>()
+ .join(" ")
+ };
+ let personal: std::collections::BTreeMap<_, _> = t
+ .personal
+ .iter()
+ .map(|(p, v)| (*p, format!("{v:+}")))
+ .collect();
+ let wins: std::collections::BTreeMap<_, _> = t
+ .personal
+ .keys()
+ .map(|p| (*p, t.wins.get(p).copied().unwrap_or(0).to_string()))
+ .collect();
+ vec![
+ format!("{} games this session", t.games),
+ format!("summed personal score — {}", seats(personal)),
+ format!("games won — {}", seats(wins)),
+ format!(
+ "table cleared its threshold {} of {} games",
+ t.group_successes, t.games
+ ),
+ ]
+}
+
+#[cfg(test)]
+mod series {
+ use crate::table::MatchTally;
+ use cb_kernel::PlayerId;
+ use games_ground::view::OutcomeView;
+
+ fn outcome(p1: i32, p2: i32, success: bool, winners: Vec) -> OutcomeView {
+ OutcomeView {
+ total: 0,
+ threshold: 5,
+ group_success: success,
+ personal: [(PlayerId(0), p1), (PlayerId(1), p2)].into_iter().collect(),
+ coalitions: vec![],
+ mastery: None,
+ winners,
+ }
+ }
+
+ fn after(games: &[OutcomeView]) -> MatchTally {
+ let mut t = MatchTally::default();
+ for o in games {
+ t.record_for_test(o);
+ }
+ t
+ }
+
+ /// The weakest possible assertion, and the one that catches a tally
+ /// reset by `play again`: two games must produce a total that is
+ /// neither game's score alone.
+ #[test]
+ fn two_games_accumulate_rather_than_replacing() {
+ let t = after(&[
+ outcome(3, 1, true, vec![PlayerId(0)]),
+ outcome(2, 4, false, vec![PlayerId(1)]),
+ ]);
+ assert_eq!(t.games, 2);
+ assert_eq!(t.personal[&PlayerId(0)], 5, "P1's scores did not sum");
+ assert_eq!(t.personal[&PlayerId(1)], 5, "P2's scores did not sum");
+ assert_eq!(t.group_successes, 1, "only one game cleared its threshold");
+ assert_eq!(t.wins[&PlayerId(0)], 1);
+ assert_eq!(t.wins[&PlayerId(1)], 1);
+ }
+
+ /// **The two tallies disagree, and that is the point.** Summed
+ /// personal score says the seats are level; games won says the same;
+ /// but a third game separates them, and which one is "the series
+ /// score" is a question GROUND does not answer. Showing one alone
+ /// would be picking a winner the rules never named.
+ #[test]
+ fn the_two_tallies_can_disagree_about_who_is_ahead() {
+ let t = after(&[
+ outcome(9, 0, false, vec![PlayerId(0)]),
+ outcome(0, 1, false, vec![PlayerId(1)]),
+ outcome(0, 1, false, vec![PlayerId(1)]),
+ ]);
+ assert!(
+ t.personal[&PlayerId(0)] > t.personal[&PlayerId(1)],
+ "P1 leads on summed score"
+ );
+ assert!(
+ t.wins[&PlayerId(1)] > t.wins.get(&PlayerId(0)).copied().unwrap_or(0),
+ "P2 leads on games won — the two answers point at different seats"
+ );
+ }
+
+ /// One game is not a series: the panel stays absent rather than
+ /// restating the outcome directly above it.
+ #[test]
+ fn a_single_game_shows_no_series_panel() {
+ assert!(super::series_lines(&after(&[outcome(1, 1, true, vec![])])).is_empty());
+ assert!(
+ !super::series_lines(&after(&[
+ outcome(1, 1, true, vec![]),
+ outcome(1, 1, true, vec![])
+ ]))
+ .is_empty(),
+ "two games must produce a panel"
+ );
+ }
+
+ /// Both tallies must be named on the page. An unlabelled number would
+ /// become "the score" by default, which is the canonisation this
+ /// deliberately avoids.
+ #[test]
+ fn the_page_says_which_tally_is_which() {
+ let lines = super::series_lines(&after(&[
+ outcome(1, 2, true, vec![PlayerId(1)]),
+ outcome(3, 0, false, vec![PlayerId(0)]),
+ ]));
+ let all = lines.join(" | ");
+ assert!(all.contains("summed personal score"), "{all}");
+ assert!(all.contains("games won"), "{all}");
+ assert!(all.contains("cleared its threshold"), "{all}");
+ }
+}
+
/// What the player asked for on the ending page.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EndChoice {
@@ -473,6 +611,7 @@ mod tests {
Some(&view),
"30 commands, hash f6c890a65271",
std::time::Duration::from_secs(20),
+ &crate::table::MatchTally::default(),
)
.expect("serve_end");
let replies = client.join().expect("client");
@@ -563,7 +702,8 @@ mod tests {
/// game that never happened.
#[test]
fn a_game_that_ended_badly_says_so_and_shows_no_table() {
- let page = cb_render_html::doc::ending(None, "P1 ran out of input", "/command?t=x", &[]);
+ let page =
+ cb_render_html::doc::ending(None, "P1 ran out of input", "/command?t=x", &[], &[]);
assert!(
page.contains("P1 ran out of input"),
"the reason is missing"
diff --git a/tools/cb-play/src/table.rs b/tools/cb-play/src/table.rs
index bdc67eb..4c84f50 100644
--- a/tools/cb-play/src/table.rs
+++ b/tools/cb-play/src/table.rs
@@ -52,6 +52,59 @@ impl Default for Config {
}
}
+/// What carries across games in one browser session (CB-WP-0024 T04).
+///
+/// `play` already owned the session — one listener, an advancing seed,
+/// `run_game` in a loop — and kept only the last summary, so a second
+/// game started with no memory of the first.
+///
+/// ## Two tallies, because the rules define one game and not a series
+///
+/// `OutcomeView` offers `personal` (per seat), `group_success` (per
+/// table) and `winners`. Summing `personal` and counting `winners` answer
+/// **different questions**, and GROUND's dataset says nothing about how
+/// several games combine — there is no series in the rules.
+///
+/// So both are kept and both are labelled, rather than one being picked
+/// and quietly presented as *the* score. Registered as a note (kind
+/// `underdetermined`) in `specs/FindingRegister.md`: it may not be
+/// reported to ground-game until something demonstrates a problem, which
+/// is GameDesign §3.1's rule and the reason this is not being sent as a
+/// finding.
+#[derive(Debug, Clone, Default)]
+pub struct MatchTally {
+ pub games: u32,
+ /// Sum of each seat's per-game `personal` score.
+ pub personal: std::collections::BTreeMap,
+ /// Games the table cleared its threshold.
+ pub group_successes: u32,
+ /// How often each seat appeared in `winners`.
+ pub wins: std::collections::BTreeMap,
+}
+
+impl MatchTally {
+ /// Test-visible alias for [`Self::record`]. The real call site is the
+ /// driver; this exists so the series tests drive the same code rather
+ /// than a reimplementation of it beside it.
+ #[cfg(test)]
+ pub fn record_for_test(&mut self, o: &games_ground::view::OutcomeView) {
+ self.record(o);
+ }
+
+ fn record(&mut self, o: &games_ground::view::OutcomeView) {
+ self.games += 1;
+ for (seat, score) in &o.personal {
+ *self.personal.entry(*seat).or_insert(0) += *score;
+ }
+ if o.group_success {
+ self.group_successes += 1;
+ }
+ for w in &o.winners {
+ *self.wins.entry(*w).or_insert(0) += 1;
+ }
+ }
+}
+
/// What a finished game reports back.
/// The end-state hash is what makes a session comparable to its replay.
#[derive(Debug)]
@@ -200,7 +253,11 @@ fn end_badly(
msg: String,
) -> Result {
if let Some(s) = server {
- let _ = s.serve_end(None, &msg, END_LINGER);
+ // A game that ended badly has no outcome, so it contributes
+ // nothing to the series and the panel stays absent. Passing an
+ // empty tally rather than the live one is deliberate: a crashed
+ // game must not be counted as a played one.
+ let _ = s.serve_end(None, &msg, END_LINGER, &MatchTally::default());
}
Err(msg)
}
@@ -239,8 +296,12 @@ pub fn play(config: &Config, input: R, out: W) -> Result(
input: &'a std::cell::RefCell,
out: &'a std::cell::RefCell,
server: Option>,
+ tally: &mut MatchTally,
) -> Result<(Summary, crate::hotseat::EndChoice), String> {
let setup = Setup {
players: config.players,
@@ -342,8 +404,14 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
let mut end_choice = crate::hotseat::EndChoice::Closed;
if let Some(srv) = &server {
let ended = game.state.project(Viewer::Spectator);
+ // Recorded BEFORE the page is served, so the ending page shows a
+ // tally that includes the game being looked at. A tally that
+ // lagged by one game would be worse than none.
+ if let Some(o) = &ended.outcome {
+ tally.record(o);
+ }
let msg = format!("{} commands, hash {}", game.commands, &end_hash[..12]);
- end_choice = srv.serve_end(Some(&ended), &msg, END_LINGER)?;
+ end_choice = srv.serve_end(Some(&ended), &msg, END_LINGER, tally)?;
}
let scenario = games_ground::record::to_scenario(
diff --git a/workplans/CB-WP-0024-the-table-you-can-watch.md b/workplans/CB-WP-0024-the-table-you-can-watch.md
index 54208b8..c91787a 100644
--- a/workplans/CB-WP-0024-the-table-you-can-watch.md
+++ b/workplans/CB-WP-0024-the-table-you-can-watch.md
@@ -2,7 +2,7 @@
id: CB-WP-0024
kind: product
title: "The table you can watch: the piles, the other seats' moves, the score that carries"
-status: active
+status: done
state_hub_workstream_id: "5f17b6f9-cd4b-4c31-a8af-712313149cf2"
---
@@ -163,7 +163,7 @@ when the deck is actually out.
```task
id: CB-WP-0024-T03
-status: todo
+status: done
priority: high
state_hub_task_id: "7b0b08d9-cf0d-40f4-8697-049bdb20085d"
```
@@ -189,11 +189,28 @@ face-down as one identical back.
`view.rs`'s own test already uses; copy it);
- `make sim` still passes: 26 scenarios encode what the table looks like.
+**Done 2026-08-05.** Each seat's play is drawn as a card in its own area,
+with the sentence kept beside it — the log is the record and a player
+reading back needs the same vocabulary.
+
+**The face-down back is a `const` with no parameters.** `SelectionView::
+Hidden` carries nothing, so the renderer is structurally unable to leak
+what it was not given; making the back a constant means there is no data
+path into it to add later. The risk this guards is real but is the
+renderer's, not the model's: a tint keyed on suit or a shape keyed on
+action would leak even though `view.rs` handed over nothing.
+
+The leak test copies `view.rs`'s own shape — assert two different hidden
+situations render an identical back, **then** assert the same renderer
+does show a revealed play, because without the second half the first
+passes for a renderer that draws nothing at all. Both are
+mutation-proven: deleting the call turns both red.
+
## Task: the score that carries across games
```task
id: CB-WP-0024-T04
-status: todo
+status: done
priority: medium
state_hub_task_id: "4f312267-2b82-4cf9-bbb1-7f287dc9e766"
```
@@ -219,11 +236,36 @@ raise the question rather than silently canonising a choice.
- the tally survives the seed advance and resets only on a new process;
- the per-game outcome block is unchanged, and a test says so.
+**Done 2026-08-05.** `MatchTally` lives in `play`, beside the listener and
+the seed — the other two things that survive `play again`. Shown on the
+ending page above the log, and **absent for a first game**: one game is not
+a series, and a cumulative panel restating the outcome directly above it
+is noise.
+
+**"Cumulative" was decided before anything was summed, and the answer is
+that the rules do not decide it.** `OutcomeView` offers `personal` (per
+seat), `group_success` (per table) and `winners`; summing the first and
+counting the third answer different questions, and GROUND defines one game
+and no series at all. **Both are shown and both are named** rather than one
+being picked and becoming *the* score by default. A test asserts they can
+point at different seats — 9/0/0 against 0/1/1 leads on summed score for
+one seat and on games won for the other.
+
+Registered as **F15, kind `underdetermined`, state `note`** in
+`FindingRegister.md`. It stays a note deliberately: the test shows the two
+tallies *can differ*, which is arithmetic, not evidence that the ambiguity
+harms play — so under GameDesign §3.1 it may not be reported to
+ground-game yet. **This is the note tier doing the job D6 built it for**,
+on the first new question since it was written.
+
+A crashed game contributes nothing: the error path passes an empty tally,
+so a game that ended without an outcome is never counted as played.
+
## Task: evidence
```task
id: CB-WP-0024-T05
-status: todo
+status: done
priority: high
state_hub_task_id: "db176595-e00d-467c-ba54-0714cfb7ba5c"
```
@@ -238,3 +280,30 @@ state_hub_task_id: "db176595-e00d-467c-ba54-0714cfb7ba5c"
- **What the cumulative-score question turned out to be**, and whether it
went to `ground-game`.
- **Quote CB-WP-0023's cost by re-running the instrument.**
+
+**Done 2026-08-05.**
+[CB-EV-0023](../evidence/CB-EV-0023-the-table-you-can-watch.md).
+
+- **Three of five remarks were already implemented** — data the projection
+ carried, drawn as text. The table's problem was legibility, not content,
+ and **the coverage gate passes either way** because it proves nothing is
+ omitted, not that anything is readable. That gap is named, not fixed:
+ the honest control is a person playing it.
+- **The defect that mattered was untestable.** `jsrun`'s fetch stub never
+ invoked its callbacks, so every line of the script reacting to the
+ server was unreachable from every test in the project. Same finding as
+ CB-WP-0016's *a stub too thin to express a failure is how the failure
+ survives*, one layer deeper — at the reply, which is where the session's
+ ending lives.
+- **The reshuffle was already ruled and already implemented** (U4,
+ confirmed 2026-08-03), so nothing was raised. CB-WP-0026 applied that
+ ruling the same day this pass consumed it — the first time answering
+ *"is this underdetermined?"* was one lookup instead of a message.
+- **F15 stayed a note on purpose.** The note tier's first use since D6
+ wrote it, and it came from *building* rather than from play, which the
+ ADR did not anticipate and which the tier handled unchanged.
+- **Chaos window 2 has rolled no 8 in ten declarations**, so it will
+ likely close with no override to evaluate and its retirement condition
+ untestable. The d4 → d8 cut may have made the mechanism unevaluable —
+ a cost CB-EV-0015 did not price, and one the window's closing evidence
+ should state.