CB-WP-0043: a Problem sits where its Stress lands
Some checks failed
ci / check (push) Failing after 4s
Some checks failed
ci / check (push) Failing after 4s
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 <noreply@anthropic.com>
This commit is contained in:
parent
47e7941057
commit
3174c5507a
6 changed files with 373 additions and 12 deletions
|
|
@ -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,
|
||||
"<g transform=\"translate({x:.0},{y:.0}) scale({scale:.3})\">",
|
||||
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,
|
||||
"<g transform=\"translate({x:.0},{y:.0}) scale({scale:.3})\">",
|
||||
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("</g>");
|
||||
} else {
|
||||
scoped_problems(&mut s, view, &pos, cx, cy, ry);
|
||||
}
|
||||
s.push_str("</g>");
|
||||
let _ = write!(
|
||||
s,
|
||||
"<g transform=\"translate({x:.0},{y:.0}) scale(0.62)\">",
|
||||
|
|
@ -1116,6 +1135,130 @@ fn meta_section(s: &mut String, meta: &[String], note_to: &str) {
|
|||
s.push_str("</div>");
|
||||
}
|
||||
|
||||
/// 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::<f64>() / n;
|
||||
let my: f64 = ps.iter().map(|(_, y)| (sy + y) / 2.0).sum::<f64>() / 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,
|
||||
"<g transform=\"translate({x:.0},{y:.0}) scale({scale})\"><title>{}</title>",
|
||||
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,
|
||||
"<text x=\"60\" y=\"104\" fill=\"#9cf\" font-size=\"22\" \
|
||||
text-anchor=\"middle\">{}</text>",
|
||||
esc(&whose)
|
||||
);
|
||||
s.push_str("</g>");
|
||||
}
|
||||
}
|
||||
|
||||
/// The table itself: problems, relationships, seats, solutions, outcome.
|
||||
///
|
||||
/// Factored out of [`document`] so [`ending`] shows the SAME table rather
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue