use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex, RwLock};
use std::time::{SystemTime, UNIX_EPOCH};
use uuid::Uuid;
use tokio::sync::broadcast;
use crate::{Game, GameTransition, State, Card, TimerConfig};
use crate::ai::AiStrategy;
use crate::result::TransitionSuccess;
use crate::sqlite_store::SqliteStore;
use serde::{Serialize, Deserialize};
use rand::seq::SliceRandom;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum GameEvent {
StateChanged(GameStateResponse),
GameAborted { game_id: Uuid, reason: String },
}
struct ActiveTurnTimer {
turn_started_at: tokio::time::Instant,
remaining_at_turn_start_ms: u64,
timeout_handle: tokio::task::JoinHandle<()>,
expected_player_index: usize,
}
pub struct AiPlayerConfig {
pub ai_players: HashSet<usize>,
pub strategy: Arc<dyn AiStrategy>,
}
#[derive(Clone)]
pub struct GameManager {
games: Arc<RwLock<HashMap<Uuid, Arc<RwLock<Game>>>>>,
broadcasters: Arc<RwLock<HashMap<Uuid, broadcast::Sender<GameEvent>>>>,
db: Option<Arc<SqliteStore>>,
active_timers: Arc<Mutex<HashMap<Uuid, ActiveTurnTimer>>>,
ai_configs: Arc<RwLock<HashMap<Uuid, AiPlayerConfig>>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateGameResponse {
pub game_id: Uuid,
pub player_ids: [Uuid; 4],
}
#[derive(Debug, Clone, Serialize, Deserialize, oasgen::OaSchema)]
pub struct PlayerNameEntry {
pub player_id: Uuid,
pub name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameStateResponse {
pub game_id: Uuid,
pub short_id: String,
pub state: State,
pub team_a_score: Option<i32>,
pub team_b_score: Option<i32>,
pub team_a_bags: Option<i32>,
pub team_b_bags: Option<i32>,
pub current_player_id: Option<Uuid>,
pub player_names: [PlayerNameEntry; 4],
#[serde(skip_serializing_if = "Option::is_none")]
pub timer_config: Option<TimerConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub player_clocks_ms: Option<[u64; 4]>,
#[serde(skip_serializing_if = "Option::is_none")]
pub active_player_clock_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub table_cards: Option<[Card; 4]>,
#[serde(skip_serializing_if = "Option::is_none")]
pub player_bets: Option<[i32; 4]>,
#[serde(skip_serializing_if = "Option::is_none")]
pub player_tricks_won: Option<[i32; 4]>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_trick_winner_id: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_completed_trick: Option<[Card; 4]>,
}
#[derive(Debug, Serialize, Deserialize, oasgen::OaSchema)]
pub struct HandResponse {
pub player_id: Uuid,
pub cards: Vec<Card>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TransitionRequest {
pub game_id: Uuid,
pub player_id: Uuid,
#[serde(flatten)]
pub transition: TransitionRequestType,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TransitionRequestType {
Start,
Bet { amount: i32 },
Card { card: Card },
}
#[derive(Debug, Serialize, Deserialize)]
pub enum GameManagerError {
GameNotFound,
GameError(String),
LockError,
}
fn epoch_ms_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
impl GameManager {
pub fn new() -> Self {
GameManager {
games: Arc::new(RwLock::new(HashMap::new())),
broadcasters: Arc::new(RwLock::new(HashMap::new())),
db: None,
active_timers: Arc::new(Mutex::new(HashMap::new())),
ai_configs: Arc::new(RwLock::new(HashMap::new())),
}
}
pub fn with_db(path: &str) -> Result<Self, String> {
let store = SqliteStore::open(path)?;
let existing_games = store.load_all_games()?;
let mut games_map = HashMap::new();
let mut broadcasters_map = HashMap::new();
for game in existing_games {
let id = *game.get_id();
games_map.insert(id, Arc::new(RwLock::new(game)));
let (tx, _) = broadcast::channel(64);
broadcasters_map.insert(id, tx);
}
let count = games_map.len();
println!("Loaded {} game(s) from database", count);
let manager = GameManager {
games: Arc::new(RwLock::new(games_map)),
broadcasters: Arc::new(RwLock::new(broadcasters_map)),
db: Some(Arc::new(store)),
active_timers: Arc::new(Mutex::new(HashMap::new())),
ai_configs: Arc::new(RwLock::new(HashMap::new())),
};
manager.restart_persisted_timers();
Ok(manager)
}
fn restart_persisted_timers(&self) {
let games = match self.games.read() {
Ok(g) => g,
Err(_) => return,
};
for (&game_id, game_lock) in games.iter() {
let game = match game_lock.read() {
Ok(g) => g,
Err(_) => continue,
};
if game.get_timer_config().is_none() {
continue;
}
match game.get_state() {
State::Betting(_) | State::Trick(_) => {}
_ => continue,
}
if let (Some(epoch_ms), Some(clocks)) = (game.get_turn_started_at_epoch_ms(), game.get_player_clocks()) {
let player_idx = game.get_current_player_index_num();
let now = epoch_ms_now();
let elapsed = now.saturating_sub(epoch_ms);
let remaining = clocks.remaining_ms[player_idx].saturating_sub(elapsed);
self.start_turn_timer(game_id, player_idx, remaining);
}
}
}
fn persist_insert(&self, game: &Game) {
if let Some(db) = &self.db {
if let Err(e) = db.insert_game(game) {
eprintln!("Failed to persist game insert: {}", e);
}
}
}
fn persist_update(&self, game: &Game) {
if let Some(db) = &self.db {
if let Err(e) = db.update_game(game) {
eprintln!("Failed to persist game update: {}", e);
}
}
}
fn persist_delete(&self, game_id: Uuid) {
if let Some(db) = &self.db {
if let Err(e) = db.delete_game(game_id) {
eprintln!("Failed to persist game delete: {}", e);
}
}
}
pub fn create_game(&self, max_points: i32, timer_config: Option<TimerConfig>) -> Result<CreateGameResponse, GameManagerError> {
let game_id = Uuid::new_v4();
let player_ids = [
Uuid::new_v4(),
Uuid::new_v4(),
Uuid::new_v4(),
Uuid::new_v4(),
];
let game = Game::new(game_id, player_ids, max_points, timer_config);
self.persist_insert(&game);
let mut games = self.games.write().map_err(|_| GameManagerError::LockError)?;
games.insert(game_id, Arc::new(RwLock::new(game)));
drop(games);
let (tx, _) = broadcast::channel(64);
let mut broadcasters = self.broadcasters.write().map_err(|_| GameManagerError::LockError)?;
broadcasters.insert(game_id, tx);
Ok(CreateGameResponse {
game_id,
player_ids,
})
}
pub fn create_game_with_players(&self, player_ids: [Uuid; 4], max_points: i32, timer_config: Option<TimerConfig>) -> Result<CreateGameResponse, GameManagerError> {
let game_id = Uuid::new_v4();
let game = Game::new(game_id, player_ids, max_points, timer_config);
self.persist_insert(&game);
let mut games = self.games.write().map_err(|_| GameManagerError::LockError)?;
games.insert(game_id, Arc::new(RwLock::new(game)));
drop(games);
let (tx, _) = broadcast::channel(64);
let mut broadcasters = self.broadcasters.write().map_err(|_| GameManagerError::LockError)?;
broadcasters.insert(game_id, tx);
Ok(CreateGameResponse {
game_id,
player_ids,
})
}
pub fn create_ai_game(
&self,
human_seats: HashSet<usize>,
max_points: i32,
timer_config: Option<TimerConfig>,
strategy: Arc<dyn AiStrategy>,
) -> Result<CreateGameResponse, GameManagerError> {
let response = self.create_game(max_points, timer_config)?;
let game_id = response.game_id;
let ai_players: HashSet<usize> = (0..4)
.filter(|i| !human_seats.contains(i))
.collect();
let config = AiPlayerConfig {
ai_players,
strategy,
};
let mut configs = self.ai_configs.write().map_err(|_| GameManagerError::LockError)?;
configs.insert(game_id, config);
Ok(response)
}
pub fn play_ai_turns(&self, game_id: Uuid) -> Result<(), GameManagerError> {
loop {
let (state, player_index) = {
let games = self.games.read().map_err(|_| GameManagerError::LockError)?;
let game_lock = games.get(&game_id).ok_or(GameManagerError::GameNotFound)?;
let game = game_lock.read().map_err(|_| GameManagerError::LockError)?;
(game.get_state().clone(), game.get_current_player_index_num())
};
match state {
State::Completed | State::Aborted | State::NotStarted => break,
State::Betting(_) | State::Trick(_) => {}
}
let transition = {
let configs = self.ai_configs.read().map_err(|_| GameManagerError::LockError)?;
let config = match configs.get(&game_id) {
Some(c) => c,
None => break, };
if !config.ai_players.contains(&player_index) {
break; }
let games = self.games.read().map_err(|_| GameManagerError::LockError)?;
let game_lock = games.get(&game_id).ok_or(GameManagerError::GameNotFound)?;
let game = game_lock.read().map_err(|_| GameManagerError::LockError)?;
match state {
State::Betting(_) => {
GameTransition::Bet(config.strategy.choose_bet(&game, player_index))
}
State::Trick(_) => {
GameTransition::Card(config.strategy.choose_card(&game, player_index))
}
_ => break,
}
};
self.make_transition_internal(game_id, transition, false)?;
}
Ok(())
}
fn build_state_response(game_id: Uuid, game: &Game, active_timer: Option<&ActiveTurnTimer>) -> GameStateResponse {
let names = game.get_player_names();
let timer_config = game.get_timer_config().copied();
let (player_clocks_ms, active_player_clock_ms) = if let Some(clocks) = game.get_player_clocks() {
let mut clocks_snapshot = clocks.remaining_ms;
let mut active_clock = None;
if let Some(timer) = active_timer {
let elapsed = timer.turn_started_at.elapsed().as_millis() as u64;
let remaining = timer.remaining_at_turn_start_ms.saturating_sub(elapsed);
clocks_snapshot[timer.expected_player_index] = remaining;
active_clock = Some(remaining);
}
(Some(clocks_snapshot), active_clock)
} else {
(None, None)
};
let table_cards = game.get_current_trick_cards().ok().cloned();
GameStateResponse {
game_id,
short_id: crate::uuid_to_short_id(game_id),
state: game.get_state().clone(),
team_a_score: game.get_team_a_score().ok().copied(),
team_b_score: game.get_team_b_score().ok().copied(),
team_a_bags: game.get_team_a_bags().ok().copied(),
team_b_bags: game.get_team_b_bags().ok().copied(),
current_player_id: game.get_current_player_id().ok().copied(),
player_names: [
PlayerNameEntry { player_id: names[0].0, name: names[0].1.map(String::from) },
PlayerNameEntry { player_id: names[1].0, name: names[1].1.map(String::from) },
PlayerNameEntry { player_id: names[2].0, name: names[2].1.map(String::from) },
PlayerNameEntry { player_id: names[3].0, name: names[3].1.map(String::from) },
],
timer_config,
player_clocks_ms,
active_player_clock_ms,
table_cards,
player_bets: game.get_player_bets(),
player_tricks_won: game.get_player_tricks_won(),
last_trick_winner_id: game.get_last_trick_winner_id(),
last_completed_trick: game.get_last_completed_trick().cloned(),
}
}
pub fn build_state_response_with_timer(&self, game_id: Uuid, game: &Game) -> GameStateResponse {
let timers = self.active_timers.lock().unwrap();
let active_timer = timers.get(&game_id);
Self::build_state_response(game_id, game, active_timer)
}
pub fn get_game_state(&self, game_id: Uuid) -> Result<GameStateResponse, GameManagerError> {
let games = self.games.read().map_err(|_| GameManagerError::LockError)?;
let game_lock = games.get(&game_id).ok_or(GameManagerError::GameNotFound)?;
let game = game_lock.read().map_err(|_| GameManagerError::LockError)?;
let timers = self.active_timers.lock().map_err(|_| GameManagerError::LockError)?;
let active_timer = timers.get(&game_id);
Ok(Self::build_state_response(game_id, &game, active_timer))
}
pub fn get_hand(&self, game_id: Uuid, player_id: Uuid) -> Result<HandResponse, GameManagerError> {
let games = self.games.read().map_err(|_| GameManagerError::LockError)?;
let game_lock = games.get(&game_id).ok_or(GameManagerError::GameNotFound)?;
let game = game_lock.read().map_err(|_| GameManagerError::LockError)?;
let cards = game.get_hand_by_player_id(player_id)
.map_err(|e| GameManagerError::GameError(format!("{:?}", e)))?
.clone();
Ok(HandResponse {
player_id,
cards,
})
}
fn cancel_turn_timer(&self, game_id: Uuid) -> (u64, Option<usize>) {
let mut timers = self.active_timers.lock().unwrap();
if let Some(timer) = timers.remove(&game_id) {
timer.timeout_handle.abort();
let elapsed = timer.turn_started_at.elapsed().as_millis() as u64;
(elapsed, Some(timer.expected_player_index))
} else {
(0, None)
}
}
fn start_turn_timer(&self, game_id: Uuid, player_index: usize, remaining_ms: u64) {
let mgr = self.clone();
let handle = tokio::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_millis(remaining_ms)).await;
mgr.handle_timeout(game_id);
});
let mut timers = self.active_timers.lock().unwrap();
timers.insert(game_id, ActiveTurnTimer {
turn_started_at: tokio::time::Instant::now(),
remaining_at_turn_start_ms: remaining_ms,
timeout_handle: handle,
expected_player_index: player_index,
});
}
pub fn make_transition(&self, game_id: Uuid, transition: GameTransition)
-> Result<TransitionSuccess, GameManagerError> {
self.make_transition_internal(game_id, transition, false)
}
fn make_transition_internal(&self, game_id: Uuid, transition: GameTransition, is_timeout: bool)
-> Result<TransitionSuccess, GameManagerError> {
let games = self.games.read().map_err(|_| GameManagerError::LockError)?;
let game_lock = games.get(&game_id).ok_or(GameManagerError::GameNotFound)?;
let mut game = game_lock.write().map_err(|_| GameManagerError::LockError)?;
let is_timed = game.get_timer_config().is_some();
let is_start = matches!(transition, GameTransition::Start);
if is_timed && !is_start {
let (elapsed_ms, prev_idx) = self.cancel_turn_timer(game_id);
let increment_ms = if !is_timeout {
game.get_timer_config().map(|tc| tc.increment_secs * 1000).unwrap_or(0)
} else {
0
};
if let Some(idx) = prev_idx {
if let Some(clocks) = game.get_player_clocks_mut() {
clocks.remaining_ms[idx] = clocks.remaining_ms[idx].saturating_sub(elapsed_ms) + increment_ms;
}
}
}
let result = game.play(transition)
.map_err(|e| GameManagerError::GameError(format!("{:?}", e)))?;
if is_timed {
match game.get_state() {
State::Betting(_) | State::Trick(_) => {
game.set_turn_started_at_epoch_ms(Some(epoch_ms_now()));
}
_ => {
game.set_turn_started_at_epoch_ms(None);
}
}
}
self.persist_update(&game);
if is_timed {
match game.get_state() {
State::Betting(_) | State::Trick(_) => {
let player_idx = game.get_current_player_index_num();
let remaining = game.get_player_clocks()
.map(|c| c.remaining_ms[player_idx])
.unwrap_or(0);
self.start_turn_timer(game_id, player_idx, remaining);
}
_ => {}
}
}
let timers = self.active_timers.lock().unwrap();
let active_timer = timers.get(&game_id);
let state_response = Self::build_state_response(game_id, &game, active_timer);
drop(timers);
drop(game);
drop(games);
if let Ok(broadcasters) = self.broadcasters.read() {
if let Some(tx) = broadcasters.get(&game_id) {
let _ = tx.send(GameEvent::StateChanged(state_response));
}
}
Ok(result)
}
fn handle_timeout(&self, game_id: Uuid) {
let (is_first_round_betting, current_state, player_idx) = {
let games = match self.games.read() {
Ok(g) => g,
Err(_) => return,
};
let game_lock = match games.get(&game_id) {
Some(g) => g,
None => return,
};
let game = match game_lock.read() {
Ok(g) => g,
Err(_) => return,
};
(
game.is_first_round_betting(),
game.get_state().clone(),
game.get_current_player_index_num(),
)
};
{
let timers = self.active_timers.lock().unwrap();
if let Some(timer) = timers.get(&game_id) {
if timer.expected_player_index != player_idx {
return;
}
}
}
{
let games = match self.games.read() {
Ok(g) => g,
Err(_) => return,
};
if let Some(game_lock) = games.get(&game_id) {
if let Ok(mut game) = game_lock.write() {
if let Some(clocks) = game.get_player_clocks_mut() {
clocks.remaining_ms[player_idx] = 0;
}
}
}
}
{
let mut timers = self.active_timers.lock().unwrap();
timers.remove(&game_id);
}
if is_first_round_betting {
self.abort_game(game_id, "Player timed out during first round betting".to_string());
} else {
match current_state {
State::Betting(_) => {
let _ = self.make_transition_internal(game_id, GameTransition::Bet(1), true);
}
State::Trick(_) => {
let card = {
let games = match self.games.read() {
Ok(g) => g,
Err(_) => return,
};
let game_lock = match games.get(&game_id) {
Some(g) => g,
None => return,
};
let game = match game_lock.read() {
Ok(g) => g,
Err(_) => return,
};
match game.get_legal_cards() {
Ok(cards) if !cards.is_empty() => {
let mut rng = rand::thread_rng();
cards.choose(&mut rng).cloned()
}
_ => None,
}
};
if let Some(card) = card {
let _ = self.make_transition_internal(game_id, GameTransition::Card(card), true);
}
}
_ => {}
}
}
}
fn abort_game(&self, game_id: Uuid, reason: String) {
{
let games = match self.games.read() {
Ok(g) => g,
Err(_) => return,
};
if let Some(game_lock) = games.get(&game_id) {
if let Ok(mut game) = game_lock.write() {
game.set_state(State::Aborted);
game.set_turn_started_at_epoch_ms(None);
self.persist_update(&game);
}
}
}
if let Ok(broadcasters) = self.broadcasters.read() {
if let Some(tx) = broadcasters.get(&game_id) {
let _ = tx.send(GameEvent::GameAborted {
game_id,
reason,
});
}
}
}
pub fn set_player_name(&self, game_id: Uuid, player_id: Uuid, name: Option<String>)
-> Result<(), GameManagerError> {
let games = self.games.read().map_err(|_| GameManagerError::LockError)?;
let game_lock = games.get(&game_id).ok_or(GameManagerError::GameNotFound)?;
let mut game = game_lock.write().map_err(|_| GameManagerError::LockError)?;
game.set_player_name(player_id, name)
.map_err(|e| GameManagerError::GameError(format!("{:?}", e)))?;
self.persist_update(&game);
let state_response = Self::build_state_response(game_id, &game, None);
drop(game);
drop(games);
if let Ok(broadcasters) = self.broadcasters.read() {
if let Some(tx) = broadcasters.get(&game_id) {
let _ = tx.send(GameEvent::StateChanged(state_response));
}
}
Ok(())
}
pub fn list_games(&self) -> Result<Vec<Uuid>, GameManagerError> {
let games = self.games.read().map_err(|_| GameManagerError::LockError)?;
Ok(games.keys().copied().collect())
}
pub fn remove_game(&self, game_id: Uuid) -> Result<(), GameManagerError> {
{
let mut timers = self.active_timers.lock().unwrap();
if let Some(timer) = timers.remove(&game_id) {
timer.timeout_handle.abort();
}
}
let mut games = self.games.write().map_err(|_| GameManagerError::LockError)?;
games.remove(&game_id).ok_or(GameManagerError::GameNotFound)?;
drop(games);
self.persist_delete(game_id);
if let Ok(mut broadcasters) = self.broadcasters.write() {
broadcasters.remove(&game_id);
}
if let Ok(mut configs) = self.ai_configs.write() {
configs.remove(&game_id);
}
Ok(())
}
pub fn subscribe(&self, game_id: Uuid) -> Result<broadcast::Receiver<GameEvent>, GameManagerError> {
let broadcasters = self.broadcasters.read().map_err(|_| GameManagerError::LockError)?;
let tx = broadcasters.get(&game_id).ok_or(GameManagerError::GameNotFound)?;
Ok(tx.subscribe())
}
}
impl Default for GameManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_game() {
let manager = GameManager::new();
let response = manager.create_game(500, None).unwrap();
assert_ne!(response.game_id, Uuid::nil());
assert_eq!(response.player_ids.len(), 4);
}
#[test]
fn test_get_game_state() {
let manager = GameManager::new();
let response = manager.create_game(500, None).unwrap();
let state = manager.get_game_state(response.game_id).unwrap();
assert_eq!(state.state, State::NotStarted);
assert_eq!(state.game_id, response.game_id);
}
#[test]
fn test_make_transition() {
let manager = GameManager::new();
let response = manager.create_game(500, None).unwrap();
let result = manager.make_transition(response.game_id, GameTransition::Start).unwrap();
assert_eq!(result, TransitionSuccess::Start);
let state = manager.get_game_state(response.game_id).unwrap();
assert_eq!(state.state, State::Betting(0));
}
#[test]
fn test_list_games() {
let manager = GameManager::new();
let game1 = manager.create_game(500, None).unwrap();
let game2 = manager.create_game(500, None).unwrap();
let games = manager.list_games().unwrap();
assert_eq!(games.len(), 2);
assert!(games.contains(&game1.game_id));
assert!(games.contains(&game2.game_id));
}
#[test]
fn test_remove_game() {
let manager = GameManager::new();
let response = manager.create_game(500, None).unwrap();
manager.remove_game(response.game_id).unwrap();
let games = manager.list_games().unwrap();
assert_eq!(games.len(), 0);
}
#[test]
fn test_create_game_with_players() {
let manager = GameManager::new();
let player_ids = [
Uuid::new_v4(),
Uuid::new_v4(),
Uuid::new_v4(),
Uuid::new_v4(),
];
let response = manager.create_game_with_players(player_ids, 500, None).unwrap();
assert_eq!(response.player_ids, player_ids);
assert_ne!(response.game_id, Uuid::nil());
let state = manager.get_game_state(response.game_id).unwrap();
assert_eq!(state.state, State::NotStarted);
}
#[test]
fn test_create_timed_game() {
let manager = GameManager::new();
let tc = TimerConfig { initial_time_secs: 300, increment_secs: 5 };
let response = manager.create_game(500, Some(tc)).unwrap();
let state = manager.get_game_state(response.game_id).unwrap();
assert_eq!(state.timer_config, Some(tc));
assert_eq!(state.player_clocks_ms, Some([300_000; 4]));
}
#[test]
fn test_untimed_game_no_timer_data() {
let manager = GameManager::new();
let response = manager.create_game(500, None).unwrap();
let state = manager.get_game_state(response.game_id).unwrap();
assert!(state.timer_config.is_none());
assert!(state.player_clocks_ms.is_none());
assert!(state.active_player_clock_ms.is_none());
}
#[test]
fn test_get_hand_valid_player() {
let manager = GameManager::new();
let response = manager.create_game(500, None).unwrap();
manager.make_transition(response.game_id, GameTransition::Start).unwrap();
let hand = manager.get_hand(response.game_id, response.player_ids[0]).unwrap();
assert_eq!(hand.player_id, response.player_ids[0]);
assert_eq!(hand.cards.len(), 13);
}
#[test]
fn test_get_hand_invalid_player() {
let manager = GameManager::new();
let response = manager.create_game(500, None).unwrap();
manager.make_transition(response.game_id, GameTransition::Start).unwrap();
let result = manager.get_hand(response.game_id, Uuid::new_v4());
assert!(matches!(result, Err(GameManagerError::GameError(_))));
}
#[test]
fn test_get_hand_game_not_found() {
let manager = GameManager::new();
let result = manager.get_hand(Uuid::new_v4(), Uuid::new_v4());
assert!(matches!(result, Err(GameManagerError::GameNotFound)));
}
#[test]
fn test_make_transition_game_not_found() {
let manager = GameManager::new();
let result = manager.make_transition(Uuid::new_v4(), GameTransition::Start);
assert!(matches!(result, Err(GameManagerError::GameNotFound)));
}
#[test]
fn test_make_transition_start_twice() {
let manager = GameManager::new();
let response = manager.create_game(500, None).unwrap();
manager.make_transition(response.game_id, GameTransition::Start).unwrap();
let result = manager.make_transition(response.game_id, GameTransition::Start);
assert!(matches!(result, Err(GameManagerError::GameError(_))));
}
#[test]
fn test_make_transition_bet() {
let manager = GameManager::new();
let response = manager.create_game(500, None).unwrap();
manager.make_transition(response.game_id, GameTransition::Start).unwrap();
let result = manager.make_transition(response.game_id, GameTransition::Bet(3)).unwrap();
assert_eq!(result, TransitionSuccess::Bet);
}
#[test]
fn test_set_player_name_valid() {
let manager = GameManager::new();
let response = manager.create_game(500, None).unwrap();
manager.set_player_name(response.game_id, response.player_ids[0], Some("Alice".to_string())).unwrap();
let state = manager.get_game_state(response.game_id).unwrap();
assert_eq!(state.player_names[0].name.as_deref(), Some("Alice"));
}
#[test]
fn test_set_player_name_invalid_uuid() {
let manager = GameManager::new();
let response = manager.create_game(500, None).unwrap();
let result = manager.set_player_name(response.game_id, Uuid::new_v4(), Some("Nobody".to_string()));
assert!(matches!(result, Err(GameManagerError::GameError(_))));
}
#[test]
fn test_set_player_name_game_not_found() {
let manager = GameManager::new();
let result = manager.set_player_name(Uuid::new_v4(), Uuid::new_v4(), Some("Test".to_string()));
assert!(matches!(result, Err(GameManagerError::GameNotFound)));
}
#[test]
fn test_subscribe_valid_game() {
let manager = GameManager::new();
let response = manager.create_game(500, None).unwrap();
let rx = manager.subscribe(response.game_id);
assert!(rx.is_ok());
}
#[test]
fn test_subscribe_game_not_found() {
let manager = GameManager::new();
let result = manager.subscribe(Uuid::new_v4());
assert!(matches!(result, Err(GameManagerError::GameNotFound)));
}
#[test]
fn test_subscribe_receives_state_changed() {
let manager = GameManager::new();
let response = manager.create_game(500, None).unwrap();
let mut rx = manager.subscribe(response.game_id).unwrap();
manager.make_transition(response.game_id, GameTransition::Start).unwrap();
let event = rx.try_recv().unwrap();
match event {
GameEvent::StateChanged(state) => {
assert_eq!(state.state, State::Betting(0));
}
_ => panic!("Expected StateChanged event"),
}
}
#[test]
fn test_remove_game_not_found() {
let manager = GameManager::new();
let result = manager.remove_game(Uuid::new_v4());
assert!(matches!(result, Err(GameManagerError::GameNotFound)));
}
#[test]
fn test_get_game_state_not_found() {
let manager = GameManager::new();
let result = manager.get_game_state(Uuid::new_v4());
assert!(matches!(result, Err(GameManagerError::GameNotFound)));
}
#[test]
fn test_game_event_serde_state_changed() {
let state = GameStateResponse {
game_id: Uuid::nil(),
short_id: crate::uuid_to_short_id(Uuid::nil()),
state: State::NotStarted,
team_a_score: None,
team_b_score: None,
team_a_bags: None,
team_b_bags: None,
current_player_id: None,
player_names: [
PlayerNameEntry { player_id: Uuid::nil(), name: None },
PlayerNameEntry { player_id: Uuid::nil(), name: None },
PlayerNameEntry { player_id: Uuid::nil(), name: None },
PlayerNameEntry { player_id: Uuid::nil(), name: None },
],
timer_config: None,
player_clocks_ms: None,
active_player_clock_ms: None,
table_cards: None,
player_bets: None,
player_tricks_won: None,
last_trick_winner_id: None,
last_completed_trick: None,
};
let event = GameEvent::StateChanged(state);
let json = serde_json::to_string(&event).unwrap();
let deserialized: GameEvent = serde_json::from_str(&json).unwrap();
match deserialized {
GameEvent::StateChanged(s) => assert_eq!(s.state, State::NotStarted),
_ => panic!("Expected StateChanged"),
}
}
#[test]
fn test_game_event_serde_game_aborted() {
let event = GameEvent::GameAborted {
game_id: Uuid::nil(),
reason: "timeout".to_string(),
};
let json = serde_json::to_string(&event).unwrap();
let deserialized: GameEvent = serde_json::from_str(&json).unwrap();
match deserialized {
GameEvent::GameAborted { reason, .. } => assert_eq!(reason, "timeout"),
_ => panic!("Expected GameAborted"),
}
}
#[test]
fn test_default_trait() {
let manager = GameManager::default();
let games = manager.list_games().unwrap();
assert_eq!(games.len(), 0);
}
#[test]
fn test_with_db_empty() {
let manager = GameManager::with_db(":memory:").unwrap();
let games = manager.list_games().unwrap();
assert_eq!(games.len(), 0);
}
#[test]
fn test_with_db_persist_and_reload() {
let dir = std::env::temp_dir().join(format!("spades_test_{}", Uuid::new_v4()));
let db_path = dir.to_str().unwrap().to_string();
{
let manager = GameManager::with_db(&db_path).unwrap();
manager.create_game(500, None).unwrap();
assert_eq!(manager.list_games().unwrap().len(), 1);
}
{
let manager = GameManager::with_db(&db_path).unwrap();
assert_eq!(manager.list_games().unwrap().len(), 1);
}
let _ = std::fs::remove_file(&db_path);
}
#[test]
fn test_full_game_bet_and_play_card() {
let manager = GameManager::new();
let response = manager.create_game(500, None).unwrap();
let game_id = response.game_id;
manager.make_transition(game_id, GameTransition::Start).unwrap();
for _ in 0..4 {
manager.make_transition(game_id, GameTransition::Bet(3)).unwrap();
}
let state = manager.get_game_state(game_id).unwrap();
assert!(matches!(state.state, State::Trick(0)));
let current_pid = state.current_player_id.unwrap();
let hand = manager.get_hand(game_id, current_pid).unwrap();
let card = hand.cards[0].clone();
let result = manager.make_transition(game_id, GameTransition::Card(card));
assert!(result.is_ok());
}
#[test]
fn test_persist_operations_with_db() {
let manager = GameManager::with_db(":memory:").unwrap();
let response = manager.create_game(500, None).unwrap();
manager.make_transition(response.game_id, GameTransition::Start).unwrap();
manager.set_player_name(response.game_id, response.player_ids[0], Some("Alice".to_string())).unwrap();
manager.remove_game(response.game_id).unwrap();
assert!(manager.list_games().unwrap().is_empty());
}
#[test]
fn test_remove_game_cancels_timer() {
let manager = GameManager::new();
let tc = TimerConfig { initial_time_secs: 300, increment_secs: 5 };
let response = manager.create_game(500, Some(tc)).unwrap();
manager.remove_game(response.game_id).unwrap();
}
#[test]
fn test_build_state_response_with_timer() {
let manager = GameManager::new();
let response = manager.create_game(500, None).unwrap();
manager.make_transition(response.game_id, GameTransition::Start).unwrap();
let state = manager.build_state_response_with_timer(
response.game_id,
&crate::Game::new(response.game_id, response.player_ids, 500, None),
);
assert_eq!(state.game_id, response.game_id);
}
#[test]
fn test_transition_request_type_serde() {
let start = TransitionRequestType::Start;
let json = serde_json::to_string(&start).unwrap();
let _: TransitionRequestType = serde_json::from_str(&json).unwrap();
let bet = TransitionRequestType::Bet { amount: 3 };
let json = serde_json::to_string(&bet).unwrap();
let _: TransitionRequestType = serde_json::from_str(&json).unwrap();
let card = TransitionRequestType::Card {
card: crate::Card { suit: crate::Suit::Heart, rank: crate::Rank::Ace },
};
let json = serde_json::to_string(&card).unwrap();
let _: TransitionRequestType = serde_json::from_str(&json).unwrap();
}
#[tokio::test]
async fn test_timed_game_start_and_bet() {
let manager = GameManager::new();
let tc = TimerConfig { initial_time_secs: 300, increment_secs: 5 };
let response = manager.create_game(500, Some(tc)).unwrap();
let game_id = response.game_id;
let result = manager.make_transition(game_id, GameTransition::Start).unwrap();
assert_eq!(result, TransitionSuccess::Start);
let state = manager.get_game_state(game_id).unwrap();
assert!(state.timer_config.is_some());
assert!(state.player_clocks_ms.is_some());
for _ in 0..4 {
manager.make_transition(game_id, GameTransition::Bet(3)).unwrap();
}
let state = manager.get_game_state(game_id).unwrap();
assert!(matches!(state.state, State::Trick(0)));
let current_pid = state.current_player_id.unwrap();
let hand = manager.get_hand(game_id, current_pid).unwrap();
let card = hand.cards[0].clone();
manager.make_transition(game_id, GameTransition::Card(card)).unwrap();
}
#[tokio::test]
async fn test_timed_game_remove_cancels_timer() {
let manager = GameManager::new();
let tc = TimerConfig { initial_time_secs: 300, increment_secs: 5 };
let response = manager.create_game(500, Some(tc)).unwrap();
manager.make_transition(response.game_id, GameTransition::Start).unwrap();
manager.remove_game(response.game_id).unwrap();
}
#[tokio::test]
async fn test_timed_game_state_has_active_clock() {
let manager = GameManager::new();
let tc = TimerConfig { initial_time_secs: 300, increment_secs: 5 };
let response = manager.create_game(500, Some(tc)).unwrap();
manager.make_transition(response.game_id, GameTransition::Start).unwrap();
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
let state = manager.get_game_state(response.game_id).unwrap();
assert!(state.active_player_clock_ms.is_some());
}
#[tokio::test]
async fn test_with_db_persists_timed_game() {
let dir = std::env::temp_dir().join(format!("spades_timed_{}", Uuid::new_v4()));
let db_path = dir.to_str().unwrap().to_string();
{
let manager = GameManager::with_db(&db_path).unwrap();
let tc = TimerConfig { initial_time_secs: 300, increment_secs: 5 };
let response = manager.create_game(500, Some(tc)).unwrap();
manager.make_transition(response.game_id, GameTransition::Start).unwrap();
}
{
let manager = GameManager::with_db(&db_path).unwrap();
assert_eq!(manager.list_games().unwrap().len(), 1);
}
let _ = std::fs::remove_file(&db_path);
}
#[test]
fn test_game_manager_error_serde() {
let err = GameManagerError::GameNotFound;
let json = serde_json::to_string(&err).unwrap();
let _: GameManagerError = serde_json::from_str(&json).unwrap();
let err = GameManagerError::LockError;
let json = serde_json::to_string(&err).unwrap();
let _: GameManagerError = serde_json::from_str(&json).unwrap();
let err = GameManagerError::GameError("test".to_string());
let json = serde_json::to_string(&err).unwrap();
let _: GameManagerError = serde_json::from_str(&json).unwrap();
}
#[test]
fn test_create_ai_game() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let gm = GameManager::new();
let strategy = Arc::new(crate::ai::RandomStrategy);
let human_seats: HashSet<usize> = [0].into_iter().collect();
let response = gm.create_ai_game(human_seats, 500, None, strategy).unwrap();
assert_ne!(response.game_id, Uuid::nil());
});
}
#[test]
fn test_ai_auto_plays_betting() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let gm = GameManager::new();
let strategy = Arc::new(crate::ai::RandomStrategy);
let human_seats: HashSet<usize> = [0].into_iter().collect();
let response = gm.create_ai_game(human_seats, 500, None, strategy).unwrap();
let game_id = response.game_id;
gm.make_transition(game_id, GameTransition::Start).unwrap();
gm.make_transition(game_id, GameTransition::Bet(3)).unwrap();
gm.play_ai_turns(game_id).unwrap();
let state = gm.get_game_state(game_id).unwrap();
assert!(matches!(state.state, State::Trick(0)));
assert_eq!(state.current_player_id, Some(response.player_ids[0]));
});
}
#[test]
fn test_ai_full_game_1_human_3_ai() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let gm = GameManager::new();
let strategy = Arc::new(crate::ai::RandomStrategy);
let human_seats: HashSet<usize> = [0].into_iter().collect();
let response = gm.create_ai_game(human_seats, 200, None, strategy).unwrap();
let game_id = response.game_id;
let human_player_id = response.player_ids[0];
gm.make_transition(game_id, GameTransition::Start).unwrap();
loop {
gm.play_ai_turns(game_id).unwrap();
let state = gm.get_game_state(game_id).unwrap();
if state.state == State::Completed {
break;
}
if state.current_player_id == Some(human_player_id) {
if matches!(state.state, State::Betting(_)) {
gm.make_transition(game_id, GameTransition::Bet(3)).unwrap();
} else {
let hand = gm.get_hand(game_id, human_player_id).unwrap();
let mut played = false;
for card in &hand.cards {
if gm.make_transition(game_id, GameTransition::Card(card.clone())).is_ok() {
played = true;
break;
}
}
assert!(played, "No legal card found in hand");
}
}
}
let final_state = gm.get_game_state(game_id).unwrap();
assert_eq!(final_state.state, State::Completed);
});
}
}