From 4d3e30eda48d82f993a91788ea7e83c7b999ddd8 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 6 Aug 2026 17:25:48 +0200 Subject: [PATCH] CB-WP-0028 T04-T07: a game you solve, rankings that cite their source, and a feature deliberately not built T04 BUILT NOTHING, WHICH IS THE CORRECT OUTCOME. "Click the draw stack to get hand cards" is not a legal move: GroundCommand has no standalone draw, and the edition's own INVESTIGATE text settles it -- "Choose one hidden, non-Denied Problem and reveal it. THEN DRAW ONE SOLUTION." Drawing is a consequence, never an action. Implementing click-to-draw would have invented a rule, which is what CB-WP-0023 exists to stop. And observation 5 is already true: drawing is ALREADY automatic, inside INVESTIGATE, with no player input. An auto-draw option was asked for a thing that has never been manual. Both observations have one root cause and T02 fixed it. The maintainer expected to take cards from the deck because NOTHING ON THE PAGE SAID HOW DRAWING WORKS -- the INVESTIGATE card's own text was in a file we had not imported. No finding raised: a player's instinct differing from a legible rule is a comprehension gap, not a rules gap. Whether the instinct recurs now that the text is present is a testable question and was not before. T06: "game solved" on a win, "game over" on a loss, "the game stopped" with no outcome. Asserted all three ways, because a test checking only the win case passes for a page that always says solved. More than tone -- GROUND is co-operative and about repairing something, and arcade failure-state vocabulary for a win tells a player the wrong thing about what they just did. T05: the ending page gets the table's two-column shape. Result left; rankings, controls and the full log right. The seal still removes every control wherever they now live. T07's interesting decision was NOT TO RANK. Modes.csv defines scoring_tiebreak per mode, so ordering is the GAME's where one exists -- "Lower combined Stress, then fewer Blame tokens" for coalitions. For SHARED GROUND it says "Not applicable", because the table succeeds or fails together. So co-op shows contributions and refuses to order them, and says why. Drawing a leaderboard because a leaderboard is easy would invent scoring the rules do not have. The one derived superlative is labelled "clay-borg's reading, not a rule" and appears only where the mode ranks; ties are shown as ties. 52 render tests pass. check clean, loop-lint clean. Co-Authored-By: Claude Opus 5 --- crates/cb-render-html/src/doc.rs | 139 ++++++++++++++++++- crates/cb-render-html/src/lib.rs | 83 +++++++++++ workplans/CB-WP-0028-the-table-you-sit-at.md | 62 ++++++++- 3 files changed, 275 insertions(+), 9 deletions(-) diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs index 60bbdde..fca3e47 100644 --- a/crates/cb-render-html/src/doc.rs +++ b/crates/cb-render-html/src/doc.rs @@ -1107,6 +1107,112 @@ fn log_section(s: &mut String, log: &[LogLine]) { s.push_str(""); } +/// Who did what, at the end (CB-WP-0028 T07). +/// +/// **Two kinds of line, and they are labelled differently.** A *fact* is +/// something the game counts — Problems claimed, personal score, Stress, +/// Blame. A *reading* is ours, and says so. +/// +/// **In SHARED GROUND the game defines no ranking at all**: `Modes.csv` +/// gives `scoring_tiebreak` as *"Not applicable"*, because the table +/// succeeds or fails together. So contributions are shown and **not +/// ordered**, and the page says why. Ranking a co-operative game because +/// a leaderboard is easy to draw would be inventing scoring the game does +/// not have — the same defect as canonising a provisional default. +fn rankings(s: &mut String, view: &GroundView) { + let Some(outcome) = &view.outcome else { + return; + }; + let mode_id = match view.mode { + games_ground::ScoringMode::SharedGround => "MODE_COOP", + games_ground::ScoringMode::CommonProblem => "MODE_SEMI", + games_ground::ScoringMode::BondedCoalitions => "MODE_COALITION", + }; + let tiebreak = games_ground::edition::modes() + .ok() + .and_then(|m| m.into_iter().find(|(c, _)| c.id == mode_id)) + .map(|(_, tb)| tb) + .unwrap_or_default(); + let ranked = !tiebreak.trim().is_empty() && !tiebreak.starts_with("Not applicable"); + + // A fact the game counts: who claimed which Problem. + let solved = |p: PlayerId| { + view.problems + .values() + .filter(|q| matches!(q, ProblemView::FaceUp { claimed_by: Some(c), .. } if *c == p)) + .count() + }; + + let mut seats: Vec = view.players.keys().copied().collect(); + if ranked { + // The GAME's tiebreak, not ours: lower Stress first, which both + // ranked modes name as their first key. + seats.sort_by_key(|p| (view.players[p].stress, std::cmp::Reverse(solved(*p)))); + } + + let _ = write!( + s, + "

who did what

mode {mode}", + mode = esc(&format!("{:?}", view.mode)), + ); + if ranked { + let _ = write!( + s, + "
ranked by the game's own tiebreak {tb}", + tb = esc(&tiebreak), + ); + } else { + s.push_str( + "
not ranked \ + this mode's scoring_tiebreak is \u{201c}Not applicable\u{201d} \u{2014} \ + the table succeeds or fails together, so these are contributions, \ + not a leaderboard", + ); + } + s.push_str("
"); + for p in &seats { + let pv = &view.players[p]; + let _ = write!( + s, + "
{name}
\ + problems solved {n}
\ + personal {score}
\ + stress {stress} \ + blame {blame}
", + name = seat_name(*p), + n = solved(*p), + score = outcome + .personal + .get(p) + .map(|v| format!("{v:+}")) + .unwrap_or_else(|| "\u{2014}".into()), + stress = pv.stress, + blame = pv.blame_from.len(), + ); + } + s.push_str("
"); + + // A READING, marked as ours. Only where the game ranks at all, and + // only when there is a clear leader -- a tie is shown as a tie. + if ranked { + let best = seats.iter().map(|p| solved(*p)).max().unwrap_or(0); + let leaders: Vec = seats + .iter() + .filter(|p| solved(**p) == best && best > 0) + .map(|p| seat_name(*p)) + .collect(); + let _ = write!( + s, + "
clay-borg's reading, not a rule {who}
", + who = match leaders.len() { + 0 => "nobody claimed a Problem".to_string(), + 1 => format!("most Problems solved: {}", leaders[0]), + _ => format!("most Problems solved, tied: {}", leaders.join(", ")), + }, + ); + } +} + /// The page a finished game leaves behind (CB-WP-0018 T01). /// /// `view` is `None` when the game ended badly: then there is no result to @@ -1128,10 +1234,23 @@ pub fn ending( s, "\ \ - GROUND — game over\ -

game over

{msg}
", + GROUND — {heading}\ +

{heading}

{msg}
", msg = esc(message), + // CB-WP-0028 T06. "Game over" is arcade vocabulary for a failure + // state. GROUND is co-operative and about repairing a situation: + // when the table clears its threshold it SOLVED something, and + // telling a player "game over" for that describes what they did + // wrongly. A game with no outcome claims neither. + heading = match view.and_then(|v| v.outcome.as_ref()) { + Some(o) if o.group_success => "game solved", + Some(_) => "game over", + None => "the game stopped", + }, ); + // CB-WP-0028 T05: the ending page gets the table's shape — the + // result on the left, everything about the session on the right. + s.push_str("
"); match view { Some(v) => body(&mut s, v), None => { @@ -1141,6 +1260,10 @@ pub fn ending( ); } } + s.push_str("
"); + if let Some(v) = view { + rankings(&mut s, v); + } // 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. @@ -1154,14 +1277,20 @@ pub fn ending( } s.push_str("
"); } - log_section(&mut s, log); + // Observations 6 and 7: the controls and the full log belong beside + // the result, not under it. let _ = write!( s, "
\
play again
\
end session \u{2014} stops the game server
\ -
\ -
the game is over
\ +
", + ); + log_section(&mut s, log); + s.push_str(""); + let _ = write!( + s, + "
the game is over
\ ", endpoint = json_string(endpoint), ); diff --git a/crates/cb-render-html/src/lib.rs b/crates/cb-render-html/src/lib.rs index 5178bb0..6fbb79c 100644 --- a/crates/cb-render-html/src/lib.rs +++ b/crates/cb-render-html/src/lib.rs @@ -1138,6 +1138,89 @@ mod ending_page { ); } + /// **CB-WP-0028 T06, asserted both ways.** A test that only checked + /// the win case would pass for a page that always says "solved". + #[test] + fn a_won_game_is_solved_and_a_lost_one_is_over() { + let mut won = crate::testfix::view(None); + if let Some(o) = won.outcome.as_mut() { + o.group_success = true; + } + let mut lost = crate::testfix::view(None); + if let Some(o) = lost.outcome.as_mut() { + o.group_success = false; + } + let head = |v: &games_ground::view::GroundView| { + crate::text_of(&doc::ending(Some(v), "m", "/command?t=x", &[], &[])) + }; + assert!( + head(&won).contains("game solved"), + "a won game said otherwise" + ); + assert!( + !head(&won).contains("game over"), + "\"game over\" is arcade vocabulary for a failure state" + ); + assert!( + head(&lost).contains("game over"), + "a lost game said otherwise" + ); + assert!(!head(&lost).contains("game solved")); + + // And a game with no outcome claims neither. + let none = crate::text_of(&doc::ending( + None, + "P1 ran out of input", + "/command?t=x", + &[], + &[], + )); + assert!(!none.contains("game solved") && !none.contains("game over")); + } + + /// **CB-WP-0028 T07.** Every ranking names its source, and the + /// co-operative mode is not ranked at all — the game says its + /// tiebreak is "Not applicable", and ranking it anyway would invent + /// scoring the rules do not have. + #[test] + fn a_cooperative_game_shows_contributions_and_refuses_to_rank_them() { + let mut v = crate::testfix::view(None); + v.mode = games_ground::ScoringMode::SharedGround; + let text = crate::text_of(&doc::ending(Some(&v), "m", "/command?t=x", &[], &[])); + + assert!( + text.contains("problems solved"), + "the countable fact is missing" + ); + assert!( + text.contains("not ranked"), + "SHARED GROUND must not be ranked: {text}" + ); + assert!( + !text.contains("clay-borg's reading"), + "a reading must not be offered where the game defines no ranking" + ); + } + + /// And the inverse, or the test above passes for a page that never + /// ranks anything. A ranked mode cites the GAME's tiebreak and marks + /// anything derived as ours. + #[test] + fn a_ranked_mode_cites_the_games_own_tiebreak() { + let mut v = crate::testfix::view(None); + v.mode = games_ground::ScoringMode::BondedCoalitions; + let text = crate::text_of(&doc::ending(Some(&v), "m", "/command?t=x", &[], &[])); + + assert!( + text.contains("Lower combined Stress"), + "the tiebreak shown must be the edition's own words: {text}" + ); + assert!( + text.contains("clay-borg's reading") || text.contains("clay-borg's reading"), + "a derived superlative must be marked as ours, not as a rule" + ); + } + /// 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. diff --git a/workplans/CB-WP-0028-the-table-you-sit-at.md b/workplans/CB-WP-0028-the-table-you-sit-at.md index 95931f3..9b34a8c 100644 --- a/workplans/CB-WP-0028-the-table-you-sit-at.md +++ b/workplans/CB-WP-0028-the-table-you-sit-at.md @@ -208,7 +208,7 @@ as a circle and once as a row. **One table, not two diagrams.** ```task id: CB-WP-0028-T04 -status: todo +status: done priority: medium state_hub_task_id: "1106c60e-112b-401f-b854-eeec566ef3d1" ``` @@ -234,11 +234,38 @@ because SOLVE was offered where it could not act. the same seed produces the same end-state hash, or it is not automation but a rules change. +**Done 2026-08-06 — and NOTHING WAS BUILT, which is the correct outcome.** + +**Clicking the deck is not a legal move.** `GroundCommand` has no +standalone draw, and the edition's own INVESTIGATE text settles it: + +> *"Choose one hidden, non-Denied Problem and reveal it. **Then draw one +> Solution.** If no hidden Problems remain, draw one Solution only."* + +Drawing is a **consequence** of INVESTIGATE, never an action. Implementing +a click-to-draw would have invented a rule — which is what CB-WP-0023 +exists to stop. + +**And observation 5 is already true.** Drawing is *already* automatic: it +happens inside INVESTIGATE with no player input at all. The maintainer +asked for an auto-draw option for a thing that has never been manual. + +**Both observations have the same root cause, and T02 fixed it.** He +expected to take cards from the deck because **nothing on the page said +how drawing works** — the INVESTIGATE card's own text was in a file we had +not imported. It now reads on the card. + +**No finding raised.** The rules are clear and were simply not visible; a +player's instinct differing from a legible rule is a comprehension gap, +not a rules gap. Worth ground-game knowing as design feedback if the +instinct recurs with the text present — **which is now a testable +question and was not before.** + ## Task: the meta column takes the controls and the log ```task id: CB-WP-0028-T05 -status: todo +status: done priority: medium state_hub_task_id: "6c78e44e-38fc-451b-b1a0-205b1d079ca0" ``` @@ -256,11 +283,16 @@ not for the ending page. - the ending page's existing tests pass unchanged or the change is a regression. +**Done 2026-08-06.** The ending page gets the table's two-column shape: +result on the left, rankings + controls + the full log on the right. The +seal still removes every control wherever it now lives, and the existing +ending tests passed unchanged. + ## Task: a game you solve ```task id: CB-WP-0028-T06 -status: todo +status: done priority: medium state_hub_task_id: "df0a967e-636b-4fe5-9926-4d73030a87d7" ``` @@ -280,11 +312,16 @@ win tells the player the wrong thing about what they just did. - the no-outcome case (a game that ended badly) keeps its own wording and claims neither. +**Done 2026-08-06.** `game solved` on a win, `game over` on a loss, **the +game stopped** when there is no outcome — asserted all three ways, because +a test checking only the win case passes for a page that always says +"solved". + ## Task: who did what — rankings that are not invented ```task id: CB-WP-0028-T07 -status: todo +status: done priority: medium state_hub_task_id: "ad6e6f6f-e531-494c-b8a9-18dfdd14681c" ``` @@ -311,6 +348,23 @@ silently canonised. - a ranking that no seat leads (nobody solved anything) renders as that, not as an empty list. +**Done 2026-08-06, and the interesting decision was not to rank.** + +`Modes.csv` gives `scoring_tiebreak` per mode, so the ordering is **the +game's own** where it defines one — *"Lower combined Stress, then fewer +Blame tokens"* for coalitions. **For SHARED GROUND it says "Not +applicable"**, because the table succeeds or fails together. + +So in co-op the page shows **contributions and refuses to order them**, +and says why. Drawing a leaderboard because a leaderboard is easy would +have invented scoring the rules do not have — the same defect as +canonising a provisional default. + +Facts the game counts (Problems claimed, personal score, Stress, Blame) +are shown plainly. The one derived superlative is labelled **"clay-borg's +reading, not a rule"** and appears only where the mode ranks at all. Ties +are shown as ties. + ## Task: evidence ```task