diff --git a/backend/Cargo.toml b/backend/Cargo.toml index e9a0e89..2aafc34 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -24,7 +24,7 @@ tauri-plugin-geolocation = "2.2" rand_chacha = "0.9.0" futures = "0.3.31" matchbox_socket = "0.12.0" -uuid = "1.17.0" +uuid = { version = "1.17.0", features = ["serde", "v4"] } rmp-serde = "1.3.0" tauri-plugin-store = "2.2.0" specta = { version = "=2.0.0-rc.22", features = ["chrono", "uuid"] } diff --git a/backend/src/game/events.rs b/backend/src/game/events.rs index 8507a56..f1530ac 100644 --- a/backend/src/game/events.rs +++ b/backend/src/game/events.rs @@ -1,14 +1,14 @@ use serde::{Deserialize, Serialize}; -use super::{location::Location, state::PlayerPing, PlayerId}; +use super::{location::Location, state::PlayerPing, Id}; /// An event used between players to update state #[derive(Debug, Clone, Serialize, Deserialize)] -pub enum GameEvent { +pub enum GameEvent { /// A player has been caught and is now a seeker, contains the ID of the caught player PlayerCaught(Id), /// Public ping from a player revealing location - Ping(PlayerPing), + Ping(PlayerPing), /// Force the player specified in `0` to ping, optionally display the ping as from the user /// specified in `1`. ForcePing(Id, Option), diff --git a/backend/src/game/mod.rs b/backend/src/game/mod.rs index 6e3a0e0..1ded9cf 100644 --- a/backend/src/game/mod.rs +++ b/backend/src/game/mod.rs @@ -2,13 +2,7 @@ use chrono::{DateTime, Utc}; pub use events::GameEvent; use powerups::PowerUpType; pub use settings::GameSettings; -use std::{ - collections::HashMap, - fmt::{Debug, Display}, - hash::Hash, - sync::Arc, - time::Duration, -}; +use std::{collections::HashMap, sync::Arc, time::Duration}; use uuid::Uuid; use tokio::{sync::RwLock, time::MissedTickBehavior}; @@ -24,13 +18,7 @@ pub use location::{Location, LocationService}; pub use state::GameState; pub use transport::Transport; -pub trait PlayerId: - Display + Debug + Hash + Ord + Eq + PartialEq + Send + Sync + Sized + Copy + Clone + specta::Type -{ - -} - -impl PlayerId for Uuid {} +pub type Id = Uuid; /// Convenence alias for UTC DT pub type UtcDT = DateTime; @@ -38,14 +26,14 @@ pub type UtcDT = DateTime; /// Struct representing an ongoing game, handles communication with /// other clients via [Transport], gets location with [LocationService], and provides high-level methods for /// taking actions in the game. -pub struct Game> { - state: RwLock>, +pub struct Game { + state: RwLock, transport: Arc, location: L, interval: Duration, } -impl> Game { +impl Game { pub fn new( my_id: Id, interval: Duration, @@ -54,7 +42,7 @@ impl> Game { transport: Arc, location: L, ) -> Self { - let state = GameState::::new(settings, my_id, initial_caught_state); + let state = GameState::new(settings, my_id, initial_caught_state); Self { transport, @@ -64,7 +52,7 @@ impl> Game { } } - pub async fn clone_state(&self) -> GameState { + pub async fn clone_state(&self) -> GameState { self.state.read().await.clone() } @@ -116,7 +104,7 @@ impl> Game { } } - async fn consume_event(&self, event: GameEvent) { + async fn consume_event(&self, event: GameEvent) { let mut state = self.state.write().await; match event { @@ -235,23 +223,21 @@ mod tests { use super::*; use tokio::{sync::Mutex, task::yield_now, test}; - type GameEventRx = tokio::sync::mpsc::Receiver>; - type GameEventTx = tokio::sync::mpsc::Sender>; - - impl PlayerId for u32 {} + type GameEventRx = tokio::sync::mpsc::Receiver; + type GameEventTx = tokio::sync::mpsc::Sender; struct MockTransport { rx: Mutex, txs: Vec, } - impl Transport for MockTransport { - async fn receive_message(&self) -> Option> { + impl Transport for MockTransport { + async fn receive_message(&self) -> Option { let mut rx = self.rx.lock().await; rx.recv().await } - async fn send_message(&self, msg: GameEvent) { + async fn send_message(&self, msg: GameEvent) { for (_id, tx) in self.txs.iter().enumerate() { tx.send(msg.clone()).await.expect("Failed to send msg"); } @@ -270,9 +256,10 @@ mod tests { } } - type TestGame = Game; + type TestGame = Game; struct MockMatch { + uuids: Vec, games: HashMap>, settings: GameSettings, mock_now: UtcDT, @@ -282,6 +269,11 @@ mod tests { impl MockMatch { pub fn new(settings: GameSettings, players: u32, seekers: u32) -> Self { + let uuids = (0..players) + .into_iter() + .map(|_| uuid::Uuid::new_v4()) + .collect::>(); + let channels = (0..players) .into_iter() .map(|_| tokio::sync::mpsc::channel(10)) @@ -289,7 +281,7 @@ mod tests { let initial_caught_state = (0..players) .into_iter() - .map(|id| (id, id < seekers)) + .map(|id| (uuids[id as usize], id < seekers)) .collect::>(); let txs = channels .iter() @@ -306,7 +298,7 @@ mod tests { }; let location = MockLocation; let game = TestGame::new( - id as u32, + uuids[id], INTERVAL, initial_caught_state.clone(), settings.clone(), @@ -321,6 +313,7 @@ mod tests { Self { settings, games, + uuids, mock_now: Utc::now(), } } @@ -339,7 +332,7 @@ mod tests { self.mock_now += d; } - pub async fn assert_all_states(&self, f: impl Fn(&GameState)) { + pub async fn assert_all_states(&self, f: impl Fn(&GameState)) { for (_, game) in &self.games { let state = game.state.read().await; f(&state); @@ -408,7 +401,7 @@ mod tests { mat.assert_all_states(|s| { assert_eq!( - s.get_caught(1), + s.get_caught(mat.uuids[1]), Some(true), "Game {} sees player 1 as not caught", s.id @@ -432,7 +425,7 @@ mod tests { mat.assert_all_states(|s| { for id in 0..4 { - let ping = s.get_ping(id); + let ping = s.get_ping(mat.uuids[id]); if id == 0 { assert!( ping.is_none(), @@ -457,7 +450,7 @@ mod tests { mat.assert_all_states(|s| { for id in 0..4 { - let ping = s.get_ping(id); + let ping = s.get_ping(mat.uuids[id]); if id <= 1 { assert!( ping.is_none(), @@ -535,9 +528,9 @@ mod tests { mat.tick().await; mat.assert_all_states(|s| { - if let Some(ping) = s.get_ping(1) { + if let Some(ping) = s.get_ping(mat.uuids[1]) { assert_eq!( - ping.real_player, 0, + ping.real_player, mat.uuids[0], "Ping for 1 is not truly 0 (in {})", s.id ); @@ -568,9 +561,9 @@ mod tests { mat.assert_all_states(|s| { // Player 0 is a seeker, player 1 user the powerup, so 2 is the only one that should // could have pinged - assert!(s.get_ping(2).is_some()); - assert!(s.get_ping(0).is_none()); - assert!(s.get_ping(1).is_none()); + assert!(s.get_ping(mat.uuids[2]).is_some()); + assert!(s.get_ping(mat.uuids[0]).is_none()); + assert!(s.get_ping(mat.uuids[1]).is_none()); }) .await; } @@ -594,7 +587,7 @@ mod tests { mat.assert_all_states(|s| { for id in 0..3 { assert!( - s.get_caught(id).is_some(), + s.get_caught(mat.uuids[id]).is_some(), "Player {} should be pinged due to the powerup (in {})", id, s.id diff --git a/backend/src/game/state.rs b/backend/src/game/state.rs index 36151bc..490df6f 100644 --- a/backend/src/game/state.rs +++ b/backend/src/game/state.rs @@ -13,12 +13,12 @@ use super::{ location::Location, powerups::PowerUpType, settings::{GameSettings, PingStartCondition}, - PlayerId, UtcDT, + Id, UtcDT, }; #[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] /// An on-map ping of a player -pub struct PlayerPing { +pub struct PlayerPing { /// Location of the ping loc: Location, /// Time the ping happened @@ -29,7 +29,7 @@ pub struct PlayerPing { pub real_player: Id, } -impl PlayerPing { +impl PlayerPing { pub fn new(loc: Location, display_player: Id, real_player: Id) -> Self { Self { loc, @@ -42,7 +42,7 @@ impl PlayerPing { #[derive(Debug, Clone, Serialize, specta::Type)] /// This struct handles all logic regarding state updates -pub struct GameState { +pub struct GameState { /// The id of this player in this game pub id: Id, @@ -65,7 +65,7 @@ pub struct GameState { caught_state: HashMap, /// A map of the latest global ping results for each player - pings: HashMap>, + pings: HashMap, /// Powerup on the map that players can grab. Only one at a time available_powerup: Option, @@ -92,7 +92,7 @@ pub struct GameState { shared_random_state: u64, } -impl GameState { +impl GameState { pub fn new(settings: GameSettings, my_id: Id, initial_caught_state: HashMap) -> Self { let mut rand = ChaCha20Rng::seed_from_u64(settings.random_seed as u64); let increment = rand.random_range(-100..100); @@ -230,23 +230,23 @@ impl GameState { } /// Add a ping for a specific player - pub fn add_ping(&mut self, ping: PlayerPing) { + pub fn add_ping(&mut self, ping: PlayerPing) { self.pings.insert(ping.display_player, ping); } /// Get a ping for a player #[cfg(test)] - pub fn get_ping(&self, player: Id) -> Option<&PlayerPing> { + pub fn get_ping(&self, player: Id) -> Option<&PlayerPing> { self.pings.get(&player) } /// Remove a ping from the map - pub fn remove_ping(&mut self, player: Id) -> Option> { + pub fn remove_ping(&mut self, player: Id) -> Option { self.pings.remove(&player) } /// Iterate over all seekers in the game - pub fn iter_seekers(&self) -> impl Iterator + use<'_, Id> { + pub fn iter_seekers(&self) -> impl Iterator + use<'_> { self.caught_state .iter() .filter_map(|(k, v)| if *v { Some(*k) } else { None }) @@ -260,7 +260,7 @@ impl GameState { } /// Iterate over all hiders in the game - fn iter_hiders(&self) -> impl Iterator + use<'_, Id> { + fn iter_hiders(&self) -> impl Iterator + use<'_> { self.caught_state .iter() .filter_map(|(k, v)| if !*v { Some(*k) } else { None }) @@ -274,12 +274,12 @@ impl GameState { } /// Create a [PlayerPing] with the latest location saved for the player - pub fn create_self_ping(&self) -> Option> { + pub fn create_self_ping(&self) -> Option { self.create_ping(self.id) } /// Create a [PlayerPing] with the latest location as another player - pub fn create_ping(&self, id: Id) -> Option> { + pub fn create_ping(&self, id: Id) -> Option { self.get_loc() .map(|loc| PlayerPing::new(loc.clone(), id, self.id)) } diff --git a/backend/src/game/transport.rs b/backend/src/game/transport.rs index bb46b53..ca569ae 100644 --- a/backend/src/game/transport.rs +++ b/backend/src/game/transport.rs @@ -1,8 +1,8 @@ -use super::{events::GameEvent, PlayerId}; +use super::events::GameEvent; -pub trait Transport { +pub trait Transport { /// Receive an event - async fn receive_message(&self) -> Option>; + async fn receive_message(&self) -> Option; /// Send an event - async fn send_message(&self, msg: GameEvent); + async fn send_message(&self, msg: GameEvent); } diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 6ec7adc..65e13d7 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -6,7 +6,7 @@ mod transport; use std::{sync::Arc, time::Duration}; -use game::{Game as BaseGame, GameSettings, GameState as BaseGameState}; +use game::{Game as BaseGame, GameSettings, GameState}; use lobby::{Lobby, LobbyState, StartGameInfo}; use location::TauriLocation; use profile::PlayerProfile; @@ -18,7 +18,7 @@ use tokio::sync::RwLock; use transport::MatchboxTransport; use uuid::Uuid; -type Game = BaseGame; +type Game = BaseGame; enum AppState { Setup, @@ -241,12 +241,10 @@ async fn host_start_game(state: State<'_, AppStateHandle>) -> Result { // AppScreen::Game COMMANDS -type AppGameState = BaseGameState; - #[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 state = state.read().await; if let AppState::Game(game) = &*state { game.mark_caught().await; @@ -260,7 +258,7 @@ async fn mark_caught(state: State<'_, AppStateHandle>) -> Result { #[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 state = state.read().await; if let AppState::Game(game) = &*state { game.get_powerup().await; @@ -274,7 +272,7 @@ async fn grab_powerup(state: State<'_, AppStateHandle>) -> Result #[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 state = state.read().await; if let AppState::Game(game) = &*state { game.use_powerup().await; @@ -298,8 +296,8 @@ pub fn run() { switch_teams, host_start_game, mark_caught, - // grab_powerup, - // use_powerup, + grab_powerup, + use_powerup, ]); #[cfg(debug_assertions)] diff --git a/backend/src/transport.rs b/backend/src/transport.rs index 54e2636..a4ca19b 100644 --- a/backend/src/transport.rs +++ b/backend/src/transport.rs @@ -14,7 +14,7 @@ use crate::{ #[derive(Debug, Serialize, Deserialize, Clone)] pub enum TransportMessage { /// Message related to the actual game - Game(GameEvent), + Game(GameEvent), /// Message related to the pre-game lobby Lobby(LobbyMessage), /// Internal message when peer connects @@ -154,8 +154,8 @@ impl MatchboxTransport { } } -impl Transport for MatchboxTransport { - async fn receive_message(&self) -> Option> { +impl Transport for MatchboxTransport { + async fn receive_message(&self) -> Option { self.recv_transport_message() .await .and_then(|(_, msg)| match msg { @@ -164,7 +164,7 @@ impl Transport for MatchboxTransport { }) } - async fn send_message(&self, msg: GameEvent) { + async fn send_message(&self, msg: GameEvent) { let msg = TransportMessage::Game(msg); self.send_transport_message(None, msg).await; } diff --git a/frontend/src/bindings.ts b/frontend/src/bindings.ts index e69de29..4c03930 100644 --- a/frontend/src/bindings.ts +++ b/frontend/src/bindings.ts @@ -0,0 +1,357 @@ + +// This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually. + +/** user-defined commands **/ + + +export const commands = { +/** + * (Screen: Menu) Start/Join a new lobby, set `join_code` to `null` to be host, + * set it to a join code to be a client. This triggers a screen change to [AppScreen::Lobby] + */ +async startLobby(joinCode: string | null, settings: GameSettings) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("start_lobby", { joinCode, settings }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * Quit a running game or leave a lobby + */ +async quitGameOrLobby() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("quit_game_or_lobby") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * Get the screen the app should currently be on, returns [AppScreen] + */ +async getCurrentScreen() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("get_current_screen") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * (Screen: Menu) Update the player's profile and persist it + */ +async updateProfile(newProfile: PlayerProfile) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("update_profile", { newProfile }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * (Screen: Lobby) Get the current state of the lobby, call after receiving an update event + */ +async getLobbyState() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("get_lobby_state") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * (Screen: Lobby) HOST ONLY: Push new settings to everyone, does nothing on clients. Returns the + * new lobby state + */ +async hostUpdateSettings(settings: GameSettings) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("host_update_settings", { settings }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * (Screen: Lobby) Switch teams between seekers and hiders, returns the new [LobbyState] + */ +async switchTeams(seeker: boolean) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("switch_teams", { seeker }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * (Screen: Lobby) HOST ONLY: Start the game, stops anyone else from joining and switched screen + * to AppScreen::Game. + */ +async hostStartGame() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("host_start_game") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * (Screen: Game) Mark this player as caught, this player will become a seeker. Returns the new game state + */ +async markCaught() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("mark_caught") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * (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> { + try { + return { status: "ok", data: await TAURI_INVOKE("grab_powerup") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * (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> { + try { + return { status: "ok", data: await TAURI_INVOKE("use_powerup") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +} +} + +/** user-defined events **/ + + + +/** user-defined constants **/ + + + +/** user-defined types **/ + +export type AppScreen = "Setup" | "Menu" | "Lobby" | "Game" +/** + * Settings for the game, host is the only person able to change these + */ +export type GameSettings = { +/** + * The random seed used for shared rng + */ +random_seed: number; +/** + * The number of seconds to wait before seekers are allowed to go + */ +hiding_time_seconds: number; +/** + * Condition to wait for global pings to begin + */ +ping_start: PingStartCondition; +/** + * Time between pings after the condition is met (first ping is either after the interval or + * instantly after the condition is met depending on the condition) + */ +ping_minutes_interval: number; +/** + * Condition for powerups to start spawning + */ +powerup_start: PingStartCondition; +/** + * Chance every minute of a powerup spawning, out of 100 + */ +powerup_chance: number; +/** + * Hard cooldown between powerups spawning + */ +powerup_minutes_cooldown: number; +/** + * Locations that powerups may spawn at + */ +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; +/** + * True represents seeker, false hider + */ +teams: Partial<{ [key in string]: boolean }>; self_seeker: boolean; settings: GameSettings } +/** + * Some location in the world as gotten from a Geolocation API + */ +export type Location = { +/** + * Latitude + */ +lat: number; +/** + * Longitude + */ +long: number; +/** + * The bearing (float normalized from 0 to 1) optional as GPS can't always determine + */ +heading: number | null } +/** + * The starting condition for global pings to begin + */ +export type PingStartCondition = +/** + * Wait For X players to be caught before beginning global pings + */ +{ Players: number } | +/** + * Wait for X minutes after game start to begin global pings + */ +{ Minutes: number } | +/** + * Don't wait at all, ping location after seekers are released + */ +"Instant" +/** + * An on-map ping of a player + */ +export type PlayerPing = { +/** + * Location of the ping + */ +loc: Location; +/** + * Time the ping happened + */ +timestamp: string; +/** + * The player to display as + */ +display_player: string; +/** + * The actual player that initialized this ping + */ +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 **/ + +import { + invoke as TAURI_INVOKE, + Channel as TAURI_CHANNEL, +} from "@tauri-apps/api/core"; +import * as TAURI_API_EVENT from "@tauri-apps/api/event"; +import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow"; + +type __EventObj__ = { + listen: ( + cb: TAURI_API_EVENT.EventCallback, + ) => ReturnType>; + once: ( + cb: TAURI_API_EVENT.EventCallback, + ) => ReturnType>; + emit: null extends T + ? (payload?: T) => ReturnType + : (payload: T) => ReturnType; +}; + +export type Result = + | { status: "ok"; data: T } + | { status: "error"; error: E }; + +function __makeEvents__>( + mappings: Record, +) { + return new Proxy( + {} as unknown as { + [K in keyof T]: __EventObj__ & { + (handle: __WebviewWindow__): __EventObj__; + }; + }, + { + get: (_, event) => { + const name = mappings[event as keyof T]; + + return new Proxy((() => {}) as any, { + apply: (_, __, [window]: [__WebviewWindow__]) => ({ + listen: (arg: any) => window.listen(name, arg), + once: (arg: any) => window.once(name, arg), + emit: (arg: any) => window.emit(name, arg), + }), + get: (_, command: keyof __EventObj__) => { + switch (command) { + case "listen": + return (arg: any) => TAURI_API_EVENT.listen(name, arg); + case "once": + return (arg: any) => TAURI_API_EVENT.once(name, arg); + case "emit": + return (arg: any) => TAURI_API_EVENT.emit(name, arg); + } + }, + }); + }, + }, + ); +}