Live location tracking and playback for the game "manhunt"

Add post-game sync and game replay

bwc9876.dev 673be726 769d1dca

verified
+394 -146
+6 -5
TODO.md
··· 8 8 - [x] API : Command to check if a game exists and is open for fast error checking 9 9 - [x] Transport : Switch to burst message processing for less time in the 10 10 critical path 11 - - [ ] State : Event history tracking 12 - - [ ] State : Post game sync 13 - - [ ] API : Handling Profile Syncing 14 - - [ ] ALL : State Update Events 15 - - [ ] ALL : Game Replay Screen 11 + - [x] State : Event history tracking 12 + - [x] State : Post game sync 13 + - [x] API : Handling Profile Syncing 14 + - [ ] API : State Update Events 15 + - [x] API : Game Replay Screen 16 16 - [ ] Frontend : Scaffolding 17 17 - [x] Meta : CI Setup 18 18 - [x] Meta : README Instructions 19 19 - [x] Meta : Recipes for type binding generation 20 20 - [x] Signaling: All of it 21 + - [ ] Backend : More tests
+3 -3
backend/src/game/events.rs
··· 1 1 use serde::{Deserialize, Serialize}; 2 2 3 - use super::{location::Location, state::PlayerPing, Id}; 3 + use super::{location::Location, state::PlayerPing, Id, UtcDT}; 4 4 5 5 /// An event used between players to update state 6 - #[derive(Debug, Clone, Serialize, Deserialize)] 6 + #[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] 7 7 pub enum GameEvent { 8 8 /// A player has been caught and is now a seeker, contains the ID of the caught player 9 9 PlayerCaught(Id), ··· 16 16 PowerupDespawn(Id), 17 17 /// Contains location history of the given player, used after the game to sync location 18 18 /// histories 19 - PostGameSync(Id, Vec<Location>), 19 + PostGameSync(Id, Vec<(UtcDT, Location)>), 20 20 /// A player has been disconnected and removed from the game (because of error or otherwise). 21 21 /// The player should be removed from all state 22 22 DroppedPlayer(Id),
+33 -14
backend/src/game/mod.rs
··· 18 18 use crate::prelude::*; 19 19 20 20 pub use location::{Location, LocationService}; 21 - pub use state::GameState; 21 + pub use state::{GameHistory, GameState}; 22 22 pub use transport::Transport; 23 23 24 24 pub type Id = Uuid; ··· 56 56 interval, 57 57 state: RwLock::new(state), 58 58 } 59 - } 60 - 61 - pub async fn clone_state(&self) -> GameState { 62 - self.state.read().await.clone() 63 59 } 64 60 65 61 pub async fn mark_caught(&self) { ··· 67 63 let id = state.id; 68 64 state.mark_caught(id); 69 65 state.remove_ping(id); 70 - // TODO: Maybe reroll for new powerups instead of just erasing it 66 + // TODO: Maybe reroll for new powerups (specifically seeker ones) instead of just erasing it 71 67 state.use_powerup(); 72 68 73 69 self.transport ··· 111 107 } 112 108 113 109 async fn consume_event(&self, state: &mut GameState, event: GameEvent) -> Result { 110 + if !state.game_ended() { 111 + state.event_history.push((Utc::now(), event.clone())); 112 + } 113 + 114 114 match event { 115 115 GameEvent::Ping(player_ping) => state.add_ping(player_ping), 116 116 GameEvent::ForcePing(target, display) => { ··· 140 140 GameEvent::TransportDisconnect => { 141 141 bail!("Transport disconnected"); 142 142 } 143 - GameEvent::PostGameSync(_, _locations) => {} 143 + GameEvent::PostGameSync(id, history) => { 144 + state.insert_player_location_history(id, history); 145 + } 144 146 } 145 147 146 148 Ok(()) 147 149 } 148 150 149 151 /// Perform a tick for a specific moment in time 150 - async fn tick(&self, state: &mut GameState, now: UtcDT) { 152 + /// Returns whether the game loop should be broken. 153 + async fn tick(&self, state: &mut GameState, now: UtcDT) -> bool { 154 + if state.check_end_game() { 155 + // If we're at the point where the game is over, send out our location history 156 + let msg = GameEvent::PostGameSync(state.id, state.location_history.clone()); 157 + self.transport.send_message(msg).await; 158 + } 159 + 160 + if state.game_ended() { 161 + // Don't do normal ticks if the game is over, 162 + // simply return if we're done doing a post-game sync 163 + 164 + return state.check_post_game_sync(); 165 + } 166 + 151 167 // Push to location history 152 168 if let Some(location) = self.location.get_loc() { 153 169 state.push_loc(location); ··· 192 208 if state.should_spawn_powerup(&now) { 193 209 state.try_spawn_powerup(now); 194 210 } 211 + 212 + false 195 213 } 196 214 197 215 #[cfg(test)] ··· 205 223 } 206 224 207 225 /// Main loop of the game, handles ticking and receiving messages from [Transport]. 208 - pub async fn main_loop(&self) -> Result { 226 + pub async fn main_loop(&self) -> Result<GameHistory> { 209 227 let mut interval = tokio::time::interval(self.interval); 210 228 211 229 interval.set_missed_tick_behavior(MissedTickBehavior::Delay); ··· 221 239 break 'game Err(why); 222 240 } 223 241 } 224 - 225 - if state.should_end() { 226 - break Ok(()); 227 - } 228 242 } 229 243 230 244 _ = interval.tick() => { 231 245 let mut state = self.state.write().await; 232 - self.tick(&mut state, Utc::now()).await; 246 + let should_break = self.tick(&mut state, Utc::now()).await; 247 + 248 + if should_break { 249 + let history = state.as_game_history(); 250 + break Ok(history); 251 + } 233 252 } 234 253 } 235 254 };
+65 -11
backend/src/game/state.rs
··· 8 8 }; 9 9 use rand_chacha::ChaCha20Rng; 10 10 use serde::{Deserialize, Serialize}; 11 + use uuid::Uuid; 12 + 13 + use crate::game::GameEvent; 11 14 12 15 use super::{ 13 16 location::Location, ··· 40 43 } 41 44 } 42 45 43 - #[derive(Debug, Clone, Serialize, specta::Type)] 46 + #[derive(Debug, Clone)] 44 47 /// This struct handles all logic regarding state updates 45 48 pub struct GameState { 46 49 /// The id of this player in this game ··· 51 54 52 55 /// When the game started 53 56 game_started: UtcDT, 57 + 58 + /// When the game ended, if this is [Option::Some] then the state will enter post-game sync 59 + game_ended: Option<UtcDT>, 60 + 61 + /// A HashMap of player IDs to location histories, used to track all player location histories 62 + /// during post-game sync 63 + player_histories: HashMap<Uuid, Option<Vec<(UtcDT, Location)>>>, 54 64 55 65 /// When seekers were allowed to begin 56 66 seekers_started: Option<UtcDT>, ··· 70 80 /// Powerup on the map that players can grab. Only one at a time 71 81 available_powerup: Option<Location>, 72 82 73 - #[serde(skip)] 83 + pub event_history: Vec<(UtcDT, GameEvent)>, 84 + 74 85 /// The game's current settings 75 86 settings: GameSettings, 76 87 77 - #[serde(skip)] 78 88 /// The player's location history 79 - location_history: Vec<Location>, 89 + pub location_history: Vec<(UtcDT, Location)>, 80 90 81 91 /// Cached bernoulli distribution for powerups, faster sampling 82 - #[serde(skip)] 83 92 powerup_bernoulli: Bernoulli, 84 93 85 94 /// A seed with a shared value between all players, should be reproducible 86 95 /// RNG for use in stuff like powerup location selection. 87 - #[serde(skip)] 88 96 shared_random_increment: i64, 89 97 90 98 /// State for [ChaCha20Rng] to be used and added to when performing shared RNG operations 91 - #[serde(skip)] 92 99 shared_random_state: u64, 93 100 } 94 101 ··· 100 107 Self { 101 108 id: my_id, 102 109 game_started: Utc::now(), 110 + event_history: Vec::with_capacity(15), 111 + game_ended: None, 103 112 seekers_started: None, 104 113 pings: HashMap::with_capacity(initial_caught_state.len()), 114 + player_histories: HashMap::from_iter(initial_caught_state.keys().map(|id| (*id, None))), 105 115 caught_state: initial_caught_state, 106 116 available_powerup: None, 107 117 powerup_bernoulli: settings.get_powerup_bernoulli(), ··· 240 250 self.pings.get(&player) 241 251 } 242 252 253 + /// Add a location history for the given player 254 + pub fn insert_player_location_history(&mut self, id: Uuid, history: Vec<(UtcDT, Location)>) { 255 + self.player_histories.insert(id, Some(history)); 256 + } 257 + 258 + /// Check if we've complete the post-game sync 259 + pub fn check_post_game_sync(&self) -> bool { 260 + self.game_ended() && self.player_histories.values().all(Option::is_some) 261 + } 262 + 243 263 /// Check if the game should be ended (due to all players being caught) 244 - pub fn should_end(&self) -> bool { 245 - self.caught_state.values().all(|v| *v) 264 + pub fn check_end_game(&mut self) -> bool { 265 + let should_end = self.caught_state.values().all(|v| *v); 266 + if should_end { 267 + self.game_ended = Some(Utc::now()); 268 + self.player_histories 269 + .insert(self.id, Some(self.location_history.clone())); 270 + } 271 + should_end 272 + } 273 + 274 + pub fn game_ended(&self) -> bool { 275 + self.game_ended.is_some() 246 276 } 247 277 248 278 /// Remove a ping from the map ··· 292 322 pub fn remove_player(&mut self, id: Id) { 293 323 self.pings.remove(&id); 294 324 self.caught_state.remove(&id); 325 + self.player_histories.remove(&id); 295 326 } 296 327 297 328 /// Player has gotten a powerup, rolls to see which powerup and stores it ··· 318 349 319 350 /// Push a new player location 320 351 pub fn push_loc(&mut self, loc: Location) { 321 - self.location_history.push(loc); 352 + self.location_history.push((Utc::now(), loc)); 322 353 } 323 354 324 355 /// Get the latest player location 325 356 fn get_loc(&self) -> Option<&Location> { 326 - self.location_history.last() 357 + self.location_history.last().map(|(_, l)| l) 327 358 } 328 359 329 360 /// Mark a player as caught ··· 342 373 pub fn is_seeker(&self) -> bool { 343 374 self.caught_state.get(&self.id).copied().unwrap_or_default() 344 375 } 376 + 377 + pub fn as_game_history(&self) -> GameHistory { 378 + GameHistory { 379 + my_id: self.id, 380 + events: self.event_history.clone(), 381 + locations: self 382 + .player_histories 383 + .iter() 384 + .map(|(id, history)| (*id, history.as_ref().cloned().unwrap_or_default())) 385 + .collect(), 386 + game_started: self.game_started, 387 + game_ended: self.game_ended.unwrap_or_default(), 388 + } 389 + } 390 + } 391 + 392 + #[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] 393 + pub struct GameHistory { 394 + my_id: Uuid, 395 + pub game_started: UtcDT, 396 + game_ended: UtcDT, 397 + events: Vec<(UtcDT, GameEvent)>, 398 + locations: Vec<(Uuid, Vec<(UtcDT, Location)>)>, 345 399 }
+58
backend/src/history.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + use std::{collections::HashMap, sync::Arc}; 3 + use tauri::{AppHandle, Runtime}; 4 + use tauri_plugin_store::{Store, StoreExt}; 5 + use uuid::Uuid; 6 + 7 + use crate::{ 8 + game::{GameHistory, UtcDT}, 9 + prelude::*, 10 + profile::PlayerProfile, 11 + }; 12 + 13 + #[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] 14 + pub struct AppGameHistory { 15 + history: GameHistory, 16 + profiles: HashMap<Uuid, PlayerProfile>, 17 + } 18 + 19 + impl AppGameHistory { 20 + pub fn new(history: GameHistory, profiles: HashMap<Uuid, PlayerProfile>) -> Self { 21 + Self { history, profiles } 22 + } 23 + 24 + fn get_store<R: Runtime>(app: &AppHandle<R>) -> Result<Arc<Store<R>>> { 25 + app.store("histories.json") 26 + .context("Failed to get history store") 27 + } 28 + 29 + pub fn ls_histories(app: &AppHandle) -> Result<Vec<UtcDT>> { 30 + let store = Self::get_store(app)?; 31 + 32 + let mut histories = store 33 + .keys() 34 + .into_iter() 35 + .filter_map(|k| serde_json::from_str::<UtcDT>(&k).ok()) 36 + .collect::<Vec<_>>(); 37 + 38 + histories.sort_unstable_by(|a, b| a.cmp(b).reverse()); 39 + 40 + Ok(histories) 41 + } 42 + 43 + pub fn get_history(app: &AppHandle, dt: UtcDT) -> Result<AppGameHistory> { 44 + let store = Self::get_store(app)?; 45 + let key = serde_json::to_string(&dt).context("Failed to make key")?; 46 + let val = store.get(key).context("Key not found")?; 47 + serde_json::from_value(val).context("Failed to deserialize game history") 48 + } 49 + 50 + pub fn save_history(&self, app: &AppHandle) -> Result { 51 + let store = Self::get_store(app)?; 52 + let serialized = serde_json::to_value(self).context("Failed to serialize history")?; 53 + let key = 54 + serde_json::to_string(&self.history.game_started).context("Failed to make key")?; 55 + store.set(key, serialized); 56 + Ok(()) 57 + } 58 + }
+124 -46
backend/src/lib.rs
··· 1 1 mod game; 2 + mod history; 2 3 mod lobby; 3 4 mod location; 4 5 mod profile; 5 6 mod transport; 6 7 7 - use std::{sync::Arc, time::Duration}; 8 + use std::{collections::HashMap, sync::Arc, time::Duration}; 8 9 9 - use game::{Game as BaseGame, GameSettings, GameState}; 10 + use game::{Game as BaseGame, GameSettings}; 11 + use history::AppGameHistory; 10 12 use lobby::{Lobby, LobbyState, StartGameInfo}; 11 13 use location::TauriLocation; 12 14 use log::{error, warn}; ··· 20 22 use uuid::Uuid; 21 23 22 24 mod prelude { 23 - pub use anyhow::{anyhow, bail, Error as AnyhowError}; 25 + pub use anyhow::{anyhow, bail, Context, Error as AnyhowError}; 24 26 pub use std::result::Result as StdResult; 25 27 26 28 pub type Result<T = (), E = AnyhowError> = StdResult<T, E>; 27 29 } 30 + 31 + use prelude::*; 28 32 29 33 type Game = BaseGame<TauriLocation, MatchboxTransport>; 30 34 ··· 32 36 Setup, 33 37 Menu(PlayerProfile), 34 38 Lobby(Arc<Lobby>), 35 - Game(Arc<Game>), 39 + Game(Arc<Game>, HashMap<Uuid, PlayerProfile>), 40 + Replay(AppGameHistory), 41 + } 42 + 43 + #[derive(Serialize, Deserialize, specta::Type, Debug, Clone, Eq, PartialEq)] 44 + enum AppScreen { 45 + Setup, 46 + Menu, 47 + Lobby, 48 + Game, 49 + Replay, 36 50 } 37 51 38 52 type AppStateHandle = RwLock<AppState>; ··· 58 72 struct ChangeScreen(AppScreen); 59 73 60 74 impl AppState { 61 - pub fn start_game(&mut self, app: AppHandle, my_id: Uuid, start: StartGameInfo) { 75 + pub async fn start_game(&mut self, app: AppHandle, my_id: Uuid, start: StartGameInfo) { 62 76 if let AppState::Lobby(lobby) = self { 63 77 let transport = lobby.clone_transport(); 78 + let profiles = lobby.clone_profiles().await; 64 79 let location = TauriLocation::new(app.clone()); 65 80 let game = Arc::new(Game::new( 66 81 my_id, ··· 71 86 location, 72 87 lobby.clone_cancel(), 73 88 )); 74 - *self = AppState::Game(game.clone()); 89 + *self = AppState::Game(game.clone(), profiles.clone()); 75 90 tokio::spawn(async move { 76 91 let res = game.main_loop().await; 77 92 let app2 = app.clone(); 78 93 let state_handle = app.state::<AppStateHandle>(); 79 94 let mut state = state_handle.write().await; 80 95 match res { 81 - Ok(_) => { 82 - // TODO: Post game screen, etc here. Game::main_loop should return smth 83 - // like GameHistory for playback and serialization 84 - state.quit_game_or_lobby(app2); 96 + Ok(history) => { 97 + let history = AppGameHistory::new(history, profiles); 98 + if let Err(why) = history.save_history(&app2) { 99 + error!("Failed to save game history: {why:?}"); 100 + } 101 + state.quit_to_menu(app2); 85 102 } 86 103 Err(why) => { 87 104 error!("Game Error: {why:?}"); 88 - state.quit_game_or_lobby(app2); 105 + state.quit_to_menu(app2); 89 106 } 90 107 } 91 108 }); ··· 107 124 } 108 125 109 126 pub fn get_lobby(&self) -> Result<Arc<Lobby>> { 110 - match self { 111 - AppState::Lobby(lobby) => Ok(lobby.clone()), 112 - _ => Err("Not on lobby screen".to_string()), 127 + if let AppState::Lobby(lobby) = self { 128 + Ok(lobby.clone()) 129 + } else { 130 + Err("Not on lobby screen".to_string()) 113 131 } 114 132 } 115 133 116 134 pub fn get_game(&self) -> Result<Arc<Game>> { 117 - match self { 118 - AppState::Game(game) => Ok(game.clone()), 119 - _ => Err("Not on game screen".to_string()), 135 + if let AppState::Game(game, _) = self { 136 + Ok(game.clone()) 137 + } else { 138 + Err("Not on game screen".to_string()) 139 + } 140 + } 141 + 142 + pub fn get_profiles(&self) -> Result<&HashMap<Uuid, PlayerProfile>> { 143 + if let AppState::Game(_, profiles) = self { 144 + Ok(profiles) 145 + } else { 146 + Err("Not on game screen".to_string()) 147 + } 148 + } 149 + 150 + pub fn get_replay(&self) -> Result<AppGameHistory> { 151 + if let AppState::Replay(history) = self { 152 + Ok(history.clone()) 153 + } else { 154 + Err("Not on replay screen".to_string()) 120 155 } 121 156 } 122 157 123 158 fn emit_screen_change(app: &AppHandle, screen: AppScreen) { 124 159 if let Err(why) = ChangeScreen(screen).emit(app) { 125 160 warn!("Error emitting screen change: {why:?}"); 161 + } 162 + } 163 + 164 + pub fn replay_game(&mut self, app: &AppHandle, id: UtcDT) -> Result { 165 + if let AppState::Menu(_) = self { 166 + let history = AppGameHistory::get_history(app, id) 167 + .context("Failed to read history") 168 + .map_err(|e| e.to_string())?; 169 + *self = AppState::Replay(history); 170 + Self::emit_screen_change(app, AppScreen::Replay); 171 + Ok(()) 172 + } else { 173 + Err("Not on menu screen".to_string()) 126 174 } 127 175 } 128 176 ··· 151 199 let mut state = state_handle.write().await; 152 200 match res { 153 201 Ok((my_id, start)) => { 154 - state.start_game(app_game, my_id, start); 202 + state.start_game(app_game, my_id, start).await; 155 203 } 156 204 Err(why) => { 157 205 error!("Lobby Error: {why:?}"); 158 - state.quit_game_or_lobby(app_game); 206 + state.quit_to_menu(app_game); 159 207 } 160 208 } 161 209 }); ··· 163 211 } 164 212 } 165 213 166 - pub fn quit_game_or_lobby(&mut self, app: AppHandle) { 214 + pub fn quit_to_menu(&mut self, app: AppHandle) { 167 215 let profile = match self { 168 216 AppState::Setup => None, 169 217 AppState::Menu(_) => { ··· 174 222 lobby.quit_lobby(); 175 223 Some(lobby.self_profile.clone()) 176 224 } 177 - AppState::Game(game) => { 225 + AppState::Game(game, _) => { 178 226 game.quit_game(); 179 227 PlayerProfile::load_from_store(&app) 180 228 } 229 + AppState::Replay(_) => PlayerProfile::load_from_store(&app), 181 230 }; 182 231 let screen = if let Some(profile) = profile { 183 232 *self = AppState::Menu(profile); ··· 193 242 194 243 use std::result::Result as StdResult; 195 244 196 - type Result<T = (), E = String> = StdResult<T, E>; 245 + use crate::game::UtcDT; 197 246 198 - #[derive(Serialize, Deserialize, specta::Type, Debug, Clone)] 199 - enum AppScreen { 200 - Setup, 201 - Menu, 202 - Lobby, 203 - Game, 204 - } 247 + type Result<T = (), E = String> = StdResult<T, E>; 205 248 206 249 // == GENERAL / FLOW COMMANDS == 207 250 ··· 214 257 AppState::Setup => AppScreen::Setup, 215 258 AppState::Menu(_player_profile) => AppScreen::Menu, 216 259 AppState::Lobby(_lobby) => AppScreen::Lobby, 217 - AppState::Game(_game) => AppScreen::Game, 260 + AppState::Game(_game, _profiles) => AppScreen::Game, 261 + AppState::Replay(_) => AppScreen::Replay, 218 262 }) 219 263 } 220 264 221 265 #[tauri::command] 222 266 #[specta::specta] 223 267 /// Quit a running game or leave a lobby 224 - async fn quit_game_or_lobby(app: AppHandle, state: State<'_, AppStateHandle>) -> Result { 268 + async fn quit_to_menu(app: AppHandle, state: State<'_, AppStateHandle>) -> Result { 225 269 let mut state = state.write().await; 226 - state.quit_game_or_lobby(app); 270 + state.quit_to_menu(app); 227 271 Ok(()) 228 272 } 229 273 ··· 236 280 let state = state.read().await; 237 281 let profile = state.get_menu()?; 238 282 Ok(profile.clone()) 283 + } 284 + 285 + #[tauri::command] 286 + #[specta::specta] 287 + /// (Screen: Menu) Get a list of all previously played games, returns of list of DateTimes that represent when 288 + /// each game started, use this as a key 289 + fn list_game_histories(app: AppHandle) -> Result<Vec<UtcDT>> { 290 + AppGameHistory::ls_histories(&app) 291 + .map_err(|err| err.context("Failed to get game histories").to_string()) 292 + } 293 + 294 + #[tauri::command] 295 + #[specta::specta] 296 + /// (Screen: Menu) Go to the game replay screen to replay the game history specified by id 297 + async fn replay_game(id: UtcDT, app: AppHandle, state: State<'_, AppStateHandle>) -> Result { 298 + state.write().await.replay_game(&app, id) 239 299 } 240 300 241 301 #[tauri::command] ··· 293 353 #[tauri::command] 294 354 #[specta::specta] 295 355 /// (Screen: Lobby) Switch teams between seekers and hiders, returns the new [LobbyState] 296 - async fn switch_teams(seeker: bool, state: State<'_, AppStateHandle>) -> Result<LobbyState> { 356 + async fn switch_teams(seeker: bool, state: State<'_, AppStateHandle>) -> Result { 297 357 let lobby = state.read().await.get_lobby()?; 298 358 lobby.switch_teams(seeker).await; 299 - Ok(lobby.clone_state().await) 359 + Ok(()) 300 360 } 301 361 302 362 #[tauri::command] 303 363 #[specta::specta] 304 364 /// (Screen: Lobby) HOST ONLY: Push new settings to everyone, does nothing on clients. Returns the 305 365 /// new lobby state 306 - async fn host_update_settings( 307 - settings: GameSettings, 308 - state: State<'_, AppStateHandle>, 309 - ) -> Result<LobbyState> { 366 + async fn host_update_settings(settings: GameSettings, state: State<'_, AppStateHandle>) -> Result { 310 367 let lobby = state.read().await.get_lobby()?; 311 368 lobby.update_settings(settings).await; 312 - Ok(lobby.clone_state().await) 369 + Ok(()) 313 370 } 314 371 315 372 #[tauri::command] ··· 325 382 326 383 #[tauri::command] 327 384 #[specta::specta] 385 + /// (Screen: Game) Get all player profiles with display names and profile pictures for this game 386 + async fn get_profiles(state: State<'_, AppStateHandle>) -> Result<HashMap<Uuid, PlayerProfile>> { 387 + state.read().await.get_profiles().cloned() 388 + } 389 + 390 + #[tauri::command] 391 + #[specta::specta] 328 392 /// (Screen: Game) Mark this player as caught, this player will become a seeker. Returns the new game state 329 - async fn mark_caught(state: State<'_, AppStateHandle>) -> Result<GameState> { 393 + async fn mark_caught(state: State<'_, AppStateHandle>) -> Result { 330 394 let game = state.read().await.get_game()?; 331 395 game.mark_caught().await; 332 - Ok(game.clone_state().await) 396 + Ok(()) 333 397 } 334 398 335 399 #[tauri::command] 336 400 #[specta::specta] 337 401 /// (Screen: Game) Grab a powerup on the map, this should be called when the user is *in range* of 338 402 /// the powerup. Returns the new game state after rolling for the powerup 339 - async fn grab_powerup(state: State<'_, AppStateHandle>) -> Result<GameState> { 403 + async fn grab_powerup(state: State<'_, AppStateHandle>) -> Result { 340 404 let game = state.read().await.get_game()?; 341 405 game.get_powerup().await; 342 - Ok(game.clone_state().await) 406 + Ok(()) 343 407 } 344 408 345 409 #[tauri::command] 346 410 #[specta::specta] 347 411 /// (Screen: Game) Use the currently held powerup in the player's held_powerup. Does nothing if the 348 412 /// player has none. Returns the updated game state 349 - async fn use_powerup(state: State<'_, AppStateHandle>) -> Result<GameState> { 413 + async fn use_powerup(state: State<'_, AppStateHandle>) -> Result { 350 414 let game = state.read().await.get_game()?; 351 415 game.use_powerup().await; 352 - Ok(game.clone_state().await) 416 + Ok(()) 417 + } 418 + 419 + // AppState::Replay COMMANDS 420 + 421 + #[tauri::command] 422 + #[specta::specta] 423 + /// (Screen: Replay) Get the game history that's currently being replayed. Try to limit calls to 424 + /// this 425 + async fn get_current_replay_history(state: State<'_, AppStateHandle>) -> Result<AppGameHistory> { 426 + state.read().await.get_replay() 353 427 } 354 428 355 429 pub fn mk_specta() -> tauri_specta::Builder { ··· 357 431 .commands(collect_commands![ 358 432 start_lobby, 359 433 get_profile, 360 - quit_game_or_lobby, 434 + quit_to_menu, 361 435 get_current_screen, 362 436 update_profile, 363 437 get_lobby_state, ··· 368 442 grab_powerup, 369 443 use_powerup, 370 444 check_room_code, 445 + get_profiles, 446 + replay_game, 447 + list_game_histories, 448 + get_current_replay_history 371 449 ]) 372 450 .events(collect_events![ChangeScreen]) 373 451 }
+5
backend/src/lobby.rs
··· 87 87 self.state.lock().await.clone() 88 88 } 89 89 90 + pub async fn clone_profiles(&self) -> HashMap<Uuid, PlayerProfile> { 91 + let state = self.state.lock().await; 92 + state.profiles.clone() 93 + } 94 + 90 95 /// Set self as seeker or hider 91 96 pub async fn switch_teams(&self, seeker: bool) { 92 97 let mut state = self.state.lock().await;
-2
backend/src/transport.rs
··· 298 298 299 299 _ = cancel.cancelled() => { 300 300 socket.close(); 301 - 302 301 } 303 302 304 303 _ = timer.tick() => { 305 304 // Transport Tick 306 - continue; 307 305 } 308 306 309 307 _ = outgoing_rx.recv_many(&mut buffer, 30) => {
+100 -65
frontend/src/bindings.ts
··· 37 37 /** 38 38 * Quit a running game or leave a lobby 39 39 */ 40 - async quitGameOrLobby(): Promise<Result<null, string>> { 40 + async quitToMenu(): Promise<Result<null, string>> { 41 41 try { 42 - return { status: "ok", data: await TAURI_INVOKE("quit_game_or_lobby") }; 42 + return { status: "ok", data: await TAURI_INVOKE("quit_to_menu") }; 43 43 } catch (e) { 44 44 if (e instanceof Error) throw e; 45 45 else return { status: "error", error: e as any }; ··· 82 82 * (Screen: Lobby) HOST ONLY: Push new settings to everyone, does nothing on clients. Returns the 83 83 * new lobby state 84 84 */ 85 - async hostUpdateSettings(settings: GameSettings): Promise<Result<LobbyState, string>> { 85 + async hostUpdateSettings(settings: GameSettings): Promise<Result<null, string>> { 86 86 try { 87 87 return { status: "ok", data: await TAURI_INVOKE("host_update_settings", { settings }) }; 88 88 } catch (e) { ··· 93 93 /** 94 94 * (Screen: Lobby) Switch teams between seekers and hiders, returns the new [LobbyState] 95 95 */ 96 - async switchTeams(seeker: boolean): Promise<Result<LobbyState, string>> { 96 + async switchTeams(seeker: boolean): Promise<Result<null, string>> { 97 97 try { 98 98 return { status: "ok", data: await TAURI_INVOKE("switch_teams", { seeker }) }; 99 99 } catch (e) { ··· 116 116 /** 117 117 * (Screen: Game) Mark this player as caught, this player will become a seeker. Returns the new game state 118 118 */ 119 - async markCaught(): Promise<Result<GameState, string>> { 119 + async markCaught(): Promise<Result<null, string>> { 120 120 try { 121 121 return { status: "ok", data: await TAURI_INVOKE("mark_caught") }; 122 122 } catch (e) { ··· 128 128 * (Screen: Game) Grab a powerup on the map, this should be called when the user is *in range* of 129 129 * the powerup. Returns the new game state after rolling for the powerup 130 130 */ 131 - async grabPowerup(): Promise<Result<GameState, string>> { 131 + async grabPowerup(): Promise<Result<null, string>> { 132 132 try { 133 133 return { status: "ok", data: await TAURI_INVOKE("grab_powerup") }; 134 134 } catch (e) { ··· 140 140 * (Screen: Game) Use the currently held powerup in the player's held_powerup. Does nothing if the 141 141 * player has none. Returns the updated game state 142 142 */ 143 - async usePowerup(): Promise<Result<GameState, string>> { 143 + async usePowerup(): Promise<Result<null, string>> { 144 144 try { 145 145 return { status: "ok", data: await TAURI_INVOKE("use_powerup") }; 146 146 } catch (e) { ··· 159 159 if (e instanceof Error) throw e; 160 160 else return { status: "error", error: e as any }; 161 161 } 162 + }, 163 + /** 164 + * (Screen: Game) Get all player profiles with display names and profile pictures for this game 165 + */ 166 + async getProfiles(): Promise<Result<Partial<{ [key in string]: PlayerProfile }>, string>> { 167 + try { 168 + return { status: "ok", data: await TAURI_INVOKE("get_profiles") }; 169 + } catch (e) { 170 + if (e instanceof Error) throw e; 171 + else return { status: "error", error: e as any }; 172 + } 173 + }, 174 + /** 175 + * (Screen: Menu) Go to the game replay screen to replay the game history specified by id 176 + */ 177 + async replayGame(id: string): Promise<Result<null, string>> { 178 + try { 179 + return { status: "ok", data: await TAURI_INVOKE("replay_game", { id }) }; 180 + } catch (e) { 181 + if (e instanceof Error) throw e; 182 + else return { status: "error", error: e as any }; 183 + } 184 + }, 185 + /** 186 + * (Screen: Menu) Get a list of all previously played games, returns of list of DateTimes that represent when 187 + * each game started, use this as a key 188 + */ 189 + async listGameHistories(): Promise<Result<string[], string>> { 190 + try { 191 + return { status: "ok", data: await TAURI_INVOKE("list_game_histories") }; 192 + } catch (e) { 193 + if (e instanceof Error) throw e; 194 + else return { status: "error", error: e as any }; 195 + } 196 + }, 197 + /** 198 + * (Screen: Replay) Get the game history that's currently being replayed. Try to limit calls to 199 + * this 200 + */ 201 + async getCurrentReplayHistory(): Promise<Result<AppGameHistory, string>> { 202 + try { 203 + return { status: "ok", data: await TAURI_INVOKE("get_current_replay_history") }; 204 + } catch (e) { 205 + if (e instanceof Error) throw e; 206 + else return { status: "error", error: e as any }; 207 + } 162 208 } 163 209 }; 164 210 ··· 174 220 175 221 /** user-defined types **/ 176 222 177 - export type AppScreen = "Setup" | "Menu" | "Lobby" | "Game"; 223 + export type AppGameHistory = { 224 + history: GameHistory; 225 + profiles: Partial<{ [key in string]: PlayerProfile }>; 226 + }; 227 + export type AppScreen = "Setup" | "Menu" | "Lobby" | "Game" | "Replay"; 178 228 export type ChangeScreen = AppScreen; 179 229 /** 230 + * An event used between players to update state 231 + */ 232 + export type GameEvent = 233 + /** 234 + * A player has been caught and is now a seeker, contains the ID of the caught player 235 + */ 236 + | { PlayerCaught: string } 237 + /** 238 + * Public ping from a player revealing location 239 + */ 240 + | { Ping: PlayerPing } 241 + /** 242 + * Force the player specified in `0` to ping, optionally display the ping as from the user 243 + * specified in `1`. 244 + */ 245 + | { ForcePing: [string, string | null] } 246 + /** 247 + * Force a powerup to despawn because a player got it, contains the player that got it. 248 + */ 249 + | { PowerupDespawn: string } 250 + /** 251 + * Contains location history of the given player, used after the game to sync location 252 + * histories 253 + */ 254 + | { PostGameSync: [string, [string, Location][]] } 255 + /** 256 + * A player has been disconnected and removed from the game (because of error or otherwise). 257 + * The player should be removed from all state 258 + */ 259 + | { DroppedPlayer: string } 260 + /** 261 + * The underlying transport has disconnected 262 + */ 263 + | "TransportDisconnect"; 264 + export type GameHistory = { 265 + my_id: string; 266 + game_started: string; 267 + game_ended: string; 268 + events: [string, GameEvent][]; 269 + locations: [string, [string, Location][]][]; 270 + }; 271 + /** 180 272 * Settings for the game, host is the only person able to change these 181 273 */ 182 274 export type GameSettings = { ··· 214 306 */ 215 307 powerup_locations: Location[]; 216 308 }; 217 - /** 218 - * This struct handles all logic regarding state updates 219 - */ 220 - export type GameState = { 221 - /** 222 - * The id of this player in this game 223 - */ 224 - id: string; 225 - /** 226 - * The powerup the player is currently holding 227 - */ 228 - held_powerup: PowerUpType | null; 229 - /** 230 - * When the game started 231 - */ 232 - game_started: string; 233 - /** 234 - * When seekers were allowed to begin 235 - */ 236 - seekers_started: string | null; 237 - /** 238 - * Last time we pinged all players 239 - */ 240 - last_global_ping: string | null; 241 - /** 242 - * Last time a powerup was spawned 243 - */ 244 - last_powerup_spawn: string | null; 245 - /** 246 - * Hashmap tracking if a player is a seeker (true) or a hider (false) 247 - */ 248 - caught_state: Partial<{ [key in string]: boolean }>; 249 - /** 250 - * A map of the latest global ping results for each player 251 - */ 252 - pings: Partial<{ [key in string]: PlayerPing }>; 253 - /** 254 - * Powerup on the map that players can grab. Only one at a time 255 - */ 256 - available_powerup: Location | null; 257 - }; 258 309 export type LobbyState = { 259 310 profiles: Partial<{ [key in string]: PlayerProfile }>; 260 311 join_code: string; ··· 320 371 real_player: string; 321 372 }; 322 373 export type PlayerProfile = { display_name: string; pfp_base64: string | null }; 323 - /** 324 - * Type of powerup 325 - */ 326 - export type PowerUpType = 327 - /** 328 - * Ping a random seeker instead of a hider 329 - */ 330 - | "PingSeeker" 331 - /** 332 - * Pings all seekers locations on the map for hiders 333 - */ 334 - | "PingAllSeekers" 335 - /** 336 - * Ping another random hider instantly 337 - */ 338 - | "ForcePingOther"; 339 374 340 375 /** tauri-specta globals **/ 341 376