Skip to main content

turnbase_cli/
lib.rs

1//! A generic command-line runner: give it any [`turnbase::Game`] and get a
2//! headless CLI, bot self-play, and interactive play, with no per-game
3//! plumbing.
4//!
5//! Two tiers:
6//!
7//! - [`run`] needs only a `Game` whose `State`/`Action`/`View` are
8//!   serde-serializable. It provides `new`/`query`/`act` (headless, file-backed
9//!   via [`turnbase_session::FileSession`]), `self-play` (bots), and a text
10//!   `play`. Actions on the command line are JSON, so it works for any game.
11//! - [`run_tui`] (the `tui` feature, on by default) additionally requires
12//!   [`turnbase_simulator::PrintableGame`] and swaps the text `play` for the
13//!   retroglyph dashboard. Everything else is identical.
14//!
15//! A game's `main` is then one line, e.g. `turnbase_cli::run(TicTacToe)` or
16//! `turnbase_cli::run_tui(Coup::new(4))`.
17
18use std::collections::HashMap;
19use std::fmt::{Debug, Display};
20use std::io::Write;
21use std::path::{Path, PathBuf};
22use std::process::ExitCode;
23
24use clap::{Args, Parser, Subcommand};
25use serde::Serialize;
26use serde::de::DeserializeOwned;
27use turnbase::{Game, PlayerId};
28use turnbase_bots::Random;
29use turnbase_match::{PlayerAgent, Simulator};
30use turnbase_protocol::{Request, Response};
31use turnbase_session::FileSession;
32
33#[cfg(feature = "tui")]
34use turnbase_simulator::{PrintableGame, SessionApp, standard_bots};
35
36/// A guard against a game whose random-play match never terminates (uniform
37/// random Risk, say), so `self-play` and `play` always return instead of
38/// looping forever. Every reference game that does converge finishes far
39/// inside this.
40const STEP_LIMIT: usize = 10_000;
41
42#[derive(Parser)]
43#[command(name = "turnbase", version, about)]
44struct Cli {
45    #[command(subcommand)]
46    command: Command,
47}
48
49#[derive(Subcommand)]
50enum Command {
51    /// Create a new session save file and print its path plus the opening view.
52    New {
53        /// Seed for the match. Chosen at random if omitted.
54        #[arg(long)]
55        seed: Option<u64>,
56        /// Where to save. Generated under the temp directory if omitted.
57        #[arg(long)]
58        session: Option<PathBuf>,
59    },
60    /// Print a seat's current view. No side effects.
61    Query {
62        /// Path to an existing session.
63        #[arg(long)]
64        session: PathBuf,
65        /// The seat asking.
66        #[arg(long)]
67        player: u32,
68    },
69    /// Submit one seat's action (encoded as JSON), then print the result.
70    Act {
71        /// Path to an existing session.
72        #[arg(long)]
73        session: PathBuf,
74        /// The seat acting.
75        #[arg(long)]
76        player: u32,
77        /// The action as JSON, e.g. `4` for tic-tac-toe or `{"Coup":2}` for Coup.
78        #[arg(long)]
79        action: String,
80    },
81    /// Play a full match with every seat driven by a bot; print the outcome.
82    SelfPlay {
83        /// Seed for the match. Chosen at random if omitted.
84        #[arg(long)]
85        seed: Option<u64>,
86    },
87    /// Play interactively: you drive some seats, bots play the rest.
88    Play(PlayArgs),
89}
90
91/// Arguments for the interactive `play` command, exposed so a game that passes
92/// its own `play` handler to [`run_with_play`] can read them.
93#[derive(Args)]
94pub struct PlayArgs {
95    /// Seed for the match. Chosen at random if omitted.
96    #[arg(long)]
97    seed: Option<u64>,
98    /// Seats you control, comma-separated (e.g. `0` or `0,1`). Defaults to seat 0.
99    #[arg(long)]
100    manual: Option<String>,
101}
102
103impl PlayArgs {
104    /// The seed requested on the command line, if any (otherwise the caller
105    /// picks one, e.g. at random).
106    #[must_use]
107    pub const fn seed(&self) -> Option<u64> {
108        self.seed
109    }
110
111    /// The seats the human asked to control, defaulting to seat 0.
112    #[must_use]
113    pub fn manual_seats(&self) -> Vec<u32> {
114        parse_seats(self.manual.as_deref())
115    }
116}
117
118/// Runs the text/headless CLI for `game`: `new`, `query`, `act`, `self-play`,
119/// and a text `play`. Works for any game whose types are serde-serializable.
120#[must_use]
121pub fn run<G>(game: G) -> ExitCode
122where
123    G: Game + Serialize + DeserializeOwned,
124    G::State: Serialize + DeserializeOwned,
125    G::Action: DeserializeOwned + Debug,
126    G::View: Serialize,
127{
128    run_with_play(game, text_play)
129}
130
131/// Like [`run`], but the game supplies its own interactive `play` handler (a
132/// bespoke UI, say) rather than the built-in text stepper.
133///
134/// The headless `new`/`query`/`act` and `self-play` commands are handled here
135/// exactly as [`run`] does; only the interactive `play` command is delegated
136/// to `play`. This is how a game ships its own terminal UI (see
137/// `examples/blackjack`) without reimplementing the rest of the CLI.
138#[must_use]
139pub fn run_with_play<G, F>(game: G, play: F) -> ExitCode
140where
141    G: Game + Serialize + DeserializeOwned,
142    G::State: Serialize + DeserializeOwned,
143    G::Action: DeserializeOwned + Debug,
144    G::View: Serialize,
145    F: FnOnce(G, &PlayArgs) -> ExitCode,
146{
147    match Cli::parse().command {
148        Command::New { seed, session } => handle_new(game, seed, session),
149        Command::Query { session, player } => handle_query::<G>(&session, player),
150        Command::Act {
151            session,
152            player,
153            action,
154        } => handle_act::<G>(&session, player, &action),
155        Command::SelfPlay { seed } => handle_self_play(game, seed),
156        Command::Play(args) => play(game, &args),
157    }
158}
159
160/// Like [`run`], but `play` opens the retroglyph dashboard instead of the text
161/// stepper. Requires the game to implement [`PrintableGame`].
162#[cfg(feature = "tui")]
163#[must_use]
164pub fn run_tui<G>(game: G) -> ExitCode
165where
166    G: PrintableGame + Clone + Serialize + DeserializeOwned,
167    G::State: Clone + Serialize + DeserializeOwned,
168    G::Action: Clone + DeserializeOwned + Debug,
169    G::View: Serialize,
170{
171    run_with_play(game, tui_play)
172}
173
174fn handle_new<G>(game: G, seed: Option<u64>, session: Option<PathBuf>) -> ExitCode
175where
176    G: Game + Serialize + DeserializeOwned,
177    G::State: Serialize + DeserializeOwned,
178    G::View: Serialize,
179{
180    let seed = seed.unwrap_or_else(random_seed);
181    let state = game.new_initial_state(seed);
182    let path = match FileSession::create(game, session, state, seed) {
183        Ok(path) => path,
184        Err(err) => return fail(err),
185    };
186    // Echo the opening position from seat 0's view so `new` is self-explaining.
187    match FileSession::handle::<G>(&path, PlayerId::new(0), Request::Query) {
188        Ok(response) => {
189            print_response(&path, &response);
190            ExitCode::SUCCESS
191        }
192        Err(err) => fail(err),
193    }
194}
195
196fn handle_query<G>(session: &Path, player: u32) -> ExitCode
197where
198    G: Game + Serialize + DeserializeOwned,
199    G::State: Serialize + DeserializeOwned,
200    G::View: Serialize,
201{
202    match FileSession::handle::<G>(session, PlayerId::new(player), Request::Query) {
203        Ok(response) => {
204            print_response(session, &response);
205            ExitCode::SUCCESS
206        }
207        Err(err) => fail(err),
208    }
209}
210
211fn handle_act<G>(session: &Path, player: u32, action_json: &str) -> ExitCode
212where
213    G: Game + Serialize + DeserializeOwned,
214    G::State: Serialize + DeserializeOwned,
215    G::Action: DeserializeOwned,
216    G::View: Serialize,
217{
218    let action: G::Action = match serde_json::from_str(action_json) {
219        Ok(action) => action,
220        Err(err) => return fail(format!("could not parse --action as JSON: {err}")),
221    };
222    let player = PlayerId::new(player);
223    match FileSession::handle::<G>(session, player, Request::Act(action)) {
224        // An accepted action returns no state; follow up with a query so the
225        // caller sees the result of their move in one command.
226        Ok(Response::Ack) => handle_query::<G>(session, player.index()),
227        Ok(response @ Response::State { .. }) => {
228            print_response(session, &response);
229            ExitCode::SUCCESS
230        }
231        Ok(Response::Error(message)) => fail(format!("rejected: {message}")),
232        Err(err) => fail(err),
233    }
234}
235
236fn handle_self_play<G>(game: G, seed: Option<u64>) -> ExitCode
237where
238    G: Game,
239    G::Action: Debug,
240{
241    let seed = seed.unwrap_or_else(random_seed);
242    let agents = build_agents(&game, seed, &[]);
243    let mut sim = Simulator::new(game, seed, agents);
244    if let Err(err) = drive_to_end(&mut sim) {
245        return fail(err);
246    }
247    print_outcome(&sim);
248    ExitCode::SUCCESS
249}
250
251fn text_play<G>(game: G, args: &PlayArgs) -> ExitCode
252where
253    G: Game,
254    G::Action: Debug,
255    G::View: Serialize,
256{
257    let seed = args.seed.unwrap_or_else(random_seed);
258    let manual = parse_seats(args.manual.as_deref());
259    let agents = build_agents(&game, seed, &manual);
260    let mut sim = Simulator::new(game, seed, agents);
261
262    // Render from a single controlled seat's view (so hidden info stays hidden);
263    // with zero or several controlled seats, render the public spectator view.
264    // Multi-seat pass-and-play therefore does not reveal each acting seat's own
265    // private data at its prompt; a hidden-info game wanting that needs the
266    // dashboard (`run_tui`), which fixes one viewing seat per match.
267    let viewer = match manual.as_slice() {
268        [only] => Some(PlayerId::new(*only)),
269        _ => None,
270    };
271
272    println!("# seed {seed}, you control {manual:?}");
273    render_view(&sim, viewer);
274    let mut steps = 0;
275    while !sim.is_terminal() && steps < STEP_LIMIT {
276        steps += 1;
277        match sim.awaiting_human() {
278            Some(player) => {
279                let mut actions = sim.game().legal_actions(sim.state(), player);
280                if actions.is_empty() {
281                    break;
282                }
283                let choice = prompt_choice(player, &actions);
284                let action = actions.swap_remove(choice);
285                if let Err(err) = sim.select_human_action(player, action) {
286                    return fail(err);
287                }
288            }
289            None => match sim.step() {
290                Ok(true) => {}
291                Ok(false) => break,
292                Err(err) => return fail(err),
293            },
294        }
295        render_view(&sim, viewer);
296    }
297    print_outcome(&sim);
298    ExitCode::SUCCESS
299}
300
301#[cfg(feature = "tui")]
302fn tui_play<G>(game: G, args: &PlayArgs) -> ExitCode
303where
304    G: PrintableGame + Clone,
305    G::State: Clone,
306    G::Action: Clone + Debug,
307{
308    let seed = args.seed.unwrap_or_else(random_seed);
309    // Open the setup modal so the player can pick Human/AI per seat, pre-seeding
310    // the seats they named with --manual as human.
311    let mut app = SessionApp::new(game, standard_bots(), seed).with_setup_open(true);
312    for seat in parse_seats(args.manual.as_deref()) {
313        if let Ok(index) = usize::try_from(seat) {
314            app = app.with_human_seat(index);
315        }
316    }
317    match turnbase_simulator::run_session(app) {
318        Ok(()) => ExitCode::SUCCESS,
319        Err(err) => fail(err),
320    }
321}
322
323/// Builds one agent per seat: [`PlayerAgent::Human`] for a seat in `manual`,
324/// otherwise a per-seat-seeded [`Random`]. `manual` empty means all bots.
325fn build_agents<G: Game>(game: &G, seed: u64, manual: &[u32]) -> HashMap<PlayerId, PlayerAgent<G>> {
326    let mut agents = HashMap::new();
327    for seat in 0..game.num_players() {
328        let id = PlayerId::new(u32::try_from(seat).expect("seat index fits in u32"));
329        let agent = if manual.contains(&id.index()) {
330            PlayerAgent::Human
331        } else {
332            PlayerAgent::Ai(Box::new(Random::new(seat_seed(seed, id.index()))))
333        };
334        agents.insert(id, agent);
335    }
336    agents
337}
338
339/// Steps an all-bot (plus chance) match to its end, or the step guard.
340fn drive_to_end<G>(sim: &mut Simulator<G>) -> Result<(), turnbase::Error>
341where
342    G: Game,
343    G::Action: Debug,
344{
345    let mut steps = 0;
346    while !sim.is_terminal() && steps < STEP_LIMIT {
347        // Ok(false) at a non-terminal state means a seat is waiting on a human,
348        // or a bot declined (no legal action). Neither should happen in an
349        // all-bot match, so log and stop rather than spin.
350        if !sim.step()? {
351            log::debug!("self-play stalled at a non-terminal state; stopping");
352            break;
353        }
354        steps += 1;
355    }
356    Ok(())
357}
358
359/// Prints the JSON view for `viewer` on one line.
360fn render_view<G>(sim: &Simulator<G>, viewer: Option<PlayerId>)
361where
362    G: Game,
363    G::View: Serialize,
364{
365    let view = sim.game().view(sim.state(), viewer);
366    match serde_json::to_string(&view) {
367        Ok(json) => println!("  {json}"),
368        Err(err) => eprintln!("could not encode view: {err}"),
369    }
370}
371
372/// Prints each seat's terminal reward and the move count, or an honest note if
373/// the match hit the step guard without finishing (rewards are only meaningful
374/// at a terminal state).
375fn print_outcome<G: Game>(sim: &Simulator<G>) {
376    if !sim.is_terminal() {
377        println!(
378            "=== unfinished after {} move(s) (hit the step limit) ===",
379            sim.log_history().len()
380        );
381        return;
382    }
383    let game = sim.game();
384    let rewards: Vec<String> = (0..game.num_players())
385        .map(|seat| {
386            let id = PlayerId::new(u32::try_from(seat).expect("seat index fits in u32"));
387            format!("P{seat}={:+}", game.reward(sim.state(), id))
388        })
389        .collect();
390    println!(
391        "=== terminal after {} move(s) === {}",
392        sim.log_history().len(),
393        rewards.join("  ")
394    );
395}
396
397/// Prompts a human to pick one of `actions` by index, returning that index.
398/// A forced single choice is taken automatically.
399fn prompt_choice<A: Debug>(player: PlayerId, actions: &[A]) -> usize {
400    if actions.len() == 1 {
401        return 0;
402    }
403    loop {
404        print!("P{} choose ", player.index());
405        for (index, action) in actions.iter().enumerate() {
406            print!("[{index}] {action:?}  ");
407        }
408        print!("> ");
409        let _ = std::io::stdout().flush();
410
411        let mut line = String::new();
412        let Ok(bytes) = std::io::stdin().read_line(&mut line) else {
413            continue;
414        };
415        // A zero-byte read is end-of-input (piped or closed stdin); take the
416        // first choice rather than looping forever on an empty line.
417        if bytes == 0 {
418            return 0;
419        }
420        match line.trim().parse::<usize>() {
421            Ok(index) if index < actions.len() => return index,
422            _ => println!("  not a valid choice"),
423        }
424    }
425}
426
427/// Prints a `{ session, response }` JSON envelope.
428fn print_response<V: Serialize>(session: &Path, response: &Response<V>) {
429    #[derive(Serialize)]
430    struct Envelope<'a, V> {
431        session: &'a Path,
432        response: &'a Response<V>,
433    }
434    let envelope = Envelope { session, response };
435    match serde_json::to_string_pretty(&envelope) {
436        Ok(json) => println!("{json}"),
437        Err(err) => eprintln!("could not encode output: {err}"),
438    }
439}
440
441/// Parses `--manual` ("0" or "0,1"), defaulting to seat 0 when absent or empty.
442fn parse_seats(manual: Option<&str>) -> Vec<u32> {
443    let Some(raw) = manual else {
444        return vec![0];
445    };
446    let seats: Vec<u32> = raw
447        .split(',')
448        .filter_map(|token| token.trim().parse().ok())
449        .collect();
450    if seats.is_empty() { vec![0] } else { seats }
451}
452
453/// Mixes a per-seat bot seed off the match seed, so two bot seats do not share
454/// a random stream.
455fn seat_seed(seed: u64, seat: u32) -> u64 {
456    seed ^ u64::from(seat)
457        .wrapping_add(1)
458        .wrapping_mul(0x9E37_79B9_7F4A_7C15)
459}
460
461/// A process-random seed, so an unseeded match differs from run to run.
462fn random_seed() -> u64 {
463    use std::hash::{BuildHasher, Hasher};
464    std::collections::hash_map::RandomState::new()
465        .build_hasher()
466        .finish()
467}
468
469fn fail(err: impl Display) -> ExitCode {
470    eprintln!("error: {err}");
471    ExitCode::FAILURE
472}
473
474#[cfg(test)]
475mod tests {
476    use super::{parse_seats, seat_seed};
477
478    #[test]
479    fn parse_seats_defaults_to_seat_zero() {
480        assert_eq!(parse_seats(None), vec![0]);
481        assert_eq!(parse_seats(Some("")), vec![0]);
482        assert_eq!(parse_seats(Some("nonsense")), vec![0]);
483    }
484
485    #[test]
486    fn parse_seats_reads_a_comma_list() {
487        assert_eq!(parse_seats(Some("0")), vec![0]);
488        assert_eq!(parse_seats(Some("0,2")), vec![0, 2]);
489        assert_eq!(parse_seats(Some(" 1 , 3 ")), vec![1, 3]);
490    }
491
492    #[test]
493    fn seat_seed_is_stable_and_distinct_per_seat() {
494        assert_eq!(seat_seed(42, 0), seat_seed(42, 0), "same inputs, same seed");
495        assert_ne!(
496            seat_seed(42, 0),
497            seat_seed(42, 1),
498            "different seats get different streams"
499        );
500    }
501}