Live location tracking and playback for the game "manhunt"
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Make state update events, simplify transportation cancellation

Ben C 01357c7f d3ce5b93

+266 -39
+1 -1
TODO.md
··· 11 11 - [x] State : Event history tracking 12 12 - [x] State : Post game sync 13 13 - [x] API : Handling Profile Syncing 14 - - [ ] API : State Update Events 14 + - [x] API : State Update Events 15 15 - [x] API : Game Replay Screen 16 16 - [ ] Frontend : Scaffolding 17 17 - [x] Meta : CI Setup
+1 -1
backend/src/export_types.rs
··· 10 10 let mut lang = Typescript::new(); 11 11 lang.header = Cow::Borrowed("/* eslint @typescript-eslint/no-unused-vars: 0 */\n/* eslint @typescript-eslint/no-explicit-any: 0 */"); 12 12 specta.export(lang, path).expect("Failed to export types"); 13 - println!("Successfully exported type and commands to {path}",); 13 + println!("Successfully exported types, events, and commands to {path}",); 14 14 }
+45 -12
backend/src/game/mod.rs
··· 3 3 use powerups::PowerUpType; 4 4 pub use settings::GameSettings; 5 5 use std::{collections::HashMap, sync::Arc, time::Duration}; 6 - use tokio_util::sync::CancellationToken; 7 6 use uuid::Uuid; 8 7 9 8 use tokio::{sync::RwLock, time::MissedTickBehavior}; ··· 18 17 use crate::prelude::*; 19 18 20 19 pub use location::{Location, LocationService}; 21 - pub use state::{GameHistory, GameState}; 20 + pub use state::{GameHistory, GameState, GameUiState}; 22 21 pub use transport::Transport; 23 22 24 23 pub type Id = Uuid; ··· 26 25 /// Convenence alias for UTC DT 27 26 pub type UtcDT = DateTime<Utc>; 28 27 28 + pub trait StateUpdateSender { 29 + fn send_update(&self); 30 + } 31 + 29 32 /// Struct representing an ongoing game, handles communication with 30 33 /// other clients via [Transport], gets location with [LocationService], and provides high-level methods for 31 34 /// taking actions in the game. 32 - pub struct Game<L: LocationService, T: Transport> { 35 + pub struct Game<L: LocationService, T: Transport, S: StateUpdateSender> { 33 36 state: RwLock<GameState>, 34 37 transport: Arc<T>, 35 38 location: L, 39 + state_update_sender: S, 36 40 interval: Duration, 37 - transport_cancel_token: CancellationToken, 38 41 } 39 42 40 - impl<L: LocationService, T: Transport> Game<L, T> { 43 + impl<L: LocationService, T: Transport, S: StateUpdateSender> Game<L, T, S> { 41 44 pub fn new( 42 45 my_id: Id, 43 46 interval: Duration, ··· 45 48 settings: GameSettings, 46 49 transport: Arc<T>, 47 50 location: L, 48 - transport_cancel_token: CancellationToken, 51 + state_update_sender: S, 49 52 ) -> Self { 50 53 let state = GameState::new(settings, my_id, initial_caught_state); 51 54 52 55 Self { 53 56 transport, 54 - transport_cancel_token, 55 57 location, 56 58 interval, 57 59 state: RwLock::new(state), 60 + state_update_sender, 58 61 } 59 62 } 60 63 ··· 69 72 self.transport 70 73 .send_message(GameEvent::PlayerCaught(state.id)) 71 74 .await; 75 + } 76 + 77 + pub async fn clone_settings(&self) -> GameSettings { 78 + self.state.read().await.clone_settings() 79 + } 80 + 81 + pub async fn get_ui_state(&self) -> GameUiState { 82 + self.state.read().await.as_ui_state() 72 83 } 73 84 74 85 pub async fn get_powerup(&self) { ··· 145 156 } 146 157 } 147 158 159 + self.state_update_sender.send_update(); 160 + 148 161 Ok(()) 149 162 } 150 163 151 164 /// Perform a tick for a specific moment in time 152 165 /// Returns whether the game loop should be broken. 153 166 async fn tick(&self, state: &mut GameState, now: UtcDT) -> bool { 167 + let mut send_update = false; 168 + 154 169 if state.check_end_game() { 155 170 // If we're at the point where the game is over, send out our location history 156 171 let msg = GameEvent::PostGameSync(state.id, state.location_history.clone()); 157 172 self.transport.send_message(msg).await; 173 + send_update = true; 158 174 } 159 175 160 176 if state.game_ended() { 161 177 // Don't do normal ticks if the game is over, 162 178 // simply return if we're done doing a post-game sync 163 - 179 + if send_update { 180 + self.state_update_sender.send_update(); 181 + } 164 182 return state.check_post_game_sync(); 165 183 } 166 184 ··· 172 190 // Release Seekers? 173 191 if !state.seekers_released() && state.should_release_seekers(now) { 174 192 state.release_seekers(now); 193 + send_update = true; 175 194 } 176 195 177 196 // Start Pings? 178 197 if !state.pings_started() && state.should_start_pings(now) { 179 198 state.start_pings(now); 199 + send_update = true; 180 200 } 181 201 182 202 // Do a Ping? ··· 202 222 // Start Powerup Rolls? 203 223 if !state.powerups_started() && state.should_start_powerups(now) { 204 224 state.start_powerups(now); 225 + send_update = true; 205 226 } 206 227 207 228 // Should roll for a powerup? 208 229 if state.should_spawn_powerup(&now) { 209 230 state.try_spawn_powerup(now); 231 + send_update = true; 232 + } 233 + 234 + // Send a state update to the UI? 235 + if send_update { 236 + self.state_update_sender.send_update(); 210 237 } 211 238 212 239 false ··· 219 246 } 220 247 221 248 pub fn quit_game(&self) { 222 - self.transport_cancel_token.cancel(); 249 + self.transport.disconnect(); 223 250 } 224 251 225 252 /// Main loop of the game, handles ticking and receiving messages from [Transport]. ··· 253 280 } 254 281 }; 255 282 256 - self.transport_cancel_token.cancel(); 283 + self.transport.disconnect(); 257 284 258 285 res 259 286 } ··· 303 330 } 304 331 } 305 332 306 - type TestGame = Game<MockLocation, MockTransport>; 333 + struct DummySender; 334 + 335 + impl StateUpdateSender for DummySender { 336 + fn send_update(&self) {} 337 + } 338 + 339 + type TestGame = Game<MockLocation, MockTransport, DummySender>; 307 340 308 341 struct MockMatch { 309 342 uuids: Vec<Uuid>, ··· 348 381 settings.clone(), 349 382 Arc::new(transport), 350 383 location, 351 - CancellationToken::new(), 384 + DummySender, 352 385 ); 353 386 354 387 (id as u32, Arc::new(game))
+38
backend/src/game/state.rs
··· 387 387 game_ended: self.game_ended.unwrap_or_default(), 388 388 } 389 389 } 390 + 391 + pub fn as_ui_state(&self) -> GameUiState { 392 + GameUiState { 393 + my_id: self.id, 394 + caught_state: self.caught_state.clone(), 395 + available_powerup: self.available_powerup, 396 + pings: self.pings.clone(), 397 + game_started: self.game_started, 398 + last_global_ping: self.last_global_ping, 399 + held_powerup: self.held_powerup, 400 + seekers_started: self.seekers_started, 401 + } 402 + } 403 + 404 + pub fn clone_settings(&self) -> GameSettings { 405 + self.settings.clone() 406 + } 390 407 } 391 408 392 409 #[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] ··· 397 414 events: Vec<(UtcDT, GameEvent)>, 398 415 locations: Vec<(Uuid, Vec<(UtcDT, Location)>)>, 399 416 } 417 + 418 + /// Subset of [GameState] that is meant to be sent to a UI frontend 419 + #[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] 420 + pub struct GameUiState { 421 + /// ID of the local player 422 + my_id: Uuid, 423 + /// A map of player IDs to whether that player is a seeker 424 + caught_state: HashMap<Uuid, bool>, 425 + /// A powerup that is available on the map 426 + available_powerup: Option<Location>, 427 + /// A map of player IDs to an active ping on them 428 + pings: HashMap<Uuid, PlayerPing>, 429 + /// When the game was started **in UTC** 430 + game_started: UtcDT, 431 + /// The last time all hiders were pinged **in UTC** 432 + last_global_ping: Option<UtcDT>, 433 + /// The [PowerUpType] the local player is holding 434 + held_powerup: Option<PowerUpType>, 435 + /// When the seekers were allowed to start **in UTC** 436 + seekers_started: Option<UtcDT>, 437 + }
+2
backend/src/game/transport.rs
··· 5 5 async fn receive_messages(&self) -> impl Iterator<Item = GameEvent>; 6 6 /// Send an event 7 7 async fn send_message(&self, msg: GameEvent); 8 + /// Disconnect from the transport 9 + fn disconnect(&self) {} 8 10 }
+46 -6
backend/src/lib.rs
··· 30 30 31 31 use prelude::*; 32 32 33 - type Game = BaseGame<TauriLocation, MatchboxTransport>; 33 + type Game = BaseGame<TauriLocation, MatchboxTransport, TauriStateUpdateSender>; 34 34 35 35 enum AppState { 36 36 Setup, ··· 68 68 } 69 69 } 70 70 71 + /// The app is changing screens, contains the screen it's switching to 71 72 #[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] 72 73 struct ChangeScreen(AppScreen); 73 74 75 + /// The state of the game has updated in some way, you're expected to call [get_game_state] when 76 + /// receiving this 77 + #[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] 78 + struct GameStateUpdate; 79 + 80 + struct TauriStateUpdateSender(AppHandle); 81 + 82 + impl StateUpdateSender for TauriStateUpdateSender { 83 + fn send_update(&self) { 84 + if let Err(why) = GameStateUpdate.emit(&self.0) { 85 + error!("Error sending Game state update to UI: {why:?}"); 86 + } 87 + } 88 + } 89 + 74 90 impl AppState { 75 91 pub async fn start_game(&mut self, app: AppHandle, my_id: Uuid, start: StartGameInfo) { 76 92 if let AppState::Lobby(lobby) = self { 77 93 let transport = lobby.clone_transport(); 78 94 let profiles = lobby.clone_profiles().await; 79 95 let location = TauriLocation::new(app.clone()); 96 + let state_updates = TauriStateUpdateSender(app.clone()); 80 97 let game = Arc::new(Game::new( 81 98 my_id, 82 99 GAME_TICK_RATE, ··· 84 101 start.settings, 85 102 transport, 86 103 location, 87 - lobby.clone_cancel(), 104 + state_updates, 88 105 )); 89 106 *self = AppState::Game(game.clone(), profiles.clone()); 90 107 tokio::spawn(async move { ··· 189 206 host, 190 207 profile.clone(), 191 208 settings, 209 + app.clone(), 192 210 )); 193 211 *self = AppState::Lobby(lobby.clone()); 194 212 let app2 = app.clone(); ··· 242 260 243 261 use std::result::Result as StdResult; 244 262 245 - use crate::game::UtcDT; 263 + use crate::game::{GameUiState, StateUpdateSender, UtcDT}; 246 264 247 265 type Result<T = (), E = String> = StdResult<T, E>; 248 266 ··· 382 400 383 401 #[tauri::command] 384 402 #[specta::specta] 385 - /// (Screen: Game) Get all player profiles with display names and profile pictures for this game 403 + /// (Screen: Game) Get all player profiles with display names and profile pictures for this game. 404 + /// This value will never change and is fairly expensive to clone, so please minimize calls to 405 + /// this command. 386 406 async fn get_profiles(state: State<'_, AppStateHandle>) -> Result<HashMap<Uuid, PlayerProfile>> { 387 407 state.read().await.get_profiles().cloned() 408 + } 409 + 410 + #[tauri::command] 411 + #[specta::specta] 412 + /// (Screen: Game) Get the current settings for this game. 413 + async fn get_game_settings(state: State<'_, AppStateHandle>) -> Result<GameSettings> { 414 + Ok(state.read().await.get_game()?.clone_settings().await) 415 + } 416 + 417 + #[tauri::command] 418 + #[specta::specta] 419 + /// (Screen: Game) Get the current state of the game. 420 + async fn get_game_state(state: State<'_, AppStateHandle>) -> Result<GameUiState> { 421 + Ok(state.read().await.get_game()?.get_ui_state().await) 388 422 } 389 423 390 424 #[tauri::command] ··· 445 479 get_profiles, 446 480 replay_game, 447 481 list_game_histories, 448 - get_current_replay_history 482 + get_current_replay_history, 483 + get_game_settings, 484 + get_game_state, 449 485 ]) 450 - .events(collect_events![ChangeScreen]) 486 + .events(collect_events![ 487 + ChangeScreen, 488 + GameStateUpdate, 489 + lobby::LobbyStateUpdate 490 + ]) 451 491 } 452 492 453 493 #[cfg_attr(mobile, tauri::mobile_entry_point)]
+25 -15
backend/src/lobby.rs
··· 1 1 use std::{collections::HashMap, sync::Arc, time::Duration}; 2 2 3 - use log::warn; 3 + use log::{error, warn}; 4 4 use serde::{Deserialize, Serialize}; 5 + use tauri::AppHandle; 6 + use tauri_specta::Event; 5 7 use tokio::sync::Mutex; 6 - use tokio_util::sync::CancellationToken; 7 8 use uuid::Uuid; 8 9 9 10 use crate::{ ··· 48 49 pub self_profile: PlayerProfile, 49 50 state: Mutex<LobbyState>, 50 51 transport: Arc<MatchboxTransport>, 51 - cancel_token: CancellationToken, 52 + app: AppHandle, 52 53 } 53 54 55 + /// The lobby state has updated in some way, you're expected to call [get_lobby_state] after 56 + /// receiving this 57 + #[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] 58 + pub struct LobbyStateUpdate; 59 + 54 60 impl Lobby { 55 61 pub fn new( 56 62 ws_url_base: &str, ··· 58 64 host: bool, 59 65 profile: PlayerProfile, 60 66 settings: GameSettings, 67 + app: AppHandle, 61 68 ) -> Self { 62 - let cancel_token = CancellationToken::new(); 63 69 Self { 70 + app, 64 71 transport: Arc::new(MatchboxTransport::new(&format!( 65 72 "{ws_url_base}/{join_code}{}", 66 73 if host { "?create" } else { "" } 67 74 ))), 68 - cancel_token, 69 75 is_host: host, 70 76 self_profile: profile, 71 77 join_code: join_code.to_string(), ··· 79 85 } 80 86 } 81 87 88 + fn emit_state_update(&self) { 89 + if let Err(why) = LobbyStateUpdate.emit(&self.app) { 90 + error!("Error emitting Lobby state update: {why:?}"); 91 + } 92 + } 93 + 82 94 pub fn clone_transport(&self) -> Arc<MatchboxTransport> { 83 95 self.transport.clone() 84 96 } ··· 100 112 self.transport 101 113 .send_transport_message(None, LobbyMessage::PlayerSwitch(seeker).into()) 102 114 .await; 115 + self.emit_state_update(); 103 116 } 104 117 105 118 /// (Host) Update game settings ··· 110 123 drop(state); 111 124 let msg = LobbyMessage::HostPush(new_settings); 112 125 self.send_transport_message(None, msg).await; 126 + self.emit_state_update(); 113 127 } 114 128 } 115 129 ··· 141 155 if let Err(why) = self.singaling_mark_started().await { 142 156 warn!("Failed to tell signalling server that the match started: {why:?}"); 143 157 } 158 + self.emit_state_update(); 144 159 } 145 160 } 146 161 } 147 162 148 - pub fn clone_cancel(&self) -> CancellationToken { 149 - self.cancel_token.clone() 150 - } 151 - 152 163 pub fn quit_lobby(&self) { 153 - self.cancel_token.cancel(); 164 + self.transport.cancel(); 154 165 } 155 166 156 167 pub async fn open(&self) -> Result<(Uuid, StartGameInfo)> { 157 168 let transport_inner = self.transport.clone(); 158 - tokio::spawn({ 159 - let cancel = self.cancel_token.clone(); 160 - async move { transport_inner.transport_loop(cancel).await } 161 - }); 169 + tokio::spawn(async move { transport_inner.transport_loop().await }); 162 170 163 171 let mut interval = tokio::time::interval(Duration::from_secs(1)); 164 172 165 173 let res = 'lobby: loop { 174 + self.emit_state_update(); 175 + 166 176 interval.tick().await; 167 177 168 178 let msgs = self.transport.recv_transport_messages().await; ··· 224 234 }; 225 235 226 236 if res.is_err() { 227 - self.cancel_token.cancel(); 237 + self.transport.cancel(); 228 238 } 229 239 230 240 res
+12 -2
backend/src/transport.rs
··· 120 120 incoming: (IncomingQueueSender, Mutex<IncomingQueueReceiver>), 121 121 outgoing: (OutgoingQueueSender, Mutex<OutgoingQueueReceiver>), 122 122 my_id: RwLock<Option<Uuid>>, 123 + cancel_token: CancellationToken, 123 124 } 124 125 125 126 impl MatchboxTransport { ··· 132 133 incoming: (itx, Mutex::new(irx)), 133 134 outgoing: (otx, Mutex::new(orx)), 134 135 my_id: RwLock::new(None), 136 + cancel_token: CancellationToken::new(), 135 137 } 136 138 } 137 139 ··· 190 192 } 191 193 } 192 194 193 - pub async fn transport_loop(&self, cancel: CancellationToken) { 195 + pub fn cancel(&self) { 196 + self.cancel_token.cancel(); 197 + } 198 + 199 + pub async fn transport_loop(&self) { 194 200 let (mut socket, loop_fut) = WebRtcSocket::new_reliable(&self.ws_url); 195 201 196 202 let loop_fut = loop_fut.fuse(); ··· 296 302 297 303 tokio::select! { 298 304 299 - _ = cancel.cancelled() => { 305 + _ = self.cancel_token.cancelled() => { 300 306 socket.close(); 301 307 } 302 308 ··· 331 337 332 338 async fn send_message(&self, msg: GameEvent) { 333 339 self.send_transport_message(None, msg.into()).await; 340 + } 341 + 342 + fn disconnect(&self) { 343 + self.cancel(); 334 344 } 335 345 }
+96 -2
frontend/src/bindings.ts
··· 161 161 } 162 162 }, 163 163 /** 164 - * (Screen: Game) Get all player profiles with display names and profile pictures for this game 164 + * (Screen: Game) Get all player profiles with display names and profile pictures for this game. 165 + * This value will never change and is fairly expensive to clone, so please minimize calls to 166 + * this command. 165 167 */ 166 168 async getProfiles(): Promise<Result<Partial<{ [key in string]: PlayerProfile }>, string>> { 167 169 try { ··· 205 207 if (e instanceof Error) throw e; 206 208 else return { status: "error", error: e as any }; 207 209 } 210 + }, 211 + /** 212 + * (Screen: Game) Get the current settings for this game. 213 + */ 214 + async getGameSettings(): Promise<Result<GameSettings, string>> { 215 + try { 216 + return { status: "ok", data: await TAURI_INVOKE("get_game_settings") }; 217 + } catch (e) { 218 + if (e instanceof Error) throw e; 219 + else return { status: "error", error: e as any }; 220 + } 221 + }, 222 + /** 223 + * (Screen: Game) Get the current state of the game. 224 + */ 225 + async getGameState(): Promise<Result<GameUiState, string>> { 226 + try { 227 + return { status: "ok", data: await TAURI_INVOKE("get_game_state") }; 228 + } catch (e) { 229 + if (e instanceof Error) throw e; 230 + else return { status: "error", error: e as any }; 231 + } 208 232 } 209 233 }; 210 234 ··· 212 236 213 237 export const events = __makeEvents__<{ 214 238 changeScreen: ChangeScreen; 239 + gameStateUpdate: GameStateUpdate; 240 + lobbyStateUpdate: LobbyStateUpdate; 215 241 }>({ 216 - changeScreen: "change-screen" 242 + changeScreen: "change-screen", 243 + gameStateUpdate: "game-state-update", 244 + lobbyStateUpdate: "lobby-state-update" 217 245 }); 218 246 219 247 /** user-defined constants **/ ··· 225 253 profiles: Partial<{ [key in string]: PlayerProfile }>; 226 254 }; 227 255 export type AppScreen = "Setup" | "Menu" | "Lobby" | "Game" | "Replay"; 256 + /** 257 + * The app is changing screens, contains the screen it's switching to 258 + */ 228 259 export type ChangeScreen = AppScreen; 229 260 /** 230 261 * An event used between players to update state ··· 306 337 */ 307 338 powerup_locations: Location[]; 308 339 }; 340 + /** 341 + * The state of the game has updated in some way, you're expected to call [get_game_state] when 342 + * receiving this 343 + */ 344 + export type GameStateUpdate = null; 345 + /** 346 + * Subset of [GameState] that is meant to be sent to a UI frontend 347 + */ 348 + export type GameUiState = { 349 + /** 350 + * ID of the local player 351 + */ 352 + my_id: string; 353 + /** 354 + * A map of player IDs to whether that player is a seeker 355 + */ 356 + caught_state: Partial<{ [key in string]: boolean }>; 357 + /** 358 + * A powerup that is available on the map 359 + */ 360 + available_powerup: Location | null; 361 + /** 362 + * A map of player IDs to an active ping on them 363 + */ 364 + pings: Partial<{ [key in string]: PlayerPing }>; 365 + /** 366 + * When the game was started **in UTC** 367 + */ 368 + game_started: string; 369 + /** 370 + * The last time all hiders were pinged **in UTC** 371 + */ 372 + last_global_ping: string | null; 373 + /** 374 + * The [PowerUpType] the local player is holding 375 + */ 376 + held_powerup: PowerUpType | null; 377 + /** 378 + * When the seekers were allowed to start **in UTC** 379 + */ 380 + seekers_started: string | null; 381 + }; 309 382 export type LobbyState = { 310 383 profiles: Partial<{ [key in string]: PlayerProfile }>; 311 384 join_code: string; ··· 316 389 self_seeker: boolean; 317 390 settings: GameSettings; 318 391 }; 392 + /** 393 + * The lobby state has updated in some way, you're expected to call [get_lobby_state] after 394 + * receiving this 395 + */ 396 + export type LobbyStateUpdate = null; 319 397 /** 320 398 * Some location in the world as gotten from a Geolocation API 321 399 */ ··· 371 449 real_player: string; 372 450 }; 373 451 export type PlayerProfile = { display_name: string; pfp_base64: string | null }; 452 + /** 453 + * Type of powerup 454 + */ 455 + export type PowerUpType = 456 + /** 457 + * Ping a random seeker instead of a hider 458 + */ 459 + | "PingSeeker" 460 + /** 461 + * Pings all seekers locations on the map for hiders 462 + */ 463 + | "PingAllSeekers" 464 + /** 465 + * Ping another random hider instantly 466 + */ 467 + | "ForcePingOther"; 374 468 375 469 /** tauri-specta globals **/ 376 470