use std::{collections::HashMap, path::PathBuf};
use async_trait::async_trait;
use tracing::{debug, instrument};
use crate::arena::action::Action;
use super::Historian;
#[derive(Debug, Clone)]
pub struct DirectoryHistorian {
base_path: PathBuf,
sequence: HashMap<u128, Vec<Action>>,
}
impl DirectoryHistorian {
pub fn new(base_path: PathBuf) -> Self {
debug!(?base_path, "Creating DirectoryHistorian");
DirectoryHistorian {
base_path,
sequence: HashMap::new(),
}
}
}
#[async_trait]
impl Historian for DirectoryHistorian {
#[instrument(level = "trace", skip(self, _game_state), fields(base_path = ?self.base_path))]
async fn record_action(
&mut self,
id: u128,
_game_state: &crate::arena::GameState,
action: &crate::arena::action::Action,
) -> Result<(), super::HistorianError> {
if !tokio::fs::try_exists(&self.base_path).await? {
debug!(?self.base_path, "Creating directory for game history");
tokio::fs::create_dir_all(&self.base_path).await?;
}
let game_path = self.base_path.join(id.to_string()).with_extension("json");
let sequence = self.sequence.entry(id).or_default();
sequence.push(action.clone());
debug!(
?game_path,
action_count = sequence.len(),
"Writing game history"
);
let bytes = serde_json::to_vec_pretty(sequence)?;
tokio::fs::write(&game_path, &bytes).await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::arena::GameStateBuilder;
use crate::arena::action::{ForcedBetPayload, ForcedBetType, GameStartPayload};
use tempfile::TempDir;
#[tokio::test(flavor = "current_thread")]
async fn test_creates_directory_when_missing() {
let temp_dir = TempDir::new().unwrap();
let non_existent_path = temp_dir.path().join("subdir").join("game_history");
assert!(
!non_existent_path.exists(),
"Directory should not exist initially"
);
let mut historian = DirectoryHistorian::new(non_existent_path.clone());
let game_state = GameStateBuilder::new()
.num_players_with_stack(2, 100.0)
.blinds(10.0, 5.0)
.build()
.unwrap();
let action = Action::GameStart(GameStartPayload {
ante: 0.0,
small_blind: 5.0,
big_blind: 10.0,
});
historian
.record_action(12345, &game_state, &action)
.await
.unwrap();
assert!(
non_existent_path.exists(),
"Directory should be created by record_action"
);
let expected_file = non_existent_path.join("12345.json");
assert!(
expected_file.exists(),
"Game history file should be created"
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_records_action_to_file() {
let temp_dir = TempDir::new().unwrap();
let history_path = temp_dir.path().join("history");
let mut historian = DirectoryHistorian::new(history_path.clone());
let game_state = GameStateBuilder::new()
.num_players_with_stack(2, 100.0)
.blinds(10.0, 5.0)
.build()
.unwrap();
let action = Action::ForcedBet(ForcedBetPayload {
bet: 5.0,
player_stack: 100.0,
idx: 0,
forced_bet_type: ForcedBetType::SmallBlind,
});
historian
.record_action(42, &game_state, &action)
.await
.unwrap();
let file_path = history_path.join("42.json");
let content = std::fs::read_to_string(&file_path).unwrap();
assert!(
content.contains("ForcedBet"),
"File should contain the action type"
);
assert!(
content.contains("SmallBlind"),
"File should contain forced bet type"
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_records_multiple_actions() {
let temp_dir = TempDir::new().unwrap();
let history_path = temp_dir.path().join("history");
let mut historian = DirectoryHistorian::new(history_path.clone());
let game_state = GameStateBuilder::new()
.num_players_with_stack(2, 100.0)
.blinds(10.0, 5.0)
.build()
.unwrap();
let action1 = Action::ForcedBet(ForcedBetPayload {
bet: 5.0,
player_stack: 100.0,
idx: 0,
forced_bet_type: ForcedBetType::SmallBlind,
});
let action2 = Action::ForcedBet(ForcedBetPayload {
bet: 10.0,
player_stack: 100.0,
idx: 1,
forced_bet_type: ForcedBetType::BigBlind,
});
historian
.record_action(100, &game_state, &action1)
.await
.unwrap();
historian
.record_action(100, &game_state, &action2)
.await
.unwrap();
let file_path = history_path.join("100.json");
let content = std::fs::read_to_string(&file_path).unwrap();
let actions: Vec<Action> = serde_json::from_str(&content).unwrap();
assert_eq!(actions.len(), 2, "Should have 2 recorded actions");
}
}