clay-borg/games/ground/src/bot.rs

1102 lines
40 KiB
Rust
Raw Normal View History

//! 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<PlayerId>,
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<crate::GroundEvent>,
/// 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<GroundCommand> {
let seats: Vec<PlayerId> = state.players.keys().copied().collect();
let problems: Vec<u32> = state.problems.keys().copied().collect();
let mut candidates: Vec<GroundCommand> = 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 {
CB-REV-0001: the adversarial review, and it was not approvable Thirteen challenges, five FATAL, all five conceded. Nothing had reached ground-game, which is the only reason this is a correction and not a retraction. The worst: `Reactive` was not "greedy with one preference changed". It differed in five, including SpendFreedom — ranked 95 unconditionally against greedy's `95 if gated else 0` — so the seat burned its Freedom token in round one of every game. A second change to the exact mechanism under study, and every number in CB-EV-0031 was measuring it. The pass claimed ADR-0018's one-varying-parameter discipline in its own workplan while violating it. GreedyPolicy::rank is now public and the policy delegates, overriding one match arm, so the control is structurally true. Withdrawn entirely: "H1-B suppresses DARVO in the attacker". Disabling H1-B under the corrected policy changes the arm count by exactly zero. The pass hedged the wrong variable — it disclaimed "the number 2" and defended "the direction", and the direction is what failed. The supporting inference was invalid anyway: final Stress cannot show who armed, because DarvoEnded resets the stage and REVERSE gives its owner -2. Corrected: criterion 1 was failed on the greedy column while the pass's own printed table showed 31-1000 arms in the other columns — the selective-column move, in the file that names it. "Peak Stress was 1" was a maximum over StressSet payloads, not held state (true: 2); the baseline game count was 1,600 not 3,200; and "a reckless policy plays identically to a careful one" is refuted by this repo's own rank-95 policy. Inert controls replaced, each verified red against the reviewer's own mutation: the baseline hash test compared two identically-constructed states (serde(skip) on variant left 57/57 green); the `unchanged:` test checked 3 of 7 entries and passed with SOLVE made illegal; H1-A's ordering and H1-B's OU-cancel path had no test at all. edition-check now covers catalog.yaml and rules_delta.yaml, whose digests CB-WP-0038 claimed and never recorded — the review found it and reported it unverified rather than absent, which was the right call. Still open: H1-B on the DARVO extra-Attack path is untested, regulation.rs still skips setup failures silently, and round-5 arms are counted though they can never act. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 02:02:35 +02:00
/// 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<dyn Policy + 'a>],
) -> Result<BotGame, BotError> {
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<dyn Policy + 'a>],
journal: Option<Journal>,
) -> Result<BotGame, BotError> {
let mut log = Log {
journal,
..Log::default()
};
let seats: Vec<PlayerId> = 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<crate::GroundEvent>,
}
/// 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<std::cell::RefCell<Vec<Applied>>>;
/// What the driver accumulates while a game runs.
#[derive(Default)]
struct Log {
events: Vec<crate::GroundEvent>,
steps: Vec<(Actor, GroundCommand)>,
journal: Option<Journal>,
}
/// 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<dyn Policy + '_>],
seat: PlayerId,
round: u8,
may_pass: bool,
) -> Result<bool, BotError> {
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<Selection> {
state.selections.get(&seat).copied()
}
/// Relation between two seats, for the same reason.
pub fn relation_of(state: &GroundState, a: PlayerId, b: PlayerId) -> Option<Relation> {
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<u32> {
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<u32> = 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<Box<dyn Policy>> {
(0..n)
.map(|i| -> Box<dyn Policy> {
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<Box<dyn Policy>> = (0..3)
.map(|_| Box::new(Passer) as Box<dyn Policy>)
.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<Box<dyn Policy>> =
(0..3).map(|_| Box::new(Wild) as Box<dyn Policy>).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<String, usize> =
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 26 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"
);
}
}
}
CB-WP-0021 T01/T02/T05: the engine plays its own data — AM-7 blocks ADR-0011 decided it: vendor the CSV with a checked digest, read it with a ~50-line reader, and let the hashes move. The declaration's constraint was measured against the WRONG BUDGET. It said a CSV crate costs 21,613 against AM-4a's 3,798 of headroom, '5.7x over, settled by measurement'. But setup and problem_priorities are cfg(scenarios) and are not in the shipped runtime at all, so AM-4a never sees them. Against AM-4b, csv costs 17,651 against 19,742 -- it FITS, with 2,091 to spare. It is refused anyway, on proportion: 89% of the budget's remaining capacity to read 20 rows. The revisit condition is stated (nested quoting, embedded newlines, multiple dialects). GR-S01 now deals Surface + hidden 1..=k as ruled, with edition values and suits. Measured: 6/9/12 available against thresholds 5/7/9 -- the game is winnable at every seat count, which is what the maintainer could not do. gd0001 is INVERTED, not deleted, and now also asserts the 6/9/12 so a deal that is reachable for the wrong reason still fails. Blast radius was scenario expectations, exactly as the ADR predicted: no scenario pinned a hash and no bundle is committed. Six scenarios and two unit tests updated, each with a note. gr-e01-threshold-unreachable-2p is RENAMED to -reachable- and rewritten as the non-provisional import check ground-game asked for by name. gr-e03's setup was restructured, not just renumbered: with values 2,2,2 its personal-edge test would have tied three ways and asserted nothing. BLOCKING: AM-7 fails at median 0.845 against its 0.9 floor. Isolated across three runs -- 3 problems + stand-in 0.97, 3 problems + edition 0.909, 4 problems + edition 0.845. State is BOUNDED (proven: identical after 5k and 100k events), so this is not the unbounded-growth defect AM-7 exists to catch; it is a bigger working set streaming a long log. Whether AM-7's floor is still right for a larger aggregate is a spec question and lowering it requires an ADR, so it is not being tuned here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:47:56 +02:00
// `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.
}