···1818serde = { version = "1", features = ["derive"] }
1919serde_json = "1"
2020chrono = { version = "0.4", features = ["serde", "now"] }
2121-tokio = { version = "1.45", features = ["sync", "macros", "time"] }
2121+tokio = { version = "1.45", features = ["sync", "macros", "time", "fs"] }
2222rand = { version = "0.9", features = ["thread_rng"] }
2323tauri-plugin-geolocation = "2.2"
2424rand_chacha = "0.9.0"
2525futures = "0.3.31"
2626+matchbox_socket = "0.12.0"
2727+uuid = "1.17.0"
2828+rmp-serde = "1.3.0"
2929+tauri-plugin-store = "2.2.0"
3030+specta = { version = "=2.0.0-rc.22", features = ["chrono", "uuid"] }
3131+tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
3232+specta-typescript = "0.0.9"
+1-1
backend/src/game/location.rs
···33/// A "part" of a location
44pub type LocationComponent = f64;
5566-#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
66+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, specta::Type)]
77/// Some location in the world as gotten from a Geolocation API
88pub struct Location {
99 /// Latitude
+17-10
backend/src/game/mod.rs
···11use chrono::{DateTime, Utc};
22pub use events::GameEvent;
33-use matchbox_socket::PeerId;
43use powerups::PowerUpType;
54pub use settings::GameSettings;
66-use std::{collections::HashMap, fmt::Debug, hash::Hash, ops::Deref, sync::Arc, time::Duration};
55+use std::{
66+ collections::HashMap,
77+ fmt::{Debug, Display},
88+ hash::Hash,
99+ sync::Arc,
1010+ time::Duration,
1111+};
712use uuid::Uuid;
813914use tokio::{sync::RwLock, time::MissedTickBehavior};
···1621mod transport;
17221823pub use location::{Location, LocationService};
1919-use state::GameState;
2424+pub use state::GameState;
2025pub use transport::Transport;
21262222-/// Type used to uniquely identify players in the game
2327pub trait PlayerId:
2424- Debug + Hash + Ord + Eq + PartialEq + Send + Sync + Sized + Copy + Clone
2828+ Display + Debug + Hash + Ord + Eq + PartialEq + Send + Sync + Sized + Copy + Clone + specta::Type
2529{
3030+2631}
27322833impl PlayerId for Uuid {}
2929-impl PlayerId for PeerId {}
30343135/// Convenence alias for UTC DT
3236pub type UtcDT = DateTime<Utc>;
···5862 interval,
5963 state: RwLock::new(state),
6064 }
6565+ }
6666+6767+ pub async fn clone_state(&self) -> GameState<Id> {
6868+ self.state.read().await.clone()
6169 }
62706371 pub async fn mark_caught(&self) {
···244252 }
245253246254 async fn send_message(&self, msg: GameEvent<u32>) {
247247- for (id, tx) in self.txs.iter().enumerate() {
255255+ for (_id, tx) in self.txs.iter().enumerate() {
248256 tx.send(msg.clone()).await.expect("Failed to send msg");
249257 }
250258 }
···318326 }
319327320328 pub async fn start(&self) {
321321- for (id, game) in &self.games {
329329+ for (_id, game) in &self.games {
322330 let game = game.clone();
323323- let id = *id;
324331 tokio::spawn(async move {
325332 game.main_loop().await;
326333 });
···572579 async fn test_powerup_ping_seekers() {
573580 let settings = mk_settings();
574581575575- let mut mat = MockMatch::new(settings, 5, 3);
582582+ let mat = MockMatch::new(settings, 5, 3);
576583577584 mat.start().await;
578585
+1-3
backend/src/game/powerups.rs
···11use serde::{Deserialize, Serialize};
2233-use super::{location::Location, PlayerId};
44-55-#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
33+#[derive(Debug, Clone, Copy, Serialize, Deserialize, specta::Type)]
64/// Type of powerup
75pub enum PowerUpType {
86 /// Ping a random seeker instead of a hider
+6-6
backend/src/game/settings.rs
···3344use super::location::Location;
5566-#[derive(Debug, Clone, Serialize, Deserialize)]
66+#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
77/// The starting condition for global pings to begin
88pub enum PingStartCondition {
99 /// Wait For X players to be caught before beginning global pings
···1414 Instant,
1515}
16161717-#[derive(Debug, Clone, Serialize, Deserialize)]
1717+#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
1818/// Settings for the game, host is the only person able to change these
1919pub struct GameSettings {
2020 /// The random seed used for shared rng
2121- pub random_seed: u64,
2121+ pub random_seed: u32,
2222 /// The number of seconds to wait before seekers are allowed to go
2323 pub hiding_time_seconds: u32,
2424 /// Condition to wait for global pings to begin
2525 pub ping_start: PingStartCondition,
2626 /// Time between pings after the condition is met (first ping is either after the interval or
2727 /// instantly after the condition is met depending on the condition)
2828- pub ping_minutes_interval: u64,
2828+ pub ping_minutes_interval: u32,
2929 /// Condition for powerups to start spawning
3030 pub powerup_start: PingStartCondition,
3131 /// Chance every minute of a powerup spawning, out of 100
3232 pub powerup_chance: u32,
3333 /// Hard cooldown between powerups spawning
3434- pub powerup_minutes_cooldown: u64,
3434+ pub powerup_minutes_cooldown: u32,
3535 /// Locations that powerups may spawn at
3636 pub powerup_locations: Vec<Location>,
3737}
···4545impl Default for GameSettings {
4646 fn default() -> Self {
4747 Self {
4848- random_seed: rand::random_range(0..=u64::MAX),
4848+ random_seed: rand::random_range(0..=u32::MAX),
4949 hiding_time_seconds: 60,
5050 ping_start: PingStartCondition::Players(2),
5151 ping_minutes_interval: 3,
+12-10
backend/src/game/state.rs
···11use std::collections::HashMap;
22-use std::sync::Arc;
3244-use chrono::{DateTime, Utc};
33+use chrono::Utc;
54use rand::{
65 distr::{Bernoulli, Distribution},
77- rngs::ThreadRng,
86 seq::{IndexedRandom, IteratorRandom},
97 Rng, SeedableRng,
108};
···1816 PlayerId, UtcDT,
1917};
20182121-#[derive(Debug, Clone, Serialize, Deserialize)]
1919+#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
2220/// An on-map ping of a player
2321pub struct PlayerPing<Id: PlayerId> {
2422 /// Location of the ping
···4240 }
4341}
44424545-#[derive(Debug, Clone, Serialize)]
4646-/// Represents the game's state as a whole, seamlessly connects public and player state.
4343+#[derive(Debug, Clone, Serialize, specta::Type)]
4744/// This struct handles all logic regarding state updates
4845pub struct GameState<Id: PlayerId> {
4946 /// The id of this player in this game
···7370 /// Powerup on the map that players can grab. Only one at a time
7471 available_powerup: Option<Location>,
75727373+ #[serde(skip)]
7674 /// The game's current settings
7775 settings: GameSettings,
7876···96949795impl<Id: PlayerId> GameState<Id> {
9896 pub fn new(settings: GameSettings, my_id: Id, initial_caught_state: HashMap<Id, bool>) -> Self {
9999- let mut rand = ChaCha20Rng::seed_from_u64(settings.random_seed);
9797+ let mut rand = ChaCha20Rng::seed_from_u64(settings.random_seed as u64);
10098 let increment = rand.random_range(-100..100);
10199102100 Self {
···107105 caught_state: initial_caught_state,
108106 available_powerup: None,
109107 powerup_bernoulli: settings.get_powerup_bernoulli(),
110110- shared_random_state: settings.random_seed,
108108+ shared_random_state: settings.random_seed as u64,
111109 settings,
112110 last_global_ping: None,
113111 last_powerup_spawn: None,
···169167 !self.is_seeker()
170168 && self.last_global_ping.as_ref().is_some_and(|last_ping| {
171169 let minutes = (*now - *last_ping).num_minutes().unsigned_abs();
172172- minutes >= self.settings.ping_minutes_interval
170170+ minutes >= (self.settings.ping_minutes_interval as u64)
173171 })
174172 }
175173···202200 pub fn should_spawn_powerup(&self, now: &UtcDT) -> bool {
203201 self.last_powerup_spawn.as_ref().is_some_and(|last_spawn| {
204202 let minutes = (*now - *last_spawn).num_minutes().unsigned_abs();
205205- minutes >= self.settings.powerup_minutes_cooldown
203203+ minutes >= (self.settings.powerup_minutes_cooldown as u64)
206204 })
207205 }
208206207207+ #[cfg(test)]
209208 pub fn powerup_location(&self) -> Option<Location> {
210209 self.available_powerup
211210 }
···236235 }
237236238237 /// Get a ping for a player
238238+ #[cfg(test)]
239239 pub fn get_ping(&self, player: Id) -> Option<&PlayerPing<Id>> {
240240 self.pings.get(&player)
241241 }
···292292 self.held_powerup = choice;
293293 }
294294295295+ #[cfg(test)]
295296 pub fn force_set_powerup(&mut self, typ: PowerUpType) {
296297 self.held_powerup = Some(typ);
297298 }
···323324 }
324325325326 /// Gets if a player was caught or not
327327+ #[cfg(test)]
326328 pub fn get_caught(&self, player: Id) -> Option<bool> {
327329 self.caught_state.get(&player).copied()
328330 }
+199-20
backend/src/lib.rs
···6677use std::{sync::Arc, time::Duration};
8899-use game::{Game as BaseGame, GameSettings};
1010-use lobby::{Lobby, StartGameInfo};
99+use game::{Game as BaseGame, GameSettings, GameState as BaseGameState};
1010+use lobby::{Lobby, LobbyState, StartGameInfo};
1111use location::TauriLocation;
1212-use matchbox_socket::PeerId;
1312use profile::PlayerProfile;
1313+use serde::{Deserialize, Serialize};
1414+use specta_typescript::Typescript;
1415use tauri::{AppHandle, Manager, State};
1616+use tauri_specta::collect_commands;
1517use tokio::sync::RwLock;
1618use transport::MatchboxTransport;
1919+use uuid::Uuid;
17201818-type Game = BaseGame<PeerId, TauriLocation, MatchboxTransport>;
2121+type Game = BaseGame<Uuid, TauriLocation, MatchboxTransport>;
19222023enum AppState {
2124 Setup,
···4548}
46494750impl AppState {
4848- pub fn start_game(&mut self, app: AppHandle, my_id: PeerId, start: StartGameInfo) {
5151+ pub fn start_game(&mut self, app: AppHandle, my_id: Uuid, start: StartGameInfo) {
4952 match self {
5053 AppState::Lobby(lobby) => {
5154 let transport = lobby.clone_transport();
···7780 AppState::Menu(profile) => {
7881 let host = join_code.is_none();
7982 let room_code = join_code.unwrap_or_else(generate_join_code);
8080- let app_after = app.clone();
8183 let lobby = Arc::new(Lobby::new(
8284 server_url(),
8385 &room_code,
8484- app,
8586 host,
8687 profile.clone(),
8788 settings,
···8990 *self = AppState::Lobby(lobby.clone());
9091 tokio::spawn(async move {
9192 let (my_id, start) = lobby.open().await;
9292- let app_game = app_after.clone();
9393- let state_handle = app_after.state::<AppStateHandle>();
9393+ let app_game = app.clone();
9494+ let state_handle = app.state::<AppStateHandle>();
9495 let mut state = state_handle.write().await;
9596 state.start_game(app_game, my_id, start);
9697 });
···100101 }
101102}
102103104104+use std::result::Result as StdResult;
105105+106106+type Result<T = (), E = String> = StdResult<T, E>;
107107+108108+#[derive(Serialize, Deserialize, specta::Type, Debug, Clone)]
109109+enum AppScreen {
110110+ Setup,
111111+ Menu,
112112+ Lobby,
113113+ Game,
114114+}
115115+116116+// == GENERAL / FLOW COMMANDS ==
117117+103118#[tauri::command]
104104-async fn go_to_lobby(
119119+#[specta::specta]
120120+/// Get the screen the app should currently be on, returns [AppScreen]
121121+async fn get_current_screen(state: State<'_, AppStateHandle>) -> Result<AppScreen> {
122122+ let state = state.read().await;
123123+ Ok(match &*state {
124124+ AppState::Setup => AppScreen::Setup,
125125+ AppState::Menu(_player_profile) => AppScreen::Menu,
126126+ AppState::Lobby(_lobby) => AppScreen::Lobby,
127127+ AppState::Game(_game) => AppScreen::Game,
128128+ })
129129+}
130130+131131+#[tauri::command]
132132+#[specta::specta]
133133+/// Quit a running game or leave a lobby
134134+async fn quit_game_or_lobby(app: AppHandle, state: State<'_, AppStateHandle>) -> Result {
135135+ let mut state = state.write().await;
136136+ let profile = match &*state {
137137+ AppState::Setup => Err("Invalid Screen".to_string()),
138138+ AppState::Menu(_) => Err("Already In Menu".to_string()),
139139+ AppState::Lobby(_) | AppState::Game(_) => Ok(PlayerProfile::load_from_store(&app)),
140140+ }?;
141141+ if let Some(profile) = profile {
142142+ *state = AppState::Menu(profile);
143143+ } else {
144144+ *state = AppState::Setup;
145145+ }
146146+ Ok(())
147147+}
148148+149149+// == AppState::Menu COMMANDS ==
150150+151151+#[tauri::command]
152152+#[specta::specta]
153153+/// (Screen: Menu) Update the player's profile and persist it
154154+async fn update_profile(
155155+ new_profile: PlayerProfile,
156156+ app: AppHandle,
157157+ state: State<'_, AppStateHandle>,
158158+) -> Result {
159159+ new_profile.write_to_store(&app);
160160+ let mut state = state.write().await;
161161+ if let AppState::Menu(profile) = &mut *state {
162162+ *profile = new_profile;
163163+ Ok(())
164164+ } else {
165165+ Err("Profile can only be updated on Menu screen".to_string())
166166+ }
167167+}
168168+169169+#[tauri::command]
170170+#[specta::specta]
171171+/// (Screen: Menu) Start/Join a new lobby, set `join_code` to `null` to be host,
172172+/// set it to a join code to be a client. This triggers a screen change to [AppScreen::Lobby]
173173+async fn start_lobby(
105174 app: AppHandle,
106175 join_code: Option<String>,
107176 settings: GameSettings,
108177 state: State<'_, AppStateHandle>,
109109-) -> Result<(), String> {
178178+) -> Result {
110179 let mut state = state.write().await;
111180 state.start_lobby(join_code, app, settings);
112181 Ok(())
113182}
114183184184+// AppState::Lobby COMMANDS
185185+115186#[tauri::command]
116116-async fn host_start_game(state: State<'_, AppStateHandle>) -> Result<(), String> {
187187+#[specta::specta]
188188+/// (Screen: Lobby) Get the current state of the lobby, call after receiving an update event
189189+async fn get_lobby_state(state: State<'_, AppStateHandle>) -> Result<LobbyState> {
190190+ let state = state.read().await;
191191+ if let AppState::Lobby(lobby) = &*state {
192192+ Ok(lobby.clone_state().await)
193193+ } else {
194194+ Err("Must be called on Lobby screen".to_string())
195195+ }
196196+}
197197+198198+#[tauri::command]
199199+#[specta::specta]
200200+/// (Screen: Lobby) Switch teams between seekers and hiders, returns the new [LobbyState]
201201+async fn switch_teams(seeker: bool, state: State<'_, AppStateHandle>) -> Result<LobbyState> {
202202+ let state = state.read().await;
203203+ if let AppState::Lobby(lobby) = &*state {
204204+ lobby.switch_teams(seeker).await;
205205+ Ok(lobby.clone_state().await)
206206+ } else {
207207+ Err("Must be called on Lobby screen".to_string())
208208+ }
209209+}
210210+211211+#[tauri::command]
212212+#[specta::specta]
213213+/// (Screen: Lobby) HOST ONLY: Push new settings to everyone, does nothing on clients. Returns the
214214+/// new lobby state
215215+async fn host_update_settings(
216216+ settings: GameSettings,
217217+ state: State<'_, AppStateHandle>,
218218+) -> Result<LobbyState> {
219219+ let state = state.read().await;
220220+ if let AppState::Lobby(lobby) = &*state {
221221+ lobby.update_settings(settings).await;
222222+ Ok(lobby.clone_state().await)
223223+ } else {
224224+ Err("Must be called on Lobby screen".to_string())
225225+ }
226226+}
227227+228228+#[tauri::command]
229229+#[specta::specta]
230230+/// (Screen: Lobby) HOST ONLY: Start the game, stops anyone else from joining and switched screen
231231+/// to AppScreen::Game.
232232+async fn host_start_game(state: State<'_, AppStateHandle>) -> Result {
233233+ let state = state.read().await;
234234+ if let AppState::Lobby(lobby) = &*state {
235235+ lobby.start_game().await;
236236+ Ok(())
237237+ } else {
238238+ Err("Must be called on Lobby screen".to_string())
239239+ }
240240+}
241241+242242+// AppScreen::Game COMMANDS
243243+244244+type AppGameState = BaseGameState<Uuid>;
245245+246246+#[tauri::command]
247247+#[specta::specta]
248248+/// (Screen: Game) Mark this player as caught, this player will become a seeker. Returns the new game state
249249+async fn mark_caught(state: State<'_, AppStateHandle>) -> Result<AppGameState> {
250250+ let state = state.read().await;
251251+ if let AppState::Game(game) = &*state {
252252+ game.mark_caught().await;
253253+ Ok(game.clone_state().await)
254254+ } else {
255255+ Err("Must be called on Game screen".to_string())
256256+ }
257257+}
258258+259259+#[tauri::command]
260260+#[specta::specta]
261261+/// (Screen: Game) Grab a powerup on the map, this should be called when the user is *in range* of
262262+/// the powerup. Returns the new game state after rolling for the powerup
263263+async fn grab_powerup(state: State<'_, AppStateHandle>) -> Result<AppGameState> {
117264 let state = state.read().await;
118118- match &*state {
119119- AppState::Lobby(lobby) => {
120120- lobby.start_game().await;
121121- Ok(())
122122- }
123123- _ => Err("Invalid AppState".to_string()),
265265+ if let AppState::Game(game) = &*state {
266266+ game.get_powerup().await;
267267+ Ok(game.clone_state().await)
268268+ } else {
269269+ Err("Must be called on Game screen".to_string())
270270+ }
271271+}
272272+273273+#[tauri::command]
274274+#[specta::specta]
275275+/// (Screen: Game) Use the currently held powerup in the player's held_powerup. Does nothing if the
276276+/// player has none. Returns the updated game state
277277+async fn use_powerup(state: State<'_, AppStateHandle>) -> Result<AppGameState> {
278278+ let state = state.read().await;
279279+ if let AppState::Game(game) = &*state {
280280+ game.use_powerup().await;
281281+ Ok(game.clone_state().await)
282282+ } else {
283283+ Err("Must be called on Game screen".to_string())
124284 }
125285}
126286···128288pub fn run() {
129289 let state = RwLock::new(AppState::Setup);
130290291291+ let builder = tauri_specta::Builder::<tauri::Wry>::new().commands(collect_commands![
292292+ start_lobby,
293293+ quit_game_or_lobby,
294294+ get_current_screen,
295295+ update_profile,
296296+ get_lobby_state,
297297+ host_update_settings,
298298+ switch_teams,
299299+ host_start_game,
300300+ mark_caught,
301301+ // grab_powerup,
302302+ // use_powerup,
303303+ ]);
304304+305305+ #[cfg(debug_assertions)]
306306+ builder
307307+ .export(Typescript::default(), "../frontend/src/bindings.ts")
308308+ .expect("Failed to export typescript bindings");
309309+131310 tauri::Builder::default()
132311 .manage(state)
133312 .plugin(tauri_plugin_opener::init())
134313 .plugin(tauri_plugin_geolocation::init())
135314 .plugin(tauri_plugin_store::Builder::default().build())
315315+ .invoke_handler(builder.invoke_handler())
136316 .setup(|app| {
137317 let handle = app.handle().clone();
138138- tokio::spawn(async move {
318318+ tauri::async_runtime::spawn(async move {
139319 if let Some(profile) = PlayerProfile::load_from_store(&handle) {
140320 let state_handle = handle.state::<AppStateHandle>();
141321 let mut state = state_handle.write().await;
···144324 });
145325 Ok(())
146326 })
147147- .invoke_handler(tauri::generate_handler![go_to_lobby])
148327 .run(tauri::generate_context!())
149328 .expect("error while running tauri application");
150329}