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.