CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
Some checks failed
ci / check (push) Failing after 4s
Some checks failed
ci / check (push) Failing after 4s
A Policy trait, a seeded random policy and a greedy one with a stated heuristic, a legal-command generator that filters candidates through validate, and a driver that runs a 3-player all-bot game to GameEnded. Same-seed runs are hash-identical (K8), and a different policy seed produces a different game — without that second assertion the first is satisfied by a bot that ignores its RNG. Every failure is loud, because the one a bot driver must not have is the silent one: no legal move, passing where an action is required, an out-of-range index (not clamped), a rejected command, and a stall guard. What the second consumer found, none of it fixed here: - GR-A13 admits SOLVE against an already-claimed Problem and resolution then does nothing — the action is silently wasted. The policy avoids it; the rule is left for a ruling. - The 3-player standard fixture cannot reach GR-E01's threshold of 7: three Problems valued 1,2,3 cap the total at 6. No scenario noticed because none plays to scoring. - K13's Project trait still has zero implementors. T02 is its first. Mutation-checked by hand. The first mutation was a no-op and survived; removing the Resolve call outright turned three tests red for the stated reason. Third instance of the weak-mutation class. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
be2d7a3e5a
commit
6197677126
4 changed files with 926 additions and 2 deletions
849
games/ground/src/bot.rs
Normal file
849
games/ground/src/bot.rs
Normal file
|
|
@ -0,0 +1,849 @@
|
|||
//! 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>,
|
||||
/// 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 {
|
||||
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 {
|
||||
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(mut state: GroundState, policies: &mut [Box<dyn Policy>]) -> Result<BotGame, BotError> {
|
||||
let mut events = Vec::new();
|
||||
let mut commands = 0usize;
|
||||
|
||||
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 events,
|
||||
&mut commands,
|
||||
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 events,
|
||||
&mut commands,
|
||||
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 events,
|
||||
&mut commands,
|
||||
policies,
|
||||
*seat,
|
||||
round,
|
||||
!obligatory,
|
||||
)? {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
apply(
|
||||
&mut state,
|
||||
&mut events,
|
||||
&mut commands,
|
||||
Actor::System,
|
||||
&GroundCommand::Resolve,
|
||||
)?;
|
||||
apply(
|
||||
&mut state,
|
||||
&mut events,
|
||||
&mut commands,
|
||||
Actor::System,
|
||||
&GroundCommand::EndRound,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(BotGame {
|
||||
state,
|
||||
events,
|
||||
commands,
|
||||
rounds,
|
||||
})
|
||||
}
|
||||
|
||||
/// Offer one seat its legal commands and apply what the policy picks.
|
||||
/// `Ok(false)` means the seat passed.
|
||||
fn step_seat(
|
||||
state: &mut GroundState,
|
||||
events: &mut Vec<crate::GroundEvent>,
|
||||
commands: &mut usize,
|
||||
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, events, commands, Actor::Player(seat), cmd)?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(
|
||||
state: &mut GroundState,
|
||||
events: &mut Vec<crate::GroundEvent>,
|
||||
commands: &mut usize,
|
||||
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);
|
||||
}
|
||||
events.extend(produced);
|
||||
*commands += 1;
|
||||
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")
|
||||
}
|
||||
|
||||
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 Vec::new(),
|
||||
&mut 0,
|
||||
&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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,12 @@
|
|||
//! GameKernel K15–K16). Every rule realized here names its GR-id in a doc
|
||||
//! comment, giving a greppable rule→code→scenario chain.
|
||||
|
||||
/// Bots (CB-WP-0008 T01) — the kernel's first non-scenario consumer.
|
||||
/// Deliberately **not** gated on `scenarios`: a bot needs only the
|
||||
/// aggregate. That its tests do need `scenarios` is a real seam — setup
|
||||
/// presets currently live behind that feature (see `bot.rs`).
|
||||
pub mod bot;
|
||||
|
||||
#[cfg(feature = "scenarios")]
|
||||
use cb_game_runtime::{parse_actor, CommandStep, ScenarioGame, Setup};
|
||||
use cb_kernel::{Actor, Aggregate, ChaChaRng, KernelRng, PlayerId, Rejection, Seed};
|
||||
|
|
|
|||
64
history/260801-cb-wp-0008-log.md
Normal file
64
history/260801-cb-wp-0008-log.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# 2026-08-01 — CB-WP-0008 delivery notes
|
||||
|
||||
Kept out of the workplan file so it stays loadable (`loop-lint`).
|
||||
|
||||
## T01 — bots
|
||||
|
||||
`games/ground/src/bot.rs`, 8 tests. `Policy` + `RandomPolicy` (seeded
|
||||
`ChaChaRng`) + `GreedyPolicy` (stated heuristic, deterministic), a
|
||||
`legal_commands` generator that filters candidates through `validate`, and
|
||||
a driver that reaches `GameEnded`.
|
||||
|
||||
Measured, 3-player, seed 42, both policies to GR-R09's fifth round:
|
||||
|
||||
| policy | commands | events | claimed | total vs threshold |
|
||||
|---|---|---|---|---|
|
||||
| random | 36 | 81 | 1 | 2 / 7 |
|
||||
| greedy | 30 | 51 | 2 | 3 / 7 |
|
||||
|
||||
**The module is not gated on `scenarios`; its tests are.** A bot needs
|
||||
only the aggregate, but the setup presets live behind that feature. That
|
||||
is a real seam and it is left visible rather than papered over with a
|
||||
feature the bot does not need.
|
||||
|
||||
### What the second consumer found
|
||||
|
||||
INTENT's second-use rule is why T01 was worth doing before the CLI. Three
|
||||
findings, none of them fixed here, because fixing a rule from inside a bot
|
||||
is exactly the invention-in-isolation the rule guards against.
|
||||
|
||||
1. **GR-A13 admits SOLVE against an already-claimed Problem.** The rule
|
||||
names "a face-up, non-Denied Problem"; `claimed_by` is not mentioned,
|
||||
so `check_targeting` accepts it and resolution then does nothing. The
|
||||
action is silently wasted. Greedy hit this immediately — it spent all
|
||||
fifteen selections re-solving Problem 1 and scored 1. The **policy**
|
||||
now avoids it; the **rule** is untouched and this is a U-item
|
||||
candidate for GroundRules.
|
||||
|
||||
2. **The 3-player standard fixture cannot succeed.** GR-S01 gives 3
|
||||
Problems at 3–4 players, the preset values them by priority (1, 2, 3 —
|
||||
maximum 6), and GR-E01 sets the 3–4p threshold at **7**.
|
||||
`group_success` is therefore unreachable at 3 players regardless of
|
||||
play. No scenario noticed because no scenario plays to scoring with
|
||||
maximal claiming. The fixture is documented as placeholder ("Problem
|
||||
content is scenario data") — so this is evidence that the placeholder
|
||||
is not neutral, not that GR-E01 is wrong.
|
||||
|
||||
3. **K13's `Project` trait has zero implementors.** `grep` finds no
|
||||
`impl Project` anywhere in the tree. T02 is its first consumer, and
|
||||
the projection will be written against a trait that has never been
|
||||
exercised.
|
||||
|
||||
### Process
|
||||
|
||||
The acceptance tests were mutation-checked by hand (no `mutation-check`
|
||||
row — that denominator is the GameKernel acceptance table, and adding a
|
||||
game-level row would move a metric by changing its question).
|
||||
|
||||
**The first mutation was a no-op and SURVIVED**: it changed
|
||||
`apply(...)?` to `let _ = apply(...)`, which still issues the command.
|
||||
Removing the `Resolve` call outright turned three tests red for their
|
||||
stated reason. Third recorded instance of the weak-mutation class — the
|
||||
retrospective in `260801-instrument-the-table-retrospective.md` predicted
|
||||
mutation strength would stay a standing maintenance cost, and this is
|
||||
that cost arriving on the next pass.
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
id: CB-WP-0008
|
||||
kind: product
|
||||
title: "Ship INTENT stage 0: a GROUND game you can actually play"
|
||||
status: proposed
|
||||
status: in_progress
|
||||
state_hub_workstream_id: "ee960213-ffc9-4654-a962-35017a231fe2"
|
||||
---
|
||||
|
||||
|
|
@ -34,7 +34,7 @@ and the `LogStore` port are the right shapes.
|
|||
|
||||
```task
|
||||
id: CB-WP-0008-T01
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "832d934d-a239-4392-a649-b8bb736ef07f"
|
||||
```
|
||||
|
|
@ -55,6 +55,11 @@ because a stalled bot looks like a finished game.
|
|||
rounds) with every command legal, and two runs at the same seed produce
|
||||
identical state hashes (K8).
|
||||
|
||||
**Done 2026-08-01.** `games/ground/src/bot.rs`, 8 tests, both policies
|
||||
finish. Three findings from the second consumer — a wasted-action gap in
|
||||
GR-A13, an unwinnable 3-player fixture, and K13 with zero implementors —
|
||||
are in `history/260801-cb-wp-0008-log.md`.
|
||||
|
||||
## Task: `cb-play` — the CLI player
|
||||
|
||||
```task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue