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