clay-borg/games/ground/examples/attack-value.rs
tegwick 302fc95c97
Some checks failed
ci / check (push) Failing after 4s
GR-E03 and GR-E04 played to the end — F14 closed, and the reason they were
unplayed was ours

Tier S (a fix and a measurement inside a boundary; chaos d8=4 from
CB-WP-0029's roll, no override).

cb-play built EVERY game with ScoringMode::SharedGround and passed an
empty patch. The mode was settable in scenarios and not from the driver,
so two of the three shipped modes were unreachable from the only way
anyone actually plays. F14 sat open for a week because nobody could reach
the thing it was about.

--mode added. All three now play out and give DIFFERENT WINNERS FROM
IDENTICAL PLAY: shared -> all four seats (mastery 4), common -> P3 alone
(top personal scorer), coalitions -> P1+P2 (best Bond network, 4>3>2).
Same 37 commands, three answers.

AND THEY ANSWER F17'S OPEN QUESTION. I had flagged that ATTACK might earn
its place where Blame costs personal score. It does not, in any mode:

  SHARED GROUND     132/165/190/200 -> identical      free but pointless
  COMMON PROBLEM     59/52/48/44    -> 59/52/48/34    a cost at six seats
  BONDED COALITIONS 131/134/132/116 -> 59/52/48/34    roughly halved

The coalitions row has a mechanism and the data confirms it unprompted.
GR-A07 flips a Bond to a Rivalry on Attack, and GR-E04 scores Bond
NETWORKS -- so attacking destroys the thing that scores. And the attacking
numbers in E04 are IDENTICAL to E03's, which is exactly what that
predicts: break every Bond and each seat is a coalition of one, so GR-E04
degenerates into GR-E03. That check was not designed; it fell out.

F14 -> applied. F17 strengthened and no longer bounded to co-op: ATTACK
has no mode in which it helps, and one where it actively destroys your
score.

Still framed as a question rather than a verdict. DARVO is the pattern the
game is about not falling into, so a self-destructive ATTACK may be the
design. What ground-game has to decide is whether the namesake mechanic
being unreachable in competent play -- in all three modes -- is intended.

make all: exit 0.

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

185 lines
6.5 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};
/// 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.
fn sweep(
mode: ScoringMode,
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);
for seed in 0..200u64 {
let Ok(mut st) = GroundState::setup(
&Setup {
players,
preset: format!("standard-{players}p"),
patch: Default::default(),
},
seed,
) else {
continue;
};
st.mode = mode;
let mut ps = mk(players);
let Ok(g) = play(st, &mut ps) else { continue };
games += 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;
}
}
}
(games, won, atk, darvo)
}
fn main() {
println!("F17 — what is ATTACK worth, in each scoring mode?\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 (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, players, &|n| {
(0..n)
.map(|_| Box::new(GreedyPolicy) as Box<dyn Policy>)
.collect()
});
let cell = |r: i32| {
sweep(mode, 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)");
}