//! Bots — the kernel's first non-scenario consumer (CB-WP-0008 T01). //! //! Everything here drives `GroundState` through the ordinary //! `Aggregate::validate` → `fold` path. Nothing in this module reaches //! into state to mutate it: a bot that did would prove nothing about the //! kernel, which is the whole reason INTENT wants a *second* consumer. //! //! **Stated limit on [`legal_commands`].** It is complete only up to the //! candidate shapes it enumerates. Every candidate it yields is legal — //! each one is filtered through `validate` — but a command shape this //! function forgets to construct is invisible, and nothing here detects //! that. Under-generation is the failure mode; it would show up as a bot //! that never uses a rule, not as an error. use crate::{ Action, DarvoTarget, GroundChoice, GroundCommand, GroundMode, GroundState, Relation, RoundStep, ScoringMode, Selection, SupportResponse, }; use cb_kernel::{Actor, Aggregate, ChaChaRng, KernelRng, PlayerId, Rejection, Seed}; /// A seat's decision when offered the legal commands available to it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Choice { /// Index into the offered slice. Command(usize), /// Decline to act. Legal only where the driver says it is. Pass, } /// How one seat decides. Implementors see the whole state — bots are not /// the place to enforce hidden information; that is K13's projection, and /// [`crate::bot`] deliberately does not pretend otherwise. pub trait Policy { fn name(&self) -> &'static str; /// Pick one of `legal`, or [`Choice::Pass`] when `may_pass`. /// /// `legal` is never empty when this is called: the driver treats an /// empty legal set at an obligatory point as [`BotError::NoLegalMove`] /// rather than asking a policy to invent a move. fn choose( &mut self, state: &GroundState, seat: PlayerId, legal: &[GroundCommand], may_pass: bool, ) -> Choice; } /// Every way a bot game can fail. All of them are **loud**: the failure a /// bot driver must never have is the silent one, where a stalled game is /// indistinguishable from a finished one. #[derive(Debug, Clone, PartialEq, Eq)] pub enum BotError { /// A seat had to act and had nothing legal to do. NoLegalMove { seat: PlayerId, round: u8, step: RoundStep, }, /// A policy passed where passing is not allowed. PassedWhenObligatory { seat: PlayerId, round: u8, step: RoundStep, }, /// A policy returned an index outside the offered slice. Not clamped: /// clamping would turn a broken policy into a quietly playing one. IllegalChoice { seat: PlayerId, index: usize, offered: usize, }, /// A command the driver generated was rejected. Since generation is /// validate-filtered, this means state moved underneath it. Rejected { seat: Option, command: String, rejection: Rejection, }, /// The game did not progress. A bot that loops forever looks like a /// bot that is working; this is the guard that makes it look broken. Stalled { round: u8, detail: String }, /// Fewer policies than seats. NoPolicy { seat: PlayerId }, } impl core::fmt::Display for BotError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { BotError::NoLegalMove { seat, round, step } => write!( f, "player {seat} has no legal command in round {round} {step:?}" ), BotError::PassedWhenObligatory { seat, round, step } => write!( f, "player {seat} passed in round {round} {step:?}, where an action is required" ), BotError::IllegalChoice { seat, index, offered, } => write!( f, "player {seat}'s policy chose index {index} of {offered} offered commands" ), BotError::Rejected { seat, command, rejection, } => write!(f, "{seat:?} issuing {command} was rejected: {rejection}"), BotError::Stalled { round, detail } => { write!(f, "game stalled in round {round}: {detail}") } BotError::NoPolicy { seat } => write!(f, "no policy for seat {seat}"), } } } /// What a completed bot game produced. #[derive(Debug, Clone)] pub struct BotGame { pub state: GroundState, /// Every event, in order — the log a replay bundle would carry. pub events: Vec, /// Every accepted command, in order, as issued. `record::to_step` /// turns these into a scenario a replay bundle can carry. pub steps: Vec<(Actor, GroundCommand)>, /// Commands accepted, player and system alike. pub commands: usize, pub rounds: u8, } // ------------------------------------------------------------ generation /// Every legal command `seat` may issue right now, in canonical order. /// /// Candidates are constructed then filtered through `validate`, so the /// game rules stay in one place. See the module note for what this /// cannot tell you. pub fn legal_commands(state: &GroundState, seat: PlayerId) -> Vec { let seats: Vec = state.players.keys().copied().collect(); let problems: Vec = state.problems.keys().copied().collect(); let mut candidates: Vec = Vec::new(); match state.step { RoundStep::Select => { for action in [ Action::Investigate, Action::Solve, Action::Support, Action::Attack, Action::Ground, ] { match action { // Offered on every Problem; `validate` decides // which are legal, and this function filters every // candidate through it below. // // CB-WP-0023 first put GR-P05's conditions here, in // the OFFER layer. The AM-1 coverage gate then asked // for a scenario covering GR-P05 — and scenarios drive // `validate`, not this. That is what revealed the rule // belonged in `validate`: a rule enforced only by the // offer is enforced only for clients that ask what is // legal. Once it moved, everything here was dead code. Action::Investigate | Action::Solve => { for problem in &problems { candidates.push(GroundCommand::SelectAction { action, target: None, problem: Some(*problem), }); } } Action::Support | Action::Attack => { for other in &seats { candidates.push(GroundCommand::SelectAction { action, target: Some(*other), problem: None, }); } } Action::Ground => candidates.push(GroundCommand::SelectAction { action, target: None, problem: None, }), } } candidates.push(GroundCommand::SpendFreedom); } RoundStep::Reveal => { candidates.push(GroundCommand::ChooseGroundMode { mode: GroundMode::Gr, choice: None, }); for problem in &problems { for choice in [ GroundChoice::RestoreProblem { problem: *problem }, GroundChoice::ProtectProblem { problem: *problem }, ] { candidates.push(GroundCommand::ChooseGroundMode { mode: GroundMode::Ou, choice: Some(choice), }); } } for other in &seats { candidates.push(GroundCommand::ChooseGroundMode { mode: GroundMode::Ou, choice: Some(GroundChoice::CancelAttack { attacker: *other }), }); for choice in [ GroundChoice::RemoveBlame { owner: *other }, GroundChoice::BreakRelation { with: *other }, ] { candidates.push(GroundCommand::ChooseGroundMode { mode: GroundMode::Nd, choice: Some(choice), }); } } candidates.push(GroundCommand::ChooseGroundMode { mode: GroundMode::Nd, choice: Some(GroundChoice::RejectReverse), }); for response in [ SupportResponse::AcceptBond, SupportResponse::DeclineBond, SupportResponse::FlipToBond, SupportResponse::BreakRivalry, ] { candidates.push(GroundCommand::RespondToSupport { response }); } for problem in &problems { candidates.push(GroundCommand::ChooseDarvoTarget { target: DarvoTarget { problem: Some(*problem), player: None, }, }); } for other in &seats { candidates.push(GroundCommand::ChooseDarvoTarget { target: DarvoTarget { problem: None, player: Some(*other), }, }); } } // GR-R06/R08: system-driven; a seat has nothing to issue. RoundStep::Resolve | RoundStep::End => {} } candidates .into_iter() .filter(|cmd| state.validate(Actor::Player(seat), cmd).is_ok()) .collect() } // --------------------------------------------------------------- policies /// Uniform over the legal commands, from a seeded kernel RNG so a bot /// game stays deterministic and replayable (K8). pub struct RandomPolicy { rng: ChaChaRng, /// Probability, in sixteenths, of taking an optional action rather /// than passing. Fixed rather than tunable: a knob here would be a /// parameter nobody has a second use for. act_in_16: u32, } impl RandomPolicy { pub fn new(seed: u64) -> Self { Self { rng: ChaChaRng::from_seed(Seed(seed)), act_in_16: 12, } } } impl Policy for RandomPolicy { fn name(&self) -> &'static str { "random" } fn choose( &mut self, _state: &GroundState, _seat: PlayerId, legal: &[GroundCommand], may_pass: bool, ) -> Choice { if may_pass && self.rng.draw(16) >= self.act_in_16 { return Choice::Pass; } Choice::Command(self.rng.draw(legal.len() as u32) as usize) } } /// A stated heuristic, in priority order: /// /// 1. **Get out from under the stress gate.** At Stress ≥ 4 only ATTACK /// and GROUND are selectable (GR-R03), so GROUND—GR (−2 Stress) is /// worth more than anything else on the board. /// 2. **SOLVE** a face-up Problem — the only action that adds to the /// shared total the outcome is scored against (GR-E01). /// 3. **INVESTIGATE** — turns a hidden Problem face up, which is what /// makes a later SOLVE possible. /// 4. **SUPPORT** — Bonds are the cheapest relationship and feed GR-E04. /// 5. **GROUND**, then **ATTACK** last: attacking raises another seat's /// Stress, which under SHARED GROUND scoring costs the group. /// /// Deterministic by construction: ties break toward the earlier candidate /// in canonical order, so two runs of a greedy game are identical without /// needing a seed. #[derive(Debug, Default, Clone, Copy)] pub struct GreedyPolicy; impl GreedyPolicy { /// The ranking, **public so a variant policy can override exactly one /// arm and inherit the rest** (CB-WP-0039, after review). /// /// It was private, so `regulation.rs` re-typed an abridged copy and /// called it "greedy with one preference changed". It differed in /// five places — including `SpendFreedom`, which the copy ranked 95 /// unconditionally where this ranks it 0 unless the gate is biting, /// so the "reactive" seat burned its Freedom token in round one of /// every game. **A second change to the exact mechanism the pass was /// studying**, and every number in CB-EV-0031 was measuring it. /// /// Making this callable removes the possibility rather than testing /// for it: a caller that delegates cannot drift. pub fn rank(state: &GroundState, seat: PlayerId, cmd: &GroundCommand) -> i32 { let gated = state .players .get(&seat) .is_some_and(|p| p.stress >= 4 && !p.freedom_gate_lifted); match cmd { GroundCommand::SelectAction { action, problem, .. } => match action { Action::Ground if gated => 100, // GR-A13 admits SOLVE against an already-claimed Problem // (it names only "face-up, non-Denied"), and resolution // then does nothing — the action is silently wasted. The // policy avoids it; the *rule* is left alone, and the gap // is recorded rather than patched here. 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, Action::Attack => 10, }, // Only worth spending when the gate is actually biting. GroundCommand::SpendFreedom => { if gated { 95 } else { 0 } } GroundCommand::ChooseGroundMode { mode, choice } => match (mode, choice) { // GR-A10: the only mode that lowers our own Stress. (GroundMode::Gr, _) => 90, (_, Some(GroundChoice::RestoreProblem { .. })) => 80, (_, Some(GroundChoice::CancelAttack { .. })) => 75, (_, Some(GroundChoice::ProtectProblem { .. })) => 70, (_, Some(GroundChoice::RemoveBlame { .. })) => 65, _ => 40, }, // GR-L02/A05: take the Bond wherever one is on offer. GroundCommand::RespondToSupport { response } => match response { SupportResponse::AcceptBond | SupportResponse::FlipToBond => 90, SupportResponse::BreakRivalry => 50, SupportResponse::DeclineBond => 10, }, GroundCommand::ChooseDarvoTarget { .. } => 50, GroundCommand::Reveal | GroundCommand::Resolve | GroundCommand::EndRound => -1, } } } impl Policy for GreedyPolicy { fn name(&self) -> &'static str { "greedy" } fn choose( &mut self, state: &GroundState, seat: PlayerId, legal: &[GroundCommand], _may_pass: bool, ) -> Choice { let mut best = 0usize; let mut best_rank = i32::MIN; for (i, cmd) in legal.iter().enumerate() { let rank = Self::rank(state, seat, cmd); if rank > best_rank { best_rank = rank; best = i; } } Choice::Command(best) } } // ----------------------------------------------------------------- driver /// Rounds the driver will run before declaring a stall. GR-R09 ends the /// game after five; anything past that is a defect, not a long game. const MAX_ROUNDS: u8 = 20; /// Attempts a single seat gets within one step. Bounded so a policy that /// keeps choosing non-advancing commands fails instead of spinning. const MAX_ATTEMPTS: usize = 8; /// Drive `state` to `GameEnded` with one policy per seat. /// /// Seats are matched to `policies` by index: `PlayerId(n)` gets /// `policies[n]`. pub fn play<'a>( state: GroundState, policies: &mut [Box], ) -> Result { play_journaled(state, policies, None) } /// The same, appending every applied command and its events to `journal` /// as it goes, for a caller rendering the game while it runs. pub fn play_journaled<'a>( mut state: GroundState, policies: &mut [Box], journal: Option, ) -> Result { let mut log = Log { journal, ..Log::default() }; let seats: Vec = state.players.keys().copied().collect(); for seat in &seats { if policies.get(seat.0 as usize).is_none() { return Err(BotError::NoPolicy { seat: *seat }); } } let mut rounds = 0u8; while state.outcome.is_none() { rounds += 1; if rounds > MAX_ROUNDS { return Err(BotError::Stalled { round: rounds, detail: format!("no outcome after {MAX_ROUNDS} rounds"), }); } let round = state.round; // GR-R02 — Select. Every seat must end this step with a selection. for seat in &seats { let mut attempts = 0; while !state.selections.contains_key(seat) { attempts += 1; if attempts > MAX_ATTEMPTS { return Err(BotError::Stalled { round, detail: format!("player {seat} never selected an action"), }); } step_seat(&mut state, &mut log, policies, *seat, round, false)?; } } // Positive control: the driver must have done the work it claims. if state.selections.len() != state.players.len() { return Err(BotError::Stalled { round, detail: format!( "Select ended with {} of {} selections", state.selections.len(), state.players.len() ), }); } apply(&mut state, &mut log, Actor::System, &GroundCommand::Reveal)?; // GR-R05 — after Reveal: modes are obligatory for a seat that // revealed GROUND; Support responses and DARVO targets are not. for seat in &seats { let mut attempts = 0; loop { attempts += 1; if attempts > MAX_ATTEMPTS { return Err(BotError::Stalled { round, detail: format!("player {seat} kept acting after Reveal"), }); } let obligatory = state .selections .get(seat) .is_some_and(|s| s.action == Action::Ground) && !state.ground_modes.contains_key(seat); if !obligatory && legal_commands(&state, *seat).is_empty() { break; } if !step_seat(&mut state, &mut log, policies, *seat, round, !obligatory)? { break; } } } apply(&mut state, &mut log, Actor::System, &GroundCommand::Resolve)?; apply( &mut state, &mut log, Actor::System, &GroundCommand::EndRound, )?; } Ok(BotGame { state, commands: log.steps.len(), events: log.events, steps: log.steps, rounds, }) } /// One command and everything it produced. /// /// **The empty case is the load-bearing one** (CB-WP-0018 T02). GR-A02's /// resolver silently `continue`s when a SOLVE cannot be fulfilled, so a /// player can select it three rounds running and change nothing. A journal /// built only from events would show nothing for those and reproduce the /// silence; keeping the command with an empty `events` is what lets a /// reader say *"this happened and did nothing"*. #[derive(Debug, Clone)] pub struct Applied { pub actor: Actor, pub command: GroundCommand, pub events: Vec, } /// A live account of a game in progress, shared with whoever is watching. /// /// `BotGame.events` is the same information but only after `play` returns, /// which is no use to a page rendered mid-game. pub type Journal = std::rc::Rc>>; /// What the driver accumulates while a game runs. #[derive(Default)] struct Log { events: Vec, steps: Vec<(Actor, GroundCommand)>, journal: Option, } /// Offer one seat its legal commands and apply what the policy picks. /// `Ok(false)` means the seat passed. fn step_seat( state: &mut GroundState, log: &mut Log, policies: &mut [Box], seat: PlayerId, round: u8, may_pass: bool, ) -> Result { let legal = legal_commands(state, seat); if legal.is_empty() { if may_pass { return Ok(false); } return Err(BotError::NoLegalMove { seat, round, step: state.step, }); } let policy = policies .get_mut(seat.0 as usize) .ok_or(BotError::NoPolicy { seat })?; match policy.choose(state, seat, &legal, may_pass) { Choice::Pass if may_pass => Ok(false), Choice::Pass => Err(BotError::PassedWhenObligatory { seat, round, step: state.step, }), Choice::Command(i) => { let cmd = legal.get(i).ok_or(BotError::IllegalChoice { seat, index: i, offered: legal.len(), })?; apply(state, log, Actor::Player(seat), cmd)?; Ok(true) } } } fn apply( state: &mut GroundState, log: &mut Log, actor: Actor, cmd: &GroundCommand, ) -> Result<(), BotError> { let produced = state .validate(actor, cmd) .map_err(|rejection| BotError::Rejected { seat: match actor { Actor::Player(p) => Some(p), Actor::System => None, }, command: format!("{cmd:?}"), rejection, })?; for event in &produced { state.fold(event); } if let Some(j) = &log.journal { j.borrow_mut().push(Applied { actor, command: cmd.clone(), events: produced.clone(), }); } log.events.extend(produced); log.steps.push((actor, cmd.clone())); Ok(()) } /// The selection a seat made this round, for callers that want to report /// a game rather than only run one. pub fn selection_of(state: &GroundState, seat: PlayerId) -> Option { state.selections.get(&seat).copied() } /// Relation between two seats, for the same reason. pub fn relation_of(state: &GroundState, a: PlayerId, b: PlayerId) -> Option { state.relations.get(&crate::Pair::new(a, b)).copied() } #[cfg(all(test, feature = "scenarios"))] mod tests { use super::*; use cb_events::state_hash_hex; use cb_game_runtime::{ScenarioGame, Setup}; fn setup(players: u8, seed: u64) -> GroundState { GroundState::setup( &Setup { players, preset: format!("standard-{players}p"), patch: Default::default(), }, seed, ) .expect("preset") } // --------------------------------------------------------------- // CB-WP-0023: SOLVE's legality, ruled by ground-game 2026-08-03. // // Four conditions, each asserted on its own, because a single // "SOLVE is filtered" test would pass with three of the four // implemented and nobody would know which. /// Problems that SOLVE is offered on, for `seat`. fn solvable(state: &GroundState, seat: PlayerId) -> Vec { legal_commands(state, seat) .into_iter() .filter_map(|c| match c { GroundCommand::SelectAction { action: Action::Solve, problem: Some(n), .. } => Some(n), _ => None, }) .collect() } /// A 3p game where seat 0 can solve exactly problem 1. fn solvable_fixture() -> (GroundState, u32) { let mut st = setup(3, 42); let (n, suit) = st .problems .iter() .find(|(_, p)| p.face_up) .map(|(n, p)| (*n, p.suit)) .expect("GR-S01 deals one face-up Surface Problem"); // Give seat 0 the matching Solution, so the ONLY thing under test // is the condition each case perturbs. st.players.get_mut(&PlayerId(0)).expect("seat 0").hand = vec![crate::SolutionCard { suit }]; assert_eq!( solvable(&st, PlayerId(0)), vec![n], "fixture is not solvable" ); (st, n) } /// **Which layer enforces GR-P05**, pinned so it cannot drift. /// /// All four conditions live in `validate`, and `legal_commands` gets /// them for free by filtering candidates through it. This test is what /// keeps that true: if `validate` ever stops rejecting one, SOLVE /// becomes offerable again and the offer layer would have to grow a /// filter back. #[test] fn validate_enforces_all_four_solve_conditions() { let (base, n) = solvable_fixture(); let sel = |p: u32| GroundCommand::SelectAction { action: Action::Solve, target: None, problem: Some(p), }; let ok = |st: &GroundState, p: u32| st.validate(Actor::Player(PlayerId(0)), &sel(p)).is_ok(); let face_down = *base .problems .iter() .find(|(_, p)| !p.face_up) .map(|(k, _)| k) .expect("a face-down problem"); println!( " validate accepts SOLVE on face-down: {}", ok(&base, face_down) ); let mut denied = base.clone(); denied.problems.get_mut(&n).unwrap().denied = true; println!(" validate accepts SOLVE on denied: {}", ok(&denied, n)); let mut claimed = base.clone(); claimed.problems.get_mut(&n).unwrap().claimed_by = Some(PlayerId(1)); println!( " validate accepts SOLVE on claimed: {}", ok(&claimed, n) ); let mut nohand = base.clone(); nohand.players.get_mut(&PlayerId(0)).unwrap().hand = vec![]; println!(" validate accepts SOLVE with no hand: {}", ok(&nohand, n)); // validate's: adding these to `legal_commands` would be dead code. for (what, accepted) in [ ("face-down", ok(&base, face_down)), ("Denied", ok(&denied, n)), ("claimed", ok(&claimed, n)), ("handless", ok(&nohand, n)), ] { assert!( !accepted, "validate accepts SOLVE on a {what} Problem — GR-P05 is not \ enforced where rules live, so only clients that ask what is \ legal would be constrained" ); } // Positive control: the fixture must still be solvable, or this // test would pass by rejecting everything. assert!(ok(&base, n), "the solvable fixture stopped being solvable"); } /// **The maintainer's reported case.** SOLVE was offered on a /// face-down Problem and did nothing; they played it three rounds /// running with no explanation. ground-game: *"not offered — illegal /// target. Browser no-ops were a filter bug, not a bluff mechanic."* #[test] fn solve_is_not_offered_on_a_face_down_problem() { let (st, open) = solvable_fixture(); let face_down: Vec = st .problems .iter() .filter(|(_, p)| !p.face_up) .map(|(n, _)| *n) .collect(); assert!(!face_down.is_empty(), "GR-S01 deals face-down Problems"); let offered = solvable(&st, PlayerId(0)); for n in face_down { assert!( !offered.contains(&n), "SOLVE offered on face-down problem {n}; offered = {offered:?}" ); } assert!(offered.contains(&open), "the face-up one is still offered"); } #[test] fn solve_is_not_offered_without_a_matching_solution_in_hand() { let (mut st, n) = solvable_fixture(); let wrong = [ crate::Suit::Clarify, crate::Suit::Repair, crate::Suit::Boundary, crate::Suit::Change, ] .into_iter() .find(|s| *s != st.problems[&n].suit) .expect("another suit exists"); st.players.get_mut(&PlayerId(0)).expect("seat 0").hand = vec![crate::SolutionCard { suit: wrong }]; assert!( !solvable(&st, PlayerId(0)).contains(&n), "SOLVE offered with no matching Solution in hand" ); } #[test] fn solve_is_not_offered_on_a_denied_problem() { let (mut st, n) = solvable_fixture(); st.problems.get_mut(&n).expect("problem").denied = true; assert!( !solvable(&st, PlayerId(0)).contains(&n), "SOLVE offered on a Denied Problem" ); } /// The ruling's (c): a Problem claimed in a PRIOR round is not /// offered. At Select time every `claimed_by` is prior-round, because /// claims land at Resolve — so the same-round race needs no code. #[test] fn solve_is_not_offered_on_an_already_claimed_problem() { let (mut st, n) = solvable_fixture(); st.problems.get_mut(&n).expect("problem").claimed_by = Some(PlayerId(1)); assert!( !solvable(&st, PlayerId(0)).contains(&n), "SOLVE offered on an already-claimed Problem" ); } fn policies(kind: &str, n: u8, seed: u64) -> Vec> { (0..n) .map(|i| -> Box { match kind { "greedy" => Box::new(GreedyPolicy), _ => Box::new(RandomPolicy::new(seed + u64::from(i))), } }) .collect() } /// Acceptance, first half: a 3-player all-bot game reaches GR-R09's /// end with every command legal (nothing was rejected — `play` /// returns `Err` on the first rejection). #[test] fn three_player_all_bot_game_reaches_game_ended() { for kind in ["random", "greedy"] { let game = play(setup(3, 42), &mut policies(kind, 3, 42)) .unwrap_or_else(|e| panic!("{kind} bots: {e}")); let outcome = game.state.outcome.as_ref().expect("GR-R09 outcome"); assert_eq!(game.state.round, 5, "{kind}: GR-R09 runs five rounds"); assert!( game.events .iter() .any(|e| matches!(e, crate::GroundEvent::GameEnded { .. })), "{kind}: GameEnded must be in the log" ); // Positive control: a driver that did nothing would still see // an outcome if the state arrived scored, so assert the work. assert!( game.commands > 3 * 5, "{kind}: only {} commands for a five-round game", game.commands ); assert!(outcome.threshold > 0); } } /// Acceptance, second half (K8): same seed, identical state hash. #[test] fn same_seed_bot_games_are_hash_identical() { let a = play(setup(3, 7), &mut policies("random", 3, 7)).expect("run a"); let b = play(setup(3, 7), &mut policies("random", 3, 7)).expect("run b"); assert_eq!( state_hash_hex(&a.state), state_hash_hex(&b.state), "same-seed bot games must be hash-identical" ); assert_eq!(a.events.len(), b.events.len()); // And the seed must matter, or the equality above is vacuous — // two runs of a bot that ignores its RNG are also identical. let c = play(setup(3, 7), &mut policies("random", 3, 999)).expect("run c"); assert_ne!( state_hash_hex(&a.state), state_hash_hex(&c.state), "different policy seeds must produce a different game" ); } /// The positive control the workplan asks for: no legal move must be /// loud. A seat the game does not contain has no legal command, and /// the driver must say so rather than skip it. #[test] fn a_seat_with_no_legal_move_fails_loudly() { let mut state = setup(3, 1); let ghost = PlayerId(9); assert!(legal_commands(&state, ghost).is_empty()); let mut ps = policies("greedy", 10, 0); let err = step_seat(&mut state, &mut Log::default(), &mut ps, ghost, 1, false) .expect_err("a seat with nothing legal must fail"); assert!(matches!(err, BotError::NoLegalMove { seat, .. } if seat == ghost)); } /// A policy that passes where it may not is a defect, not a turn. #[test] fn passing_when_obligatory_fails_loudly() { struct Passer; impl Policy for Passer { fn name(&self) -> &'static str { "passer" } fn choose( &mut self, _: &GroundState, _: PlayerId, _: &[GroundCommand], _: bool, ) -> Choice { Choice::Pass } } let mut ps: Vec> = (0..3) .map(|_| Box::new(Passer) as Box) .collect(); let err = play(setup(3, 3), &mut ps).expect_err("a passing bot must not finish a game"); assert!( matches!(err, BotError::PassedWhenObligatory { .. }), "got {err}" ); } /// An out-of-range index is not clamped to a legal move. #[test] fn an_out_of_range_choice_fails_loudly() { struct Wild; impl Policy for Wild { fn name(&self) -> &'static str { "wild" } fn choose( &mut self, _: &GroundState, _: PlayerId, legal: &[GroundCommand], _: bool, ) -> Choice { Choice::Command(legal.len()) } } let mut ps: Vec> = (0..3).map(|_| Box::new(Wild) as Box).collect(); let err = play(setup(3, 3), &mut ps).expect_err("an out-of-range index must fail"); assert!(matches!(err, BotError::IllegalChoice { .. }), "got {err}"); } /// Every command a bot issues goes through `validate`. This asserts /// the generator agrees with the kernel rather than trusting it. #[test] fn every_generated_command_validates() { let state = setup(3, 11); let mut total = 0; for seat in state.players.keys().copied() { let legal = legal_commands(&state, seat); assert!(!legal.is_empty(), "seat {seat} has no opening move"); for cmd in &legal { assert!( state.validate(Actor::Player(seat), cmd).is_ok(), "generated {cmd:?} is not legal for {seat}" ); } total += legal.len(); } assert!( total > 10, "only {total} legal opening commands across 3 seats" ); } /// The greedy heuristic must actually be a heuristic — a policy that /// ranked everything equally would pick index 0 and still finish. #[test] fn greedy_prefers_solve_over_attack() { let state = setup(3, 5); let seat = PlayerId(0); let legal = legal_commands(&state, seat); let mut p = GreedyPolicy; let Choice::Command(i) = p.choose(&state, seat, &legal, false) else { panic!("greedy never passes"); }; match &legal[i] { GroundCommand::SelectAction { action, .. } => assert!( matches!(action, Action::Solve | Action::Investigate), "greedy opened with {action:?}" ), other => panic!("greedy opened with {other:?}"), } } /// HDN control: a bot game that "finishes" without touching the board /// is the failure this project keeps finding. Assert the game moved. #[test] fn a_bot_game_does_substantive_work() { for kind in ["random", "greedy"] { let g = play(setup(3, 42), &mut policies(kind, 3, 42)).expect("game"); let mut counts: std::collections::BTreeMap = std::collections::BTreeMap::new(); for e in &g.events { let text = format!("{e:?}"); let kind = text.split([' ', '{']).next().unwrap_or("?").to_string(); *counts.entry(kind).or_default() += 1; } println!( "{kind}: cmds={} events={} {counts:?}", g.commands, g.events.len() ); println!(" outcome {:?}", g.state.outcome); // Every seat selected in every round (GR-R02 × GR-R09). assert_eq!( counts.get("ActionSelected").copied().unwrap_or(0), 3 * 5, "{kind}: a five-round three-player game has fifteen selections" ); // And resolution actually did something with them. A driver // that selected, revealed and ended without resolving would // pass every assertion above this line. let board_moved: usize = [ "ProblemClaimed", "ProblemRevealed", "RelationFormed", "StressSet", "SolutionDrawn", ] .iter() .filter_map(|k| counts.get(*k)) .sum(); assert!( board_moved >= 4, "{kind}: only {board_moved} board-changing events in a whole game" ); } } /// GR-O01 says 2–6 players. Every scenario in the corpus was /// 3-player until CB-WP-0008 T03, so the range was stated and tested /// at one point. This plays all five counts with both policies. #[test] fn every_seat_count_in_gr_o01_plays_to_the_end() { for players in 2..=6u8 { for kind in ["random", "greedy"] { let game = play(setup(players, 42), &mut policies(kind, players, 42)) .unwrap_or_else(|e| panic!("{players}p {kind}: {e}")); let outcome = game .state .outcome .as_ref() .unwrap_or_else(|| panic!("{players}p {kind}: no outcome")); assert_eq!(game.state.round, 5, "{players}p {kind}: GR-R09"); assert_eq!( outcome.personal.len(), usize::from(players), "{players}p {kind}: every seat must be scored" ); // K8 at each boundary, not only at three seats. let again = play(setup(players, 42), &mut policies(kind, players, 42)).expect("second run"); assert_eq!( cb_events::state_hash_hex(&game.state), cb_events::state_hash_hex(&again.state), "{players}p {kind}: same seed must reproduce" ); } } } // `the_standard_preset_cannot_reach_the_threshold_below_five_seats` // lived here and was deleted 2026-08-04, on its own instruction: it // said "the failure is the signal to delete it, not to re-tune it". // ground-game ruled GR-S01's deal and the game became winnable. // // The record did not go with it. `gd0001_group_success_is_reachable_at // _every_seat_count` in lib.rs is the same arithmetic, inverted rather // than removed, and carries why the numbers changed. } /// What a seat is trying to maximise (CB-WP-0049 T02). /// /// **Read off `GroundState::score`, never restated.** The game already /// computes all three answers to build its `Outcome`; a copy in the bot /// would be a second definition of winning, and the two would disagree /// the first time ground-game rules on F28. /// /// | mode | objective | /// |---|---| /// | SHARED GROUND | the group total | /// | COMMON PROBLEM | own claimed value − own Blame | /// | BONDED COALITIONS | own coalition's summed personal score | /// /// It reads only `claimed_by`, `value` of **claimed** Problems and Blame /// tokens — all of which are on the table — so it is blind by /// construction (ADR-0023) rather than by inspection. pub fn objective(state: &GroundState, seat: PlayerId) -> i32 { let o = state.score(); match state.mode { ScoringMode::SharedGround => o.total as i32, ScoringMode::CommonProblem => o.personal.get(&seat).copied().unwrap_or(0), ScoringMode::BondedCoalitions => o .coalitions .iter() .find(|c| c.members.contains(&seat)) .map(|c| c.score) // A seat in no coalition is a one-seat coalition (GR-E04), so // falling back to its own score is the rule and not a guess. .unwrap_or_else(|| o.personal.get(&seat).copied().unwrap_or(0)), } } /// A seat that plays **its own** objective (CB-WP-0049 T03). /// /// ## Where the modes actually differ, and where they cannot /// /// Working this out was most of the task. **SOLVE always claims for the /// actor**, so a seat maximising its own score and a seat maximising the /// group's want the same SOLVE in almost every position — which is a /// fact about GROUND's action set, not a shortcoming of the bot, and it /// bounds how far apart any two policies can get. /// /// Two places the objective really does diverge, both readable off the /// table: /// /// - **SUPPORT regulates someone else.** Under SHARED GROUND that is /// worth what it is worth to the group. Under COMMON PROBLEM the /// beneficiary is a rival, so it is worth less. Under BONDED /// COALITIONS a Bond *merges that seat into my coalition*, and my score /// becomes the coalition's sum — so it is worth more, and worth most /// with a seat not already in my network. /// - **SOLVE's value is the Problem's value.** `GreedyPolicy::rank` gives /// every legal SOLVE 90 regardless of what the card is worth. Under a /// competitive objective the difference between a 2 and a 3 is the /// whole margin. /// /// **It delegates.** Every arm this does not name comes from /// `GreedyPolicy::rank`. CB-WP-0039 re-typed an abridged copy of greedy /// and called it "one preference changed"; it differed in five places and /// burned the Freedom token in round one of every game. Delegating makes /// "these arms and no others" structurally true instead of a claim in a /// comment. /// /// **Blind by construction** (ADR-0023): it reads Problem values only for /// face-up Problems, and relations, which are on the table. pub struct ObjectivePolicy; impl ObjectivePolicy { /// The seats whose personal score counts toward `seat`'s objective. fn my_side(state: &GroundState, seat: PlayerId) -> Vec { match state.mode { // Everyone's claims are my claims. ScoringMode::SharedGround => state.players.keys().copied().collect(), ScoringMode::CommonProblem => vec![seat], ScoringMode::BondedCoalitions => state .score() .coalitions .into_iter() .find(|c| c.members.contains(&seat)) .map(|c| c.members) .unwrap_or_else(|| vec![seat]), } } pub fn rank(state: &GroundState, seat: PlayerId, cmd: &GroundCommand) -> i32 { let base = GreedyPolicy::rank(state, seat, cmd); match cmd { // SOLVE, weighted by what the card is worth. Face-up only: // `legal_commands` offers SOLVE on face-up Problems, and the // lookup returns nothing for a card this seat cannot read. GroundCommand::SelectAction { action: Action::Solve, problem: Some(n), .. } => { let worth = state .problems .get(n) .filter(|p| p.face_up && p.claimed_by.is_none()) .map(|p| i32::from(p.value)) .unwrap_or(0); base + worth } // SUPPORT, weighted by whether the target is on my side. GroundCommand::SelectAction { action: Action::Support, target: Some(other), .. } => { let side = Self::my_side(state, seat); if side.contains(other) { base + 5 } else { match state.mode { // A Bond would bring them onto my side, and my // score is my side's sum. ScoringMode::BondedCoalitions => base + 10, // Regulating a rival is work I do for them. ScoringMode::CommonProblem => base - 30, ScoringMode::SharedGround => base, } } } _ => base, } } } impl Policy for ObjectivePolicy { fn name(&self) -> &'static str { "objective" } fn choose( &mut self, state: &GroundState, seat: PlayerId, legal: &[GroundCommand], _may_pass: bool, ) -> Choice { let mut best = 0; for (i, c) in legal.iter().enumerate() { if Self::rank(state, seat, c) > Self::rank(state, seat, &legal[best]) { best = i; } } Choice::Command(best) } } /// **A policy is bound by what its seat can see** ([ADR-0023]). /// /// `Policy::choose` takes the whole `GroundState`, which carries every /// face-down Problem's suit and value and every seat's hand — exactly /// what `project` exists to withhold. Nothing in the type system stops a /// policy reading it, so this establishes the property behaviourally: /// **vary only what the seat cannot see, and the choice must not move.** /// /// This binds every policy, including ones written later and ones written /// outside this crate, without their cooperation. /// /// [ADR-0023]: ../../../decisions/ADR-0023-a-policy-is-bound-by-what-its-seat-can-see.md #[cfg(test)] pub mod blindness { use super::*; /// Every rearrangement of what `seat` cannot see, as states that are /// **identical in that seat's projection**. /// /// Concretely: permute the suits and values of every face-down /// Problem, and permute the other seats' hands. A seat that plays the /// game can tell none of these apart. pub fn hidden_rearrangements(state: &GroundState, seat: PlayerId) -> Vec { let mut out = Vec::new(); // **The multiset must move, not only the arrangement.** The first // version rotated every hidden value 2<->3 together, which leaves // `max` and (with equal counts) `sum` invariant — so a policy // ranking by the largest hidden value was NOT caught. A control // whose variation is invariant under the statistic a violator // reads is not a control. These force all-2, all-3 and an // alternating split, so any symmetric function of the hidden // values moves across the family. for shift in 0..=3u8 { let mut alt = state.clone(); for (i, p) in alt.problems.values_mut().enumerate() { if p.face_up { continue; } // A different card under the same back. p.value = match shift { 0 => 2, 1 => 3, 2 => 2 + (i as u8 % 2), _ => 3 - (i as u8 % 2), }; p.suit = rotate_suit(p.suit, shift + 1); } for (other, ps) in alt.players.iter_mut() { if *other == seat { continue; } for c in ps.hand.iter_mut() { c.suit = rotate_suit(c.suit, shift); } } out.push(alt); } out } fn rotate_suit(s: crate::Suit, by: u8) -> crate::Suit { use crate::Suit::*; let order = [Clarify, Repair, Boundary, Change]; let i = order.iter().position(|x| *x == s).unwrap_or(0); order[(i + by as usize) % order.len()] } /// Does `policy` choose the same thing across every rearrangement? /// /// Returns the disagreement if there is one, so the caller can report /// *what* moved rather than only that something did. /// **Takes a constructor, not a policy.** `choose` may advance /// internal state — `RandomPolicy` draws from its own stream — so /// reusing one instance compares a first call against a fourth and /// reports every stateful policy as a peeker. The first version did /// exactly that and accused `random`. Each variant is judged from an /// identical starting policy, which is the only way the difference /// between two runs is the state and nothing else. pub fn is_blind( mut make: impl FnMut() -> P, state: &GroundState, seat: PlayerId, ) -> Result<(), String> { let legal = legal_commands(state, seat); if legal.is_empty() { return Ok(()); } let base = make().choose(state, seat, &legal, false); for alt in hidden_rearrangements(state, seat) { // **The same legal set, deliberately.** Rearranging hidden // cards can change which moves are legal, and a policy that // picks a different INDEX into a different list has not // necessarily seen anything. Holding the list fixed varies // only the state, which is the variable under test. let mut p = make(); let got = p.choose(&alt, seat, &legal, false); if got != base { return Err(format!( "{} chose {:?} and then {:?} over the same legal moves, \ with only hidden cards rearranged", p.name(), base, got )); } } Ok(()) } } #[cfg(test)] mod blindness_tests { use super::blindness::*; use super::*; use cb_game_runtime::{ScenarioGame, Setup}; fn deal(players: u8, seed: u64) -> GroundState { GroundState::setup( &Setup { players, preset: format!("standard-{players}p"), patch: Default::default(), }, seed, ) .expect("preset") } /// **A policy that peeks IS CAUGHT** (ADR-0023 D3). /// /// The control is proven against a deliberate violator before it is /// trusted about compliant ones. A peek control that nothing can fail /// is decoration (ADR-0006 D3) — and this is the shape of policy /// CB-WP-0049 is about to write, ranking a claim by a Problem's /// value, which is the field the projection hides. #[test] fn the_peek_control_catches_a_peeking_policy() { struct Peeker; impl Policy for Peeker { fn name(&self) -> &'static str { "peeker" } fn choose( &mut self, state: &GroundState, _seat: PlayerId, legal: &[GroundCommand], _may_pass: bool, ) -> Choice { // Rank by the value of the highest FACE-DOWN Problem — // information the seat does not have. let hidden: u8 = state .problems .values() .filter(|p| !p.face_up) .map(|p| p.value) .max() .unwrap_or(0); Choice::Command((hidden as usize) % legal.len()) } } let st = deal(3, 7); let seat = PlayerId(0); assert!( st.problems.values().any(|p| !p.face_up), "the fixture must actually hide something" ); let err = is_blind(|| Peeker, &st, seat) .expect_err("a policy reading face-down values must be caught"); assert!(err.contains("peeker"), "{err}"); } /// The new policy is blind too — the point of T01 being first. #[test] fn the_objective_policy_is_blind() { for seed in [1u64, 7, 42] { for players in [2u8, 4, 6] { for mode in [ ScoringMode::SharedGround, ScoringMode::CommonProblem, ScoringMode::BondedCoalitions, ] { let mut st = deal(players, seed); st.mode = mode; for seat in st.players.keys().copied() { is_blind(|| ObjectivePolicy, &st, seat) .unwrap_or_else(|e| panic!("{players}p {mode:?} seed {seed}: {e}")); } } } } } /// **The objective is the game's own scoring** (CB-WP-0049 T02). #[test] fn the_objective_agrees_with_the_outcome() { let mut st = deal(4, 5); let seats: Vec = st.players.keys().copied().collect(); // Claim two Problems for two different seats, so group and // personal cannot coincide by accident. let keys: Vec = st.problems.keys().copied().collect(); st.problems.get_mut(&keys[0]).unwrap().claimed_by = Some(seats[0]); st.problems.get_mut(&keys[1]).unwrap().claimed_by = Some(seats[1]); let o = st.score(); st.mode = ScoringMode::SharedGround; assert_eq!(objective(&st, seats[0]), o.total as i32); // Everyone shares one number. assert_eq!(objective(&st, seats[0]), objective(&st, seats[2])); st.mode = ScoringMode::CommonProblem; assert_eq!(objective(&st, seats[0]), o.personal[&seats[0]]); // **And a seat's objective is NOT the group's** — the claim that // makes a competitive mode competitive, asserted on a board // rather than argued. assert_ne!( objective(&st, seats[0]), st.score().total as i32, "under COMMON PROBLEM a seat's objective coincided with the group's" ); assert_ne!( objective(&st, seats[0]), objective(&st, seats[2]), "a claiming seat and an empty-handed seat had the same objective" ); st.mode = ScoringMode::BondedCoalitions; let mine = st .score() .coalitions .into_iter() .find(|c| c.members.contains(&seats[0])) .expect("every seat is in a coalition"); assert_eq!(objective(&st, seats[0]), mine.score); } /// **Under SHARED GROUND the objective policy IS greedy** — the /// control that separates "attends to the objective" from "plays /// differently". /// /// Without it, any change in the panel could be the new policy simply /// being a different bot. The two must agree wherever the objective /// is the group's, and diverge only where it is not. #[test] fn under_shared_ground_the_objective_policy_and_greedy_want_the_same_thing() { let mut differed = 0; for seed in [1u64, 7, 42, 99] { for players in [2u8, 4, 6] { let mut st = deal(players, seed); st.mode = ScoringMode::SharedGround; for seat in st.players.keys().copied() { let legal = legal_commands(&st, seat); if legal.is_empty() { continue; } let a = GreedyPolicy.choose(&st, seat, &legal, false); let b = ObjectivePolicy.choose(&st, seat, &legal, false); if a != b { differed += 1; } } } } // SOLVE is weighted by card value in every mode, so a tie greedy // broke by order can break the other way here. That is a // refinement of greedy's own objective, not a different one — so // the assertion is that divergence is RARE, with the number // stated rather than a vague "mostly". assert!( differed <= 2, "under SHARED GROUND the two policies differed at {differed} decision points; they are supposed to share an objective" ); } /// **Under COMMON PROBLEM they do NOT** — and the reason is Support. #[test] fn a_competitive_seat_values_supporting_a_rival_less() { let mut st = deal(4, 5); let seat = PlayerId(0); let other = PlayerId(1); let support = GroundCommand::SelectAction { action: Action::Support, target: Some(other), problem: None, }; st.mode = ScoringMode::SharedGround; let shared = ObjectivePolicy::rank(&st, seat, &support); st.mode = ScoringMode::CommonProblem; let selfish = ObjectivePolicy::rank(&st, seat, &support); st.mode = ScoringMode::BondedCoalitions; let coalition = ObjectivePolicy::rank(&st, seat, &support); assert!( selfish < shared, "a seat scoring only its own claims valued regulating a rival the same as a cooperative seat did ({selfish} vs {shared})" ); assert!( coalition > shared, "under BONDED COALITIONS a Bond brings that seat's score into mine, so Support is worth MORE, not the same ({coalition} vs {shared})" ); } /// And the shipped policies are blind. #[test] fn every_shipped_policy_is_blind_to_what_its_seat_cannot_see() { for seed in [1u64, 7, 42] { for players in [2u8, 4, 6] { let st = deal(players, seed); for seat in st.players.keys().copied() { is_blind(|| GreedyPolicy, &st, seat) .unwrap_or_else(|e| panic!("{players}p seed {seed}: {e}")); // Random is seeded from its own stream, so it is // blind for a different reason — included because // "every shipped policy" must mean every one. is_blind(|| RandomPolicy::new(seed), &st, seat) .unwrap_or_else(|e| panic!("{players}p seed {seed}: {e}")); } } } } /// The rearrangements must actually rearrange. /// /// A control that varies nothing passes for every policy, including /// the peeker — so the fixture's own sensitivity is asserted rather /// than assumed. #[test] fn the_rearrangements_change_the_hidden_cards() { let st = deal(4, 3); let alts = hidden_rearrangements(&st, PlayerId(0)); assert!(!alts.is_empty()); let hidden = |s: &GroundState| -> Vec<(u8, String)> { s.problems .values() .filter(|p| !p.face_up) .map(|p| (p.value, format!("{:?}", p.suit))) .collect() }; assert!( alts.iter().any(|a| hidden(a) != hidden(&st)), "no rearrangement changed a hidden card" ); // And they leave the VISIBLE game alone, or the test would be // varying two things at once. let seen = |s: &GroundState| -> Vec<(u8, String)> { s.problems .values() .filter(|p| p.face_up) .map(|p| (p.value, format!("{:?}", p.suit))) .collect() }; for a in &alts { assert_eq!(seen(a), seen(&st), "a rearrangement moved a face-up card"); } } }