CB-WP-0042 T01-T04: H2's scoped stress, with the named defects caught
Some checks failed
ci / check (push) Failing after 4s
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:
parent
05d1a9aada
commit
04e26b3077
9 changed files with 900 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue