clay-borg/games/ground/examples/attack-value.rs
tegwick 47e7941057
Some checks failed
ci / check (push) Failing after 3s
H2's ATTACK dynamics, and a construction hazard found while measuring them
The H2 panel measured wins, variance and DARVO but not ATTACK selection —
which is F17's actual question. attack-value now runs H2 too.

The shape is the finding. Under H1 a seat that sometimes attacks loses
everything at 3p and above. Under H2 rank-75 wins 112/173/199 against
greedy's 120/175/199, while attacking and arming DARVO. So H2 makes
occasional ATTACK affordable — it does not make it pay. rank-75 never
beats greedy in any cell, and rank-95 (always attack) still wins 0
everywhere in all three variants, so "not always-attack-optimal" holds.

F17 therefore stands: ATTACK earns its place in no mode. What changed is
that choosing it is no longer catastrophic. Whether affordable is what the
design wants is ground-game's judgement.

And a hazard: with_variant() exists because H2 assigns Problem owners at
setup and `state.variant = v` leaves them unassigned, so scoped pressure
ticks nobody and H2 measures as INERT. Three call sites had the bare
write, including cb-play's driver.

No published figure is affected, and that was checked rather than assumed:
h2-panel used the builder, and the two harnesses with the bare write had
only ever run baseline and H1, neither of which has a setup step; the
driver has never played H2. All three fixed, and
a_bare_variant_write_leaves_h2_inert now states the difference so a
regression is caught by a named test rather than by a reader wondering why
H2 did nothing.

The builder was not enough — the field is public, so the old form still
compiles. Worth knowing before the next variant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 16:56:27 +02:00

256 lines
9.8 KiB
Rust

//! F17's reproduction: what is ATTACK worth, in each scoring mode?
//!
//! The maintainer reported *"there is no incentive to play attacks as long
//! as I have positive cards"*. F17 sat as a note because nothing
//! demonstrated it. This is the artifact.
//!
//! **`GreedyPolicy` plays ATTACK zero times, and that measures our bot.**
//! `bot.rs` ranks `Action::Attack => 10`, below everything else. Reporting
//! "the game gives no incentive" from a policy programmed to rank attack
//! last would be CB-WP-0025's C4 error again — one policy's behaviour
//! presented as the game's.
//!
//! So this varies **exactly one number**: ATTACK's rank in an otherwise
//! identical policy. 10 is greedy's. 75 puts it above SUPPORT and below
//! INVESTIGATE — *attack when convenient*. 95 puts it above SOLVE —
//! *attack whenever legal*.
//!
//! And it asks the question in **all three scoring modes**, because Blame
//! costs *personal* score: ATTACK may be worthless in the co-op mode and
//! earn its place in GR-E03 or GR-E04.
use cb_game_runtime::{ScenarioGame, Setup};
use cb_kernel::PlayerId;
use games_ground::bot::{play, Choice, GreedyPolicy, Policy};
use games_ground::{Action, GroundCommand, GroundState, ScoringMode};
/// Games per cell. Named once so the footer and the assertion cannot
/// disagree — the footer said "200 games per cell" over 195-game columns.
const GAMES: u32 = 200;
/// Greedy's ordering with ATTACK's rank as a parameter.
///
/// A copy of the ranking rather than a call into it: `GreedyPolicy::rank`
/// is private, and the point is to vary one number while holding every
/// other preference identical.
struct Attacker(i32);
impl Policy for Attacker {
fn name(&self) -> &'static str {
"attacker"
}
fn choose(
&mut self,
state: &GroundState,
seat: PlayerId,
legal: &[GroundCommand],
_may_pass: bool,
) -> Choice {
let gated = state
.players
.get(&seat)
.is_some_and(|p| p.stress >= 4 && !p.freedom_gate_lifted);
let rank = |c: &GroundCommand| -> i32 {
match c {
GroundCommand::SelectAction {
action, problem, ..
} => match action {
Action::Ground if gated => 100,
Action::Attack => self.0, // the one varying number
Action::Solve
if problem
.and_then(|p| state.problems.get(&p))
.is_some_and(|p| p.claimed_by.is_some()) =>
{
5
}
Action::Solve => 90,
Action::Investigate => 80,
Action::Support => 70,
Action::Ground => 60,
},
GroundCommand::SpendFreedom => {
if gated {
96
} else {
0
}
}
GroundCommand::Reveal | GroundCommand::Resolve | GroundCommand::EndRound => -1,
_ => 50,
}
};
let mut best = 0usize;
for (i, c) in legal.iter().enumerate() {
if rank(c) > rank(&legal[best]) {
best = i;
}
}
Choice::Command(best)
}
}
/// `(games, good outcomes, attacks, DARVO armings)` over 200 seeds.
///
/// **`games` is reported, not assumed to be 200.** A rules variant can
/// make the engine refuse a position, and a harness that skips those
/// silently reports "nobody won" for a run in which nobody played.
fn sweep(
mode: ScoringMode,
variant: games_ground::Variant,
players: u8,
mk: &dyn Fn(u8) -> Vec<Box<dyn Policy>>,
) -> (u32, u32, u32, u32) {
let (mut games, mut won, mut atk, mut darvo) = (0, 0, 0, 0);
let mut played = 0u32;
let mut errs: Vec<String> = Vec::new();
let mut setup_fails = 0u32;
for seed in 0..GAMES as u64 {
let Ok(mut st) = GroundState::setup(
&Setup {
players,
preset: format!("standard-{players}p"),
patch: Default::default(),
},
seed,
) else {
setup_fails += 1;
continue;
};
st.mode = mode;
// CB-WP-0038 T03: the SAME instrument measures both rule sets in
// the same run. Two harnesses would compare harnesses.
// `with_variant`, never a field write (see regulation.rs).
let st = st.with_variant(variant);
let mut ps = mk(players);
// CB-WP-0038 T03: a game the engine REFUSES is not a game the
// table lost, and `else { continue }` made the two identical.
// H1's first run showed won/atk/darvo all exactly 0 at 3+ seats,
// which is the signature of every game being discarded rather
// than played -- so the count is now reported, not swallowed.
let g = match play(st, &mut ps) {
Ok(g) => g,
Err(e) => {
errs.push(format!("{e:?}"));
continue;
}
};
games += 1;
if g.state.outcome.is_some() && g.rounds == 5 {
played += 1;
}
// In the co-op mode the table wins together. In the other two the
// question is whether SEAT 0 is among the winners — because that
// is what an individual player's incentive turns on, and ATTACK is
// an individual's choice.
let good = match mode {
ScoringMode::SharedGround => g.state.outcome.as_ref().is_some_and(|o| o.group_success),
_ => g
.state
.outcome
.as_ref()
.is_some_and(|o| o.winners.contains(&PlayerId(0))),
};
if good {
won += 1;
}
for (_, c) in &g.steps {
if let GroundCommand::SelectAction {
action: Action::Attack,
..
} = c
{
atk += 1;
}
}
for e in &g.events {
if matches!(e, games_ground::GroundEvent::DarvoTriggered { .. }) {
darvo += 1;
}
}
}
// **Asserted, not printed** (CB-REV-0003 #1). This harness produced
// every number in CB-EV-0030's DARVO table, and it still only warned
// on a short cell — the very defect fixed in `regulation.rs` and left
// here, while the gate registered to close it claimed the property
// for both.
assert_eq!(
games,
GAMES,
"{players}p {mode:?} {variant:?}: only {games} of {GAMES} games ran \
({setup_fails} setups refused, {} play errors) — the cell is short, so \
every number in it is over a sample nobody chose",
errs.len()
);
// **And that a game RAN is not that it was PLAYED** (CB-REV-0003 #2).
// Stopping the engine after one round gave 200 games, all-zero
// columns and exit 0 — byte for byte the signature CB-EV-0030 §3
// claims to distinguish from a real result. A counted game must have
// reached an outcome.
assert_eq!(
played, GAMES,
"{players}p {mode:?} {variant:?}: {played} of {games} games reached an \
outcome — the rest stopped early, and all-zero columns would read as \
a result rather than as nothing having happened"
);
(games, won, atk, darvo)
}
fn main() {
println!("F17 — what is ATTACK worth, in each scoring mode?\n");
println!("CB-WP-0038 T03: run for BOTH rule sets. ground-game's H1 asks");
println!("whether problem pressure plus a high-Stress self-soothe make");
println!("ATTACK and DARVO relevant without making always-ATTACK optimal.\n");
println!("`won` is group success in SHARED GROUND, and \"seat 0 is among the");
println!("winners\" in the other two, because ATTACK is an individual's choice");
println!("and that is what an individual's incentive turns on.\n");
for (vlabel, variant) in [
("BASELINE ground-darvo-r0", games_ground::Variant::Baseline),
(
"H1 h1-problem-stress",
games_ground::Variant::H1ProblemStress,
),
(
"H2 h2-scoped-problem-stress",
games_ground::Variant::H2ScopedProblemStress,
),
] {
println!("================ {vlabel} ================\n");
for (label, mode) in [
("SHARED GROUND (GR-E02, co-op)", ScoringMode::SharedGround),
(
"COMMON PROBLEM (GR-E03, semi-co-op)",
ScoringMode::CommonProblem,
),
("BONDED COALITIONS (GR-E04)", ScoringMode::BondedCoalitions),
] {
println!("{label}");
println!(" rank=10 (greedy) rank=75 (sometimes) rank=95 (always)");
println!("seats won atk darvo won atk darvo won atk darvo");
for players in [2u8, 3, 4, 6] {
let (_, gw, ga, gd) = sweep(mode, variant, players, &|n| {
(0..n)
.map(|_| Box::new(GreedyPolicy) as Box<dyn Policy>)
.collect()
});
let cell = |r: i32| {
sweep(mode, variant, players, &move |n| {
(0..n)
.map(|_| Box::new(Attacker(r)) as Box<dyn Policy>)
.collect()
})
};
let (_, mw, ma, md) = cell(75);
let (_, aw, aa, ad) = cell(95);
println!(
" {players}p {gw:>4} {ga:>4} {gd:>5} {mw:>4} {ma:>4} {md:>5} \
{aw:>4} {aa:>4} {ad:>5}"
);
}
println!();
}
}
println!("(200 games per cell)");
}