diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs index 99f0f94..533521e 100644 --- a/crates/cb-render-html/src/doc.rs +++ b/crates/cb-render-html/src/doc.rs @@ -79,29 +79,6 @@ pub const SCRIPT: &str = r#" function key(n) { return n ? n.getAttribute('data-drop') : null; } function status(t) { document.getElementById('cb-status').textContent = t; } - // The session has ended server-side, so nothing on this page can work - // any more -- and nothing on it may look like it can (CB-WP-0024 T01). - // - // The old page kept a full table and a live `play again` control after - // the server had stopped listening, because the script acted only on - // 'ok'. A control that cannot work must stop being a control, so the - // `data-drop` attribute is REMOVED rather than styled: that is the - // attribute the script's own walk reads, so the element becomes - // undroppable by the same rule that made it droppable. - // - // No game vocabulary here (ADR-0007 D5). 'closed' is a server - // lifecycle word, exactly like the 'ok' branch below it. - function seal() { - var all = document.querySelectorAll('[data-drop]'); - for (var i = 0; i < all.length; i++) { - all[i].classList.add('sealed'); - // removeAttribute, NOT setAttribute(_, null): the latter writes the - // literal string "null" in a real browser, which is truthy, so the - // control would stay droppable while the test stub said otherwise. - all[i].removeAttribute('data-drop'); - } - } - // ADR-0010 D1: the destinations come from `data-targets`, which Rust // wrote. This matches on them. It does not compute, infer, filter or // default one -- a script that pattern-matched ids to guess what is @@ -198,7 +175,6 @@ pub const SCRIPT: &str = r#" }).then(function (r) { return r.text(); }).then(function (t) { status(t); if (t.indexOf('ok') === 0) { window.location.reload(); } - else if (t.indexOf('closed') === 0) { seal(); } }); }); })(); @@ -249,14 +225,6 @@ h1,h2{font-size:1rem;margin:1.2rem 0 .4rem;color:#9cf} #cb-ghost .why{color:#9cf;margin-left:.4rem} /* What you picked up, left visibly behind so there are not two cards. */ .held{cursor:grabbing;opacity:.35;border-style:dashed} -/* CB-WP-0024 T01: a control the server can no longer serve. The script - removes its `data-drop` so it is genuinely inert; this is only how that - reads. `pointer-events:none` is belt and braces, not the mechanism -- - 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} @@ -329,112 +297,6 @@ fn problem_svg(out: &mut String, priority: u32, p: &ProblemView, x: i32) { /// The relationship graph — the one element every Rust 2D toolkit would /// have left us to hand-roll, and the reason SVG earns its place here /// rather than merely fitting the budget (CB-RES-0006 §5). -/// One pile, drawn as a small stack of offset cards with its count on top. -/// -/// `depth` is how many card-backs to suggest, not the count — a pile of 17 -/// is not seventeen rectangles. The **number** is the truth; the stack is -/// how you tell at a glance that there is a pile there at all. -fn pile_svg(out: &mut String, x: i32, label: &str, count: usize, face: &str, note: &str) { - let depth = match count { - 0 => 0, - 1..=3 => 1, - 4..=9 => 2, - _ => 3, - }; - // The title carries the count in words. It is what a screen reader - // announces, and it is the stable thing a coverage probe can match -- - // the on-canvas number is a bare numeral that could be anything. - let _ = write!( - out, - "{} pile: {count} remaining", - esc(label), - ); - if depth == 0 { - // An EMPTY pile is drawn, not omitted. A missing slot reads as - // "this game has no discard", which is a different statement from - // "the discard is empty" (CB-WP-0024 T02). - let _ = write!( - out, - "", - ); - } - for d in (0..depth).rev() { - let dx = x + d * 3; - let dy = 14 - d * 3; - let _ = write!( - out, - "", - ); - } - let _ = write!( - out, - "{count}\ - {label}\ - {note}", - tx = x + 38, - count = count, - label = esc(label), - note = esc(note), - ); -} - -/// The draw stack and the discard stack, as objects on the table. -/// -/// Both numbers come from the projection — `solution_deck_len` and -/// `solution_discard` — and are never recomputed here. -/// -/// **The reshuffle is real and is the U4 default**, confirmed by -/// ground-game on 2026-08-03: when the deck runs out, the discard is -/// reshuffled into it deterministically; if both are empty the draw is -/// skipped (`games/ground/src/lib.rs` `draw_solution`). So the pile shows -/// the state in which the *next* draw will trigger it. It does not claim a -/// reshuffle has happened — the view carries no such flag, and the event -/// already reads out in the log. -fn piles_svg(out: &mut String, view: &GroundView) { - let deck = view.solution_deck_len; - let discard = view.solution_discard.len(); - let will_reshuffle = deck == 0 && discard > 0; - - out.push_str(""); - pile_svg( - out, - 10, - "draw", - deck, - "#26303a", - if will_reshuffle { "empty" } else { "" }, - ); - pile_svg( - out, - 120, - "discard", - discard, - "#2a2f3a", - if will_reshuffle { - "shuffles in on next draw" - } else { - "" - }, - ); - if will_reshuffle { - out.push_str( - "\ - ", - ); - } - out.push_str(""); - - // The discard is public — it has been played (GR-S04 hides only the - // deck) — so its contents stay readable as text beside the picture. - let _ = write!( - out, - "
discard {}
", - esc(&cards(&view.solution_discard)), - ); -} - fn relations_svg(view: &GroundView) -> String { let n = view.players.len().max(1); let (cx, cy, r) = (200.0f64, 150.0f64, 110.0f64); @@ -520,51 +382,6 @@ fn selection_words(s: &games_ground::Selection) -> 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!( @@ -629,10 +446,6 @@ 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 {}
", @@ -757,8 +570,13 @@ fn body(s: &mut String, view: &GroundView) { } s.push_str(""); - s.push_str("

solutions

"); - piles_svg(s, view); + let _ = write!( + s, + "

solutions

deck {} remaining \ + discard {}
", + view.solution_deck_len, + esc(&cards(&view.solution_discard)), + ); if let Some(o) = &view.outcome { let personal = o @@ -980,13 +798,7 @@ 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], - series: &[String], -) -> String { +pub fn ending(view: Option<&GroundView>, message: &str, endpoint: &str, log: &[LogLine]) -> String { let mut s = String::with_capacity(4096); let _ = write!( s, @@ -1005,25 +817,12 @@ pub fn ending( ); } } - // 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, "
\
play again
\ -
end session \u{2014} stops the game server
\ +
close \u{2014} I have read this
\
\
the game is over
\ ", diff --git a/crates/cb-render-html/src/jsrun.rs b/crates/cb-render-html/src/jsrun.rs index 82b9bcf..40d46ac 100644 --- a/crates/cb-render-html/src/jsrun.rs +++ b/crates/cb-render-html/src/jsrun.rs @@ -79,11 +79,6 @@ function __mk(key, targets, text, descs) { 'data-descs': descs || null }, getAttribute: function (a) { return this._attr[a] !== undefined ? this._attr[a] : null; }, setAttribute: function (a, v) { this._attr[a] = v; }, - // A real browser's removeAttribute. The stub must have it so the - // script can use it instead of `setAttribute(a, null)`, which writes - // the truthy string "null" in a browser but a real null here -- a - // difference that would make a broken seal pass its own test. - removeAttribute: function (a) { delete this._attr[a]; }, classList: { add: function (c) { n._cls[c] = true; }, remove: function (c) { delete n._cls[c]; }, @@ -102,25 +97,10 @@ var document = { querySelectorAll: function (_sel) { return __all; } }; var window = { location: { reload: function () { __reloaded(); } } }; - -// The server's REPLY, delivered synchronously through the then-chain. -// -// CB-WP-0024 T01: the old stub returned `{then: function(){return this}}`, -// which never invoked the callbacks -- so every line of the script that -// reacts to what the server said was unreachable in every test. That is -// why "the page still looks live after the session ends" survived: the -// branch that would have fixed it could not be executed here. -// -// `__reply` is set per run. Chained `.then(fn)` passes fn's return value -// on, matching the promise semantics the script relies on (`r.text()` -// then the text). -var __reply = ""; function fetch(url, opts) { __post(url, (opts && opts.body) || ""); - function chain(v) { - return { then: function (fn) { return chain(fn ? fn(v) : v); } }; - } - return chain({ text: function () { return __reply; } }); + var chainable = { then: function () { return chainable; } }; + return chainable; } // Gestures are driven against REGISTERED nodes, so the script's walk, its @@ -156,19 +136,6 @@ function __heldKeys() { return out.sort().join(','); } function __ghosts() { return __body.children.length; } - -// What the page still offers AFTER the reply was handled. A control the -// script has sealed must stop being droppable, not merely look different -// -- so this reads the same attribute the script's own walk reads. -function __liveKeys() { - var out = []; - for (var i = 0; i < __all.length; i++) { - var k = __all[i].getAttribute('data-drop'); - if (k) { out.push(k); } - } - return out.sort().join(','); -} -function __statusText() { return __status.textContent; } "#; /// Run the document's scripts, then a pointer gesture, and report what @@ -216,57 +183,6 @@ pub fn gesture(html: &str, down: &str, up: &str) -> Result, String> Ok(out) } -/// What the page LOOKS LIKE after a gesture whose reply was `reply`. -/// -/// Returns `(live drop keys, status text)` — the keys still droppable -/// once the script has handled the server's answer. -/// -/// **This path did not exist before CB-WP-0024 T01, and that is why the -/// defect it tests survived.** The fetch stub used to return a -/// `then`-chain that never called its callbacks, so nothing the script -/// does in response to the server was reachable from any test. A page -/// that ignores what it was told looked identical to one that acts on it. -pub fn gesture_with_reply( - html: &str, - down: &str, - up: &str, - reply: &str, -) -> Result<(Vec, String), String> { - let ctx = quick_js::Context::new().map_err(|e| format!("quickjs init: {e}"))?; - ctx.add_callback("__post", |_url: String, _body: String| 0i32) - .map_err(|e| format!("register __post: {e}"))?; - let reloaded = Arc::new(Mutex::new(false)); - let flag = reloaded.clone(); - ctx.add_callback("__reloaded", move || { - *flag.lock().expect("reloaded") = true; - 0i32 - }) - .map_err(|e| format!("register __reloaded: {e}"))?; - - prepare(&ctx, html)?; - ctx.eval(&format!("__reply = {};", json_lit(reply))) - .map_err(|e| format!("set reply: {e}"))?; - ctx.eval(&format!( - "__down({}); __up({});", - json_lit(down), - json_lit(up) - )) - .map_err(|e| format!("gesture: {e}"))?; - - let keys: String = ctx - .eval_as("__liveKeys()") - .map_err(|e| format!("live keys: {e}"))?; - let status: String = ctx - .eval_as("__statusText()") - .map_err(|e| format!("status: {e}"))?; - let keys = if keys.is_empty() { - Vec::new() - } else { - keys.split(',').map(str::to_string).collect() - }; - Ok((keys, status)) -} - /// Every droppable in the document, as `(data-drop, data-targets)`. /// /// The stub's node set is built from the **real emitted page**, not from a diff --git a/crates/cb-render-html/src/lib.rs b/crates/cb-render-html/src/lib.rs index 7409f5b..03b74e7 100644 --- a/crates/cb-render-html/src/lib.rs +++ b/crates/cb-render-html/src/lib.rs @@ -128,7 +128,7 @@ mod coverage { ("step", "step Select"), ("mode", "scoring BondedCoalitions"), ("viewer", "viewing as P1"), - ("solution_deck_len", "draw pile: 17 remaining"), + ("solution_deck_len", "17 remaining"), ("solution_discard.*.suit", "discard Repair Change"), ("players.*.stress", "stress 5"), ("players.*.protection", "protect 2"), @@ -630,256 +630,5 @@ mod gamelog { } } -/// CB-WP-0024 T02 — the draw and discard stacks as objects on the table. -/// -/// The maintainer asked for the piles to be visible and for the discard -/// to show a shuffle when the draw runs out. **The reshuffle is real** — -/// `games/ground/src/lib.rs::draw_solution` implements the U4 default -/// (deterministic reshuffle of the discard; skip the draw if both are -/// empty), which ground-game confirmed on 2026-08-03. So this renders the -/// state in which the next draw triggers it, rather than inventing a rule. -#[cfg(test)] -mod piles { - use cb_kernel::PlayerId; - use games_ground::view::GroundView; - - use crate::doc::document; - - fn view() -> GroundView { - crate::testfix::view(Some(PlayerId(0))) - } - - fn html(v: &GroundView) -> String { - document(v, &[], "/command?t=x", Some(PlayerId(0)), false) - } - - /// The counts must come from the projection, never be recomputed. - #[test] - fn both_counts_are_the_views_own_numbers() { - let mut v = view(); - v.solution_deck_len = 5; - let doc = crate::text_of(&html(&v)); - assert!( - doc.contains("draw pile: 5 remaining"), - "the drawn deck count is not the view's: {doc}" - ); - let n = v.solution_discard.len(); - assert!( - doc.contains(&format!("discard pile: {n} remaining")), - "the drawn discard count is not the view's ({n}): {doc}" - ); - } - - /// An empty discard is an EMPTY pile, not a missing one. A absent slot - /// reads as "this game has no discard", which is a different claim. - #[test] - fn an_empty_pile_is_drawn_rather_than_omitted() { - let mut v = view(); - v.solution_discard.clear(); - let doc = crate::text_of(&html(&v)); - assert!( - doc.contains("discard pile: 0 remaining"), - "an empty discard vanished instead of rendering as empty: {doc}" - ); - } - - /// The U4 state: deck empty, discard holding cards. The next draw - /// reshuffles, and the table should say so. - #[test] - fn an_exhausted_deck_says_the_discard_shuffles_back_in() { - let mut v = view(); - v.solution_deck_len = 0; - assert!(!v.solution_discard.is_empty(), "fixture needs a discard"); - let doc = crate::text_of(&html(&v)); - assert!( - doc.contains("shuffles in on next draw"), - "an exhausted deck did not announce the U4 reshuffle: {doc}" - ); - - // And the negative half: with cards left, no shuffle is promised. - let mut full = view(); - full.solution_deck_len = 12; - assert!( - !crate::text_of(&html(&full)).contains("shuffles in on next draw"), - "a stocked deck claimed a reshuffle was coming" - ); - } -} - -/// 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' -/// — why? the UI is not closing."* Two defects wearing one button: the -/// label described a reading while the control stopped a server, and -/// acknowledging it changed nothing on screen, leaving a live-looking -/// table and a `play again` pointing at a closed port. -#[cfg(test)] -mod ending_page { - use crate::{doc, jsrun}; - - fn page() -> String { - doc::ending(None, "the game ended", "/command?t=x", &[], &[]) - } - - /// The label must say what the control DOES. Asserted on the rendered - /// text so reverting the wording turns this red — a comment would not. - #[test] - fn the_control_is_labelled_by_its_effect_not_by_a_reading() { - let text = crate::text_of(&page()); - assert!( - text.contains("end session") && text.contains("stops the game server"), - "the ending control must name its effect: {text}" - ); - assert!( - !text.contains("I have read this"), - "the label claimed the player had read something; it stops a server" - ); - } - - /// The defect the maintainer actually saw. After the server says the - /// session is closed, `play again` must stop being offered — it now - /// points at a port nobody is listening on. - #[test] - fn acknowledging_the_end_stops_the_page_offering_anything() { - let html = page(); - let before = doc::drop_keys(&html); - assert!( - before.contains("again") && before.contains("done"), - "fixture must start with both controls: {before:?}" - ); - - let (live, status) = - jsrun::gesture_with_reply(&html, "done", "done", "closed — the session has ended") - .expect("run the page"); - - assert!( - !live.contains(&"again".to_string()), - "`play again` survived the session ending and would post to a closed port: {live:?}" - ); - assert!( - live.is_empty(), - "every control must be sealed once the server stops, not only `again`: {live:?}" - ); - assert!( - status.contains("session has ended"), - "the page must say what happened: {status:?}" - ); - } - - /// The negative control. If `seal` fired on any reply, this test would - /// pass for a page that tears itself down whenever it is touched — - /// which would break `play again` in the ordinary case. - #[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"); - assert!( - live.contains(&"again".to_string()) && live.contains(&"done".to_string()), - "an 'ok' reply must not seal the page: {live:?}" - ); - } -} - #[cfg(test)] mod testfix; diff --git a/evidence/CB-EV-0023-the-table-you-can-watch.md b/evidence/CB-EV-0023-the-table-you-can-watch.md deleted file mode 100644 index 3e59f14..0000000 --- a/evidence/CB-EV-0023-the-table-you-can-watch.md +++ /dev/null @@ -1,173 +0,0 @@ -# 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 42be87e..d91a9cb 100644 --- a/specs/FindingRegister.md +++ b/specs/FindingRegister.md @@ -42,7 +42,6 @@ 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 --> @@ -62,15 +61,6 @@ 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 c8112a8..b856215 100644 --- a/tools/cb-play/src/hotseat.rs +++ b/tools/cb-play/src/hotseat.rs @@ -200,7 +200,6 @@ 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 @@ -238,7 +237,6 @@ impl Server { message, &self.guard.endpoint(), &self.log_lines(), - &series_lines(tally), ); respond(&mut stream, 200, "text/html; charset=utf-8", &page); } @@ -251,17 +249,7 @@ impl Server { &mut stream, 200, "text/plain", - // CB-WP-0024 T01: the reply is what the player - // reads. "closed" alone left them looking at a - // live table wondering why nothing happened. - // The `closed` prefix is what the page's script - // matches on to seal itself. - if again { - "ok: dealing" - } else { - "closed \u{2014} the session has ended and the server has stopped. \ - You can close this tab." - }, + if again { "ok: dealing" } else { "closed" }, ); break Ok(if again { EndChoice::Again @@ -277,142 +265,6 @@ 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 { @@ -611,7 +463,6 @@ 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"); @@ -702,8 +553,7 @@ 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 4c84f50..bdc67eb 100644 --- a/tools/cb-play/src/table.rs +++ b/tools/cb-play/src/table.rs @@ -52,59 +52,6 @@ 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)] @@ -253,11 +200,7 @@ fn end_badly<T>( msg: String, ) -> Result<T, String> { if let Some(s) = server { - // 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()); + let _ = s.serve_end(None, &msg, END_LINGER); } Err(msg) } @@ -296,12 +239,8 @@ 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(), &mut tally)?; + let (summary, choice) = run_game(&cfg, &input, &out, server.clone())?; if choice != crate::hotseat::EndChoice::Again { return Ok(summary); } @@ -314,7 +253,6 @@ 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, @@ -404,14 +342,8 @@ 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, tally)?; + end_choice = srv.serve_end(Some(&ended), &msg, END_LINGER)?; } 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 c91787a..6d65391 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: done +status: ready state_hub_workstream_id: "5f17b6f9-cd4b-4c31-a8af-712313149cf2" --- @@ -47,7 +47,7 @@ except where task T04 says so explicitly. ```task id: CB-WP-0024-T01 -status: done +status: todo priority: high state_hub_task_id: "acb4231c-35df-490f-93fd-be71c0abf1dc" ``` @@ -75,36 +75,11 @@ because a control that can no longer work must not look like it can. - the label change must be mutation-visible: assert the rendered text, so reverting the wording turns a test red. -**Done 2026-08-05.** Label is now `end session — stops the game server`; -the reply is `closed — the session has ended and the server has stopped. -You can close this tab.`; and the script **seals** the page on a `closed` -reply — `removeAttribute('data-drop')` on every control, so they stop -being droppable by the same rule that made them droppable. `sealed` CSS is -how that reads, not the mechanism. - -**`removeAttribute`, not `setAttribute(_, null)`** — the latter writes the -literal string `"null"` in a browser, which is truthy, so the control -would stay live while the stub reported it sealed. - -**The reply path had never been executable in a test.** `jsrun`'s `fetch` -stub returned `{then: function(){return this}}`, which never invoked its -callbacks — so every line of the script that reacts to what the server -said was unreachable from every test in the project. **That is why this -defect survived**: a page that ignores the server looked identical to one -that acts on it. The stub now delivers a real then-chain, and -`gesture_with_reply` reports the page's surviving controls. - -Three tests, and the seal is mutation-proven: deleting the `closed` branch -turns exactly one red. The negative control (`ok: dealing` must NOT seal) -exists because a seal that fired on every reply would pass the first test -and break `play again`. `cb-play`: 22 passed, including -`play_again_deals_a_second_game`. - ## Task: the piles are objects on the table ```task id: CB-WP-0024-T02 -status: done +status: todo priority: high state_hub_task_id: "37b16a87-e791-4c85-9f60-671812f6dd2e" ``` @@ -132,38 +107,11 @@ stop. - the shuffle question is answered in the task record with the line number that settles it, either way. -**Done 2026-08-05.** Both piles are drawn as offset stacks with their -counts, replacing the text line. Depth suggests *a pile exists*; the -number is the truth — 17 cards is not seventeen rectangles. - -**The shuffle question is settled and the answer is that it already -works.** `games/ground/src/lib.rs:1419-1435` (`draw_solution`) implements -the **U4 default**: deck empty → deterministic reshuffle of the discard -via a `DeckReshuffled` event seeded from `seed ^ round`; both empty → skip -the draw. ground-game **confirmed U4 on 2026-08-03**, so this is a ruled -rule, not an invented one. Nothing to raise. - -The event already reads out in the log (`hotseat.rs:374`). What the piles -add is the *state before it fires*: deck 0 with a non-empty discard draws -an arrow and says **"shuffles in on next draw"**. That is derivable from -the view; a claim that a reshuffle *has happened* would not be, and is not -made. - -**The coverage gate caught the probe going stale** — removing the -`17 remaining` text broke `solution_deck_len`'s classification. Fixed by -putting the count in the pile's `<title>` (`draw pile: 17 remaining`), -which is both a stable probe and what a screen reader announces; the -on-canvas numeral alone could be any number on the page. - -Three tests: counts come from the view, an empty discard renders as an -empty pile rather than vanishing, and the reshuffle notice appears only -when the deck is actually out. - ## Task: what the other seats played, on the table ```task id: CB-WP-0024-T03 -status: done +status: todo priority: high state_hub_task_id: "7b0b08d9-cf0d-40f4-8697-049bdb20085d" ``` @@ -189,28 +137,11 @@ 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: done +status: todo priority: medium state_hub_task_id: "4f312267-2b82-4cf9-bbb1-7f287dc9e766" ``` @@ -236,36 +167,11 @@ 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: done +status: todo priority: high state_hub_task_id: "db176595-e00d-467c-ba54-0714cfb7ba5c" ``` @@ -280,30 +186,3 @@ 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.