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.