···8- [x] API : Command to check if a game exists and is open for fast error checking
9- [x] Transport : Switch to burst message processing for less time in the
10 critical path
11-- [ ] State : Event history tracking
12-- [ ] State : Post game sync
13-- [ ] API : Handling Profile Syncing
14-- [ ] ALL : State Update Events
15-- [ ] ALL : Game Replay Screen
16- [ ] Frontend : Scaffolding
17- [x] Meta : CI Setup
18- [x] Meta : README Instructions
19- [x] Meta : Recipes for type binding generation
20- [x] Signaling: All of it
0
···8- [x] API : Command to check if a game exists and is open for fast error checking
9- [x] Transport : Switch to burst message processing for less time in the
10 critical path
11+- [x] State : Event history tracking
12+- [x] State : Post game sync
13+- [x] API : Handling Profile Syncing
14+- [ ] API : State Update Events
15+- [x] API : Game Replay Screen
16- [ ] Frontend : Scaffolding
17- [x] Meta : CI Setup
18- [x] Meta : README Instructions
19- [x] Meta : Recipes for type binding generation
20- [x] Signaling: All of it
21+- [ ] Backend : More tests
+3-3
backend/src/game/events.rs
···1use serde::{Deserialize, Serialize};
23-use super::{location::Location, state::PlayerPing, Id};
45/// An event used between players to update state
6-#[derive(Debug, Clone, Serialize, Deserialize)]
7pub enum GameEvent {
8 /// A player has been caught and is now a seeker, contains the ID of the caught player
9 PlayerCaught(Id),
···16 PowerupDespawn(Id),
17 /// Contains location history of the given player, used after the game to sync location
18 /// histories
19- PostGameSync(Id, Vec<Location>),
20 /// A player has been disconnected and removed from the game (because of error or otherwise).
21 /// The player should be removed from all state
22 DroppedPlayer(Id),
···1use serde::{Deserialize, Serialize};
23+use super::{location::Location, state::PlayerPing, Id, UtcDT};
45/// An event used between players to update state
6+#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
7pub enum GameEvent {
8 /// A player has been caught and is now a seeker, contains the ID of the caught player
9 PlayerCaught(Id),
···16 PowerupDespawn(Id),
17 /// Contains location history of the given player, used after the game to sync location
18 /// histories
19+ PostGameSync(Id, Vec<(UtcDT, Location)>),
20 /// A player has been disconnected and removed from the game (because of error or otherwise).
21 /// The player should be removed from all state
22 DroppedPlayer(Id),
+33-14
backend/src/game/mod.rs
···18use crate::prelude::*;
1920pub use location::{Location, LocationService};
21-pub use state::GameState;
22pub use transport::Transport;
2324pub type Id = Uuid;
···56 interval,
57 state: RwLock::new(state),
58 }
59- }
60-61- pub async fn clone_state(&self) -> GameState {
62- self.state.read().await.clone()
63 }
6465 pub async fn mark_caught(&self) {
···67 let id = state.id;
68 state.mark_caught(id);
69 state.remove_ping(id);
70- // TODO: Maybe reroll for new powerups instead of just erasing it
71 state.use_powerup();
7273 self.transport
···111 }
112113 async fn consume_event(&self, state: &mut GameState, event: GameEvent) -> Result {
0000114 match event {
115 GameEvent::Ping(player_ping) => state.add_ping(player_ping),
116 GameEvent::ForcePing(target, display) => {
···140 GameEvent::TransportDisconnect => {
141 bail!("Transport disconnected");
142 }
143- GameEvent::PostGameSync(_, _locations) => {}
00144 }
145146 Ok(())
147 }
148149 /// Perform a tick for a specific moment in time
150- async fn tick(&self, state: &mut GameState, now: UtcDT) {
00000000000000151 // Push to location history
152 if let Some(location) = self.location.get_loc() {
153 state.push_loc(location);
···192 if state.should_spawn_powerup(&now) {
193 state.try_spawn_powerup(now);
194 }
00195 }
196197 #[cfg(test)]
···205 }
206207 /// Main loop of the game, handles ticking and receiving messages from [Transport].
208- pub async fn main_loop(&self) -> Result {
209 let mut interval = tokio::time::interval(self.interval);
210211 interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
···221 break 'game Err(why);
222 }
223 }
224-225- if state.should_end() {
226- break Ok(());
227- }
228 }
229230 _ = interval.tick() => {
231 let mut state = self.state.write().await;
232- self.tick(&mut state, Utc::now()).await;
00000233 }
234 }
235 };
···18use crate::prelude::*;
1920pub use location::{Location, LocationService};
21+pub use state::{GameHistory, GameState};
22pub use transport::Transport;
2324pub type Id = Uuid;
···56 interval,
57 state: RwLock::new(state),
58 }
000059 }
6061 pub async fn mark_caught(&self) {
···63 let id = state.id;
64 state.mark_caught(id);
65 state.remove_ping(id);
66+ // TODO: Maybe reroll for new powerups (specifically seeker ones) instead of just erasing it
67 state.use_powerup();
6869 self.transport
···107 }
108109 async fn consume_event(&self, state: &mut GameState, event: GameEvent) -> Result {
110+ if !state.game_ended() {
111+ state.event_history.push((Utc::now(), event.clone()));
112+ }
113+114 match event {
115 GameEvent::Ping(player_ping) => state.add_ping(player_ping),
116 GameEvent::ForcePing(target, display) => {
···140 GameEvent::TransportDisconnect => {
141 bail!("Transport disconnected");
142 }
143+ GameEvent::PostGameSync(id, history) => {
144+ state.insert_player_location_history(id, history);
145+ }
146 }
147148 Ok(())
149 }
150151 /// Perform a tick for a specific moment in time
152+ /// Returns whether the game loop should be broken.
153+ async fn tick(&self, state: &mut GameState, now: UtcDT) -> bool {
154+ if state.check_end_game() {
155+ // If we're at the point where the game is over, send out our location history
156+ let msg = GameEvent::PostGameSync(state.id, state.location_history.clone());
157+ self.transport.send_message(msg).await;
158+ }
159+160+ if state.game_ended() {
161+ // Don't do normal ticks if the game is over,
162+ // simply return if we're done doing a post-game sync
163+164+ return state.check_post_game_sync();
165+ }
166+167 // Push to location history
168 if let Some(location) = self.location.get_loc() {
169 state.push_loc(location);
···208 if state.should_spawn_powerup(&now) {
209 state.try_spawn_powerup(now);
210 }
211+212+ false
213 }
214215 #[cfg(test)]
···223 }
224225 /// Main loop of the game, handles ticking and receiving messages from [Transport].
226+ pub async fn main_loop(&self) -> Result<GameHistory> {
227 let mut interval = tokio::time::interval(self.interval);
228229 interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
···239 break 'game Err(why);
240 }
241 }
0000242 }
243244 _ = interval.tick() => {
245 let mut state = self.state.write().await;
246+ let should_break = self.tick(&mut state, Utc::now()).await;
247+248+ if should_break {
249+ let history = state.as_game_history();
250+ break Ok(history);
251+ }
252 }
253 }
254 };
+65-11
backend/src/game/state.rs
···8};
9use rand_chacha::ChaCha20Rng;
10use serde::{Deserialize, Serialize};
0001112use super::{
13 location::Location,
···40 }
41}
4243-#[derive(Debug, Clone, Serialize, specta::Type)]
44/// This struct handles all logic regarding state updates
45pub struct GameState {
46 /// The id of this player in this game
···5152 /// When the game started
53 game_started: UtcDT,
00000005455 /// When seekers were allowed to begin
56 seekers_started: Option<UtcDT>,
···70 /// Powerup on the map that players can grab. Only one at a time
71 available_powerup: Option<Location>,
7273- #[serde(skip)]
074 /// The game's current settings
75 settings: GameSettings,
7677- #[serde(skip)]
78 /// The player's location history
79- location_history: Vec<Location>,
8081 /// Cached bernoulli distribution for powerups, faster sampling
82- #[serde(skip)]
83 powerup_bernoulli: Bernoulli,
8485 /// A seed with a shared value between all players, should be reproducible
86 /// RNG for use in stuff like powerup location selection.
87- #[serde(skip)]
88 shared_random_increment: i64,
8990 /// State for [ChaCha20Rng] to be used and added to when performing shared RNG operations
91- #[serde(skip)]
92 shared_random_state: u64,
93}
94···100 Self {
101 id: my_id,
102 game_started: Utc::now(),
00103 seekers_started: None,
104 pings: HashMap::with_capacity(initial_caught_state.len()),
0105 caught_state: initial_caught_state,
106 available_powerup: None,
107 powerup_bernoulli: settings.get_powerup_bernoulli(),
···240 self.pings.get(&player)
241 }
2420000000000243 /// Check if the game should be ended (due to all players being caught)
244- pub fn should_end(&self) -> bool {
245- self.caught_state.values().all(|v| *v)
0000000000246 }
247248 /// Remove a ping from the map
···292 pub fn remove_player(&mut self, id: Id) {
293 self.pings.remove(&id);
294 self.caught_state.remove(&id);
0295 }
296297 /// Player has gotten a powerup, rolls to see which powerup and stores it
···318319 /// Push a new player location
320 pub fn push_loc(&mut self, loc: Location) {
321- self.location_history.push(loc);
322 }
323324 /// Get the latest player location
325 fn get_loc(&self) -> Option<&Location> {
326- self.location_history.last()
327 }
328329 /// Mark a player as caught
···342 pub fn is_seeker(&self) -> bool {
343 self.caught_state.get(&self.id).copied().unwrap_or_default()
344 }
00000000000000000000000345}
···8};
9use rand_chacha::ChaCha20Rng;
10use serde::{Deserialize, Serialize};
11+use uuid::Uuid;
12+13+use crate::game::GameEvent;
1415use super::{
16 location::Location,
···43 }
44}
4546+#[derive(Debug, Clone)]
47/// This struct handles all logic regarding state updates
48pub struct GameState {
49 /// The id of this player in this game
···5455 /// When the game started
56 game_started: UtcDT,
57+58+ /// When the game ended, if this is [Option::Some] then the state will enter post-game sync
59+ game_ended: Option<UtcDT>,
60+61+ /// A HashMap of player IDs to location histories, used to track all player location histories
62+ /// during post-game sync
63+ player_histories: HashMap<Uuid, Option<Vec<(UtcDT, Location)>>>,
6465 /// When seekers were allowed to begin
66 seekers_started: Option<UtcDT>,
···80 /// Powerup on the map that players can grab. Only one at a time
81 available_powerup: Option<Location>,
8283+ pub event_history: Vec<(UtcDT, GameEvent)>,
84+85 /// The game's current settings
86 settings: GameSettings,
87088 /// The player's location history
89+ pub location_history: Vec<(UtcDT, Location)>,
9091 /// Cached bernoulli distribution for powerups, faster sampling
092 powerup_bernoulli: Bernoulli,
9394 /// A seed with a shared value between all players, should be reproducible
95 /// RNG for use in stuff like powerup location selection.
096 shared_random_increment: i64,
9798 /// State for [ChaCha20Rng] to be used and added to when performing shared RNG operations
099 shared_random_state: u64,
100}
101···107 Self {
108 id: my_id,
109 game_started: Utc::now(),
110+ event_history: Vec::with_capacity(15),
111+ game_ended: None,
112 seekers_started: None,
113 pings: HashMap::with_capacity(initial_caught_state.len()),
114+ player_histories: HashMap::from_iter(initial_caught_state.keys().map(|id| (*id, None))),
115 caught_state: initial_caught_state,
116 available_powerup: None,
117 powerup_bernoulli: settings.get_powerup_bernoulli(),
···250 self.pings.get(&player)
251 }
252253+ /// Add a location history for the given player
254+ pub fn insert_player_location_history(&mut self, id: Uuid, history: Vec<(UtcDT, Location)>) {
255+ self.player_histories.insert(id, Some(history));
256+ }
257+258+ /// Check if we've complete the post-game sync
259+ pub fn check_post_game_sync(&self) -> bool {
260+ self.game_ended() && self.player_histories.values().all(Option::is_some)
261+ }
262+263 /// Check if the game should be ended (due to all players being caught)
264+ pub fn check_end_game(&mut self) -> bool {
265+ let should_end = self.caught_state.values().all(|v| *v);
266+ if should_end {
267+ self.game_ended = Some(Utc::now());
268+ self.player_histories
269+ .insert(self.id, Some(self.location_history.clone()));
270+ }
271+ should_end
272+ }
273+274+ pub fn game_ended(&self) -> bool {
275+ self.game_ended.is_some()
276 }
277278 /// Remove a ping from the map
···322 pub fn remove_player(&mut self, id: Id) {
323 self.pings.remove(&id);
324 self.caught_state.remove(&id);
325+ self.player_histories.remove(&id);
326 }
327328 /// Player has gotten a powerup, rolls to see which powerup and stores it
···349350 /// Push a new player location
351 pub fn push_loc(&mut self, loc: Location) {
352+ self.location_history.push((Utc::now(), loc));
353 }
354355 /// Get the latest player location
356 fn get_loc(&self) -> Option<&Location> {
357+ self.location_history.last().map(|(_, l)| l)
358 }
359360 /// Mark a player as caught
···373 pub fn is_seeker(&self) -> bool {
374 self.caught_state.get(&self.id).copied().unwrap_or_default()
375 }
376+377+ pub fn as_game_history(&self) -> GameHistory {
378+ GameHistory {
379+ my_id: self.id,
380+ events: self.event_history.clone(),
381+ locations: self
382+ .player_histories
383+ .iter()
384+ .map(|(id, history)| (*id, history.as_ref().cloned().unwrap_or_default()))
385+ .collect(),
386+ game_started: self.game_started,
387+ game_ended: self.game_ended.unwrap_or_default(),
388+ }
389+ }
390+}
391+392+#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
393+pub struct GameHistory {
394+ my_id: Uuid,
395+ pub game_started: UtcDT,
396+ game_ended: UtcDT,
397+ events: Vec<(UtcDT, GameEvent)>,
398+ locations: Vec<(Uuid, Vec<(UtcDT, Location)>)>,
399}
···37 /**
38 * Quit a running game or leave a lobby
39 */
40- async quitGameOrLobby(): Promise<Result<null, string>> {
41 try {
42- return { status: "ok", data: await TAURI_INVOKE("quit_game_or_lobby") };
43 } catch (e) {
44 if (e instanceof Error) throw e;
45 else return { status: "error", error: e as any };
···82 * (Screen: Lobby) HOST ONLY: Push new settings to everyone, does nothing on clients. Returns the
83 * new lobby state
84 */
85- async hostUpdateSettings(settings: GameSettings): Promise<Result<LobbyState, string>> {
86 try {
87 return { status: "ok", data: await TAURI_INVOKE("host_update_settings", { settings }) };
88 } catch (e) {
···93 /**
94 * (Screen: Lobby) Switch teams between seekers and hiders, returns the new [LobbyState]
95 */
96- async switchTeams(seeker: boolean): Promise<Result<LobbyState, string>> {
97 try {
98 return { status: "ok", data: await TAURI_INVOKE("switch_teams", { seeker }) };
99 } catch (e) {
···116 /**
117 * (Screen: Game) Mark this player as caught, this player will become a seeker. Returns the new game state
118 */
119- async markCaught(): Promise<Result<GameState, string>> {
120 try {
121 return { status: "ok", data: await TAURI_INVOKE("mark_caught") };
122 } catch (e) {
···128 * (Screen: Game) Grab a powerup on the map, this should be called when the user is *in range* of
129 * the powerup. Returns the new game state after rolling for the powerup
130 */
131- async grabPowerup(): Promise<Result<GameState, string>> {
132 try {
133 return { status: "ok", data: await TAURI_INVOKE("grab_powerup") };
134 } catch (e) {
···140 * (Screen: Game) Use the currently held powerup in the player's held_powerup. Does nothing if the
141 * player has none. Returns the updated game state
142 */
143- async usePowerup(): Promise<Result<GameState, string>> {
144 try {
145 return { status: "ok", data: await TAURI_INVOKE("use_powerup") };
146 } catch (e) {
···159 if (e instanceof Error) throw e;
160 else return { status: "error", error: e as any };
161 }
0000000000000000000000000000000000000000000000162 }
163};
164···174175/** user-defined types **/
176177-export type AppScreen = "Setup" | "Menu" | "Lobby" | "Game";
0000178export type ChangeScreen = AppScreen;
179/**
000000000000000000000000000000000000000000180 * Settings for the game, host is the only person able to change these
181 */
182export type GameSettings = {
···214 */
215 powerup_locations: Location[];
216};
217-/**
218- * This struct handles all logic regarding state updates
219- */
220-export type GameState = {
221- /**
222- * The id of this player in this game
223- */
224- id: string;
225- /**
226- * The powerup the player is currently holding
227- */
228- held_powerup: PowerUpType | null;
229- /**
230- * When the game started
231- */
232- game_started: string;
233- /**
234- * When seekers were allowed to begin
235- */
236- seekers_started: string | null;
237- /**
238- * Last time we pinged all players
239- */
240- last_global_ping: string | null;
241- /**
242- * Last time a powerup was spawned
243- */
244- last_powerup_spawn: string | null;
245- /**
246- * Hashmap tracking if a player is a seeker (true) or a hider (false)
247- */
248- caught_state: Partial<{ [key in string]: boolean }>;
249- /**
250- * A map of the latest global ping results for each player
251- */
252- pings: Partial<{ [key in string]: PlayerPing }>;
253- /**
254- * Powerup on the map that players can grab. Only one at a time
255- */
256- available_powerup: Location | null;
257-};
258export type LobbyState = {
259 profiles: Partial<{ [key in string]: PlayerProfile }>;
260 join_code: string;
···320 real_player: string;
321};
322export type PlayerProfile = { display_name: string; pfp_base64: string | null };
323-/**
324- * Type of powerup
325- */
326-export type PowerUpType =
327- /**
328- * Ping a random seeker instead of a hider
329- */
330- | "PingSeeker"
331- /**
332- * Pings all seekers locations on the map for hiders
333- */
334- | "PingAllSeekers"
335- /**
336- * Ping another random hider instantly
337- */
338- | "ForcePingOther";
339340/** tauri-specta globals **/
341
···37 /**
38 * Quit a running game or leave a lobby
39 */
40+ async quitToMenu(): Promise<Result<null, string>> {
41 try {
42+ return { status: "ok", data: await TAURI_INVOKE("quit_to_menu") };
43 } catch (e) {
44 if (e instanceof Error) throw e;
45 else return { status: "error", error: e as any };
···82 * (Screen: Lobby) HOST ONLY: Push new settings to everyone, does nothing on clients. Returns the
83 * new lobby state
84 */
85+ async hostUpdateSettings(settings: GameSettings): Promise<Result<null, string>> {
86 try {
87 return { status: "ok", data: await TAURI_INVOKE("host_update_settings", { settings }) };
88 } catch (e) {
···93 /**
94 * (Screen: Lobby) Switch teams between seekers and hiders, returns the new [LobbyState]
95 */
96+ async switchTeams(seeker: boolean): Promise<Result<null, string>> {
97 try {
98 return { status: "ok", data: await TAURI_INVOKE("switch_teams", { seeker }) };
99 } catch (e) {
···116 /**
117 * (Screen: Game) Mark this player as caught, this player will become a seeker. Returns the new game state
118 */
119+ async markCaught(): Promise<Result<null, string>> {
120 try {
121 return { status: "ok", data: await TAURI_INVOKE("mark_caught") };
122 } catch (e) {
···128 * (Screen: Game) Grab a powerup on the map, this should be called when the user is *in range* of
129 * the powerup. Returns the new game state after rolling for the powerup
130 */
131+ async grabPowerup(): Promise<Result<null, string>> {
132 try {
133 return { status: "ok", data: await TAURI_INVOKE("grab_powerup") };
134 } catch (e) {
···140 * (Screen: Game) Use the currently held powerup in the player's held_powerup. Does nothing if the
141 * player has none. Returns the updated game state
142 */
143+ async usePowerup(): Promise<Result<null, string>> {
144 try {
145 return { status: "ok", data: await TAURI_INVOKE("use_powerup") };
146 } catch (e) {
···159 if (e instanceof Error) throw e;
160 else return { status: "error", error: e as any };
161 }
162+ },
163+ /**
164+ * (Screen: Game) Get all player profiles with display names and profile pictures for this game
165+ */
166+ async getProfiles(): Promise<Result<Partial<{ [key in string]: PlayerProfile }>, string>> {
167+ try {
168+ return { status: "ok", data: await TAURI_INVOKE("get_profiles") };
169+ } catch (e) {
170+ if (e instanceof Error) throw e;
171+ else return { status: "error", error: e as any };
172+ }
173+ },
174+ /**
175+ * (Screen: Menu) Go to the game replay screen to replay the game history specified by id
176+ */
177+ async replayGame(id: string): Promise<Result<null, string>> {
178+ try {
179+ return { status: "ok", data: await TAURI_INVOKE("replay_game", { id }) };
180+ } catch (e) {
181+ if (e instanceof Error) throw e;
182+ else return { status: "error", error: e as any };
183+ }
184+ },
185+ /**
186+ * (Screen: Menu) Get a list of all previously played games, returns of list of DateTimes that represent when
187+ * each game started, use this as a key
188+ */
189+ async listGameHistories(): Promise<Result<string[], string>> {
190+ try {
191+ return { status: "ok", data: await TAURI_INVOKE("list_game_histories") };
192+ } catch (e) {
193+ if (e instanceof Error) throw e;
194+ else return { status: "error", error: e as any };
195+ }
196+ },
197+ /**
198+ * (Screen: Replay) Get the game history that's currently being replayed. Try to limit calls to
199+ * this
200+ */
201+ async getCurrentReplayHistory(): Promise<Result<AppGameHistory, string>> {
202+ try {
203+ return { status: "ok", data: await TAURI_INVOKE("get_current_replay_history") };
204+ } catch (e) {
205+ if (e instanceof Error) throw e;
206+ else return { status: "error", error: e as any };
207+ }
208 }
209};
210···220221/** user-defined types **/
222223+export type AppGameHistory = {
224+ history: GameHistory;
225+ profiles: Partial<{ [key in string]: PlayerProfile }>;
226+};
227+export type AppScreen = "Setup" | "Menu" | "Lobby" | "Game" | "Replay";
228export type ChangeScreen = AppScreen;
229/**
230+ * An event used between players to update state
231+ */
232+export type GameEvent =
233+ /**
234+ * A player has been caught and is now a seeker, contains the ID of the caught player
235+ */
236+ | { PlayerCaught: string }
237+ /**
238+ * Public ping from a player revealing location
239+ */
240+ | { Ping: PlayerPing }
241+ /**
242+ * Force the player specified in `0` to ping, optionally display the ping as from the user
243+ * specified in `1`.
244+ */
245+ | { ForcePing: [string, string | null] }
246+ /**
247+ * Force a powerup to despawn because a player got it, contains the player that got it.
248+ */
249+ | { PowerupDespawn: string }
250+ /**
251+ * Contains location history of the given player, used after the game to sync location
252+ * histories
253+ */
254+ | { PostGameSync: [string, [string, Location][]] }
255+ /**
256+ * A player has been disconnected and removed from the game (because of error or otherwise).
257+ * The player should be removed from all state
258+ */
259+ | { DroppedPlayer: string }
260+ /**
261+ * The underlying transport has disconnected
262+ */
263+ | "TransportDisconnect";
264+export type GameHistory = {
265+ my_id: string;
266+ game_started: string;
267+ game_ended: string;
268+ events: [string, GameEvent][];
269+ locations: [string, [string, Location][]][];
270+};
271+/**
272 * Settings for the game, host is the only person able to change these
273 */
274export type GameSettings = {
···306 */
307 powerup_locations: Location[];
308};
00000000000000000000000000000000000000000309export type LobbyState = {
310 profiles: Partial<{ [key in string]: PlayerProfile }>;
311 join_code: string;
···371 real_player: string;
372};
373export type PlayerProfile = { display_name: string; pfp_base64: string | null };
0000000000000000374375/** tauri-specta globals **/
376