diff --git a/Cargo.lock b/Cargo.lock index 9c3cdc9..5a6ecd7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -74,6 +74,18 @@ dependencies = [ "serde", ] +[[package]] +name = "cb-play" +version = "0.1.0" +dependencies = [ + "cb-events", + "cb-game-runtime", + "cb-kernel", + "games-ground", + "serde_json", + "serde_yaml", +] + [[package]] name = "cb-sim" version = "0.1.0" @@ -230,6 +242,8 @@ dependencies = [ "cb-kernel", "criterion", "serde", + "serde_json", + "serde_yaml", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 87d10cc..9952ba4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/cb-game-runtime", "games/ground", "tools/cb-sim", + "tools/cb-play", ] [workspace.package] diff --git a/Makefile b/Makefile index 70a395f..1af25c3 100644 --- a/Makefile +++ b/Makefile @@ -24,13 +24,18 @@ TOOLS := $(REPO)/tools # Every cargo recipe runs at the repo root; the shell does not persist cd. IN_REPO := cd $(REPO) && -.PHONY: check test sim bench bench-test coverage dep-weight cost cost-test cost-pin cost-budget shape-budget cost-mix loop-lint self-tests env-test task-done status facts-check facts-gen mutation-check size-metrics runtime-metrics build-time am6 replay-test loc all +.PHONY: check test sim bench bench-test coverage dep-weight cost cost-test cost-pin cost-budget shape-budget cost-mix loop-lint self-tests env-test task-done status facts-check facts-gen mutation-check size-metrics runtime-metrics build-time am6 replay-test loc play all ## fmt + clippy (deny warnings) + HashMap deny-lint check: $(IN_REPO) $(CARGO) fmt --all --check $(IN_REPO) $(CARGO) clippy --workspace --all-targets -- -D warnings +## play GROUND from the terminal (INTENT stage 0's CLI player). +## `make play ARGS="--all-bots --bot random"` to watch one instead. +play: + $(IN_REPO) $(CARGO) run -q -p cb-play -- $(ARGS) + ## unit + scenario-format tests test: $(IN_REPO) $(CARGO) test --workspace diff --git a/games/ground/Cargo.toml b/games/ground/Cargo.toml index 4b9b912..9d0768b 100644 --- a/games/ground/Cargo.toml +++ b/games/ground/Cargo.toml @@ -9,14 +9,19 @@ cb-kernel.workspace = true cb-events.workspace = true cb-game-runtime = { workspace = true, default-features = false } serde.workspace = true +# Scenario args are YAML values; the recorder writes them (T02). +serde_yaml = { workspace = true, optional = true } [features] default = ["scenarios"] # The ScenarioGame impl exists only when scenarios are compiled in. -scenarios = ["cb-game-runtime/scenarios"] +scenarios = ["cb-game-runtime/scenarios", "dep:serde_yaml"] [dev-dependencies] criterion.workspace = true +# Projection tests assert on the serialized view — what a CLI prints is +# what leaks (CB-WP-0008 T02). +serde_json.workspace = true [[bench]] name = "synthetic" diff --git a/games/ground/src/bot.rs b/games/ground/src/bot.rs index b11cf37..3fd8ec0 100644 --- a/games/ground/src/bot.rs +++ b/games/ground/src/bot.rs @@ -123,6 +123,9 @@ pub struct BotGame { pub state: GroundState, /// Every event, in order — the log a replay bundle would carry. pub events: Vec, + /// 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]) -> Result { - let mut events = Vec::new(); - let mut commands = 0usize; +pub fn play<'a>( + mut state: GroundState, + policies: &mut [Box], +) -> Result { + let mut log = Log::default(); let seats: Vec = state.players.keys().copied().collect(); for seat in &seats { @@ -438,15 +443,7 @@ pub fn play(mut state: GroundState, policies: &mut [Box]) -> 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]) -> 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]) -> 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]) -> 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, + 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, - commands: &mut usize, - policies: &mut [Box], + log: &mut Log, + policies: &mut [Box], 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, - 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)); } diff --git a/games/ground/src/lib.rs b/games/ground/src/lib.rs index f1a1504..a2d1e2b 100644 --- a/games/ground/src/lib.rs +++ b/games/ground/src/lib.rs @@ -8,6 +8,16 @@ /// presets currently live behind that feature (see `bot.rs`). pub mod bot; +/// K13's per-player projection (CB-WP-0008 T02) — the trait's first +/// implementor. Needs the runtime's `Project`, which the game already +/// depends on, so it is not feature-gated either. +pub mod view; + +/// The inverse of `parse_command` (CB-WP-0008 T02): a played game becomes +/// a scenario. Needs the scenario vocabulary, so it is gated with it. +#[cfg(feature = "scenarios")] +pub mod record; + #[cfg(feature = "scenarios")] use cb_game_runtime::{parse_actor, CommandStep, ScenarioGame, Setup}; use cb_kernel::{Actor, Aggregate, ChaChaRng, KernelRng, PlayerId, Rejection, Seed}; diff --git a/games/ground/src/record.rs b/games/ground/src/record.rs new file mode 100644 index 0000000..10a1194 --- /dev/null +++ b/games/ground/src/record.rs @@ -0,0 +1,364 @@ +//! Turn a played game back into a scenario (CB-WP-0008 T02). +//! +//! `ScenarioGame::parse_command` reads a `CommandStep` into a typed +//! command. This is its inverse, so a game somebody played becomes a +//! scenario the runner can re-execute and a `.cbreplay` bundle can carry. +//! +//! **The round trip is the test**, and it is the only reason this is +//! trustworthy: `parse_command(to_step(actor, cmd)) == (actor, cmd)` for +//! every command shape. An encoder checked only against hand-written +//! expectations agrees with itself. + +use crate::{Action, GroundChoice, GroundCommand, GroundMode, SupportResponse}; +use cb_game_runtime::{CommandStep, Setup}; +use cb_kernel::{Actor, PlayerId}; +use std::collections::BTreeMap; + +fn seat(p: PlayerId) -> serde_yaml::Value { + serde_yaml::Value::String(format!("P{}", p.0 + 1)) +} + +fn num(n: u64) -> serde_yaml::Value { + serde_yaml::Value::Number(n.into()) +} + +fn text(s: &str) -> serde_yaml::Value { + serde_yaml::Value::String(s.to_string()) +} + +fn actor_name(actor: Actor) -> String { + match actor { + Actor::Player(p) => format!("P{}", p.0 + 1), + Actor::System => "SYSTEM".into(), + } +} + +fn action_name(action: Action) -> &'static str { + match action { + Action::Investigate => "INVESTIGATE", + Action::Solve => "SOLVE", + Action::Support => "SUPPORT", + Action::Attack => "ATTACK", + Action::Ground => "GROUND", + } +} + +fn mode_name(mode: GroundMode) -> &'static str { + match mode { + GroundMode::Gr => "GR", + GroundMode::Ou => "OU", + GroundMode::Nd => "ND", + } +} + +fn response_name(r: SupportResponse) -> &'static str { + match r { + SupportResponse::AcceptBond => "accept_bond", + SupportResponse::DeclineBond => "decline_bond", + SupportResponse::FlipToBond => "flip_to_bond", + SupportResponse::BreakRivalry => "break_rivalry", + } +} + +/// One issued command as a scenario step. +pub fn to_step(actor: Actor, command: &GroundCommand) -> CommandStep { + let mut args: BTreeMap = BTreeMap::new(); + let cmd = match command { + GroundCommand::SelectAction { + action, + target, + problem, + } => { + args.insert("action".into(), text(action_name(*action))); + if let Some(t) = target { + args.insert("target".into(), seat(*t)); + } + if let Some(p) = problem { + args.insert("problem".into(), num(u64::from(*p))); + } + "select_action" + } + GroundCommand::SpendFreedom => "spend_freedom", + GroundCommand::ChooseGroundMode { mode, choice } => { + args.insert("mode".into(), text(mode_name(*mode))); + match choice { + None => {} + Some(GroundChoice::RestoreProblem { problem }) => { + args.insert("choice".into(), text("restore_problem")); + args.insert("problem".into(), num(u64::from(*problem))); + } + Some(GroundChoice::ProtectProblem { problem }) => { + args.insert("choice".into(), text("protect_problem")); + args.insert("problem".into(), num(u64::from(*problem))); + } + Some(GroundChoice::CancelAttack { attacker }) => { + args.insert("choice".into(), text("cancel_attack")); + args.insert("seat".into(), num(u64::from(attacker.0))); + } + Some(GroundChoice::RemoveBlame { owner }) => { + args.insert("choice".into(), text("remove_blame")); + args.insert("seat".into(), num(u64::from(owner.0))); + } + Some(GroundChoice::BreakRelation { with }) => { + args.insert("choice".into(), text("break_relation")); + args.insert("seat".into(), num(u64::from(with.0))); + } + Some(GroundChoice::RejectReverse) => { + args.insert("choice".into(), text("reject_reverse")); + } + } + "choose_ground_mode" + } + GroundCommand::RespondToSupport { response } => { + args.insert("response".into(), text(response_name(*response))); + "respond_to_support" + } + GroundCommand::ChooseDarvoTarget { target } => { + if let Some(p) = target.problem { + args.insert("problem".into(), num(u64::from(p))); + } + if let Some(t) = target.player { + args.insert("target".into(), seat(t)); + } + "choose_darvo_target" + } + GroundCommand::Reveal => "reveal", + GroundCommand::Resolve => "resolve", + GroundCommand::EndRound => "end_round", + }; + CommandStep { + actor: actor_name(actor), + cmd: cmd.into(), + args, + } +} + +/// A whole played game as a scenario file. +/// +/// `covers` is deliberately **empty**: a recorded game exercises whatever +/// it happened to exercise, and claiming rule coverage from it would +/// inflate AM-1 with rules nobody asserted. `expect.state_hash` carries +/// the end-state hash, which is the assertion that a replay must satisfy. +pub fn to_scenario( + name: &str, + seed: u64, + players: u8, + steps: &[(Actor, GroundCommand)], + end_state_hash: Option, +) -> cb_game_runtime::ScenarioFile { + cb_game_runtime::ScenarioFile { + scenario: name.to_string(), + description: "recorded by cb-play (CB-WP-0008 T02)".into(), + covers: vec![], + provisional: false, + provisional_owner: String::new(), + provisional_raised: String::new(), + seed, + setup: Setup { + players, + preset: format!("standard-{players}p"), + patch: BTreeMap::new(), + }, + commands: steps.iter().map(|(a, c)| to_step(*a, c)).collect(), + expect: cb_game_runtime::scenario::Expect { + state_hash: end_state_hash, + ..Default::default() + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{DarvoTarget, GroundState}; + use cb_game_runtime::ScenarioGame; + + /// A game somebody played becomes a scenario the runner re-executes + /// to the same hash. This is the acceptance clause "the same + /// transcript replays identically", taken through the *scenario + /// runner* rather than through a second call to the same driver — + /// which would only prove the driver is deterministic. + #[test] + fn a_recorded_bot_game_replays_as_a_scenario() { + use crate::bot::{play, GreedyPolicy, Policy}; + use cb_events::state_hash_hex; + use cb_game_runtime::{run, RunOutcome, Setup}; + + let setup = Setup { + players: 3, + preset: "standard-3p".into(), + patch: BTreeMap::new(), + }; + let game = play( + GroundState::setup(&setup, 42).expect("preset"), + &mut (0..3) + .map(|_| Box::new(GreedyPolicy) as Box) + .collect::>(), + ) + .expect("bot game"); + let hash = state_hash_hex(&game.state); + + let scenario = to_scenario( + "ground/recorded-bot-game", + 42, + 3, + &game.steps, + Some(hash.clone()), + ); + assert_eq!(scenario.commands.len(), game.steps.len()); + match run::(&scenario) { + RunOutcome::Passed { .. } => {} + RunOutcome::Failed { reason, .. } => { + panic!("recorded game did not replay: {reason}") + } + } + + // Control: the recorded hash is what the replay is checked + // against, so a wrong hash must fail. Without this the pass above + // is satisfied by a scenario that asserts nothing. + let tampered = to_scenario( + "ground/recorded-bot-game", + 42, + 3, + &game.steps, + Some("0".repeat(hash.len())), + ); + assert!( + matches!(run::(&tampered), RunOutcome::Failed { .. }), + "a tampered end-state hash must fail the replay" + ); + } + + /// Every command shape must survive encode → parse unchanged. This is + /// the whole warrant for the encoder: nothing else compares it to the + /// reader it must agree with. + #[test] + fn every_command_shape_round_trips() { + let p2 = PlayerId(1); + let cases: Vec<(Actor, GroundCommand)> = vec![ + ( + Actor::Player(PlayerId(0)), + GroundCommand::SelectAction { + action: Action::Investigate, + target: None, + problem: Some(3), + }, + ), + ( + Actor::Player(PlayerId(0)), + GroundCommand::SelectAction { + action: Action::Attack, + target: Some(p2), + problem: None, + }, + ), + ( + Actor::Player(PlayerId(2)), + GroundCommand::SelectAction { + action: Action::Ground, + target: None, + problem: None, + }, + ), + (Actor::Player(PlayerId(0)), GroundCommand::SpendFreedom), + ( + Actor::Player(PlayerId(0)), + GroundCommand::ChooseGroundMode { + mode: GroundMode::Gr, + choice: None, + }, + ), + ( + Actor::Player(PlayerId(0)), + GroundCommand::ChooseGroundMode { + mode: GroundMode::Ou, + choice: Some(GroundChoice::RestoreProblem { problem: 2 }), + }, + ), + ( + Actor::Player(PlayerId(0)), + GroundCommand::ChooseGroundMode { + mode: GroundMode::Ou, + choice: Some(GroundChoice::ProtectProblem { problem: 1 }), + }, + ), + ( + Actor::Player(PlayerId(0)), + GroundCommand::ChooseGroundMode { + mode: GroundMode::Ou, + choice: Some(GroundChoice::CancelAttack { attacker: p2 }), + }, + ), + ( + Actor::Player(PlayerId(0)), + GroundCommand::ChooseGroundMode { + mode: GroundMode::Nd, + choice: Some(GroundChoice::RemoveBlame { owner: p2 }), + }, + ), + ( + Actor::Player(PlayerId(0)), + GroundCommand::ChooseGroundMode { + mode: GroundMode::Nd, + choice: Some(GroundChoice::BreakRelation { with: p2 }), + }, + ), + ( + Actor::Player(PlayerId(0)), + GroundCommand::ChooseGroundMode { + mode: GroundMode::Nd, + choice: Some(GroundChoice::RejectReverse), + }, + ), + ( + Actor::Player(PlayerId(1)), + GroundCommand::RespondToSupport { + response: SupportResponse::AcceptBond, + }, + ), + ( + Actor::Player(PlayerId(1)), + GroundCommand::RespondToSupport { + response: SupportResponse::BreakRivalry, + }, + ), + ( + Actor::Player(PlayerId(0)), + GroundCommand::ChooseDarvoTarget { + target: DarvoTarget { + problem: Some(2), + player: None, + }, + }, + ), + ( + Actor::Player(PlayerId(0)), + GroundCommand::ChooseDarvoTarget { + target: DarvoTarget { + problem: None, + player: Some(p2), + }, + }, + ), + (Actor::System, GroundCommand::Reveal), + (Actor::System, GroundCommand::Resolve), + (Actor::System, GroundCommand::EndRound), + ]; + + for (actor, command) in &cases { + let step = to_step(*actor, command); + let parsed = GroundState::parse_command(&step) + .unwrap_or_else(|e| panic!("{step:?} does not parse back: {e}")); + assert_eq!(parsed, (*actor, command.clone()), "round trip: {step:?}"); + } + + // Positive control: the case list must cover every variant. A + // shape added to GroundCommand and forgotten here would encode + // untested — the failure this test exists to prevent. + let shapes: std::collections::BTreeSet = cases + .iter() + .map(|(_, c)| to_step(Actor::System, c).cmd) + .collect(); + assert_eq!(shapes.len(), 8, "every GroundCommand variant must appear"); + } +} diff --git a/games/ground/src/view.rs b/games/ground/src/view.rs new file mode 100644 index 0000000..7279765 --- /dev/null +++ b/games/ground/src/view.rs @@ -0,0 +1,313 @@ +//! K13's first implementor (CB-WP-0008 T02). +//! +//! `cb_game_runtime::Project` has existed since CB-WP-0001 with **zero +//! implementors** — a trait shaped for a consumer that had not been +//! written. This is that consumer: `cb-play` shows one seat what it may +//! see, and nothing else. +//! +//! K13 says a projection is *a total function from state to what one seat +//! may see*, and that projections can never feed back into validation. +//! Nothing here is `pub` to the aggregate; `validate` does not import it. +//! +//! ## What is hidden, and by which rule +//! +//! | hidden | rule | +//! |---|---| +//! | other seats' face-down selections, until Reveal | GR-R02/R04 | +//! | other seats' Solution hands (count only) | GR-S02 | +//! | the undealt Solution deck (count only) | GR-S04 | +//! | a face-down Problem's suit and value | GR-S01 | +//! | `seed` | it determines every future shuffle | +//! +//! The last one is the interesting one: `seed` is not secret *content*, +//! but a seat holding it can compute the deck. It is omitted from the +//! view for the same reason the deck is. + +use crate::{ + Coalition, DarvoStage, DarvoTarget, GroundChoice, GroundMode, GroundState, Outcome, Pair, + ProblemState, Relation, RoundStep, ScoringMode, Selection, SolutionCard, SupportResponse, +}; +use cb_game_runtime::{Project, Viewer}; +use cb_kernel::PlayerId; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// What one seat may see of a `GroundState`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GroundView { + /// `None` for a spectator. + pub viewer: Option, + pub round: u8, + pub lead: PlayerId, + pub step: RoundStep, + pub mode: ScoringMode, + pub players: BTreeMap, + pub relations: BTreeMap, + pub problems: BTreeMap, + pub focus: BTreeMap, + /// GR-R02: `Hidden` for other seats until Reveal. + pub selections: BTreeMap, + pub ground_modes: BTreeMap, + pub ground_choices: BTreeMap, + pub support_responses: BTreeMap, + pub darvo_targets: BTreeMap, + /// GR-S04: how many Solutions remain, not which. + pub solution_deck_len: usize, + /// Discards are public — they have been played. + pub solution_discard: Vec, + pub outcome: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PlayerView { + pub stress: u8, + pub freedom_ready: bool, + pub freedom_gate_lifted: bool, + pub darvo: DarvoStage, + pub protection: u8, + pub blame_from: Vec, + /// GR-S02: only the viewer's own hand. + pub hand: Option>, + pub hand_size: usize, +} + +/// GR-S01: a face-down Problem shows nothing but its priority (the map +/// key) and that it exists. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state")] +pub enum ProblemView { + FaceDown, + FaceUp { + suit: crate::Suit, + value: u8, + denied: bool, + claimed_by: Option, + protected_this_round: bool, + }, +} + +/// GR-R02/R04: face-down means face-down, including to the projection. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state")] +pub enum SelectionView { + /// Someone has selected; what, this seat may not see. + Hidden, + Shown(Selection), +} + +/// The final outcome is public once it exists (GR-E01..E04). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OutcomeView { + pub total: u32, + pub threshold: u32, + pub group_success: bool, + pub personal: BTreeMap, + pub coalitions: Vec, + pub mastery: Option, + pub winners: Vec, +} + +impl From<&Outcome> for OutcomeView { + fn from(o: &Outcome) -> Self { + Self { + total: o.total, + threshold: o.threshold, + group_success: o.group_success, + personal: o.personal.clone(), + coalitions: o.coalitions.clone(), + mastery: o.mastery, + winners: o.winners.clone(), + } + } +} + +impl From<&ProblemState> for ProblemView { + fn from(p: &ProblemState) -> Self { + if p.face_up { + ProblemView::FaceUp { + suit: p.suit, + value: p.value, + denied: p.denied, + claimed_by: p.claimed_by, + protected_this_round: p.protected_this_round, + } + } else { + ProblemView::FaceDown + } + } +} + +impl Project for GroundState { + type View = GroundView; + + fn project(&self, viewer: Viewer) -> GroundView { + let seat = match viewer { + Viewer::Player(p) => Some(p), + Viewer::Spectator => None, + }; + // GR-R04: once Reveal has happened, selections are public for the + // rest of the round. + let revealed = self.step != RoundStep::Select; + + GroundView { + viewer: seat, + round: self.round, + lead: self.lead, + step: self.step, + mode: self.mode, + players: self + .players + .iter() + .map(|(id, p)| { + ( + *id, + PlayerView { + stress: p.stress, + freedom_ready: p.freedom_ready, + freedom_gate_lifted: p.freedom_gate_lifted, + darvo: p.darvo, + protection: p.protection, + blame_from: p.blame_from.clone(), + hand: (Some(*id) == seat).then(|| p.hand.clone()), + hand_size: p.hand.len(), + }, + ) + }) + .collect(), + relations: self.relations.clone(), + problems: self.problems.iter().map(|(k, p)| (*k, p.into())).collect(), + focus: self.focus.clone(), + selections: self + .selections + .iter() + .map(|(id, sel)| { + let visible = revealed || Some(*id) == seat; + ( + *id, + if visible { + SelectionView::Shown(*sel) + } else { + SelectionView::Hidden + }, + ) + }) + .collect(), + ground_modes: self.ground_modes.clone(), + ground_choices: self.ground_choices.clone(), + support_responses: self.support_responses.clone(), + darvo_targets: self.darvo_targets.clone(), + solution_deck_len: self.solution_deck.len(), + solution_discard: self.solution_discard.clone(), + outcome: self.outcome.as_ref().map(OutcomeView::from), + } + } +} + +#[cfg(all(test, feature = "scenarios"))] +mod tests { + use super::*; + use crate::{Action, Actor, Aggregate, GroundCommand}; + 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 select(state: &mut GroundState, seat: u8, action: Action, problem: Option) { + let cmd = GroundCommand::SelectAction { + action, + target: None, + problem, + }; + let events = state + .validate(Actor::Player(PlayerId(seat)), &cmd) + .expect("legal"); + for e in &events { + state.fold(e); + } + } + + /// The acceptance clause: a seat's projection never contains another + /// seat's hidden selection. Asserted on the serialized form, because + /// that is what a CLI prints and what leaks. + #[test] + fn a_seat_never_sees_another_seats_face_down_selection() { + let mut state = setup(3, 42); + // Distinct actions, so finding the other seat's is unambiguous. + select(&mut state, 0, Action::Investigate, Some(2)); + select(&mut state, 1, Action::Solve, Some(1)); + // GR-R04 needs every seat before Reveal. + select(&mut state, 2, Action::Ground, None); + + let view = state.project(Viewer::Player(PlayerId(0))); + let json = serde_json::to_string(&view).expect("serialize"); + + assert!(json.contains("Investigate"), "own selection must be shown"); + assert!( + !json.contains("Solve"), + "seat 1's face-down SOLVE leaked into seat 0's view: {json}" + ); + assert_eq!( + view.selections.get(&PlayerId(1)), + Some(&SelectionView::Hidden) + ); + + // And the same view after Reveal shows it — otherwise the + // assertion above passes for a projection that shows nothing. + let events = state + .validate(Actor::System, &GroundCommand::Reveal) + .expect("reveal"); + for e in &events { + state.fold(e); + } + let after = state.project(Viewer::Player(PlayerId(0))); + assert!(matches!( + after.selections.get(&PlayerId(1)), + Some(SelectionView::Shown(_)) + )); + } + + /// GR-S02/S04: hands and deck are hidden; counts are not. + #[test] + fn hands_and_deck_are_hidden_but_counted() { + let state = setup(3, 7); + let view = state.project(Viewer::Player(PlayerId(0))); + assert!(view.players[&PlayerId(0)].hand.is_some()); + assert!(view.players[&PlayerId(1)].hand.is_none()); + assert_eq!(view.players[&PlayerId(1)].hand_size, 2); + assert_eq!(view.solution_deck_len, state.solution_deck.len()); + + let json = serde_json::to_string(&view).expect("serialize"); + assert!( + !json.contains("\"seed\""), + "the seed determines every future shuffle and must not project" + ); + } + + /// GR-S01: a face-down Problem shows nothing about itself. + #[test] + fn a_face_down_problem_shows_nothing() { + let state = setup(3, 7); + let view = state.project(Viewer::Spectator); + assert_eq!(view.problems[&1], (&state.problems[&1]).into()); + assert!(matches!(view.problems[&1], ProblemView::FaceUp { .. })); + assert_eq!(view.problems[&2], ProblemView::FaceDown); + assert_eq!(view.viewer, None); + } + + /// A spectator sees no hand at all — the `Some(*id) == seat` test must + /// not accidentally match `None`. + #[test] + fn a_spectator_sees_no_hands() { + let view = setup(3, 7).project(Viewer::Spectator); + assert!(view.players.values().all(|p| p.hand.is_none())); + } +} diff --git a/history/260801-cb-wp-0008-log.md b/history/260801-cb-wp-0008-log.md index 6c65c3a..609c34d 100644 --- a/history/260801-cb-wp-0008-log.md +++ b/history/260801-cb-wp-0008-log.md @@ -62,3 +62,41 @@ stated reason. Third recorded instance of the weak-mutation class — the retrospective in `260801-instrument-the-table-retrospective.md` predicted mutation strength would stay a standing maintenance cost, and this is that cost arriving on the next pass. + +## T02 — `cb-play` + +`tools/cb-play` (`make play`), 6 tests. 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`. + +- **K13's first implementor** is `games/ground/src/view.rs`. Hidden: + other seats' face-down selections until Reveal, hands (count only), the + undealt deck (count 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 FILE` writes it as a + scenario the runner executes; `--replay DIR` writes a `.cbreplay` + bundle. `games/ground/src/record.rs` is the inverse of + `parse_command`, and its warrant is a round-trip test over every + command shape — an encoder checked against hand-written expectations + only agrees with itself. + +### Two vacuous tests, caught by counting + +The acceptance clause *"a seat's projection never contains another seat's +hidden selection"* passed twice while asserting nothing. + +1. The first version asserted the rendered text contained `face-down` — + which it does, from **Problems**, in every game ever rendered. +2. The counted version then reported **0 inspected entries**: seats are + asked in order, so a human at P1 is always prompted before anyone has + selected and never sees a hidden selection at all. + +Seating the human at **P3** exercises the rule; the test now counts what +it inspected and requires at least four. Mutating the projection +(`revealed = true`) fails it for its stated reason. + +This is the same class as the AM-2 `expect` that matched passing output — +an assertion that cannot distinguish the two worlds. The remedy that +worked both times was **counting what the harness examined**, not +strengthening the predicate. diff --git a/tools/cb-play/Cargo.toml b/tools/cb-play/Cargo.toml new file mode 100644 index 0000000..da170fd --- /dev/null +++ b/tools/cb-play/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "cb-play" +edition.workspace = true +version.workspace = true +license-file.workspace = true + +[dependencies] +cb-kernel.workspace = true +cb-events.workspace = true +cb-game-runtime = { workspace = true, features = ["scenarios"] } +games-ground = { workspace = true, features = ["scenarios"] } +serde_json.workspace = true +# Scenario args are YAML values; the prompt renders them (T02). +serde_yaml.workspace = true + +[lints] +workspace = true diff --git a/tools/cb-play/src/main.rs b/tools/cb-play/src/main.rs new file mode 100644 index 0000000..9426824 --- /dev/null +++ b/tools/cb-play/src/main.rs @@ -0,0 +1,313 @@ +//! cb-play — play GROUND headless, against bots (CB-WP-0008 T02; +//! precursor of `cb play`). +//! +//! INTENT stage 0 asks for a **CLI player**. Everything else on that list +//! shipped in CB-WP-0001..0006; until this binary existed, the engine was +//! correct, measured and replayable, and nothing could play it. +//! +//! Exit codes: 0 the game finished, 1 it did not. There is no partial +//! success — a session that ends without an outcome is a failure, and +//! saying otherwise is how a stalled game passes for a played one. + +mod table; + +use table::Config; + +const USAGE: &str = "\ +usage: cb-play [--seed N] [--players N] [--seat N]... [--bot greedy|random] + [--replay DIR] [--all-bots] + + --seed N game seed (default 1); the same seed replays identically + --players N 2..6 seats (default 3) + --seat N a seat you play, 0-based, repeatable (default 0) + --all-bots no human seats; watch a game play itself + --bot KIND policy for every other seat: greedy (default) or random + --replay DIR write a .cbreplay bundle of the finished game to DIR + --record FILE write the finished game as a scenario YAML +"; + +fn parse_args(argv: &[String]) -> Result { + let mut config = Config::default(); + let mut seats: Vec = Vec::new(); + let mut all_bots = false; + let mut i = 0; + let value = |i: usize, argv: &[String], flag: &str| -> Result { + argv.get(i + 1) + .cloned() + .ok_or_else(|| format!("{flag} needs a value")) + }; + while i < argv.len() { + let flag = argv[i].as_str(); + match flag { + "--seed" => { + config.seed = value(i, argv, flag)? + .parse() + .map_err(|e| format!("--seed: {e}"))?; + i += 2; + } + "--players" => { + config.players = value(i, argv, flag)? + .parse() + .map_err(|e| format!("--players: {e}"))?; + i += 2; + } + "--seat" => { + seats.push( + value(i, argv, flag)? + .parse() + .map_err(|e| format!("--seat: {e}"))?, + ); + i += 2; + } + "--bot" => { + config.bot = value(i, argv, flag)?; + i += 2; + } + "--record" => { + config.record = Some(value(i, argv, flag)?.into()); + i += 2; + } + "--replay" => { + config.replay_dir = Some(value(i, argv, flag)?.into()); + i += 2; + } + "--all-bots" => { + all_bots = true; + i += 1; + } + "-h" | "--help" => return Err(USAGE.into()), + other => return Err(format!("unknown flag {other:?}\n\n{USAGE}")), + } + } + if !(2..=6).contains(&config.players) { + return Err(format!("GR-O01: {} players is outside 2–6", config.players)); + } + config.human_seats = if all_bots { + vec![] + } else if seats.is_empty() { + vec![0] + } else { + seats + }; + if let Some(bad) = config.human_seats.iter().find(|s| **s >= config.players) { + return Err(format!( + "--seat {bad} is not one of {} seats", + config.players + )); + } + Ok(config) +} + +fn main() { + let argv: Vec = std::env::args().skip(1).collect(); + let config = match parse_args(&argv) { + Ok(c) => c, + Err(message) => { + eprintln!("{message}"); + std::process::exit(64); + } + }; + + let stdin = std::io::stdin(); + let stdout = std::io::stdout(); + match table::play(&config, stdin.lock(), stdout.lock()) { + Ok(summary) => { + println!( + " {} round(s), {} commands, hash {}", + summary.rounds, + summary.scenario.commands.len(), + summary.end_state_hash + ); + if let Some(path) = summary.bundle { + println!(" bundle {}", path.display()); + } + if let Some(path) = summary.recorded { + println!(" recorded {}", path.display()); + } + } + Err(message) => { + eprintln!("cb-play: {message}"); + std::process::exit(1); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cb_game_runtime::{run, RunOutcome}; + use games_ground::GroundState; + + fn args(list: &[&str]) -> Vec { + list.iter().map(|s| s.to_string()).collect() + } + + /// A scripted transcript plays a full 3-player game to `GameEnded`. + /// "0" always takes the first legal command, which is a real player + /// input and needs no knowledge of the board. + #[test] + fn a_scripted_transcript_plays_a_full_game() { + let config = Config { + seed: 42, + players: 3, + human_seats: vec![0], + bot: "greedy".into(), + replay_dir: None, + record: None, + }; + let script = "0\n".repeat(200); + let mut out: Vec = Vec::new(); + let summary = table::play(&config, script.as_bytes(), &mut out) + .unwrap_or_else(|e| panic!("scripted game failed: {e}")); + + assert_eq!(summary.rounds, 5, "GR-R09 runs five rounds"); + let text = String::from_utf8(out).expect("utf8"); + assert!(text.contains("OUTCOME"), "the game must report an outcome"); + assert!( + text.contains("you are P1"), + "the human seat must be prompted" + ); + + // The transcript replays identically — through the scenario + // runner, not through a second call to the same driver. + match run::(&summary.scenario) { + RunOutcome::Passed { .. } => {} + RunOutcome::Failed { reason, .. } => panic!("replay failed: {reason}"), + } + } + + /// K13 at the boundary that matters: what the human is *shown* must + /// not contain another seat's face-down selection. + /// + /// The human seat is **P3**, deliberately. Seats are asked in order, + /// so a P1 human is always prompted before anyone has selected and + /// never sees a hidden selection at all — a test seated there passes + /// without exercising the rule. + #[test] + fn the_prompt_never_shows_a_hidden_selection() { + let config = Config { + seed: 42, + players: 3, + human_seats: vec![2], + bot: "greedy".into(), + replay_dir: None, + record: None, + }; + let mut out: Vec = Vec::new(); + table::play(&config, "0\n".repeat(200).as_bytes(), &mut out).expect("game"); + let text = String::from_utf8(out).expect("utf8"); + + // Every rendered Select-step block must show other seats as + // face-down. A block naming a seat's action before Reveal is a + // leak; `step Select` and `Shown` never co-occur. + let mut checked = 0; + for block in text.split("\nround ") { + if !block.contains("step Select") { + continue; + } + if let Some(line) = block + .lines() + .find(|l| l.trim_start().starts_with("selections:")) + { + let others: Vec<&str> = line + .split(" ") + .filter(|s| s.starts_with("P1") || s.starts_with("P2")) + .collect(); + for seat in others { + checked += 1; + assert!( + seat.contains("face-down"), + "a Select-step prompt showed {seat:?}: {line}" + ); + } + } + } + // The loop above passes trivially if it never found a selections + // line to check — the harness-does-nothing class, which is why + // this counts what it inspected. + assert!( + checked >= 4, + "only {checked} other-seat entries inspected across the session" + ); + } + + /// A session whose input runs out must say so, not finish the game + /// on the human's behalf. + #[test] + fn running_out_of_input_fails_loudly() { + let config = Config::default(); + let mut out: Vec = Vec::new(); + let err = table::play(&config, "0\n".as_bytes(), &mut out) + .expect_err("an exhausted transcript must not finish a game"); + assert!(err.contains("input ended"), "got {err:?}"); + } + + /// `--all-bots` is the flag that makes a game watchable, and the + /// control that proves the human path is not the only path. + #[test] + fn all_bots_needs_no_input_at_all() { + let config = Config { + seed: 42, + players: 3, + human_seats: vec![], + bot: "random".into(), + replay_dir: None, + record: None, + }; + let mut out: Vec = Vec::new(); + let summary = table::play(&config, "".as_bytes(), &mut out).expect("bot game"); + assert_eq!(summary.rounds, 5); + } + + #[test] + fn flags_parse_and_bad_ones_are_refused() { + let c = parse_args(&args(&["--seed", "9", "--players", "4", "--seat", "2"])).unwrap(); + assert_eq!((c.seed, c.players, c.human_seats.clone()), (9, 4, vec![2])); + assert_eq!(parse_args(&args(&[])).unwrap().human_seats, vec![0]); + assert!(parse_args(&args(&["--all-bots"])) + .unwrap() + .human_seats + .is_empty()); + // GR-O01's range, refused at the door rather than at setup. + assert!(parse_args(&args(&["--players", "7"])).is_err()); + assert!(parse_args(&args(&["--players", "1"])).is_err()); + // A seat nobody occupies would silently never be prompted. + assert!(parse_args(&args(&["--players", "3", "--seat", "3"])).is_err()); + assert!(parse_args(&args(&["--seed"])).is_err()); + assert!(parse_args(&args(&["--nope"])).is_err()); + } + + /// A bundle is written only when asked, and it replays. + #[test] + fn a_bundle_is_written_and_replays() { + let dir = std::env::temp_dir().join(format!("cb-play-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("tmp"); + let config = Config { + seed: 42, + players: 3, + human_seats: vec![], + bot: "greedy".into(), + replay_dir: Some(dir.clone()), + record: Some(dir.join("session.yaml")), + }; + let mut out: Vec = Vec::new(); + let summary = table::play(&config, "".as_bytes(), &mut out).expect("game"); + let bundle = summary.bundle.expect("bundle path"); + assert!(bundle.join("manifest.yaml").exists()); + assert!(bundle.join("commands.log").exists()); + let report = cb_game_runtime::replay::replay::(&bundle) + .unwrap_or_else(|e| panic!("bundle does not replay: {e}")); + assert_eq!(report.hash, summary.end_state_hash); + + // The recorded scenario is a file the runner can execute, which + // is what makes a played session a regression test. + let text = std::fs::read_to_string(summary.recorded.expect("record path")).expect("read"); + let parsed = cb_game_runtime::ScenarioFile::from_yaml(&text).expect("parse"); + assert!(matches!( + run::(&parsed), + RunOutcome::Passed { .. } + )); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/tools/cb-play/src/table.rs b/tools/cb-play/src/table.rs new file mode 100644 index 0000000..a9a6fb2 --- /dev/null +++ b/tools/cb-play/src/table.rs @@ -0,0 +1,404 @@ +//! The playable loop: render one seat's projection, offer it the legal +//! commands, read one, apply it (CB-WP-0008 T02). +//! +//! Everything a human sees comes from `GroundState::project` — K13's +//! projection, whose first consumer this is. The process itself holds +//! full state, because it is the referee: legality is decided by +//! `validate`, and hidden information is withheld at the *rendering* +//! boundary, which is where it leaks. +//! +//! **Stated non-goal:** no TUI, no colour, no readline. Stage 1 is the +//! inspectable 2D table; this is the smallest thing that makes the rules +//! playable. Dressing it up now would be inventing a UI before a player +//! has used one. + +use cb_game_runtime::{Project, Setup, Viewer}; +use cb_kernel::{Actor, PlayerId}; +use games_ground::bot::{BotError, Choice, GreedyPolicy, Policy, RandomPolicy}; +use games_ground::record::to_step; +use games_ground::view::{GroundView, PlayerView, ProblemView, SelectionView}; +use games_ground::{GroundCommand, GroundState}; +use std::io::{BufRead, Write}; + +pub struct Config { + pub seed: u64, + pub players: u8, + /// Seats a human plays, 0-based (`PlayerId(0)` is P1). + pub human_seats: Vec, + pub bot: String, + /// Where to write a `.cbreplay` bundle of the finished game. + pub replay_dir: Option, + /// Where to write the finished game as a scenario file. A session + /// somebody played becomes a regression test. + pub record: Option, +} + +impl Default for Config { + fn default() -> Self { + Self { + seed: 1, + players: 3, + human_seats: vec![0], + bot: "greedy".into(), + replay_dir: None, + record: None, + } + } +} + +/// What a finished game reports back. +/// The end-state hash is what makes a session comparable to its replay. +#[derive(Debug)] +pub struct Summary { + pub rounds: u8, + pub end_state_hash: String, + pub scenario: cb_game_runtime::ScenarioFile, + pub bundle: Option, + pub recorded: Option, +} + +// ------------------------------------------------------------- rendering + +fn suit_name(s: games_ground::Suit) -> &'static str { + match s { + games_ground::Suit::Clarify => "Clarify", + games_ground::Suit::Repair => "Repair", + games_ground::Suit::Boundary => "Boundary", + games_ground::Suit::Change => "Change", + } +} + +fn seat_name(p: PlayerId) -> String { + format!("P{}", p.0 + 1) +} + +fn describe(command: &GroundCommand) -> String { + // Reuse the recorder's vocabulary rather than inventing a third one: + // what the player reads is what the scenario file will say. + let step = to_step(Actor::System, command); + let mut out = step.cmd.clone(); + for (key, value) in &step.args { + let rendered = match value { + serde_yaml::Value::String(s) => s.clone(), + other => serde_yaml::to_string(other) + .unwrap_or_default() + .trim() + .to_string(), + }; + out.push_str(&format!(" {key}={rendered}")); + } + out +} + +fn render_player(id: PlayerId, p: &PlayerView, is_viewer: bool) -> String { + let hand = match &p.hand { + Some(cards) => { + let names: Vec<&str> = cards.iter().map(|c| suit_name(c.suit)).collect(); + format!("hand [{}]", names.join(", ")) + } + None => format!("hand {} card(s)", p.hand_size), + }; + format!( + " {}{} stress {} freedom {} darvo {:?} blame {} {hand}", + seat_name(id), + if is_viewer { " (you)" } else { " " }, + p.stress, + if p.freedom_ready { "READY" } else { "SPENT" }, + p.darvo, + p.blame_from.len(), + ) +} + +/// One seat's whole picture, from the projection and nothing else. +pub fn render(view: &GroundView) -> String { + let mut out = String::new(); + out.push_str(&format!( + "\nround {} step {:?} lead {} deck {}\n", + view.round, + view.step, + seat_name(view.lead), + view.solution_deck_len, + )); + for (id, p) in &view.players { + out.push_str(&render_player(*id, p, Some(*id) == view.viewer)); + out.push('\n'); + } + out.push_str(" problems:"); + for (priority, problem) in &view.problems { + match problem { + ProblemView::FaceDown => out.push_str(&format!(" [{priority}] face-down")), + ProblemView::FaceUp { + suit, + value, + denied, + claimed_by, + .. + } => { + out.push_str(&format!( + " [{priority}] {} {}{}{}", + suit_name(*suit), + value, + if *denied { " DENIED" } else { "" }, + match claimed_by { + Some(p) => format!(" claimed by {}", seat_name(*p)), + None => String::new(), + } + )); + } + } + } + out.push('\n'); + if !view.relations.is_empty() { + out.push_str(" relations:"); + for (pair, relation) in &view.relations { + out.push_str(&format!(" {pair} {relation:?}")); + } + out.push('\n'); + } + if !view.selections.is_empty() { + out.push_str(" selections:"); + for (id, sel) in &view.selections { + match sel { + SelectionView::Hidden => out.push_str(&format!(" {} face-down", seat_name(*id))), + SelectionView::Shown(s) => out.push_str(&format!( + " {} {:?}{}{}", + seat_name(*id), + s.action, + match s.target { + Some(t) => format!("→{}", seat_name(t)), + None => String::new(), + }, + match s.problem { + Some(p) => format!("→[{p}]"), + None => String::new(), + } + )), + } + } + out.push('\n'); + } + if let Some(o) = &view.outcome { + out.push_str(&format!( + " OUTCOME total {} / threshold {} group {} winners {:?}\n", + o.total, + o.threshold, + if o.group_success { + "SUCCESS" + } else { + "failure" + }, + o.winners.iter().map(|w| seat_name(*w)).collect::>(), + )); + } + out +} + +// --------------------------------------------------------------- policies + +/// A seat driven from stdin. Implements the same `Policy` the bots do, so +/// a human and a bot are interchangeable and the driver stays one loop. +pub struct HumanPolicy<'a, R: BufRead, W: Write> { + input: &'a std::cell::RefCell, + out: &'a std::cell::RefCell, + /// `Policy::choose` cannot fail, so an unreadable input is parked + /// here and turned into a loud error by [`play`]. Returning some + /// default move instead would let a broken session play itself. + /// + /// A shared slot rather than a trait method: widening `Policy` so one + /// implementor can fail would push a CLI concern into every bot. + failure: Failure, +} + +/// Where a human seat parks the reason it could not answer. +pub type Failure = std::rc::Rc>>; + +impl<'a, R: BufRead, W: Write> HumanPolicy<'a, R, W> { + pub fn new( + input: &'a std::cell::RefCell, + out: &'a std::cell::RefCell, + failure: Failure, + ) -> Self { + Self { + input, + out, + failure, + } + } +} + +impl Policy for HumanPolicy<'_, R, W> { + fn name(&self) -> &'static str { + "human" + } + + fn choose( + &mut self, + state: &GroundState, + seat: PlayerId, + legal: &[GroundCommand], + may_pass: bool, + ) -> Choice { + let mut w = self.out.borrow_mut(); + let _ = write!(w, "{}", render(&state.project(Viewer::Player(seat)))); + let _ = writeln!(w, " you are {}", seat_name(seat)); + for (i, cmd) in legal.iter().enumerate() { + let _ = writeln!(w, " [{i}] {}", describe(cmd)); + } + let _ = writeln!( + w, + " choose a number{}:", + if may_pass { " or `pass`" } else { "" } + ); + let _ = w.flush(); + drop(w); + + let mut line = String::new(); + loop { + line.clear(); + match self.input.borrow_mut().read_line(&mut line) { + Ok(0) => { + *self.failure.borrow_mut() = + Some(format!("input ended while {} had to act", seat_name(seat))); + // Out of range on purpose: the driver reports it. + return Choice::Command(usize::MAX); + } + Err(e) => { + *self.failure.borrow_mut() = Some(format!("read error: {e}")); + return Choice::Command(usize::MAX); + } + Ok(_) => {} + } + let word = line.trim(); + if word.is_empty() { + continue; + } + if word.eq_ignore_ascii_case("pass") { + return Choice::Pass; + } + match word.parse::() { + Ok(i) => return Choice::Command(i), + Err(_) => { + let mut w = self.out.borrow_mut(); + let _ = writeln!(w, " not a number: {word:?}"); + let _ = w.flush(); + } + } + } + } +} + +fn bot_policy<'a>(kind: &str, seed: u64) -> Result, String> { + match kind { + "greedy" => Ok(Box::new(GreedyPolicy)), + "random" => Ok(Box::new(RandomPolicy::new(seed))), + other => Err(format!("unknown bot policy {other:?} (greedy, random)")), + } +} + +// ----------------------------------------------------------------- driver + +/// Play one game. The human seats read from `input`; the rest are bots. +pub fn play(config: &Config, input: R, out: W) -> Result { + // The shared reader and writer must outlive the policy objects that + // borrow them, so ownership stays here and the game runs one frame in. + let input = std::cell::RefCell::new(input); + let out = std::cell::RefCell::new(out); + run_game(config, &input, &out) +} + +fn run_game<'a, R: BufRead + 'a, W: Write + 'a>( + config: &Config, + input: &'a std::cell::RefCell, + out: &'a std::cell::RefCell, +) -> Result { + let setup = Setup { + players: config.players, + preset: format!("standard-{}p", config.players), + patch: Default::default(), + }; + let initial = ::setup(&setup, config.seed)?; + let initial_json = serde_json::to_value(&initial).map_err(|e| e.to_string())?; + let initial_hash = cb_events::state_hash_hex(&initial); + + // Human seats and bot seats fill one policy vector; the driver does + // not know which is which, which is the point. + let failure: Failure = Default::default(); + let mut policies: Vec> = Vec::new(); + for seat in 0..config.players { + if config.human_seats.contains(&seat) { + policies.push(Box::new(HumanPolicy::new(input, out, failure.clone()))); + } else { + policies.push(bot_policy(&config.bot, config.seed + u64::from(seat))?); + } + } + + let result = games_ground::bot::play(initial, &mut policies); + + // A human seat that ran out of input reports *that*, not the + // out-of-range index it had to return to get here. + let human_failure = failure.borrow().clone(); + let game = match (result, human_failure) { + (_, Some(msg)) => return Err(msg), + (Err(BotError::IllegalChoice { seat, offered, .. }), None) => { + return Err(format!( + "{} chose a command outside the {offered} offered", + seat_name(seat) + )) + } + (Err(e), None) => return Err(e.to_string()), + (Ok(game), None) => game, + }; + + let end_hash = cb_events::state_hash_hex(&game.state); + let mut w = out.borrow_mut(); + let _ = write!(w, "{}", render(&game.state.project(Viewer::Spectator))); + let _ = writeln!( + w, + " game over — {} commands, hash {}", + game.commands, + &end_hash[..12] + ); + let _ = w.flush(); + drop(w); + + let scenario = games_ground::record::to_scenario( + "ground/cb-play-session", + config.seed, + config.players, + &game.steps, + Some(end_hash.clone()), + ); + + let bundle = match &config.replay_dir { + None => None, + Some(dir) => { + let end_json = serde_json::to_value(&game.state).map_err(|e| e.to_string())?; + Some(cb_game_runtime::replay::write_bundle( + dir, + &scenario, + &initial_json, + &initial_hash, + &end_json, + &end_hash, + "recorded by cb-play", + )?) + } + }; + + let recorded = match &config.record { + None => None, + Some(path) => { + let yaml = serde_yaml::to_string(&scenario).map_err(|e| e.to_string())?; + std::fs::write(path, yaml).map_err(|e| format!("write {}: {e}", path.display()))?; + Some(path.clone()) + } + }; + + Ok(Summary { + rounds: game.rounds, + end_state_hash: end_hash, + scenario, + bundle, + recorded, + }) +} diff --git a/workplans/CB-WP-0008-ship-stage-0.md b/workplans/CB-WP-0008-ship-stage-0.md index 41ff563..056b97e 100644 --- a/workplans/CB-WP-0008-ship-stage-0.md +++ b/workplans/CB-WP-0008-ship-stage-0.md @@ -64,7 +64,7 @@ are in `history/260801-cb-wp-0008-log.md`. ```task id: CB-WP-0008-T02 -status: todo +status: done priority: high state_hub_task_id: "4842d3a0-dfec-4699-84f9-2bb1ef07809f" ``` @@ -88,6 +88,11 @@ player has used one. `GameEnded`; the same transcript replays identically; and a seat's projection never contains another seat's hidden selection. +**Done 2026-08-01.** `tools/cb-play`, `make play`, 6 tests; K13 gained its +first implementor in `games/ground/src/view.rs` and a played session now +records as a scenario and as a `.cbreplay` bundle. Notes in +`history/260801-cb-wp-0008-log.md`. + ## Task: prove the 2–6 player range ```task