CB-WP-0038: variant selection, H1 implemented, and H1 measured
Some checks failed
ci / check (push) Failing after 3s
Some checks failed
ci / check (push) Failing after 3s
ground-game packages hypotheses as selectable rules variants — a catalog, a rules_delta.yaml, and prose — and their note is explicit that CSV text alone is not executable here. So the kernel gains a Variant in game state: in the state, therefore in the hash, therefore in the recording, because a scenario replayed under a different variant would diverge silently. Baseline is bit-for-bit what it was, asserted across seat counts and seeds. A variant system that perturbs the baseline invalidates every measurement this repo has. H1-A and H1-B implemented from rules_delta.yaml and mutation-proven on their own defects: "unclaimed" misread as face-up-and-unsolved, and the attacker's Stress read after the attack's effects. Their `unchanged:` list is asserted rather than trusted — that list is their claim about their own experiment. Measured, and three of their four criteria fail. DARVO arm rate is still 0 under greedy; ATTACK selection does not rise and falls for the rank-75 policy; group success collapses from 165/190/200 to 0 at 3/4/6 seats. The mechanism is not the assumed one: greedy answers the pressure by regulating, Stress plateaus at 3, so it never reaches the gate at 4 or the arm at 5 — H1-A acts as a solve-rate tax and H1-B is unreachable under competent play. A harness defect was caught before the claim: sweep discarded refused games silently and never reported its count, so "nobody won" and "nothing played" printed identically. Reporting H1 as unwinnable on that basis would have been the ADR-0018 family aimed at another repo's design. All 200 games ran in every cell; the zeros are real. Chaos d8 = 8 — the window's first override, redrew L against a structural L, so it changed nothing. Window 3 recorded in ChaosRollHistory. NOT REVIEWED: tier L owes a separate-agent adversarial review, and no H1 result may reach ground-game until it has run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
627992bc85
commit
a928b5925c
15 changed files with 928 additions and 28 deletions
|
|
@ -173,6 +173,16 @@ pub struct GroundState {
|
|||
pub darvo_targets: BTreeMap<PlayerId, DarvoTarget>,
|
||||
/// GR-E02..E04: which scoring mode this game uses.
|
||||
pub mode: ScoringMode,
|
||||
/// Which selectable rules package the kernel is playing
|
||||
/// (CB-WP-0038, ground-game `editions/catalog.yaml`).
|
||||
///
|
||||
/// **In the state, therefore in the hash, therefore in the
|
||||
/// recording.** A scenario replayed under a different variant would
|
||||
/// diverge silently, and the recording is what every other artifact
|
||||
/// rests on. `#[serde(default)]` so every scenario written before
|
||||
/// variants existed still loads, as baseline — which is what it was.
|
||||
#[serde(default)]
|
||||
pub variant: Variant,
|
||||
/// GR-R09: set once the game has ended and scoring has run.
|
||||
pub outcome: Option<Outcome>,
|
||||
/// GR-S04/U4: retained so a deck reshuffle stays a pure function of
|
||||
|
|
@ -191,6 +201,48 @@ pub enum ScoringMode {
|
|||
BondedCoalitions,
|
||||
}
|
||||
|
||||
/// A selectable rules package (ground-game `editions/catalog.yaml`).
|
||||
///
|
||||
/// **Not a difficulty setting and not a preference.** A variant changes
|
||||
/// what the rules *are*, so unlike `Pace` it legitimately changes the
|
||||
/// outcome, the state hash and the recording — and must therefore be
|
||||
/// recorded with the game rather than chosen at render time.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum Variant {
|
||||
/// `ground-darvo-r0` — the printed baseline, and the default.
|
||||
#[default]
|
||||
Baseline,
|
||||
/// `h1-problem-stress` — ground-game's hypothesis H1.
|
||||
///
|
||||
/// **Experimental.** Two deltas only: unclaimed Problems raise
|
||||
/// everyone's Stress at Round End, and a high-Stress attacker gets a
|
||||
/// small self-relief.
|
||||
H1ProblemStress,
|
||||
}
|
||||
|
||||
impl Variant {
|
||||
/// The catalog's `variant_id`, which is how ground-game names these.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Variant::Baseline => "ground-darvo-r0",
|
||||
Variant::H1ProblemStress => "h1-problem-stress",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for Variant {
|
||||
type Err = String;
|
||||
fn from_str(s: &str) -> Result<Self, String> {
|
||||
match s {
|
||||
"ground-darvo-r0" | "baseline" | "r0" => Ok(Variant::Baseline),
|
||||
"h1-problem-stress" | "h1" => Ok(Variant::H1ProblemStress),
|
||||
other => Err(format!(
|
||||
"unknown variant {other:?} (ground-darvo-r0, h1-problem-stress)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GR-E01..E04: the final scoring result.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Outcome {
|
||||
|
|
@ -1374,6 +1426,13 @@ impl GroundState {
|
|||
ou_cancels: &std::collections::BTreeSet<(PlayerId, PlayerId)>,
|
||||
events: &mut Vec<GroundEvent>,
|
||||
) {
|
||||
// H1-B (CB-WP-0038): read BEFORE anything resolves. The delta
|
||||
// says "the attacker's Stress was >= 4 **before this Attack's
|
||||
// effects**", and the attacker's own Stress can move during
|
||||
// resolution -- so capturing it afterwards would answer a
|
||||
// different question.
|
||||
let attacker_stress_before = self.players.get(&attacker).map_or(0, |p| p.stress);
|
||||
|
||||
// GR-A09 under the U8 default: a GROUND—OU cancellation is
|
||||
// chosen at step 1 and applies first, so Protection is only
|
||||
// consumed when it is what actually cancels.
|
||||
|
|
@ -1423,6 +1482,23 @@ impl GroundState {
|
|||
events.push(event);
|
||||
self.fold(events.last().expect("just pushed"));
|
||||
}
|
||||
|
||||
// H1-B: the self-soothe, **after target and relation effects**
|
||||
// (`order: after_target_and_relation_effects`) and only on an
|
||||
// Attack that actually resolved -- both cancel paths above have
|
||||
// already returned.
|
||||
//
|
||||
// The DARVO extra Attack comes through here too, which the delta
|
||||
// requires: "DARVO-stage extra Attack uses the same Attack
|
||||
// resolution (so it can self-soothe too if Stress >= 4)".
|
||||
if self.variant == Variant::H1ProblemStress && attacker_stress_before >= 4 {
|
||||
let stress = self.stress_after(attacker, -1);
|
||||
events.push(GroundEvent::StressSet {
|
||||
player: attacker,
|
||||
stress,
|
||||
});
|
||||
self.fold(events.last().expect("just pushed"));
|
||||
}
|
||||
}
|
||||
|
||||
/// GR-A01: draw one Solution, reshuffling the discard first if the
|
||||
|
|
@ -1452,10 +1528,38 @@ impl GroundState {
|
|||
fn end_round_events(&self) -> Vec<GroundEvent> {
|
||||
let mut events = Vec::new();
|
||||
|
||||
// H1-A (CB-WP-0038): problem pressure, and it lands BEFORE the
|
||||
// DARVO arm check below, because ground-game's delta orders it
|
||||
// "+1 Stress, then clamp 0-5, then DARVO arm check as today".
|
||||
// Applying it after would make the pressure unable to arm
|
||||
// anything for a round -- the opposite of the hypothesis.
|
||||
//
|
||||
// **"Unclaimed" includes Denied and still-hidden Problems**, and
|
||||
// that is the clause a careless reading drops: it is `claimed_by
|
||||
// .is_none()`, not "face-up and unsolved".
|
||||
//
|
||||
// The trigger loop reads `work`, so it sees the new Stress.
|
||||
let mut work = self.clone();
|
||||
if self.variant == Variant::H1ProblemStress
|
||||
&& self.problems.values().any(|p| p.claimed_by.is_none())
|
||||
{
|
||||
for seat in self.seat_order() {
|
||||
// `stress_after` clamps 0..=5, which is the delta's
|
||||
// "then clamp".
|
||||
let stress = work.stress_after(seat, 1);
|
||||
let e = GroundEvent::StressSet {
|
||||
player: seat,
|
||||
stress,
|
||||
};
|
||||
work.fold(&e);
|
||||
events.push(e);
|
||||
}
|
||||
}
|
||||
|
||||
// GR-D01: Stress 5 with the marker OFF starts a sequence. In
|
||||
// Lead order, so two simultaneous triggers are ordered (U9).
|
||||
for seat in self.seat_order() {
|
||||
let player = &self.players[&seat];
|
||||
for seat in work.seat_order() {
|
||||
let player = &work.players[&seat];
|
||||
if player.stress == 5 && player.darvo == DarvoStage::Off {
|
||||
events.push(GroundEvent::DarvoTriggered { player: seat });
|
||||
}
|
||||
|
|
@ -1891,6 +1995,11 @@ impl ScenarioGame for GroundState {
|
|||
support_responses: BTreeMap::new(),
|
||||
darvo_targets: BTreeMap::new(),
|
||||
mode: ScoringMode::SharedGround,
|
||||
// Baseline. The driver overwrites this after setup and
|
||||
// before the hash is taken, which is the route `mode` uses
|
||||
// (`table.rs`) — so a recorded session replays under the
|
||||
// variant it was played under.
|
||||
variant: Variant::default(),
|
||||
outcome: None,
|
||||
seed,
|
||||
})
|
||||
|
|
@ -1963,6 +2072,198 @@ impl ScenarioGame for GroundState {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// CB-WP-0038 T02 — the H1 deltas, and ground-game's own claim about
|
||||
/// what they leave alone.
|
||||
mod h1 {
|
||||
use super::super::*;
|
||||
use cb_game_runtime::{ScenarioGame, Setup};
|
||||
|
||||
fn setup(players: u8, variant: Variant, seed: u64) -> GroundState {
|
||||
let mut s = GroundState::setup(
|
||||
&Setup {
|
||||
players,
|
||||
preset: format!("standard-{players}p"),
|
||||
patch: Default::default(),
|
||||
},
|
||||
seed,
|
||||
)
|
||||
.expect("setup");
|
||||
s.variant = variant;
|
||||
s
|
||||
}
|
||||
|
||||
/// **The load-bearing control.** A variant system that perturbs
|
||||
/// the baseline invalidates every measurement this repo has.
|
||||
#[test]
|
||||
fn baseline_is_bit_for_bit_what_it_was() {
|
||||
for players in [2u8, 3, 6] {
|
||||
for seed in 0..8u64 {
|
||||
let base = setup(players, Variant::Baseline, seed);
|
||||
let mut default_built = GroundState::setup(
|
||||
&Setup {
|
||||
players,
|
||||
preset: format!("standard-{players}p"),
|
||||
patch: Default::default(),
|
||||
},
|
||||
seed,
|
||||
)
|
||||
.expect("setup");
|
||||
// Untouched: whatever `setup` produces IS baseline.
|
||||
assert_eq!(default_built.variant, Variant::Baseline);
|
||||
default_built.variant = Variant::Baseline;
|
||||
assert_eq!(
|
||||
cb_events::state_hash_hex(&base),
|
||||
cb_events::state_hash_hex(&default_built),
|
||||
"{players}p seed {seed}: selecting the baseline changed it"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **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.
|
||||
#[test]
|
||||
fn h1a_pressure_applies_while_any_problem_is_unclaimed() {
|
||||
let mut s = setup(3, Variant::H1ProblemStress, 7);
|
||||
// A fresh deal has unclaimed Problems by construction.
|
||||
assert!(s.problems.values().any(|p| p.claimed_by.is_none()));
|
||||
// One hidden, one Denied: neither is "face-up unsolved", and
|
||||
// both must still count.
|
||||
let ids: Vec<u32> = s.problems.keys().copied().collect();
|
||||
s.problems.get_mut(&ids[0]).expect("p").face_up = false;
|
||||
s.problems.get_mut(&ids[1]).expect("p").denied = true;
|
||||
|
||||
let before: Vec<u8> = s.players.values().map(|p| p.stress).collect();
|
||||
let events = s.end_round_events();
|
||||
let bumped: Vec<&GroundEvent> = events
|
||||
.iter()
|
||||
.filter(|e| matches!(e, GroundEvent::StressSet { .. }))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
bumped.len(),
|
||||
s.players.len(),
|
||||
"every player takes the pressure, not just some"
|
||||
);
|
||||
for e in bumped {
|
||||
if let GroundEvent::StressSet { player, stress } = e {
|
||||
let was = s.players[player].stress;
|
||||
assert_eq!(*stress, (was + 1).min(5), "clamped 0..=5");
|
||||
}
|
||||
}
|
||||
let _ = before;
|
||||
}
|
||||
|
||||
/// No unclaimed Problem, no pressure — the `when` clause is a
|
||||
/// condition, not decoration.
|
||||
#[test]
|
||||
fn h1a_is_silent_once_every_problem_is_claimed() {
|
||||
let mut s = setup(3, Variant::H1ProblemStress, 7);
|
||||
let me = *s.players.keys().next().expect("seat");
|
||||
for p in s.problems.values_mut() {
|
||||
p.claimed_by = Some(me);
|
||||
}
|
||||
assert!(
|
||||
!s.end_round_events()
|
||||
.iter()
|
||||
.any(|e| matches!(e, GroundEvent::StressSet { .. })),
|
||||
"pressure applied with nothing left unclaimed"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The baseline must not feel H1-A at all.**
|
||||
#[test]
|
||||
fn h1a_does_not_touch_the_baseline() {
|
||||
let s = setup(3, Variant::Baseline, 7);
|
||||
assert!(s.problems.values().any(|p| p.claimed_by.is_none()));
|
||||
assert!(
|
||||
!s.end_round_events()
|
||||
.iter()
|
||||
.any(|e| matches!(e, GroundEvent::StressSet { .. })),
|
||||
"the baseline gained problem pressure"
|
||||
);
|
||||
}
|
||||
|
||||
fn attack(variant: Variant, attacker_stress: u8, protect_target: bool) -> Vec<GroundEvent> {
|
||||
let mut s = setup(3, variant, 3);
|
||||
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
|
||||
let (a, t) = (seats[0], seats[1]);
|
||||
s.players.get_mut(&a).expect("a").stress = attacker_stress;
|
||||
s.players.get_mut(&t).expect("t").protection = u8::from(protect_target);
|
||||
let mut events = Vec::new();
|
||||
s.resolve_attack(a, t, &Default::default(), &mut events);
|
||||
events
|
||||
.into_iter()
|
||||
.filter(|e| matches!(e, GroundEvent::StressSet { player, .. } if *player == a))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// **H1-B.** A high-Stress attacker who actually lands an Attack
|
||||
/// gets a small self-relief.
|
||||
#[test]
|
||||
fn h1b_soothes_only_a_landed_attack_from_high_stress() {
|
||||
// Stress 4, uncancelled: soothed.
|
||||
let soothed = attack(Variant::H1ProblemStress, 4, false);
|
||||
assert_eq!(soothed.len(), 1, "no self-soothe at Stress 4");
|
||||
if let GroundEvent::StressSet { stress, .. } = soothed[0] {
|
||||
assert_eq!(stress, 3, "the delta is -1");
|
||||
}
|
||||
// Below the threshold: nothing.
|
||||
assert!(
|
||||
attack(Variant::H1ProblemStress, 3, false).is_empty(),
|
||||
"soothed below Stress 4"
|
||||
);
|
||||
// Cancelled by Protection: nothing. "Not cancelled" is a
|
||||
// condition of the delta, and Protection is a cancel path.
|
||||
assert!(
|
||||
attack(Variant::H1ProblemStress, 4, true).is_empty(),
|
||||
"a cancelled Attack still soothed the attacker"
|
||||
);
|
||||
// And the baseline never soothes.
|
||||
assert!(
|
||||
attack(Variant::Baseline, 4, false).is_empty(),
|
||||
"the baseline gained the self-soothe"
|
||||
);
|
||||
}
|
||||
|
||||
/// **`rules_delta.yaml`'s `unchanged:` list is ground-game's claim
|
||||
/// about their own experiment, and it is checkable.**
|
||||
///
|
||||
/// Trusting it would be taking a rules statement on faith, which
|
||||
/// is the habit CB-WP-0037 was written to end.
|
||||
#[test]
|
||||
fn h1_changes_nothing_it_said_it_would_not() {
|
||||
for players in [2u8, 3, 4, 5, 6] {
|
||||
let base = setup(players, Variant::Baseline, 11);
|
||||
let h1 = setup(players, Variant::H1ProblemStress, 11);
|
||||
|
||||
// deal_and_thresholds
|
||||
assert_eq!(base.problems, h1.problems, "{players}p: the deal moved");
|
||||
assert_eq!(
|
||||
base.threshold(),
|
||||
h1.threshold(),
|
||||
"{players}p: the threshold moved"
|
||||
);
|
||||
// start_stress: 2
|
||||
for (seat, p) in &h1.players {
|
||||
assert_eq!(p.stress, 2, "{players}p {seat}: starting Stress moved");
|
||||
}
|
||||
// relation_slots: 2 — asserted through the engine's own
|
||||
// capacity check rather than a constant beside it.
|
||||
let seats: Vec<PlayerId> = h1.players.keys().copied().collect();
|
||||
assert!(h1.has_free_slot(seats[0]), "a fresh seat has slots");
|
||||
// ground_modes / darvo_stage_table / support: the tables
|
||||
// are shared code, so equality of the starting state plus
|
||||
// the deltas' scope is what carries them.
|
||||
assert_eq!(base.mode, h1.mode, "{players}p: scoring mode moved");
|
||||
assert_eq!(
|
||||
base.solution_deck, h1.solution_deck,
|
||||
"{players}p: deck moved"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use super::*;
|
||||
use cb_events::state_hash_hex;
|
||||
|
||||
|
|
@ -1994,6 +2295,7 @@ mod tests {
|
|||
support_responses: BTreeMap::new(),
|
||||
darvo_targets: BTreeMap::new(),
|
||||
mode: ScoringMode::SharedGround,
|
||||
variant: Variant::Baseline,
|
||||
outcome: None,
|
||||
seed: 0,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue