CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
Some checks failed
ci / check (push) Failing after 3s

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>
This commit is contained in:
tegwick 2026-08-01 14:43:53 +02:00
parent 6197677126
commit 25531e9d01
13 changed files with 1523 additions and 60 deletions

View file

@ -123,6 +123,9 @@ 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,
@ -405,9 +408,11 @@ const MAX_ATTEMPTS: usize = 8;
///
/// Seats are matched to `policies` by index: `PlayerId(n)` gets
/// `policies[n]`.
pub fn play(mut state: GroundState, policies: &mut [Box<dyn Policy>]) -> Result<BotGame, BotError> {
let mut events = Vec::new();
let mut commands = 0usize;
pub fn play<'a>(
mut state: GroundState,
policies: &mut [Box<dyn Policy + 'a>],
) -> Result<BotGame, BotError> {
let mut log = Log::default();
let seats: Vec<PlayerId> = state.players.keys().copied().collect();
for seat in &seats {
@ -438,15 +443,7 @@ pub fn play(mut state: GroundState, policies: &mut [Box<dyn Policy>]) -> Result<
detail: format!("player {seat} never selected an action"),
});
}
step_seat(
&mut state,
&mut events,
&mut commands,
policies,
*seat,
round,
false,
)?;
step_seat(&mut state, &mut log, policies, *seat, round, false)?;
}
}
// Positive control: the driver must have done the work it claims.
@ -461,13 +458,7 @@ pub fn play(mut state: GroundState, policies: &mut [Box<dyn Policy>]) -> Result<
});
}
apply(
&mut state,
&mut events,
&mut commands,
Actor::System,
&GroundCommand::Reveal,
)?;
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.
@ -489,31 +480,16 @@ pub fn play(mut state: GroundState, policies: &mut [Box<dyn Policy>]) -> Result<
if !obligatory && legal_commands(&state, *seat).is_empty() {
break;
}
if !step_seat(
&mut state,
&mut events,
&mut commands,
policies,
*seat,
round,
!obligatory,
)? {
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 events,
&mut commands,
Actor::System,
&GroundCommand::Resolve,
)?;
apply(
&mut state,
&mut events,
&mut commands,
&mut log,
Actor::System,
&GroundCommand::EndRound,
)?;
@ -521,19 +497,26 @@ pub fn play(mut state: GroundState, policies: &mut [Box<dyn Policy>]) -> Result<
Ok(BotGame {
state,
events,
commands,
commands: log.steps.len(),
events: log.events,
steps: log.steps,
rounds,
})
}
/// What the driver accumulates while a game runs.
#[derive(Default)]
struct Log {
events: Vec<crate::GroundEvent>,
steps: Vec<(Actor, GroundCommand)>,
}
/// Offer one seat its legal commands and apply what the policy picks.
/// `Ok(false)` means the seat passed.
fn step_seat(
state: &mut GroundState,
events: &mut Vec<crate::GroundEvent>,
commands: &mut usize,
policies: &mut [Box<dyn Policy>],
log: &mut Log,
policies: &mut [Box<dyn Policy + '_>],
seat: PlayerId,
round: u8,
may_pass: bool,
@ -565,7 +548,7 @@ fn step_seat(
index: i,
offered: legal.len(),
})?;
apply(state, events, commands, Actor::Player(seat), cmd)?;
apply(state, log, Actor::Player(seat), cmd)?;
Ok(true)
}
}
@ -573,8 +556,7 @@ fn step_seat(
fn apply(
state: &mut GroundState,
events: &mut Vec<crate::GroundEvent>,
commands: &mut usize,
log: &mut Log,
actor: Actor,
cmd: &GroundCommand,
) -> Result<(), BotError> {
@ -591,8 +573,8 @@ fn apply(
for event in &produced {
state.fold(event);
}
events.extend(produced);
*commands += 1;
log.events.extend(produced);
log.steps.push((actor, cmd.clone()));
Ok(())
}
@ -694,16 +676,8 @@ mod tests {
let ghost = PlayerId(9);
assert!(legal_commands(&state, ghost).is_empty());
let mut ps = policies("greedy", 10, 0);
let err = step_seat(
&mut state,
&mut Vec::new(),
&mut 0,
&mut ps,
ghost,
1,
false,
)
.expect_err("a seat with nothing legal must fail");
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));
}