···1717tauri-plugin-opener = "2"
1818serde = { version = "1", features = ["derive"] }
1919serde_json = "1"
2020-chrono = { version = "0.4.41", features = ["serde", "now"] }
2121-tokio = { version = "1.45.1", features = ["sync", "macros", "time"] }
2222-rand = { version = "0.9.1", features = ["thread_rng"] }
2323-tauri-plugin-geolocation = "2.2.4"
2020+chrono = { version = "0.4", features = ["serde", "now"] }
2121+tokio = { version = "1.45", features = ["sync", "macros", "time"] }
2222+rand = { version = "0.9", features = ["thread_rng"] }
2323+tauri-plugin-geolocation = "2.2"
2424+rand_chacha = "0.9.0"
2525+futures = "0.3.31"
-447
backend/src/game.rs
···11-use std::{collections::HashMap, time::Duration};
22-use tauri::Runtime;
33-use tokio::sync::RwLock;
44-55-use crate::{
66- powerup::{PowerUpType, PowerUpUsage},
77- state::{GameEvent, GameState, Location, PlayerId, DT},
88-};
99-1010-type EventMessage = (PlayerId, DT, GameEvent);
1111-1212-/// Struct representing an ongoing game, handles communication with
1313-/// other clients via [Transport] and provides high-level methods for
1414-/// taking actions in the game.
1515-struct Game<L: LocationService, T: Transport> {
1616- id: PlayerId,
1717- is_host: bool,
1818- state: RwLock<GameState<L>>,
1919- transport: T,
2020- interval: Duration,
2121-}
2222-2323-pub trait Transport {
2424- async fn receive_message(&self) -> Option<EventMessage>;
2525- async fn send_event_to(&self, id: PlayerId, event: GameEvent);
2626- async fn send_event_host(&self, event: GameEvent);
2727- async fn send_event_multiple(&self, ids: Vec<PlayerId>, event: GameEvent);
2828- async fn send_event_all(&self, event: GameEvent);
2929-}
3030-3131-pub trait LocationService {
3232- fn get_loc(&self) -> Location;
3333-}
3434-3535-impl<L: LocationService, T: Transport> Game<L, T> {
3636- pub fn new(id: PlayerId, game_state: GameState<L>, transport: T, interval: Duration) -> Self {
3737- Self {
3838- id,
3939- is_host: game_state.host.is_some(),
4040- state: RwLock::new(game_state),
4141- transport,
4242- interval,
4343- }
4444- }
4545-4646- /// Mark yourself as caught, sends out a message to all other players
4747- async fn mark_caught(&self) {
4848- self.transport
4949- .send_event_all(GameEvent::HiderCaught(self.id))
5050- .await;
5151- }
5252-5353- /// Get the active powerup, to be called when the user is in range of the powerup
5454- async fn get_powerup(&self) {
5555- let mut state = self.state.write().await;
5656- if let Some(powerup) = state.public.available_powerup.take() {
5757- state.player.held_powerup = Some(powerup.typ);
5858- drop(state);
5959- self.transport
6060- .send_event_all(GameEvent::PowerUpDespawn)
6161- .await;
6262- }
6363- }
6464-6565- async fn use_powerup(&self) {
6666- let mut state = self.state.write().await;
6767- if let Some(powerup) = state.player.held_powerup.take() {
6868- drop(state);
6969- match powerup {
7070- PowerUpType::PingSeeker => {
7171- let e = GameEvent::PowerUpActivate(PowerUpUsage::PingSeeker);
7272- self.transport.send_event_host(e).await;
7373- }
7474- PowerUpType::PingAllSeekers => {
7575- let e = GameEvent::PingReq(None);
7676- let state = self.state.read().await;
7777- let seekers = state.public.iter_seekers().collect::<Vec<_>>();
7878- drop(state);
7979- let host_log = GameEvent::PowerUpActivate(PowerUpUsage::PingAllSeekers);
8080- self.transport.send_event_host(host_log).await;
8181- self.transport.send_event_multiple(seekers, e).await;
8282- }
8383- PowerUpType::ForcePingOther => {
8484- let e = GameEvent::PingReq(None);
8585- let mut state = self.state.write().await;
8686- if let Some(target) = state.random_other_hider() {
8787- let host_log =
8888- GameEvent::PowerUpActivate(PowerUpUsage::ForcePingOther(target));
8989- self.transport.send_event_host(host_log).await;
9090- self.transport.send_event_to(target, e).await;
9191- }
9292- }
9393- }
9494- }
9595- }
9696-9797- /// Start main loop of the game, this should ideally be put into its own thread via
9898- /// [tokio::spawn].
9999- async fn main_loop(
100100- &self,
101101- ) -> (
102102- HashMap<PlayerId, Vec<Location>>,
103103- Vec<(PlayerId, DT, GameEvent)>,
104104- ) {
105105- let interval = tokio::time::interval(self.interval);
106106- tokio::pin!(interval);
107107-108108- let mut ended = false;
109109-110110- while !ended {
111111- tokio::select! {
112112- _ = interval.tick() => {
113113- let mut state = self.state.write().await;
114114- let messages = state.tick();
115115- drop(state);
116116- for (player, event) in messages {
117117- if let Some(player) = player {
118118- self.transport.send_event_to(player, event).await;
119119- } else {
120120- self.transport.send_event_all(event).await;
121121- }
122122- }
123123- }
124124-125125- Some((player, time_sent, event)) = self.transport.receive_message() => {
126126- if let GameEvent::GameEnd(dt) = event {
127127- ended = true;
128128-129129- } else {
130130- let mut state = self.state.write().await;
131131- let new_event = state.consume_event(time_sent, event, player);
132132- drop(state);
133133- if let Some(event) = new_event {
134134- self.transport.send_event_all(event).await;
135135- }
136136- }
137137- }
138138- }
139139- }
140140-141141- let state = self.state.read().await;
142142- let locations = state.player.locations.clone();
143143-144144- if self.is_host {
145145- let player_count = state.public.caught_state.len();
146146- let mut player_location_history =
147147- HashMap::<PlayerId, Vec<Location>>::with_capacity(player_count);
148148- player_location_history.insert(self.id, locations);
149149- while player_location_history.len() != player_count {
150150- // TODO: Join with a timeout, etc
151151- if let Some((id, _, GameEvent::PostGameSync(player_locations))) =
152152- self.transport.receive_message().await
153153- {
154154- player_location_history.insert(id, player_locations);
155155- }
156156- }
157157- let history = (
158158- player_location_history,
159159- state.host.as_ref().unwrap().event_history.clone(),
160160- );
161161- let ev = GameEvent::HostHistorySync(history.clone());
162162- self.transport.send_event_all(ev).await;
163163- history
164164- } else {
165165- self.transport
166166- .send_event_host(GameEvent::PostGameSync(locations))
167167- .await;
168168- loop {
169169- if let Some((_, _, GameEvent::HostHistorySync(history))) =
170170- self.transport.receive_message().await
171171- {
172172- break history;
173173- }
174174- }
175175- }
176176- }
177177-}
178178-179179-#[cfg(test)]
180180-mod tests {
181181- use std::collections::HashMap;
182182- use std::sync::Arc;
183183-184184- use crate::state::{GameSettings, HostState, PingStartCondition};
185185-186186- use super::*;
187187- use tokio::sync::mpsc::{Receiver, Sender};
188188- use tokio::sync::Mutex;
189189- use tokio::task::yield_now;
190190- use tokio::test;
191191-192192- type EventRx = Receiver<EventMessage>;
193193- type EventTx = Sender<EventMessage>;
194194-195195- struct MockTransport {
196196- player_id: PlayerId,
197197- rx: Mutex<EventRx>,
198198- txs: HashMap<PlayerId, EventTx>,
199199- }
200200-201201- impl MockTransport {
202202- fn new(player_id: PlayerId) -> (Self, EventTx) {
203203- let (tx, rx) = tokio::sync::mpsc::channel(5);
204204- let trans = Self {
205205- player_id,
206206- rx: Mutex::new(rx),
207207- txs: HashMap::new(),
208208- };
209209- (trans, tx)
210210- }
211211-212212- fn set_txs(&mut self, txs: HashMap<PlayerId, EventTx>) {
213213- self.txs = txs;
214214- }
215215-216216- fn make_msg(&self, e: GameEvent) -> EventMessage {
217217- (self.player_id, chrono::Utc::now(), e)
218218- }
219219- }
220220-221221- impl Transport for MockTransport {
222222- async fn receive_message(&self) -> Option<EventMessage> {
223223- let mut rx = self.rx.lock().await;
224224- rx.recv().await
225225- }
226226-227227- async fn send_event_to(&self, id: PlayerId, event: GameEvent) {
228228- if let Some(tx) = self.txs.get(&id) {
229229- if let Err(why) = tx.send(self.make_msg(event)).await {
230230- eprintln!("Error sending msg to {id}: {why}");
231231- }
232232- }
233233- }
234234-235235- async fn send_event_host(&self, event: GameEvent) {
236236- // While testing, host is always player 0
237237- self.send_event_to(0, event).await;
238238- }
239239-240240- async fn send_event_multiple(&self, ids: Vec<PlayerId>, event: GameEvent) {
241241- for id in ids {
242242- self.send_event_to(id, event.clone()).await;
243243- }
244244- }
245245-246246- async fn send_event_all(&self, event: GameEvent) {
247247- for id in self.txs.keys() {
248248- self.send_event_to(*id, event.clone()).await;
249249- }
250250- }
251251- }
252252-253253- struct MockLocation;
254254-255255- impl LocationService for MockLocation {
256256- fn get_loc(&self) -> Location {
257257- Location {
258258- lat: 0.0,
259259- long: 0.0,
260260- heading: None,
261261- }
262262- }
263263- }
264264-265265- type MockGame = Game<MockLocation, MockTransport>;
266266-267267- struct TestMatch {
268268- games: HashMap<PlayerId, Arc<MockGame>>,
269269- }
270270-271271- impl TestMatch {
272272- /// New test match
273273- /// player_count: number of players
274274- /// num_seekers: number of seekers
275275- /// host_seeker: whether to mark the host as a seeker
276276- pub fn new(
277277- player_count: u32,
278278- num_seekers: u32,
279279- host_seeker: bool,
280280- settings: GameSettings,
281281- ) -> Self {
282282- let caught_state =
283283- HashMap::<PlayerId, bool>::from_iter((0..player_count).into_iter().map(|id| {
284284- let should_seeker =
285285- (if id == 0 || host_seeker { id } else { id - 1 }) < num_seekers;
286286- (id, should_seeker && (host_seeker || id != 0))
287287- }));
288288-289289- let mut txs = HashMap::<PlayerId, EventTx>::with_capacity(player_count as usize);
290290- let mut games = HashMap::<PlayerId, MockGame>::with_capacity(player_count as usize);
291291-292292- for id in 0..player_count {
293293- let (transport, tx) = MockTransport::new(id);
294294- let state = GameState::new(
295295- id == 0,
296296- id,
297297- caught_state.clone(),
298298- settings.clone(),
299299- MockLocation,
300300- );
301301- txs.insert(id, tx);
302302- let game = MockGame::new(id, state, transport, Duration::from_secs(1));
303303- games.insert(id, game);
304304- }
305305-306306- for game in games.values_mut() {
307307- game.transport.set_txs(txs.clone());
308308- }
309309-310310- Self {
311311- games: games.into_iter().map(|(k, v)| (k, Arc::new(v))).collect(),
312312- }
313313- }
314314-315315- pub fn start(&self) {
316316- for game in self.games.values() {
317317- let game = game.clone();
318318- tokio::spawn(async move { game.main_loop().await });
319319- }
320320- }
321321-322322- pub fn host(&self) -> Arc<MockGame> {
323323- self.game(0)
324324- }
325325-326326- pub fn game(&self, id: PlayerId) -> Arc<MockGame> {
327327- self.games.get(&id).unwrap().clone()
328328- }
329329-330330- pub async fn wait_tick(&self) {
331331- tokio::time::sleep(Duration::from_secs(1)).await;
332332- yield_now().await;
333333- }
334334-335335- pub async fn wait_assert_seekers_released(&self) {
336336- tokio::time::sleep(Duration::from_secs(1)).await;
337337- yield_now().await;
338338-339339- self.assert_all_player_states(|state| {
340340- assert!(state.public.seekers_started.is_some());
341341- });
342342- }
343343-344344- /// Assert a condition on the host state
345345- pub async fn assert_host_state<F: Fn(&HostState)>(&self, f: F) {
346346- let host = self.host();
347347- let state = host.state.read().await;
348348- f(state.host.as_ref().unwrap());
349349- }
350350-351351- /// Assert a condition on all player states
352352- pub async fn assert_all_player_states<F: Fn(&GameState<MockLocation>)>(&self, f: F) {
353353- for game in self.games.values() {
354354- let state = game.state.read().await;
355355- f(&state);
356356- }
357357- }
358358- }
359359-360360- const TEST_LOC: Location = Location {
361361- lat: 0.0,
362362- long: 0.0,
363363- heading: None,
364364- };
365365-366366- #[test]
367367- async fn test_game() {
368368- let settings = GameSettings {
369369- hiding_time_seconds: 1,
370370- ping_start: PingStartCondition::Players(3),
371371- ping_minutes_interval: 0,
372372- powerup_start: PingStartCondition::Players(3),
373373- powerup_chance: 0,
374374- powerup_minutes_cooldown: 1,
375375- powerup_locations: vec![TEST_LOC.clone()],
376376- };
377377-378378- // A test match with 5 players, player 0 (host) is a hider, players 1 and 2 are seekers.
379379- let test_match = TestMatch::new(5, 2, false, settings);
380380-381381- let correct_caught_state = HashMap::<PlayerId, bool>::from_iter([
382382- (0, false),
383383- (1, true),
384384- (2, true),
385385- (3, false),
386386- (4, false),
387387- ]);
388388-389389- // Let's make sure our initial `caught_state` is correct
390390- test_match
391391- .assert_all_player_states(|s| assert_eq!(s.public.caught_state, correct_caught_state))
392392- .await;
393393-394394- test_match.start();
395395-396396- // Wait for seekers to be released, and then assert all player states properly reflect this
397397- test_match.wait_assert_seekers_released().await;
398398-399399- test_match.wait_tick().await;
400400-401401- // After a tick, all players should have at least one location in [PlayerState::locations]
402402- test_match
403403- .assert_all_player_states(|s| assert!(!s.player.locations.is_empty()))
404404- .await;
405405-406406- // Now, let's see if we can mark player 3 as caught
407407- let player_3 = test_match.game(3);
408408- player_3.mark_caught().await;
409409- yield_now().await;
410410-411411- // All states should be updated to reflect this
412412- test_match
413413- .assert_all_player_states(|s| {
414414- assert_eq!(s.public.caught_state.get(&3).copied(), Some(true))
415415- })
416416- .await;
417417-418418- test_match.wait_tick().await;
419419-420420- // And now, 3 players have been caught, meaning our [PingStartCondition] has been met,
421421- // let's check the host state to make sure it's starting to perform pings
422422- test_match
423423- .assert_host_state(|h| assert!(h.last_ping.is_some()))
424424- .await;
425425-426426- test_match.wait_tick().await;
427427-428428- // Value represents if the [Option] should be [Option::Some]
429429- let correct_pings = HashMap::<u32, bool>::from_iter([
430430- (0, true),
431431- (1, false),
432432- (2, false),
433433- (3, false),
434434- (4, true),
435435- ]);
436436-437437- // Now let's make sure the hiders are being pinged (3 was just caught, triggering pings.
438438- // Therefore, 3 should not be pinged)
439439- test_match
440440- .assert_all_player_states(|s| {
441441- for (k, v) in s.public.pings.iter() {
442442- assert_eq!(v.is_some(), correct_pings[k]);
443443- }
444444- })
445445- .await;
446446- }
447447-}
+20
backend/src/game/events.rs
···11+use serde::{Deserialize, Serialize};
22+33+use super::{location::Location, state::PlayerPing, PlayerId};
44+55+/// An event used between players to update state
66+#[derive(Debug, Clone, Serialize, Deserialize)]
77+pub enum GameEvent<Id: PlayerId> {
88+ /// A player has been caught and is now a seeker, contains the ID of the caught player
99+ PlayerCaught(Id),
1010+ /// Public ping from a player revealing location
1111+ Ping(PlayerPing<Id>),
1212+ /// Force the player specified in `0` to ping, optionally display the ping as from the user
1313+ /// specified in `1`.
1414+ ForcePing(Id, Option<Id>),
1515+ /// Force a powerup to despawn because a player got it, contains the player that got it.
1616+ PowerupDespawn(Id),
1717+ /// Contains location history of the given player, used after the game to sync location
1818+ /// histories
1919+ PostGameSync(Id, Vec<Location>),
2020+}
+19
backend/src/game/location.rs
···11+use serde::{Deserialize, Serialize};
22+33+/// A "part" of a location
44+pub type LocationComponent = f64;
55+66+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
77+/// Some location in the world as gotten from a Geolocation API
88+pub struct Location {
99+ /// Latitude
1010+ pub lat: LocationComponent,
1111+ /// Longitude
1212+ pub long: LocationComponent,
1313+ /// The bearing (float normalized from 0 to 1) optional as GPS can't always determine
1414+ pub heading: Option<LocationComponent>,
1515+}
1616+1717+pub trait LocationService {
1818+ fn get_loc(&self) -> Location;
1919+}
+594
backend/src/game/mod.rs
···11+use chrono::{DateTime, Utc};
22+use events::GameEvent;
33+use powerups::PowerUpType;
44+use settings::GameSettings;
55+use std::{collections::HashMap, fmt::Debug, hash::Hash, time::Duration};
66+77+use tokio::{sync::RwLock, time::MissedTickBehavior};
88+99+mod events;
1010+mod location;
1111+mod powerups;
1212+mod settings;
1313+mod state;
1414+mod transport;
1515+1616+use location::LocationService;
1717+use state::GameState;
1818+use transport::Transport;
1919+2020+/// Type used to uniquely identify players in the game
2121+pub trait PlayerId:
2222+ Debug + Hash + Ord + Eq + PartialEq + Send + Sync + Sized + Copy + Clone
2323+{
2424+}
2525+2626+/// Convenence alias for UTC DT
2727+pub type UtcDT = DateTime<Utc>;
2828+2929+/// Struct representing an ongoing game, handles communication with
3030+/// other clients via [Transport], gets location with [LocationService], and provides high-level methods for
3131+/// taking actions in the game.
3232+struct Game<Id: PlayerId, L: LocationService, T: Transport<Id>> {
3333+ state: RwLock<GameState<Id>>,
3434+ transport: T,
3535+ location: L,
3636+ interval: Duration,
3737+}
3838+3939+impl<Id: PlayerId, L: LocationService, T: Transport<Id>> Game<Id, L, T> {
4040+ pub fn new(
4141+ my_id: Id,
4242+ interval: Duration,
4343+ random_seed: u64,
4444+ initial_caught_state: HashMap<Id, bool>,
4545+ settings: GameSettings,
4646+ transport: T,
4747+ location: L,
4848+ ) -> Self {
4949+ let state = GameState::<Id>::new(settings, my_id, random_seed, initial_caught_state);
5050+5151+ Self {
5252+ transport,
5353+ location,
5454+ interval,
5555+ state: RwLock::new(state),
5656+ }
5757+ }
5858+5959+ pub async fn mark_caught(&self) {
6060+ let mut state = self.state.write().await;
6161+ let id = state.id;
6262+ state.mark_caught(id);
6363+ state.remove_ping(id);
6464+ // TODO: Maybe reroll for new powerups instead of just erasing it
6565+ state.use_powerup();
6666+6767+ self.transport
6868+ .send_message(GameEvent::PlayerCaught(state.id))
6969+ .await;
7070+ }
7171+7272+ pub async fn get_powerup(&self) {
7373+ let mut state = self.state.write().await;
7474+ state.get_powerup();
7575+ self.transport
7676+ .send_message(GameEvent::PowerupDespawn(state.id))
7777+ .await;
7878+ }
7979+8080+ pub async fn use_powerup(&self) {
8181+ let mut state = self.state.write().await;
8282+8383+ if let Some(powerup) = state.use_powerup() {
8484+ match powerup {
8585+ PowerUpType::PingSeeker => {}
8686+ PowerUpType::PingAllSeekers => {
8787+ for seeker in state.iter_seekers() {
8888+ self.transport
8989+ .send_message(GameEvent::ForcePing(seeker, None))
9090+ .await;
9191+ }
9292+ }
9393+ PowerUpType::ForcePingOther => {
9494+ // Fallback to a seeker if there are no other hiders
9595+ let target = state.random_other_hider().or_else(|| state.random_seeker());
9696+9797+ if let Some(target) = target {
9898+ self.transport
9999+ .send_message(GameEvent::ForcePing(target, None))
100100+ .await;
101101+ }
102102+ }
103103+ }
104104+ }
105105+ }
106106+107107+ async fn consume_event(&self, event: GameEvent<Id>) {
108108+ let mut state = self.state.write().await;
109109+110110+ match event {
111111+ GameEvent::Ping(player_ping) => state.add_ping(player_ping),
112112+ GameEvent::ForcePing(target, display) => {
113113+ if target != state.id {
114114+ return;
115115+ }
116116+117117+ let ping = if let Some(display) = display {
118118+ state.create_ping(display)
119119+ } else {
120120+ state.create_self_ping()
121121+ };
122122+123123+ if let Some(ping) = ping {
124124+ state.add_ping(ping.clone());
125125+ self.transport.send_message(GameEvent::Ping(ping)).await;
126126+ }
127127+ }
128128+ GameEvent::PowerupDespawn(_) => state.despawn_powerup(),
129129+ GameEvent::PlayerCaught(player) => {
130130+ state.mark_caught(player);
131131+ state.remove_ping(player);
132132+ }
133133+ GameEvent::PostGameSync(_, _locations) => {}
134134+ }
135135+ }
136136+137137+ /// Perform a tick for a specific moment in time
138138+ async fn tick(&self, now: UtcDT) {
139139+ let mut state = self.state.write().await;
140140+141141+ // Push to location history
142142+ let location = self.location.get_loc();
143143+ state.push_loc(location);
144144+145145+ // Release Seekers?
146146+ if !state.seekers_released() && state.should_release_seekers(now) {
147147+ state.release_seekers(now);
148148+ }
149149+150150+ // Start Pings?
151151+ if !state.pings_started() && state.should_start_pings(now) {
152152+ state.start_pings(now);
153153+ }
154154+155155+ // Do a Ping?
156156+ if state.should_ping(&now) {
157157+ if let Some(&PowerUpType::PingSeeker) = state.peek_powerup() {
158158+ // We have a powerup that lets us ping a seeker as us, use it.
159159+ if let Some(seeker) = state.random_seeker() {
160160+ state.use_powerup();
161161+ self.transport
162162+ .send_message(GameEvent::ForcePing(seeker, Some(state.id)))
163163+ .await;
164164+ state.start_pings(now);
165165+ }
166166+ } else {
167167+ // No powerup, normal ping
168168+ if let Some(ping) = state.create_self_ping() {
169169+ self.transport.send_message(GameEvent::Ping(ping)).await;
170170+ state.start_pings(now);
171171+ }
172172+ }
173173+ }
174174+175175+ // Start Powerup Rolls?
176176+ if !state.powerups_started() && state.should_start_powerups(now) {
177177+ state.start_powerups(now);
178178+ }
179179+180180+ // Should roll for a powerup?
181181+ if state.should_spawn_powerup(&now) {
182182+ state.try_spawn_powerup(now);
183183+ }
184184+ }
185185+186186+ #[cfg(test)]
187187+ pub async fn force_tick(&self, now: UtcDT) {
188188+ self.tick(now).await;
189189+ }
190190+191191+ /// Main loop of the game, handles ticking and receiving messages from [Transport].
192192+ pub async fn main_loop(&self) {
193193+ let mut interval = tokio::time::interval(self.interval);
194194+195195+ interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
196196+197197+ loop {
198198+ tokio::select! {
199199+200200+ biased;
201201+202202+ Some(msg) = self.transport.receive_message() => {
203203+ self.consume_event(msg).await;
204204+ // TODO: Check all caught, end game
205205+ }
206206+207207+ _ = interval.tick() => {
208208+ let now = Utc::now();
209209+ self.tick(now).await;
210210+ }
211211+ };
212212+ }
213213+ }
214214+}
215215+216216+#[cfg(test)]
217217+mod tests {
218218+ use std::{sync::Arc, u64};
219219+220220+ use crate::game::{location::Location, settings::PingStartCondition};
221221+222222+ use super::*;
223223+ use tokio::{sync::Mutex, task::yield_now, test};
224224+225225+ type GameEventRx = tokio::sync::mpsc::Receiver<GameEvent<u32>>;
226226+ type GameEventTx = tokio::sync::mpsc::Sender<GameEvent<u32>>;
227227+228228+ impl PlayerId for u32 {}
229229+230230+ struct MockTransport {
231231+ rx: Mutex<GameEventRx>,
232232+ txs: Vec<GameEventTx>,
233233+ }
234234+235235+ impl Transport<u32> for MockTransport {
236236+ async fn receive_message(&self) -> Option<GameEvent<u32>> {
237237+ let mut rx = self.rx.lock().await;
238238+ rx.recv().await
239239+ }
240240+241241+ async fn send_message(&self, msg: GameEvent<u32>) {
242242+ for (id, tx) in self.txs.iter().enumerate() {
243243+ tx.send(msg.clone()).await.expect("Failed to send msg");
244244+ }
245245+ }
246246+ }
247247+248248+ struct MockLocation;
249249+250250+ impl LocationService for MockLocation {
251251+ fn get_loc(&self) -> location::Location {
252252+ location::Location {
253253+ lat: 0.0,
254254+ long: 0.0,
255255+ heading: None,
256256+ }
257257+ }
258258+ }
259259+260260+ type TestGame = Game<u32, MockLocation, MockTransport>;
261261+262262+ struct MockMatch {
263263+ games: HashMap<u32, Arc<TestGame>>,
264264+ settings: GameSettings,
265265+ mock_now: UtcDT,
266266+ }
267267+268268+ const INTERVAL: Duration = Duration::from_secs(u64::MAX);
269269+270270+ impl MockMatch {
271271+ pub fn new(settings: GameSettings, players: u32, seekers: u32) -> Self {
272272+ let channels = (0..players)
273273+ .into_iter()
274274+ .map(|_| tokio::sync::mpsc::channel(10))
275275+ .collect::<Vec<_>>();
276276+277277+ let initial_caught_state = (0..players)
278278+ .into_iter()
279279+ .map(|id| (id, id < seekers))
280280+ .collect::<HashMap<_, _>>();
281281+ let txs = channels
282282+ .iter()
283283+ .map(|(tx, _)| tx.clone())
284284+ .collect::<Vec<_>>();
285285+286286+ let games = channels
287287+ .into_iter()
288288+ .enumerate()
289289+ .map(|(id, (_, rx))| {
290290+ let transport = MockTransport {
291291+ rx: Mutex::new(rx),
292292+ txs: txs.clone(),
293293+ };
294294+ let location = MockLocation;
295295+ let game = TestGame::new(
296296+ id as u32,
297297+ INTERVAL,
298298+ 0,
299299+ initial_caught_state.clone(),
300300+ settings.clone(),
301301+ transport,
302302+ location,
303303+ );
304304+305305+ (id as u32, Arc::new(game))
306306+ })
307307+ .collect::<HashMap<_, _>>();
308308+309309+ Self {
310310+ settings,
311311+ games,
312312+ mock_now: Utc::now(),
313313+ }
314314+ }
315315+316316+ pub async fn start(&self) {
317317+ for (id, game) in &self.games {
318318+ let game = game.clone();
319319+ let id = *id;
320320+ tokio::spawn(async move {
321321+ game.main_loop().await;
322322+ });
323323+ yield_now().await;
324324+ }
325325+ }
326326+327327+ pub async fn pass_time(&mut self, d: Duration) {
328328+ self.mock_now += d;
329329+ }
330330+331331+ pub async fn assert_all_states(&self, f: impl Fn(&GameState<u32>)) {
332332+ for (_, game) in &self.games {
333333+ let state = game.state.read().await;
334334+ f(&state);
335335+ }
336336+ }
337337+338338+ pub fn game(&self, id: u32) -> &TestGame {
339339+ self.games.get(&id).as_ref().unwrap()
340340+ }
341341+342342+ pub async fn wait_for_seekers(&mut self) {
343343+ let hiding_time = Duration::from_secs(self.settings.hiding_time_seconds as u64 + 1);
344344+ self.mock_now += hiding_time;
345345+346346+ self.tick().await;
347347+348348+ self.assert_all_states(|s| {
349349+ assert!(s.seekers_released());
350350+ })
351351+ .await;
352352+ }
353353+354354+ async fn tick_all(&self, now: UtcDT) {
355355+ for (_, game) in &self.games {
356356+ game.force_tick(now).await;
357357+ }
358358+ }
359359+360360+ pub async fn tick(&self) {
361361+ self.tick_all(self.mock_now).await;
362362+ yield_now().await;
363363+ }
364364+ }
365365+366366+ fn mk_settings() -> GameSettings {
367367+ GameSettings {
368368+ hiding_time_seconds: 1,
369369+ ping_start: PingStartCondition::Instant,
370370+ ping_minutes_interval: 1,
371371+ powerup_start: PingStartCondition::Instant,
372372+ powerup_chance: 0,
373373+ powerup_minutes_cooldown: 1,
374374+ powerup_locations: vec![Location {
375375+ lat: 0.0,
376376+ long: 0.0,
377377+ heading: None,
378378+ }],
379379+ }
380380+ }
381381+382382+ #[test]
383383+ async fn test_minimal_game() {
384384+ let settings = mk_settings();
385385+386386+ // 2 players, one is a seeker
387387+ let mut mat = MockMatch::new(settings, 2, 1);
388388+389389+ mat.start().await;
390390+391391+ mat.wait_for_seekers().await;
392392+393393+ mat.game(1).mark_caught().await;
394394+395395+ mat.tick().await;
396396+397397+ mat.assert_all_states(|s| {
398398+ assert_eq!(
399399+ s.get_caught(1),
400400+ Some(true),
401401+ "Game {} sees player 1 as not caught",
402402+ s.id
403403+ );
404404+ })
405405+ .await;
406406+407407+ // Game over, See TODO in main_loop for more assertions
408408+ }
409409+410410+ #[test]
411411+ async fn test_basic_pinging() {
412412+ let mut settings = mk_settings();
413413+ settings.ping_minutes_interval = 0;
414414+415415+ let mut mat = MockMatch::new(settings, 4, 1);
416416+417417+ mat.start().await;
418418+419419+ mat.wait_for_seekers().await;
420420+421421+ mat.assert_all_states(|s| {
422422+ for id in 0..4 {
423423+ let ping = s.get_ping(id);
424424+ if id == 0 {
425425+ assert!(
426426+ ping.is_none(),
427427+ "Game 0 is a seeker and shouldn't be pinged (in {})",
428428+ s.id
429429+ );
430430+ } else {
431431+ assert!(
432432+ ping.is_some(),
433433+ "Game {} is a hider and should be pinged (in {})",
434434+ id,
435435+ s.id
436436+ );
437437+ }
438438+ }
439439+ })
440440+ .await;
441441+442442+ mat.game(1).mark_caught().await;
443443+444444+ mat.tick().await;
445445+446446+ mat.assert_all_states(|s| {
447447+ for id in 0..4 {
448448+ let ping = s.get_ping(id);
449449+ if id <= 1 {
450450+ assert!(
451451+ ping.is_none(),
452452+ "Game {} is a seeker and shouldn't be pinged (in {})",
453453+ id,
454454+ s.id
455455+ );
456456+ } else {
457457+ assert!(
458458+ ping.is_some(),
459459+ "Game {} is a hider and should be pinged (in {})",
460460+ id,
461461+ s.id
462462+ );
463463+ }
464464+ }
465465+ })
466466+ .await;
467467+ }
468468+469469+ #[test]
470470+ async fn test_rng_sync() {
471471+ let mut settings = mk_settings();
472472+ settings.powerup_chance = 100;
473473+ settings.powerup_minutes_cooldown = 1;
474474+ settings.powerup_start = PingStartCondition::Instant;
475475+ settings.powerup_locations = (1..1000)
476476+ .into_iter()
477477+ .map(|x| Location {
478478+ lat: x as f64,
479479+ long: 1.0,
480480+ heading: None,
481481+ })
482482+ .collect();
483483+484484+ let mut mat = MockMatch::new(settings, 10, 2);
485485+486486+ mat.start().await;
487487+ mat.tick().await;
488488+ mat.wait_for_seekers().await;
489489+ mat.pass_time(Duration::from_secs(60)).await;
490490+ mat.tick().await;
491491+492492+ let game = mat.game(0);
493493+ let state = game.state.read().await;
494494+ let location = state.powerup_location().expect("Powerup didn't spawn");
495495+496496+ drop(state);
497497+498498+ mat.assert_all_states(|s| {
499499+ assert_eq!(
500500+ s.powerup_location(),
501501+ Some(location),
502502+ "Game {} has a different location than 0",
503503+ s.id
504504+ );
505505+ })
506506+ .await;
507507+ }
508508+509509+ #[test]
510510+ async fn test_powerup_ping_seeker_as_you() {
511511+ let mut settings = mk_settings();
512512+ settings.ping_minutes_interval = 0;
513513+ let mut mat = MockMatch::new(settings, 2, 1);
514514+515515+ mat.start().await;
516516+ mat.wait_for_seekers().await;
517517+518518+ let game = mat.game(1);
519519+ let mut state = game.state.write().await;
520520+ state.force_set_powerup(PowerUpType::PingSeeker);
521521+ drop(state);
522522+523523+ mat.tick().await;
524524+525525+ mat.assert_all_states(|s| {
526526+ if let Some(ping) = s.get_ping(1) {
527527+ assert_eq!(
528528+ ping.real_player, 0,
529529+ "Ping for 1 is not truly 0 (in {})",
530530+ s.id
531531+ );
532532+ } else {
533533+ panic!("No ping for 1 (in {})", s.id);
534534+ }
535535+ })
536536+ .await;
537537+ }
538538+539539+ #[test]
540540+ async fn test_powerup_ping_random_hider() {
541541+ let settings = mk_settings();
542542+543543+ let mut mat = MockMatch::new(settings, 3, 1);
544544+545545+ mat.start().await;
546546+ mat.wait_for_seekers().await;
547547+548548+ let game = mat.game(1);
549549+ let mut state = game.state.write().await;
550550+ state.force_set_powerup(PowerUpType::ForcePingOther);
551551+ drop(state);
552552+553553+ game.use_powerup().await;
554554+ mat.tick().await;
555555+556556+ mat.assert_all_states(|s| {
557557+ // Player 0 is a seeker, player 1 user the powerup, so 2 is the only one that should
558558+ // could have pinged
559559+ assert!(s.get_ping(2).is_some());
560560+ assert!(s.get_ping(0).is_none());
561561+ assert!(s.get_ping(1).is_none());
562562+ })
563563+ .await;
564564+ }
565565+566566+ #[test]
567567+ async fn test_powerup_ping_seekers() {
568568+ let settings = mk_settings();
569569+570570+ let mut mat = MockMatch::new(settings, 5, 3);
571571+572572+ mat.start().await;
573573+574574+ let game = mat.game(3);
575575+ let mut state = game.state.write().await;
576576+ state.force_set_powerup(PowerUpType::PingAllSeekers);
577577+ drop(state);
578578+579579+ game.use_powerup().await;
580580+ mat.tick().await;
581581+582582+ mat.assert_all_states(|s| {
583583+ for id in 0..3 {
584584+ assert!(
585585+ s.get_caught(id).is_some(),
586586+ "Player {} should be pinged due to the powerup (in {})",
587587+ id,
588588+ s.id
589589+ );
590590+ }
591591+ })
592592+ .await;
593593+ }
594594+}
+24
backend/src/game/powerups.rs
···11+use serde::{Deserialize, Serialize};
22+33+use super::{location::Location, PlayerId};
44+55+#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
66+/// Type of powerup
77+pub enum PowerUpType {
88+ /// Ping a random seeker instead of a hider
99+ PingSeeker,
1010+1111+ /// Pings all seekers locations on the map for hiders
1212+ PingAllSeekers,
1313+1414+ /// Ping another random hider instantly
1515+ ForcePingOther,
1616+}
1717+1818+impl PowerUpType {
1919+ pub const ALL_TYPES: [Self; 3] = [
2020+ PowerUpType::ForcePingOther,
2121+ PowerUpType::PingAllSeekers,
2222+ PowerUpType::PingSeeker,
2323+ ];
2424+}
+41
backend/src/game/settings.rs
···11+use rand::distr::Bernoulli;
22+use serde::{Deserialize, Serialize};
33+44+use super::location::Location;
55+66+#[derive(Debug, Clone, Serialize, Deserialize)]
77+/// The starting condition for global pings to begin
88+pub enum PingStartCondition {
99+ /// Wait For X players to be caught before beginning global pings
1010+ Players(u32),
1111+ /// Wait for X minutes after game start to begin global pings
1212+ Minutes(u32),
1313+ /// Don't wait at all, ping location after seekers are released
1414+ Instant,
1515+}
1616+1717+#[derive(Debug, Clone, Serialize, Deserialize)]
1818+/// Settings for the game, host is the only person able to change these
1919+pub struct GameSettings {
2020+ /// The number of seconds to wait before seekers are allowed to go
2121+ pub hiding_time_seconds: u32,
2222+ /// Condition to wait for global pings to begin
2323+ pub ping_start: PingStartCondition,
2424+ /// Time between pings after the condition is met (first ping is either after the interval or
2525+ /// instantly after the condition is met depending on the condition)
2626+ pub ping_minutes_interval: u64,
2727+ /// Condition for powerups to start spawning
2828+ pub powerup_start: PingStartCondition,
2929+ /// Chance every minute of a powerup spawning, out of 100
3030+ pub powerup_chance: u32,
3131+ /// Hard cooldown between powerups spawning
3232+ pub powerup_minutes_cooldown: u64,
3333+ /// Locations that powerups may spawn at
3434+ pub powerup_locations: Vec<Location>,
3535+}
3636+3737+impl GameSettings {
3838+ pub fn get_powerup_bernoulli(&self) -> Bernoulli {
3939+ Bernoulli::from_ratio(self.powerup_chance, 100).unwrap()
4040+ }
4141+}
+338
backend/src/game/state.rs
···11+use std::collections::HashMap;
22+use std::sync::Arc;
33+44+use chrono::{DateTime, Utc};
55+use rand::{
66+ distr::{Bernoulli, Distribution},
77+ rngs::ThreadRng,
88+ seq::{IndexedRandom, IteratorRandom},
99+ Rng, SeedableRng,
1010+};
1111+use rand_chacha::ChaCha20Rng;
1212+use serde::{Deserialize, Serialize};
1313+1414+use super::{
1515+ location::Location,
1616+ powerups::PowerUpType,
1717+ settings::{GameSettings, PingStartCondition},
1818+ PlayerId, UtcDT,
1919+};
2020+2121+#[derive(Debug, Clone, Serialize, Deserialize)]
2222+/// An on-map ping of a player
2323+pub struct PlayerPing<Id: PlayerId> {
2424+ /// Location of the ping
2525+ loc: Location,
2626+ /// Time the ping happened
2727+ timestamp: UtcDT,
2828+ /// The player to display as
2929+ pub display_player: Id,
3030+ /// The actual player that initialized this ping
3131+ pub real_player: Id,
3232+}
3333+3434+impl<Id: PlayerId> PlayerPing<Id> {
3535+ pub fn new(loc: Location, display_player: Id, real_player: Id) -> Self {
3636+ Self {
3737+ loc,
3838+ display_player,
3939+ real_player,
4040+ timestamp: Utc::now(),
4141+ }
4242+ }
4343+}
4444+4545+#[derive(Debug, Clone, Serialize)]
4646+/// Represents the game's state as a whole, seamlessly connects public and player state.
4747+/// This struct handles all logic regarding state updates
4848+pub struct GameState<Id: PlayerId> {
4949+ /// The id of this player in this game
5050+ pub id: Id,
5151+5252+ /// The powerup the player is currently holding
5353+ held_powerup: Option<PowerUpType>,
5454+5555+ /// When the game started
5656+ game_started: UtcDT,
5757+5858+ /// When seekers were allowed to begin
5959+ seekers_started: Option<UtcDT>,
6060+6161+ /// Last time we pinged all players
6262+ last_global_ping: Option<UtcDT>,
6363+6464+ /// Last time a powerup was spawned
6565+ last_powerup_spawn: Option<UtcDT>,
6666+6767+ /// Hashmap tracking if a player is a seeker (true) or a hider (false)
6868+ caught_state: HashMap<Id, bool>,
6969+7070+ /// A map of the latest global ping results for each player
7171+ pings: HashMap<Id, PlayerPing<Id>>,
7272+7373+ /// Powerup on the map that players can grab. Only one at a time
7474+ available_powerup: Option<Location>,
7575+7676+ /// The game's current settings
7777+ settings: GameSettings,
7878+7979+ #[serde(skip)]
8080+ /// The player's location history
8181+ location_history: Vec<Location>,
8282+8383+ /// Cached bernoulli distribution for powerups, faster sampling
8484+ #[serde(skip)]
8585+ powerup_bernoulli: Bernoulli,
8686+8787+ /// A seed with a shared value between all players, should be reproducible
8888+ /// RNG for use in stuff like powerup location selection.
8989+ #[serde(skip)]
9090+ shared_random_increment: i64,
9191+9292+ /// State for [ChaCha20Rng] to be used and added to when performing shared RNG operations
9393+ #[serde(skip)]
9494+ shared_random_state: u64,
9595+}
9696+9797+impl<Id: PlayerId> GameState<Id> {
9898+ pub fn new(
9999+ settings: GameSettings,
100100+ my_id: Id,
101101+ random_seed: u64,
102102+ initial_caught_state: HashMap<Id, bool>,
103103+ ) -> Self {
104104+ let mut rand = ChaCha20Rng::seed_from_u64(random_seed);
105105+ let increment = rand.random_range(-100..100);
106106+107107+ Self {
108108+ id: my_id,
109109+ game_started: Utc::now(),
110110+ seekers_started: None,
111111+ pings: HashMap::with_capacity(initial_caught_state.len()),
112112+ caught_state: initial_caught_state,
113113+ available_powerup: None,
114114+ powerup_bernoulli: settings.get_powerup_bernoulli(),
115115+ settings,
116116+ last_global_ping: None,
117117+ last_powerup_spawn: None,
118118+ location_history: Vec::with_capacity(30),
119119+ held_powerup: None,
120120+ shared_random_increment: increment,
121121+ shared_random_state: random_seed,
122122+ }
123123+ }
124124+125125+ fn create_rand_from_shared_seed(&mut self) -> ChaCha20Rng {
126126+ let rand = ChaCha20Rng::seed_from_u64(self.shared_random_state);
127127+128128+ self.shared_random_state = self
129129+ .shared_random_state
130130+ .wrapping_add_signed(self.shared_random_increment);
131131+132132+ rand
133133+ }
134134+135135+ /// Spawn a powerup on the map, this **MUST** be called on all players at about the same time.
136136+ /// First rolls to see if we will spawn one with `chance` (chance is percent chance out of 100).
137137+ /// If the roll succeeds, spawn a powerup at one of the given locations.
138138+ pub fn try_spawn_powerup(&mut self, now: UtcDT) {
139139+ let mut shared_rand = self.create_rand_from_shared_seed();
140140+ let roll = self.powerup_bernoulli.sample(&mut shared_rand);
141141+ if roll {
142142+ let choice = self
143143+ .settings
144144+ .powerup_locations
145145+ .choose(&mut shared_rand)
146146+ .cloned();
147147+ self.available_powerup = choice;
148148+ self.last_powerup_spawn = Some(now);
149149+ }
150150+ }
151151+152152+ fn minutes_since_seekers_released(&self, now: UtcDT) -> Option<u32> {
153153+ self.seekers_started
154154+ .as_ref()
155155+ .map(|released| (now - *released).num_minutes().unsigned_abs() as u32)
156156+ }
157157+158158+ pub fn pings_started(&self) -> bool {
159159+ self.last_global_ping.is_some()
160160+ }
161161+162162+ pub fn should_start_pings(&self, now: UtcDT) -> bool {
163163+ match self.settings.ping_start {
164164+ PingStartCondition::Players(num) => (self.iter_seekers().count() as u32) >= num,
165165+ PingStartCondition::Minutes(minutes) => self
166166+ .minutes_since_seekers_released(now)
167167+ .is_some_and(|seekers_released| seekers_released >= minutes),
168168+ PingStartCondition::Instant => true,
169169+ }
170170+ }
171171+172172+ /// Whether enough time has passed that we should perform a ping
173173+ pub fn should_ping(&self, now: &UtcDT) -> bool {
174174+ !self.is_seeker()
175175+ && self.last_global_ping.as_ref().is_some_and(|last_ping| {
176176+ let minutes = (*now - *last_ping).num_minutes().unsigned_abs();
177177+ minutes >= self.settings.ping_minutes_interval
178178+ })
179179+ }
180180+181181+ /// Begin pinging, will start the countdown for global pings. Also refreshes the timeout
182182+ pub fn start_pings(&mut self, now: UtcDT) {
183183+ self.last_global_ping = Some(now);
184184+ }
185185+186186+ /// Begin spawning powerups
187187+ pub fn start_powerups(&mut self, now: UtcDT) {
188188+ self.last_powerup_spawn = Some(now);
189189+ }
190190+191191+ /// Whether to start spawning powerups
192192+ pub fn should_start_powerups(&self, now: UtcDT) -> bool {
193193+ match self.settings.powerup_start {
194194+ PingStartCondition::Players(num) => (self.iter_seekers().count() as u32) >= num,
195195+ PingStartCondition::Minutes(mins) => self
196196+ .minutes_since_seekers_released(now)
197197+ .is_some_and(|seekers_released| seekers_released >= mins),
198198+ PingStartCondition::Instant => true,
199199+ }
200200+ }
201201+202202+ pub fn powerups_started(&self) -> bool {
203203+ self.last_powerup_spawn.is_some()
204204+ }
205205+206206+ /// Whether enough time has passed that we should roll for powerup spawns
207207+ pub fn should_spawn_powerup(&self, now: &UtcDT) -> bool {
208208+ self.last_powerup_spawn.as_ref().is_some_and(|last_spawn| {
209209+ let minutes = (*now - *last_spawn).num_minutes().unsigned_abs();
210210+ minutes >= self.settings.powerup_minutes_cooldown
211211+ })
212212+ }
213213+214214+ pub fn powerup_location(&self) -> Option<Location> {
215215+ self.available_powerup
216216+ }
217217+218218+ /// Despawn a powerup (due to timeout, other person getting it)
219219+ pub fn despawn_powerup(&mut self) {
220220+ self.available_powerup = None;
221221+ }
222222+223223+ pub fn should_release_seekers(&self, now: UtcDT) -> bool {
224224+ let seconds = (now - self.game_started).num_seconds().unsigned_abs();
225225+ seconds >= (self.settings.hiding_time_seconds as u64)
226226+ }
227227+228228+ /// Mark seekers as released
229229+ pub fn release_seekers(&mut self, now: UtcDT) {
230230+ self.seekers_started = Some(now);
231231+ }
232232+233233+ /// If seekers are released
234234+ pub fn seekers_released(&self) -> bool {
235235+ self.seekers_started.is_some()
236236+ }
237237+238238+ /// Add a ping for a specific player
239239+ pub fn add_ping(&mut self, ping: PlayerPing<Id>) {
240240+ self.pings.insert(ping.display_player, ping);
241241+ }
242242+243243+ /// Get a ping for a player
244244+ pub fn get_ping(&self, player: Id) -> Option<&PlayerPing<Id>> {
245245+ self.pings.get(&player)
246246+ }
247247+248248+ /// Remove a ping from the map
249249+ pub fn remove_ping(&mut self, player: Id) -> Option<PlayerPing<Id>> {
250250+ self.pings.remove(&player)
251251+ }
252252+253253+ /// Iterate over all seekers in the game
254254+ pub fn iter_seekers(&self) -> impl Iterator<Item = Id> + use<'_, Id> {
255255+ self.caught_state
256256+ .iter()
257257+ .filter_map(|(k, v)| if *v { Some(*k) } else { None })
258258+ }
259259+260260+ /// Pick a random seeker
261261+ pub fn random_seeker(&mut self) -> Option<Id> {
262262+ let seekers = self.iter_seekers().collect::<Vec<_>>();
263263+ let mut rand = rand::rng();
264264+ seekers.choose(&mut rand).copied()
265265+ }
266266+267267+ /// Iterate over all hiders in the game
268268+ fn iter_hiders(&self) -> impl Iterator<Item = Id> + use<'_, Id> {
269269+ self.caught_state
270270+ .iter()
271271+ .filter_map(|(k, v)| if !*v { Some(*k) } else { None })
272272+ }
273273+274274+ pub fn random_other_hider(&self) -> Option<Id> {
275275+ let mut rand = rand::rng();
276276+ self.iter_hiders()
277277+ .filter(|id| *id != self.id)
278278+ .choose(&mut rand)
279279+ }
280280+281281+ /// Create a [PlayerPing] with the latest location saved for the player
282282+ pub fn create_self_ping(&self) -> Option<PlayerPing<Id>> {
283283+ self.create_ping(self.id)
284284+ }
285285+286286+ /// Create a [PlayerPing] with the latest location as another player
287287+ pub fn create_ping(&self, id: Id) -> Option<PlayerPing<Id>> {
288288+ self.get_loc()
289289+ .map(|loc| PlayerPing::new(loc.clone(), id, self.id))
290290+ }
291291+292292+ /// Player has gotten a powerup, rolls to see which powerup and stores it
293293+ pub fn get_powerup(&mut self) {
294294+ let mut rand = rand::rng();
295295+ // TODO: Seekers vs Hiders, Weights?
296296+ let choice = PowerUpType::ALL_TYPES.choose(&mut rand).copied();
297297+ self.held_powerup = choice;
298298+ }
299299+300300+ pub fn force_set_powerup(&mut self, typ: PowerUpType) {
301301+ self.held_powerup = Some(typ);
302302+ }
303303+304304+ pub fn peek_powerup(&self) -> Option<&PowerUpType> {
305305+ self.held_powerup.as_ref()
306306+ }
307307+308308+ /// "Use" a powerup, takes it out of [held_powerup] and returns the type for use in game logic
309309+ pub fn use_powerup(&mut self) -> Option<PowerUpType> {
310310+ self.held_powerup.take()
311311+ }
312312+313313+ /// Push a new player location
314314+ pub fn push_loc(&mut self, loc: Location) {
315315+ self.location_history.push(loc);
316316+ }
317317+318318+ /// Get the latest player location
319319+ fn get_loc(&self) -> Option<&Location> {
320320+ self.location_history.last()
321321+ }
322322+323323+ /// Mark a player as caught
324324+ pub fn mark_caught(&mut self, player: Id) {
325325+ if let Some(caught) = self.caught_state.get_mut(&player) {
326326+ *caught = true;
327327+ }
328328+ }
329329+330330+ /// Gets if a player was caught or not
331331+ pub fn get_caught(&self, player: Id) -> Option<bool> {
332332+ self.caught_state.get(&player).copied()
333333+ }
334334+335335+ pub fn is_seeker(&self) -> bool {
336336+ self.caught_state.get(&self.id).copied().unwrap_or_default()
337337+ }
338338+}
···11-use crate::state::{Location, PlayerId};
22-33-#[derive(Clone, Copy)]
44-/// Type of powerup
55-pub enum PowerUpType {
66- /// Ping a random seeker instead of a hider
77- PingSeeker,
88-99- /// Pings all seekers locations on the map for hiders
1010- PingAllSeekers,
1111-1212- /// Ping another random hider instantly
1313- ForcePingOther,
1414-}
1515-1616-impl PowerUpType {
1717- pub const ALL_TYPES: [Self; 3] = [
1818- PowerUpType::ForcePingOther,
1919- PowerUpType::PingAllSeekers,
2020- PowerUpType::PingSeeker,
2121- ];
2222-}
2323-2424-#[derive(Clone)]
2525-/// Usage of a powerup as reported to the host
2626-pub enum PowerUpUsage {
2727- /// The hider will have their location replaced with a random seeker's
2828- PingSeeker,
2929- /// No additional args
3030- PingAllSeekers,
3131- /// Instantly ping another random hider, contains the unlucky person that is being pinged
3232- ForcePingOther(PlayerId),
3333-}
3434-3535-#[derive(Clone, PartialEq, Eq)]
3636-/// When a plugin is used
3737-pub enum PowerUpTiming {
3838- /// Used the second it's activated
3939- Instant,
4040- /// Used during the next global ping
4141- NextPing,
4242-}
4343-4444-impl PowerUpUsage {
4545- pub fn timing(&self) -> PowerUpTiming {
4646- match self {
4747- PowerUpUsage::PingSeeker => PowerUpTiming::NextPing,
4848- PowerUpUsage::ForcePingOther(_) => PowerUpTiming::Instant,
4949- PowerUpUsage::PingAllSeekers => PowerUpTiming::Instant,
5050- }
5151- }
5252-}
5353-5454-#[derive(Clone)]
5555-/// An on-map powerup that can be picked up by hiders
5656-pub struct PowerUp {
5757- loc: Location,
5858- pub typ: PowerUpType,
5959-}
6060-6161-impl PowerUp {
6262- pub fn new(loc: Location, typ: PowerUpType) -> Self {
6363- Self { loc, typ }
6464- }
6565-}
-475
backend/src/state.rs
···11-use std::collections::HashMap;
22-33-use chrono::{DateTime, Utc};
44-55-use crate::{
66- game::LocationService,
77- powerup::{PowerUp, PowerUpTiming, PowerUpType, PowerUpUsage},
88-};
99-1010-/// UTC DateTime;
1111-pub type DT = DateTime<Utc>;
1212-1313-/// Type used to uniquely identify players in the game
1414-pub type PlayerId = u32;
1515-1616-/// Type used for latitude and longitude
1717-pub type LocationComponent = f64;
1818-1919-#[derive(Debug, Clone)]
2020-/// The starting condition for global pings to begin
2121-pub enum PingStartCondition {
2222- /// Wait For X players to be caught before beginning global pings
2323- Players(u32),
2424- /// Wait for X minutes after game start to begin global pings
2525- Minutes(u32),
2626- /// Don't wait at all, ping location after seekers are released
2727- Instant,
2828-}
2929-3030-#[derive(Debug, Clone)]
3131-/// Settings for the game, host is the only person able to change these
3232-pub struct GameSettings {
3333- /// The number of seconds to wait before seekers are allowed to go
3434- pub hiding_time_seconds: u64,
3535- /// Condition to wait for global pings to begin
3636- pub ping_start: PingStartCondition,
3737- /// Time between pings after the condition is met (first ping is either after the interval or
3838- /// instantly after the condition is met depending on the condition)
3939- pub ping_minutes_interval: u64,
4040- /// Condition for powerups to start spawning
4141- pub powerup_start: PingStartCondition,
4242- /// Chance (after cooldown) each minute of a powerup spawning, out of 100
4343- pub powerup_chance: u32,
4444- /// Hard cooldown between powerups spawning
4545- pub powerup_minutes_cooldown: u32,
4646- /// Locations that powerups may spawn at
4747- pub powerup_locations: Vec<Location>,
4848-}
4949-5050-#[derive(Debug, Clone, Copy)]
5151-/// Some location in the world as gotten from the Geolocation API
5252-pub struct Location {
5353- /// Latitude
5454- pub lat: LocationComponent,
5555- /// Longitude
5656- pub long: LocationComponent,
5757- /// The bearing (float normalized from 0 to 1) optional as GPS can't always determine
5858- pub heading: Option<LocationComponent>,
5959-}
6060-6161-/// State for each player during the game, the host also has this
6262-pub struct PlayerState {
6363- /// The id of this player in this game
6464- pub id: PlayerId,
6565- /// All previous locations of this player, used in replay screen and when a ping happens
6666- pub locations: Vec<Location>,
6767- /// Whether the local player is a seeker
6868- pub seeker: bool,
6969- /// The powerup the player is currently holding
7070- pub held_powerup: Option<PowerUpType>,
7171-}
7272-7373-/// Host state that determines when "privileged" events happen
7474-pub struct HostState {
7575- /// The last time a location global ping occurred. If this is [Option::None] it means we're not
7676- /// pinging yet
7777- pub last_ping: Option<DT>,
7878-7979- /// The last time a power-up has spawned.
8080- pub last_powerup: Option<DT>,
8181-8282- /// Last time a roll was done for a powerup to spawn, if this is [Option::None] it means we're
8383- /// not spawning powerups yet.
8484- pub last_powerup_proc: Option<DT>,
8585-8686- /// Set of users that will not be pinged / ping someone else next ping
8787- pub ping_power_usages: HashMap<PlayerId, PowerUpUsage>,
8888-8989- /// A list of all events that happened in this game, and their times
9090- pub event_history: Vec<(PlayerId, DT, GameEvent)>,
9191-}
9292-9393-impl HostState {
9494- pub fn new() -> Self {
9595- Self {
9696- last_ping: None,
9797- last_powerup: None,
9898- last_powerup_proc: None,
9999- ping_power_usages: HashMap::with_capacity(4),
100100- event_history: Vec::with_capacity(50),
101101- }
102102- }
103103-}
104104-105105-#[derive(Clone)]
106106-/// An on-map ping of a player
107107-pub struct PlayerPing {
108108- /// Location of the ping
109109- loc: Location,
110110- /// Time the ping happened
111111- time: DT,
112112- /// The player to display who initialized this ping
113113- player: PlayerId,
114114- /// The actual player that initialized this ping
115115- real_player: PlayerId,
116116-}
117117-118118-impl PlayerPing {
119119- pub fn new(loc: Location, player: PlayerId, real_player: PlayerId) -> Self {
120120- Self {
121121- loc,
122122- player,
123123- real_player,
124124- time: Utc::now(),
125125- }
126126- }
127127-}
128128-129129-/// State meant to be updated and synced
130130-pub struct PublicState {
131131- /// When the game started
132132- pub game_started: DT,
133133-134134- /// When seekers were allowed to begin
135135- pub seekers_started: Option<DT>,
136136-137137- /// Hashmap tracking if a player is a seeker (true) or a hider (false)
138138- pub caught_state: HashMap<PlayerId, bool>,
139139-140140- /// A map of the latest global ping results for each player
141141- pub pings: HashMap<PlayerId, Option<PlayerPing>>,
142142-143143- /// Powerup on the map that players can grab. Only one at a time
144144- pub available_powerup: Option<PowerUp>,
145145-}
146146-147147-impl PublicState {
148148- pub fn new(players: HashMap<PlayerId, bool>) -> Self {
149149- Self {
150150- game_started: Utc::now(),
151151- seekers_started: None,
152152- pings: HashMap::from_iter(players.keys().map(|id| (*id, None))),
153153- caught_state: players,
154154- available_powerup: None,
155155- }
156156- }
157157-158158- pub fn iter_seekers(&self) -> impl Iterator<Item = PlayerId> + use<'_> {
159159- self.caught_state
160160- .iter()
161161- .filter_map(|(k, v)| if *v { Some(*k) } else { None })
162162- }
163163-164164- pub fn iter_hiders(&self) -> impl Iterator<Item = PlayerId> + use<'_> {
165165- self.caught_state
166166- .iter()
167167- .filter_map(|(k, v)| if !*v { Some(*k) } else { None })
168168- }
169169-}
170170-171171-impl PlayerState {
172172- pub fn new(id: PlayerId, seeker: bool) -> Self {
173173- Self {
174174- id,
175175- locations: Vec::with_capacity(20),
176176- seeker,
177177- held_powerup: None,
178178- }
179179- }
180180-181181- /// Create a [PlayerPing] with the latest location saved for the player
182182- pub fn create_self_ping(&self) -> Option<PlayerPing> {
183183- self.create_ping(self.id)
184184- }
185185-186186- /// Create a [PlayerPing] with the latest location as another player, used when powerups are
187187- /// active
188188- pub fn create_ping(&self, id: PlayerId) -> Option<PlayerPing> {
189189- self.get_loc()
190190- .map(|loc| PlayerPing::new(loc.clone(), id, self.id))
191191- }
192192-193193- /// Push a new player location
194194- pub fn push_loc(&mut self, loc: Location) {
195195- self.locations.push(loc);
196196- }
197197-198198- /// Get the latest player location
199199- pub fn get_loc(&self) -> Option<&Location> {
200200- self.locations.last()
201201- }
202202-}
203203-204204-/// Central struct for managing the entire game's state
205205-pub struct GameState<L: LocationService> {
206206- /// Player state, different for each player
207207- pub player: PlayerState,
208208- /// Public state, kept in sync via events
209209- pub public: PublicState,
210210- /// Host state, only for host, this being [Option::None] implies not being host
211211- pub host: Option<HostState>,
212212- /// The settings for the current game, read only
213213- pub settings: GameSettings,
214214- loc: L,
215215-}
216216-217217-#[derive(Clone)]
218218-/// Enum representing all events that can be published, some are host only although
219219-/// implicit trust is given to all players because uh who cares.
220220-pub enum GameEvent {
221221- /// Seekers are now active and can see the map
222222- SeekersReleased(DT),
223223- /// (Host) A request for a given player to ping, optionally includes another player to ping
224224- /// *as* (e.g. when [PowerUpType::PingSeeker] is used)
225225- PingReq(Option<PlayerId>),
226226- /// A [PlayerPing] was published to [PublicState]
227227- Ping(PlayerPing),
228228- /// The given hider has been caught
229229- HiderCaught(PlayerId),
230230- /// (Host) The powerup has spawned and is available to grab
231231- PowerUpSpawn(PowerUp),
232232- /// The powerup has despawned (Was grabbed or timed out)
233233- PowerUpDespawn,
234234- /// A player has activated a powerup, some powerups will be published globally and some will be
235235- /// handled only by the host (such as [PowerUpType::PingSeeker])
236236- PowerUpActivate(PowerUpUsage),
237237- /// (Host) The game has ended (all players were caught or the host cancelled the game)
238238- GameEnd(DT),
239239- /// (Players) After the game has ended, players send this as the final game message
240240- /// to the host with their entire location history
241241- PostGameSync(Vec<Location>),
242242- /// (Host) After the game has ended and all players have sent their location histories to the
243243- /// host, the host will send this back to all players. Contains the entire history of the game
244244- /// to be saved and replayed.
245245- HostHistorySync(
246246- (
247247- HashMap<PlayerId, Vec<Location>>,
248248- Vec<(PlayerId, DT, GameEvent)>,
249249- ),
250250- ),
251251-}
252252-253253-impl<L: LocationService> GameState<L> {
254254- /// Create a new game state (starting a game). Needs the ID of the current player and a HashMap
255255- /// of other player ids to their caught state (whether they start out as seeker).
256256- pub fn new(
257257- host: bool,
258258- id: PlayerId,
259259- players: HashMap<PlayerId, bool>,
260260- settings: GameSettings,
261261- loc: L,
262262- ) -> Self {
263263- let is_seeker = players.get(&id).copied().unwrap_or_default();
264264- Self {
265265- player: PlayerState::new(id, is_seeker),
266266- public: PublicState::new(players),
267267- host: if host { Some(HostState::new()) } else { None },
268268- settings,
269269- loc,
270270- }
271271- }
272272-273273- pub fn random_other_hider(&mut self) -> Option<PlayerId> {
274274- let hiders = self
275275- .public
276276- .iter_hiders()
277277- .filter(|i| *i != self.player.id)
278278- .collect::<Vec<_>>();
279279- let choice = rand::random_range(0..hiders.len());
280280- hiders.get(choice).copied()
281281- }
282282-283283- fn host_tick(&mut self, events: &mut Vec<(Option<PlayerId>, GameEvent)>) {
284284- if let Some(host) = self.host.as_mut() {
285285- let now = Utc::now();
286286-287287- // Do seekers need to be released?
288288- if self.public.seekers_started.is_none()
289289- && (now - self.public.game_started)
290290- .num_seconds()
291291- .unsigned_abs()
292292- >= self.settings.hiding_time_seconds
293293- {
294294- events.push((None, GameEvent::SeekersReleased(now)));
295295- }
296296-297297- // Do we need to start doing global pings?
298298- if host.last_ping.is_none() {
299299- let should_start = match self.settings.ping_start {
300300- PingStartCondition::Players(players) => {
301301- self.public.caught_state.values().filter(|v| **v).count()
302302- >= (players as usize)
303303- }
304304- PingStartCondition::Minutes(min) => {
305305- let delta = now - self.public.game_started;
306306- delta.num_minutes() >= (min as i64)
307307- }
308308- PingStartCondition::Instant => true,
309309- };
310310- if should_start {
311311- host.last_ping = Some(now);
312312- }
313313- }
314314-315315- // Do we need to do a global ping?
316316- if let Some(last_ping) = host.last_ping.as_mut() {
317317- if (now - *last_ping).num_minutes().unsigned_abs()
318318- >= self.settings.ping_minutes_interval
319319- {
320320- events.extend(self.public.caught_state.iter().filter_map(
321321- |(player, caught)| {
322322- // If caught, don't send a ping request
323323- if *caught {
324324- None
325325- } else {
326326- // If the player is pinging as someone else, do that here.
327327- if let Some(PowerUpUsage::PingSeeker) =
328328- host.ping_power_usages.get(player)
329329- {
330330- host.ping_power_usages.remove(player);
331331- let seekers = self.public.iter_seekers().collect::<Vec<_>>();
332332- let choice = rand::random_range(0..seekers.len());
333333- let seeker = seekers[choice];
334334- return Some((Some(seeker), GameEvent::PingReq(Some(*player))));
335335- }
336336- Some((Some(*player), GameEvent::PingReq(None)))
337337- }
338338- },
339339- ));
340340-341341- *last_ping = now;
342342- }
343343- }
344344-345345- // Do we need to start rolling for powerups?
346346- if host.last_powerup_proc.is_none() {
347347- let should_start = match self.settings.ping_start {
348348- PingStartCondition::Players(players) => {
349349- self.public.caught_state.values().filter(|v| **v).count()
350350- >= (players as usize)
351351- }
352352- PingStartCondition::Minutes(min) => {
353353- let delta = now - self.public.game_started;
354354- delta.num_minutes() >= (min as i64)
355355- }
356356- PingStartCondition::Instant => true,
357357- };
358358- if should_start {
359359- host.last_powerup_proc = Some(now);
360360- }
361361- }
362362-363363- // Should we roll for a powerup?
364364- if let Some(last_powerup_proc) = host.last_powerup_proc.as_mut() {
365365- if (now - *last_powerup_proc).num_minutes() >= 1 {
366366- // A minute has passed, roll to see if we should spawn a powerup
367367- let cooldown_over = host.last_powerup.is_none_or(|d| {
368368- (now - d).num_minutes() >= (self.settings.powerup_minutes_cooldown as i64)
369369- });
370370- let roll = rand::random_ratio(self.settings.powerup_chance, 100);
371371-372372- if cooldown_over && roll {
373373- // Cooldown is over and we rolled positive, choose and send out a powerup.
374374- let typ_choice = rand::random_range(0..PowerUpType::ALL_TYPES.len());
375375- let loc_choice =
376376- rand::random_range(0..self.settings.powerup_locations.len());
377377- let powerup = PowerUp::new(
378378- self.settings.powerup_locations[loc_choice],
379379- PowerUpType::ALL_TYPES[typ_choice],
380380- );
381381-382382- events.push((None, GameEvent::PowerUpSpawn(powerup)));
383383-384384- host.last_powerup = Some(now);
385385- }
386386-387387- *last_powerup_proc = now;
388388- }
389389- }
390390- }
391391- }
392392-393393- fn update_loc(&mut self) {
394394- let loc = self.loc.get_loc();
395395- self.player.push_loc(loc);
396396- }
397397-398398- /// Run a single game tick, returns any messages that need to be sent
399399- pub fn tick(&mut self) -> Vec<(Option<PlayerId>, GameEvent)> {
400400- let mut events = Vec::with_capacity(5);
401401-402402- self.host_tick(&mut events);
403403- self.update_loc();
404404-405405- events
406406- }
407407-408408- /// Consume an event, optionally returns events to re-broadcast
409409- pub fn consume_event(
410410- &mut self,
411411- time_sent: DT,
412412- event: GameEvent,
413413- player_id: PlayerId,
414414- ) -> Option<GameEvent> {
415415- if let Some(host) = self.host.as_mut() {
416416- host.event_history
417417- .push((player_id, time_sent, event.clone()));
418418- }
419419-420420- match event {
421421- GameEvent::SeekersReleased(time) => {
422422- self.public.seekers_started = Some(time);
423423- }
424424- GameEvent::PingReq(fake_player) => {
425425- let ping = if let Some(fake_player) = fake_player {
426426- self.player.create_ping(fake_player)
427427- } else {
428428- self.player.create_self_ping()
429429- };
430430-431431- return ping.map(|p| GameEvent::Ping(p));
432432- }
433433- GameEvent::Ping(ping) => {
434434- if let Some(current) = self.public.pings.get_mut(&ping.player) {
435435- *current = Some(ping);
436436- }
437437- }
438438- GameEvent::HiderCaught(id) => {
439439- if id == self.player.id {
440440- self.player.seeker = true;
441441- }
442442- if let Some(state) = self.public.caught_state.get_mut(&id) {
443443- *state = true;
444444- }
445445- if self.host.is_some() && self.public.caught_state.iter().all(|(_, k)| *k) {
446446- return Some(GameEvent::GameEnd(Utc::now()));
447447- }
448448- }
449449- GameEvent::GameEnd(_dt) => {
450450- // [Game] handles this case, do nothing if we get here.
451451- }
452452- GameEvent::PowerUpSpawn(power_up) => {
453453- self.public.available_powerup = Some(power_up);
454454- }
455455- GameEvent::PowerUpDespawn => {
456456- self.public.available_powerup = None;
457457- }
458458- GameEvent::PowerUpActivate(usage) => {
459459- if usage.timing() == PowerUpTiming::NextPing {
460460- if let Some(host) = self.host.as_mut() {
461461- if let Some(old_usage) = host.ping_power_usages.get_mut(&player_id) {
462462- *old_usage = usage;
463463- } else {
464464- host.ping_power_usages.insert(player_id, usage);
465465- }
466466- }
467467- }
468468- }
469469- GameEvent::PostGameSync(_) | GameEvent::HostHistorySync(_) => {
470470- // Handled by [Game]
471471- }
472472- }
473473- None
474474- }
475475-}