diff --git a/TODO.md b/TODO.md index 37f856c..388d0c3 100644 --- a/TODO.md +++ b/TODO.md @@ -8,13 +8,14 @@ - [x] API : Command to check if a game exists and is open for fast error checking - [x] Transport : Switch to burst message processing for less time in the critical path -- [ ] State : Event history tracking -- [ ] State : Post game sync -- [ ] API : Handling Profile Syncing -- [ ] ALL : State Update Events -- [ ] ALL : Game Replay Screen +- [x] State : Event history tracking +- [x] State : Post game sync +- [x] API : Handling Profile Syncing +- [ ] API : State Update Events +- [x] API : Game Replay Screen - [ ] Frontend : Scaffolding - [x] Meta : CI Setup - [x] Meta : README Instructions - [x] Meta : Recipes for type binding generation - [x] Signaling: All of it +- [ ] Backend : More tests diff --git a/backend/src/game/events.rs b/backend/src/game/events.rs index bec235a..f82d705 100644 --- a/backend/src/game/events.rs +++ b/backend/src/game/events.rs @@ -1,9 +1,9 @@ use serde::{Deserialize, Serialize}; -use super::{location::Location, state::PlayerPing, Id}; +use super::{location::Location, state::PlayerPing, Id, UtcDT}; /// An event used between players to update state -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] pub enum GameEvent { /// A player has been caught and is now a seeker, contains the ID of the caught player PlayerCaught(Id), @@ -16,7 +16,7 @@ pub enum GameEvent { PowerupDespawn(Id), /// Contains location history of the given player, used after the game to sync location /// histories - PostGameSync(Id, Vec), + PostGameSync(Id, Vec<(UtcDT, Location)>), /// A player has been disconnected and removed from the game (because of error or otherwise). /// The player should be removed from all state DroppedPlayer(Id), diff --git a/backend/src/game/mod.rs b/backend/src/game/mod.rs index e2a9696..e4a8798 100644 --- a/backend/src/game/mod.rs +++ b/backend/src/game/mod.rs @@ -18,7 +18,7 @@ mod transport; use crate::prelude::*; pub use location::{Location, LocationService}; -pub use state::GameState; +pub use state::{GameHistory, GameState}; pub use transport::Transport; pub type Id = Uuid; @@ -58,16 +58,12 @@ impl Game { } } - pub async fn clone_state(&self) -> GameState { - self.state.read().await.clone() - } - pub async fn mark_caught(&self) { let mut state = self.state.write().await; let id = state.id; state.mark_caught(id); state.remove_ping(id); - // TODO: Maybe reroll for new powerups instead of just erasing it + // TODO: Maybe reroll for new powerups (specifically seeker ones) instead of just erasing it state.use_powerup(); self.transport @@ -111,6 +107,10 @@ impl Game { } async fn consume_event(&self, state: &mut GameState, event: GameEvent) -> Result { + if !state.game_ended() { + state.event_history.push((Utc::now(), event.clone())); + } + match event { GameEvent::Ping(player_ping) => state.add_ping(player_ping), GameEvent::ForcePing(target, display) => { @@ -140,14 +140,30 @@ impl Game { GameEvent::TransportDisconnect => { bail!("Transport disconnected"); } - GameEvent::PostGameSync(_, _locations) => {} + GameEvent::PostGameSync(id, history) => { + state.insert_player_location_history(id, history); + } } Ok(()) } /// Perform a tick for a specific moment in time - async fn tick(&self, state: &mut GameState, now: UtcDT) { + /// Returns whether the game loop should be broken. + async fn tick(&self, state: &mut GameState, now: UtcDT) -> bool { + if state.check_end_game() { + // If we're at the point where the game is over, send out our location history + let msg = GameEvent::PostGameSync(state.id, state.location_history.clone()); + self.transport.send_message(msg).await; + } + + if state.game_ended() { + // Don't do normal ticks if the game is over, + // simply return if we're done doing a post-game sync + + return state.check_post_game_sync(); + } + // Push to location history if let Some(location) = self.location.get_loc() { state.push_loc(location); @@ -192,6 +208,8 @@ impl Game { if state.should_spawn_powerup(&now) { state.try_spawn_powerup(now); } + + false } #[cfg(test)] @@ -205,7 +223,7 @@ impl Game { } /// Main loop of the game, handles ticking and receiving messages from [Transport]. - pub async fn main_loop(&self) -> Result { + pub async fn main_loop(&self) -> Result { let mut interval = tokio::time::interval(self.interval); interval.set_missed_tick_behavior(MissedTickBehavior::Delay); @@ -221,15 +239,16 @@ impl Game { break 'game Err(why); } } - - if state.should_end() { - break Ok(()); - } } _ = interval.tick() => { let mut state = self.state.write().await; - self.tick(&mut state, Utc::now()).await; + let should_break = self.tick(&mut state, Utc::now()).await; + + if should_break { + let history = state.as_game_history(); + break Ok(history); + } } } }; diff --git a/backend/src/game/state.rs b/backend/src/game/state.rs index 97e403b..d30d498 100644 --- a/backend/src/game/state.rs +++ b/backend/src/game/state.rs @@ -8,6 +8,9 @@ use rand::{ }; use rand_chacha::ChaCha20Rng; use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::game::GameEvent; use super::{ location::Location, @@ -40,7 +43,7 @@ impl PlayerPing { } } -#[derive(Debug, Clone, Serialize, specta::Type)] +#[derive(Debug, Clone)] /// This struct handles all logic regarding state updates pub struct GameState { /// The id of this player in this game @@ -52,6 +55,13 @@ pub struct GameState { /// When the game started game_started: UtcDT, + /// When the game ended, if this is [Option::Some] then the state will enter post-game sync + game_ended: Option, + + /// A HashMap of player IDs to location histories, used to track all player location histories + /// during post-game sync + player_histories: HashMap>>, + /// When seekers were allowed to begin seekers_started: Option, @@ -70,25 +80,22 @@ pub struct GameState { /// Powerup on the map that players can grab. Only one at a time available_powerup: Option, - #[serde(skip)] + pub event_history: Vec<(UtcDT, GameEvent)>, + /// The game's current settings settings: GameSettings, - #[serde(skip)] /// The player's location history - location_history: Vec, + pub location_history: Vec<(UtcDT, Location)>, /// Cached bernoulli distribution for powerups, faster sampling - #[serde(skip)] powerup_bernoulli: Bernoulli, /// A seed with a shared value between all players, should be reproducible /// RNG for use in stuff like powerup location selection. - #[serde(skip)] shared_random_increment: i64, /// State for [ChaCha20Rng] to be used and added to when performing shared RNG operations - #[serde(skip)] shared_random_state: u64, } @@ -100,8 +107,11 @@ impl GameState { Self { id: my_id, game_started: Utc::now(), + event_history: Vec::with_capacity(15), + game_ended: None, seekers_started: None, pings: HashMap::with_capacity(initial_caught_state.len()), + player_histories: HashMap::from_iter(initial_caught_state.keys().map(|id| (*id, None))), caught_state: initial_caught_state, available_powerup: None, powerup_bernoulli: settings.get_powerup_bernoulli(), @@ -240,9 +250,29 @@ impl GameState { self.pings.get(&player) } + /// Add a location history for the given player + pub fn insert_player_location_history(&mut self, id: Uuid, history: Vec<(UtcDT, Location)>) { + self.player_histories.insert(id, Some(history)); + } + + /// Check if we've complete the post-game sync + pub fn check_post_game_sync(&self) -> bool { + self.game_ended() && self.player_histories.values().all(Option::is_some) + } + /// Check if the game should be ended (due to all players being caught) - pub fn should_end(&self) -> bool { - self.caught_state.values().all(|v| *v) + pub fn check_end_game(&mut self) -> bool { + let should_end = self.caught_state.values().all(|v| *v); + if should_end { + self.game_ended = Some(Utc::now()); + self.player_histories + .insert(self.id, Some(self.location_history.clone())); + } + should_end + } + + pub fn game_ended(&self) -> bool { + self.game_ended.is_some() } /// Remove a ping from the map @@ -292,6 +322,7 @@ impl GameState { pub fn remove_player(&mut self, id: Id) { self.pings.remove(&id); self.caught_state.remove(&id); + self.player_histories.remove(&id); } /// Player has gotten a powerup, rolls to see which powerup and stores it @@ -318,12 +349,12 @@ impl GameState { /// Push a new player location pub fn push_loc(&mut self, loc: Location) { - self.location_history.push(loc); + self.location_history.push((Utc::now(), loc)); } /// Get the latest player location fn get_loc(&self) -> Option<&Location> { - self.location_history.last() + self.location_history.last().map(|(_, l)| l) } /// Mark a player as caught @@ -342,4 +373,27 @@ impl GameState { pub fn is_seeker(&self) -> bool { self.caught_state.get(&self.id).copied().unwrap_or_default() } + + pub fn as_game_history(&self) -> GameHistory { + GameHistory { + my_id: self.id, + events: self.event_history.clone(), + locations: self + .player_histories + .iter() + .map(|(id, history)| (*id, history.as_ref().cloned().unwrap_or_default())) + .collect(), + game_started: self.game_started, + game_ended: self.game_ended.unwrap_or_default(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +pub struct GameHistory { + my_id: Uuid, + pub game_started: UtcDT, + game_ended: UtcDT, + events: Vec<(UtcDT, GameEvent)>, + locations: Vec<(Uuid, Vec<(UtcDT, Location)>)>, } diff --git a/backend/src/history.rs b/backend/src/history.rs new file mode 100644 index 0000000..f8c20b0 --- /dev/null +++ b/backend/src/history.rs @@ -0,0 +1,58 @@ +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, sync::Arc}; +use tauri::{AppHandle, Runtime}; +use tauri_plugin_store::{Store, StoreExt}; +use uuid::Uuid; + +use crate::{ + game::{GameHistory, UtcDT}, + prelude::*, + profile::PlayerProfile, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +pub struct AppGameHistory { + history: GameHistory, + profiles: HashMap, +} + +impl AppGameHistory { + pub fn new(history: GameHistory, profiles: HashMap) -> Self { + Self { history, profiles } + } + + fn get_store(app: &AppHandle) -> Result>> { + app.store("histories.json") + .context("Failed to get history store") + } + + pub fn ls_histories(app: &AppHandle) -> Result> { + let store = Self::get_store(app)?; + + let mut histories = store + .keys() + .into_iter() + .filter_map(|k| serde_json::from_str::(&k).ok()) + .collect::>(); + + histories.sort_unstable_by(|a, b| a.cmp(b).reverse()); + + Ok(histories) + } + + pub fn get_history(app: &AppHandle, dt: UtcDT) -> Result { + let store = Self::get_store(app)?; + let key = serde_json::to_string(&dt).context("Failed to make key")?; + let val = store.get(key).context("Key not found")?; + serde_json::from_value(val).context("Failed to deserialize game history") + } + + pub fn save_history(&self, app: &AppHandle) -> Result { + let store = Self::get_store(app)?; + let serialized = serde_json::to_value(self).context("Failed to serialize history")?; + let key = + serde_json::to_string(&self.history.game_started).context("Failed to make key")?; + store.set(key, serialized); + Ok(()) + } +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 9dceb96..a38d13a 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -1,12 +1,14 @@ mod game; +mod history; mod lobby; mod location; mod profile; mod transport; -use std::{sync::Arc, time::Duration}; +use std::{collections::HashMap, sync::Arc, time::Duration}; -use game::{Game as BaseGame, GameSettings, GameState}; +use game::{Game as BaseGame, GameSettings}; +use history::AppGameHistory; use lobby::{Lobby, LobbyState, StartGameInfo}; use location::TauriLocation; use log::{error, warn}; @@ -20,19 +22,31 @@ use transport::MatchboxTransport; use uuid::Uuid; mod prelude { - pub use anyhow::{anyhow, bail, Error as AnyhowError}; + pub use anyhow::{anyhow, bail, Context, Error as AnyhowError}; pub use std::result::Result as StdResult; pub type Result = StdResult; } +use prelude::*; + type Game = BaseGame; enum AppState { Setup, Menu(PlayerProfile), Lobby(Arc), - Game(Arc), + Game(Arc, HashMap), + Replay(AppGameHistory), +} + +#[derive(Serialize, Deserialize, specta::Type, Debug, Clone, Eq, PartialEq)] +enum AppScreen { + Setup, + Menu, + Lobby, + Game, + Replay, } type AppStateHandle = RwLock; @@ -58,9 +72,10 @@ pub const fn server_url() -> &'static str { struct ChangeScreen(AppScreen); impl AppState { - pub fn start_game(&mut self, app: AppHandle, my_id: Uuid, start: StartGameInfo) { + pub async fn start_game(&mut self, app: AppHandle, my_id: Uuid, start: StartGameInfo) { if let AppState::Lobby(lobby) = self { let transport = lobby.clone_transport(); + let profiles = lobby.clone_profiles().await; let location = TauriLocation::new(app.clone()); let game = Arc::new(Game::new( my_id, @@ -71,21 +86,23 @@ impl AppState { location, lobby.clone_cancel(), )); - *self = AppState::Game(game.clone()); + *self = AppState::Game(game.clone(), profiles.clone()); tokio::spawn(async move { let res = game.main_loop().await; let app2 = app.clone(); let state_handle = app.state::(); let mut state = state_handle.write().await; match res { - Ok(_) => { - // TODO: Post game screen, etc here. Game::main_loop should return smth - // like GameHistory for playback and serialization - state.quit_game_or_lobby(app2); + Ok(history) => { + let history = AppGameHistory::new(history, profiles); + if let Err(why) = history.save_history(&app2) { + error!("Failed to save game history: {why:?}"); + } + state.quit_to_menu(app2); } Err(why) => { error!("Game Error: {why:?}"); - state.quit_game_or_lobby(app2); + state.quit_to_menu(app2); } } }); @@ -107,16 +124,34 @@ impl AppState { } pub fn get_lobby(&self) -> Result> { - match self { - AppState::Lobby(lobby) => Ok(lobby.clone()), - _ => Err("Not on lobby screen".to_string()), + if let AppState::Lobby(lobby) = self { + Ok(lobby.clone()) + } else { + Err("Not on lobby screen".to_string()) } } pub fn get_game(&self) -> Result> { - match self { - AppState::Game(game) => Ok(game.clone()), - _ => Err("Not on game screen".to_string()), + if let AppState::Game(game, _) = self { + Ok(game.clone()) + } else { + Err("Not on game screen".to_string()) + } + } + + pub fn get_profiles(&self) -> Result<&HashMap> { + if let AppState::Game(_, profiles) = self { + Ok(profiles) + } else { + Err("Not on game screen".to_string()) + } + } + + pub fn get_replay(&self) -> Result { + if let AppState::Replay(history) = self { + Ok(history.clone()) + } else { + Err("Not on replay screen".to_string()) } } @@ -126,6 +161,19 @@ impl AppState { } } + pub fn replay_game(&mut self, app: &AppHandle, id: UtcDT) -> Result { + if let AppState::Menu(_) = self { + let history = AppGameHistory::get_history(app, id) + .context("Failed to read history") + .map_err(|e| e.to_string())?; + *self = AppState::Replay(history); + Self::emit_screen_change(app, AppScreen::Replay); + Ok(()) + } else { + Err("Not on menu screen".to_string()) + } + } + pub fn start_lobby( &mut self, join_code: Option, @@ -151,11 +199,11 @@ impl AppState { let mut state = state_handle.write().await; match res { Ok((my_id, start)) => { - state.start_game(app_game, my_id, start); + state.start_game(app_game, my_id, start).await; } Err(why) => { error!("Lobby Error: {why:?}"); - state.quit_game_or_lobby(app_game); + state.quit_to_menu(app_game); } } }); @@ -163,7 +211,7 @@ impl AppState { } } - pub fn quit_game_or_lobby(&mut self, app: AppHandle) { + pub fn quit_to_menu(&mut self, app: AppHandle) { let profile = match self { AppState::Setup => None, AppState::Menu(_) => { @@ -174,10 +222,11 @@ impl AppState { lobby.quit_lobby(); Some(lobby.self_profile.clone()) } - AppState::Game(game) => { + AppState::Game(game, _) => { game.quit_game(); PlayerProfile::load_from_store(&app) } + AppState::Replay(_) => PlayerProfile::load_from_store(&app), }; let screen = if let Some(profile) = profile { *self = AppState::Menu(profile); @@ -193,15 +242,9 @@ impl AppState { use std::result::Result as StdResult; -type Result = StdResult; +use crate::game::UtcDT; -#[derive(Serialize, Deserialize, specta::Type, Debug, Clone)] -enum AppScreen { - Setup, - Menu, - Lobby, - Game, -} +type Result = StdResult; // == GENERAL / FLOW COMMANDS == @@ -214,16 +257,17 @@ async fn get_current_screen(state: State<'_, AppStateHandle>) -> Result AppScreen::Setup, AppState::Menu(_player_profile) => AppScreen::Menu, AppState::Lobby(_lobby) => AppScreen::Lobby, - AppState::Game(_game) => AppScreen::Game, + AppState::Game(_game, _profiles) => AppScreen::Game, + AppState::Replay(_) => AppScreen::Replay, }) } #[tauri::command] #[specta::specta] /// Quit a running game or leave a lobby -async fn quit_game_or_lobby(app: AppHandle, state: State<'_, AppStateHandle>) -> Result { +async fn quit_to_menu(app: AppHandle, state: State<'_, AppStateHandle>) -> Result { let mut state = state.write().await; - state.quit_game_or_lobby(app); + state.quit_to_menu(app); Ok(()) } @@ -238,6 +282,22 @@ async fn get_profile(state: State<'_, AppStateHandle>) -> Result Ok(profile.clone()) } +#[tauri::command] +#[specta::specta] +/// (Screen: Menu) Get a list of all previously played games, returns of list of DateTimes that represent when +/// each game started, use this as a key +fn list_game_histories(app: AppHandle) -> Result> { + AppGameHistory::ls_histories(&app) + .map_err(|err| err.context("Failed to get game histories").to_string()) +} + +#[tauri::command] +#[specta::specta] +/// (Screen: Menu) Go to the game replay screen to replay the game history specified by id +async fn replay_game(id: UtcDT, app: AppHandle, state: State<'_, AppStateHandle>) -> Result { + state.write().await.replay_game(&app, id) +} + #[tauri::command] #[specta::specta] /// (Screen: Menu) Check if a room code is valid to join, use this before starting a game @@ -293,23 +353,20 @@ async fn get_lobby_state(state: State<'_, AppStateHandle>) -> Result #[tauri::command] #[specta::specta] /// (Screen: Lobby) Switch teams between seekers and hiders, returns the new [LobbyState] -async fn switch_teams(seeker: bool, state: State<'_, AppStateHandle>) -> Result { +async fn switch_teams(seeker: bool, state: State<'_, AppStateHandle>) -> Result { let lobby = state.read().await.get_lobby()?; lobby.switch_teams(seeker).await; - Ok(lobby.clone_state().await) + Ok(()) } #[tauri::command] #[specta::specta] /// (Screen: Lobby) HOST ONLY: Push new settings to everyone, does nothing on clients. Returns the /// new lobby state -async fn host_update_settings( - settings: GameSettings, - state: State<'_, AppStateHandle>, -) -> Result { +async fn host_update_settings(settings: GameSettings, state: State<'_, AppStateHandle>) -> Result { let lobby = state.read().await.get_lobby()?; lobby.update_settings(settings).await; - Ok(lobby.clone_state().await) + Ok(()) } #[tauri::command] @@ -323,33 +380,50 @@ async fn host_start_game(state: State<'_, AppStateHandle>) -> Result { // AppScreen::Game COMMANDS +#[tauri::command] +#[specta::specta] +/// (Screen: Game) Get all player profiles with display names and profile pictures for this game +async fn get_profiles(state: State<'_, AppStateHandle>) -> Result> { + state.read().await.get_profiles().cloned() +} + #[tauri::command] #[specta::specta] /// (Screen: Game) Mark this player as caught, this player will become a seeker. Returns the new game state -async fn mark_caught(state: State<'_, AppStateHandle>) -> Result { +async fn mark_caught(state: State<'_, AppStateHandle>) -> Result { let game = state.read().await.get_game()?; game.mark_caught().await; - Ok(game.clone_state().await) + Ok(()) } #[tauri::command] #[specta::specta] /// (Screen: Game) Grab a powerup on the map, this should be called when the user is *in range* of /// the powerup. Returns the new game state after rolling for the powerup -async fn grab_powerup(state: State<'_, AppStateHandle>) -> Result { +async fn grab_powerup(state: State<'_, AppStateHandle>) -> Result { let game = state.read().await.get_game()?; game.get_powerup().await; - Ok(game.clone_state().await) + Ok(()) } #[tauri::command] #[specta::specta] /// (Screen: Game) Use the currently held powerup in the player's held_powerup. Does nothing if the /// player has none. Returns the updated game state -async fn use_powerup(state: State<'_, AppStateHandle>) -> Result { +async fn use_powerup(state: State<'_, AppStateHandle>) -> Result { let game = state.read().await.get_game()?; game.use_powerup().await; - Ok(game.clone_state().await) + Ok(()) +} + +// AppState::Replay COMMANDS + +#[tauri::command] +#[specta::specta] +/// (Screen: Replay) Get the game history that's currently being replayed. Try to limit calls to +/// this +async fn get_current_replay_history(state: State<'_, AppStateHandle>) -> Result { + state.read().await.get_replay() } pub fn mk_specta() -> tauri_specta::Builder { @@ -357,7 +431,7 @@ pub fn mk_specta() -> tauri_specta::Builder { .commands(collect_commands![ start_lobby, get_profile, - quit_game_or_lobby, + quit_to_menu, get_current_screen, update_profile, get_lobby_state, @@ -368,6 +442,10 @@ pub fn mk_specta() -> tauri_specta::Builder { grab_powerup, use_powerup, check_room_code, + get_profiles, + replay_game, + list_game_histories, + get_current_replay_history ]) .events(collect_events![ChangeScreen]) } diff --git a/backend/src/lobby.rs b/backend/src/lobby.rs index f011e7c..6b101f4 100644 --- a/backend/src/lobby.rs +++ b/backend/src/lobby.rs @@ -87,6 +87,11 @@ impl Lobby { self.state.lock().await.clone() } + pub async fn clone_profiles(&self) -> HashMap { + let state = self.state.lock().await; + state.profiles.clone() + } + /// Set self as seeker or hider pub async fn switch_teams(&self, seeker: bool) { let mut state = self.state.lock().await; diff --git a/backend/src/transport.rs b/backend/src/transport.rs index 8860f07..a43994f 100644 --- a/backend/src/transport.rs +++ b/backend/src/transport.rs @@ -298,12 +298,10 @@ impl MatchboxTransport { _ = cancel.cancelled() => { socket.close(); - } _ = timer.tick() => { // Transport Tick - continue; } _ = outgoing_rx.recv_many(&mut buffer, 30) => { diff --git a/frontend/src/bindings.ts b/frontend/src/bindings.ts index e3538f2..47b18a8 100644 --- a/frontend/src/bindings.ts +++ b/frontend/src/bindings.ts @@ -37,9 +37,9 @@ export const commands = { /** * Quit a running game or leave a lobby */ - async quitGameOrLobby(): Promise> { + async quitToMenu(): Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("quit_game_or_lobby") }; + return { status: "ok", data: await TAURI_INVOKE("quit_to_menu") }; } catch (e) { if (e instanceof Error) throw e; else return { status: "error", error: e as any }; @@ -82,7 +82,7 @@ export const commands = { * (Screen: Lobby) HOST ONLY: Push new settings to everyone, does nothing on clients. Returns the * new lobby state */ - async hostUpdateSettings(settings: GameSettings): Promise> { + async hostUpdateSettings(settings: GameSettings): Promise> { try { return { status: "ok", data: await TAURI_INVOKE("host_update_settings", { settings }) }; } catch (e) { @@ -93,7 +93,7 @@ export const commands = { /** * (Screen: Lobby) Switch teams between seekers and hiders, returns the new [LobbyState] */ - async switchTeams(seeker: boolean): Promise> { + async switchTeams(seeker: boolean): Promise> { try { return { status: "ok", data: await TAURI_INVOKE("switch_teams", { seeker }) }; } catch (e) { @@ -116,7 +116,7 @@ export const commands = { /** * (Screen: Game) Mark this player as caught, this player will become a seeker. Returns the new game state */ - async markCaught(): Promise> { + async markCaught(): Promise> { try { return { status: "ok", data: await TAURI_INVOKE("mark_caught") }; } catch (e) { @@ -128,7 +128,7 @@ export const commands = { * (Screen: Game) Grab a powerup on the map, this should be called when the user is *in range* of * the powerup. Returns the new game state after rolling for the powerup */ - async grabPowerup(): Promise> { + async grabPowerup(): Promise> { try { return { status: "ok", data: await TAURI_INVOKE("grab_powerup") }; } catch (e) { @@ -140,7 +140,7 @@ export const commands = { * (Screen: Game) Use the currently held powerup in the player's held_powerup. Does nothing if the * player has none. Returns the updated game state */ - async usePowerup(): Promise> { + async usePowerup(): Promise> { try { return { status: "ok", data: await TAURI_INVOKE("use_powerup") }; } catch (e) { @@ -159,6 +159,52 @@ export const commands = { if (e instanceof Error) throw e; else return { status: "error", error: e as any }; } + }, + /** + * (Screen: Game) Get all player profiles with display names and profile pictures for this game + */ + async getProfiles(): Promise, string>> { + try { + return { status: "ok", data: await TAURI_INVOKE("get_profiles") }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: "error", error: e as any }; + } + }, + /** + * (Screen: Menu) Go to the game replay screen to replay the game history specified by id + */ + async replayGame(id: string): Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("replay_game", { id }) }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: "error", error: e as any }; + } + }, + /** + * (Screen: Menu) Get a list of all previously played games, returns of list of DateTimes that represent when + * each game started, use this as a key + */ + async listGameHistories(): Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("list_game_histories") }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: "error", error: e as any }; + } + }, + /** + * (Screen: Replay) Get the game history that's currently being replayed. Try to limit calls to + * this + */ + async getCurrentReplayHistory(): Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("get_current_replay_history") }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: "error", error: e as any }; + } } }; @@ -174,8 +220,54 @@ export const events = __makeEvents__<{ /** user-defined types **/ -export type AppScreen = "Setup" | "Menu" | "Lobby" | "Game"; +export type AppGameHistory = { + history: GameHistory; + profiles: Partial<{ [key in string]: PlayerProfile }>; +}; +export type AppScreen = "Setup" | "Menu" | "Lobby" | "Game" | "Replay"; export type ChangeScreen = AppScreen; +/** + * An event used between players to update state + */ +export type GameEvent = + /** + * A player has been caught and is now a seeker, contains the ID of the caught player + */ + | { PlayerCaught: string } + /** + * Public ping from a player revealing location + */ + | { Ping: PlayerPing } + /** + * Force the player specified in `0` to ping, optionally display the ping as from the user + * specified in `1`. + */ + | { ForcePing: [string, string | null] } + /** + * Force a powerup to despawn because a player got it, contains the player that got it. + */ + | { PowerupDespawn: string } + /** + * Contains location history of the given player, used after the game to sync location + * histories + */ + | { PostGameSync: [string, [string, Location][]] } + /** + * A player has been disconnected and removed from the game (because of error or otherwise). + * The player should be removed from all state + */ + | { DroppedPlayer: string } + /** + * The underlying transport has disconnected + */ + | "TransportDisconnect"; +export type GameHistory = { + my_id: string; + game_started: string; + game_ended: string; + events: [string, GameEvent][]; + locations: [string, [string, Location][]][]; +}; /** * Settings for the game, host is the only person able to change these */ @@ -214,47 +306,6 @@ export type GameSettings = { */ powerup_locations: Location[]; }; -/** - * This struct handles all logic regarding state updates - */ -export type GameState = { - /** - * The id of this player in this game - */ - id: string; - /** - * The powerup the player is currently holding - */ - held_powerup: PowerUpType | null; - /** - * When the game started - */ - game_started: string; - /** - * When seekers were allowed to begin - */ - seekers_started: string | null; - /** - * Last time we pinged all players - */ - last_global_ping: string | null; - /** - * Last time a powerup was spawned - */ - last_powerup_spawn: string | null; - /** - * Hashmap tracking if a player is a seeker (true) or a hider (false) - */ - caught_state: Partial<{ [key in string]: boolean }>; - /** - * A map of the latest global ping results for each player - */ - pings: Partial<{ [key in string]: PlayerPing }>; - /** - * Powerup on the map that players can grab. Only one at a time - */ - available_powerup: Location | null; -}; export type LobbyState = { profiles: Partial<{ [key in string]: PlayerProfile }>; join_code: string; @@ -320,22 +371,6 @@ export type PlayerPing = { real_player: string; }; export type PlayerProfile = { display_name: string; pfp_base64: string | null }; -/** - * Type of powerup - */ -export type PowerUpType = - /** - * Ping a random seeker instead of a hider - */ - | "PingSeeker" - /** - * Pings all seekers locations on the map for hiders - */ - | "PingAllSeekers" - /** - * Ping another random hider instantly - */ - | "ForcePingOther"; /** tauri-specta globals **/