CB-WP-0042 T01-T04: H2's scoped stress, with the named defects caught
Some checks failed
ci / check (push) Failing after 4s

H2 is ground-game's answer to our H1 reading — that a flat +1 to every
seat is a solve-rate tax scaling with the number of Problems. Unclaimed
Problems now tick only the seats in scope: global (all), personal (the
owner), bond (the owner's Bond network over Bond edges only, degree 0
falling back to personal), assigned by hidden priority so 2p never has the
bond card in play.

T01: the package is vendored with digests, and H2's Problems.csv is r0's
with one column added and NOTHING else changed — checked, not assumed,
because the delta claims deal_and_thresholds unchanged and a silent
difference would make every H2-vs-baseline comparison a comparison of two
boards as well as two rule sets. Scopes are read from the column, not
derived from the priority in Rust: F25 exists because we hardcoded numbers
the edition already carried.

T02: owner and scope are new ProblemState fields, both Option and both
skipped when None, so a baseline state serialises without them and every
recorded scenario's hash is untouched — asserted on the JSON, not assumed.
with_variant() replaces the bare field write, because state.variant = v
would leave owners unassigned: a silently wrong game rather than a failing
one.

T03: every named defect is mutation-proven — traversing Rivalry edges,
applying stacking once, a degree-0 owner ticking everyone, personal
hitting everyone. The degree-0 mutation MISSED first: the fallback lives
inside bond_network and the mutation broke the None-owner arm instead, a
different branch. It stayed green until aimed at the path the test
exercises. A mutation that misses is not evidence the test works.

T04: ownership is not a permission. Filtering SOLVE to the owner turns it
red, which is the regression this task exists for — the engine had no
owner concept before T02 added one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-08 16:06:21 +02:00
parent 05d1a9aada
commit 04e26b3077
9 changed files with 900 additions and 7 deletions

View file

@ -11,6 +11,8 @@
//! is wrong and `csv` is the answer (ADR-0011 D1).
use crate::{SolutionCard, Suit};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// One Problem as the edition prints it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -77,6 +79,9 @@ const RELATIONS_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Rela
const SCENARIOS_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Scenarios.csv");
const PLAYER_MATS_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Player_Mats.csv");
const GLOSSARY_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Glossary.csv");
/// H2's Problems table (CB-WP-0042). r0's with one column added.
const H2_PROBLEMS_CSV: &str =
include_str!("../../../editions/experiments/h2-scoped-problem-stress/Problems.csv");
/// A vendored CSV, parsed into rows addressable by column name.
///
@ -504,6 +509,59 @@ pub fn scenarios() -> Result<Vec<ScenarioText>, String> {
Ok(out)
}
/// Who an unclaimed Problem's End-of-Round Stress falls on (H2-SCOPE).
///
/// **Read from the edition, never derived from the priority.** The delta
/// states a priority→scope mapping and `Problems.csv` carries the
/// column; F25 exists because we hardcoded numbers the edition already
/// held, and this is the same shape.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum StressScope {
/// All seats.
Global,
/// The Problem's owner alone.
Personal,
/// The owner's Bond network — Bond edges only, never Rivalry.
Bond,
}
impl std::str::FromStr for StressScope {
type Err = String;
fn from_str(s: &str) -> Result<Self, String> {
match s.trim() {
"global" => Ok(StressScope::Global),
"personal" => Ok(StressScope::Personal),
"bond" => Ok(StressScope::Bond),
other => Err(format!(
"unknown stress_scope {other:?} (global, personal, bond)"
)),
}
}
}
/// H2's scope per hidden priority, for one scenario.
///
/// Keyed by `hidden_priority` because that is what survives the deal —
/// `EditionProblem` carries the priority and not the `problem_id`.
pub fn stress_scopes(scenario_id: &str) -> Result<BTreeMap<u8, StressScope>, String> {
let t = Table::parse(H2_PROBLEMS_CSV, "h2/Problems.csv")?;
let mut out = BTreeMap::new();
for row in &t.rows {
if t.get(row, "scenario_id")? != scenario_id {
continue;
}
let priority: u8 = t
.get(row, "hidden_priority")?
.parse()
.map_err(|_| "hidden_priority is not a number".to_string())?;
out.insert(priority, t.get(row, "stress_scope")?.parse()?);
}
if out.is_empty() {
return Err(format!("h2/Problems.csv has no rows for {scenario_id}"));
}
Ok(out)
}
/// The stress gate, printed on every player mat (CB-WP-0037 T03).
///
/// **A mat is mostly ornamentation with one rule on it.** Symbol, colour
@ -750,6 +808,80 @@ mod card_text_tests {
println!("{report}");
}
/// **H2 changes only the column it says it changes** (CB-WP-0042 T01).
///
/// `rules_delta.yaml`'s `unchanged:` list claims
/// `deal_and_thresholds`, and H2 ships its own `Problems.csv`. If a
/// value, suit, visibility or priority moved in it, **every baseline
/// comparison in every H2 measurement would be against a different
/// board** — and the variant would be testing two things at once.
#[test]
fn h2_adds_a_column_and_alters_nothing_else() {
let base = Table::parse(CSV, "Problems.csv").expect("r0");
let h2 = Table::parse(H2_PROBLEMS_CSV, "h2/Problems.csv").expect("h2");
let key = |t: &Table, r: &Vec<String>| {
(
t.get(r, "problem_id").expect("id").to_string(),
t.get(r, "scenario_id").expect("scn").to_string(),
t.get(r, "visibility").expect("vis").to_string(),
t.get(r, "hidden_priority").expect("pri").to_string(),
t.get(r, "point_value").expect("val").to_string(),
t.get(r, "required_solution").expect("sol").to_string(),
)
};
let a: Vec<_> = base.rows.iter().map(|r| key(&base, r)).collect();
let b: Vec<_> = h2.rows.iter().map(|r| key(&h2, r)).collect();
assert_eq!(
a, b,
"H2's Problems.csv differs from r0 beyond `stress_scope` — the deal \
moved, so H2 would be testing a rules change and a board change at once"
);
// And it really does add the column, or there is nothing to read.
assert!(
h2.cols.iter().any(|c| c == "stress_scope"),
"H2's Problems.csv has no stress_scope column"
);
assert!(
!base.cols.iter().any(|c| c == "stress_scope"),
"r0 already carries stress_scope — H2 is not the variant that adds it"
);
}
/// **The scopes come from the edition, not from the priority**
/// (CB-WP-0042 T01).
///
/// The delta states the mapping — 0 global, 1 personal, 2 personal,
/// 3 bond, 4 personal — and the file carries it. **Deriving it from
/// the priority in Rust would be F25 again**: a number hardcoded that
/// the edition already holds.
#[test]
fn the_stress_scopes_are_read_from_the_edition() {
let scopes = stress_scopes("SCN_01").expect("SCN_01 scopes");
assert_eq!(
scopes.get(&0),
Some(&StressScope::Global),
"Surface is global"
);
assert_eq!(scopes.get(&1), Some(&StressScope::Personal));
assert_eq!(scopes.get(&2), Some(&StressScope::Personal));
assert_eq!(
scopes.get(&3),
Some(&StressScope::Bond),
"priority 3 is the bond card — the seat-band dial, absent at 2p"
);
assert_eq!(scopes.get(&4), Some(&StressScope::Personal));
// Every scenario the edition ships, not just the one we deal.
for id in ["SCN_01", "SCN_02", "SCN_03", "SCN_04"] {
assert!(
stress_scopes(id).is_ok(),
"{id} has no scopes, so a later pass that deals it would have none"
);
}
}
/// **The stress gate is printed on the mats** (CB-WP-0037 T03).
///
/// `Player_Mats.csv` looked like pure ornamentation — a symbol, a

View file

@ -75,6 +75,23 @@ pub struct ProblemState {
pub claimed_by: Option<PlayerId>,
/// GR-A11: protected from Deny this round by GROUND—OU.
pub protected_this_round: bool,
/// H2-OWN: which seat this Problem's Stress falls on, for a
/// `personal` or `bond` scope (CB-WP-0042).
///
/// **`None` under the baseline, and skipped when `None` so it
/// serialises exactly as it always did** — every recorded scenario
/// predates this field, and a state that hashed differently would
/// break all of them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<PlayerId>,
/// H2-SCOPE: who this Problem's End-of-Round Stress falls on.
///
/// Stored rather than looked up, because the lookup needs the
/// `hidden_priority` and `ProblemState` does not carry it — and
/// deriving the priority from the map key would make the deal order
/// load-bearing for a rules effect.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<crate::edition::StressScope>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
@ -212,6 +229,10 @@ pub enum Variant {
/// `ground-darvo-r0` — the printed baseline, and the default.
#[default]
Baseline,
/// `h2-scoped-problem-stress` — unclaimed Problems tick only the
/// seats in their scope. **Does not stack with H1** — the package
/// says `replaces_experiments: [h1-problem-stress]`.
H2ScopedProblemStress,
/// `h1-problem-stress` — ground-game's hypothesis H1.
///
/// **Experimental.** Two deltas only: unclaimed Problems raise
@ -226,6 +247,7 @@ impl Variant {
match self {
Variant::Baseline => "ground-darvo-r0",
Variant::H1ProblemStress => "h1-problem-stress",
Variant::H2ScopedProblemStress => "h2-scoped-problem-stress",
}
}
}
@ -236,13 +258,63 @@ impl std::str::FromStr for Variant {
match s {
"ground-darvo-r0" | "baseline" | "r0" => Ok(Variant::Baseline),
"h1-problem-stress" | "h1" => Ok(Variant::H1ProblemStress),
"h2-scoped-problem-stress" | "h2" => Ok(Variant::H2ScopedProblemStress),
other => Err(format!(
"unknown variant {other:?} (ground-darvo-r0, h1-problem-stress)"
"unknown variant {other:?} (ground-darvo-r0, h1-problem-stress, \
h2-scoped-problem-stress)"
)),
}
}
}
impl GroundState {
/// Select the rules package, **and apply whatever its setup needs**
/// (CB-WP-0042 T02).
///
/// **A builder rather than a bare field write.** H2 assigns Problem
/// owners at setup, and `state.variant = v` would leave them
/// unassigned — a silently wrong game rather than a failing one.
/// Every driver path goes through here.
pub fn with_variant(mut self, variant: Variant) -> Self {
self.variant = variant;
if variant == Variant::H2ScopedProblemStress {
self.apply_h2_setup();
}
self
}
/// H2-SCOPE and H2-OWN, applied to the dealt board.
///
/// Owners go to the **non-global** Problems in **ascending hidden
/// priority**, starting at the Lead and stepping clockwise. The
/// Problems map is keyed 1..n in deal order and `edition::deal`
/// sorts by priority, so map order *is* priority order — asserted in
/// `owners_follow_ascending_priority_from_the_lead` rather than
/// assumed.
fn apply_h2_setup(&mut self) {
let Ok(scopes) = crate::edition::stress_scopes("SCN_01") else {
return;
};
let seats: Vec<PlayerId> = self.players.keys().copied().collect();
let start = seats.iter().position(|s| *s == self.lead).unwrap_or(0);
let mut next = 0usize;
// Keys ascend with priority; priority 0 is the Surface.
for (i, (_, problem)) in self.problems.iter_mut().enumerate() {
let priority = i as u8;
let scope = scopes.get(&priority).copied();
problem.scope = scope;
if matches!(
scope,
Some(crate::edition::StressScope::Personal)
| Some(crate::edition::StressScope::Bond)
) {
problem.owner = Some(seats[(start + next) % seats.len()]);
next += 1;
}
}
}
}
/// GR-E01..E04: the final scoring result.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Outcome {
@ -618,6 +690,27 @@ impl GroundState {
player.stress >= 4 && !player.freedom_gate_lifted
}
/// H2-A: the owner plus every seat reachable over **Bond edges
/// only** — never Rivalry. A degree-0 owner falls back to personal,
/// which the delta states and which is the branch a test forgets.
fn bond_network(&self, owner: PlayerId) -> Vec<PlayerId> {
let mut seen = vec![owner];
let mut frontier = vec![owner];
while let Some(seat) = frontier.pop() {
for (pair, rel) in &self.relations {
if *rel != Relation::Bond || !pair.contains(seat) {
continue;
}
let other = if pair.0 == seat { pair.1 } else { pair.0 };
if !seen.contains(&other) {
seen.push(other);
frontier.push(other);
}
}
}
seen
}
fn relation_between(&self, a: PlayerId, b: PlayerId) -> Option<Relation> {
self.relations.get(&Pair::new(a, b)).copied()
}
@ -1540,6 +1633,49 @@ impl GroundState {
//
// The trigger loop reads `work`, so it sees the new Stress.
let mut work = self.clone();
// H2-A (CB-WP-0042): scoped pressure. Each unclaimed Problem
// ticks only the seats in ITS scope, and `stacking: true` — two
// open bond Problems hit the network twice, so this accumulates
// per Problem rather than applying once.
if self.variant == Variant::H2ScopedProblemStress {
let mut ticks: BTreeMap<PlayerId, i16> = BTreeMap::new();
for problem in self.problems.values() {
if problem.claimed_by.is_some() {
continue;
}
let recipients: Vec<PlayerId> = match problem.scope {
Some(crate::edition::StressScope::Global) => {
self.players.keys().copied().collect()
}
Some(crate::edition::StressScope::Personal) => {
problem.owner.into_iter().collect()
}
Some(crate::edition::StressScope::Bond) => match problem.owner {
Some(owner) => self.bond_network(owner),
None => vec![],
},
None => vec![],
};
for seat in recipients {
*ticks.entry(seat).or_default() += 1;
}
}
// In seat order, so two seats taking a tick are ordered (U9).
for seat in self.seat_order() {
let Some(n) = ticks.get(&seat).copied() else {
continue;
};
let stress = work.stress_after(seat, n);
let e = GroundEvent::StressSet {
player: seat,
stress,
};
work.fold(&e);
events.push(e);
}
}
if self.variant == Variant::H1ProblemStress
&& self.problems.values().any(|p| p.claimed_by.is_none())
{
@ -1977,6 +2113,8 @@ impl ScenarioGame for GroundState {
denied: false,
claimed_by: None,
protected_this_round: false,
owner: None,
scope: None,
},
)
})
@ -2094,7 +2232,7 @@ mod tests {
seed,
)
.expect("setup");
s.variant = variant;
s = s.with_variant(variant);
s
}
@ -2150,6 +2288,346 @@ mod tests {
}
}
/// **The baseline is untouched by H2's new state** (T02).
///
/// `owner` and `scope` are new fields on `ProblemState`. Both are
/// `None` under the baseline and skipped when `None`, so a
/// baseline state must serialise and hash exactly as it did
/// before they existed — **every recorded scenario predates
/// them.**
#[test]
fn h2s_new_state_is_invisible_under_the_baseline() {
for players in [2u8, 3, 6] {
let s = GroundState::setup(
&Setup {
players,
preset: format!("standard-{players}p"),
patch: Default::default(),
},
5,
)
.expect("setup")
.with_variant(Variant::Baseline);
assert!(
s.problems
.values()
.all(|p| p.owner.is_none() && p.scope.is_none()),
"{players}p: the baseline acquired owners or scopes"
);
let json = serde_json::to_string(&s).expect("json");
assert!(
!json.contains("\"owner\"") && !json.contains("\"scope\""),
"{players}p: the baseline serialises H2's fields, so every \
recorded scenario's hash has moved"
);
}
}
/// **H2-OWN: ascending priority, from the Lead, clockwise** (T02).
#[test]
fn owners_follow_ascending_priority_from_the_lead() {
for players in [2u8, 3, 4, 5, 6] {
let s = setup(players, Variant::H2ScopedProblemStress, 5);
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
let start = seats.iter().position(|x| *x == s.lead).expect("lead seats");
// The Surface is global and owns nothing; the rest are
// owned in map order, which is priority order.
let mut expected = 0usize;
for (key, p) in &s.problems {
match p.scope {
Some(crate::edition::StressScope::Global) => {
assert!(
p.owner.is_none(),
"{players}p: a global Problem has an owner"
);
assert_eq!(*key, 1, "the Surface should be the first key");
}
Some(_) => {
assert_eq!(
p.owner,
Some(seats[(start + expected) % seats.len()]),
"{players}p problem {key}: owner is not the next seat \
clockwise from the Lead"
);
expected += 1;
}
None => panic!("{players}p problem {key}: H2 left a Problem unscoped"),
}
}
assert!(expected > 0, "{players}p: nothing was owned");
// Exactly one owner each, and the walk did not skip.
let owned = s.problems.values().filter(|p| p.owner.is_some()).count();
assert_eq!(owned, expected, "{players}p: an owner was assigned twice");
}
}
/// **Deterministic, as the delta requires** — "deterministic for
/// sims". A seeded-but-unstated order would be untestable.
#[test]
fn h2_ownership_is_deterministic() {
for seed in [1u64, 7, 99] {
let a = setup(4, Variant::H2ScopedProblemStress, seed);
let b = setup(4, Variant::H2ScopedProblemStress, seed);
let owners = |s: &GroundState| -> Vec<Option<PlayerId>> {
s.problems.values().map(|p| p.owner).collect()
};
assert_eq!(
owners(&a),
owners(&b),
"seed {seed}: ownership is not stable"
);
}
}
/// **H2-A: each scope reaches exactly who it should** (T03).
///
/// Each case is built so one scope is unclaimed and the rest are
/// claimed, so the recipients are unambiguous.
#[test]
fn h2_pressure_reaches_only_the_scope() {
use crate::edition::StressScope;
let ticks = |scope: StressScope, bonds: &[(usize, usize)]| {
let mut s = setup(4, Variant::H2ScopedProblemStress, 5);
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
for (a, b) in bonds {
s.relations
.insert(Pair::new(seats[*a], seats[*b]), Relation::Bond);
}
// Claim everything, then re-open exactly one Problem of
// the scope under test — with a known owner, seat 0.
let keys: Vec<u32> = s.problems.keys().copied().collect();
for k in &keys {
s.problems.get_mut(k).expect("p").claimed_by = Some(seats[0]);
}
let target = *keys
.iter()
.find(|k| s.problems[k].scope == Some(scope))
.unwrap_or_else(|| panic!("no {scope:?} Problem in the deal"));
let p = s.problems.get_mut(&target).expect("p");
p.claimed_by = None;
p.owner = Some(seats[0]);
let mut out: Vec<(PlayerId, u8)> = s
.end_round_events()
.into_iter()
.filter_map(|e| match e {
GroundEvent::StressSet { player, stress } => Some((player, stress)),
_ => None,
})
.collect();
out.sort();
(seats, out)
};
// global: everyone.
let (seats, out) = ticks(StressScope::Global, &[]);
assert_eq!(out.len(), seats.len(), "global did not reach every seat");
// personal: the owner alone.
let (seats, out) = ticks(StressScope::Personal, &[]);
assert_eq!(
out.len(),
1,
"personal reached more than the owner: {out:?}"
);
assert_eq!(out[0].0, seats[0]);
// bond: the owner's network, over BOND edges only.
let (seats, out) = ticks(StressScope::Bond, &[(0, 1), (1, 2)]);
let hit: Vec<PlayerId> = out.iter().map(|(p, _)| *p).collect();
assert!(
hit.contains(&seats[0]) && hit.contains(&seats[1]) && hit.contains(&seats[2]),
"bond did not traverse the network: {hit:?}"
);
assert!(
!hit.contains(&seats[3]),
"bond reached a seat outside the network: {hit:?}"
);
}
/// **Rivalry edges are not the network** (T03) — the single most
/// likely defect, named in the workplan before the code existed.
#[test]
fn h2_bond_scope_does_not_traverse_rivalry() {
use crate::edition::StressScope;
let mut s = setup(4, Variant::H2ScopedProblemStress, 5);
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
// 0—1 Bond, 1—2 RIVALRY. The network is {0,1}, never 2.
s.relations
.insert(Pair::new(seats[0], seats[1]), Relation::Bond);
s.relations
.insert(Pair::new(seats[1], seats[2]), Relation::Rivalry);
let keys: Vec<u32> = s.problems.keys().copied().collect();
for k in &keys {
s.problems.get_mut(k).expect("p").claimed_by = Some(seats[0]);
}
let target = *keys
.iter()
.find(|k| s.problems[k].scope == Some(StressScope::Bond))
.expect("a bond Problem");
let p = s.problems.get_mut(&target).expect("p");
p.claimed_by = None;
p.owner = Some(seats[0]);
let hit: Vec<PlayerId> = s
.end_round_events()
.into_iter()
.filter_map(|e| match e {
GroundEvent::StressSet { player, .. } => Some(player),
_ => None,
})
.collect();
assert!(
!hit.contains(&seats[2]),
"the Bond network crossed a Rivalry edge: {hit:?}"
);
}
/// **A degree-0 owner falls back to personal** (T03) — the branch
/// a test forgets, named in advance.
#[test]
fn h2_a_bond_problem_with_no_bonds_is_personal() {
use crate::edition::StressScope;
let mut s = setup(4, Variant::H2ScopedProblemStress, 5);
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
assert!(s.relations.is_empty(), "the fixture needs no relations");
let keys: Vec<u32> = s.problems.keys().copied().collect();
for k in &keys {
s.problems.get_mut(k).expect("p").claimed_by = Some(seats[0]);
}
let target = *keys
.iter()
.find(|k| s.problems[k].scope == Some(StressScope::Bond))
.expect("a bond Problem");
let p = s.problems.get_mut(&target).expect("p");
p.claimed_by = None;
p.owner = Some(seats[0]);
let hit: Vec<PlayerId> = s
.end_round_events()
.into_iter()
.filter_map(|e| match e {
GroundEvent::StressSet { player, .. } => Some(player),
_ => None,
})
.collect();
assert_eq!(hit, vec![seats[0]], "a bondless owner should tick alone");
}
/// **`stacking: true`** (T03): two open Problems hitting the same
/// seat tick it **twice**. A careless implementation applies the
/// pressure once.
#[test]
fn h2_pressure_stacks_per_problem() {
let mut s = setup(4, Variant::H2ScopedProblemStress, 5);
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
let keys: Vec<u32> = s.problems.keys().copied().collect();
for k in &keys {
let p = s.problems.get_mut(k).expect("p");
p.claimed_by = Some(seats[0]);
}
// Two personal Problems, both owned by seat 0, both open.
let personal: Vec<u32> = keys
.iter()
.copied()
.filter(|k| s.problems[k].scope == Some(crate::edition::StressScope::Personal))
.take(2)
.collect();
assert_eq!(personal.len(), 2, "the deal needs two personal Problems");
for k in &personal {
let p = s.problems.get_mut(k).expect("p");
p.claimed_by = None;
p.owner = Some(seats[0]);
}
s.players.get_mut(&seats[0]).expect("s").stress = 0;
let got: Vec<(PlayerId, u8)> = s
.end_round_events()
.into_iter()
.filter_map(|e| match e {
GroundEvent::StressSet { player, stress } => Some((player, stress)),
_ => None,
})
.collect();
assert_eq!(
got,
vec![(seats[0], 2)],
"two open Problems on one seat must tick it twice, not once"
);
}
/// **The baseline and H1 do not feel H2-A** (T03).
#[test]
fn h2_pressure_does_not_reach_the_other_variants() {
for variant in [Variant::Baseline, Variant::H1ProblemStress] {
let mut s = setup(3, variant, 5);
// H1 would fire on an unclaimed Problem; claim them all so
// only H2-A could produce anything here.
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
let keys: Vec<u32> = s.problems.keys().copied().collect();
for k in &keys {
s.problems.get_mut(k).expect("p").claimed_by = Some(seats[0]);
}
assert!(
!s.end_round_events()
.iter()
.any(|e| matches!(e, GroundEvent::StressSet { .. })),
"{variant:?} gained scoped pressure"
);
}
}
/// **H2-SOLVE: ownership is not a permission** (T04).
///
/// *"Any seat with a matching suit may solve; the owner need not
/// be the solver"* — and altruistic clearing is **intended**,
/// especially on bond-scope cards.
///
/// **This is a regression test, not a feature.** The engine had
/// no owner concept, so it was already true; T02 added ownership,
/// and the risk is that ownership silently becomes a right.
#[test]
fn h2_ownership_does_not_restrict_who_may_solve() {
let s = setup(4, Variant::H2ScopedProblemStress, 5);
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
// A Problem owned by someone else, face up and unclaimed.
let (key, owner) = s
.problems
.iter()
.find_map(|(k, p)| p.owner.map(|o| (*k, o)))
.expect("H2 assigns owners");
let mut s = s;
s.problems.get_mut(&key).expect("p").face_up = true;
let suit = s.problems[&key].suit;
for seat in &seats {
// Give every seat the matching card.
s.players.get_mut(seat).expect("p").hand = vec![SolutionCard { suit }];
}
for seat in &seats {
let legal = crate::bot::legal_commands(&s, *seat);
let can_solve = legal.iter().any(|c| {
matches!(
c,
GroundCommand::SelectAction {
action: Action::Solve,
problem: Some(p),
..
} if *p == key
)
});
assert!(
can_solve,
"{seat:?} holds the matching suit but may not SOLVE problem {key} \
(owned by {owner:?}) ownership has become a permission"
);
}
}
/// **H1-A.** Unclaimed Problems raise everyone's Stress at Round
/// End — and "unclaimed" includes Denied and still-hidden, which
/// is the clause a careless reading drops.
@ -2550,6 +3028,8 @@ mod tests {
denied: false,
claimed_by,
protected_this_round: false,
owner: None,
scope: None,
};
s.problems.insert(1, mk(4, Some(seats[0])));
s.problems.insert(2, mk(4, Some(seats[1])));
@ -2611,6 +3091,8 @@ mod tests {
denied: false,
claimed_by,
protected_this_round: false,
owner: None,
scope: None,
};
// GR-E03 key 2, Stress: equal claims, lower Stress wins.