From 3174c5507ae48a3434e3f4de62764e37e37a6e2e Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 8 Aug 2026 17:30:14 +0200 Subject: [PATCH] CB-WP-0043: a Problem sits where its Stress lands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picture had become a lie. A row across the middle says "these are the table's" — true under the baseline, false under H2, where three of five cards fall on particular seats. Global goes to the middle, personal beside its owner, and bond on the mean midpoint of the owner's Bond edges. A bond Problem whose owner has no Bond is drawn as personal, because that is the rule — bond_network falls back to the owner at degree 0 — and the picture must agree with the arithmetic. The baseline page is untouched: the scoped layout is taken only when a non-global scope exists, so every prior look at the baseline still holds. The scope reaches the view as a separate marker rather than a field on ProblemView, because the delta says "place owner marker on the card" — a token beside a card — and it is public while the card is face down, which a ProblemView variant could not express. The coverage probe forced a real improvement. It demanded a text token for the new fields and a POSITION is not a token — which is the probe being right: position alone is invisible to text_of and to a screen reader, and illegible when two anchors coincide. So each scoped Problem now says whose it is: everyone's, P1's alone, P2's Bond network, and "P1's alone — no Bond to share it" at degree 0. The fixture gained a third Problem. With two, one of the three placement rules was unexercised and the probe unsatisfiable — a fixture that cannot reach a branch is how a rule ships untested. Co-Authored-By: Claude Opus 5 --- crates/cb-render-html/src/doc.rs | 167 ++++++++++++++++-- crates/cb-render-html/src/lib.rs | 79 +++++++++ crates/cb-render-html/src/testfix.rs | 30 ++++ games/ground/src/view.rs | 30 ++++ tools/cb-play/src/inspect.rs | 1 + ...3-a-problem-sits-where-its-stress-lands.md | 78 ++++++++ 6 files changed, 373 insertions(+), 12 deletions(-) create mode 100644 workplans/CB-WP-0043-a-problem-sits-where-its-stress-lands.md diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs index 0212411..e04d4c1 100644 --- a/crates/cb-render-html/src/doc.rs +++ b/crates/cb-render-html/src/doc.rs @@ -682,19 +682,38 @@ fn table_svg(view: &GroundView) -> String { } } - // On the table: Problems across the middle, the two stacks under them. - let count = view.problems.len().max(1) as f64; - let scale = (2.0 * rx * 0.92 / (count * 130.0)).min(0.82); - let _ = write!( - s, - "", - x = cx - count * 130.0 * scale / 2.0, - y = cy - ry + 24.0, - ); - for (i, (priority, p)) in view.problems.iter().enumerate() { - problem_svg(&mut s, *priority, p, (i as i32) * 130); + // **Where a Problem sits says who it falls on** (CB-WP-0043). + // + // H2 scopes End-of-Round Stress: `global` hits everyone, `personal` + // its owner, `bond` the owner's Bond network. A row across the middle + // says "these are the table's" — true under the baseline, and a lie + // under H2, where three of five cards belong to particular seats. + // + // **This shows mechanism; it does not invent any.** The scope and + // owner come off the state; only the position is ours + // (`specs/Ornamentation.md`). + let scoped = view.problem_markers.values().any(|m| { + m.scope + .is_some_and(|x| x != games_ground::edition::StressScope::Global) + }); + if !scoped { + // Baseline, unchanged: a row across the middle, because under + // the baseline every Problem really is the table's. + let count = view.problems.len().max(1) as f64; + let scale = (2.0 * rx * 0.92 / (count * 130.0)).min(0.82); + let _ = write!( + s, + "", + x = cx - count * 130.0 * scale / 2.0, + y = cy - ry + 24.0, + ); + for (i, (priority, p)) in view.problems.iter().enumerate() { + problem_svg(&mut s, *priority, p, (i as i32) * 130); + } + s.push_str(""); + } else { + scoped_problems(&mut s, view, &pos, cx, cy, ry); } - s.push_str(""); let _ = write!( s, "", @@ -1116,6 +1135,130 @@ fn meta_section(s: &mut String, meta: &[String], note_to: &str) { s.push_str(""); } +/// Place each Problem where its Stress lands (CB-WP-0043). +/// +/// | scope | where | +/// |---|---| +/// | `global` | the middle of the table — it is everyone's | +/// | `personal` | beside its owner, between that seat and the table | +/// | `bond` | on the owner's Bond lines: the mean midpoint of each Bond edge from the owner | +/// +/// **A bond card with no Bonds is drawn as personal**, which is not a +/// rendering convenience — it is the rule. `bond_network` falls back to +/// the owner alone at degree 0, so the picture and the arithmetic agree. +fn scoped_problems( + s: &mut String, + view: &GroundView, + pos: &[(PlayerId, f64, f64)], + cx: f64, + cy: f64, + ry: f64, +) { + use games_ground::edition::StressScope; + let seat_at = |p: PlayerId| { + pos.iter() + .find(|(q, _, _)| *q == p) + .map(|(_, x, y)| (*x, *y)) + }; + + // Bond partners of a seat, for the `bond` anchor. One hop, because + // the LINES are what the card sits on and a line is one edge. + let partners = |owner: PlayerId| -> Vec<(f64, f64)> { + view.relations + .iter() + .filter(|(_, rel)| **rel == games_ground::Relation::Bond) + .filter_map(|(pair, _)| { + let other = if pair.0 == owner { + Some(pair.1) + } else if pair.1 == owner { + Some(pair.0) + } else { + None + }; + other.and_then(seat_at) + }) + .collect() + }; + + // Stack cards that land on the same anchor, so two personal Problems + // on one seat do not draw on top of each other. + let mut used: Vec<(f64, f64)> = Vec::new(); + for (priority, p) in &view.problems { + let marker = view.problem_markers.get(priority).copied(); + let owner = marker.and_then(|m| m.owner); + let (ax, mut ay) = match (marker.and_then(|m| m.scope), owner.and_then(seat_at)) { + // Everyone's: the middle. + (Some(StressScope::Global), _) | (None, _) => (cx, cy - ry * 0.30), + // The owner's: between that seat and the table, so it reads + // as theirs without leaving the felt. + (Some(StressScope::Personal), Some((sx, sy))) => { + (cx + (sx - cx) * 0.52, cy + (sy - cy) * 0.52) + } + // The network's: on the Bond lines out of the owner. + (Some(StressScope::Bond), Some((sx, sy))) => { + let ps = owner.map(partners).unwrap_or_default(); + if ps.is_empty() { + // Degree 0 — the rule says personal, so does the picture. + (cx + (sx - cx) * 0.52, cy + (sy - cy) * 0.52) + } else { + let n = ps.len() as f64; + let mx: f64 = ps.iter().map(|(x, _)| (sx + x) / 2.0).sum::() / n; + let my: f64 = ps.iter().map(|(_, y)| (sy + y) / 2.0).sum::() / n; + (mx, my) + } + } + // Scoped but ownerless: treat as the table's. + (Some(_), None) => (cx, cy - ry * 0.30), + }; + while used + .iter() + .any(|(ux, uy)| (ux - ax).abs() < 40.0 && (uy - ay).abs() < 30.0) + { + ay += 30.0; + } + used.push((ax, ay)); + + // **Say it, do not only place it.** Position is not legible when + // two anchors coincide and is invisible to `text_of` and to a + // screen reader — so whose Problem this is, is written as well as + // shown. The coverage probe asks for exactly this. + let whose = match (marker.and_then(|m| m.scope), owner) { + (Some(StressScope::Global), _) | (None, _) => "everyone\u{2019}s".to_string(), + (Some(StressScope::Personal), Some(o)) => { + format!("{}\u{2019}s alone", seat_name(o)) + } + (Some(StressScope::Bond), Some(o)) => { + if owner.map(partners).unwrap_or_default().is_empty() { + format!( + "{}\u{2019}s alone \u{2014} no Bond to share it", + seat_name(o) + ) + } else { + format!("{}\u{2019}s Bond network", seat_name(o)) + } + } + (Some(_), None) => "everyone\u{2019}s".to_string(), + }; + + let scale = 0.46; + let _ = write!( + s, + "{}", + esc(&whose), + x = ax - 120.0 * scale / 2.0, + y = ay - 78.0 * scale / 2.0, + ); + problem_svg(s, *priority, p, 0); + let _ = write!( + s, + "{}", + esc(&whose) + ); + s.push_str(""); + } +} + /// The table itself: problems, relationships, seats, solutions, outcome. /// /// Factored out of [`document`] so [`ending`] shows the SAME table rather diff --git a/crates/cb-render-html/src/lib.rs b/crates/cb-render-html/src/lib.rs index cce3c29..efacefd 100644 --- a/crates/cb-render-html/src/lib.rs +++ b/crates/cb-render-html/src/lib.rs @@ -108,6 +108,7 @@ mod coverage { "players", "relations", "problems", + "problem_markers", "focus", "selections", "ground_modes", @@ -180,6 +181,14 @@ mod coverage { ("support_responses.*", "accepted P3\u{2019}s Support"), ("darvo_targets.*.problem", "Problem 1"), ("darvo_targets.*.player", "darvo target P2"), + // CB-WP-0043: H2's owner marker. The scope is expressed as + // POSITION, which no text probe can see — so each scoped Problem + // also says whose it is, and that is what these match. A probe + // that could only be satisfied by geometry would have to be + // OMITTED, and the page would be illegible to `text_of` and to a + // screen reader alike. + ("problem_markers.*.owner", "P1\u{2019}s alone"), + ("problem_markers.*.scope", "P2\u{2019}s Bond network"), ("outcome.total", "total 9"), ("outcome.threshold", "of 12"), ("outcome.group_success", "failure"), @@ -687,6 +696,76 @@ mod gamelog { } } + /// **A Problem is drawn where its Stress lands** (CB-WP-0043). + /// + /// H2 scopes End-of-Round Stress, so a row across the middle — true + /// under the baseline — becomes a lie: three of five cards belong to + /// particular seats. This checks the placement rule rather than the + /// fact that something was drawn. + #[test] + fn a_problem_sits_where_its_stress_lands() { + use games_ground::edition::StressScope; + use games_ground::view::ProblemMarker; + + let v = crate::testfix::view(Some(PlayerId(0))); + let svg = crate::doc::document(&v, &[], "/c", Some(PlayerId(0)), false); + + // Each scope says whose it is — position alone is invisible to + // `text_of` and to a screen reader. + let text = crate::text_of(&svg); + assert!( + text.contains("everyone\u{2019}s"), + "the global Problem is unlabelled: {text}" + ); + assert!( + text.contains("P1\u{2019}s alone"), + "the personal Problem is unlabelled" + ); + assert!( + text.contains("P2\u{2019}s Bond network"), + "the bond Problem is unlabelled" + ); + + // And the anchors really differ: a global Problem must not be + // drawn where a personal one is. + let mut baseline = v.clone(); + baseline.problem_markers.clear(); + let row = crate::doc::document(&baseline, &[], "/c", Some(PlayerId(0)), false); + assert_ne!( + row, svg, + "the scoped layout is identical to the baseline row — scope is not \ + reaching the placement" + ); + assert!( + !crate::text_of(&row).contains("P1\u{2019}s alone"), + "the baseline page labels Problems with owners it does not have" + ); + + // A bond Problem whose owner has no Bond says so, because the + // RULE falls back to personal at degree 0 and the picture must + // agree with the arithmetic. + let mut lonely = v.clone(); + lonely.relations.clear(); + lonely.problem_markers.insert( + 7, + ProblemMarker { + owner: Some(PlayerId(1)), + scope: Some(StressScope::Bond), + }, + ); + let alone = crate::text_of(&crate::doc::document( + &lonely, + &[], + "/c", + Some(PlayerId(0)), + false, + )); + assert!( + alone.contains("no Bond to share it"), + "a bond Problem with no Bonds should read as personal: {alone}" + ); + } + /// CB-WP-0034. The move button must name **who**. /// /// Reported three times across three sessions, two days apart, and it diff --git a/crates/cb-render-html/src/testfix.rs b/crates/cb-render-html/src/testfix.rs index 177b495..1ebb887 100644 --- a/crates/cb-render-html/src/testfix.rs +++ b/crates/cb-render-html/src/testfix.rs @@ -55,6 +55,10 @@ pub fn view(viewer: Option) -> GroundView { let mut problems = BTreeMap::new(); problems.insert(1, ProblemView::FaceDown); + // CB-WP-0043: a third Problem so all three H2 scopes are drawn — + // global (1), personal (6) and bond (7). Two would leave one + // placement rule unexercised and the coverage probe unsatisfiable. + problems.insert(6, ProblemView::FaceDown); problems.insert( 7, ProblemView::FaceUp { @@ -102,6 +106,32 @@ pub fn view(viewer: Option) -> GroundView { }), ), ]), + // CB-WP-0043: the fixture carries H2 markers, so the scoped + // layout is exercised — a fixture with none would leave the new + // placement untested and the row layout would always be taken. + problem_markers: BTreeMap::from([ + ( + 1, + games_ground::view::ProblemMarker { + owner: None, + scope: Some(games_ground::edition::StressScope::Global), + }, + ), + ( + 6, + games_ground::view::ProblemMarker { + owner: Some(p1), + scope: Some(games_ground::edition::StressScope::Personal), + }, + ), + ( + 7, + games_ground::view::ProblemMarker { + owner: Some(p2), + scope: Some(games_ground::edition::StressScope::Bond), + }, + ), + ]), ground_modes: BTreeMap::from([(p3, GroundMode::Gr)]), ground_choices: BTreeMap::from([(p3, GroundChoice::ProtectProblem { problem: 7 })]), support_responses: BTreeMap::from([(p2, SupportResponse::AcceptBond)]), diff --git a/games/ground/src/view.rs b/games/ground/src/view.rs index 7279765..02e6832 100644 --- a/games/ground/src/view.rs +++ b/games/ground/src/view.rs @@ -44,6 +44,9 @@ pub struct GroundView { pub players: BTreeMap, pub relations: BTreeMap, pub problems: BTreeMap, + /// H2: who each Problem's Stress falls on. Empty under the baseline. + #[serde(default)] + pub problem_markers: BTreeMap, pub focus: BTreeMap, /// GR-R02: `Hidden` for other seats until Reveal. pub selections: BTreeMap, @@ -86,6 +89,18 @@ pub enum ProblemView { }, } +/// H2's owner marker and scope, for one Problem (CB-WP-0043). +/// +/// **Separate from `ProblemView`, because it is a separate component.** +/// The delta says *"place owner marker on the card"* — a token beside a +/// card, not part of it — and it is **public even while the card is face +/// down**, which a variant of `ProblemView` could not express. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProblemMarker { + pub owner: Option, + pub scope: Option, +} + /// GR-R02/R04: face-down means face-down, including to the projection. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "state")] @@ -176,6 +191,21 @@ impl Project for GroundState { .collect(), relations: self.relations.clone(), problems: self.problems.iter().map(|(k, p)| (*k, p.into())).collect(), + // Public regardless of the card's face: at a table the owner + // marker sits beside it where everyone can see. + problem_markers: self + .problems + .iter() + .map(|(k, p)| { + ( + *k, + ProblemMarker { + owner: p.owner, + scope: p.scope, + }, + ) + }) + .collect(), focus: self.focus.clone(), selections: self .selections diff --git a/tools/cb-play/src/inspect.rs b/tools/cb-play/src/inspect.rs index 6085d6d..1ede001 100644 --- a/tools/cb-play/src/inspect.rs +++ b/tools/cb-play/src/inspect.rs @@ -616,6 +616,7 @@ mod tests { (Pair::new(p2, p3), Relation::Rivalry), ]), problems, + problem_markers: Default::default(), focus: BTreeMap::from([(p1, p3)]), selections: BTreeMap::from([ ( diff --git a/workplans/CB-WP-0043-a-problem-sits-where-its-stress-lands.md b/workplans/CB-WP-0043-a-problem-sits-where-its-stress-lands.md new file mode 100644 index 0000000..aa5fce1 --- /dev/null +++ b/workplans/CB-WP-0043-a-problem-sits-where-its-stress-lands.md @@ -0,0 +1,78 @@ +--- +id: CB-WP-0043 +kind: product +title: "A Problem sits where its Stress lands" +status: done +--- + +# Purpose + +``` +structural tier M (adds a field to the projection, which is a + canonical interface) +declared tier M +``` + +**No chaos roll** — retired by +[ADR-0021](../decisions/ADR-0021-the-chaos-roll-is-retired.md). + +## The report + +> *"We need to place the problems next to the player that is affected. +> Only global problems affecting all should go to the middle of the table. +> Problems attached to people that also affect their bonds should be +> placed close to the bond lines of the player."* + +**The picture had become a lie.** A row of Problems across the middle says +*these are the table's*, which is true under the baseline and false under +H2, where three of five cards fall on particular seats. + +## Task: place them where the Stress lands + +```task +id: CB-WP-0043-T01 +status: done +priority: high +``` + +| scope | where | +|---|---| +| `global` | the middle — it really is everyone's | +| `personal` | beside its owner, 52% of the way from the seat to the table centre | +| `bond` | the mean midpoint of the owner's Bond edges — *on the lines* | + +**Controls:** +- **the baseline page is unchanged.** A variant that redraws the baseline + invalidates every prior look at it — the scoped layout is taken only + when a non-global scope exists; +- **a bond Problem with no Bonds is drawn as personal**, because that is + the *rule* (`bond_network` falls back to the owner at degree 0) and the + picture must agree with the arithmetic; +- the placement is asserted, not the fact that something was drawn. + +**Done 2026-08-08.** + +**The scope reaches the view as a separate marker**, not as a field on +`ProblemView`: the delta says *"place owner marker on the card"* — a token +beside a card — and it is **public while the card is face down**, which a +variant of `ProblemView` could not express. + +**The coverage probe forced a real improvement.** It demanded a text token +for the new fields, and a *position* is not a token. That is the probe +being right: **position alone is invisible to `text_of` and to a screen +reader, and illegible when two anchors coincide.** So each scoped Problem +now *says* whose it is — "everyone's", "P1's alone", "P2's Bond network", +and "P1's alone — no Bond to share it" at degree 0. + +**The fixture gained a third Problem.** With two, one of the three +placement rules was unexercised and the probe unsatisfiable — a fixture +that cannot reach a branch is how a rule ships untested. + +## Not done + +- **Nothing tells a player what the scope *means*** — that an unclaimed + personal card ticks only its owner. The position and the label say + *whose*, not *what happens*. That is a rules-legibility question and + the edition's `Rules_Text.csv` for H2 is vendored and unread. +- **No felt-play.** Whether the placement reads as intended at a table is + exactly the class ADR-0010 D5 says only a human answers.