1use 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
36const 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 New {
53 #[arg(long)]
55 seed: Option<u64>,
56 #[arg(long)]
58 session: Option<PathBuf>,
59 },
60 Query {
62 #[arg(long)]
64 session: PathBuf,
65 #[arg(long)]
67 player: u32,
68 },
69 Act {
71 #[arg(long)]
73 session: PathBuf,
74 #[arg(long)]
76 player: u32,
77 #[arg(long)]
79 action: String,
80 },
81 SelfPlay {
83 #[arg(long)]
85 seed: Option<u64>,
86 },
87 Play(PlayArgs),
89}
90
91#[derive(Args)]
94pub struct PlayArgs {
95 #[arg(long)]
97 seed: Option<u64>,
98 #[arg(long)]
100 manual: Option<String>,
101}
102
103impl PlayArgs {
104 #[must_use]
107 pub const fn seed(&self) -> Option<u64> {
108 self.seed
109 }
110
111 #[must_use]
113 pub fn manual_seats(&self) -> Vec<u32> {
114 parse_seats(self.manual.as_deref())
115 }
116}
117
118#[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#[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#[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 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 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 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 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
323fn 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
339fn 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 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
359fn 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
372fn 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
397fn 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 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
427fn 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
441fn 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
453fn 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
461fn 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}