···1111- [x] State : Event history tracking
1212- [x] State : Post game sync
1313- [x] API : Handling Profile Syncing
1414-- [ ] API : State Update Events
1414+- [x] API : State Update Events
1515- [x] API : Game Replay Screen
1616- [ ] Frontend : Scaffolding
1717- [x] Meta : CI Setup
+1-1
backend/src/export_types.rs
···1010 let mut lang = Typescript::new();
1111 lang.header = Cow::Borrowed("/* eslint @typescript-eslint/no-unused-vars: 0 */\n/* eslint @typescript-eslint/no-explicit-any: 0 */");
1212 specta.export(lang, path).expect("Failed to export types");
1313- println!("Successfully exported type and commands to {path}",);
1313+ println!("Successfully exported types, events, and commands to {path}",);
1414}
+45-12
backend/src/game/mod.rs
···33use powerups::PowerUpType;
44pub use settings::GameSettings;
55use std::{collections::HashMap, sync::Arc, time::Duration};
66-use tokio_util::sync::CancellationToken;
76use uuid::Uuid;
8798use tokio::{sync::RwLock, time::MissedTickBehavior};
···1817use crate::prelude::*;
19182019pub use location::{Location, LocationService};
2121-pub use state::{GameHistory, GameState};
2020+pub use state::{GameHistory, GameState, GameUiState};
2221pub use transport::Transport;
23222423pub type Id = Uuid;
···2625/// Convenence alias for UTC DT
2726pub type UtcDT = DateTime<Utc>;
28272828+pub trait StateUpdateSender {
2929+ fn send_update(&self);
3030+}
3131+2932/// Struct representing an ongoing game, handles communication with
3033/// other clients via [Transport], gets location with [LocationService], and provides high-level methods for
3134/// taking actions in the game.
3232-pub struct Game<L: LocationService, T: Transport> {
3535+pub struct Game<L: LocationService, T: Transport, S: StateUpdateSender> {
3336 state: RwLock<GameState>,
3437 transport: Arc<T>,
3538 location: L,
3939+ state_update_sender: S,
3640 interval: Duration,
3737- transport_cancel_token: CancellationToken,
3841}
39424040-impl<L: LocationService, T: Transport> Game<L, T> {
4343+impl<L: LocationService, T: Transport, S: StateUpdateSender> Game<L, T, S> {
4144 pub fn new(
4245 my_id: Id,
4346 interval: Duration,
···4548 settings: GameSettings,
4649 transport: Arc<T>,
4750 location: L,
4848- transport_cancel_token: CancellationToken,
5151+ state_update_sender: S,
4952 ) -> Self {
5053 let state = GameState::new(settings, my_id, initial_caught_state);
51545255 Self {
5356 transport,
5454- transport_cancel_token,
5557 location,
5658 interval,
5759 state: RwLock::new(state),
6060+ state_update_sender,
5861 }
5962 }
6063···6972 self.transport
7073 .send_message(GameEvent::PlayerCaught(state.id))
7174 .await;
7575+ }
7676+7777+ pub async fn clone_settings(&self) -> GameSettings {
7878+ self.state.read().await.clone_settings()
7979+ }
8080+8181+ pub async fn get_ui_state(&self) -> GameUiState {
8282+ self.state.read().await.as_ui_state()
7283 }
73847485 pub async fn get_powerup(&self) {
···145156 }
146157 }
147158159159+ self.state_update_sender.send_update();
160160+148161 Ok(())
149162 }
150163151164 /// Perform a tick for a specific moment in time
152165 /// Returns whether the game loop should be broken.
153166 async fn tick(&self, state: &mut GameState, now: UtcDT) -> bool {
167167+ let mut send_update = false;
168168+154169 if state.check_end_game() {
155170 // If we're at the point where the game is over, send out our location history
156171 let msg = GameEvent::PostGameSync(state.id, state.location_history.clone());
157172 self.transport.send_message(msg).await;
173173+ send_update = true;
158174 }
159175160176 if state.game_ended() {
161177 // Don't do normal ticks if the game is over,
162178 // simply return if we're done doing a post-game sync
163163-179179+ if send_update {
180180+ self.state_update_sender.send_update();
181181+ }
164182 return state.check_post_game_sync();
165183 }
166184···172190 // Release Seekers?
173191 if !state.seekers_released() && state.should_release_seekers(now) {
174192 state.release_seekers(now);
193193+ send_update = true;
175194 }
176195177196 // Start Pings?
178197 if !state.pings_started() && state.should_start_pings(now) {
179198 state.start_pings(now);
199199+ send_update = true;
180200 }
181201182202 // Do a Ping?
···202222 // Start Powerup Rolls?
203223 if !state.powerups_started() && state.should_start_powerups(now) {
204224 state.start_powerups(now);
225225+ send_update = true;
205226 }
206227207228 // Should roll for a powerup?
208229 if state.should_spawn_powerup(&now) {
209230 state.try_spawn_powerup(now);
231231+ send_update = true;
232232+ }
233233+234234+ // Send a state update to the UI?
235235+ if send_update {
236236+ self.state_update_sender.send_update();
210237 }
211238212239 false
···219246 }
220247221248 pub fn quit_game(&self) {
222222- self.transport_cancel_token.cancel();
249249+ self.transport.disconnect();
223250 }
224251225252 /// Main loop of the game, handles ticking and receiving messages from [Transport].
···253280 }
254281 };
255282256256- self.transport_cancel_token.cancel();
283283+ self.transport.disconnect();
257284258285 res
259286 }
···303330 }
304331 }
305332306306- type TestGame = Game<MockLocation, MockTransport>;
333333+ struct DummySender;
334334+335335+ impl StateUpdateSender for DummySender {
336336+ fn send_update(&self) {}
337337+ }
338338+339339+ type TestGame = Game<MockLocation, MockTransport, DummySender>;
307340308341 struct MockMatch {
309342 uuids: Vec<Uuid>,
···348381 settings.clone(),
349382 Arc::new(transport),
350383 location,
351351- CancellationToken::new(),
384384+ DummySender,
352385 );
353386354387 (id as u32, Arc::new(game))
+38
backend/src/game/state.rs
···387387 game_ended: self.game_ended.unwrap_or_default(),
388388 }
389389 }
390390+391391+ pub fn as_ui_state(&self) -> GameUiState {
392392+ GameUiState {
393393+ my_id: self.id,
394394+ caught_state: self.caught_state.clone(),
395395+ available_powerup: self.available_powerup,
396396+ pings: self.pings.clone(),
397397+ game_started: self.game_started,
398398+ last_global_ping: self.last_global_ping,
399399+ held_powerup: self.held_powerup,
400400+ seekers_started: self.seekers_started,
401401+ }
402402+ }
403403+404404+ pub fn clone_settings(&self) -> GameSettings {
405405+ self.settings.clone()
406406+ }
390407}
391408392409#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
···397414 events: Vec<(UtcDT, GameEvent)>,
398415 locations: Vec<(Uuid, Vec<(UtcDT, Location)>)>,
399416}
417417+418418+/// Subset of [GameState] that is meant to be sent to a UI frontend
419419+#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
420420+pub struct GameUiState {
421421+ /// ID of the local player
422422+ my_id: Uuid,
423423+ /// A map of player IDs to whether that player is a seeker
424424+ caught_state: HashMap<Uuid, bool>,
425425+ /// A powerup that is available on the map
426426+ available_powerup: Option<Location>,
427427+ /// A map of player IDs to an active ping on them
428428+ pings: HashMap<Uuid, PlayerPing>,
429429+ /// When the game was started **in UTC**
430430+ game_started: UtcDT,
431431+ /// The last time all hiders were pinged **in UTC**
432432+ last_global_ping: Option<UtcDT>,
433433+ /// The [PowerUpType] the local player is holding
434434+ held_powerup: Option<PowerUpType>,
435435+ /// When the seekers were allowed to start **in UTC**
436436+ seekers_started: Option<UtcDT>,
437437+}
+2
backend/src/game/transport.rs
···55 async fn receive_messages(&self) -> impl Iterator<Item = GameEvent>;
66 /// Send an event
77 async fn send_message(&self, msg: GameEvent);
88+ /// Disconnect from the transport
99+ fn disconnect(&self) {}
810}
+46-6
backend/src/lib.rs
···30303131use prelude::*;
32323333-type Game = BaseGame<TauriLocation, MatchboxTransport>;
3333+type Game = BaseGame<TauriLocation, MatchboxTransport, TauriStateUpdateSender>;
34343535enum AppState {
3636 Setup,
···6868 }
6969}
70707171+/// The app is changing screens, contains the screen it's switching to
7172#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)]
7273struct ChangeScreen(AppScreen);
73747575+/// The state of the game has updated in some way, you're expected to call [get_game_state] when
7676+/// receiving this
7777+#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)]
7878+struct GameStateUpdate;
7979+8080+struct TauriStateUpdateSender(AppHandle);
8181+8282+impl StateUpdateSender for TauriStateUpdateSender {
8383+ fn send_update(&self) {
8484+ if let Err(why) = GameStateUpdate.emit(&self.0) {
8585+ error!("Error sending Game state update to UI: {why:?}");
8686+ }
8787+ }
8888+}
8989+7490impl AppState {
7591 pub async fn start_game(&mut self, app: AppHandle, my_id: Uuid, start: StartGameInfo) {
7692 if let AppState::Lobby(lobby) = self {
7793 let transport = lobby.clone_transport();
7894 let profiles = lobby.clone_profiles().await;
7995 let location = TauriLocation::new(app.clone());
9696+ let state_updates = TauriStateUpdateSender(app.clone());
8097 let game = Arc::new(Game::new(
8198 my_id,
8299 GAME_TICK_RATE,
···84101 start.settings,
85102 transport,
86103 location,
8787- lobby.clone_cancel(),
104104+ state_updates,
88105 ));
89106 *self = AppState::Game(game.clone(), profiles.clone());
90107 tokio::spawn(async move {
···189206 host,
190207 profile.clone(),
191208 settings,
209209+ app.clone(),
192210 ));
193211 *self = AppState::Lobby(lobby.clone());
194212 let app2 = app.clone();
···242260243261use std::result::Result as StdResult;
244262245245-use crate::game::UtcDT;
263263+use crate::game::{GameUiState, StateUpdateSender, UtcDT};
246264247265type Result<T = (), E = String> = StdResult<T, E>;
248266···382400383401#[tauri::command]
384402#[specta::specta]
385385-/// (Screen: Game) Get all player profiles with display names and profile pictures for this game
403403+/// (Screen: Game) Get all player profiles with display names and profile pictures for this game.
404404+/// This value will never change and is fairly expensive to clone, so please minimize calls to
405405+/// this command.
386406async fn get_profiles(state: State<'_, AppStateHandle>) -> Result<HashMap<Uuid, PlayerProfile>> {
387407 state.read().await.get_profiles().cloned()
408408+}
409409+410410+#[tauri::command]
411411+#[specta::specta]
412412+/// (Screen: Game) Get the current settings for this game.
413413+async fn get_game_settings(state: State<'_, AppStateHandle>) -> Result<GameSettings> {
414414+ Ok(state.read().await.get_game()?.clone_settings().await)
415415+}
416416+417417+#[tauri::command]
418418+#[specta::specta]
419419+/// (Screen: Game) Get the current state of the game.
420420+async fn get_game_state(state: State<'_, AppStateHandle>) -> Result<GameUiState> {
421421+ Ok(state.read().await.get_game()?.get_ui_state().await)
388422}
389423390424#[tauri::command]
···445479 get_profiles,
446480 replay_game,
447481 list_game_histories,
448448- get_current_replay_history
482482+ get_current_replay_history,
483483+ get_game_settings,
484484+ get_game_state,
449485 ])
450450- .events(collect_events![ChangeScreen])
486486+ .events(collect_events![
487487+ ChangeScreen,
488488+ GameStateUpdate,
489489+ lobby::LobbyStateUpdate
490490+ ])
451491}
452492453493#[cfg_attr(mobile, tauri::mobile_entry_point)]
···161161 }
162162 },
163163 /**
164164- * (Screen: Game) Get all player profiles with display names and profile pictures for this game
164164+ * (Screen: Game) Get all player profiles with display names and profile pictures for this game.
165165+ * This value will never change and is fairly expensive to clone, so please minimize calls to
166166+ * this command.
165167 */
166168 async getProfiles(): Promise<Result<Partial<{ [key in string]: PlayerProfile }>, string>> {
167169 try {
···205207 if (e instanceof Error) throw e;
206208 else return { status: "error", error: e as any };
207209 }
210210+ },
211211+ /**
212212+ * (Screen: Game) Get the current settings for this game.
213213+ */
214214+ async getGameSettings(): Promise<Result<GameSettings, string>> {
215215+ try {
216216+ return { status: "ok", data: await TAURI_INVOKE("get_game_settings") };
217217+ } catch (e) {
218218+ if (e instanceof Error) throw e;
219219+ else return { status: "error", error: e as any };
220220+ }
221221+ },
222222+ /**
223223+ * (Screen: Game) Get the current state of the game.
224224+ */
225225+ async getGameState(): Promise<Result<GameUiState, string>> {
226226+ try {
227227+ return { status: "ok", data: await TAURI_INVOKE("get_game_state") };
228228+ } catch (e) {
229229+ if (e instanceof Error) throw e;
230230+ else return { status: "error", error: e as any };
231231+ }
208232 }
209233};
210234···212236213237export const events = __makeEvents__<{
214238 changeScreen: ChangeScreen;
239239+ gameStateUpdate: GameStateUpdate;
240240+ lobbyStateUpdate: LobbyStateUpdate;
215241}>({
216216- changeScreen: "change-screen"
242242+ changeScreen: "change-screen",
243243+ gameStateUpdate: "game-state-update",
244244+ lobbyStateUpdate: "lobby-state-update"
217245});
218246219247/** user-defined constants **/
···225253 profiles: Partial<{ [key in string]: PlayerProfile }>;
226254};
227255export type AppScreen = "Setup" | "Menu" | "Lobby" | "Game" | "Replay";
256256+/**
257257+ * The app is changing screens, contains the screen it's switching to
258258+ */
228259export type ChangeScreen = AppScreen;
229260/**
230261 * An event used between players to update state
···306337 */
307338 powerup_locations: Location[];
308339};
340340+/**
341341+ * The state of the game has updated in some way, you're expected to call [get_game_state] when
342342+ * receiving this
343343+ */
344344+export type GameStateUpdate = null;
345345+/**
346346+ * Subset of [GameState] that is meant to be sent to a UI frontend
347347+ */
348348+export type GameUiState = {
349349+ /**
350350+ * ID of the local player
351351+ */
352352+ my_id: string;
353353+ /**
354354+ * A map of player IDs to whether that player is a seeker
355355+ */
356356+ caught_state: Partial<{ [key in string]: boolean }>;
357357+ /**
358358+ * A powerup that is available on the map
359359+ */
360360+ available_powerup: Location | null;
361361+ /**
362362+ * A map of player IDs to an active ping on them
363363+ */
364364+ pings: Partial<{ [key in string]: PlayerPing }>;
365365+ /**
366366+ * When the game was started **in UTC**
367367+ */
368368+ game_started: string;
369369+ /**
370370+ * The last time all hiders were pinged **in UTC**
371371+ */
372372+ last_global_ping: string | null;
373373+ /**
374374+ * The [PowerUpType] the local player is holding
375375+ */
376376+ held_powerup: PowerUpType | null;
377377+ /**
378378+ * When the seekers were allowed to start **in UTC**
379379+ */
380380+ seekers_started: string | null;
381381+};
309382export type LobbyState = {
310383 profiles: Partial<{ [key in string]: PlayerProfile }>;
311384 join_code: string;
···316389 self_seeker: boolean;
317390 settings: GameSettings;
318391};
392392+/**
393393+ * The lobby state has updated in some way, you're expected to call [get_lobby_state] after
394394+ * receiving this
395395+ */
396396+export type LobbyStateUpdate = null;
319397/**
320398 * Some location in the world as gotten from a Geolocation API
321399 */
···371449 real_player: string;
372450};
373451export type PlayerProfile = { display_name: string; pfp_base64: string | null };
452452+/**
453453+ * Type of powerup
454454+ */
455455+export type PowerUpType =
456456+ /**
457457+ * Ping a random seeker instead of a hider
458458+ */
459459+ | "PingSeeker"
460460+ /**
461461+ * Pings all seekers locations on the map for hiders
462462+ */
463463+ | "PingAllSeekers"
464464+ /**
465465+ * Ping another random hider instantly
466466+ */
467467+ | "ForcePingOther";
374468375469/** tauri-specta globals **/
376470