CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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>
2026-08-01 14:29:45 +02:00
|
|
|
|
//! 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>,
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
/// 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)>,
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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>
2026-08-01 14:29:45 +02:00
|
|
|
|
/// 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]`.
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
pub fn play<'a>(
|
2026-08-03 02:15:30 +02:00
|
|
|
|
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>(
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
mut state: GroundState,
|
|
|
|
|
|
policies: &mut [Box<dyn Policy + 'a>],
|
2026-08-03 02:15:30 +02:00
|
|
|
|
journal: Option<Journal>,
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
) -> Result<BotGame, BotError> {
|
2026-08-03 02:15:30 +02:00
|
|
|
|
let mut log = Log {
|
|
|
|
|
|
journal,
|
|
|
|
|
|
..Log::default()
|
|
|
|
|
|
};
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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>
2026-08-01 14:29:45 +02:00
|
|
|
|
|
|
|
|
|
|
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"),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
step_seat(&mut state, &mut log, policies, *seat, round, false)?;
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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>
2026-08-01 14:29:45 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// 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()
|
|
|
|
|
|
),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
apply(&mut state, &mut log, Actor::System, &GroundCommand::Reveal)?;
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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>
2026-08-01 14:29:45 +02:00
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
if !step_seat(&mut state, &mut log, policies, *seat, round, !obligatory)? {
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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>
2026-08-01 14:29:45 +02:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
apply(&mut state, &mut log, Actor::System, &GroundCommand::Resolve)?;
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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>
2026-08-01 14:29:45 +02:00
|
|
|
|
apply(
|
|
|
|
|
|
&mut state,
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
&mut log,
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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>
2026-08-01 14:29:45 +02:00
|
|
|
|
Actor::System,
|
|
|
|
|
|
&GroundCommand::EndRound,
|
|
|
|
|
|
)?;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Ok(BotGame {
|
|
|
|
|
|
state,
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
commands: log.steps.len(),
|
|
|
|
|
|
events: log.events,
|
|
|
|
|
|
steps: log.steps,
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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>
2026-08-01 14:29:45 +02:00
|
|
|
|
rounds,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 02:15:30 +02:00
|
|
|
|
/// 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>>>;
|
|
|
|
|
|
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
/// What the driver accumulates while a game runs.
|
|
|
|
|
|
#[derive(Default)]
|
|
|
|
|
|
struct Log {
|
|
|
|
|
|
events: Vec<crate::GroundEvent>,
|
|
|
|
|
|
steps: Vec<(Actor, GroundCommand)>,
|
2026-08-03 02:15:30 +02:00
|
|
|
|
journal: Option<Journal>,
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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>
2026-08-01 14:29:45 +02:00
|
|
|
|
/// Offer one seat its legal commands and apply what the policy picks.
|
|
|
|
|
|
/// `Ok(false)` means the seat passed.
|
|
|
|
|
|
fn step_seat(
|
|
|
|
|
|
state: &mut GroundState,
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
log: &mut Log,
|
|
|
|
|
|
policies: &mut [Box<dyn Policy + '_>],
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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>
2026-08-01 14:29:45 +02:00
|
|
|
|
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(),
|
|
|
|
|
|
})?;
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
apply(state, log, Actor::Player(seat), cmd)?;
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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>
2026-08-01 14:29:45 +02:00
|
|
|
|
Ok(true)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn apply(
|
|
|
|
|
|
state: &mut GroundState,
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
log: &mut Log,
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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>
2026-08-01 14:29:45 +02:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-08-03 02:15:30 +02:00
|
|
|
|
if let Some(j) = &log.journal {
|
|
|
|
|
|
j.borrow_mut().push(Applied {
|
|
|
|
|
|
actor,
|
|
|
|
|
|
command: cmd.clone(),
|
|
|
|
|
|
events: produced.clone(),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
log.events.extend(produced);
|
|
|
|
|
|
log.steps.push((actor, cmd.clone()));
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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>
2026-08-01 14:29:45 +02:00
|
|
|
|
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);
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
let err = step_seat(&mut state, &mut Log::default(), &mut ps, ghost, 1, false)
|
|
|
|
|
|
.expect_err("a seat with nothing legal must fail");
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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>
2026-08-01 14:29:45 +02:00
|
|
|
|
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"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
CB-WP-0008-T03: prove the 2-6 player range
GR-O01 states 2-6 players; every scenario in the corpus was 3-player.
Now all five counts play to GameEnded under both policies and reproduce
at the same seed, with scenarios at both boundaries and the CLI
transcript run at 2p, 3p and 6p.
Nothing broke — the rules are seat-count-generic. What the boundaries
exposed is arithmetic: with the standard preset's placeholder Problem
values (value = priority), the best total any game can reach is 3 at 2p,
6 at 3-4p, 10 at 5-6p, against GR-E01 thresholds of 5, 7 and 9. Group
success is unreachable below five seats regardless of play, and no
scenario noticed because none had played to scoring with everything
claimed.
GR-S01 calls the fixture a stand-in for scenario Problem data, so this
is evidence the stand-in is not neutral, not that GR-E01 is wrong. It is
pinned by a passing scenario, an arithmetic test, and a provisional
marker owned by ground-game so it ages in `make coverage`. The test
states its own delete-by: it is expected to fail when Problem values
become real data, and that failure is the signal to delete it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 15:20:04 +02:00
|
|
|
|
/// 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"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// **A recorded finding, not a desired property.** With the standard
|
|
|
|
|
|
/// preset's placeholder Problem values (value = priority), the total
|
|
|
|
|
|
/// a game can possibly reach is below GR-E01's threshold at 2, 3 and
|
|
|
|
|
|
/// 4 players — group success is unreachable regardless of play. Only
|
|
|
|
|
|
/// 5–6p can clear its 9.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// This test pins the arithmetic so the gap cannot close silently.
|
|
|
|
|
|
/// **It is expected to fail** when Problem values become scenario
|
|
|
|
|
|
/// data (GR-S01 calls the current fixture a stand-in); the failure is
|
|
|
|
|
|
/// the signal to delete it, not to re-tune it.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn the_standard_preset_cannot_reach_the_threshold_below_five_seats() {
|
|
|
|
|
|
let mut report = Vec::new();
|
|
|
|
|
|
for players in 2..=6u8 {
|
|
|
|
|
|
let initial = setup(players, 42);
|
|
|
|
|
|
let best: u32 = initial.problems.values().map(|p| u32::from(p.value)).sum();
|
|
|
|
|
|
let threshold = play(initial, &mut policies("greedy", players, 42))
|
|
|
|
|
|
.expect("game")
|
|
|
|
|
|
.state
|
|
|
|
|
|
.outcome
|
|
|
|
|
|
.expect("outcome")
|
|
|
|
|
|
.threshold;
|
|
|
|
|
|
report.push(format!("{players}p best {best} vs threshold {threshold}"));
|
|
|
|
|
|
if players < 5 {
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
best < threshold,
|
|
|
|
|
|
"{players}p: best {best} now reaches threshold {threshold} — \
|
|
|
|
|
|
the fixture changed, delete this test"
|
|
|
|
|
|
);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
best >= threshold,
|
|
|
|
|
|
"{players}p: best {best} cannot reach threshold {threshold}"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
println!("GR-E01 reachability: {}", report.join(", "));
|
|
|
|
|
|
}
|
CB-WP-0008-T01: bots — the kernel's first non-scenario consumer
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>
2026-08-01 14:29:45 +02:00
|
|
|
|
}
|