//! 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, 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. }