Some checks failed
ci / check (push) Has been cancelled
objective() reads GroundState::score (now public) rather than restating
what winning is; a copy in the bot would disagree with the kernel the
first time ground-game rules on F28.
Working out WHERE the modes can differ was most of the task and it
bounds the result: SOLVE always claims for the actor, so own-score and
group-score want the same SOLVE nearly everywhere. That is a fact about
GROUND's action set, not a shortcoming of the bot. Two real divergences,
both readable off the table: SUPPORT regulates someone else (worth less
against a rival, worth MORE under coalitions where a Bond merges them
into my side), and SOLVE's value is the card's value, which greedy
ignores entirely.
THE RESULT — F27 splits in two:
group success UNCHANGED in 34 of 36 cells
who wins MOVES: BONDED COALITIONS at 4p goes 2.04 -> 2.98,
2.12 -> 3.29, 2.05 -> 3.01 winning seats per game
So "the competitive modes are scoring lenses over cooperative play" was
too strong and is withdrawn. The sharper claim: GROUND's scoring modes
change WHO WINS, not WHETHER THE GROUP SUCCEEDS. And the effect is
seat-band dependent -- 2p none, 4p largest, 6p none under coalitions;
two relation slots capping network growth is a candidate explanation and
is untested.
The panel now prints BOTH policies side by side. That was a correction
mid-task: the first version printed only the new one and I compared it
against a figure remembered from CB-WP-0047 -- a comparison against a
board nobody re-ran.
Control that makes the numbers mean anything: under SHARED GROUND the
two policies agree at all but <=2 decision points across 12 boards, so a
moving column is mode-awareness and not simply a different bot.
Also: two T01 tests keyed on `status: proposed`, which ground-game
renamed to `ready-for-implement` mid-session. They now find the module
by asking resolve() -- the structural property is ours and does not move
when another repo edits its vocabulary.
Also: `make vendor` replaces three hand re-vendors with a tool that
regenerates digests by walking editions/, and reports one-sided files
rather than resolving them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1606 lines
60 KiB
Rust
1606 lines
60 KiB
Rust
//! Bots — the kernel's first non-scenario consumer (CB-WP-0008 T01).
|
||
//!
|
||
//! Everything here drives `GroundState` through the ordinary
|
||
//! `Aggregate::validate` → `fold` path. Nothing in this module reaches
|
||
//! into state to mutate it: a bot that did would prove nothing about the
|
||
//! kernel, which is the whole reason INTENT wants a *second* consumer.
|
||
//!
|
||
//! **Stated limit on [`legal_commands`].** It is complete only up to the
|
||
//! candidate shapes it enumerates. Every candidate it yields is legal —
|
||
//! each one is filtered through `validate` — but a command shape this
|
||
//! function forgets to construct is invisible, and nothing here detects
|
||
//! that. Under-generation is the failure mode; it would show up as a bot
|
||
//! that never uses a rule, not as an error.
|
||
|
||
use crate::{
|
||
Action, DarvoTarget, GroundChoice, GroundCommand, GroundMode, GroundState, Relation, RoundStep,
|
||
ScoringMode, Selection, SupportResponse,
|
||
};
|
||
use cb_kernel::{Actor, Aggregate, ChaChaRng, KernelRng, PlayerId, Rejection, Seed};
|
||
|
||
/// A seat's decision when offered the legal commands available to it.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum Choice {
|
||
/// Index into the offered slice.
|
||
Command(usize),
|
||
/// Decline to act. Legal only where the driver says it is.
|
||
Pass,
|
||
}
|
||
|
||
/// How one seat decides. Implementors see the whole state — bots are not
|
||
/// the place to enforce hidden information; that is K13's projection, and
|
||
/// [`crate::bot`] deliberately does not pretend otherwise.
|
||
pub trait Policy {
|
||
fn name(&self) -> &'static str;
|
||
|
||
/// Pick one of `legal`, or [`Choice::Pass`] when `may_pass`.
|
||
///
|
||
/// `legal` is never empty when this is called: the driver treats an
|
||
/// empty legal set at an obligatory point as [`BotError::NoLegalMove`]
|
||
/// rather than asking a policy to invent a move.
|
||
fn choose(
|
||
&mut self,
|
||
state: &GroundState,
|
||
seat: PlayerId,
|
||
legal: &[GroundCommand],
|
||
may_pass: bool,
|
||
) -> Choice;
|
||
}
|
||
|
||
/// Every way a bot game can fail. All of them are **loud**: the failure a
|
||
/// bot driver must never have is the silent one, where a stalled game is
|
||
/// indistinguishable from a finished one.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum BotError {
|
||
/// A seat had to act and had nothing legal to do.
|
||
NoLegalMove {
|
||
seat: PlayerId,
|
||
round: u8,
|
||
step: RoundStep,
|
||
},
|
||
/// A policy passed where passing is not allowed.
|
||
PassedWhenObligatory {
|
||
seat: PlayerId,
|
||
round: u8,
|
||
step: RoundStep,
|
||
},
|
||
/// A policy returned an index outside the offered slice. Not clamped:
|
||
/// clamping would turn a broken policy into a quietly playing one.
|
||
IllegalChoice {
|
||
seat: PlayerId,
|
||
index: usize,
|
||
offered: usize,
|
||
},
|
||
/// A command the driver generated was rejected. Since generation is
|
||
/// validate-filtered, this means state moved underneath it.
|
||
Rejected {
|
||
seat: Option<PlayerId>,
|
||
command: String,
|
||
rejection: Rejection,
|
||
},
|
||
/// The game did not progress. A bot that loops forever looks like a
|
||
/// bot that is working; this is the guard that makes it look broken.
|
||
Stalled { round: u8, detail: String },
|
||
/// Fewer policies than seats.
|
||
NoPolicy { seat: PlayerId },
|
||
}
|
||
|
||
impl core::fmt::Display for BotError {
|
||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||
match self {
|
||
BotError::NoLegalMove { seat, round, step } => write!(
|
||
f,
|
||
"player {seat} has no legal command in round {round} {step:?}"
|
||
),
|
||
BotError::PassedWhenObligatory { seat, round, step } => write!(
|
||
f,
|
||
"player {seat} passed in round {round} {step:?}, where an action is required"
|
||
),
|
||
BotError::IllegalChoice {
|
||
seat,
|
||
index,
|
||
offered,
|
||
} => write!(
|
||
f,
|
||
"player {seat}'s policy chose index {index} of {offered} offered commands"
|
||
),
|
||
BotError::Rejected {
|
||
seat,
|
||
command,
|
||
rejection,
|
||
} => write!(f, "{seat:?} issuing {command} was rejected: {rejection}"),
|
||
BotError::Stalled { round, detail } => {
|
||
write!(f, "game stalled in round {round}: {detail}")
|
||
}
|
||
BotError::NoPolicy { seat } => write!(f, "no policy for seat {seat}"),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// What a completed bot game produced.
|
||
#[derive(Debug, Clone)]
|
||
pub struct BotGame {
|
||
pub state: GroundState,
|
||
/// Every event, in order — the log a replay bundle would carry.
|
||
pub events: Vec<crate::GroundEvent>,
|
||
/// Every accepted command, in order, as issued. `record::to_step`
|
||
/// turns these into a scenario a replay bundle can carry.
|
||
pub steps: Vec<(Actor, GroundCommand)>,
|
||
/// Commands accepted, player and system alike.
|
||
pub commands: usize,
|
||
pub rounds: u8,
|
||
}
|
||
|
||
// ------------------------------------------------------------ generation
|
||
|
||
/// Every legal command `seat` may issue right now, in canonical order.
|
||
///
|
||
/// Candidates are constructed then filtered through `validate`, so the
|
||
/// game rules stay in one place. See the module note for what this
|
||
/// cannot tell you.
|
||
pub fn legal_commands(state: &GroundState, seat: PlayerId) -> Vec<GroundCommand> {
|
||
let seats: Vec<PlayerId> = state.players.keys().copied().collect();
|
||
let problems: Vec<u32> = state.problems.keys().copied().collect();
|
||
let mut candidates: Vec<GroundCommand> = Vec::new();
|
||
|
||
match state.step {
|
||
RoundStep::Select => {
|
||
for action in [
|
||
Action::Investigate,
|
||
Action::Solve,
|
||
Action::Support,
|
||
Action::Attack,
|
||
Action::Ground,
|
||
] {
|
||
match action {
|
||
// Offered on every Problem; `validate` decides
|
||
// which are legal, and this function filters every
|
||
// candidate through it below.
|
||
//
|
||
// CB-WP-0023 first put GR-P05's conditions here, in
|
||
// the OFFER layer. The AM-1 coverage gate then asked
|
||
// for a scenario covering GR-P05 — and scenarios drive
|
||
// `validate`, not this. That is what revealed the rule
|
||
// belonged in `validate`: a rule enforced only by the
|
||
// offer is enforced only for clients that ask what is
|
||
// legal. Once it moved, everything here was dead code.
|
||
Action::Investigate | Action::Solve => {
|
||
for problem in &problems {
|
||
candidates.push(GroundCommand::SelectAction {
|
||
action,
|
||
target: None,
|
||
problem: Some(*problem),
|
||
});
|
||
}
|
||
}
|
||
Action::Support | Action::Attack => {
|
||
for other in &seats {
|
||
candidates.push(GroundCommand::SelectAction {
|
||
action,
|
||
target: Some(*other),
|
||
problem: None,
|
||
});
|
||
}
|
||
}
|
||
Action::Ground => candidates.push(GroundCommand::SelectAction {
|
||
action,
|
||
target: None,
|
||
problem: None,
|
||
}),
|
||
}
|
||
}
|
||
candidates.push(GroundCommand::SpendFreedom);
|
||
}
|
||
RoundStep::Reveal => {
|
||
candidates.push(GroundCommand::ChooseGroundMode {
|
||
mode: GroundMode::Gr,
|
||
choice: None,
|
||
});
|
||
for problem in &problems {
|
||
for choice in [
|
||
GroundChoice::RestoreProblem { problem: *problem },
|
||
GroundChoice::ProtectProblem { problem: *problem },
|
||
] {
|
||
candidates.push(GroundCommand::ChooseGroundMode {
|
||
mode: GroundMode::Ou,
|
||
choice: Some(choice),
|
||
});
|
||
}
|
||
}
|
||
for other in &seats {
|
||
candidates.push(GroundCommand::ChooseGroundMode {
|
||
mode: GroundMode::Ou,
|
||
choice: Some(GroundChoice::CancelAttack { attacker: *other }),
|
||
});
|
||
for choice in [
|
||
GroundChoice::RemoveBlame { owner: *other },
|
||
GroundChoice::BreakRelation { with: *other },
|
||
] {
|
||
candidates.push(GroundCommand::ChooseGroundMode {
|
||
mode: GroundMode::Nd,
|
||
choice: Some(choice),
|
||
});
|
||
}
|
||
}
|
||
candidates.push(GroundCommand::ChooseGroundMode {
|
||
mode: GroundMode::Nd,
|
||
choice: Some(GroundChoice::RejectReverse),
|
||
});
|
||
|
||
for response in [
|
||
SupportResponse::AcceptBond,
|
||
SupportResponse::DeclineBond,
|
||
SupportResponse::FlipToBond,
|
||
SupportResponse::BreakRivalry,
|
||
] {
|
||
candidates.push(GroundCommand::RespondToSupport { response });
|
||
}
|
||
|
||
for problem in &problems {
|
||
candidates.push(GroundCommand::ChooseDarvoTarget {
|
||
target: DarvoTarget {
|
||
problem: Some(*problem),
|
||
player: None,
|
||
},
|
||
});
|
||
}
|
||
for other in &seats {
|
||
candidates.push(GroundCommand::ChooseDarvoTarget {
|
||
target: DarvoTarget {
|
||
problem: None,
|
||
player: Some(*other),
|
||
},
|
||
});
|
||
}
|
||
}
|
||
// GR-R06/R08: system-driven; a seat has nothing to issue.
|
||
RoundStep::Resolve | RoundStep::End => {}
|
||
}
|
||
|
||
candidates
|
||
.into_iter()
|
||
.filter(|cmd| state.validate(Actor::Player(seat), cmd).is_ok())
|
||
.collect()
|
||
}
|
||
|
||
// --------------------------------------------------------------- policies
|
||
|
||
/// Uniform over the legal commands, from a seeded kernel RNG so a bot
|
||
/// game stays deterministic and replayable (K8).
|
||
pub struct RandomPolicy {
|
||
rng: ChaChaRng,
|
||
/// Probability, in sixteenths, of taking an optional action rather
|
||
/// than passing. Fixed rather than tunable: a knob here would be a
|
||
/// parameter nobody has a second use for.
|
||
act_in_16: u32,
|
||
}
|
||
|
||
impl RandomPolicy {
|
||
pub fn new(seed: u64) -> Self {
|
||
Self {
|
||
rng: ChaChaRng::from_seed(Seed(seed)),
|
||
act_in_16: 12,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Policy for RandomPolicy {
|
||
fn name(&self) -> &'static str {
|
||
"random"
|
||
}
|
||
|
||
fn choose(
|
||
&mut self,
|
||
_state: &GroundState,
|
||
_seat: PlayerId,
|
||
legal: &[GroundCommand],
|
||
may_pass: bool,
|
||
) -> Choice {
|
||
if may_pass && self.rng.draw(16) >= self.act_in_16 {
|
||
return Choice::Pass;
|
||
}
|
||
Choice::Command(self.rng.draw(legal.len() as u32) as usize)
|
||
}
|
||
}
|
||
|
||
/// A stated heuristic, in priority order:
|
||
///
|
||
/// 1. **Get out from under the stress gate.** At Stress ≥ 4 only ATTACK
|
||
/// and GROUND are selectable (GR-R03), so GROUND—GR (−2 Stress) is
|
||
/// worth more than anything else on the board.
|
||
/// 2. **SOLVE** a face-up Problem — the only action that adds to the
|
||
/// shared total the outcome is scored against (GR-E01).
|
||
/// 3. **INVESTIGATE** — turns a hidden Problem face up, which is what
|
||
/// makes a later SOLVE possible.
|
||
/// 4. **SUPPORT** — Bonds are the cheapest relationship and feed GR-E04.
|
||
/// 5. **GROUND**, then **ATTACK** last: attacking raises another seat's
|
||
/// Stress, which under SHARED GROUND scoring costs the group.
|
||
///
|
||
/// Deterministic by construction: ties break toward the earlier candidate
|
||
/// in canonical order, so two runs of a greedy game are identical without
|
||
/// needing a seed.
|
||
#[derive(Debug, Default, Clone, Copy)]
|
||
pub struct GreedyPolicy;
|
||
|
||
impl GreedyPolicy {
|
||
/// The ranking, **public so a variant policy can override exactly one
|
||
/// arm and inherit the rest** (CB-WP-0039, after review).
|
||
///
|
||
/// It was private, so `regulation.rs` re-typed an abridged copy and
|
||
/// called it "greedy with one preference changed". It differed in
|
||
/// five places — including `SpendFreedom`, which the copy ranked 95
|
||
/// unconditionally where this ranks it 0 unless the gate is biting,
|
||
/// so the "reactive" seat burned its Freedom token in round one of
|
||
/// every game. **A second change to the exact mechanism the pass was
|
||
/// studying**, and every number in CB-EV-0031 was measuring it.
|
||
///
|
||
/// Making this callable removes the possibility rather than testing
|
||
/// for it: a caller that delegates cannot drift.
|
||
pub fn rank(state: &GroundState, seat: PlayerId, cmd: &GroundCommand) -> i32 {
|
||
let gated = state
|
||
.players
|
||
.get(&seat)
|
||
.is_some_and(|p| p.stress >= 4 && !p.freedom_gate_lifted);
|
||
match cmd {
|
||
GroundCommand::SelectAction {
|
||
action, problem, ..
|
||
} => match action {
|
||
Action::Ground if gated => 100,
|
||
// GR-A13 admits SOLVE against an already-claimed Problem
|
||
// (it names only "face-up, non-Denied"), and resolution
|
||
// then does nothing — the action is silently wasted. The
|
||
// policy avoids it; the *rule* is left alone, and the gap
|
||
// is recorded rather than patched here.
|
||
Action::Solve
|
||
if problem
|
||
.and_then(|p| state.problems.get(&p))
|
||
.is_some_and(|p| p.claimed_by.is_some()) =>
|
||
{
|
||
5
|
||
}
|
||
Action::Solve => 90,
|
||
Action::Investigate => 80,
|
||
Action::Support => 70,
|
||
Action::Ground => 60,
|
||
Action::Attack => 10,
|
||
},
|
||
// Only worth spending when the gate is actually biting.
|
||
GroundCommand::SpendFreedom => {
|
||
if gated {
|
||
95
|
||
} else {
|
||
0
|
||
}
|
||
}
|
||
GroundCommand::ChooseGroundMode { mode, choice } => match (mode, choice) {
|
||
// GR-A10: the only mode that lowers our own Stress.
|
||
(GroundMode::Gr, _) => 90,
|
||
(_, Some(GroundChoice::RestoreProblem { .. })) => 80,
|
||
(_, Some(GroundChoice::CancelAttack { .. })) => 75,
|
||
(_, Some(GroundChoice::ProtectProblem { .. })) => 70,
|
||
(_, Some(GroundChoice::RemoveBlame { .. })) => 65,
|
||
_ => 40,
|
||
},
|
||
// GR-L02/A05: take the Bond wherever one is on offer.
|
||
GroundCommand::RespondToSupport { response } => match response {
|
||
SupportResponse::AcceptBond | SupportResponse::FlipToBond => 90,
|
||
SupportResponse::BreakRivalry => 50,
|
||
SupportResponse::DeclineBond => 10,
|
||
},
|
||
GroundCommand::ChooseDarvoTarget { .. } => 50,
|
||
GroundCommand::Reveal | GroundCommand::Resolve | GroundCommand::EndRound => -1,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Policy for GreedyPolicy {
|
||
fn name(&self) -> &'static str {
|
||
"greedy"
|
||
}
|
||
|
||
fn choose(
|
||
&mut self,
|
||
state: &GroundState,
|
||
seat: PlayerId,
|
||
legal: &[GroundCommand],
|
||
_may_pass: bool,
|
||
) -> Choice {
|
||
let mut best = 0usize;
|
||
let mut best_rank = i32::MIN;
|
||
for (i, cmd) in legal.iter().enumerate() {
|
||
let rank = Self::rank(state, seat, cmd);
|
||
if rank > best_rank {
|
||
best_rank = rank;
|
||
best = i;
|
||
}
|
||
}
|
||
Choice::Command(best)
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------------- driver
|
||
|
||
/// Rounds the driver will run before declaring a stall. GR-R09 ends the
|
||
/// game after five; anything past that is a defect, not a long game.
|
||
const MAX_ROUNDS: u8 = 20;
|
||
|
||
/// Attempts a single seat gets within one step. Bounded so a policy that
|
||
/// keeps choosing non-advancing commands fails instead of spinning.
|
||
const MAX_ATTEMPTS: usize = 8;
|
||
|
||
/// Drive `state` to `GameEnded` with one policy per seat.
|
||
///
|
||
/// Seats are matched to `policies` by index: `PlayerId(n)` gets
|
||
/// `policies[n]`.
|
||
pub fn play<'a>(
|
||
state: GroundState,
|
||
policies: &mut [Box<dyn Policy + 'a>],
|
||
) -> Result<BotGame, BotError> {
|
||
play_journaled(state, policies, None)
|
||
}
|
||
|
||
/// The same, appending every applied command and its events to `journal`
|
||
/// as it goes, for a caller rendering the game while it runs.
|
||
pub fn play_journaled<'a>(
|
||
mut state: GroundState,
|
||
policies: &mut [Box<dyn Policy + 'a>],
|
||
journal: Option<Journal>,
|
||
) -> Result<BotGame, BotError> {
|
||
let mut log = Log {
|
||
journal,
|
||
..Log::default()
|
||
};
|
||
|
||
let seats: Vec<PlayerId> = state.players.keys().copied().collect();
|
||
for seat in &seats {
|
||
if policies.get(seat.0 as usize).is_none() {
|
||
return Err(BotError::NoPolicy { seat: *seat });
|
||
}
|
||
}
|
||
|
||
let mut rounds = 0u8;
|
||
while state.outcome.is_none() {
|
||
rounds += 1;
|
||
if rounds > MAX_ROUNDS {
|
||
return Err(BotError::Stalled {
|
||
round: rounds,
|
||
detail: format!("no outcome after {MAX_ROUNDS} rounds"),
|
||
});
|
||
}
|
||
let round = state.round;
|
||
|
||
// GR-R02 — Select. Every seat must end this step with a selection.
|
||
for seat in &seats {
|
||
let mut attempts = 0;
|
||
while !state.selections.contains_key(seat) {
|
||
attempts += 1;
|
||
if attempts > MAX_ATTEMPTS {
|
||
return Err(BotError::Stalled {
|
||
round,
|
||
detail: format!("player {seat} never selected an action"),
|
||
});
|
||
}
|
||
step_seat(&mut state, &mut log, policies, *seat, round, false)?;
|
||
}
|
||
}
|
||
// Positive control: the driver must have done the work it claims.
|
||
if state.selections.len() != state.players.len() {
|
||
return Err(BotError::Stalled {
|
||
round,
|
||
detail: format!(
|
||
"Select ended with {} of {} selections",
|
||
state.selections.len(),
|
||
state.players.len()
|
||
),
|
||
});
|
||
}
|
||
|
||
apply(&mut state, &mut log, Actor::System, &GroundCommand::Reveal)?;
|
||
|
||
// GR-R05 — after Reveal: modes are obligatory for a seat that
|
||
// revealed GROUND; Support responses and DARVO targets are not.
|
||
for seat in &seats {
|
||
let mut attempts = 0;
|
||
loop {
|
||
attempts += 1;
|
||
if attempts > MAX_ATTEMPTS {
|
||
return Err(BotError::Stalled {
|
||
round,
|
||
detail: format!("player {seat} kept acting after Reveal"),
|
||
});
|
||
}
|
||
let obligatory = state
|
||
.selections
|
||
.get(seat)
|
||
.is_some_and(|s| s.action == Action::Ground)
|
||
&& !state.ground_modes.contains_key(seat);
|
||
if !obligatory && legal_commands(&state, *seat).is_empty() {
|
||
break;
|
||
}
|
||
if !step_seat(&mut state, &mut log, policies, *seat, round, !obligatory)? {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
apply(&mut state, &mut log, Actor::System, &GroundCommand::Resolve)?;
|
||
apply(
|
||
&mut state,
|
||
&mut log,
|
||
Actor::System,
|
||
&GroundCommand::EndRound,
|
||
)?;
|
||
}
|
||
|
||
Ok(BotGame {
|
||
state,
|
||
commands: log.steps.len(),
|
||
events: log.events,
|
||
steps: log.steps,
|
||
rounds,
|
||
})
|
||
}
|
||
|
||
/// One command and everything it produced.
|
||
///
|
||
/// **The empty case is the load-bearing one** (CB-WP-0018 T02). GR-A02's
|
||
/// resolver silently `continue`s when a SOLVE cannot be fulfilled, so a
|
||
/// player can select it three rounds running and change nothing. A journal
|
||
/// built only from events would show nothing for those and reproduce the
|
||
/// silence; keeping the command with an empty `events` is what lets a
|
||
/// reader say *"this happened and did nothing"*.
|
||
#[derive(Debug, Clone)]
|
||
pub struct Applied {
|
||
pub actor: Actor,
|
||
pub command: GroundCommand,
|
||
pub events: Vec<crate::GroundEvent>,
|
||
}
|
||
|
||
/// A live account of a game in progress, shared with whoever is watching.
|
||
///
|
||
/// `BotGame.events` is the same information but only after `play` returns,
|
||
/// which is no use to a page rendered mid-game.
|
||
pub type Journal = std::rc::Rc<std::cell::RefCell<Vec<Applied>>>;
|
||
|
||
/// What the driver accumulates while a game runs.
|
||
#[derive(Default)]
|
||
struct Log {
|
||
events: Vec<crate::GroundEvent>,
|
||
steps: Vec<(Actor, GroundCommand)>,
|
||
journal: Option<Journal>,
|
||
}
|
||
|
||
/// Offer one seat its legal commands and apply what the policy picks.
|
||
/// `Ok(false)` means the seat passed.
|
||
fn step_seat(
|
||
state: &mut GroundState,
|
||
log: &mut Log,
|
||
policies: &mut [Box<dyn Policy + '_>],
|
||
seat: PlayerId,
|
||
round: u8,
|
||
may_pass: bool,
|
||
) -> Result<bool, BotError> {
|
||
let legal = legal_commands(state, seat);
|
||
if legal.is_empty() {
|
||
if may_pass {
|
||
return Ok(false);
|
||
}
|
||
return Err(BotError::NoLegalMove {
|
||
seat,
|
||
round,
|
||
step: state.step,
|
||
});
|
||
}
|
||
let policy = policies
|
||
.get_mut(seat.0 as usize)
|
||
.ok_or(BotError::NoPolicy { seat })?;
|
||
match policy.choose(state, seat, &legal, may_pass) {
|
||
Choice::Pass if may_pass => Ok(false),
|
||
Choice::Pass => Err(BotError::PassedWhenObligatory {
|
||
seat,
|
||
round,
|
||
step: state.step,
|
||
}),
|
||
Choice::Command(i) => {
|
||
let cmd = legal.get(i).ok_or(BotError::IllegalChoice {
|
||
seat,
|
||
index: i,
|
||
offered: legal.len(),
|
||
})?;
|
||
apply(state, log, Actor::Player(seat), cmd)?;
|
||
Ok(true)
|
||
}
|
||
}
|
||
}
|
||
|
||
fn apply(
|
||
state: &mut GroundState,
|
||
log: &mut Log,
|
||
actor: Actor,
|
||
cmd: &GroundCommand,
|
||
) -> Result<(), BotError> {
|
||
let produced = state
|
||
.validate(actor, cmd)
|
||
.map_err(|rejection| BotError::Rejected {
|
||
seat: match actor {
|
||
Actor::Player(p) => Some(p),
|
||
Actor::System => None,
|
||
},
|
||
command: format!("{cmd:?}"),
|
||
rejection,
|
||
})?;
|
||
for event in &produced {
|
||
state.fold(event);
|
||
}
|
||
if let Some(j) = &log.journal {
|
||
j.borrow_mut().push(Applied {
|
||
actor,
|
||
command: cmd.clone(),
|
||
events: produced.clone(),
|
||
});
|
||
}
|
||
log.events.extend(produced);
|
||
log.steps.push((actor, cmd.clone()));
|
||
Ok(())
|
||
}
|
||
|
||
/// The selection a seat made this round, for callers that want to report
|
||
/// a game rather than only run one.
|
||
pub fn selection_of(state: &GroundState, seat: PlayerId) -> Option<Selection> {
|
||
state.selections.get(&seat).copied()
|
||
}
|
||
|
||
/// Relation between two seats, for the same reason.
|
||
pub fn relation_of(state: &GroundState, a: PlayerId, b: PlayerId) -> Option<Relation> {
|
||
state.relations.get(&crate::Pair::new(a, b)).copied()
|
||
}
|
||
|
||
#[cfg(all(test, feature = "scenarios"))]
|
||
mod tests {
|
||
use super::*;
|
||
use cb_events::state_hash_hex;
|
||
use cb_game_runtime::{ScenarioGame, Setup};
|
||
|
||
fn setup(players: u8, seed: u64) -> GroundState {
|
||
GroundState::setup(
|
||
&Setup {
|
||
players,
|
||
preset: format!("standard-{players}p"),
|
||
patch: Default::default(),
|
||
},
|
||
seed,
|
||
)
|
||
.expect("preset")
|
||
}
|
||
|
||
// ---------------------------------------------------------------
|
||
// CB-WP-0023: SOLVE's legality, ruled by ground-game 2026-08-03.
|
||
//
|
||
// Four conditions, each asserted on its own, because a single
|
||
// "SOLVE is filtered" test would pass with three of the four
|
||
// implemented and nobody would know which.
|
||
|
||
/// Problems that SOLVE is offered on, for `seat`.
|
||
fn solvable(state: &GroundState, seat: PlayerId) -> Vec<u32> {
|
||
legal_commands(state, seat)
|
||
.into_iter()
|
||
.filter_map(|c| match c {
|
||
GroundCommand::SelectAction {
|
||
action: Action::Solve,
|
||
problem: Some(n),
|
||
..
|
||
} => Some(n),
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// A 3p game where seat 0 can solve exactly problem 1.
|
||
fn solvable_fixture() -> (GroundState, u32) {
|
||
let mut st = setup(3, 42);
|
||
let (n, suit) = st
|
||
.problems
|
||
.iter()
|
||
.find(|(_, p)| p.face_up)
|
||
.map(|(n, p)| (*n, p.suit))
|
||
.expect("GR-S01 deals one face-up Surface Problem");
|
||
// Give seat 0 the matching Solution, so the ONLY thing under test
|
||
// is the condition each case perturbs.
|
||
st.players.get_mut(&PlayerId(0)).expect("seat 0").hand = vec![crate::SolutionCard { suit }];
|
||
assert_eq!(
|
||
solvable(&st, PlayerId(0)),
|
||
vec![n],
|
||
"fixture is not solvable"
|
||
);
|
||
(st, n)
|
||
}
|
||
|
||
/// **Which layer enforces GR-P05**, pinned so it cannot drift.
|
||
///
|
||
/// All four conditions live in `validate`, and `legal_commands` gets
|
||
/// them for free by filtering candidates through it. This test is what
|
||
/// keeps that true: if `validate` ever stops rejecting one, SOLVE
|
||
/// becomes offerable again and the offer layer would have to grow a
|
||
/// filter back.
|
||
#[test]
|
||
fn validate_enforces_all_four_solve_conditions() {
|
||
let (base, n) = solvable_fixture();
|
||
let sel = |p: u32| GroundCommand::SelectAction {
|
||
action: Action::Solve,
|
||
target: None,
|
||
problem: Some(p),
|
||
};
|
||
let ok =
|
||
|st: &GroundState, p: u32| st.validate(Actor::Player(PlayerId(0)), &sel(p)).is_ok();
|
||
|
||
let face_down = *base
|
||
.problems
|
||
.iter()
|
||
.find(|(_, p)| !p.face_up)
|
||
.map(|(k, _)| k)
|
||
.expect("a face-down problem");
|
||
println!(
|
||
" validate accepts SOLVE on face-down: {}",
|
||
ok(&base, face_down)
|
||
);
|
||
|
||
let mut denied = base.clone();
|
||
denied.problems.get_mut(&n).unwrap().denied = true;
|
||
println!(" validate accepts SOLVE on denied: {}", ok(&denied, n));
|
||
|
||
let mut claimed = base.clone();
|
||
claimed.problems.get_mut(&n).unwrap().claimed_by = Some(PlayerId(1));
|
||
println!(
|
||
" validate accepts SOLVE on claimed: {}",
|
||
ok(&claimed, n)
|
||
);
|
||
|
||
let mut nohand = base.clone();
|
||
nohand.players.get_mut(&PlayerId(0)).unwrap().hand = vec![];
|
||
println!(" validate accepts SOLVE with no hand: {}", ok(&nohand, n));
|
||
|
||
// validate's: adding these to `legal_commands` would be dead code.
|
||
for (what, accepted) in [
|
||
("face-down", ok(&base, face_down)),
|
||
("Denied", ok(&denied, n)),
|
||
("claimed", ok(&claimed, n)),
|
||
("handless", ok(&nohand, n)),
|
||
] {
|
||
assert!(
|
||
!accepted,
|
||
"validate accepts SOLVE on a {what} Problem — GR-P05 is not \
|
||
enforced where rules live, so only clients that ask what is \
|
||
legal would be constrained"
|
||
);
|
||
}
|
||
// Positive control: the fixture must still be solvable, or this
|
||
// test would pass by rejecting everything.
|
||
assert!(ok(&base, n), "the solvable fixture stopped being solvable");
|
||
}
|
||
|
||
/// **The maintainer's reported case.** SOLVE was offered on a
|
||
/// face-down Problem and did nothing; they played it three rounds
|
||
/// running with no explanation. ground-game: *"not offered — illegal
|
||
/// target. Browser no-ops were a filter bug, not a bluff mechanic."*
|
||
#[test]
|
||
fn solve_is_not_offered_on_a_face_down_problem() {
|
||
let (st, open) = solvable_fixture();
|
||
let face_down: Vec<u32> = st
|
||
.problems
|
||
.iter()
|
||
.filter(|(_, p)| !p.face_up)
|
||
.map(|(n, _)| *n)
|
||
.collect();
|
||
assert!(!face_down.is_empty(), "GR-S01 deals face-down Problems");
|
||
let offered = solvable(&st, PlayerId(0));
|
||
for n in face_down {
|
||
assert!(
|
||
!offered.contains(&n),
|
||
"SOLVE offered on face-down problem {n}; offered = {offered:?}"
|
||
);
|
||
}
|
||
assert!(offered.contains(&open), "the face-up one is still offered");
|
||
}
|
||
|
||
#[test]
|
||
fn solve_is_not_offered_without_a_matching_solution_in_hand() {
|
||
let (mut st, n) = solvable_fixture();
|
||
let wrong = [
|
||
crate::Suit::Clarify,
|
||
crate::Suit::Repair,
|
||
crate::Suit::Boundary,
|
||
crate::Suit::Change,
|
||
]
|
||
.into_iter()
|
||
.find(|s| *s != st.problems[&n].suit)
|
||
.expect("another suit exists");
|
||
st.players.get_mut(&PlayerId(0)).expect("seat 0").hand =
|
||
vec![crate::SolutionCard { suit: wrong }];
|
||
assert!(
|
||
!solvable(&st, PlayerId(0)).contains(&n),
|
||
"SOLVE offered with no matching Solution in hand"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn solve_is_not_offered_on_a_denied_problem() {
|
||
let (mut st, n) = solvable_fixture();
|
||
st.problems.get_mut(&n).expect("problem").denied = true;
|
||
assert!(
|
||
!solvable(&st, PlayerId(0)).contains(&n),
|
||
"SOLVE offered on a Denied Problem"
|
||
);
|
||
}
|
||
|
||
/// The ruling's (c): a Problem claimed in a PRIOR round is not
|
||
/// offered. At Select time every `claimed_by` is prior-round, because
|
||
/// claims land at Resolve — so the same-round race needs no code.
|
||
#[test]
|
||
fn solve_is_not_offered_on_an_already_claimed_problem() {
|
||
let (mut st, n) = solvable_fixture();
|
||
st.problems.get_mut(&n).expect("problem").claimed_by = Some(PlayerId(1));
|
||
assert!(
|
||
!solvable(&st, PlayerId(0)).contains(&n),
|
||
"SOLVE offered on an already-claimed Problem"
|
||
);
|
||
}
|
||
|
||
fn policies(kind: &str, n: u8, seed: u64) -> Vec<Box<dyn Policy>> {
|
||
(0..n)
|
||
.map(|i| -> Box<dyn Policy> {
|
||
match kind {
|
||
"greedy" => Box::new(GreedyPolicy),
|
||
_ => Box::new(RandomPolicy::new(seed + u64::from(i))),
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Acceptance, first half: a 3-player all-bot game reaches GR-R09's
|
||
/// end with every command legal (nothing was rejected — `play`
|
||
/// returns `Err` on the first rejection).
|
||
#[test]
|
||
fn three_player_all_bot_game_reaches_game_ended() {
|
||
for kind in ["random", "greedy"] {
|
||
let game = play(setup(3, 42), &mut policies(kind, 3, 42))
|
||
.unwrap_or_else(|e| panic!("{kind} bots: {e}"));
|
||
let outcome = game.state.outcome.as_ref().expect("GR-R09 outcome");
|
||
assert_eq!(game.state.round, 5, "{kind}: GR-R09 runs five rounds");
|
||
assert!(
|
||
game.events
|
||
.iter()
|
||
.any(|e| matches!(e, crate::GroundEvent::GameEnded { .. })),
|
||
"{kind}: GameEnded must be in the log"
|
||
);
|
||
// Positive control: a driver that did nothing would still see
|
||
// an outcome if the state arrived scored, so assert the work.
|
||
assert!(
|
||
game.commands > 3 * 5,
|
||
"{kind}: only {} commands for a five-round game",
|
||
game.commands
|
||
);
|
||
assert!(outcome.threshold > 0);
|
||
}
|
||
}
|
||
|
||
/// Acceptance, second half (K8): same seed, identical state hash.
|
||
#[test]
|
||
fn same_seed_bot_games_are_hash_identical() {
|
||
let a = play(setup(3, 7), &mut policies("random", 3, 7)).expect("run a");
|
||
let b = play(setup(3, 7), &mut policies("random", 3, 7)).expect("run b");
|
||
assert_eq!(
|
||
state_hash_hex(&a.state),
|
||
state_hash_hex(&b.state),
|
||
"same-seed bot games must be hash-identical"
|
||
);
|
||
assert_eq!(a.events.len(), b.events.len());
|
||
|
||
// And the seed must matter, or the equality above is vacuous —
|
||
// two runs of a bot that ignores its RNG are also identical.
|
||
let c = play(setup(3, 7), &mut policies("random", 3, 999)).expect("run c");
|
||
assert_ne!(
|
||
state_hash_hex(&a.state),
|
||
state_hash_hex(&c.state),
|
||
"different policy seeds must produce a different game"
|
||
);
|
||
}
|
||
|
||
/// The positive control the workplan asks for: no legal move must be
|
||
/// loud. A seat the game does not contain has no legal command, and
|
||
/// the driver must say so rather than skip it.
|
||
#[test]
|
||
fn a_seat_with_no_legal_move_fails_loudly() {
|
||
let mut state = setup(3, 1);
|
||
let ghost = PlayerId(9);
|
||
assert!(legal_commands(&state, ghost).is_empty());
|
||
let mut ps = policies("greedy", 10, 0);
|
||
let err = step_seat(&mut state, &mut Log::default(), &mut ps, ghost, 1, false)
|
||
.expect_err("a seat with nothing legal must fail");
|
||
assert!(matches!(err, BotError::NoLegalMove { seat, .. } if seat == ghost));
|
||
}
|
||
|
||
/// A policy that passes where it may not is a defect, not a turn.
|
||
#[test]
|
||
fn passing_when_obligatory_fails_loudly() {
|
||
struct Passer;
|
||
impl Policy for Passer {
|
||
fn name(&self) -> &'static str {
|
||
"passer"
|
||
}
|
||
fn choose(
|
||
&mut self,
|
||
_: &GroundState,
|
||
_: PlayerId,
|
||
_: &[GroundCommand],
|
||
_: bool,
|
||
) -> Choice {
|
||
Choice::Pass
|
||
}
|
||
}
|
||
let mut ps: Vec<Box<dyn Policy>> = (0..3)
|
||
.map(|_| Box::new(Passer) as Box<dyn Policy>)
|
||
.collect();
|
||
let err = play(setup(3, 3), &mut ps).expect_err("a passing bot must not finish a game");
|
||
assert!(
|
||
matches!(err, BotError::PassedWhenObligatory { .. }),
|
||
"got {err}"
|
||
);
|
||
}
|
||
|
||
/// An out-of-range index is not clamped to a legal move.
|
||
#[test]
|
||
fn an_out_of_range_choice_fails_loudly() {
|
||
struct Wild;
|
||
impl Policy for Wild {
|
||
fn name(&self) -> &'static str {
|
||
"wild"
|
||
}
|
||
fn choose(
|
||
&mut self,
|
||
_: &GroundState,
|
||
_: PlayerId,
|
||
legal: &[GroundCommand],
|
||
_: bool,
|
||
) -> Choice {
|
||
Choice::Command(legal.len())
|
||
}
|
||
}
|
||
let mut ps: Vec<Box<dyn Policy>> =
|
||
(0..3).map(|_| Box::new(Wild) as Box<dyn Policy>).collect();
|
||
let err = play(setup(3, 3), &mut ps).expect_err("an out-of-range index must fail");
|
||
assert!(matches!(err, BotError::IllegalChoice { .. }), "got {err}");
|
||
}
|
||
|
||
/// Every command a bot issues goes through `validate`. This asserts
|
||
/// the generator agrees with the kernel rather than trusting it.
|
||
#[test]
|
||
fn every_generated_command_validates() {
|
||
let state = setup(3, 11);
|
||
let mut total = 0;
|
||
for seat in state.players.keys().copied() {
|
||
let legal = legal_commands(&state, seat);
|
||
assert!(!legal.is_empty(), "seat {seat} has no opening move");
|
||
for cmd in &legal {
|
||
assert!(
|
||
state.validate(Actor::Player(seat), cmd).is_ok(),
|
||
"generated {cmd:?} is not legal for {seat}"
|
||
);
|
||
}
|
||
total += legal.len();
|
||
}
|
||
assert!(
|
||
total > 10,
|
||
"only {total} legal opening commands across 3 seats"
|
||
);
|
||
}
|
||
|
||
/// The greedy heuristic must actually be a heuristic — a policy that
|
||
/// ranked everything equally would pick index 0 and still finish.
|
||
#[test]
|
||
fn greedy_prefers_solve_over_attack() {
|
||
let state = setup(3, 5);
|
||
let seat = PlayerId(0);
|
||
let legal = legal_commands(&state, seat);
|
||
let mut p = GreedyPolicy;
|
||
let Choice::Command(i) = p.choose(&state, seat, &legal, false) else {
|
||
panic!("greedy never passes");
|
||
};
|
||
match &legal[i] {
|
||
GroundCommand::SelectAction { action, .. } => assert!(
|
||
matches!(action, Action::Solve | Action::Investigate),
|
||
"greedy opened with {action:?}"
|
||
),
|
||
other => panic!("greedy opened with {other:?}"),
|
||
}
|
||
}
|
||
/// HDN control: a bot game that "finishes" without touching the board
|
||
/// is the failure this project keeps finding. Assert the game moved.
|
||
#[test]
|
||
fn a_bot_game_does_substantive_work() {
|
||
for kind in ["random", "greedy"] {
|
||
let g = play(setup(3, 42), &mut policies(kind, 3, 42)).expect("game");
|
||
let mut counts: std::collections::BTreeMap<String, usize> =
|
||
std::collections::BTreeMap::new();
|
||
for e in &g.events {
|
||
let text = format!("{e:?}");
|
||
let kind = text.split([' ', '{']).next().unwrap_or("?").to_string();
|
||
*counts.entry(kind).or_default() += 1;
|
||
}
|
||
println!(
|
||
"{kind}: cmds={} events={} {counts:?}",
|
||
g.commands,
|
||
g.events.len()
|
||
);
|
||
println!(" outcome {:?}", g.state.outcome);
|
||
|
||
// Every seat selected in every round (GR-R02 × GR-R09).
|
||
assert_eq!(
|
||
counts.get("ActionSelected").copied().unwrap_or(0),
|
||
3 * 5,
|
||
"{kind}: a five-round three-player game has fifteen selections"
|
||
);
|
||
// And resolution actually did something with them. A driver
|
||
// that selected, revealed and ended without resolving would
|
||
// pass every assertion above this line.
|
||
let board_moved: usize = [
|
||
"ProblemClaimed",
|
||
"ProblemRevealed",
|
||
"RelationFormed",
|
||
"StressSet",
|
||
"SolutionDrawn",
|
||
]
|
||
.iter()
|
||
.filter_map(|k| counts.get(*k))
|
||
.sum();
|
||
assert!(
|
||
board_moved >= 4,
|
||
"{kind}: only {board_moved} board-changing events in a whole game"
|
||
);
|
||
}
|
||
}
|
||
/// GR-O01 says 2–6 players. Every scenario in the corpus was
|
||
/// 3-player until CB-WP-0008 T03, so the range was stated and tested
|
||
/// at one point. This plays all five counts with both policies.
|
||
#[test]
|
||
fn every_seat_count_in_gr_o01_plays_to_the_end() {
|
||
for players in 2..=6u8 {
|
||
for kind in ["random", "greedy"] {
|
||
let game = play(setup(players, 42), &mut policies(kind, players, 42))
|
||
.unwrap_or_else(|e| panic!("{players}p {kind}: {e}"));
|
||
let outcome = game
|
||
.state
|
||
.outcome
|
||
.as_ref()
|
||
.unwrap_or_else(|| panic!("{players}p {kind}: no outcome"));
|
||
assert_eq!(game.state.round, 5, "{players}p {kind}: GR-R09");
|
||
assert_eq!(
|
||
outcome.personal.len(),
|
||
usize::from(players),
|
||
"{players}p {kind}: every seat must be scored"
|
||
);
|
||
// K8 at each boundary, not only at three seats.
|
||
let again =
|
||
play(setup(players, 42), &mut policies(kind, players, 42)).expect("second run");
|
||
assert_eq!(
|
||
cb_events::state_hash_hex(&game.state),
|
||
cb_events::state_hash_hex(&again.state),
|
||
"{players}p {kind}: same seed must reproduce"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// `the_standard_preset_cannot_reach_the_threshold_below_five_seats`
|
||
// lived here and was deleted 2026-08-04, on its own instruction: it
|
||
// said "the failure is the signal to delete it, not to re-tune it".
|
||
// ground-game ruled GR-S01's deal and the game became winnable.
|
||
//
|
||
// The record did not go with it. `gd0001_group_success_is_reachable_at
|
||
// _every_seat_count` in lib.rs is the same arithmetic, inverted rather
|
||
// than removed, and carries why the numbers changed.
|
||
}
|
||
|
||
/// What a seat is trying to maximise (CB-WP-0049 T02).
|
||
///
|
||
/// **Read off `GroundState::score`, never restated.** The game already
|
||
/// computes all three answers to build its `Outcome`; a copy in the bot
|
||
/// would be a second definition of winning, and the two would disagree
|
||
/// the first time ground-game rules on F28.
|
||
///
|
||
/// | mode | objective |
|
||
/// |---|---|
|
||
/// | SHARED GROUND | the group total |
|
||
/// | COMMON PROBLEM | own claimed value − own Blame |
|
||
/// | BONDED COALITIONS | own coalition's summed personal score |
|
||
///
|
||
/// It reads only `claimed_by`, `value` of **claimed** Problems and Blame
|
||
/// tokens — all of which are on the table — so it is blind by
|
||
/// construction (ADR-0023) rather than by inspection.
|
||
pub fn objective(state: &GroundState, seat: PlayerId) -> i32 {
|
||
let o = state.score();
|
||
match state.mode {
|
||
ScoringMode::SharedGround => o.total as i32,
|
||
ScoringMode::CommonProblem => o.personal.get(&seat).copied().unwrap_or(0),
|
||
ScoringMode::BondedCoalitions => o
|
||
.coalitions
|
||
.iter()
|
||
.find(|c| c.members.contains(&seat))
|
||
.map(|c| c.score)
|
||
// A seat in no coalition is a one-seat coalition (GR-E04), so
|
||
// falling back to its own score is the rule and not a guess.
|
||
.unwrap_or_else(|| o.personal.get(&seat).copied().unwrap_or(0)),
|
||
}
|
||
}
|
||
|
||
/// A seat that plays **its own** objective (CB-WP-0049 T03).
|
||
///
|
||
/// ## Where the modes actually differ, and where they cannot
|
||
///
|
||
/// Working this out was most of the task. **SOLVE always claims for the
|
||
/// actor**, so a seat maximising its own score and a seat maximising the
|
||
/// group's want the same SOLVE in almost every position — which is a
|
||
/// fact about GROUND's action set, not a shortcoming of the bot, and it
|
||
/// bounds how far apart any two policies can get.
|
||
///
|
||
/// Two places the objective really does diverge, both readable off the
|
||
/// table:
|
||
///
|
||
/// - **SUPPORT regulates someone else.** Under SHARED GROUND that is
|
||
/// worth what it is worth to the group. Under COMMON PROBLEM the
|
||
/// beneficiary is a rival, so it is worth less. Under BONDED
|
||
/// COALITIONS a Bond *merges that seat into my coalition*, and my score
|
||
/// becomes the coalition's sum — so it is worth more, and worth most
|
||
/// with a seat not already in my network.
|
||
/// - **SOLVE's value is the Problem's value.** `GreedyPolicy::rank` gives
|
||
/// every legal SOLVE 90 regardless of what the card is worth. Under a
|
||
/// competitive objective the difference between a 2 and a 3 is the
|
||
/// whole margin.
|
||
///
|
||
/// **It delegates.** Every arm this does not name comes from
|
||
/// `GreedyPolicy::rank`. CB-WP-0039 re-typed an abridged copy of greedy
|
||
/// and called it "one preference changed"; it differed in five places and
|
||
/// burned the Freedom token in round one of every game. Delegating makes
|
||
/// "these arms and no others" structurally true instead of a claim in a
|
||
/// comment.
|
||
///
|
||
/// **Blind by construction** (ADR-0023): it reads Problem values only for
|
||
/// face-up Problems, and relations, which are on the table.
|
||
pub struct ObjectivePolicy;
|
||
|
||
impl ObjectivePolicy {
|
||
/// The seats whose personal score counts toward `seat`'s objective.
|
||
fn my_side(state: &GroundState, seat: PlayerId) -> Vec<PlayerId> {
|
||
match state.mode {
|
||
// Everyone's claims are my claims.
|
||
ScoringMode::SharedGround => state.players.keys().copied().collect(),
|
||
ScoringMode::CommonProblem => vec![seat],
|
||
ScoringMode::BondedCoalitions => state
|
||
.score()
|
||
.coalitions
|
||
.into_iter()
|
||
.find(|c| c.members.contains(&seat))
|
||
.map(|c| c.members)
|
||
.unwrap_or_else(|| vec![seat]),
|
||
}
|
||
}
|
||
|
||
pub fn rank(state: &GroundState, seat: PlayerId, cmd: &GroundCommand) -> i32 {
|
||
let base = GreedyPolicy::rank(state, seat, cmd);
|
||
match cmd {
|
||
// SOLVE, weighted by what the card is worth. Face-up only:
|
||
// `legal_commands` offers SOLVE on face-up Problems, and the
|
||
// lookup returns nothing for a card this seat cannot read.
|
||
GroundCommand::SelectAction {
|
||
action: Action::Solve,
|
||
problem: Some(n),
|
||
..
|
||
} => {
|
||
let worth = state
|
||
.problems
|
||
.get(n)
|
||
.filter(|p| p.face_up && p.claimed_by.is_none())
|
||
.map(|p| i32::from(p.value))
|
||
.unwrap_or(0);
|
||
base + worth
|
||
}
|
||
// SUPPORT, weighted by whether the target is on my side.
|
||
GroundCommand::SelectAction {
|
||
action: Action::Support,
|
||
target: Some(other),
|
||
..
|
||
} => {
|
||
let side = Self::my_side(state, seat);
|
||
if side.contains(other) {
|
||
base + 5
|
||
} else {
|
||
match state.mode {
|
||
// A Bond would bring them onto my side, and my
|
||
// score is my side's sum.
|
||
ScoringMode::BondedCoalitions => base + 10,
|
||
// Regulating a rival is work I do for them.
|
||
ScoringMode::CommonProblem => base - 30,
|
||
ScoringMode::SharedGround => base,
|
||
}
|
||
}
|
||
}
|
||
_ => base,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Policy for ObjectivePolicy {
|
||
fn name(&self) -> &'static str {
|
||
"objective"
|
||
}
|
||
fn choose(
|
||
&mut self,
|
||
state: &GroundState,
|
||
seat: PlayerId,
|
||
legal: &[GroundCommand],
|
||
_may_pass: bool,
|
||
) -> Choice {
|
||
let mut best = 0;
|
||
for (i, c) in legal.iter().enumerate() {
|
||
if Self::rank(state, seat, c) > Self::rank(state, seat, &legal[best]) {
|
||
best = i;
|
||
}
|
||
}
|
||
Choice::Command(best)
|
||
}
|
||
}
|
||
|
||
/// **A policy is bound by what its seat can see** ([ADR-0023]).
|
||
///
|
||
/// `Policy::choose` takes the whole `GroundState`, which carries every
|
||
/// face-down Problem's suit and value and every seat's hand — exactly
|
||
/// what `project` exists to withhold. Nothing in the type system stops a
|
||
/// policy reading it, so this establishes the property behaviourally:
|
||
/// **vary only what the seat cannot see, and the choice must not move.**
|
||
///
|
||
/// This binds every policy, including ones written later and ones written
|
||
/// outside this crate, without their cooperation.
|
||
///
|
||
/// [ADR-0023]: ../../../decisions/ADR-0023-a-policy-is-bound-by-what-its-seat-can-see.md
|
||
#[cfg(test)]
|
||
pub mod blindness {
|
||
use super::*;
|
||
|
||
/// Every rearrangement of what `seat` cannot see, as states that are
|
||
/// **identical in that seat's projection**.
|
||
///
|
||
/// Concretely: permute the suits and values of every face-down
|
||
/// Problem, and permute the other seats' hands. A seat that plays the
|
||
/// game can tell none of these apart.
|
||
pub fn hidden_rearrangements(state: &GroundState, seat: PlayerId) -> Vec<GroundState> {
|
||
let mut out = Vec::new();
|
||
// **The multiset must move, not only the arrangement.** The first
|
||
// version rotated every hidden value 2<->3 together, which leaves
|
||
// `max` and (with equal counts) `sum` invariant — so a policy
|
||
// ranking by the largest hidden value was NOT caught. A control
|
||
// whose variation is invariant under the statistic a violator
|
||
// reads is not a control. These force all-2, all-3 and an
|
||
// alternating split, so any symmetric function of the hidden
|
||
// values moves across the family.
|
||
for shift in 0..=3u8 {
|
||
let mut alt = state.clone();
|
||
for (i, p) in alt.problems.values_mut().enumerate() {
|
||
if p.face_up {
|
||
continue;
|
||
}
|
||
// A different card under the same back.
|
||
p.value = match shift {
|
||
0 => 2,
|
||
1 => 3,
|
||
2 => 2 + (i as u8 % 2),
|
||
_ => 3 - (i as u8 % 2),
|
||
};
|
||
p.suit = rotate_suit(p.suit, shift + 1);
|
||
}
|
||
for (other, ps) in alt.players.iter_mut() {
|
||
if *other == seat {
|
||
continue;
|
||
}
|
||
for c in ps.hand.iter_mut() {
|
||
c.suit = rotate_suit(c.suit, shift);
|
||
}
|
||
}
|
||
out.push(alt);
|
||
}
|
||
out
|
||
}
|
||
|
||
fn rotate_suit(s: crate::Suit, by: u8) -> crate::Suit {
|
||
use crate::Suit::*;
|
||
let order = [Clarify, Repair, Boundary, Change];
|
||
let i = order.iter().position(|x| *x == s).unwrap_or(0);
|
||
order[(i + by as usize) % order.len()]
|
||
}
|
||
|
||
/// Does `policy` choose the same thing across every rearrangement?
|
||
///
|
||
/// Returns the disagreement if there is one, so the caller can report
|
||
/// *what* moved rather than only that something did.
|
||
/// **Takes a constructor, not a policy.** `choose` may advance
|
||
/// internal state — `RandomPolicy` draws from its own stream — so
|
||
/// reusing one instance compares a first call against a fourth and
|
||
/// reports every stateful policy as a peeker. The first version did
|
||
/// exactly that and accused `random`. Each variant is judged from an
|
||
/// identical starting policy, which is the only way the difference
|
||
/// between two runs is the state and nothing else.
|
||
pub fn is_blind<P: Policy>(
|
||
mut make: impl FnMut() -> P,
|
||
state: &GroundState,
|
||
seat: PlayerId,
|
||
) -> Result<(), String> {
|
||
let legal = legal_commands(state, seat);
|
||
if legal.is_empty() {
|
||
return Ok(());
|
||
}
|
||
let base = make().choose(state, seat, &legal, false);
|
||
for alt in hidden_rearrangements(state, seat) {
|
||
// **The same legal set, deliberately.** Rearranging hidden
|
||
// cards can change which moves are legal, and a policy that
|
||
// picks a different INDEX into a different list has not
|
||
// necessarily seen anything. Holding the list fixed varies
|
||
// only the state, which is the variable under test.
|
||
let mut p = make();
|
||
let got = p.choose(&alt, seat, &legal, false);
|
||
if got != base {
|
||
return Err(format!(
|
||
"{} chose {:?} and then {:?} over the same legal moves, \
|
||
with only hidden cards rearranged",
|
||
p.name(),
|
||
base,
|
||
got
|
||
));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod blindness_tests {
|
||
use super::blindness::*;
|
||
use super::*;
|
||
use cb_game_runtime::{ScenarioGame, Setup};
|
||
|
||
fn deal(players: u8, seed: u64) -> GroundState {
|
||
GroundState::setup(
|
||
&Setup {
|
||
players,
|
||
preset: format!("standard-{players}p"),
|
||
patch: Default::default(),
|
||
},
|
||
seed,
|
||
)
|
||
.expect("preset")
|
||
}
|
||
|
||
/// **A policy that peeks IS CAUGHT** (ADR-0023 D3).
|
||
///
|
||
/// The control is proven against a deliberate violator before it is
|
||
/// trusted about compliant ones. A peek control that nothing can fail
|
||
/// is decoration (ADR-0006 D3) — and this is the shape of policy
|
||
/// CB-WP-0049 is about to write, ranking a claim by a Problem's
|
||
/// value, which is the field the projection hides.
|
||
#[test]
|
||
fn the_peek_control_catches_a_peeking_policy() {
|
||
struct Peeker;
|
||
impl Policy for Peeker {
|
||
fn name(&self) -> &'static str {
|
||
"peeker"
|
||
}
|
||
fn choose(
|
||
&mut self,
|
||
state: &GroundState,
|
||
_seat: PlayerId,
|
||
legal: &[GroundCommand],
|
||
_may_pass: bool,
|
||
) -> Choice {
|
||
// Rank by the value of the highest FACE-DOWN Problem —
|
||
// information the seat does not have.
|
||
let hidden: u8 = state
|
||
.problems
|
||
.values()
|
||
.filter(|p| !p.face_up)
|
||
.map(|p| p.value)
|
||
.max()
|
||
.unwrap_or(0);
|
||
Choice::Command((hidden as usize) % legal.len())
|
||
}
|
||
}
|
||
let st = deal(3, 7);
|
||
let seat = PlayerId(0);
|
||
assert!(
|
||
st.problems.values().any(|p| !p.face_up),
|
||
"the fixture must actually hide something"
|
||
);
|
||
let err = is_blind(|| Peeker, &st, seat)
|
||
.expect_err("a policy reading face-down values must be caught");
|
||
assert!(err.contains("peeker"), "{err}");
|
||
}
|
||
|
||
/// The new policy is blind too — the point of T01 being first.
|
||
#[test]
|
||
fn the_objective_policy_is_blind() {
|
||
for seed in [1u64, 7, 42] {
|
||
for players in [2u8, 4, 6] {
|
||
for mode in [
|
||
ScoringMode::SharedGround,
|
||
ScoringMode::CommonProblem,
|
||
ScoringMode::BondedCoalitions,
|
||
] {
|
||
let mut st = deal(players, seed);
|
||
st.mode = mode;
|
||
for seat in st.players.keys().copied() {
|
||
is_blind(|| ObjectivePolicy, &st, seat)
|
||
.unwrap_or_else(|e| panic!("{players}p {mode:?} seed {seed}: {e}"));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// **The objective is the game's own scoring** (CB-WP-0049 T02).
|
||
#[test]
|
||
fn the_objective_agrees_with_the_outcome() {
|
||
let mut st = deal(4, 5);
|
||
let seats: Vec<PlayerId> = st.players.keys().copied().collect();
|
||
// Claim two Problems for two different seats, so group and
|
||
// personal cannot coincide by accident.
|
||
let keys: Vec<u32> = st.problems.keys().copied().collect();
|
||
st.problems.get_mut(&keys[0]).unwrap().claimed_by = Some(seats[0]);
|
||
st.problems.get_mut(&keys[1]).unwrap().claimed_by = Some(seats[1]);
|
||
let o = st.score();
|
||
|
||
st.mode = ScoringMode::SharedGround;
|
||
assert_eq!(objective(&st, seats[0]), o.total as i32);
|
||
// Everyone shares one number.
|
||
assert_eq!(objective(&st, seats[0]), objective(&st, seats[2]));
|
||
|
||
st.mode = ScoringMode::CommonProblem;
|
||
assert_eq!(objective(&st, seats[0]), o.personal[&seats[0]]);
|
||
// **And a seat's objective is NOT the group's** — the claim that
|
||
// makes a competitive mode competitive, asserted on a board
|
||
// rather than argued.
|
||
assert_ne!(
|
||
objective(&st, seats[0]),
|
||
st.score().total as i32,
|
||
"under COMMON PROBLEM a seat's objective coincided with the group's"
|
||
);
|
||
assert_ne!(
|
||
objective(&st, seats[0]),
|
||
objective(&st, seats[2]),
|
||
"a claiming seat and an empty-handed seat had the same objective"
|
||
);
|
||
|
||
st.mode = ScoringMode::BondedCoalitions;
|
||
let mine = st
|
||
.score()
|
||
.coalitions
|
||
.into_iter()
|
||
.find(|c| c.members.contains(&seats[0]))
|
||
.expect("every seat is in a coalition");
|
||
assert_eq!(objective(&st, seats[0]), mine.score);
|
||
}
|
||
|
||
/// **Under SHARED GROUND the objective policy IS greedy** — the
|
||
/// control that separates "attends to the objective" from "plays
|
||
/// differently".
|
||
///
|
||
/// Without it, any change in the panel could be the new policy simply
|
||
/// being a different bot. The two must agree wherever the objective
|
||
/// is the group's, and diverge only where it is not.
|
||
#[test]
|
||
fn under_shared_ground_the_objective_policy_and_greedy_want_the_same_thing() {
|
||
let mut differed = 0;
|
||
for seed in [1u64, 7, 42, 99] {
|
||
for players in [2u8, 4, 6] {
|
||
let mut st = deal(players, seed);
|
||
st.mode = ScoringMode::SharedGround;
|
||
for seat in st.players.keys().copied() {
|
||
let legal = legal_commands(&st, seat);
|
||
if legal.is_empty() {
|
||
continue;
|
||
}
|
||
let a = GreedyPolicy.choose(&st, seat, &legal, false);
|
||
let b = ObjectivePolicy.choose(&st, seat, &legal, false);
|
||
if a != b {
|
||
differed += 1;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// SOLVE is weighted by card value in every mode, so a tie greedy
|
||
// broke by order can break the other way here. That is a
|
||
// refinement of greedy's own objective, not a different one — so
|
||
// the assertion is that divergence is RARE, with the number
|
||
// stated rather than a vague "mostly".
|
||
assert!(
|
||
differed <= 2,
|
||
"under SHARED GROUND the two policies differed at {differed} decision points; they are supposed to share an objective"
|
||
);
|
||
}
|
||
|
||
/// **Under COMMON PROBLEM they do NOT** — and the reason is Support.
|
||
#[test]
|
||
fn a_competitive_seat_values_supporting_a_rival_less() {
|
||
let mut st = deal(4, 5);
|
||
let seat = PlayerId(0);
|
||
let other = PlayerId(1);
|
||
let support = GroundCommand::SelectAction {
|
||
action: Action::Support,
|
||
target: Some(other),
|
||
problem: None,
|
||
};
|
||
st.mode = ScoringMode::SharedGround;
|
||
let shared = ObjectivePolicy::rank(&st, seat, &support);
|
||
st.mode = ScoringMode::CommonProblem;
|
||
let selfish = ObjectivePolicy::rank(&st, seat, &support);
|
||
st.mode = ScoringMode::BondedCoalitions;
|
||
let coalition = ObjectivePolicy::rank(&st, seat, &support);
|
||
assert!(
|
||
selfish < shared,
|
||
"a seat scoring only its own claims valued regulating a rival the same as a cooperative seat did ({selfish} vs {shared})"
|
||
);
|
||
assert!(
|
||
coalition > shared,
|
||
"under BONDED COALITIONS a Bond brings that seat's score into mine, so Support is worth MORE, not the same ({coalition} vs {shared})"
|
||
);
|
||
}
|
||
|
||
/// And the shipped policies are blind.
|
||
#[test]
|
||
fn every_shipped_policy_is_blind_to_what_its_seat_cannot_see() {
|
||
for seed in [1u64, 7, 42] {
|
||
for players in [2u8, 4, 6] {
|
||
let st = deal(players, seed);
|
||
for seat in st.players.keys().copied() {
|
||
is_blind(|| GreedyPolicy, &st, seat)
|
||
.unwrap_or_else(|e| panic!("{players}p seed {seed}: {e}"));
|
||
// Random is seeded from its own stream, so it is
|
||
// blind for a different reason — included because
|
||
// "every shipped policy" must mean every one.
|
||
is_blind(|| RandomPolicy::new(seed), &st, seat)
|
||
.unwrap_or_else(|e| panic!("{players}p seed {seed}: {e}"));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The rearrangements must actually rearrange.
|
||
///
|
||
/// A control that varies nothing passes for every policy, including
|
||
/// the peeker — so the fixture's own sensitivity is asserted rather
|
||
/// than assumed.
|
||
#[test]
|
||
fn the_rearrangements_change_the_hidden_cards() {
|
||
let st = deal(4, 3);
|
||
let alts = hidden_rearrangements(&st, PlayerId(0));
|
||
assert!(!alts.is_empty());
|
||
let hidden = |s: &GroundState| -> Vec<(u8, String)> {
|
||
s.problems
|
||
.values()
|
||
.filter(|p| !p.face_up)
|
||
.map(|p| (p.value, format!("{:?}", p.suit)))
|
||
.collect()
|
||
};
|
||
assert!(
|
||
alts.iter().any(|a| hidden(a) != hidden(&st)),
|
||
"no rearrangement changed a hidden card"
|
||
);
|
||
// And they leave the VISIBLE game alone, or the test would be
|
||
// varying two things at once.
|
||
let seen = |s: &GroundState| -> Vec<(u8, String)> {
|
||
s.problems
|
||
.values()
|
||
.filter(|p| p.face_up)
|
||
.map(|p| (p.value, format!("{:?}", p.suit)))
|
||
.collect()
|
||
};
|
||
for a in &alts {
|
||
assert_eq!(seen(a), seen(&st), "a rearrangement moved a face-up card");
|
||
}
|
||
}
|
||
}
|