//! 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::{GroundCommand, GroundState}; use crate::inspect::{render, seat_name}; use std::io::{BufRead, Write}; #[derive(Clone)] 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, /// Serve human seats in a browser instead of on the terminal /// (ADR-0007). `Some(0)` lets the OS pick the port. pub serve: 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, serve: None, } } } /// What carries across games in one browser session (CB-WP-0024 T04). /// /// `play` already owned the session — one listener, an advancing seed, /// `run_game` in a loop — and kept only the last summary, so a second /// game started with no memory of the first. /// /// ## Two tallies, because the rules define one game and not a series /// /// `OutcomeView` offers `personal` (per seat), `group_success` (per /// table) and `winners`. Summing `personal` and counting `winners` answer /// **different questions**, and GROUND's dataset says nothing about how /// several games combine — there is no series in the rules. /// /// So both are kept and both are labelled, rather than one being picked /// and quietly presented as *the* score. Registered as a note (kind /// `underdetermined`) in `specs/FindingRegister.md`: it may not be /// reported to ground-game until something demonstrates a problem, which /// is GameDesign §3.1's rule and the reason this is not being sent as a /// finding. #[derive(Debug, Clone, Default)] pub struct MatchTally { pub games: u32, /// Sum of each seat's per-game `personal` score. pub personal: std::collections::BTreeMap, /// Games the table cleared its threshold. pub group_successes: u32, /// How often each seat appeared in `winners`. pub wins: std::collections::BTreeMap, } impl MatchTally { /// Test-visible alias for [`Self::record`]. The real call site is the /// driver; this exists so the series tests drive the same code rather /// than a reimplementation of it beside it. #[cfg(test)] pub fn record_for_test(&mut self, o: &games_ground::view::OutcomeView) { self.record(o); } fn record(&mut self, o: &games_ground::view::OutcomeView) { self.games += 1; for (seat, score) in &o.personal { *self.personal.entry(*seat).or_insert(0) += *score; } if o.group_success { self.group_successes += 1; } for w in &o.winners { *self.wins.entry(*w).or_insert(0) += 1; } } } /// 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 // // The table itself is rendered by `inspect`. What stays here is the // vocabulary a *chooser* needs: how a legal command is described in the // menu. Rendering the state and naming a move are different jobs, and // T02 needs the first without the second. 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 } // --------------------------------------------------------------- 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(); } } } } } /// How long the terminal page stays available for an abandoned tab. /// /// A server that never exits is its own defect; a short timeout races a /// player reading the result. So it serves until the page posts `done`, /// with this only as the bound. const END_LINGER: std::time::Duration = std::time::Duration::from_secs(600); /// Show the browser that the game ended badly, then return the error. /// /// CB-WP-0018 T01: this path used to `return Err(...)` straight to a /// terminal nobody was reading, and the browser got a refused connection — /// identical to a normal win. An error the only interface cannot see is /// not reported. fn end_badly( server: &Option>, msg: String, ) -> Result { if let Some(s) = server { // A game that ended badly has no outcome, so it contributes // nothing to the series and the panel stays absent. Passing an // empty tally rather than the live one is deliberate: a crashed // game must not be counted as a played one. let _ = s.serve_end(None, &msg, END_LINGER, &MatchTally::default()); } Err(msg) } 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); // CB-WP-0020 T05: a browser session can deal a second game. The seed // advances, because replaying the identical deal is not "again". // CB-WP-0020 T05: ONE listener for the session, not one per game. // The first version bound a fresh port inside `run_game`, so "play // again" moved the game to a new URL and the player's tab was left // pointing at a dead port. Caught by `play_again_deals_a_second_game`. let server = match config.serve { Some(port) => { let s = std::rc::Rc::new(crate::hotseat::Server::bind(port)?); let _ = writeln!(out.borrow_mut(), " open {}", s.url()); let _ = out.borrow_mut().flush(); Some(s) } None => None, }; let mut cfg: Config = config.clone(); // CB-WP-0024 T04: the tally outlives the game, which is the whole // point — it is declared here, beside the listener and the seed, // because those are the other two things that survive `play again`. let mut tally = MatchTally::default(); loop { let (summary, choice) = run_game(&cfg, &input, &out, server.clone(), &mut tally)?; if choice != crate::hotseat::EndChoice::Again { return Ok(summary); } cfg.seed = cfg.seed.wrapping_add(1); } } fn run_game<'a, R: BufRead + 'a, W: Write + 'a>( config: &Config, input: &'a std::cell::RefCell, out: &'a std::cell::RefCell, server: Option>, tally: &mut MatchTally, ) -> Result<(Summary, crate::hotseat::EndChoice), String> { 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(); // ADR-0007: a browser seat and a CLI seat are both just a Policy, so // the driver cannot tell them apart — which is the property that lets // a browser game replay as a scenario like any other. let mut policies: Vec> = Vec::new(); for seat in 0..config.players { if config.human_seats.contains(&seat) { match &server { Some(s) => policies.push(Box::new(crate::hotseat::SeatPolicy::new( s.clone(), failure.clone(), ))), None => policies.push(Box::new(HumanPolicy::new(input, out, failure.clone()))), } } else { policies.push(bot_policy(&config.bot, config.seed + u64::from(seat))?); } } // CB-WP-0018 T02: the browser seat's page renders the journal as it // fills, so a player sees what each command produced -- including the // commands that produced nothing. let result = match &server { Some(srv) => games_ground::bot::play_journaled(initial, &mut policies, Some(srv.journal())), None => 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 end_badly(&server, msg), (Err(BotError::IllegalChoice { seat, offered, .. }), None) => { return end_badly( &server, format!( "{} chose a command outside the {offered} offered", seat_name(seat) ), ) } (Err(e), None) => return end_badly(&server, 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] ); // ADR-0007 control 1 leaves evidence rather than only a 403: a // session that was probed says so, so a player finds out from the // transcript rather than from nothing at all. if let Some(s) = &server { let refusals = s.refusals(); if refusals.is_empty() { let _ = writeln!(w, " no requests were refused this session"); } else { let _ = writeln!(w, " {} request(s) refused:", refusals.len()); for r in &refusals { let _ = writeln!(w, " {r}"); } } } let _ = w.flush(); drop(w); // CB-WP-0018 T01: the browser sees the end of its own game. Measured // before this: a game ended at 5 rounds / 30 commands, the result went // to stdout, and the page's post-`ok` reload got Connection refused. let mut end_choice = crate::hotseat::EndChoice::Closed; if let Some(srv) = &server { let ended = game.state.project(Viewer::Spectator); // Recorded BEFORE the page is served, so the ending page shows a // tally that includes the game being looked at. A tally that // lagged by one game would be worse than none. if let Some(o) = &ended.outcome { tally.record(o); } let msg = format!("{} commands, hash {}", game.commands, &end_hash[..12]); end_choice = srv.serve_end(Some(&ended), &msg, END_LINGER, tally)?; } 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, }, end_choice, )) }