From 25531e9d0187d43fea0e4209020cbe36868db02d Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 1 Aug 2026 14:43:53 +0200 Subject: [PATCH] =?UTF-8?q?CB-WP-0008-T02:=20cb-play=20=E2=80=94=20INTENT?= =?UTF-8?q?=20stage=200's=20CLI=20player?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Cargo.lock | 14 + Cargo.toml | 1 + Makefile | 7 +- games/ground/Cargo.toml | 7 +- games/ground/src/bot.rs | 88 ++---- games/ground/src/lib.rs | 10 + games/ground/src/record.rs | 364 ++++++++++++++++++++++++ games/ground/src/view.rs | 313 +++++++++++++++++++++ history/260801-cb-wp-0008-log.md | 38 +++ tools/cb-play/Cargo.toml | 17 ++ tools/cb-play/src/main.rs | 313 +++++++++++++++++++++ tools/cb-play/src/table.rs | 404 +++++++++++++++++++++++++++ workplans/CB-WP-0008-ship-stage-0.md | 7 +- 13 files changed, 1523 insertions(+), 60 deletions(-) create mode 100644 games/ground/src/record.rs create mode 100644 games/ground/src/view.rs create mode 100644 tools/cb-play/Cargo.toml create mode 100644 tools/cb-play/src/main.rs create mode 100644 tools/cb-play/src/table.rs 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