diff --git a/games/ground/examples/attack-value.rs b/games/ground/examples/attack-value.rs index e54904c..e285f6f 100644 --- a/games/ground/examples/attack-value.rs +++ b/games/ground/examples/attack-value.rs @@ -1,23 +1,36 @@ -//! F17's reproduction: what is ATTACK worth? (CB-WP-0029 follow-up.) +//! 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 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. +//! **`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}; +use games_ground::{Action, GroundCommand, GroundState, ScoringMode}; -/// 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. +/// 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" @@ -27,7 +40,7 @@ impl Policy for Attacker { state: &GroundState, seat: PlayerId, legal: &[GroundCommand], - _p: bool, + _may_pass: bool, ) -> Choice { let gated = state .players @@ -39,7 +52,7 @@ impl Policy for Attacker { action, problem, .. } => match action { Action::Ground if gated => 100, - Action::Attack => self.0, // <-- the ONE varying number (greedy: 10) + Action::Attack => self.0, // the one varying number Action::Solve if problem .and_then(|p| state.problems.get(&p)) @@ -73,14 +86,15 @@ impl Policy for Attacker { } } +/// `(games, good outcomes, attacks, DARVO armings)` over 200 seeds. fn sweep( - name: &str, + mode: ScoringMode, players: u8, - mk: &dyn Fn(u64, u8) -> Vec>, + 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(st) = GroundState::setup( + let Ok(mut st) = GroundState::setup( &Setup { players, preset: format!("standard-{players}p"), @@ -90,10 +104,24 @@ fn sweep( ) else { continue; }; - let mut ps = mk(seed, players); + st.mode = mode; + let mut ps = mk(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) { + + // 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 { @@ -111,33 +139,47 @@ fn sweep( } } } - 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| { + 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(Attacker(r)) as Box) + .map(|_| Box::new(GreedyPolicy) 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}"); + }); + 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!("\n(200 games per cell; greedy shown as the rank=10 column)"); + println!("(200 games per cell)"); } diff --git a/specs/FindingRegister.md b/specs/FindingRegister.md index 5923d8b..acd0f34 100644 --- a/specs/FindingRegister.md +++ b/specs/FindingRegister.md @@ -41,7 +41,7 @@ kinds, states and metrics: [`GameDesign.md`](GameDesign.md). Reported by | F11 | inert | applied | scenarios/ground/gr-p05-solve-legality.yaml | counterexample | 2026-08-02 | clay-borg | | F12 | degenerate | note | — | — | 2026-08-01 | clay-borg | | F13 | inconsistent | withdrawn | scenarios/ground/gr-e01-threshold-reachable-2p.yaml | counterexample | 2026-08-01 | clay-borg | -| F14 | unplayed | note | — | — | 2026-08-01 | clay-borg | +| F14 | unplayed | applied | games/ground/examples/attack-value.rs | counterexample | 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 | raised | games/ground/examples/attack-value.rs | counterexample | 2026-08-06 | ground-game | @@ -151,10 +151,21 @@ kinds, states and metrics: [`GameDesign.md`](GameDesign.md). Reported by 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. + **Asked in all three modes 2026-08-07, and ATTACK earns its place in + none of them:** + + | mode | never attack | attack sometimes | + |---|---|---| + | 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 the data confirms.** 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 becomes a coalition of one, so GR-E04 + degenerates into GR-E03. **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 @@ -177,10 +188,14 @@ kinds, states and metrics: [`GameDesign.md`](GameDesign.md). Reported by play — a unit test showing the two tallies point at different seats demonstrates only that they can differ, which is arithmetic, not a design defect. Under GameDesign §3.1 it may not be reported until one exists. -- **F14 — GR-E03/GR-E04 never played to the end.** Nineteen passes, never - played out. `note` until a trial game exists; GROUND-WP-0003 is the - playtest that would close it, and GameDesign §5's protocol makes the - recording the artifact. +- **F14 — GR-E03/GR-E04 never played to the end. Closed 2026-08-07, and + the reason they were unplayed was ours.** `cb-play` built every game with + `ScoringMode::SharedGround` and passed an **empty patch**, so two of the + three shipped modes were unreachable from the only way anyone plays. The + mode was patchable in *scenarios* and not from the *driver*. `--mode` + added; all three now play to the end and give **different winners from + identical play**: shared → all four seats, common → P3 alone, coalitions + → P1+P2. **`applied`** — 800 games per mode in the reproduction. ### The register's first run found ten answers nobody had collected diff --git a/tools/cb-play/src/hotseat.rs b/tools/cb-play/src/hotseat.rs index 9ac0913..ae39180 100644 --- a/tools/cb-play/src/hotseat.rs +++ b/tools/cb-play/src/hotseat.rs @@ -812,6 +812,7 @@ mod tests { record: None, serve: Some(0), trial: None, + mode: games_ground::ScoringMode::SharedGround, }, std::io::Cursor::new(Vec::new()), out, @@ -980,6 +981,7 @@ mod tests { record: None, serve: Some(0), trial: None, + mode: games_ground::ScoringMode::SharedGround, }, std::io::Cursor::new(Vec::new()), out, diff --git a/tools/cb-play/src/inspect.rs b/tools/cb-play/src/inspect.rs index 826b8c9..07827cc 100644 --- a/tools/cb-play/src/inspect.rs +++ b/tools/cb-play/src/inspect.rs @@ -763,6 +763,7 @@ mod tests { record: Some(dir.join("session.yaml")), serve: None, trial: None, + mode: games_ground::ScoringMode::SharedGround, }; let mut sink: Vec = Vec::new(); let summary = diff --git a/tools/cb-play/src/main.rs b/tools/cb-play/src/main.rs index 307c66c..46fddc0 100644 --- a/tools/cb-play/src/main.rs +++ b/tools/cb-play/src/main.rs @@ -29,6 +29,8 @@ play: --replay DIR write a .cbreplay bundle of the finished game to DIR --record FILE write the finished game as a scenario YAML --trial FILE write a trial log: what the player said, bound to where + --mode M scoring mode: shared (GR-E02), common (GR-E03), + coalitions (GR-E04). Default shared. --serve PORT play human seats in a browser on 127.0.0.1:PORT instead of on the terminal; 0 lets the OS pick. Prints a URL carrying a per-process token — without it the page is @@ -107,6 +109,24 @@ fn parse_args(argv: &[String]) -> Result { } // CB-WP-0027: a trial is a recorded session PLUS what the // player said while playing (GameDesign §5, ADR-0014). + // GR-E02..E04, so the other two shipped modes are reachable. + "--mode" => { + play_flags.push(flag.into()); + let v = value(i, argv, flag)?; + config.mode = match v.to_ascii_lowercase().as_str() { + "shared" | "sharedground" | "coop" => games_ground::ScoringMode::SharedGround, + "common" | "commonproblem" | "semi" => games_ground::ScoringMode::CommonProblem, + "coalitions" | "bondedcoalitions" | "coalition" => { + games_ground::ScoringMode::BondedCoalitions + } + other => { + return Err(format!( + "unknown --mode {other:?} (shared, common, coalitions)" + )) + } + }; + i += 2; + } "--trial" => { play_flags.push(flag.into()); config.trial = Some(value(i, argv, flag)?.into()); @@ -269,6 +289,7 @@ mod tests { record: None, serve: None, trial: None, + mode: games_ground::ScoringMode::SharedGround, }; let script = "0\n".repeat(400); let mut out: Vec = Vec::new(); @@ -309,6 +330,7 @@ mod tests { record: None, serve: None, trial: None, + mode: games_ground::ScoringMode::SharedGround, }; let mut out: Vec = Vec::new(); table::play(&config, "0\n".repeat(200).as_bytes(), &mut out).expect("game"); @@ -372,6 +394,7 @@ mod tests { record: None, serve: None, trial: None, + mode: games_ground::ScoringMode::SharedGround, }; let mut out: Vec = Vec::new(); let summary = table::play(&config, "".as_bytes(), &mut out).expect("bot game"); @@ -433,6 +456,7 @@ mod tests { record: Some(dir.join("session.yaml")), serve: None, trial: None, + mode: games_ground::ScoringMode::SharedGround, }; let mut out: Vec = Vec::new(); let summary = table::play(&config, "".as_bytes(), &mut out).expect("game"); diff --git a/tools/cb-play/src/table.rs b/tools/cb-play/src/table.rs index 1c859fc..5752c06 100644 --- a/tools/cb-play/src/table.rs +++ b/tools/cb-play/src/table.rs @@ -39,6 +39,14 @@ pub struct Config { /// Where the trial log goes (CB-WP-0027). Without it the note channel /// refuses rather than dropping what the player wrote. pub trial: Option, + /// GR-E02..E04: which scoring mode to play. + /// + /// **The driver could not choose one until now**, which is a large + /// part of why GR-E03 and GR-E04 had never been played to the end + /// (F14). `setup` builds SharedGround and `run_game` passed an empty + /// patch, so two of the three shipped modes were unreachable from the + /// only way anyone actually plays. + pub mode: games_ground::ScoringMode, } impl Default for Config { @@ -52,6 +60,7 @@ impl Default for Config { record: None, serve: None, trial: None, + mode: games_ground::ScoringMode::SharedGround, } } } @@ -332,7 +341,10 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>( preset: format!("standard-{}p", config.players), patch: Default::default(), }; - let initial = ::setup(&setup, config.seed)?; + let mut initial = ::setup(&setup, config.seed)?; + // GR-E02..E04. Set before the hash is taken, so a recorded session + // replays in the mode it was played in. + initial.mode = config.mode; let initial_json = serde_json::to_value(&initial).map_err(|e| e.to_string())?; let initial_hash = cb_events::state_hash_hex(&initial);