Live location tracking and playback for the game "manhunt"

Better state sync, transport error handling

bwc9876.dev 2b2a6622 134abbba

verified
+183 -263
+1
TODO.md
··· 20 20 - [x] Signaling: All of it 21 21 - [x] Backend : Better transport error handling 22 22 - [ ] Backend : Abstract lobby? Separate crate? 23 + - [x] Transport : Handle transport cancellation better 23 24 - [x] Backend : Add checks for when the `powerup_locations` field is an empty array in settings 24 25 - [ ] Backend : More tests
+13 -8
backend/src/game/mod.rs
··· 117 117 } 118 118 } 119 119 120 - async fn consume_event(&self, state: &mut GameState, event: GameEvent) -> Result { 120 + async fn consume_event(&self, state: &mut GameState, event: GameEvent) -> Result<bool> { 121 121 if !state.game_ended() { 122 122 state.event_history.push((Utc::now(), event.clone())); 123 123 } ··· 126 126 GameEvent::Ping(player_ping) => state.add_ping(player_ping), 127 127 GameEvent::ForcePing(target, display) => { 128 128 if target != state.id { 129 - return Ok(()); 129 + return Ok(false); 130 130 } 131 131 132 132 let ping = if let Some(display) = display { ··· 149 149 state.remove_player(id); 150 150 } 151 151 GameEvent::TransportDisconnect => { 152 - bail!("Transport disconnected"); 152 + return Ok(true); 153 153 } 154 154 GameEvent::TransportError(err) => { 155 155 bail!("Transport error: {err}"); ··· 161 161 162 162 self.state_update_sender.send_update(); 163 163 164 - Ok(()) 164 + Ok(false) 165 165 } 166 166 167 167 /// Perform a tick for a specific moment in time ··· 253 253 } 254 254 255 255 /// Main loop of the game, handles ticking and receiving messages from [Transport]. 256 - pub async fn main_loop(&self) -> Result<GameHistory> { 256 + pub async fn main_loop(&self) -> Result<Option<GameHistory>> { 257 257 let mut interval = tokio::time::interval(self.interval); 258 258 259 259 interval.set_missed_tick_behavior(MissedTickBehavior::Delay); ··· 265 265 events = self.transport.receive_messages() => { 266 266 let mut state = self.state.write().await; 267 267 for event in events { 268 - if let Err(why) = self.consume_event(&mut state, event).await { 269 - break 'game Err(why); 268 + match self.consume_event(&mut state, event).await { 269 + Ok(should_break) => { 270 + if should_break { 271 + break 'game Ok(None); 272 + } 273 + } 274 + Err(why) => { break 'game Err(why); } 270 275 } 271 276 } 272 277 } ··· 277 282 278 283 if should_break { 279 284 let history = state.as_game_history(); 280 - break Ok(history); 285 + break Ok(Some(history)); 281 286 } 282 287 } 283 288 }
+18 -8
backend/src/lib.rs
··· 17 17 use serde::{Deserialize, Serialize}; 18 18 use tauri::{AppHandle, Manager, State}; 19 19 use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; 20 - use tauri_specta::{collect_commands, collect_events, Event}; 20 + use tauri_specta::{collect_commands, collect_events, ErrorHandlingMode, Event}; 21 21 use tokio::sync::RwLock; 22 22 use transport::MatchboxTransport; 23 23 use uuid::Uuid; ··· 104 104 let state_handle = app.state::<AppStateHandle>(); 105 105 let mut state = state_handle.write().await; 106 106 match res { 107 - Ok(history) => { 107 + Ok(Some(history)) => { 108 108 let history = AppGameHistory::new(history, profiles); 109 109 if let Err(why) = history.save_history(&app2) { 110 110 error!("Failed to save game history: {why:?}"); ··· 115 115 } 116 116 state.quit_to_menu(app2); 117 117 } 118 + Ok(None) => { 119 + info!("User quit game"); 120 + } 118 121 Err(why) => { 119 122 error!("Game Error: {why:?}"); 120 - app2.dialog().message("There was a connection error in the game, you have been disconnected").kind(MessageDialogKind::Error).show(|_| {}); 123 + app2.dialog() 124 + .message(format!("Connection Error: {why}")) 125 + .kind(MessageDialogKind::Error) 126 + .show(|_| {}); 121 127 state.quit_to_menu(app2); 122 128 } 123 129 } ··· 225 231 let state_handle = app2.state::<AppStateHandle>(); 226 232 let mut state = state_handle.write().await; 227 233 match res { 228 - Ok((my_id, start)) => { 234 + Ok(Some((my_id, start))) => { 229 235 info!("Starting game as {my_id}"); 230 236 state.start_game(app_game, my_id, start).await; 237 + } 238 + Ok(None) => { 239 + info!("User quit lobby"); 231 240 } 232 241 Err(why) => { 233 - error!("Lobby Error: {why:?}"); 242 + error!("Lobby Error: {why}"); 234 243 app_game 235 244 .dialog() 236 - .message("Error joining the lobby") 245 + .message(format!("Error joining the lobby: {why}")) 237 246 .kind(MessageDialogKind::Error) 238 247 .show(|_| {}); 239 248 state.quit_to_menu(app_game); ··· 470 479 #[specta::specta] 471 480 /// (Screen: Game) Use the currently held powerup in the player's held_powerup. Does nothing if the 472 481 /// player has none. Returns the updated game state 473 - async fn use_powerup(state: State<'_, AppStateHandle>) -> Result { 482 + async fn activate_powerup(state: State<'_, AppStateHandle>) -> Result { 474 483 let game = state.read().await.get_game()?; 475 484 game.use_powerup().await; 476 485 Ok(()) ··· 488 497 489 498 pub fn mk_specta() -> tauri_specta::Builder { 490 499 tauri_specta::Builder::<tauri::Wry>::new() 500 + .error_handling(ErrorHandlingMode::Throw) 491 501 .commands(collect_commands![ 492 502 start_lobby, 493 503 get_profile, ··· 500 510 host_start_game, 501 511 mark_caught, 502 512 grab_powerup, 503 - use_powerup, 513 + activate_powerup, 504 514 check_room_code, 505 515 get_profiles, 506 516 replay_game,
+3 -5
backend/src/lobby.rs
··· 162 162 self.transport.cancel(); 163 163 } 164 164 165 - pub async fn open(&self) -> Result<(Uuid, StartGameInfo)> { 165 + pub async fn open(&self) -> Result<Option<(Uuid, StartGameInfo)>> { 166 166 let transport_inner = self.transport.clone(); 167 167 tokio::spawn(async move { transport_inner.transport_loop().await }); 168 168 ··· 181 181 state.profiles.insert(id, self.self_profile.clone()); 182 182 } 183 183 TransportMessage::Disconnected => { 184 - break 'lobby Err(anyhow!( 185 - "Transport disconnected unexpectedly before lobby could start game" 186 - )); 184 + break 'lobby Ok(None); 187 185 } 188 186 TransportMessage::Error(why) => { 189 187 break 'lobby Err(anyhow!("Transport error: {why}")); ··· 207 205 .await 208 206 .self_id 209 207 .expect("Error getting self ID"); 210 - break 'lobby Ok((id, start_game_info)); 208 + break 'lobby Ok(Some((id, start_game_info))); 211 209 } 212 210 LobbyMessage::PlayerSwitch(seeker) => { 213 211 let mut state = self.state.lock().await;
+33 -15
backend/src/transport.rs
··· 6 6 use anyhow::Context; 7 7 use futures::FutureExt; 8 8 use log::error; 9 - use matchbox_socket::{PeerId, PeerState, WebRtcSocket}; 9 + use matchbox_socket::{Error as SocketError, PeerId, PeerState, WebRtcSocket}; 10 10 use serde::{Deserialize, Serialize}; 11 11 use tokio::sync::{Mutex, RwLock}; 12 12 use tokio_util::sync::CancellationToken; ··· 206 206 pub async fn transport_loop(&self) { 207 207 let (mut socket, loop_fut) = WebRtcSocket::new_reliable(&self.ws_url); 208 208 209 - let loop_fut = loop_fut.fuse(); 209 + let loop_fut = async { 210 + let msg = match loop_fut.await { 211 + Ok(_) => TransportMessage::Disconnected, 212 + Err(e) => { 213 + let msg = match e { 214 + SocketError::ConnectionFailed(e) => { 215 + format!("Failed to connect to server: {e}") 216 + } 217 + SocketError::Disconnected(e) => { 218 + format!("Disconnected from server, network error or kick: {e}") 219 + } 220 + }; 221 + TransportMessage::Error(msg) 222 + } 223 + }; 224 + self.push_incoming(self.my_id.read().await.unwrap_or_default(), msg) 225 + .await; 226 + } 227 + .fuse(); 228 + 210 229 tokio::pin!(loop_fut); 211 230 212 231 let mut all_peers = HashSet::<PeerId>::with_capacity(20); ··· 310 329 let mut buffer = Vec::with_capacity(30); 311 330 312 331 tokio::select! { 313 - 314 332 _ = self.cancel_token.cancelled() => { 315 - socket.close(); 333 + // Break if cancelled externally 334 + break; 316 335 } 317 336 318 - _ = timer.tick() => { 319 - // Transport Tick 337 + _ = &mut loop_fut => { 338 + // Break if disconnected 339 + break; 320 340 } 321 341 342 + // Rerun every tick 343 + _ = timer.tick() => {} 344 + 322 345 _ = outgoing_rx.recv_many(&mut buffer, 30) => { 323 - 346 + // Handle sending new messages 324 347 self.handle_send(&mut socket, &all_peers, &mut buffer).await; 325 348 } 326 - 327 - res = &mut loop_fut => { 328 - // Break on disconnect 329 - if let Err(why) = res { 330 - self.push_incoming(my_id.unwrap_or_default(), TransportMessage::Error(why.to_string())).await; 331 - } 332 - break; 333 - } 334 349 } 335 350 } 351 + 352 + drop(socket); 353 + loop_fut.await 336 354 } 337 355 } 338 356
+40 -146
frontend/src/bindings.ts
··· 9 9 * (Screen: Menu) Start/Join a new lobby, set `join_code` to `null` to be host, 10 10 * set it to a join code to be a client. This triggers a screen change to [AppScreen::Lobby] 11 11 */ 12 - async startLobby( 13 - joinCode: string | null, 14 - settings: GameSettings 15 - ): Promise<Result<null, string>> { 16 - try { 17 - return { 18 - status: "ok", 19 - data: await TAURI_INVOKE("start_lobby", { joinCode, settings }) 20 - }; 21 - } catch (e) { 22 - if (e instanceof Error) throw e; 23 - else return { status: "error", error: e as any }; 24 - } 12 + async startLobby(joinCode: string | null, settings: GameSettings): Promise<null> { 13 + return await TAURI_INVOKE("start_lobby", { joinCode, settings }); 25 14 }, 26 15 /** 27 16 * (Screen: Menu) Get the user's player profile 28 17 */ 29 - async getProfile(): Promise<Result<PlayerProfile, string>> { 30 - try { 31 - return { status: "ok", data: await TAURI_INVOKE("get_profile") }; 32 - } catch (e) { 33 - if (e instanceof Error) throw e; 34 - else return { status: "error", error: e as any }; 35 - } 18 + async getProfile(): Promise<PlayerProfile> { 19 + return await TAURI_INVOKE("get_profile"); 36 20 }, 37 21 /** 38 22 * Quit a running game or leave a lobby 39 23 */ 40 - async quitToMenu(): Promise<Result<null, string>> { 41 - try { 42 - return { status: "ok", data: await TAURI_INVOKE("quit_to_menu") }; 43 - } catch (e) { 44 - if (e instanceof Error) throw e; 45 - else return { status: "error", error: e as any }; 46 - } 24 + async quitToMenu(): Promise<null> { 25 + return await TAURI_INVOKE("quit_to_menu"); 47 26 }, 48 27 /** 49 28 * Get the screen the app should currently be on, returns [AppScreen] 50 29 */ 51 - async getCurrentScreen(): Promise<Result<AppScreen, string>> { 52 - try { 53 - return { status: "ok", data: await TAURI_INVOKE("get_current_screen") }; 54 - } catch (e) { 55 - if (e instanceof Error) throw e; 56 - else return { status: "error", error: e as any }; 57 - } 30 + async getCurrentScreen(): Promise<AppScreen> { 31 + return await TAURI_INVOKE("get_current_screen"); 58 32 }, 59 33 /** 60 34 * (Screen: Menu) Update the player's profile and persist it 61 35 */ 62 - async updateProfile(newProfile: PlayerProfile): Promise<Result<null, string>> { 63 - try { 64 - return { status: "ok", data: await TAURI_INVOKE("update_profile", { newProfile }) }; 65 - } catch (e) { 66 - if (e instanceof Error) throw e; 67 - else return { status: "error", error: e as any }; 68 - } 36 + async updateProfile(newProfile: PlayerProfile): Promise<null> { 37 + return await TAURI_INVOKE("update_profile", { newProfile }); 69 38 }, 70 39 /** 71 40 * (Screen: Lobby) Get the current state of the lobby, call after receiving an update event 72 41 */ 73 - async getLobbyState(): Promise<Result<LobbyState, string>> { 74 - try { 75 - return { status: "ok", data: await TAURI_INVOKE("get_lobby_state") }; 76 - } catch (e) { 77 - if (e instanceof Error) throw e; 78 - else return { status: "error", error: e as any }; 79 - } 42 + async getLobbyState(): Promise<LobbyState> { 43 + return await TAURI_INVOKE("get_lobby_state"); 80 44 }, 81 45 /** 82 46 * (Screen: Lobby) HOST ONLY: Push new settings to everyone, does nothing on clients. Returns the 83 47 * new lobby state 84 48 */ 85 - async hostUpdateSettings(settings: GameSettings): Promise<Result<null, string>> { 86 - try { 87 - return { status: "ok", data: await TAURI_INVOKE("host_update_settings", { settings }) }; 88 - } catch (e) { 89 - if (e instanceof Error) throw e; 90 - else return { status: "error", error: e as any }; 91 - } 49 + async hostUpdateSettings(settings: GameSettings): Promise<null> { 50 + return await TAURI_INVOKE("host_update_settings", { settings }); 92 51 }, 93 52 /** 94 53 * (Screen: Lobby) Switch teams between seekers and hiders, returns the new [LobbyState] 95 54 */ 96 - async switchTeams(seeker: boolean): Promise<Result<null, string>> { 97 - try { 98 - return { status: "ok", data: await TAURI_INVOKE("switch_teams", { seeker }) }; 99 - } catch (e) { 100 - if (e instanceof Error) throw e; 101 - else return { status: "error", error: e as any }; 102 - } 55 + async switchTeams(seeker: boolean): Promise<null> { 56 + return await TAURI_INVOKE("switch_teams", { seeker }); 103 57 }, 104 58 /** 105 59 * (Screen: Lobby) HOST ONLY: Start the game, stops anyone else from joining and switched screen 106 60 * to AppScreen::Game. 107 61 */ 108 - async hostStartGame(): Promise<Result<null, string>> { 109 - try { 110 - return { status: "ok", data: await TAURI_INVOKE("host_start_game") }; 111 - } catch (e) { 112 - if (e instanceof Error) throw e; 113 - else return { status: "error", error: e as any }; 114 - } 62 + async hostStartGame(): Promise<null> { 63 + return await TAURI_INVOKE("host_start_game"); 115 64 }, 116 65 /** 117 66 * (Screen: Game) Mark this player as caught, this player will become a seeker. Returns the new game state 118 67 */ 119 - async markCaught(): Promise<Result<null, string>> { 120 - try { 121 - return { status: "ok", data: await TAURI_INVOKE("mark_caught") }; 122 - } catch (e) { 123 - if (e instanceof Error) throw e; 124 - else return { status: "error", error: e as any }; 125 - } 68 + async markCaught(): Promise<null> { 69 + return await TAURI_INVOKE("mark_caught"); 126 70 }, 127 71 /** 128 72 * (Screen: Game) Grab a powerup on the map, this should be called when the user is *in range* of 129 73 * the powerup. Returns the new game state after rolling for the powerup 130 74 */ 131 - async grabPowerup(): Promise<Result<null, string>> { 132 - try { 133 - return { status: "ok", data: await TAURI_INVOKE("grab_powerup") }; 134 - } catch (e) { 135 - if (e instanceof Error) throw e; 136 - else return { status: "error", error: e as any }; 137 - } 75 + async grabPowerup(): Promise<null> { 76 + return await TAURI_INVOKE("grab_powerup"); 138 77 }, 139 78 /** 140 79 * (Screen: Game) Use the currently held powerup in the player's held_powerup. Does nothing if the 141 80 * player has none. Returns the updated game state 142 81 */ 143 - async usePowerup(): Promise<Result<null, string>> { 144 - try { 145 - return { status: "ok", data: await TAURI_INVOKE("use_powerup") }; 146 - } catch (e) { 147 - if (e instanceof Error) throw e; 148 - else return { status: "error", error: e as any }; 149 - } 82 + async activatePowerup(): Promise<null> { 83 + return await TAURI_INVOKE("activate_powerup"); 150 84 }, 151 85 /** 152 86 * (Screen: Menu) Check if a room code is valid to join, use this before starting a game 153 87 * for faster error checking. 154 88 */ 155 - async checkRoomCode(code: string): Promise<Result<boolean, string>> { 156 - try { 157 - return { status: "ok", data: await TAURI_INVOKE("check_room_code", { code }) }; 158 - } catch (e) { 159 - if (e instanceof Error) throw e; 160 - else return { status: "error", error: e as any }; 161 - } 89 + async checkRoomCode(code: string): Promise<boolean> { 90 + return await TAURI_INVOKE("check_room_code", { code }); 162 91 }, 163 92 /** 164 93 * (Screen: Game) Get all player profiles with display names and profile pictures for this game. 165 94 * This value will never change and is fairly expensive to clone, so please minimize calls to 166 95 * this command. 167 96 */ 168 - async getProfiles(): Promise<Result<Partial<{ [key in string]: PlayerProfile }>, string>> { 169 - try { 170 - return { status: "ok", data: await TAURI_INVOKE("get_profiles") }; 171 - } catch (e) { 172 - if (e instanceof Error) throw e; 173 - else return { status: "error", error: e as any }; 174 - } 97 + async getProfiles(): Promise<Partial<{ [key in string]: PlayerProfile }>> { 98 + return await TAURI_INVOKE("get_profiles"); 175 99 }, 176 100 /** 177 101 * (Screen: Menu) Go to the game replay screen to replay the game history specified by id 178 102 */ 179 - async replayGame(id: string): Promise<Result<null, string>> { 180 - try { 181 - return { status: "ok", data: await TAURI_INVOKE("replay_game", { id }) }; 182 - } catch (e) { 183 - if (e instanceof Error) throw e; 184 - else return { status: "error", error: e as any }; 185 - } 103 + async replayGame(id: string): Promise<null> { 104 + return await TAURI_INVOKE("replay_game", { id }); 186 105 }, 187 106 /** 188 107 * (Screen: Menu) Get a list of all previously played games, returns of list of DateTimes that represent when 189 108 * each game started, use this as a key 190 109 */ 191 - async listGameHistories(): Promise<Result<string[], string>> { 192 - try { 193 - return { status: "ok", data: await TAURI_INVOKE("list_game_histories") }; 194 - } catch (e) { 195 - if (e instanceof Error) throw e; 196 - else return { status: "error", error: e as any }; 197 - } 110 + async listGameHistories(): Promise<string[]> { 111 + return await TAURI_INVOKE("list_game_histories"); 198 112 }, 199 113 /** 200 114 * (Screen: Replay) Get the game history that's currently being replayed. Try to limit calls to 201 115 * this 202 116 */ 203 - async getCurrentReplayHistory(): Promise<Result<AppGameHistory, string>> { 204 - try { 205 - return { status: "ok", data: await TAURI_INVOKE("get_current_replay_history") }; 206 - } catch (e) { 207 - if (e instanceof Error) throw e; 208 - else return { status: "error", error: e as any }; 209 - } 117 + async getCurrentReplayHistory(): Promise<AppGameHistory> { 118 + return await TAURI_INVOKE("get_current_replay_history"); 210 119 }, 211 120 /** 212 121 * (Screen: Game) Get the current settings for this game. 213 122 */ 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 - } 123 + async getGameSettings(): Promise<GameSettings> { 124 + return await TAURI_INVOKE("get_game_settings"); 221 125 }, 222 126 /** 223 127 * (Screen: Game) Get the current state of the game. 224 128 */ 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 - } 129 + async getGameState(): Promise<GameUiState> { 130 + return await TAURI_INVOKE("get_game_state"); 232 131 }, 233 132 /** 234 133 * (Screen: Setup) Complete user setup and go to the menu screen 235 134 */ 236 - async completeSetup(profile: PlayerProfile): Promise<Result<null, string>> { 237 - try { 238 - return { status: "ok", data: await TAURI_INVOKE("complete_setup", { profile }) }; 239 - } catch (e) { 240 - if (e instanceof Error) throw e; 241 - else return { status: "error", error: e as any }; 242 - } 135 + async completeSetup(profile: PlayerProfile): Promise<null> { 136 + return await TAURI_INVOKE("complete_setup", { profile }); 243 137 } 244 138 }; 245 139
+3 -6
frontend/src/components/App.tsx
··· 1 1 import React from "react"; 2 2 import useSWR from "swr"; 3 3 import { AppScreen, commands } from "@/bindings"; 4 - import { useTauriEvent } from "@/lib/hooks"; 5 - import { unwrapResult } from "@/lib/result"; 4 + import { useTauriEvent, sharedSwrConfig } from "@/lib/hooks"; 6 5 import SetupScreen from "./SetupScreen"; 7 6 import MenuScreen from "./MenuScreen"; 8 7 import LobbyScreen from "./LobbyScreen"; ··· 26 25 export default function App() { 27 26 const { data: screen, mutate } = useSWR( 28 27 "fetch-screen", 29 - async () => { 30 - return unwrapResult(await commands.getCurrentScreen()); 31 - }, 32 - { suspense: true, dedupingInterval: 100 } 28 + commands.getCurrentScreen, 29 + sharedSwrConfig 33 30 ); 34 31 35 32 useTauriEvent("changeScreen", (newScreen) => {
+14 -18
frontend/src/components/GameScreen.tsx
··· 1 1 import React from "react"; 2 2 import { commands } from "@/bindings"; 3 - import { useTauriEvent } from "@/lib/hooks"; 3 + import { sharedSwrConfig, useTauriEvent } from "@/lib/hooks"; 4 4 import useSWR from "swr"; 5 - import { unwrapResult } from "@/lib/result"; 6 5 7 6 export default function GameScreen() { 8 - const profiles = unwrapResult(React.use(commands.getProfiles())); 7 + const { data: profiles } = useSWR("game-get-profiles", commands.getProfiles); 8 + 9 9 const { data: gameState, mutate } = useSWR( 10 10 "fetch-game-state", 11 - async () => { 12 - return unwrapResult(await commands.getGameState()); 13 - }, 14 - { 15 - suspense: true, 16 - dedupingInterval: 100 17 - } 11 + commands.getGameState, 12 + sharedSwrConfig 18 13 ); 19 14 20 15 useTauriEvent("gameStateUpdate", () => { ··· 25 20 26 21 const markCaught = async () => { 27 22 if (!isSeeker) { 28 - unwrapResult(await commands.markCaught()); 23 + await commands.markCaught(); 29 24 } 30 25 }; 31 26 32 27 const grabPowerup = async () => { 33 28 if (gameState.available_powerup !== null) { 34 - unwrapResult(await commands.grabPowerup()); 29 + await commands.grabPowerup(); 35 30 } 36 31 }; 37 32 38 - const usePowerup = async () => { 33 + const activatePowerup = async () => { 39 34 if (gameState.held_powerup !== null && gameState.held_powerup !== "PingSeeker") { 40 - unwrapResult(await commands.usePowerup()); 35 + await commands.activatePowerup(); 41 36 } 42 37 }; 43 38 44 39 const quitToMenu = async () => { 45 - unwrapResult(await commands.quitToMenu()); 40 + await commands.quitToMenu(); 46 41 }; 47 42 48 43 if (gameState.game_ended) { ··· 56 51 {Object.keys(gameState.caught_state) 57 52 .filter((k) => !gameState.caught_state[k]) 58 53 .map((key) => ( 59 - <li key={key}>{profiles[key]?.display_name ?? key}</li> 54 + <li key={key}>{profiles?.[key]?.display_name ?? key}</li> 60 55 ))} 61 56 {!isSeeker && <button onClick={markCaught}>I got caught!</button>} 62 57 <h2>Pings</h2> ··· 67 62 .filter(([key, v]) => key && v !== undefined) 68 63 .map(([k, v]) => ( 69 64 <li key={k}> 70 - {profiles[v!.display_player]?.display_name ?? v!.display_player} 65 + {profiles?.[v!.display_player]?.display_name ?? 66 + v!.display_player} 71 67 : {v && JSON.stringify(v.loc)} 72 68 </li> 73 69 ))} ··· 90 86 Held Powerup: {gameState.held_powerup} 91 87 {(gameState.held_powerup === "PingSeeker" && ( 92 88 <small>(Will be used next ping)</small> 93 - )) || <button onClick={usePowerup}>Use</button>} 89 + )) || <button onClick={activatePowerup}>Use</button>} 94 90 </p> 95 91 )} 96 92 <h2>Quit</h2>
+6 -12
frontend/src/components/LobbyScreen.tsx
··· 1 1 import React from "react"; 2 2 import { commands } from "@/bindings"; 3 - import { useTauriEvent } from "@/lib/hooks"; 3 + import { sharedSwrConfig, useTauriEvent } from "@/lib/hooks"; 4 4 import useSWR from "swr"; 5 - import { unwrapResult } from "@/lib/result"; 6 5 7 6 export default function LobbyScreen() { 8 7 const { data: lobbyState, mutate } = useSWR( 9 8 "fetch-lobby-state", 10 - async () => { 11 - return unwrapResult(await commands.getLobbyState()); 12 - }, 13 - { 14 - suspense: true, 15 - dedupingInterval: 100 16 - } 9 + commands.getLobbyState, 10 + sharedSwrConfig 17 11 ); 18 12 19 13 useTauriEvent("lobbyStateUpdate", () => { ··· 21 15 }); 22 16 23 17 const setSeeker = async (seeker: boolean) => { 24 - unwrapResult(await commands.switchTeams(seeker)); 18 + await commands.switchTeams(seeker); 25 19 }; 26 20 27 21 const startGame = async () => { 28 - unwrapResult(await commands.hostStartGame()); 22 + await commands.hostStartGame(); 29 23 }; 30 24 31 25 const quit = async () => { 32 - unwrapResult(await commands.quitToMenu()); 26 + await commands.quitToMenu(); 33 27 }; 34 28 35 29 if (lobbyState.self_id === null) {
+23 -29
frontend/src/components/MenuScreen.tsx
··· 1 1 import { commands, GameSettings } from "@/bindings"; 2 - import { unwrapResult } from "@/lib/result"; 2 + import { sharedSwrConfig } from "@/lib/hooks"; 3 3 import React from "react"; 4 + import useSWR from "swr"; 4 5 5 6 // Temp settings for now. 6 7 const settings: GameSettings = { ··· 20 21 ] 21 22 }; 22 23 23 - function MainMenu({ 24 - profilePromise, 25 - historyPromise 26 - }: { 27 - profilePromise: ReturnType<typeof commands.getProfile>; 28 - historyPromise: ReturnType<typeof commands.listGameHistories>; 29 - }) { 30 - const initialProfile = unwrapResult(React.use(profilePromise)); 31 - const gameHistory = unwrapResult(React.use(historyPromise)); 32 - const [profile, setProfile] = React.useState(initialProfile); 33 - const [newName, setName] = React.useState(initialProfile.display_name); 24 + export default function MenuScreen() { 34 25 const [roomCode, setRoomCode] = React.useState(""); 26 + const [newName, setName] = React.useState(""); 27 + 28 + const { data: profile, mutate: setProfile } = useSWR( 29 + "fetch-profile", 30 + commands.getProfile, 31 + sharedSwrConfig 32 + ); 33 + const { data: gameHistory } = useSWR( 34 + "list-game-history", 35 + commands.listGameHistories, 36 + sharedSwrConfig 37 + ); 35 38 36 39 const onStartGame = async (code: string | null) => { 37 40 if (code) { 38 41 try { 39 - const validCode = unwrapResult(await commands.checkRoomCode(code)); 42 + const validCode = await commands.checkRoomCode(code); 40 43 if (!validCode) { 41 44 window.alert("Invalid Join Code"); 42 45 return; ··· 50 53 }; 51 54 52 55 const onSaveProfile = async () => { 53 - unwrapResult(await commands.updateProfile({ ...profile, display_name: newName })); 54 - setProfile((p) => { 55 - return { ...p, display_name: newName }; 56 - }); 56 + await commands.updateProfile({ ...profile, display_name: newName }); 57 + setProfile({ ...profile, display_name: newName }); 57 58 }; 58 59 59 60 return ( ··· 77 78 </div> 78 79 <hr /> 79 80 <h3>Edit Profile</h3> 80 - <input value={newName} onChange={(e) => setName(e.target.value)} /> 81 + <input 82 + placeholder={profile.display_name} 83 + value={newName} 84 + onChange={(e) => setName(e.target.value)} 85 + /> 81 86 <button onClick={onSaveProfile}>Save</button> 82 87 <hr /> 83 88 <h3>Previous Games</h3> ··· 89 94 </> 90 95 ); 91 96 } 92 - 93 - export default function MenuScreen() { 94 - const profilePromise = commands.getProfile(); 95 - const previousGamesPromise = commands.listGameHistories(); 96 - 97 - return ( 98 - <React.Suspense fallback={<p>Loading profile</p>}> 99 - <MainMenu profilePromise={profilePromise} historyPromise={previousGamesPromise} /> 100 - </React.Suspense> 101 - ); 102 - }
+1 -2
frontend/src/components/SetupScreen.tsx
··· 1 1 import React from "react"; 2 2 import { commands, PlayerProfile } from "@/bindings"; 3 - import { unwrapResult } from "@/lib/result"; 4 3 5 4 export default function SetupScreen() { 6 5 const [displayName, setName] = React.useState("User"); 7 6 8 7 const onSave = async () => { 9 8 const profile = { display_name: displayName, pfp_base64: null } as PlayerProfile; 10 - unwrapResult(await commands.completeSetup(profile)); 9 + await commands.completeSetup(profile); 11 10 }; 12 11 13 12 return (
+8 -3
frontend/src/lib/hooks.ts
··· 1 1 import { useEffect } from "react"; 2 2 import { events } from "@/bindings"; 3 + import { SWRConfiguration } from "swr"; 3 4 4 5 type ExtractCallback<E extends keyof typeof events> = ( 5 6 payload: Parameters<Parameters<(typeof events)[E]["listen"]>[0]>[0]["payload"] 6 7 ) => void; 7 8 9 + type SWRConfigTyp = { suspense: true } & SWRConfiguration; 10 + 11 + export const sharedSwrConfig: SWRConfigTyp = { suspense: true, dedupingInterval: 100 }; 12 + 8 13 /** 9 14 * Convenience hook that does useEffect for a Tauri event and handles unsubscribing on unmount 10 15 */ 11 - export const useTauriEvent = <E extends keyof typeof events>( 16 + export function useTauriEvent<E extends keyof typeof events>( 12 17 tauriEvent: E, 13 18 cb: ExtractCallback<E> 14 - ) => { 19 + ) { 15 20 useEffect(() => { 16 21 const unlisten = events[tauriEvent].listen((e) => { 17 22 cb(e.payload); ··· 21 26 unlisten.then((f) => f()); 22 27 }; 23 28 }, [tauriEvent, cb]); 24 - }; 29 + }
-10
frontend/src/lib/result.ts
··· 1 - import { Result } from "@/bindings.ts"; 2 - 3 - export const unwrapResult = <T, E>(res: Result<T, E>): T => { 4 - switch (res.status) { 5 - case "ok": 6 - return res.data; 7 - case "error": 8 - throw res.error; 9 - } 10 - };
+19
frontend/src/main.tsx
··· 3 3 4 4 const App = React.lazy(() => import("@/components/App")); 5 5 6 + import { warn, debug, trace, info, error } from "@tauri-apps/plugin-log"; 7 + 8 + function forwardConsole( 9 + fnName: "log" | "debug" | "info" | "warn" | "error", 10 + logger: (message: string) => Promise<void> 11 + ) { 12 + const original = console[fnName]; 13 + console[fnName] = (message) => { 14 + original(message); 15 + logger(message); 16 + }; 17 + } 18 + 19 + forwardConsole("log", trace); 20 + forwardConsole("debug", debug); 21 + forwardConsole("info", info); 22 + forwardConsole("warn", warn); 23 + forwardConsole("error", error); 24 + 6 25 ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 7 26 <React.StrictMode> 8 27 <React.Suspense>
+1 -1
frontend/vite.config.ts
··· 4 4 import react from "@vitejs/plugin-react"; 5 5 import path from "path"; 6 6 7 - const host = process.env.TAURI_DEV_HOST; 7 + const host = process.env.HOST_OVERRIDE || process.env.TAURI_DEV_HOST; 8 8 9 9 export default defineConfig(async () => ({ 10 10 plugins: [react()],