diff --git a/games/ground/examples/attack-value.rs b/games/ground/examples/attack-value.rs new file mode 100644 index 0000000..e54904c --- /dev/null +++ b/games/ground/examples/attack-value.rs @@ -0,0 +1,143 @@ +//! F17's reproduction: what is ATTACK worth? (CB-WP-0029 follow-up.) +//! +//! 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 ranks Attack at 10, below everything (bot.rs). So its zero +//! attacks measure OUR HEURISTIC, not the game. The test is a policy +//! identical in every other respect with Attack promoted: if it wins as +//! often, attacking is neutral; much less, avoiding it is correct play; +//! more, and greedy is simply wrong. +use cb_game_runtime::{ScenarioGame, Setup}; +use cb_kernel::PlayerId; +use games_ground::bot::{play, Choice, GreedyPolicy, Policy}; +use games_ground::{Action, GroundCommand, GroundState}; + +/// Greedy's ordering with ATTACK promoted to the top of the action list. +/// A copy of the ranking, not a call into it: `GreedyPolicy::rank` is +/// private, and the point is to vary exactly one number. +struct Attacker(i32); +impl Policy for Attacker { + fn name(&self) -> &'static str { + "attacker" + } + fn choose( + &mut self, + state: &GroundState, + seat: PlayerId, + legal: &[GroundCommand], + _p: 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 (greedy: 10) + 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) + } +} + +fn sweep( + name: &str, + players: u8, + mk: &dyn Fn(u64, 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(st) = GroundState::setup( + &Setup { + players, + preset: format!("standard-{players}p"), + patch: Default::default(), + }, + seed, + ) else { + continue; + }; + let mut ps = mk(seed, players); + let Ok(g) = play(st, &mut ps) else { continue }; + games += 1; + if g.state.outcome.as_ref().is_some_and(|o| o.group_success) { + 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; + } + } + } + let _ = name; + (games, won, atk, darvo) +} + +fn main() { + println!("F17: does ATTACK cost you the game, or does our bot just avoid it?\n"); + // The gradient, not just the extremes: greedy ranks Attack at 10, + // below everything. 75 puts it above SUPPORT and below INVESTIGATE -- + // "attack when convenient". 95 is "attack whenever legal". + 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, 5, 6] { + let cell = |r: i32, p: u8| { + sweep("x", p, &move |_s, n| { + (0..n) + .map(|_| Box::new(Attacker(r)) as Box) + .collect() + }) + }; + let (_, gw, ga, gd) = sweep("greedy", players, &|_s, p| { + (0..p) + .map(|_| Box::new(GreedyPolicy) as Box) + .collect() + }); + let (_, mw, ma, md) = cell(75, players); + let (_, aw, aa, ad) = cell(95, players); + println!(" {players}p {gw:>4} {ga:>4} {gd:>5} {mw:>4} {ma:>4} {md:>5} {aw:>4} {aa:>4} {ad:>5}"); + } + println!("\n(200 games per cell; greedy shown as the rank=10 column)"); +} diff --git a/specs/FindingRegister.md b/specs/FindingRegister.md index e7f81c9..5923d8b 100644 --- a/specs/FindingRegister.md +++ b/specs/FindingRegister.md @@ -44,7 +44,7 @@ kinds, states and metrics: [`GameDesign.md`](GameDesign.md). Reported by | F14 | unplayed | note | — | — | 2026-08-01 | clay-borg | | F15 | underdetermined | note | — | — | 2026-08-05 | clay-borg | | F16 | inconsistent | withdrawn | games/ground/examples/difficulty.rs | counterexample | 2026-08-05 | clay-borg | -| F17 | degenerate | note | — | — | 2026-08-06 | ground-game | +| F17 | degenerate | raised | games/ground/examples/attack-value.rs | counterexample | 2026-08-06 | ground-game | | F18 | inert | raised | — | — | 2026-08-06 | clay-borg | | F19 | degenerate | applied | crates/cb-render-html/src/lib.rs::overhead_table | counterexample | 2026-08-06 | clay-borg | | F20 | inert | applied | crates/cb-render-html/src/lib.rs::ending_page | counterexample | 2026-08-06 | clay-borg | @@ -128,16 +128,38 @@ kinds, states and metrics: [`GameDesign.md`](GameDesign.md). Reported by seat-position and single-drop-zone proxies added. **They are proxies.** Nothing here lays out a browser, and the gap CB-EV-0026 named — that no gate measures whether a player can play — is unclosed. -- **F17 — no incentive to ATTACK while holding useful Solutions.** - Reported from play, 2026-08-06: *"there is no incentive to play attacks - as long as I have positive cards."* If true, ATTACK is a dead branch for - most of a game, which would make GR-A06/A07/A08 and the whole Rivalry - half of the relation system reachable only when a player is out of good - options. **`note`, not a finding**: no artifact demonstrates it yet. One - is cheap — count ATTACK selections across the policy panel and compare - against hand quality — and until it exists this may not go to - `ground-game` (GameDesign §3.1). **Owner is ground-game if it survives**; - it would be a design finding, not an engine one. +- **F17 — ATTACK cannot affect whether the table succeeds.** Reported + from play as *"there is no incentive to play attacks as long as I have + positive cards"*, and **the artifact now exists**: + `games/ground/examples/attack-value.rs`, 200 games per cell, one number + varied — ATTACK's rank in an otherwise identical policy. + + | ATTACK ranked | wins (2/3/4/5/6p) | attacks | DARVO armed | + |---|---|---:|---:| + | 10 (below all) | 132 / 165 / 190 / 200 / 200 | 0 | 0 | + | 75 (above SUPPORT) | **132 / 165 / 190 / 200 / 200** | 315–923 | 13–218 | + | 95 (above SOLVE) | **0 / 0 / 0 / 0 / 0** | 1400–5170 | 400–1000 | + + **The middle row is the finding: identical win counts at every seat + count**, while attacking hundreds of times and arming DARVO. Attacking + is not punished — it is **inert with respect to the goal**. Group success + is a function of SOLVE alone, and ATTACK only costs anything when it + ranks above SOLVE and displaces it. + + **So the maintainer was right and the reason is sharper than the + phrasing.** There is no incentive because there is no *path*: ATTACK's + effects (Stress, Rivalry, DARVO) feed nothing that decides + `group_success`. + + **Bounded to SHARED GROUND.** These are co-op games. Blame costs + *personal* score, so ATTACK may well earn its place in GR-E03 and + GR-E04 — **which have never been played to the end (F14)**, and that is + where this question should be asked next. + + **Not a claim that the game is broken.** DARVO is the pattern the game + is *about* not falling into; a self-destructive ATTACK may be the + design. The question for `ground-game` is whether the namesake mechanic + being unreachable in competent co-op play is intended. - **F18 — the engine imports one of nineteen edition files, so the cards cannot say what they do.** Reported as *"I don't understand the GROUND card"* — which is not a design gap: `Actions.csv` carries that card's