Some checks failed
ci / check (push) Failing after 4s
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>
1101 lines
40 KiB
Rust
1101 lines
40 KiB
Rust
//! 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 {
|
||
/// 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 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.
|
||
}
|