···88- [x] API : Command to check if a game exists and is open for fast error checking
99- [x] Transport : Switch to burst message processing for less time in the
1010 critical path
1111-- [ ] State : Event history tracking
1212-- [ ] State : Post game sync
1313-- [ ] API : Handling Profile Syncing
1414-- [ ] ALL : State Update Events
1515-- [ ] ALL : Game Replay Screen
1111+- [x] State : Event history tracking
1212+- [x] State : Post game sync
1313+- [x] API : Handling Profile Syncing
1414+- [ ] API : State Update Events
1515+- [x] API : Game Replay Screen
1616- [ ] Frontend : Scaffolding
1717- [x] Meta : CI Setup
1818- [x] Meta : README Instructions
1919- [x] Meta : Recipes for type binding generation
2020- [x] Signaling: All of it
2121+- [ ] Backend : More tests
+3-3
backend/src/game/events.rs
···11use serde::{Deserialize, Serialize};
2233-use super::{location::Location, state::PlayerPing, Id};
33+use super::{location::Location, state::PlayerPing, Id, UtcDT};
4455/// An event used between players to update state
66-#[derive(Debug, Clone, Serialize, Deserialize)]
66+#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
77pub enum GameEvent {
88 /// A player has been caught and is now a seeker, contains the ID of the caught player
99 PlayerCaught(Id),
···1616 PowerupDespawn(Id),
1717 /// Contains location history of the given player, used after the game to sync location
1818 /// histories
1919- PostGameSync(Id, Vec<Location>),
1919+ PostGameSync(Id, Vec<(UtcDT, Location)>),
2020 /// A player has been disconnected and removed from the game (because of error or otherwise).
2121 /// The player should be removed from all state
2222 DroppedPlayer(Id),
+33-14
backend/src/game/mod.rs
···1818use crate::prelude::*;
19192020pub use location::{Location, LocationService};
2121-pub use state::GameState;
2121+pub use state::{GameHistory, GameState};
2222pub use transport::Transport;
23232424pub type Id = Uuid;
···5656 interval,
5757 state: RwLock::new(state),
5858 }
5959- }
6060-6161- pub async fn clone_state(&self) -> GameState {
6262- self.state.read().await.clone()
6359 }
64606561 pub async fn mark_caught(&self) {
···6763 let id = state.id;
6864 state.mark_caught(id);
6965 state.remove_ping(id);
7070- // TODO: Maybe reroll for new powerups instead of just erasing it
6666+ // TODO: Maybe reroll for new powerups (specifically seeker ones) instead of just erasing it
7167 state.use_powerup();
72687369 self.transport
···111107 }
112108113109 async fn consume_event(&self, state: &mut GameState, event: GameEvent) -> Result {
110110+ if !state.game_ended() {
111111+ state.event_history.push((Utc::now(), event.clone()));
112112+ }
113113+114114 match event {
115115 GameEvent::Ping(player_ping) => state.add_ping(player_ping),
116116 GameEvent::ForcePing(target, display) => {
···140140 GameEvent::TransportDisconnect => {
141141 bail!("Transport disconnected");
142142 }
143143- GameEvent::PostGameSync(_, _locations) => {}
143143+ GameEvent::PostGameSync(id, history) => {
144144+ state.insert_player_location_history(id, history);
145145+ }
144146 }
145147146148 Ok(())
147149 }
148150149151 /// Perform a tick for a specific moment in time
150150- async fn tick(&self, state: &mut GameState, now: UtcDT) {
152152+ /// Returns whether the game loop should be broken.
153153+ async fn tick(&self, state: &mut GameState, now: UtcDT) -> bool {
154154+ if state.check_end_game() {
155155+ // If we're at the point where the game is over, send out our location history
156156+ let msg = GameEvent::PostGameSync(state.id, state.location_history.clone());
157157+ self.transport.send_message(msg).await;
158158+ }
159159+160160+ if state.game_ended() {
161161+ // Don't do normal ticks if the game is over,
162162+ // simply return if we're done doing a post-game sync
163163+164164+ return state.check_post_game_sync();
165165+ }
166166+151167 // Push to location history
152168 if let Some(location) = self.location.get_loc() {
153169 state.push_loc(location);
···192208 if state.should_spawn_powerup(&now) {
193209 state.try_spawn_powerup(now);
194210 }
211211+212212+ false
195213 }
196214197215 #[cfg(test)]
···205223 }
206224207225 /// Main loop of the game, handles ticking and receiving messages from [Transport].
208208- pub async fn main_loop(&self) -> Result {
226226+ pub async fn main_loop(&self) -> Result<GameHistory> {
209227 let mut interval = tokio::time::interval(self.interval);
210228211229 interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
···221239 break 'game Err(why);
222240 }
223241 }
224224-225225- if state.should_end() {
226226- break Ok(());
227227- }
228242 }
229243230244 _ = interval.tick() => {
231245 let mut state = self.state.write().await;
232232- self.tick(&mut state, Utc::now()).await;
246246+ let should_break = self.tick(&mut state, Utc::now()).await;
247247+248248+ if should_break {
249249+ let history = state.as_game_history();
250250+ break Ok(history);
251251+ }
233252 }
234253 }
235254 };
+65-11
backend/src/game/state.rs
···88};
99use rand_chacha::ChaCha20Rng;
1010use serde::{Deserialize, Serialize};
1111+use uuid::Uuid;
1212+1313+use crate::game::GameEvent;
11141215use super::{
1316 location::Location,
···4043 }
4144}
42454343-#[derive(Debug, Clone, Serialize, specta::Type)]
4646+#[derive(Debug, Clone)]
4447/// This struct handles all logic regarding state updates
4548pub struct GameState {
4649 /// The id of this player in this game
···51545255 /// When the game started
5356 game_started: UtcDT,
5757+5858+ /// When the game ended, if this is [Option::Some] then the state will enter post-game sync
5959+ game_ended: Option<UtcDT>,
6060+6161+ /// A HashMap of player IDs to location histories, used to track all player location histories
6262+ /// during post-game sync
6363+ player_histories: HashMap<Uuid, Option<Vec<(UtcDT, Location)>>>,
54645565 /// When seekers were allowed to begin
5666 seekers_started: Option<UtcDT>,
···7080 /// Powerup on the map that players can grab. Only one at a time
7181 available_powerup: Option<Location>,
72827373- #[serde(skip)]
8383+ pub event_history: Vec<(UtcDT, GameEvent)>,
8484+7485 /// The game's current settings
7586 settings: GameSettings,
76877777- #[serde(skip)]
7888 /// The player's location history
7979- location_history: Vec<Location>,
8989+ pub location_history: Vec<(UtcDT, Location)>,
80908191 /// Cached bernoulli distribution for powerups, faster sampling
8282- #[serde(skip)]
8392 powerup_bernoulli: Bernoulli,
84938594 /// A seed with a shared value between all players, should be reproducible
8695 /// RNG for use in stuff like powerup location selection.
8787- #[serde(skip)]
8896 shared_random_increment: i64,
89979098 /// State for [ChaCha20Rng] to be used and added to when performing shared RNG operations
9191- #[serde(skip)]
9299 shared_random_state: u64,
93100}
94101···100107 Self {
101108 id: my_id,
102109 game_started: Utc::now(),
110110+ event_history: Vec::with_capacity(15),
111111+ game_ended: None,
103112 seekers_started: None,
104113 pings: HashMap::with_capacity(initial_caught_state.len()),
114114+ player_histories: HashMap::from_iter(initial_caught_state.keys().map(|id| (*id, None))),
105115 caught_state: initial_caught_state,
106116 available_powerup: None,
107117 powerup_bernoulli: settings.get_powerup_bernoulli(),
···240250 self.pings.get(&player)
241251 }
242252253253+ /// Add a location history for the given player
254254+ pub fn insert_player_location_history(&mut self, id: Uuid, history: Vec<(UtcDT, Location)>) {
255255+ self.player_histories.insert(id, Some(history));
256256+ }
257257+258258+ /// Check if we've complete the post-game sync
259259+ pub fn check_post_game_sync(&self) -> bool {
260260+ self.game_ended() && self.player_histories.values().all(Option::is_some)
261261+ }
262262+243263 /// Check if the game should be ended (due to all players being caught)
244244- pub fn should_end(&self) -> bool {
245245- self.caught_state.values().all(|v| *v)
264264+ pub fn check_end_game(&mut self) -> bool {
265265+ let should_end = self.caught_state.values().all(|v| *v);
266266+ if should_end {
267267+ self.game_ended = Some(Utc::now());
268268+ self.player_histories
269269+ .insert(self.id, Some(self.location_history.clone()));
270270+ }
271271+ should_end
272272+ }
273273+274274+ pub fn game_ended(&self) -> bool {
275275+ self.game_ended.is_some()
246276 }
247277248278 /// Remove a ping from the map
···292322 pub fn remove_player(&mut self, id: Id) {
293323 self.pings.remove(&id);
294324 self.caught_state.remove(&id);
325325+ self.player_histories.remove(&id);
295326 }
296327297328 /// Player has gotten a powerup, rolls to see which powerup and stores it
···318349319350 /// Push a new player location
320351 pub fn push_loc(&mut self, loc: Location) {
321321- self.location_history.push(loc);
352352+ self.location_history.push((Utc::now(), loc));
322353 }
323354324355 /// Get the latest player location
325356 fn get_loc(&self) -> Option<&Location> {
326326- self.location_history.last()
357357+ self.location_history.last().map(|(_, l)| l)
327358 }
328359329360 /// Mark a player as caught
···342373 pub fn is_seeker(&self) -> bool {
343374 self.caught_state.get(&self.id).copied().unwrap_or_default()
344375 }
376376+377377+ pub fn as_game_history(&self) -> GameHistory {
378378+ GameHistory {
379379+ my_id: self.id,
380380+ events: self.event_history.clone(),
381381+ locations: self
382382+ .player_histories
383383+ .iter()
384384+ .map(|(id, history)| (*id, history.as_ref().cloned().unwrap_or_default()))
385385+ .collect(),
386386+ game_started: self.game_started,
387387+ game_ended: self.game_ended.unwrap_or_default(),
388388+ }
389389+ }
390390+}
391391+392392+#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
393393+pub struct GameHistory {
394394+ my_id: Uuid,
395395+ pub game_started: UtcDT,
396396+ game_ended: UtcDT,
397397+ events: Vec<(UtcDT, GameEvent)>,
398398+ locations: Vec<(Uuid, Vec<(UtcDT, Location)>)>,
345399}
+58
backend/src/history.rs
···11+use serde::{Deserialize, Serialize};
22+use std::{collections::HashMap, sync::Arc};
33+use tauri::{AppHandle, Runtime};
44+use tauri_plugin_store::{Store, StoreExt};
55+use uuid::Uuid;
66+77+use crate::{
88+ game::{GameHistory, UtcDT},
99+ prelude::*,
1010+ profile::PlayerProfile,
1111+};
1212+1313+#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
1414+pub struct AppGameHistory {
1515+ history: GameHistory,
1616+ profiles: HashMap<Uuid, PlayerProfile>,
1717+}
1818+1919+impl AppGameHistory {
2020+ pub fn new(history: GameHistory, profiles: HashMap<Uuid, PlayerProfile>) -> Self {
2121+ Self { history, profiles }
2222+ }
2323+2424+ fn get_store<R: Runtime>(app: &AppHandle<R>) -> Result<Arc<Store<R>>> {
2525+ app.store("histories.json")
2626+ .context("Failed to get history store")
2727+ }
2828+2929+ pub fn ls_histories(app: &AppHandle) -> Result<Vec<UtcDT>> {
3030+ let store = Self::get_store(app)?;
3131+3232+ let mut histories = store
3333+ .keys()
3434+ .into_iter()
3535+ .filter_map(|k| serde_json::from_str::<UtcDT>(&k).ok())
3636+ .collect::<Vec<_>>();
3737+3838+ histories.sort_unstable_by(|a, b| a.cmp(b).reverse());
3939+4040+ Ok(histories)
4141+ }
4242+4343+ pub fn get_history(app: &AppHandle, dt: UtcDT) -> Result<AppGameHistory> {
4444+ let store = Self::get_store(app)?;
4545+ let key = serde_json::to_string(&dt).context("Failed to make key")?;
4646+ let val = store.get(key).context("Key not found")?;
4747+ serde_json::from_value(val).context("Failed to deserialize game history")
4848+ }
4949+5050+ pub fn save_history(&self, app: &AppHandle) -> Result {
5151+ let store = Self::get_store(app)?;
5252+ let serialized = serde_json::to_value(self).context("Failed to serialize history")?;
5353+ let key =
5454+ serde_json::to_string(&self.history.game_started).context("Failed to make key")?;
5555+ store.set(key, serialized);
5656+ Ok(())
5757+ }
5858+}
+124-46
backend/src/lib.rs
···11mod game;
22+mod history;
23mod lobby;
34mod location;
45mod profile;
56mod transport;
6777-use std::{sync::Arc, time::Duration};
88+use std::{collections::HashMap, sync::Arc, time::Duration};
8999-use game::{Game as BaseGame, GameSettings, GameState};
1010+use game::{Game as BaseGame, GameSettings};
1111+use history::AppGameHistory;
1012use lobby::{Lobby, LobbyState, StartGameInfo};
1113use location::TauriLocation;
1214use log::{error, warn};
···2022use uuid::Uuid;
21232224mod prelude {
2323- pub use anyhow::{anyhow, bail, Error as AnyhowError};
2525+ pub use anyhow::{anyhow, bail, Context, Error as AnyhowError};
2426 pub use std::result::Result as StdResult;
25272628 pub type Result<T = (), E = AnyhowError> = StdResult<T, E>;
2729}
3030+3131+use prelude::*;
28322933type Game = BaseGame<TauriLocation, MatchboxTransport>;
3034···3236 Setup,
3337 Menu(PlayerProfile),
3438 Lobby(Arc<Lobby>),
3535- Game(Arc<Game>),
3939+ Game(Arc<Game>, HashMap<Uuid, PlayerProfile>),
4040+ Replay(AppGameHistory),
4141+}
4242+4343+#[derive(Serialize, Deserialize, specta::Type, Debug, Clone, Eq, PartialEq)]
4444+enum AppScreen {
4545+ Setup,
4646+ Menu,
4747+ Lobby,
4848+ Game,
4949+ Replay,
3650}
37513852type AppStateHandle = RwLock<AppState>;
···5872struct ChangeScreen(AppScreen);
59736074impl AppState {
6161- pub fn start_game(&mut self, app: AppHandle, my_id: Uuid, start: StartGameInfo) {
7575+ pub async fn start_game(&mut self, app: AppHandle, my_id: Uuid, start: StartGameInfo) {
6276 if let AppState::Lobby(lobby) = self {
6377 let transport = lobby.clone_transport();
7878+ let profiles = lobby.clone_profiles().await;
6479 let location = TauriLocation::new(app.clone());
6580 let game = Arc::new(Game::new(
6681 my_id,
···7186 location,
7287 lobby.clone_cancel(),
7388 ));
7474- *self = AppState::Game(game.clone());
8989+ *self = AppState::Game(game.clone(), profiles.clone());
7590 tokio::spawn(async move {
7691 let res = game.main_loop().await;
7792 let app2 = app.clone();
7893 let state_handle = app.state::<AppStateHandle>();
7994 let mut state = state_handle.write().await;
8095 match res {
8181- Ok(_) => {
8282- // TODO: Post game screen, etc here. Game::main_loop should return smth
8383- // like GameHistory for playback and serialization
8484- state.quit_game_or_lobby(app2);
9696+ Ok(history) => {
9797+ let history = AppGameHistory::new(history, profiles);
9898+ if let Err(why) = history.save_history(&app2) {
9999+ error!("Failed to save game history: {why:?}");
100100+ }
101101+ state.quit_to_menu(app2);
85102 }
86103 Err(why) => {
87104 error!("Game Error: {why:?}");
8888- state.quit_game_or_lobby(app2);
105105+ state.quit_to_menu(app2);
89106 }
90107 }
91108 });
···107124 }
108125109126 pub fn get_lobby(&self) -> Result<Arc<Lobby>> {
110110- match self {
111111- AppState::Lobby(lobby) => Ok(lobby.clone()),
112112- _ => Err("Not on lobby screen".to_string()),
127127+ if let AppState::Lobby(lobby) = self {
128128+ Ok(lobby.clone())
129129+ } else {
130130+ Err("Not on lobby screen".to_string())
113131 }
114132 }
115133116134 pub fn get_game(&self) -> Result<Arc<Game>> {
117117- match self {
118118- AppState::Game(game) => Ok(game.clone()),
119119- _ => Err("Not on game screen".to_string()),
135135+ if let AppState::Game(game, _) = self {
136136+ Ok(game.clone())
137137+ } else {
138138+ Err("Not on game screen".to_string())
139139+ }
140140+ }
141141+142142+ pub fn get_profiles(&self) -> Result<&HashMap<Uuid, PlayerProfile>> {
143143+ if let AppState::Game(_, profiles) = self {
144144+ Ok(profiles)
145145+ } else {
146146+ Err("Not on game screen".to_string())
147147+ }
148148+ }
149149+150150+ pub fn get_replay(&self) -> Result<AppGameHistory> {
151151+ if let AppState::Replay(history) = self {
152152+ Ok(history.clone())
153153+ } else {
154154+ Err("Not on replay screen".to_string())
120155 }
121156 }
122157123158 fn emit_screen_change(app: &AppHandle, screen: AppScreen) {
124159 if let Err(why) = ChangeScreen(screen).emit(app) {
125160 warn!("Error emitting screen change: {why:?}");
161161+ }
162162+ }
163163+164164+ pub fn replay_game(&mut self, app: &AppHandle, id: UtcDT) -> Result {
165165+ if let AppState::Menu(_) = self {
166166+ let history = AppGameHistory::get_history(app, id)
167167+ .context("Failed to read history")
168168+ .map_err(|e| e.to_string())?;
169169+ *self = AppState::Replay(history);
170170+ Self::emit_screen_change(app, AppScreen::Replay);
171171+ Ok(())
172172+ } else {
173173+ Err("Not on menu screen".to_string())
126174 }
127175 }
128176···151199 let mut state = state_handle.write().await;
152200 match res {
153201 Ok((my_id, start)) => {
154154- state.start_game(app_game, my_id, start);
202202+ state.start_game(app_game, my_id, start).await;
155203 }
156204 Err(why) => {
157205 error!("Lobby Error: {why:?}");
158158- state.quit_game_or_lobby(app_game);
206206+ state.quit_to_menu(app_game);
159207 }
160208 }
161209 });
···163211 }
164212 }
165213166166- pub fn quit_game_or_lobby(&mut self, app: AppHandle) {
214214+ pub fn quit_to_menu(&mut self, app: AppHandle) {
167215 let profile = match self {
168216 AppState::Setup => None,
169217 AppState::Menu(_) => {
···174222 lobby.quit_lobby();
175223 Some(lobby.self_profile.clone())
176224 }
177177- AppState::Game(game) => {
225225+ AppState::Game(game, _) => {
178226 game.quit_game();
179227 PlayerProfile::load_from_store(&app)
180228 }
229229+ AppState::Replay(_) => PlayerProfile::load_from_store(&app),
181230 };
182231 let screen = if let Some(profile) = profile {
183232 *self = AppState::Menu(profile);
···193242194243use std::result::Result as StdResult;
195244196196-type Result<T = (), E = String> = StdResult<T, E>;
245245+use crate::game::UtcDT;
197246198198-#[derive(Serialize, Deserialize, specta::Type, Debug, Clone)]
199199-enum AppScreen {
200200- Setup,
201201- Menu,
202202- Lobby,
203203- Game,
204204-}
247247+type Result<T = (), E = String> = StdResult<T, E>;
205248206249// == GENERAL / FLOW COMMANDS ==
207250···214257 AppState::Setup => AppScreen::Setup,
215258 AppState::Menu(_player_profile) => AppScreen::Menu,
216259 AppState::Lobby(_lobby) => AppScreen::Lobby,
217217- AppState::Game(_game) => AppScreen::Game,
260260+ AppState::Game(_game, _profiles) => AppScreen::Game,
261261+ AppState::Replay(_) => AppScreen::Replay,
218262 })
219263}
220264221265#[tauri::command]
222266#[specta::specta]
223267/// Quit a running game or leave a lobby
224224-async fn quit_game_or_lobby(app: AppHandle, state: State<'_, AppStateHandle>) -> Result {
268268+async fn quit_to_menu(app: AppHandle, state: State<'_, AppStateHandle>) -> Result {
225269 let mut state = state.write().await;
226226- state.quit_game_or_lobby(app);
270270+ state.quit_to_menu(app);
227271 Ok(())
228272}
229273···236280 let state = state.read().await;
237281 let profile = state.get_menu()?;
238282 Ok(profile.clone())
283283+}
284284+285285+#[tauri::command]
286286+#[specta::specta]
287287+/// (Screen: Menu) Get a list of all previously played games, returns of list of DateTimes that represent when
288288+/// each game started, use this as a key
289289+fn list_game_histories(app: AppHandle) -> Result<Vec<UtcDT>> {
290290+ AppGameHistory::ls_histories(&app)
291291+ .map_err(|err| err.context("Failed to get game histories").to_string())
292292+}
293293+294294+#[tauri::command]
295295+#[specta::specta]
296296+/// (Screen: Menu) Go to the game replay screen to replay the game history specified by id
297297+async fn replay_game(id: UtcDT, app: AppHandle, state: State<'_, AppStateHandle>) -> Result {
298298+ state.write().await.replay_game(&app, id)
239299}
240300241301#[tauri::command]
···293353#[tauri::command]
294354#[specta::specta]
295355/// (Screen: Lobby) Switch teams between seekers and hiders, returns the new [LobbyState]
296296-async fn switch_teams(seeker: bool, state: State<'_, AppStateHandle>) -> Result<LobbyState> {
356356+async fn switch_teams(seeker: bool, state: State<'_, AppStateHandle>) -> Result {
297357 let lobby = state.read().await.get_lobby()?;
298358 lobby.switch_teams(seeker).await;
299299- Ok(lobby.clone_state().await)
359359+ Ok(())
300360}
301361302362#[tauri::command]
303363#[specta::specta]
304364/// (Screen: Lobby) HOST ONLY: Push new settings to everyone, does nothing on clients. Returns the
305365/// new lobby state
306306-async fn host_update_settings(
307307- settings: GameSettings,
308308- state: State<'_, AppStateHandle>,
309309-) -> Result<LobbyState> {
366366+async fn host_update_settings(settings: GameSettings, state: State<'_, AppStateHandle>) -> Result {
310367 let lobby = state.read().await.get_lobby()?;
311368 lobby.update_settings(settings).await;
312312- Ok(lobby.clone_state().await)
369369+ Ok(())
313370}
314371315372#[tauri::command]
···325382326383#[tauri::command]
327384#[specta::specta]
385385+/// (Screen: Game) Get all player profiles with display names and profile pictures for this game
386386+async fn get_profiles(state: State<'_, AppStateHandle>) -> Result<HashMap<Uuid, PlayerProfile>> {
387387+ state.read().await.get_profiles().cloned()
388388+}
389389+390390+#[tauri::command]
391391+#[specta::specta]
328392/// (Screen: Game) Mark this player as caught, this player will become a seeker. Returns the new game state
329329-async fn mark_caught(state: State<'_, AppStateHandle>) -> Result<GameState> {
393393+async fn mark_caught(state: State<'_, AppStateHandle>) -> Result {
330394 let game = state.read().await.get_game()?;
331395 game.mark_caught().await;
332332- Ok(game.clone_state().await)
396396+ Ok(())
333397}
334398335399#[tauri::command]
336400#[specta::specta]
337401/// (Screen: Game) Grab a powerup on the map, this should be called when the user is *in range* of
338402/// the powerup. Returns the new game state after rolling for the powerup
339339-async fn grab_powerup(state: State<'_, AppStateHandle>) -> Result<GameState> {
403403+async fn grab_powerup(state: State<'_, AppStateHandle>) -> Result {
340404 let game = state.read().await.get_game()?;
341405 game.get_powerup().await;
342342- Ok(game.clone_state().await)
406406+ Ok(())
343407}
344408345409#[tauri::command]
346410#[specta::specta]
347411/// (Screen: Game) Use the currently held powerup in the player's held_powerup. Does nothing if the
348412/// player has none. Returns the updated game state
349349-async fn use_powerup(state: State<'_, AppStateHandle>) -> Result<GameState> {
413413+async fn use_powerup(state: State<'_, AppStateHandle>) -> Result {
350414 let game = state.read().await.get_game()?;
351415 game.use_powerup().await;
352352- Ok(game.clone_state().await)
416416+ Ok(())
417417+}
418418+419419+// AppState::Replay COMMANDS
420420+421421+#[tauri::command]
422422+#[specta::specta]
423423+/// (Screen: Replay) Get the game history that's currently being replayed. Try to limit calls to
424424+/// this
425425+async fn get_current_replay_history(state: State<'_, AppStateHandle>) -> Result<AppGameHistory> {
426426+ state.read().await.get_replay()
353427}
354428355429pub fn mk_specta() -> tauri_specta::Builder {
···357431 .commands(collect_commands![
358432 start_lobby,
359433 get_profile,
360360- quit_game_or_lobby,
434434+ quit_to_menu,
361435 get_current_screen,
362436 update_profile,
363437 get_lobby_state,
···368442 grab_powerup,
369443 use_powerup,
370444 check_room_code,
445445+ get_profiles,
446446+ replay_game,
447447+ list_game_histories,
448448+ get_current_replay_history
371449 ])
372450 .events(collect_events![ChangeScreen])
373451}
+5
backend/src/lobby.rs
···8787 self.state.lock().await.clone()
8888 }
89899090+ pub async fn clone_profiles(&self) -> HashMap<Uuid, PlayerProfile> {
9191+ let state = self.state.lock().await;
9292+ state.profiles.clone()
9393+ }
9494+9095 /// Set self as seeker or hider
9196 pub async fn switch_teams(&self, seeker: bool) {
9297 let mut state = self.state.lock().await;
···3737 /**
3838 * Quit a running game or leave a lobby
3939 */
4040- async quitGameOrLobby(): Promise<Result<null, string>> {
4040+ async quitToMenu(): Promise<Result<null, string>> {
4141 try {
4242- return { status: "ok", data: await TAURI_INVOKE("quit_game_or_lobby") };
4242+ return { status: "ok", data: await TAURI_INVOKE("quit_to_menu") };
4343 } catch (e) {
4444 if (e instanceof Error) throw e;
4545 else return { status: "error", error: e as any };
···8282 * (Screen: Lobby) HOST ONLY: Push new settings to everyone, does nothing on clients. Returns the
8383 * new lobby state
8484 */
8585- async hostUpdateSettings(settings: GameSettings): Promise<Result<LobbyState, string>> {
8585+ async hostUpdateSettings(settings: GameSettings): Promise<Result<null, string>> {
8686 try {
8787 return { status: "ok", data: await TAURI_INVOKE("host_update_settings", { settings }) };
8888 } catch (e) {
···9393 /**
9494 * (Screen: Lobby) Switch teams between seekers and hiders, returns the new [LobbyState]
9595 */
9696- async switchTeams(seeker: boolean): Promise<Result<LobbyState, string>> {
9696+ async switchTeams(seeker: boolean): Promise<Result<null, string>> {
9797 try {
9898 return { status: "ok", data: await TAURI_INVOKE("switch_teams", { seeker }) };
9999 } catch (e) {
···116116 /**
117117 * (Screen: Game) Mark this player as caught, this player will become a seeker. Returns the new game state
118118 */
119119- async markCaught(): Promise<Result<GameState, string>> {
119119+ async markCaught(): Promise<Result<null, string>> {
120120 try {
121121 return { status: "ok", data: await TAURI_INVOKE("mark_caught") };
122122 } catch (e) {
···128128 * (Screen: Game) Grab a powerup on the map, this should be called when the user is *in range* of
129129 * the powerup. Returns the new game state after rolling for the powerup
130130 */
131131- async grabPowerup(): Promise<Result<GameState, string>> {
131131+ async grabPowerup(): Promise<Result<null, string>> {
132132 try {
133133 return { status: "ok", data: await TAURI_INVOKE("grab_powerup") };
134134 } catch (e) {
···140140 * (Screen: Game) Use the currently held powerup in the player's held_powerup. Does nothing if the
141141 * player has none. Returns the updated game state
142142 */
143143- async usePowerup(): Promise<Result<GameState, string>> {
143143+ async usePowerup(): Promise<Result<null, string>> {
144144 try {
145145 return { status: "ok", data: await TAURI_INVOKE("use_powerup") };
146146 } catch (e) {
···159159 if (e instanceof Error) throw e;
160160 else return { status: "error", error: e as any };
161161 }
162162+ },
163163+ /**
164164+ * (Screen: Game) Get all player profiles with display names and profile pictures for this game
165165+ */
166166+ async getProfiles(): Promise<Result<Partial<{ [key in string]: PlayerProfile }>, string>> {
167167+ try {
168168+ return { status: "ok", data: await TAURI_INVOKE("get_profiles") };
169169+ } catch (e) {
170170+ if (e instanceof Error) throw e;
171171+ else return { status: "error", error: e as any };
172172+ }
173173+ },
174174+ /**
175175+ * (Screen: Menu) Go to the game replay screen to replay the game history specified by id
176176+ */
177177+ async replayGame(id: string): Promise<Result<null, string>> {
178178+ try {
179179+ return { status: "ok", data: await TAURI_INVOKE("replay_game", { id }) };
180180+ } catch (e) {
181181+ if (e instanceof Error) throw e;
182182+ else return { status: "error", error: e as any };
183183+ }
184184+ },
185185+ /**
186186+ * (Screen: Menu) Get a list of all previously played games, returns of list of DateTimes that represent when
187187+ * each game started, use this as a key
188188+ */
189189+ async listGameHistories(): Promise<Result<string[], string>> {
190190+ try {
191191+ return { status: "ok", data: await TAURI_INVOKE("list_game_histories") };
192192+ } catch (e) {
193193+ if (e instanceof Error) throw e;
194194+ else return { status: "error", error: e as any };
195195+ }
196196+ },
197197+ /**
198198+ * (Screen: Replay) Get the game history that's currently being replayed. Try to limit calls to
199199+ * this
200200+ */
201201+ async getCurrentReplayHistory(): Promise<Result<AppGameHistory, string>> {
202202+ try {
203203+ return { status: "ok", data: await TAURI_INVOKE("get_current_replay_history") };
204204+ } catch (e) {
205205+ if (e instanceof Error) throw e;
206206+ else return { status: "error", error: e as any };
207207+ }
162208 }
163209};
164210···174220175221/** user-defined types **/
176222177177-export type AppScreen = "Setup" | "Menu" | "Lobby" | "Game";
223223+export type AppGameHistory = {
224224+ history: GameHistory;
225225+ profiles: Partial<{ [key in string]: PlayerProfile }>;
226226+};
227227+export type AppScreen = "Setup" | "Menu" | "Lobby" | "Game" | "Replay";
178228export type ChangeScreen = AppScreen;
179229/**
230230+ * An event used between players to update state
231231+ */
232232+export type GameEvent =
233233+ /**
234234+ * A player has been caught and is now a seeker, contains the ID of the caught player
235235+ */
236236+ | { PlayerCaught: string }
237237+ /**
238238+ * Public ping from a player revealing location
239239+ */
240240+ | { Ping: PlayerPing }
241241+ /**
242242+ * Force the player specified in `0` to ping, optionally display the ping as from the user
243243+ * specified in `1`.
244244+ */
245245+ | { ForcePing: [string, string | null] }
246246+ /**
247247+ * Force a powerup to despawn because a player got it, contains the player that got it.
248248+ */
249249+ | { PowerupDespawn: string }
250250+ /**
251251+ * Contains location history of the given player, used after the game to sync location
252252+ * histories
253253+ */
254254+ | { PostGameSync: [string, [string, Location][]] }
255255+ /**
256256+ * A player has been disconnected and removed from the game (because of error or otherwise).
257257+ * The player should be removed from all state
258258+ */
259259+ | { DroppedPlayer: string }
260260+ /**
261261+ * The underlying transport has disconnected
262262+ */
263263+ | "TransportDisconnect";
264264+export type GameHistory = {
265265+ my_id: string;
266266+ game_started: string;
267267+ game_ended: string;
268268+ events: [string, GameEvent][];
269269+ locations: [string, [string, Location][]][];
270270+};
271271+/**
180272 * Settings for the game, host is the only person able to change these
181273 */
182274export type GameSettings = {
···214306 */
215307 powerup_locations: Location[];
216308};
217217-/**
218218- * This struct handles all logic regarding state updates
219219- */
220220-export type GameState = {
221221- /**
222222- * The id of this player in this game
223223- */
224224- id: string;
225225- /**
226226- * The powerup the player is currently holding
227227- */
228228- held_powerup: PowerUpType | null;
229229- /**
230230- * When the game started
231231- */
232232- game_started: string;
233233- /**
234234- * When seekers were allowed to begin
235235- */
236236- seekers_started: string | null;
237237- /**
238238- * Last time we pinged all players
239239- */
240240- last_global_ping: string | null;
241241- /**
242242- * Last time a powerup was spawned
243243- */
244244- last_powerup_spawn: string | null;
245245- /**
246246- * Hashmap tracking if a player is a seeker (true) or a hider (false)
247247- */
248248- caught_state: Partial<{ [key in string]: boolean }>;
249249- /**
250250- * A map of the latest global ping results for each player
251251- */
252252- pings: Partial<{ [key in string]: PlayerPing }>;
253253- /**
254254- * Powerup on the map that players can grab. Only one at a time
255255- */
256256- available_powerup: Location | null;
257257-};
258309export type LobbyState = {
259310 profiles: Partial<{ [key in string]: PlayerProfile }>;
260311 join_code: string;
···320371 real_player: string;
321372};
322373export type PlayerProfile = { display_name: string; pfp_base64: string | null };
323323-/**
324324- * Type of powerup
325325- */
326326-export type PowerUpType =
327327- /**
328328- * Ping a random seeker instead of a hider
329329- */
330330- | "PingSeeker"
331331- /**
332332- * Pings all seekers locations on the map for hiders
333333- */
334334- | "PingAllSeekers"
335335- /**
336336- * Ping another random hider instantly
337337- */
338338- | "ForcePingOther";
339374340375/** tauri-specta globals **/
341376