···18serde = { version = "1", features = ["derive"] }
19serde_json = "1"
20chrono = { version = "0.4", features = ["serde", "now"] }
21-tokio = { version = "1.45", features = ["sync", "macros", "time"] }
22rand = { version = "0.9", features = ["thread_rng"] }
23tauri-plugin-geolocation = "2.2"
24rand_chacha = "0.9.0"
25futures = "0.3.31"
0000000
···18serde = { version = "1", features = ["derive"] }
19serde_json = "1"
20chrono = { version = "0.4", features = ["serde", "now"] }
21+tokio = { version = "1.45", features = ["sync", "macros", "time", "fs"] }
22rand = { version = "0.9", features = ["thread_rng"] }
23tauri-plugin-geolocation = "2.2"
24rand_chacha = "0.9.0"
25futures = "0.3.31"
26+matchbox_socket = "0.12.0"
27+uuid = "1.17.0"
28+rmp-serde = "1.3.0"
29+tauri-plugin-store = "2.2.0"
30+specta = { version = "=2.0.0-rc.22", features = ["chrono", "uuid"] }
31+tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
32+specta-typescript = "0.0.9"
+1-1
backend/src/game/location.rs
···3/// A "part" of a location
4pub type LocationComponent = f64;
56-#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
7/// Some location in the world as gotten from a Geolocation API
8pub struct Location {
9 /// Latitude
···3/// A "part" of a location
4pub type LocationComponent = f64;
56+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, specta::Type)]
7/// Some location in the world as gotten from a Geolocation API
8pub struct Location {
9 /// Latitude
+17-10
backend/src/game/mod.rs
···1use chrono::{DateTime, Utc};
2pub use events::GameEvent;
3-use matchbox_socket::PeerId;
4use powerups::PowerUpType;
5pub use settings::GameSettings;
6-use std::{collections::HashMap, fmt::Debug, hash::Hash, ops::Deref, sync::Arc, time::Duration};
0000007use uuid::Uuid;
89use tokio::{sync::RwLock, time::MissedTickBehavior};
···16mod transport;
1718pub use location::{Location, LocationService};
19-use state::GameState;
20pub use transport::Transport;
2122-/// Type used to uniquely identify players in the game
23pub trait PlayerId:
24- Debug + Hash + Ord + Eq + PartialEq + Send + Sync + Sized + Copy + Clone
25{
026}
2728impl PlayerId for Uuid {}
29-impl PlayerId for PeerId {}
3031/// Convenence alias for UTC DT
32pub type UtcDT = DateTime<Utc>;
···58 interval,
59 state: RwLock::new(state),
60 }
000061 }
6263 pub async fn mark_caught(&self) {
···244 }
245246 async fn send_message(&self, msg: GameEvent<u32>) {
247- for (id, tx) in self.txs.iter().enumerate() {
248 tx.send(msg.clone()).await.expect("Failed to send msg");
249 }
250 }
···318 }
319320 pub async fn start(&self) {
321- for (id, game) in &self.games {
322 let game = game.clone();
323- let id = *id;
324 tokio::spawn(async move {
325 game.main_loop().await;
326 });
···572 async fn test_powerup_ping_seekers() {
573 let settings = mk_settings();
574575- let mut mat = MockMatch::new(settings, 5, 3);
576577 mat.start().await;
578
···1use chrono::{DateTime, Utc};
2pub use events::GameEvent;
03use powerups::PowerUpType;
4pub use settings::GameSettings;
5+use std::{
6+ collections::HashMap,
7+ fmt::{Debug, Display},
8+ hash::Hash,
9+ sync::Arc,
10+ time::Duration,
11+};
12use uuid::Uuid;
1314use tokio::{sync::RwLock, time::MissedTickBehavior};
···21mod transport;
2223pub use location::{Location, LocationService};
24+pub use state::GameState;
25pub use transport::Transport;
26027pub trait PlayerId:
28+ Display + Debug + Hash + Ord + Eq + PartialEq + Send + Sync + Sized + Copy + Clone + specta::Type
29{
30+31}
3233impl PlayerId for Uuid {}
03435/// Convenence alias for UTC DT
36pub type UtcDT = DateTime<Utc>;
···62 interval,
63 state: RwLock::new(state),
64 }
65+ }
66+67+ pub async fn clone_state(&self) -> GameState<Id> {
68+ self.state.read().await.clone()
69 }
7071 pub async fn mark_caught(&self) {
···252 }
253254 async fn send_message(&self, msg: GameEvent<u32>) {
255+ for (_id, tx) in self.txs.iter().enumerate() {
256 tx.send(msg.clone()).await.expect("Failed to send msg");
257 }
258 }
···326 }
327328 pub async fn start(&self) {
329+ for (_id, game) in &self.games {
330 let game = game.clone();
0331 tokio::spawn(async move {
332 game.main_loop().await;
333 });
···579 async fn test_powerup_ping_seekers() {
580 let settings = mk_settings();
581582+ let mat = MockMatch::new(settings, 5, 3);
583584 mat.start().await;
585
+1-3
backend/src/game/powerups.rs
···1use serde::{Deserialize, Serialize};
23-use super::{location::Location, PlayerId};
4-5-#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
6/// Type of powerup
7pub enum PowerUpType {
8 /// Ping a random seeker instead of a hider
···1use serde::{Deserialize, Serialize};
23+#[derive(Debug, Clone, Copy, Serialize, Deserialize, specta::Type)]
004/// Type of powerup
5pub enum PowerUpType {
6 /// Ping a random seeker instead of a hider
+6-6
backend/src/game/settings.rs
···34use super::location::Location;
56-#[derive(Debug, Clone, Serialize, Deserialize)]
7/// The starting condition for global pings to begin
8pub enum PingStartCondition {
9 /// Wait For X players to be caught before beginning global pings
···14 Instant,
15}
1617-#[derive(Debug, Clone, Serialize, Deserialize)]
18/// Settings for the game, host is the only person able to change these
19pub struct GameSettings {
20 /// The random seed used for shared rng
21- pub random_seed: u64,
22 /// The number of seconds to wait before seekers are allowed to go
23 pub hiding_time_seconds: u32,
24 /// Condition to wait for global pings to begin
25 pub ping_start: PingStartCondition,
26 /// Time between pings after the condition is met (first ping is either after the interval or
27 /// instantly after the condition is met depending on the condition)
28- pub ping_minutes_interval: u64,
29 /// Condition for powerups to start spawning
30 pub powerup_start: PingStartCondition,
31 /// Chance every minute of a powerup spawning, out of 100
32 pub powerup_chance: u32,
33 /// Hard cooldown between powerups spawning
34- pub powerup_minutes_cooldown: u64,
35 /// Locations that powerups may spawn at
36 pub powerup_locations: Vec<Location>,
37}
···45impl Default for GameSettings {
46 fn default() -> Self {
47 Self {
48- random_seed: rand::random_range(0..=u64::MAX),
49 hiding_time_seconds: 60,
50 ping_start: PingStartCondition::Players(2),
51 ping_minutes_interval: 3,
···34use super::location::Location;
56+#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
7/// The starting condition for global pings to begin
8pub enum PingStartCondition {
9 /// Wait For X players to be caught before beginning global pings
···14 Instant,
15}
1617+#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
18/// Settings for the game, host is the only person able to change these
19pub struct GameSettings {
20 /// The random seed used for shared rng
21+ pub random_seed: u32,
22 /// The number of seconds to wait before seekers are allowed to go
23 pub hiding_time_seconds: u32,
24 /// Condition to wait for global pings to begin
25 pub ping_start: PingStartCondition,
26 /// Time between pings after the condition is met (first ping is either after the interval or
27 /// instantly after the condition is met depending on the condition)
28+ pub ping_minutes_interval: u32,
29 /// Condition for powerups to start spawning
30 pub powerup_start: PingStartCondition,
31 /// Chance every minute of a powerup spawning, out of 100
32 pub powerup_chance: u32,
33 /// Hard cooldown between powerups spawning
34+ pub powerup_minutes_cooldown: u32,
35 /// Locations that powerups may spawn at
36 pub powerup_locations: Vec<Location>,
37}
···45impl Default for GameSettings {
46 fn default() -> Self {
47 Self {
48+ random_seed: rand::random_range(0..=u32::MAX),
49 hiding_time_seconds: 60,
50 ping_start: PingStartCondition::Players(2),
51 ping_minutes_interval: 3,
+12-10
backend/src/game/state.rs
···1use std::collections::HashMap;
2-use std::sync::Arc;
34-use chrono::{DateTime, Utc};
5use rand::{
6 distr::{Bernoulli, Distribution},
7- rngs::ThreadRng,
8 seq::{IndexedRandom, IteratorRandom},
9 Rng, SeedableRng,
10};
···18 PlayerId, UtcDT,
19};
2021-#[derive(Debug, Clone, Serialize, Deserialize)]
22/// An on-map ping of a player
23pub struct PlayerPing<Id: PlayerId> {
24 /// Location of the ping
···42 }
43}
4445-#[derive(Debug, Clone, Serialize)]
46-/// Represents the game's state as a whole, seamlessly connects public and player state.
47/// This struct handles all logic regarding state updates
48pub struct GameState<Id: PlayerId> {
49 /// The id of this player in this game
···73 /// Powerup on the map that players can grab. Only one at a time
74 available_powerup: Option<Location>,
75076 /// The game's current settings
77 settings: GameSettings,
78···9697impl<Id: PlayerId> GameState<Id> {
98 pub fn new(settings: GameSettings, my_id: Id, initial_caught_state: HashMap<Id, bool>) -> Self {
99- let mut rand = ChaCha20Rng::seed_from_u64(settings.random_seed);
100 let increment = rand.random_range(-100..100);
101102 Self {
···107 caught_state: initial_caught_state,
108 available_powerup: None,
109 powerup_bernoulli: settings.get_powerup_bernoulli(),
110- shared_random_state: settings.random_seed,
111 settings,
112 last_global_ping: None,
113 last_powerup_spawn: None,
···169 !self.is_seeker()
170 && self.last_global_ping.as_ref().is_some_and(|last_ping| {
171 let minutes = (*now - *last_ping).num_minutes().unsigned_abs();
172- minutes >= self.settings.ping_minutes_interval
173 })
174 }
175···202 pub fn should_spawn_powerup(&self, now: &UtcDT) -> bool {
203 self.last_powerup_spawn.as_ref().is_some_and(|last_spawn| {
204 let minutes = (*now - *last_spawn).num_minutes().unsigned_abs();
205- minutes >= self.settings.powerup_minutes_cooldown
206 })
207 }
2080209 pub fn powerup_location(&self) -> Option<Location> {
210 self.available_powerup
211 }
···236 }
237238 /// Get a ping for a player
0239 pub fn get_ping(&self, player: Id) -> Option<&PlayerPing<Id>> {
240 self.pings.get(&player)
241 }
···292 self.held_powerup = choice;
293 }
2940295 pub fn force_set_powerup(&mut self, typ: PowerUpType) {
296 self.held_powerup = Some(typ);
297 }
···323 }
324325 /// Gets if a player was caught or not
0326 pub fn get_caught(&self, player: Id) -> Option<bool> {
327 self.caught_state.get(&player).copied()
328 }
···1use std::collections::HashMap;
023+use chrono::Utc;
4use rand::{
5 distr::{Bernoulli, Distribution},
06 seq::{IndexedRandom, IteratorRandom},
7 Rng, SeedableRng,
8};
···16 PlayerId, UtcDT,
17};
1819+#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
20/// An on-map ping of a player
21pub struct PlayerPing<Id: PlayerId> {
22 /// Location of the ping
···40 }
41}
4243+#[derive(Debug, Clone, Serialize, specta::Type)]
044/// This struct handles all logic regarding state updates
45pub struct GameState<Id: PlayerId> {
46 /// The id of this player in this game
···70 /// Powerup on the map that players can grab. Only one at a time
71 available_powerup: Option<Location>,
7273+ #[serde(skip)]
74 /// The game's current settings
75 settings: GameSettings,
76···9495impl<Id: PlayerId> GameState<Id> {
96 pub fn new(settings: GameSettings, my_id: Id, initial_caught_state: HashMap<Id, bool>) -> Self {
97+ let mut rand = ChaCha20Rng::seed_from_u64(settings.random_seed as u64);
98 let increment = rand.random_range(-100..100);
99100 Self {
···105 caught_state: initial_caught_state,
106 available_powerup: None,
107 powerup_bernoulli: settings.get_powerup_bernoulli(),
108+ shared_random_state: settings.random_seed as u64,
109 settings,
110 last_global_ping: None,
111 last_powerup_spawn: None,
···167 !self.is_seeker()
168 && self.last_global_ping.as_ref().is_some_and(|last_ping| {
169 let minutes = (*now - *last_ping).num_minutes().unsigned_abs();
170+ minutes >= (self.settings.ping_minutes_interval as u64)
171 })
172 }
173···200 pub fn should_spawn_powerup(&self, now: &UtcDT) -> bool {
201 self.last_powerup_spawn.as_ref().is_some_and(|last_spawn| {
202 let minutes = (*now - *last_spawn).num_minutes().unsigned_abs();
203+ minutes >= (self.settings.powerup_minutes_cooldown as u64)
204 })
205 }
206207+ #[cfg(test)]
208 pub fn powerup_location(&self) -> Option<Location> {
209 self.available_powerup
210 }
···235 }
236237 /// Get a ping for a player
238+ #[cfg(test)]
239 pub fn get_ping(&self, player: Id) -> Option<&PlayerPing<Id>> {
240 self.pings.get(&player)
241 }
···292 self.held_powerup = choice;
293 }
294295+ #[cfg(test)]
296 pub fn force_set_powerup(&mut self, typ: PowerUpType) {
297 self.held_powerup = Some(typ);
298 }
···324 }
325326 /// Gets if a player was caught or not
327+ #[cfg(test)]
328 pub fn get_caught(&self, player: Id) -> Option<bool> {
329 self.caught_state.get(&player).copied()
330 }
+199-20
backend/src/lib.rs
···67use std::{sync::Arc, time::Duration};
89-use game::{Game as BaseGame, GameSettings};
10-use lobby::{Lobby, StartGameInfo};
11use location::TauriLocation;
12-use matchbox_socket::PeerId;
13use profile::PlayerProfile;
0014use tauri::{AppHandle, Manager, State};
015use tokio::sync::RwLock;
16use transport::MatchboxTransport;
01718-type Game = BaseGame<PeerId, TauriLocation, MatchboxTransport>;
1920enum AppState {
21 Setup,
···45}
4647impl AppState {
48- pub fn start_game(&mut self, app: AppHandle, my_id: PeerId, start: StartGameInfo) {
49 match self {
50 AppState::Lobby(lobby) => {
51 let transport = lobby.clone_transport();
···77 AppState::Menu(profile) => {
78 let host = join_code.is_none();
79 let room_code = join_code.unwrap_or_else(generate_join_code);
80- let app_after = app.clone();
81 let lobby = Arc::new(Lobby::new(
82 server_url(),
83 &room_code,
84- app,
85 host,
86 profile.clone(),
87 settings,
···89 *self = AppState::Lobby(lobby.clone());
90 tokio::spawn(async move {
91 let (my_id, start) = lobby.open().await;
92- let app_game = app_after.clone();
93- let state_handle = app_after.state::<AppStateHandle>();
94 let mut state = state_handle.write().await;
95 state.start_game(app_game, my_id, start);
96 });
···100 }
101}
10200000000000000103#[tauri::command]
104-async fn go_to_lobby(
000000000000000000000000000000000000000000000000000000105 app: AppHandle,
106 join_code: Option<String>,
107 settings: GameSettings,
108 state: State<'_, AppStateHandle>,
109-) -> Result<(), String> {
110 let mut state = state.write().await;
111 state.start_lobby(join_code, app, settings);
112 Ok(())
113}
11400115#[tauri::command]
116-async fn host_start_game(state: State<'_, AppStateHandle>) -> Result<(), String> {
0000000000000000000000000000000000000000000000000000000000000000000000000000117 let state = state.read().await;
118- match &*state {
119- AppState::Lobby(lobby) => {
120- lobby.start_game().await;
121- Ok(())
122- }
123- _ => Err("Invalid AppState".to_string()),
0000000000000124 }
125}
126···128pub fn run() {
129 let state = RwLock::new(AppState::Setup);
1300000000000000000000131 tauri::Builder::default()
132 .manage(state)
133 .plugin(tauri_plugin_opener::init())
134 .plugin(tauri_plugin_geolocation::init())
135 .plugin(tauri_plugin_store::Builder::default().build())
0136 .setup(|app| {
137 let handle = app.handle().clone();
138- tokio::spawn(async move {
139 if let Some(profile) = PlayerProfile::load_from_store(&handle) {
140 let state_handle = handle.state::<AppStateHandle>();
141 let mut state = state_handle.write().await;
···144 });
145 Ok(())
146 })
147- .invoke_handler(tauri::generate_handler![go_to_lobby])
148 .run(tauri::generate_context!())
149 .expect("error while running tauri application");
150}
···67use std::{sync::Arc, time::Duration};
89+use game::{Game as BaseGame, GameSettings, GameState as BaseGameState};
10+use lobby::{Lobby, LobbyState, StartGameInfo};
11use location::TauriLocation;
012use profile::PlayerProfile;
13+use serde::{Deserialize, Serialize};
14+use specta_typescript::Typescript;
15use tauri::{AppHandle, Manager, State};
16+use tauri_specta::collect_commands;
17use tokio::sync::RwLock;
18use transport::MatchboxTransport;
19+use uuid::Uuid;
2021+type Game = BaseGame<Uuid, TauriLocation, MatchboxTransport>;
2223enum AppState {
24 Setup,
···48}
4950impl AppState {
51+ pub fn start_game(&mut self, app: AppHandle, my_id: Uuid, start: StartGameInfo) {
52 match self {
53 AppState::Lobby(lobby) => {
54 let transport = lobby.clone_transport();
···80 AppState::Menu(profile) => {
81 let host = join_code.is_none();
82 let room_code = join_code.unwrap_or_else(generate_join_code);
083 let lobby = Arc::new(Lobby::new(
84 server_url(),
85 &room_code,
086 host,
87 profile.clone(),
88 settings,
···90 *self = AppState::Lobby(lobby.clone());
91 tokio::spawn(async move {
92 let (my_id, start) = lobby.open().await;
93+ let app_game = app.clone();
94+ let state_handle = app.state::<AppStateHandle>();
95 let mut state = state_handle.write().await;
96 state.start_game(app_game, my_id, start);
97 });
···101 }
102}
103104+use std::result::Result as StdResult;
105+106+type Result<T = (), E = String> = StdResult<T, E>;
107+108+#[derive(Serialize, Deserialize, specta::Type, Debug, Clone)]
109+enum AppScreen {
110+ Setup,
111+ Menu,
112+ Lobby,
113+ Game,
114+}
115+116+// == GENERAL / FLOW COMMANDS ==
117+118#[tauri::command]
119+#[specta::specta]
120+/// Get the screen the app should currently be on, returns [AppScreen]
121+async fn get_current_screen(state: State<'_, AppStateHandle>) -> Result<AppScreen> {
122+ let state = state.read().await;
123+ Ok(match &*state {
124+ AppState::Setup => AppScreen::Setup,
125+ AppState::Menu(_player_profile) => AppScreen::Menu,
126+ AppState::Lobby(_lobby) => AppScreen::Lobby,
127+ AppState::Game(_game) => AppScreen::Game,
128+ })
129+}
130+131+#[tauri::command]
132+#[specta::specta]
133+/// Quit a running game or leave a lobby
134+async fn quit_game_or_lobby(app: AppHandle, state: State<'_, AppStateHandle>) -> Result {
135+ let mut state = state.write().await;
136+ let profile = match &*state {
137+ AppState::Setup => Err("Invalid Screen".to_string()),
138+ AppState::Menu(_) => Err("Already In Menu".to_string()),
139+ AppState::Lobby(_) | AppState::Game(_) => Ok(PlayerProfile::load_from_store(&app)),
140+ }?;
141+ if let Some(profile) = profile {
142+ *state = AppState::Menu(profile);
143+ } else {
144+ *state = AppState::Setup;
145+ }
146+ Ok(())
147+}
148+149+// == AppState::Menu COMMANDS ==
150+151+#[tauri::command]
152+#[specta::specta]
153+/// (Screen: Menu) Update the player's profile and persist it
154+async fn update_profile(
155+ new_profile: PlayerProfile,
156+ app: AppHandle,
157+ state: State<'_, AppStateHandle>,
158+) -> Result {
159+ new_profile.write_to_store(&app);
160+ let mut state = state.write().await;
161+ if let AppState::Menu(profile) = &mut *state {
162+ *profile = new_profile;
163+ Ok(())
164+ } else {
165+ Err("Profile can only be updated on Menu screen".to_string())
166+ }
167+}
168+169+#[tauri::command]
170+#[specta::specta]
171+/// (Screen: Menu) Start/Join a new lobby, set `join_code` to `null` to be host,
172+/// set it to a join code to be a client. This triggers a screen change to [AppScreen::Lobby]
173+async fn start_lobby(
174 app: AppHandle,
175 join_code: Option<String>,
176 settings: GameSettings,
177 state: State<'_, AppStateHandle>,
178+) -> Result {
179 let mut state = state.write().await;
180 state.start_lobby(join_code, app, settings);
181 Ok(())
182}
183184+// AppState::Lobby COMMANDS
185+186#[tauri::command]
187+#[specta::specta]
188+/// (Screen: Lobby) Get the current state of the lobby, call after receiving an update event
189+async fn get_lobby_state(state: State<'_, AppStateHandle>) -> Result<LobbyState> {
190+ let state = state.read().await;
191+ if let AppState::Lobby(lobby) = &*state {
192+ Ok(lobby.clone_state().await)
193+ } else {
194+ Err("Must be called on Lobby screen".to_string())
195+ }
196+}
197+198+#[tauri::command]
199+#[specta::specta]
200+/// (Screen: Lobby) Switch teams between seekers and hiders, returns the new [LobbyState]
201+async fn switch_teams(seeker: bool, state: State<'_, AppStateHandle>) -> Result<LobbyState> {
202+ let state = state.read().await;
203+ if let AppState::Lobby(lobby) = &*state {
204+ lobby.switch_teams(seeker).await;
205+ Ok(lobby.clone_state().await)
206+ } else {
207+ Err("Must be called on Lobby screen".to_string())
208+ }
209+}
210+211+#[tauri::command]
212+#[specta::specta]
213+/// (Screen: Lobby) HOST ONLY: Push new settings to everyone, does nothing on clients. Returns the
214+/// new lobby state
215+async fn host_update_settings(
216+ settings: GameSettings,
217+ state: State<'_, AppStateHandle>,
218+) -> Result<LobbyState> {
219+ let state = state.read().await;
220+ if let AppState::Lobby(lobby) = &*state {
221+ lobby.update_settings(settings).await;
222+ Ok(lobby.clone_state().await)
223+ } else {
224+ Err("Must be called on Lobby screen".to_string())
225+ }
226+}
227+228+#[tauri::command]
229+#[specta::specta]
230+/// (Screen: Lobby) HOST ONLY: Start the game, stops anyone else from joining and switched screen
231+/// to AppScreen::Game.
232+async fn host_start_game(state: State<'_, AppStateHandle>) -> Result {
233+ let state = state.read().await;
234+ if let AppState::Lobby(lobby) = &*state {
235+ lobby.start_game().await;
236+ Ok(())
237+ } else {
238+ Err("Must be called on Lobby screen".to_string())
239+ }
240+}
241+242+// AppScreen::Game COMMANDS
243+244+type AppGameState = BaseGameState<Uuid>;
245+246+#[tauri::command]
247+#[specta::specta]
248+/// (Screen: Game) Mark this player as caught, this player will become a seeker. Returns the new game state
249+async fn mark_caught(state: State<'_, AppStateHandle>) -> Result<AppGameState> {
250+ let state = state.read().await;
251+ if let AppState::Game(game) = &*state {
252+ game.mark_caught().await;
253+ Ok(game.clone_state().await)
254+ } else {
255+ Err("Must be called on Game screen".to_string())
256+ }
257+}
258+259+#[tauri::command]
260+#[specta::specta]
261+/// (Screen: Game) Grab a powerup on the map, this should be called when the user is *in range* of
262+/// the powerup. Returns the new game state after rolling for the powerup
263+async fn grab_powerup(state: State<'_, AppStateHandle>) -> Result<AppGameState> {
264 let state = state.read().await;
265+ if let AppState::Game(game) = &*state {
266+ game.get_powerup().await;
267+ Ok(game.clone_state().await)
268+ } else {
269+ Err("Must be called on Game screen".to_string())
270+ }
271+}
272+273+#[tauri::command]
274+#[specta::specta]
275+/// (Screen: Game) Use the currently held powerup in the player's held_powerup. Does nothing if the
276+/// player has none. Returns the updated game state
277+async fn use_powerup(state: State<'_, AppStateHandle>) -> Result<AppGameState> {
278+ let state = state.read().await;
279+ if let AppState::Game(game) = &*state {
280+ game.use_powerup().await;
281+ Ok(game.clone_state().await)
282+ } else {
283+ Err("Must be called on Game screen".to_string())
284 }
285}
286···288pub fn run() {
289 let state = RwLock::new(AppState::Setup);
290291+ let builder = tauri_specta::Builder::<tauri::Wry>::new().commands(collect_commands![
292+ start_lobby,
293+ quit_game_or_lobby,
294+ get_current_screen,
295+ update_profile,
296+ get_lobby_state,
297+ host_update_settings,
298+ switch_teams,
299+ host_start_game,
300+ mark_caught,
301+ // grab_powerup,
302+ // use_powerup,
303+ ]);
304+305+ #[cfg(debug_assertions)]
306+ builder
307+ .export(Typescript::default(), "../frontend/src/bindings.ts")
308+ .expect("Failed to export typescript bindings");
309+310 tauri::Builder::default()
311 .manage(state)
312 .plugin(tauri_plugin_opener::init())
313 .plugin(tauri_plugin_geolocation::init())
314 .plugin(tauri_plugin_store::Builder::default().build())
315+ .invoke_handler(builder.invoke_handler())
316 .setup(|app| {
317 let handle = app.handle().clone();
318+ tauri::async_runtime::spawn(async move {
319 if let Some(profile) = PlayerProfile::load_from_store(&handle) {
320 let state_handle = handle.state::<AppStateHandle>();
321 let mut state = state_handle.write().await;
···324 });
325 Ok(())
326 })
0327 .run(tauri::generate_context!())
328 .expect("error while running tauri application");
329}