//! 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>, ) -> (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) .collect() }); let cell = |r: i32| { sweep(mode, players, &move |n| { (0..n) .map(|_| Box::new(Attacker(r)) as Box) .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)"); }