···20- [x] Signaling: All of it
21- [x] Backend : Better transport error handling
22- [ ] Backend : Abstract lobby? Separate crate?
023- [x] Backend : Add checks for when the `powerup_locations` field is an empty array in settings
24- [ ] Backend : More tests
···20- [x] Signaling: All of it
21- [x] Backend : Better transport error handling
22- [ ] Backend : Abstract lobby? Separate crate?
23+- [x] Transport : Handle transport cancellation better
24- [x] Backend : Add checks for when the `powerup_locations` field is an empty array in settings
25- [ ] Backend : More tests
+13-8
backend/src/game/mod.rs
···117 }
118 }
119120- async fn consume_event(&self, state: &mut GameState, event: GameEvent) -> Result {
121 if !state.game_ended() {
122 state.event_history.push((Utc::now(), event.clone()));
123 }
···126 GameEvent::Ping(player_ping) => state.add_ping(player_ping),
127 GameEvent::ForcePing(target, display) => {
128 if target != state.id {
129- return Ok(());
130 }
131132 let ping = if let Some(display) = display {
···149 state.remove_player(id);
150 }
151 GameEvent::TransportDisconnect => {
152- bail!("Transport disconnected");
153 }
154 GameEvent::TransportError(err) => {
155 bail!("Transport error: {err}");
···161162 self.state_update_sender.send_update();
163164- Ok(())
165 }
166167 /// Perform a tick for a specific moment in time
···253 }
254255 /// Main loop of the game, handles ticking and receiving messages from [Transport].
256- pub async fn main_loop(&self) -> Result<GameHistory> {
257 let mut interval = tokio::time::interval(self.interval);
258259 interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
···265 events = self.transport.receive_messages() => {
266 let mut state = self.state.write().await;
267 for event in events {
268- if let Err(why) = self.consume_event(&mut state, event).await {
269- break 'game Err(why);
00000270 }
271 }
272 }
···277278 if should_break {
279 let history = state.as_game_history();
280- break Ok(history);
281 }
282 }
283 }
···117 }
118 }
119120+ async fn consume_event(&self, state: &mut GameState, event: GameEvent) -> Result<bool> {
121 if !state.game_ended() {
122 state.event_history.push((Utc::now(), event.clone()));
123 }
···126 GameEvent::Ping(player_ping) => state.add_ping(player_ping),
127 GameEvent::ForcePing(target, display) => {
128 if target != state.id {
129+ return Ok(false);
130 }
131132 let ping = if let Some(display) = display {
···149 state.remove_player(id);
150 }
151 GameEvent::TransportDisconnect => {
152+ return Ok(true);
153 }
154 GameEvent::TransportError(err) => {
155 bail!("Transport error: {err}");
···161162 self.state_update_sender.send_update();
163164+ Ok(false)
165 }
166167 /// Perform a tick for a specific moment in time
···253 }
254255 /// Main loop of the game, handles ticking and receiving messages from [Transport].
256+ pub async fn main_loop(&self) -> Result<Option<GameHistory>> {
257 let mut interval = tokio::time::interval(self.interval);
258259 interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
···265 events = self.transport.receive_messages() => {
266 let mut state = self.state.write().await;
267 for event in events {
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); }
275 }
276 }
277 }
···282283 if should_break {
284 let history = state.as_game_history();
285+ break Ok(Some(history));
286 }
287 }
288 }
+18-8
backend/src/lib.rs
···17use serde::{Deserialize, Serialize};
18use tauri::{AppHandle, Manager, State};
19use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
20-use tauri_specta::{collect_commands, collect_events, Event};
21use tokio::sync::RwLock;
22use transport::MatchboxTransport;
23use uuid::Uuid;
···104 let state_handle = app.state::<AppStateHandle>();
105 let mut state = state_handle.write().await;
106 match res {
107- Ok(history) => {
108 let history = AppGameHistory::new(history, profiles);
109 if let Err(why) = history.save_history(&app2) {
110 error!("Failed to save game history: {why:?}");
···115 }
116 state.quit_to_menu(app2);
117 }
000118 Err(why) => {
119 error!("Game Error: {why:?}");
120- app2.dialog().message("There was a connection error in the game, you have been disconnected").kind(MessageDialogKind::Error).show(|_| {});
000121 state.quit_to_menu(app2);
122 }
123 }
···225 let state_handle = app2.state::<AppStateHandle>();
226 let mut state = state_handle.write().await;
227 match res {
228- Ok((my_id, start)) => {
229 info!("Starting game as {my_id}");
230 state.start_game(app_game, my_id, start).await;
000231 }
232 Err(why) => {
233- error!("Lobby Error: {why:?}");
234 app_game
235 .dialog()
236- .message("Error joining the lobby")
237 .kind(MessageDialogKind::Error)
238 .show(|_| {});
239 state.quit_to_menu(app_game);
···470#[specta::specta]
471/// (Screen: Game) Use the currently held powerup in the player's held_powerup. Does nothing if the
472/// player has none. Returns the updated game state
473-async fn use_powerup(state: State<'_, AppStateHandle>) -> Result {
474 let game = state.read().await.get_game()?;
475 game.use_powerup().await;
476 Ok(())
···488489pub fn mk_specta() -> tauri_specta::Builder {
490 tauri_specta::Builder::<tauri::Wry>::new()
0491 .commands(collect_commands![
492 start_lobby,
493 get_profile,
···500 host_start_game,
501 mark_caught,
502 grab_powerup,
503- use_powerup,
504 check_room_code,
505 get_profiles,
506 replay_game,
···17use serde::{Deserialize, Serialize};
18use tauri::{AppHandle, Manager, State};
19use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
20+use tauri_specta::{collect_commands, collect_events, ErrorHandlingMode, Event};
21use tokio::sync::RwLock;
22use transport::MatchboxTransport;
23use uuid::Uuid;
···104 let state_handle = app.state::<AppStateHandle>();
105 let mut state = state_handle.write().await;
106 match res {
107+ Ok(Some(history)) => {
108 let history = AppGameHistory::new(history, profiles);
109 if let Err(why) = history.save_history(&app2) {
110 error!("Failed to save game history: {why:?}");
···115 }
116 state.quit_to_menu(app2);
117 }
118+ Ok(None) => {
119+ info!("User quit game");
120+ }
121 Err(why) => {
122 error!("Game Error: {why:?}");
123+ app2.dialog()
124+ .message(format!("Connection Error: {why}"))
125+ .kind(MessageDialogKind::Error)
126+ .show(|_| {});
127 state.quit_to_menu(app2);
128 }
129 }
···231 let state_handle = app2.state::<AppStateHandle>();
232 let mut state = state_handle.write().await;
233 match res {
234+ Ok(Some((my_id, start))) => {
235 info!("Starting game as {my_id}");
236 state.start_game(app_game, my_id, start).await;
237+ }
238+ Ok(None) => {
239+ info!("User quit lobby");
240 }
241 Err(why) => {
242+ error!("Lobby Error: {why}");
243 app_game
244 .dialog()
245+ .message(format!("Error joining the lobby: {why}"))
246 .kind(MessageDialogKind::Error)
247 .show(|_| {});
248 state.quit_to_menu(app_game);
···479#[specta::specta]
480/// (Screen: Game) Use the currently held powerup in the player's held_powerup. Does nothing if the
481/// player has none. Returns the updated game state
482+async fn activate_powerup(state: State<'_, AppStateHandle>) -> Result {
483 let game = state.read().await.get_game()?;
484 game.use_powerup().await;
485 Ok(())
···497498pub fn mk_specta() -> tauri_specta::Builder {
499 tauri_specta::Builder::<tauri::Wry>::new()
500+ .error_handling(ErrorHandlingMode::Throw)
501 .commands(collect_commands![
502 start_lobby,
503 get_profile,
···510 host_start_game,
511 mark_caught,
512 grab_powerup,
513+ activate_powerup,
514 check_room_code,
515 get_profiles,
516 replay_game,
···9 * (Screen: Menu) Start/Join a new lobby, set `join_code` to `null` to be host,
10 * set it to a join code to be a client. This triggers a screen change to [AppScreen::Lobby]
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- }
25 },
26 /**
27 * (Screen: Menu) Get the user's player profile
28 */
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- }
36 },
37 /**
38 * Quit a running game or leave a lobby
39 */
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- }
47 },
48 /**
49 * Get the screen the app should currently be on, returns [AppScreen]
50 */
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- }
58 },
59 /**
60 * (Screen: Menu) Update the player's profile and persist it
61 */
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- }
69 },
70 /**
71 * (Screen: Lobby) Get the current state of the lobby, call after receiving an update event
72 */
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- }
80 },
81 /**
82 * (Screen: Lobby) HOST ONLY: Push new settings to everyone, does nothing on clients. Returns the
83 * new lobby state
84 */
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- }
92 },
93 /**
94 * (Screen: Lobby) Switch teams between seekers and hiders, returns the new [LobbyState]
95 */
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- }
103 },
104 /**
105 * (Screen: Lobby) HOST ONLY: Start the game, stops anyone else from joining and switched screen
106 * to AppScreen::Game.
107 */
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- }
115 },
116 /**
117 * (Screen: Game) Mark this player as caught, this player will become a seeker. Returns the new game state
118 */
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- }
126 },
127 /**
128 * (Screen: Game) Grab a powerup on the map, this should be called when the user is *in range* of
129 * the powerup. Returns the new game state after rolling for the powerup
130 */
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- }
138 },
139 /**
140 * (Screen: Game) Use the currently held powerup in the player's held_powerup. Does nothing if the
141 * player has none. Returns the updated game state
142 */
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- }
150 },
151 /**
152 * (Screen: Menu) Check if a room code is valid to join, use this before starting a game
153 * for faster error checking.
154 */
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- }
162 },
163 /**
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.
167 */
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- }
175 },
176 /**
177 * (Screen: Menu) Go to the game replay screen to replay the game history specified by id
178 */
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- }
186 },
187 /**
188 * (Screen: Menu) Get a list of all previously played games, returns of list of DateTimes that represent when
189 * each game started, use this as a key
190 */
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- }
198 },
199 /**
200 * (Screen: Replay) Get the game history that's currently being replayed. Try to limit calls to
201 * this
202 */
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- }
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- }
232 },
233 /**
234 * (Screen: Setup) Complete user setup and go to the menu screen
235 */
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- }
243 }
244};
245
···9 * (Screen: Menu) Start/Join a new lobby, set `join_code` to `null` to be host,
10 * set it to a join code to be a client. This triggers a screen change to [AppScreen::Lobby]
11 */
12+ async startLobby(joinCode: string | null, settings: GameSettings): Promise<null> {
13+ return await TAURI_INVOKE("start_lobby", { joinCode, settings });
0000000000014 },
15 /**
16 * (Screen: Menu) Get the user's player profile
17 */
18+ async getProfile(): Promise<PlayerProfile> {
19+ return await TAURI_INVOKE("get_profile");
0000020 },
21 /**
22 * Quit a running game or leave a lobby
23 */
24+ async quitToMenu(): Promise<null> {
25+ return await TAURI_INVOKE("quit_to_menu");
0000026 },
27 /**
28 * Get the screen the app should currently be on, returns [AppScreen]
29 */
30+ async getCurrentScreen(): Promise<AppScreen> {
31+ return await TAURI_INVOKE("get_current_screen");
0000032 },
33 /**
34 * (Screen: Menu) Update the player's profile and persist it
35 */
36+ async updateProfile(newProfile: PlayerProfile): Promise<null> {
37+ return await TAURI_INVOKE("update_profile", { newProfile });
0000038 },
39 /**
40 * (Screen: Lobby) Get the current state of the lobby, call after receiving an update event
41 */
42+ async getLobbyState(): Promise<LobbyState> {
43+ return await TAURI_INVOKE("get_lobby_state");
0000044 },
45 /**
46 * (Screen: Lobby) HOST ONLY: Push new settings to everyone, does nothing on clients. Returns the
47 * new lobby state
48 */
49+ async hostUpdateSettings(settings: GameSettings): Promise<null> {
50+ return await TAURI_INVOKE("host_update_settings", { settings });
0000051 },
52 /**
53 * (Screen: Lobby) Switch teams between seekers and hiders, returns the new [LobbyState]
54 */
55+ async switchTeams(seeker: boolean): Promise<null> {
56+ return await TAURI_INVOKE("switch_teams", { seeker });
0000057 },
58 /**
59 * (Screen: Lobby) HOST ONLY: Start the game, stops anyone else from joining and switched screen
60 * to AppScreen::Game.
61 */
62+ async hostStartGame(): Promise<null> {
63+ return await TAURI_INVOKE("host_start_game");
0000064 },
65 /**
66 * (Screen: Game) Mark this player as caught, this player will become a seeker. Returns the new game state
67 */
68+ async markCaught(): Promise<null> {
69+ return await TAURI_INVOKE("mark_caught");
0000070 },
71 /**
72 * (Screen: Game) Grab a powerup on the map, this should be called when the user is *in range* of
73 * the powerup. Returns the new game state after rolling for the powerup
74 */
75+ async grabPowerup(): Promise<null> {
76+ return await TAURI_INVOKE("grab_powerup");
0000077 },
78 /**
79 * (Screen: Game) Use the currently held powerup in the player's held_powerup. Does nothing if the
80 * player has none. Returns the updated game state
81 */
82+ async activatePowerup(): Promise<null> {
83+ return await TAURI_INVOKE("activate_powerup");
0000084 },
85 /**
86 * (Screen: Menu) Check if a room code is valid to join, use this before starting a game
87 * for faster error checking.
88 */
89+ async checkRoomCode(code: string): Promise<boolean> {
90+ return await TAURI_INVOKE("check_room_code", { code });
0000091 },
92 /**
93 * (Screen: Game) Get all player profiles with display names and profile pictures for this game.
94 * This value will never change and is fairly expensive to clone, so please minimize calls to
95 * this command.
96 */
97+ async getProfiles(): Promise<Partial<{ [key in string]: PlayerProfile }>> {
98+ return await TAURI_INVOKE("get_profiles");
0000099 },
100 /**
101 * (Screen: Menu) Go to the game replay screen to replay the game history specified by id
102 */
103+ async replayGame(id: string): Promise<null> {
104+ return await TAURI_INVOKE("replay_game", { id });
00000105 },
106 /**
107 * (Screen: Menu) Get a list of all previously played games, returns of list of DateTimes that represent when
108 * each game started, use this as a key
109 */
110+ async listGameHistories(): Promise<string[]> {
111+ return await TAURI_INVOKE("list_game_histories");
00000112 },
113 /**
114 * (Screen: Replay) Get the game history that's currently being replayed. Try to limit calls to
115 * this
116 */
117+ async getCurrentReplayHistory(): Promise<AppGameHistory> {
118+ return await TAURI_INVOKE("get_current_replay_history");
00000119 },
120 /**
121 * (Screen: Game) Get the current settings for this game.
122 */
123+ async getGameSettings(): Promise<GameSettings> {
124+ return await TAURI_INVOKE("get_game_settings");
00000125 },
126 /**
127 * (Screen: Game) Get the current state of the game.
128 */
129+ async getGameState(): Promise<GameUiState> {
130+ return await TAURI_INVOKE("get_game_state");
00000131 },
132 /**
133 * (Screen: Setup) Complete user setup and go to the menu screen
134 */
135+ async completeSetup(profile: PlayerProfile): Promise<null> {
136+ return await TAURI_INVOKE("complete_setup", { profile });
00000137 }
138};
139
+3-6
frontend/src/components/App.tsx
···1import React from "react";
2import useSWR from "swr";
3import { AppScreen, commands } from "@/bindings";
4-import { useTauriEvent } from "@/lib/hooks";
5-import { unwrapResult } from "@/lib/result";
6import SetupScreen from "./SetupScreen";
7import MenuScreen from "./MenuScreen";
8import LobbyScreen from "./LobbyScreen";
···26export default function App() {
27 const { data: screen, mutate } = useSWR(
28 "fetch-screen",
29- async () => {
30- return unwrapResult(await commands.getCurrentScreen());
31- },
32- { suspense: true, dedupingInterval: 100 }
33 );
3435 useTauriEvent("changeScreen", (newScreen) => {
···1import React from "react";
2import useSWR from "swr";
3import { AppScreen, commands } from "@/bindings";
4+import { useTauriEvent, sharedSwrConfig } from "@/lib/hooks";
05import SetupScreen from "./SetupScreen";
6import MenuScreen from "./MenuScreen";
7import LobbyScreen from "./LobbyScreen";
···25export default function App() {
26 const { data: screen, mutate } = useSWR(
27 "fetch-screen",
28+ commands.getCurrentScreen,
29+ sharedSwrConfig
0030 );
3132 useTauriEvent("changeScreen", (newScreen) => {