//! 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, }) }