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 = "face down\ + \ + "; + +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, + "\ + played {label}\ + \ + {label}\ + {sub}\ + ", + 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 | <!-- design-register:end --> @@ -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<EndChoice, String> { 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<String> { + 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<PlayerId, String>| { + m.iter() + .map(|(p, v)| format!("P{} {v}", p.0 + 1)) + .collect::<Vec<_>>() + .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<PlayerId>) -> 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<cb_kernel::PlayerId, i32>, + /// Games the table cleared its threshold. + pub group_successes: u32, + /// How often each seat appeared in `winners`. + pub wins: std::collections::BTreeMap<cb_kernel::PlayerId, u32>, +} + +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<T>( msg: String, ) -> Result<T, String> { 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<R: BufRead, W: Write>(config: &Config, input: R, out: W) -> Result<S }; let mut cfg: Config = config.clone(); + // CB-WP-0024 T04: the tally outlives the game, which is the whole + // point — it is declared here, beside the listener and the seed, + // because those are the other two things that survive `play again`. + let mut tally = MatchTally::default(); loop { - let (summary, choice) = run_game(&cfg, &input, &out, server.clone())?; + let (summary, choice) = run_game(&cfg, &input, &out, server.clone(), &mut tally)?; if choice != crate::hotseat::EndChoice::Again { return Ok(summary); } @@ -253,6 +314,7 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>( input: &'a std::cell::RefCell<R>, out: &'a std::cell::RefCell<W>, server: Option<std::rc::Rc<crate::hotseat::Server>>, + 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.