diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 7453729..1c66020 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -40,6 +40,7 @@ | workplan | CB-WP-0030 | done | — | workplans/CB-WP-0030-a-number-that-does-not-move.md | | workplan | CB-WP-0031 | done | — | workplans/CB-WP-0031-the-comment-box-outlives-the-game.md | | workplan | CB-WP-0032 | done | — | workplans/CB-WP-0032-the-comments-in-the-account.md | +| workplan | CB-WP-0033 | done | — | workplans/CB-WP-0033-a-game-is-the-unit.md | | task | CB-WP-0001-T01 | done | — | workplans/CB-WP-0001-inner-loop.md | | task | CB-WP-0001-T02 | done | — | workplans/CB-WP-0001-inner-loop.md | | task | CB-WP-0001-T03 | done | — | workplans/CB-WP-0001-inner-loop.md | @@ -203,3 +204,4 @@ | task | CB-WP-0030-T04 | done | — | workplans/CB-WP-0030-a-number-that-does-not-move.md | | task | CB-WP-0031-T01 | done | — | workplans/CB-WP-0031-the-comment-box-outlives-the-game.md | | task | CB-WP-0032-T01 | done | — | workplans/CB-WP-0032-the-comments-in-the-account.md | +| task | CB-WP-0033-T01 | done | — | workplans/CB-WP-0033-a-game-is-the-unit.md | diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs index 9387227..85a85f4 100644 --- a/crates/cb-render-html/src/doc.rs +++ b/crates/cb-render-html/src/doc.rs @@ -889,17 +889,39 @@ fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView } ); } + // CB-WP-0034. These four were `{:?}` on a player surface, which is + // the defect `selection_words` above was written for -- fixed once, + // for one field, with its four neighbours left as they were. + // + // The `support` line is the one the maintainer reported: it read + // `support AcceptBond`, which names the answer and not the person. if let Some(m) = view.ground_modes.get(&id) { - let _ = write!(out, "ground mode {m:?}
"); + let _ = write!( + out, + "ground mode {}
", + esc(ground_mode_label(m)) + ); } if let Some(c) = view.ground_choices.get(&id) { - let _ = write!(out, "ground choice {c:?}
"); + let _ = write!( + out, + "ground choice {}
", + esc(&ground_choice_label(c)) + ); } if let Some(r) = view.support_responses.get(&id) { - let _ = write!(out, "support {r:?}
"); + let _ = write!( + out, + "support {}
", + esc(&support_words(r, supporter_of(view, Some(id)))) + ); } if let Some(t) = view.darvo_targets.get(&id) { - let _ = write!(out, "darvo target {t:?}
"); + let _ = write!( + out, + "darvo target {}
", + esc(&darvo_target_words(t)) + ); } out.push_str(""); } @@ -989,7 +1011,7 @@ pub fn document_with_log( // game rather than part of it. s.push_str("
"); body(&mut s, view); - move_section(&mut s, legal, seat, may_pass); + move_section(&mut s, view, legal, seat, may_pass); s.push_str("
"); meta_section(&mut s, meta, note_to); log_section(&mut s, log); @@ -1114,9 +1136,19 @@ fn body(s: &mut String, view: &GroundView) { } else { o.coalitions .iter() - .map(|c| format!("{c:?}")) + .map(|c| { + format!( + "{} (score {})", + c.members + .iter() + .map(|m| seat_name(*m)) + .collect::>() + .join(" + "), + c.score + ) + }) .collect::>() - .join(" ") + .join(", ") }; let _ = write!( s, @@ -1159,8 +1191,175 @@ fn action_text(a: games_ground::Action) -> Option) -> Option { + let me = seat?; + view.selections.iter().find_map(|(p, sel)| match sel { + games_ground::view::SelectionView::Shown(s) + if s.action == games_ground::Action::Support && s.target == Some(me) => + { + Some(*p) + } + _ => None, + }) +} + +/// The edition's names for the three GROUND modes (GR-A10..A12). +fn ground_mode_label(m: &games_ground::GroundMode) -> &'static str { + use games_ground::GroundMode as M; + match m { + M::Gr => "GROUND \u{2014} Ground & Restate", + M::Ou => "GROUND \u{2014} Observe & Uphold", + M::Nd => "GROUND \u{2014} Name & Decide", + } +} + +/// A DARVO target, in words. +fn darvo_target_words(t: &games_ground::DarvoTarget) -> String { + match (t.player, t.problem) { + (Some(p), Some(q)) => format!("{}, Problem {q}", seat_name(p)), + (Some(p), None) => seat_name(p), + (None, Some(q)) => format!("Problem {q}"), + (None, None) => "nothing".to_string(), + } +} + +/// What a seat answered a Support with, **and to whom**. +fn support_words(r: &games_ground::SupportResponse, who: Option) -> String { + use games_ground::SupportResponse as R; + // Branch on whether the seat is KNOWN before phrasing, rather than + // substituting a noun phrase into a possessive: the first draft read + // "accepted a seat this view cannot show's Support". + let Some(who) = who.map(seat_name) else { + let what = match r { + R::AcceptBond => "accepted the Support offered \u{2014} formed a Bond", + R::DeclineBond => "declined the Support offered", + R::FlipToBond => "accepted the Support offered \u{2014} Rivalry became a Bond", + R::BreakRivalry => "broke the Rivalry", + }; + return format!("{what} (this view does not show which seat)"); + }; + match r { + R::AcceptBond => format!("accepted {who}\u{2019}s Support \u{2014} Bond with {who}"), + R::DeclineBond => format!("declined {who}\u{2019}s Support"), + R::FlipToBond => format!("accepted {who}\u{2019}s Support \u{2014} Rivalry became a Bond"), + R::BreakRivalry => format!("broke the Rivalry with {who}"), + } +} + +/// A GROUND sub-choice, in words, naming whoever it touches. +fn ground_choice_label(c: &games_ground::GroundChoice) -> String { + use games_ground::GroundChoice as G; + match c { + G::RestoreProblem { problem } => format!("restore Problem {problem}"), + G::CancelAttack { attacker } => { + format!("cancel {}\u{2019}s Attack on you", seat_name(*attacker)) + } + G::ProtectProblem { problem } => format!("protect Problem {problem} from Deny"), + G::RemoveBlame { owner } => format!("remove {}\u{2019}s Blame token", seat_name(*owner)), + G::BreakRelation { with } => format!("break your relation with {}", seat_name(*with)), + G::RejectReverse => "reject the Reverse aimed at you".to_string(), + } +} + +/// A move button\u2019s words \u2014 and **who it is aimed at** (CB-WP-0034). +/// +/// The label was `format!("{c:?}")`. A player deciding whether to accept a +/// Bond read `RespondToSupport { response: AcceptBond }` \u2014 Rust struct +/// syntax with no name anywhere in it \u2014 which is why *"i cant see whos +/// support i accept"* was reported three times across three sessions and +/// survived a whole UI rebuild. +/// +/// **Exhaustive on purpose: there is no catch-all arm.** A new command +/// must be given words or this crate stops compiling. A `_ =>` here is +/// how `Debug` output comes back one variant at a time. +/// +/// **These are clay-borg\u2019s words, not the edition\u2019s**, and the +/// distinction matters (ADR-0015). The Action cards above carry the +/// game\u2019s own `rules_text`; nothing in the vendored files names these +/// control moves, and `Glossary` is still unvendored (F18). If it lands, +/// this is a caller to revisit. +fn command_label( + c: &games_ground::GroundCommand, + view: &GroundView, + seat: Option, +) -> String { + use games_ground::GroundCommand as C; + match c { + C::SelectAction { + action, + target, + problem, + } => { + let mut out = format!("{action:?}").to_uppercase(); + if let Some(t) = target { + let _ = write!(out, " on {}", seat_name(*t)); + } + if let Some(p) = problem { + let _ = write!(out, ", Problem {p}"); + } + out + } + C::SpendFreedom => { + "spend your Freedom token \u{2014} act despite the Stress gate".to_string() + } + C::ChooseGroundMode { mode, choice } => { + let named = ground_mode_label(mode); + match choice { + Some(ch) => format!("{named}: {}", ground_choice_label(ch)), + None => named.to_string(), + } + } + // The one the report was about. + C::RespondToSupport { response } => { + use games_ground::SupportResponse as R; + // Not a guess. If the offer is not visible in this view, the + // page says so rather than inventing a name or quietly + // dropping the question -- a confidently wrong seat is worse + // than an honest gap (ADR-0018). + let Some(who) = supporter_of(view, seat).map(seat_name) else { + let what = match response { + R::AcceptBond => "accept the Support offered to you \u{2014} form a Bond", + R::DeclineBond => "decline the Support offered to you", + R::FlipToBond => { + "accept the Support offered to you \u{2014} turn your Rivalry into a Bond" + } + R::BreakRivalry => "break the Rivalry", + }; + return format!("{what} (this view does not show which seat)"); + }; + match response { + R::AcceptBond => { + format!("accept {who}\u{2019}s Support \u{2014} form a Bond with {who}") + } + R::DeclineBond => { + format!("decline {who}\u{2019}s Support \u{2014} the Stress still applies") + } + R::FlipToBond => { + format!("accept {who}\u{2019}s Support \u{2014} turn your Rivalry into a Bond") + } + R::BreakRivalry => { + format!("break your Rivalry with {who}") + } + } + } + C::ChooseDarvoTarget { target } => { + format!("DARVO target: {}", darvo_target_words(target)) + } + C::Reveal => "reveal all selections".to_string(), + C::Resolve => "resolve the revealed Actions".to_string(), + C::EndRound => "end the round".to_string(), + } +} + fn move_section( s: &mut String, + view: &GroundView, legal: &[games_ground::GroundCommand], seat: Option, may_pass: bool, @@ -1233,7 +1432,7 @@ fn move_section( let _ = write!( s, "
{}
", - esc(&format!("{c:?}")) + esc(&command_label(c, view, seat)) ); } } diff --git a/crates/cb-render-html/src/lib.rs b/crates/cb-render-html/src/lib.rs index 3885e1f..942acdc 100644 --- a/crates/cb-render-html/src/lib.rs +++ b/crates/cb-render-html/src/lib.rs @@ -163,18 +163,28 @@ mod coverage { ("selections.*.action", "selected Attack"), ("selections.*.target", "Attack on P2"), ("selections.*.problem", "for problem 7"), - ("ground_modes.*", "ground mode Gr"), - ("ground_choices.*.choice", "ProtectProblem"), - ("ground_choices.*.problem", "problem: 7 }"), - ("support_responses.*", "support AcceptBond"), - ("darvo_targets.*.problem", "problem: Some(1)"), - ("darvo_targets.*.player", "player: Some(PlayerId(1))"), + // CB-WP-0034: words, not Debug — and these probes are the reason + // it survived so long. They matched `problem: 7 }`, + // `player: Some(PlayerId(1))` and `members: [PlayerId(1)`, so the + // gate that exists to prove every field reaches the PLAYER was + // proving it by matching Rust struct syntax. It certified the + // defect as coverage. + // + // Second confirmation of CB-WP-0024's finding, from the other + // side: a probe naming a PRESENTATION does not survive a + // rendering change, and one naming Debug output actively pins it. + ("ground_modes.*", "Ground & Restate"), + ("ground_choices.*.choice", "protect Problem"), + ("ground_choices.*.problem", "Problem 7 from Deny"), + ("support_responses.*", "accepted P3\u{2019}s Support"), + ("darvo_targets.*.problem", "Problem 1"), + ("darvo_targets.*.player", "darvo target P2"), ("outcome.total", "total 9"), ("outcome.threshold", "of 12"), ("outcome.group_success", "failure"), ("outcome.personal.*", "P1 +3"), - ("outcome.coalitions.*.members.*", "members: [PlayerId(1)"), - ("outcome.coalitions.*.score", "score: 4"), + ("outcome.coalitions.*.members.*", "P2 + P3"), + ("outcome.coalitions.*.score", "(score 4)"), ("outcome.mastery", "mastery +1"), ("outcome.winners.*", "winners P1"), ]; @@ -598,6 +608,151 @@ mod gamelog { } } + /// CB-WP-0034. The move button must name **who**. + /// + /// Reported three times across three sessions, two days apart, and it + /// survived a whole UI rebuild: *"RespondToSupport is unclear as i + /// cant see whos support i accept"*, then twice more about bonding. + /// + /// The cause was not styling. The label was `format!("{c:?}")`, so + /// the button read `RespondToSupport { response: AcceptBond }` — Rust + /// struct syntax with no name in it — and the command **does not + /// carry** the counterparty, so nothing rendering the command alone + /// could have said who. It comes off the view. + #[test] + fn a_support_response_names_the_seat_that_offered_it() { + use games_ground::view::SelectionView; + use games_ground::{Action, GroundCommand, Selection, SupportResponse}; + let me = PlayerId(0); + let them = PlayerId(1); + let mut v = crate::testfix::view(Some(me)); + // P2 played Support at P1, revealed. + v.selections.insert( + them, + SelectionView::Shown(Selection { + action: Action::Support, + target: Some(me), + problem: None, + }), + ); + let legal = vec![ + GroundCommand::RespondToSupport { + response: SupportResponse::AcceptBond, + }, + GroundCommand::RespondToSupport { + response: SupportResponse::DeclineBond, + }, + ]; + let html = document_with_log( + &v, + &legal, + crate::TEST_ENDPOINTS, + Some(me), + false, + Account::of(&[]), + &[], + ); + let text = crate::text_of(&html); + assert!(text.contains("accept P2"), "the offer is anonymous: {text}"); + assert!( + text.contains("decline P2"), + "the refusal is anonymous: {text}" + ); + // The defect itself, named: no Rust struct syntax on a button. + assert!( + !text.contains("RespondToSupport"), + "the raw command name is still on the page: {text}" + ); + assert!( + !text.contains("AcceptBond"), + "the raw variant name is still on the page: {text}" + ); + } + + /// When the offer is not visible in this view, the page says so. + /// + /// **It must not invent a name and must not drop the question.** A + /// confident wrong seat is worse than an honest gap — that is the + /// family ADR-0018 exists for. + #[test] + fn an_unseen_offer_is_not_given_an_invented_name() { + use games_ground::{GroundCommand, SupportResponse}; + let me = PlayerId(0); + let mut v = crate::testfix::view(Some(me)); + v.selections.clear(); + let legal = vec![GroundCommand::RespondToSupport { + response: SupportResponse::AcceptBond, + }]; + let text = crate::text_of(&document_with_log( + &v, + &legal, + crate::TEST_ENDPOINTS, + Some(me), + false, + Account::of(&[]), + &[], + )); + assert!( + text.contains("this view does not show which seat"), + "it should say the offer is not visible: {text}" + ); + for name in ["P1", "P2", "P3"] { + assert!( + !text.contains(&format!("accept {name}")), + "it named {name} with nothing to go on: {text}" + ); + } + } + + /// No move button may carry Rust `Debug` output (CB-WP-0034). + /// + /// The guard against this returning is that `command_label` has no + /// catch-all arm, so a new variant stops the build. This asserts the + /// visible half over every command the fixture can offer. + #[test] + fn no_move_button_shows_rust_struct_syntax() { + use games_ground::*; + let me = PlayerId(0); + let legal = vec![ + GroundCommand::SpendFreedom, + GroundCommand::ChooseGroundMode { + mode: GroundMode::Ou, + choice: Some(GroundChoice::ProtectProblem { problem: 7 }), + }, + GroundCommand::ChooseDarvoTarget { + target: DarvoTarget { + problem: Some(1), + player: Some(PlayerId(1)), + }, + }, + GroundCommand::RespondToSupport { + response: SupportResponse::FlipToBond, + }, + ]; + let text = crate::text_of(&document_with_log( + &crate::testfix::view(Some(me)), + &legal, + crate::TEST_ENDPOINTS, + Some(me), + false, + Account::of(&[]), + &[], + )); + // The tells of a `{:?}` label reaching a player. + for tell in ["{ ", " }", "Some(", "None", "PlayerId("] { + assert!( + !text.contains(tell), + "Debug output {tell:?} reached the page: {text}" + ); + } + // And the words are actually there. `SpendFreedom` is not among + // them: it carries a spatial affordance, so it is a drag target + // rather than a button -- which is why this asserts on the + // commands that DO become buttons. + assert!(text.contains("Observe & Uphold"), "{text}"); + assert!(text.contains("DARVO target: P2, Problem 1"), "{text}"); + } + fn note(after: usize, text: &str) -> crate::doc::LogNote { crate::doc::LogNote { after, diff --git a/crates/cb-render-html/src/testfix.rs b/crates/cb-render-html/src/testfix.rs index d252e07..177b495 100644 --- a/crates/cb-render-html/src/testfix.rs +++ b/crates/cb-render-html/src/testfix.rs @@ -89,6 +89,18 @@ pub fn view(viewer: Option) -> GroundView { }), ), (p2, SelectionView::Hidden), + // CB-WP-0034: P3 offered P2 their Support, revealed. The + // fixture now carries the case the report was about, so the + // coverage probe for `support_responses` can name a SEAT + // rather than a response with nobody attached to it. + ( + p3, + SelectionView::Shown(Selection { + action: Action::Support, + target: Some(p2), + problem: None, + }), + ), ]), ground_modes: BTreeMap::from([(p3, GroundMode::Gr)]), ground_choices: BTreeMap::from([(p3, GroundChoice::ProtectProblem { problem: 7 })]), diff --git a/workplans/CB-WP-0033-a-game-is-the-unit.md b/workplans/CB-WP-0033-a-game-is-the-unit.md index 6dcc8a0..15625fa 100644 --- a/workplans/CB-WP-0033-a-game-is-the-unit.md +++ b/workplans/CB-WP-0033-a-game-is-the-unit.md @@ -3,6 +3,7 @@ id: CB-WP-0033 kind: product title: "A game is the unit" status: done +state_hub_workstream_id: "66c54551-38cd-465e-bb2f-9e1d83e14438" --- # Purpose @@ -51,6 +52,7 @@ session still destroyed its own evidence.** [ADR-0019](../decisions/ADR-0019-a-g id: CB-WP-0033-T01 status: done priority: high +state_hub_task_id: "b0b4b9f3-f99f-4b4d-8eed-ba2fa4f9ea98" ``` **Controls:** diff --git a/workplans/CB-WP-0034-who-you-are-bonding-with.md b/workplans/CB-WP-0034-who-you-are-bonding-with.md new file mode 100644 index 0000000..65f3490 --- /dev/null +++ b/workplans/CB-WP-0034-who-you-are-bonding-with.md @@ -0,0 +1,109 @@ +--- +id: CB-WP-0034 +kind: product +title: "Who you are bonding with" +status: done +--- + +# Purpose + +``` +structural tier S (rendering only -- no rule, no dependency, no budget, + no artifact contract moved) +chaos d8 = 4 → no override +declared tier S +``` + +**Declaration 5 of chaos window 3.** + +## The report, three times + +> *"RespondToSupport is unclear as i cant see whos support i accept."* +> — 2026-08-06 +> +> *"i can not [see] the player i bond or not-bond with. We should change +> that."* — 2026-08-07 15:58 +> +> *"But i **still** cant see the player i am bonding with."* — 2026-08-07 16:12 + +Three sessions, two days, and it **survived a whole UI rebuild**. + +## It was not styling + +The move button's label was `format!("{c:?}")`. The player was reading + +``` +RespondToSupport { response: AcceptBond } +``` + +— Rust struct syntax, with **no name anywhere in it**. And +`GroundCommand::RespondToSupport { response }` **does not carry the +counterparty**, so nothing rendering the command alone could have named +them. It comes off the view: whoever played `Support` targeting this seat. + +**Four more fields on the seat panel had the same defect**, including the +one the report names — `support AcceptBond`, an answer with nobody +attached. CB-WP-0020 fixed exactly this for `selections` and left its four +neighbours as they were. + +## Task: name who + +```task +id: CB-WP-0034-T01 +status: done +priority: high +``` + +**Controls:** +- **the seat is named**, and the raw command name never reaches the page; +- **an unseen offer is not given an invented name** — a confidently wrong + seat is worse than an honest gap (ADR-0018); +- **no catch-all arm**, so a new command cannot silently fall back to + `Debug`; +- mutation-proven both ways. + +**Done 2026-08-07.** + +**The exhaustiveness control paid for itself immediately**, before any +test ran: `GroundChoice::RejectReverse` and +`SupportResponse::BreakRivalry` both failed to compile. Two moves that +would have shipped as struct dumps, caught by the absence of a `_ =>`. + +**These are clay-borg's words, not the edition's.** The Action cards carry +the game's own `rules_text` (ADR-0015); nothing vendored names these +control moves, and `Glossary` is unvendored (F18). If it lands, this is a +caller to revisit. + +## The finding underneath: a gate that pinned the defect + +`every_view_field_is_classified_in_the_emitted_document` — the probe that +exists to prove **every view field reaches the player** — was matching + +``` +"problem: 7 }" "player: Some(PlayerId(1))" "members: [PlayerId(1)" +``` + +**It was proving the field reached the page by matching Rust struct +syntax.** A gate written to protect the player's view of the game had +been certifying the defect as coverage, and would have gone red if +anyone had fixed it. + +**Second confirmation of CB-WP-0024's finding, from the other side.** That +pass found a probe naming a *presentation* does not survive a rendering +change. This is the sharper case: a probe naming *Debug output* actively +holds the defect in place. + +Probes now name facts — `Ground & Restate`, `P2 + P3`, `accepted P3's +Support`. The fixture gained a visible Support offer so the probe can name +a seat rather than a response with nobody attached. + +## Not done here + +- **The unknown-supporter path is untested against a real game.** It is + reachable only if `RespondToSupport` is offered while the Support is not + visible in the responder's view; whether the rules permit that is a + question for `ground-game`, and the page currently says it cannot see + rather than guessing. +- **Other Debug output may remain outside the two surfaces audited here** + (the move buttons and the seat panel). The exhaustive match guards + commands only.