diff --git a/.gitignore b/.gitignore index 7d0d0d0..f4812e7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ target *.sw* + +# Developers may wish to generate JSON replays of bot games and store them in /replays/ +/replays/ diff --git a/Cargo.lock b/Cargo.lock index b972c22..fb1192b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,6 +41,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "itoa" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fad582f4b9e86b6caa621cabeb0963332d92eea04729ab12892c2533951e6440" + [[package]] name = "libc" version = "0.2.125" @@ -122,6 +128,30 @@ dependencies = [ "getopts", "log 0.3.9", "rand 0.3.23", + "serde_json", +] + +[[package]] +name = "ryu" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4b9743ed687d4b4bcedf9ff5eaa7398495ae14e61cba0a295704edbc7decde" + +[[package]] +name = "serde" +version = "1.0.152" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb7d1f0d3021d347a83e556fc4683dea2ea09d87bccdf88ff5c12545d89d5efb" + +[[package]] +name = "serde_json" +version = "1.0.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c235533714907a8c2464236f5c4b2a17262ef1bd71f38f35ea592c8da6883" +dependencies = [ + "itoa", + "ryu", + "serde", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 3cf3cfb..dc3c7eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,3 +11,4 @@ getopts = "0.2.14" fnv = "1.0.0" float-ord = "0.2.0" crossbeam = "0.2.5" +serde_json = "*" diff --git a/src/game.rs b/src/game.rs index 4e92989..fa1d56e 100644 --- a/src/game.rs +++ b/src/game.rs @@ -368,7 +368,9 @@ impl BoardState { } pub fn is_over(&self) -> bool { - (self.lives_remaining == 0) || (self.deckless_turns_remaining == 0) + (self.lives_remaining == 0) + || (self.deckless_turns_remaining == 0) + || (self.score() == PERFECT_SCORE) } } impl fmt::Display for BoardState { @@ -522,12 +524,28 @@ impl GameView for OwnedGameView { } } +// Internally, every card is annotated with its index in the deck in order to +// generate easy-to-interpret JSON output. These annotations are stripped off +// when passing GameViews to strategies. +// +// TODO: Maybe we should give strategies access to the annotations as well? +// This could simplify code like in InformationPlayerStrategy::update_public_info_for_discard_or_play. +// Also, this would let a strategy publish "notes" on cards more easily. +pub type AnnotatedCard = (usize, Card); +pub type AnnotatedCards = Vec; + +fn strip_annotations(cards: &AnnotatedCards) -> Cards { + cards.iter().map(|(_i, card)| card.clone()).collect() +} + // complete game state (known to nobody!) #[derive(Debug)] pub struct GameState { - pub hands: FnvHashMap, + pub hands: FnvHashMap, + // used to construct BorrowedGameViews + pub unannotated_hands: FnvHashMap, pub board: BoardState, - pub deck: Cards, + pub deck: AnnotatedCards, } impl fmt::Display for GameState { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { @@ -538,7 +556,7 @@ impl fmt::Display for GameState { for player in self.board.get_players() { let hand = &self.hands.get(&player).unwrap(); write!(f, "player {player}:")?; - for card in hand.iter() { + for (_i, card) in hand.iter() { write!(f, " {card}")?; } f.write_str("\n")?; @@ -552,7 +570,9 @@ impl fmt::Display for GameState { } impl GameState { - pub fn new(opts: &GameOptions, mut deck: Cards) -> GameState { + pub fn new(opts: &GameOptions, deck: Cards) -> GameState { + // We enumerate the cards in reverse order since they'll be drawn from the back of the deck. + let mut deck: AnnotatedCards = deck.into_iter().rev().enumerate().rev().collect(); let mut board = BoardState::new(opts, deck.len() as u32); let hands = (0..opts.num_players) @@ -567,8 +587,17 @@ impl GameState { (player, hand) }) .collect::>(); + let unannotated_hands = hands + .iter() + .map(|(player, hand)| (*player, strip_annotations(hand))) + .collect::>(); - GameState { hands, board, deck } + GameState { + hands, + unannotated_hands, + board, + deck, + } } pub fn get_players(&self) -> Range { @@ -586,7 +615,7 @@ impl GameState { // get the game state view of a particular player pub fn get_view(&self, player: Player) -> BorrowedGameView { let mut other_hands = FnvHashMap::default(); - for (&other_player, hand) in &self.hands { + for (&other_player, hand) in &self.unannotated_hands { if player != other_player { other_hands.insert(other_player, hand); } @@ -599,10 +628,18 @@ impl GameState { } } + fn update_player_hand(&mut self) { + let player = self.board.player; + self.unannotated_hands + .insert(player, strip_annotations(self.hands.get(&player).unwrap())); + } + // takes a card from the player's hand, and replaces it if possible fn take_from_hand(&mut self, index: usize) -> Card { let hand = &mut self.hands.get_mut(&self.board.player).unwrap(); - hand.remove(index) + let card = hand.remove(index).1; + self.update_player_hand(); + card } fn replenish_hand(&mut self) { @@ -610,10 +647,11 @@ impl GameState { if (hand.len() as u32) < self.board.hand_size { if let Some(new_card) = self.deck.pop() { self.board.deck_size -= 1; - debug!("Drew new card, {}", new_card); + debug!("Drew new card, {}", new_card.1); hand.push(new_card); } } + self.update_player_hand(); } pub fn process_choice(&mut self, choice: TurnChoice) -> TurnRecord { @@ -637,11 +675,11 @@ impl GameState { let results = match hint.hinted { Hinted::Color(color) => hand .iter() - .map(|card| card.color == color) + .map(|(_i, card)| card.color == color) .collect::>(), Hinted::Value(value) => hand .iter() - .map(|card| card.value == value) + .map(|(_i, card)| card.value == value) .collect::>(), }; if !self.board.allow_empty_hints { diff --git a/src/json_output.rs b/src/json_output.rs new file mode 100644 index 0000000..4beb72f --- /dev/null +++ b/src/json_output.rs @@ -0,0 +1,67 @@ +use crate::game::*; +use serde_json::*; + +fn color_value(color: &Color) -> usize { + COLORS + .iter() + .position(|&card_color| &card_color == color) + .unwrap() +} + +fn card_to_json(card: &Card) -> serde_json::Value { + json!({ + "rank": card.value, + "suitIndex": color_value(&card.color), + }) +} + +pub fn action_clue(hint: &Hint) -> serde_json::Value { + match hint.hinted { + Hinted::Color(color) => { + json!({ + "type": 2, + "target": hint.player, + "value": color_value(&color), + }) + } + Hinted::Value(value) => { + json!({ + "type": 3, + "target": hint.player, + "value": value, + }) + } + } +} + +pub fn action_play((i, _card): &AnnotatedCard) -> serde_json::Value { + json!({ + "type": 0, + "target": i, + }) +} + +pub fn action_discard((i, _card): &AnnotatedCard) -> serde_json::Value { + json!({ + "type": 1, + "target": i, + }) +} + +pub fn json_format( + deck: &Cards, + actions: &Vec, + players: &Vec, +) -> serde_json::Value { + json!({ + "options": { + "variant": "No Variant", + }, + "players": players, + "first_player": 0, + "notes": players.iter().map(|_player| {json!([])}).collect::>(), // TODO add notes + // The deck is reversed since in our implementation we draw from the end of the deck. + "deck": deck.iter().rev().map(card_to_json).collect::>(), + "actions": actions, + }) +} diff --git a/src/main.rs b/src/main.rs index 3e8d538..8966662 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,9 +5,11 @@ extern crate crossbeam; extern crate float_ord; extern crate fnv; extern crate rand; +extern crate serde_json; mod game; mod helpers; +mod json_output; mod simulator; mod strategy; mod strategies { @@ -60,6 +62,12 @@ fn main() { "Number of games after which to print an update", "OUTPUT_FREQ", ); + opts.optopt( + "j", + "json-output", + "Pattern for the JSON output file. '%s' will be replaced by the seed.", + "FILE_PATTERN", + ); opts.optopt( "t", "nthreads", @@ -85,6 +93,11 @@ fn main() { "write-results-table", "Update the results table in README.md", ); + opts.optflag( + "", + "losses-only", + "When saving JSON outputs, save lost games only", + ); let matches = match opts.parse(&args[1..]) { Ok(m) => m, Err(f) => { @@ -136,6 +149,8 @@ fn main() { let n_players = u32::from_str(matches.opt_str("p").as_deref().unwrap_or("4")).unwrap(); let g_opt = matches.opt_str("g"); let strategy_str: &str = g_opt.as_deref().unwrap_or("cheat"); + let json_output_pattern = matches.opt_str("j"); + let json_losses_only = matches.opt_present("losses-only"); sim_games( n_players, @@ -144,6 +159,8 @@ fn main() { n_trials, n_threads, progress_info, + json_output_pattern, + json_losses_only, ) .info(); } @@ -155,6 +172,8 @@ fn sim_games( n_trials: u32, n_threads: u32, progress_info: Option, + json_output_pattern: Option, + json_losses_only: bool, ) -> simulator::SimResult { let hand_size = match n_players { 2 => 5, @@ -195,6 +214,8 @@ fn sim_games( n_trials, n_threads, progress_info, + json_output_pattern, + json_losses_only, ) } @@ -250,8 +271,16 @@ fn get_results_table() -> String { &player_nums, (format_name(strategy), space.clone()), &|n_players| { - let simresult = - sim_games(n_players, strategy, Some(seed), n_trials, n_threads, None); + let simresult = sim_games( + n_players, + strategy, + Some(seed), + n_trials, + n_threads, + None, + None, + false, + ); ( format_score(simresult.average_score(), simresult.score_stderr()), format_percent( diff --git a/src/simulator.rs b/src/simulator.rs index 71ffc11..3467ef5 100644 --- a/src/simulator.rs +++ b/src/simulator.rs @@ -3,6 +3,7 @@ use rand::{self, Rng, SeedableRng}; use std::fmt; use crate::game::*; +use crate::json_output::*; use crate::strategy::*; fn new_deck(seed: u32) -> Cards { @@ -25,10 +26,11 @@ pub fn simulate_once( opts: &GameOptions, game_strategy: Box, seed: u32, -) -> GameState { + output_json: bool, +) -> (GameState, Option) { let deck = new_deck(seed); - let mut game = GameState::new(opts, deck); + let mut game = GameState::new(opts, deck.clone()); let mut strategies = game .get_players() @@ -40,6 +42,8 @@ pub fn simulate_once( }) .collect::>>(); + let mut actions = Vec::new(); + while !game.is_over() { let player = game.board.player; @@ -53,6 +57,19 @@ pub fn simulate_once( let strategy = strategies.get_mut(&player).unwrap(); strategy.decide(&game.get_view(player)) }; + if output_json { + actions.push(match choice { + TurnChoice::Hint(ref hint) => action_clue(hint), + TurnChoice::Play(index) => { + let card = &game.hands[&player][index]; + action_play(card) + } + TurnChoice::Discard(index) => { + let card = &game.hands[&player][index]; + action_discard(card) + } + }); + } let turn = game.process_choice(choice); @@ -65,7 +82,16 @@ pub fn simulate_once( debug!("======================================================="); debug!("Final state:\n{}", game); debug!("SCORE: {:?}", game.score()); - game + let json_output = if output_json { + let player_names = game + .get_players() + .map(|player| strategies[&player].name()) + .collect(); + Some(json_format(&deck, &actions, &player_names)) + } else { + None + }; + (game, json_output) } #[derive(Debug)] @@ -135,6 +161,8 @@ pub fn simulate( n_trials: u32, n_threads: u32, progress_info: Option, + json_output_pattern: Option, + json_losses_only: bool, ) -> SimResult where T: GameStrategyConfig + Sync, @@ -142,6 +170,7 @@ where let first_seed = first_seed_opt.unwrap_or_else(|| rand::thread_rng().next_u32()); let strat_config_ref = &strat_config; + let json_output_pattern_ref = &json_output_pattern; crossbeam::scope(|scope| { let mut join_handles = Vec::new(); for i in 0..n_threads { @@ -169,13 +198,27 @@ where ); } } - let game = simulate_once(opts, strat_config_ref.initialize(opts), seed); + let (game, json_output) = simulate_once( + opts, + strat_config_ref.initialize(opts), + seed, + json_output_pattern_ref.is_some(), + ); let score = game.score(); lives_histogram.insert(game.board.lives_remaining); score_histogram.insert(score); if score != PERFECT_SCORE { non_perfect_seeds.push(seed); } + if let Some(file_pattern) = json_output_pattern_ref { + if !(score == PERFECT_SCORE && json_losses_only) { + let file_pattern = + file_pattern.clone().replace("%s", &seed.to_string()); + let path = std::path::Path::new(&file_pattern); + let file = std::fs::File::create(path).unwrap(); + serde_json::to_writer(file, &json_output.unwrap()).unwrap(); + } + } } if progress_info.is_some() { info!("Thread {} done", i); diff --git a/src/strategies/cheating.rs b/src/strategies/cheating.rs index fd9bc94..38c9118 100644 --- a/src/strategies/cheating.rs +++ b/src/strategies/cheating.rs @@ -136,6 +136,9 @@ impl CheatingPlayerStrategy { } } impl PlayerStrategy for CheatingPlayerStrategy { + fn name(&self) -> String { + String::from("cheat") + } fn decide(&mut self, view: &BorrowedGameView) -> TurnChoice { self.inform_last_player_cards(view); diff --git a/src/strategies/examples.rs b/src/strategies/examples.rs index 6f3f3eb..1a65f28 100644 --- a/src/strategies/examples.rs +++ b/src/strategies/examples.rs @@ -39,6 +39,12 @@ pub struct RandomStrategyPlayer { } impl PlayerStrategy for RandomStrategyPlayer { + fn name(&self) -> String { + format!( + "random(hint={}, play={})", + self.hint_probability, self.play_probability + ) + } fn decide(&mut self, view: &BorrowedGameView) -> TurnChoice { let p = rand::random::(); if p < self.play_probability { diff --git a/src/strategies/information.rs b/src/strategies/information.rs index 8a98b33..e8da327 100644 --- a/src/strategies/information.rs +++ b/src/strategies/information.rs @@ -991,6 +991,10 @@ impl InformationPlayerStrategy { } impl PlayerStrategy for InformationPlayerStrategy { + fn name(&self) -> String { + String::from("info") + } + fn decide(&mut self, _: &BorrowedGameView) -> TurnChoice { let mut public_info = self.public_info.clone(); let turn_choice = self.decide_wrapped(&mut public_info); diff --git a/src/strategy.rs b/src/strategy.rs index 5b83f58..2e370f3 100644 --- a/src/strategy.rs +++ b/src/strategy.rs @@ -4,6 +4,11 @@ use crate::game::*; // Represents the strategy of a given player pub trait PlayerStrategy { + // A function returning the name of a strategy. + // This is a method of PlayerStrategy rather than GameStrategyConfig + // so that the name may incorporate useful information that's specific + // to this player instance. + fn name(&self) -> String; // A function to decide what to do on the player's turn. // Given a BorrowedGameView, outputs their choice. fn decide(&mut self, view: &BorrowedGameView) -> TurnChoice;