···6464 Game and lobby logic for the app
6565- [manhunt-transport/](https://github.com/Bwc9876/manhunt-app/tree/main/manhunt-transport):
6666 Transport (networking) implementation for communication between apps
6767-- [backend/](https://github.com/Bwc9876/manhunt-app/tree/main/backend): App
6767+- [manhunt-app/](https://github.com/Bwc9876/manhunt-app/tree/main/manhunt-app): App
6868 backend, Rust side of the Tauri application
6969- [frontend/](https://github.com/Bwc9876/manhunt-app/tree/main/frontend): App
7070 frontend, Web side of the Tauri application
···8383- `just check-frontend`: Check for potential issues on the frontend
8484 (only need to run if you edited the frontend)
85858686-**Important**: When changing any type in `backend` that derives `specta::Type`,
8686+**Important**: When changing any type in a rust file that derives `specta::Type`,
8787you need to run `just export-types` to sync these type bindings to the frontend.
8888Otherwise the TypeScript definitions will not match the ones that the backend expects.
8989
···3737 npm run lint
38383939# Export types from the backend to TypeScript bindings
4040-[working-directory('backend')]
4140export-types:
4242- cargo run --bin export-types ../frontend/src/bindings.ts
4343- prettier --write ../frontend/src/bindings.ts --config ../.prettierrc.yaml
4141+ cargo run --bin export-types frontend/src/bindings.ts
4242+ prettier --write frontend/src/bindings.ts --config .prettierrc.yaml
44434544# Start the signaling server on localhost:3536
4645[working-directory('manhunt-signaling')]
+297
manhunt-app/src/lib.rs
···11+mod history;
22+mod location;
33+mod profiles;
44+mod state;
55+66+use std::collections::HashMap;
77+88+use log::LevelFilter;
99+use manhunt_logic::{GameSettings, GameUiState, LobbyState, PlayerProfile, UtcDT};
1010+use manhunt_transport::room_exists;
1111+use tauri::{AppHandle, Manager, State};
1212+use tauri_specta::{ErrorHandlingMode, collect_commands, collect_events};
1313+use tokio::sync::RwLock;
1414+use uuid::Uuid;
1515+1616+use std::result::Result as StdResult;
1717+1818+use crate::{
1919+ history::AppGameHistory,
2020+ profiles::{read_profile_from_store, write_profile_to_store},
2121+ state::{AppScreen, AppState, AppStateHandle, ChangeScreen, GameStateUpdate, LobbyStateUpdate},
2222+};
2323+2424+type Result<T = (), E = String> = StdResult<T, E>;
2525+2626+// == GENERAL / FLOW COMMANDS ==
2727+2828+#[tauri::command]
2929+#[specta::specta]
3030+/// Get the screen the app should currently be on, returns [AppScreen]
3131+async fn get_current_screen(state: State<'_, AppStateHandle>) -> Result<AppScreen> {
3232+ let state = state.read().await;
3333+ Ok(match &*state {
3434+ AppState::Setup => AppScreen::Setup,
3535+ AppState::Menu(_player_profile) => AppScreen::Menu,
3636+ AppState::Lobby(_lobby) => AppScreen::Lobby,
3737+ AppState::Game(_game, _profiles) => AppScreen::Game,
3838+ AppState::Replay(_) => AppScreen::Replay,
3939+ })
4040+}
4141+4242+#[tauri::command]
4343+#[specta::specta]
4444+/// Quit a running game or leave a lobby
4545+async fn quit_to_menu(app: AppHandle, state: State<'_, AppStateHandle>) -> Result {
4646+ let mut state = state.write().await;
4747+ state.quit_to_menu(app).await;
4848+ Ok(())
4949+}
5050+5151+// == AppState::Setup COMMANDS
5252+5353+#[tauri::command]
5454+#[specta::specta]
5555+/// (Screen: Setup) Complete user setup and go to the menu screen
5656+async fn complete_setup(
5757+ profile: PlayerProfile,
5858+ app: AppHandle,
5959+ state: State<'_, AppStateHandle>,
6060+) -> Result {
6161+ state.write().await.complete_setup(&app, profile)
6262+}
6363+6464+// == AppState::Menu COMMANDS ==
6565+6666+#[tauri::command]
6767+#[specta::specta]
6868+/// (Screen: Menu) Get the user's player profile
6969+async fn get_profile(state: State<'_, AppStateHandle>) -> Result<PlayerProfile> {
7070+ let state = state.read().await;
7171+ let profile = state.get_menu()?;
7272+ Ok(profile.clone())
7373+}
7474+7575+#[tauri::command]
7676+#[specta::specta]
7777+/// (Screen: Menu) Get a list of all previously played games, returns of list of DateTimes that represent when
7878+/// each game started, use this as a key
7979+fn list_game_histories(app: AppHandle) -> Result<Vec<UtcDT>> {
8080+ AppGameHistory::ls_histories(&app)
8181+ .map_err(|err| err.context("Failed to get game histories").to_string())
8282+}
8383+8484+#[tauri::command]
8585+#[specta::specta]
8686+/// (Screen: Menu) Go to the game replay screen to replay the game history specified by id
8787+async fn replay_game(id: UtcDT, app: AppHandle, state: State<'_, AppStateHandle>) -> Result {
8888+ state.write().await.replay_game(&app, id)
8989+}
9090+9191+#[tauri::command]
9292+#[specta::specta]
9393+/// (Screen: Menu) Check if a room code is valid to join, use this before starting a game
9494+/// for faster error checking.
9595+async fn check_room_code(code: &str) -> Result<bool> {
9696+ room_exists(code).await.map_err(|err| err.to_string())
9797+}
9898+9999+#[tauri::command]
100100+#[specta::specta]
101101+/// (Screen: Menu) Update the player's profile and persist it
102102+async fn update_profile(
103103+ new_profile: PlayerProfile,
104104+ app: AppHandle,
105105+ state: State<'_, AppStateHandle>,
106106+) -> Result {
107107+ write_profile_to_store(&app, new_profile.clone());
108108+ let mut state = state.write().await;
109109+ let profile = state.get_menu_mut()?;
110110+ *profile = new_profile;
111111+ Ok(())
112112+}
113113+114114+#[tauri::command]
115115+#[specta::specta]
116116+/// (Screen: Menu) Start/Join a new lobby, set `join_code` to `null` to be host,
117117+/// set it to a join code to be a client. This triggers a screen change to [AppScreen::Lobby]
118118+async fn start_lobby(
119119+ app: AppHandle,
120120+ join_code: Option<String>,
121121+ settings: GameSettings,
122122+ state: State<'_, AppStateHandle>,
123123+) -> Result {
124124+ let mut state = state.write().await;
125125+ state.start_lobby(join_code, app, settings).await;
126126+ Ok(())
127127+}
128128+129129+// AppState::Lobby COMMANDS
130130+131131+#[tauri::command]
132132+#[specta::specta]
133133+/// (Screen: Lobby) Get the current state of the lobby, call after receiving an update event
134134+async fn get_lobby_state(state: State<'_, AppStateHandle>) -> Result<LobbyState> {
135135+ let lobby = state.read().await.get_lobby()?;
136136+ Ok(lobby.clone_state().await)
137137+}
138138+139139+#[tauri::command]
140140+#[specta::specta]
141141+/// (Screen: Lobby) Switch teams between seekers and hiders, returns the new [LobbyState]
142142+async fn switch_teams(seeker: bool, state: State<'_, AppStateHandle>) -> Result {
143143+ let lobby = state.read().await.get_lobby()?;
144144+ lobby.switch_teams(seeker).await;
145145+ Ok(())
146146+}
147147+148148+#[tauri::command]
149149+#[specta::specta]
150150+/// (Screen: Lobby) HOST ONLY: Push new settings to everyone, does nothing on clients. Returns the
151151+/// new lobby state
152152+async fn host_update_settings(settings: GameSettings, state: State<'_, AppStateHandle>) -> Result {
153153+ let lobby = state.read().await.get_lobby()?;
154154+ lobby.update_settings(settings).await;
155155+ Ok(())
156156+}
157157+158158+#[tauri::command]
159159+#[specta::specta]
160160+/// (Screen: Lobby) HOST ONLY: Start the game, stops anyone else from joining and switched screen
161161+/// to AppScreen::Game.
162162+async fn host_start_game(state: State<'_, AppStateHandle>) -> Result {
163163+ state.read().await.get_lobby()?.start_game().await;
164164+ Ok(())
165165+}
166166+167167+// AppScreen::Game COMMANDS
168168+169169+#[tauri::command]
170170+#[specta::specta]
171171+/// (Screen: Game) Get all player profiles with display names and profile pictures for this game.
172172+/// This value will never change and is fairly expensive to clone, so please minimize calls to
173173+/// this command.
174174+async fn get_profiles(state: State<'_, AppStateHandle>) -> Result<HashMap<Uuid, PlayerProfile>> {
175175+ state.read().await.get_profiles().cloned()
176176+}
177177+178178+#[tauri::command]
179179+#[specta::specta]
180180+/// (Screen: Game) Get the current settings for this game.
181181+async fn get_game_settings(state: State<'_, AppStateHandle>) -> Result<GameSettings> {
182182+ Ok(state.read().await.get_game()?.clone_settings().await)
183183+}
184184+185185+#[tauri::command]
186186+#[specta::specta]
187187+/// (Screen: Game) Get the current state of the game.
188188+async fn get_game_state(state: State<'_, AppStateHandle>) -> Result<GameUiState> {
189189+ Ok(state.read().await.get_game()?.get_ui_state().await)
190190+}
191191+192192+#[tauri::command]
193193+#[specta::specta]
194194+/// (Screen: Game) Mark this player as caught, this player will become a seeker. Returns the new game state
195195+async fn mark_caught(state: State<'_, AppStateHandle>) -> Result {
196196+ let game = state.read().await.get_game()?;
197197+ game.mark_caught().await;
198198+ Ok(())
199199+}
200200+201201+#[tauri::command]
202202+#[specta::specta]
203203+/// (Screen: Game) Grab a powerup on the map, this should be called when the user is *in range* of
204204+/// the powerup. Returns the new game state after rolling for the powerup
205205+async fn grab_powerup(state: State<'_, AppStateHandle>) -> Result {
206206+ let game = state.read().await.get_game()?;
207207+ game.get_powerup().await;
208208+ Ok(())
209209+}
210210+211211+#[tauri::command]
212212+#[specta::specta]
213213+/// (Screen: Game) Use the currently held powerup in the player's held_powerup. Does nothing if the
214214+/// player has none. Returns the updated game state
215215+async fn activate_powerup(state: State<'_, AppStateHandle>) -> Result {
216216+ let game = state.read().await.get_game()?;
217217+ game.use_powerup().await;
218218+ Ok(())
219219+}
220220+221221+// AppState::Replay COMMANDS
222222+223223+#[tauri::command]
224224+#[specta::specta]
225225+/// (Screen: Replay) Get the game history that's currently being replayed. Try to limit calls to
226226+/// this
227227+async fn get_current_replay_history(state: State<'_, AppStateHandle>) -> Result<AppGameHistory> {
228228+ state.read().await.get_replay()
229229+}
230230+231231+pub fn mk_specta() -> tauri_specta::Builder {
232232+ tauri_specta::Builder::<tauri::Wry>::new()
233233+ .error_handling(ErrorHandlingMode::Throw)
234234+ .commands(collect_commands![
235235+ start_lobby,
236236+ get_profile,
237237+ quit_to_menu,
238238+ get_current_screen,
239239+ update_profile,
240240+ get_lobby_state,
241241+ host_update_settings,
242242+ switch_teams,
243243+ host_start_game,
244244+ mark_caught,
245245+ grab_powerup,
246246+ activate_powerup,
247247+ check_room_code,
248248+ get_profiles,
249249+ replay_game,
250250+ list_game_histories,
251251+ get_current_replay_history,
252252+ get_game_settings,
253253+ get_game_state,
254254+ complete_setup,
255255+ ])
256256+ .events(collect_events![
257257+ ChangeScreen,
258258+ GameStateUpdate,
259259+ LobbyStateUpdate
260260+ ])
261261+}
262262+263263+#[cfg_attr(mobile, tauri::mobile_entry_point)]
264264+pub fn run() {
265265+ let state = RwLock::new(AppState::Setup);
266266+267267+ let builder = mk_specta();
268268+269269+ tauri::Builder::default()
270270+ .plugin(tauri_plugin_dialog::init())
271271+ .plugin(tauri_plugin_notification::init())
272272+ .plugin(
273273+ tauri_plugin_log::Builder::new()
274274+ .level(LevelFilter::Debug)
275275+ .build(),
276276+ )
277277+ .plugin(tauri_plugin_opener::init())
278278+ .plugin(tauri_plugin_geolocation::init())
279279+ .plugin(tauri_plugin_store::Builder::default().build())
280280+ .invoke_handler(builder.invoke_handler())
281281+ .manage(state)
282282+ .setup(move |app| {
283283+ builder.mount_events(app);
284284+285285+ let handle = app.handle().clone();
286286+ tauri::async_runtime::spawn(async move {
287287+ if let Some(profile) = read_profile_from_store(&handle) {
288288+ let state_handle = handle.state::<AppStateHandle>();
289289+ let mut state = state_handle.write().await;
290290+ *state = AppState::Menu(profile);
291291+ }
292292+ });
293293+ Ok(())
294294+ })
295295+ .run(tauri::generate_context!())
296296+ .expect("error while running tauri application");
297297+}
+300
manhunt-app/src/state.rs
···11+use std::{collections::HashMap, marker::PhantomData, sync::Arc, time::Duration};
22+33+use anyhow::Context;
44+use log::{error, info, warn};
55+use manhunt_logic::{
66+ Game as BaseGame, GameSettings, Lobby as BaseLobby, PlayerProfile, StartGameInfo,
77+ StateUpdateSender, UtcDT,
88+};
99+use manhunt_transport::{MatchboxTransport, request_room_code};
1010+use serde::{Deserialize, Serialize};
1111+use tauri::{AppHandle, Manager};
1212+use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
1313+use tauri_specta::Event;
1414+use tokio::sync::RwLock;
1515+use uuid::Uuid;
1616+1717+use crate::{
1818+ Result,
1919+ history::AppGameHistory,
2020+ location::TauriLocation,
2121+ profiles::{read_profile_from_store, write_profile_to_store},
2222+};
2323+2424+/// The state of the game has changed
2525+#[derive(Serialize, Deserialize, Clone, Default, Debug, specta::Type, tauri_specta::Event)]
2626+pub struct GameStateUpdate;
2727+2828+/// The state of the lobby has changed
2929+#[derive(Serialize, Deserialize, Clone, Default, Debug, specta::Type, tauri_specta::Event)]
3030+pub struct LobbyStateUpdate;
3131+3232+pub struct TauriStateUpdateSender<E: Clone + Default + Event + Serialize>(
3333+ AppHandle,
3434+ PhantomData<E>,
3535+);
3636+3737+impl<E: Serialize + Clone + Default + Event> TauriStateUpdateSender<E> {
3838+ fn new(app: &AppHandle) -> Self {
3939+ Self(app.clone(), PhantomData)
4040+ }
4141+}
4242+4343+impl<E: Serialize + Clone + Default + Event> StateUpdateSender for TauriStateUpdateSender<E> {
4444+ fn send_update(&self) {
4545+ if let Err(why) = E::default().emit(&self.0) {
4646+ error!("Error sending Game state update to UI: {why:?}");
4747+ }
4848+ }
4949+}
5050+5151+type Game = BaseGame<TauriLocation, MatchboxTransport, TauriStateUpdateSender<GameStateUpdate>>;
5252+type Lobby = BaseLobby<MatchboxTransport, TauriStateUpdateSender<LobbyStateUpdate>>;
5353+5454+pub enum AppState {
5555+ Setup,
5656+ Menu(PlayerProfile),
5757+ Lobby(Arc<Lobby>),
5858+ Game(Arc<Game>, HashMap<Uuid, PlayerProfile>),
5959+ Replay(AppGameHistory),
6060+}
6161+6262+#[derive(Serialize, Deserialize, specta::Type, Debug, Clone, Eq, PartialEq)]
6363+pub enum AppScreen {
6464+ Setup,
6565+ Menu,
6666+ Lobby,
6767+ Game,
6868+ Replay,
6969+}
7070+7171+pub type AppStateHandle = RwLock<AppState>;
7272+7373+const GAME_TICK_RATE: Duration = Duration::from_secs(1);
7474+7575+/// The app is changing screens, contains the screen it's switching to
7676+#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)]
7777+pub struct ChangeScreen(AppScreen);
7878+7979+fn error_dialog(app: &AppHandle, msg: &str) {
8080+ app.dialog()
8181+ .message(msg)
8282+ .kind(MessageDialogKind::Error)
8383+ .show(|_| {});
8484+}
8585+8686+impl AppState {
8787+ pub async fn start_game(&mut self, app: AppHandle, start: StartGameInfo) {
8888+ if let AppState::Lobby(lobby) = self {
8989+ let transport = lobby.clone_transport();
9090+ let profiles = lobby.clone_profiles().await;
9191+ let location = TauriLocation::new(app.clone());
9292+ let state_updates = TauriStateUpdateSender::new(&app);
9393+ let game = Arc::new(Game::new(
9494+ GAME_TICK_RATE,
9595+ start,
9696+ transport,
9797+ location,
9898+ state_updates,
9999+ ));
100100+ *self = AppState::Game(game.clone(), profiles.clone());
101101+ Self::game_loop(app.clone(), game, profiles);
102102+ Self::emit_screen_change(&app, AppScreen::Game);
103103+ }
104104+ }
105105+106106+ fn game_loop(app: AppHandle, game: Arc<Game>, profiles: HashMap<Uuid, PlayerProfile>) {
107107+ tokio::spawn(async move {
108108+ let res = game.main_loop().await;
109109+ let state_handle = app.state::<AppStateHandle>();
110110+ let mut state = state_handle.write().await;
111111+ match res {
112112+ Ok(Some(history)) => {
113113+ let history =
114114+ AppGameHistory::new(history, profiles, game.clone_settings().await);
115115+ if let Err(why) = history.save_history(&app) {
116116+ error!("Failed to save game history: {why:?}");
117117+ error_dialog(&app, "Failed to save the history of this game");
118118+ }
119119+ state.quit_to_menu(app.clone()).await;
120120+ }
121121+ Ok(None) => {
122122+ info!("User quit game");
123123+ }
124124+ Err(why) => {
125125+ error!("Game Error: {why:?}");
126126+ app.dialog()
127127+ .message(format!("Connection Error: {why}"))
128128+ .kind(MessageDialogKind::Error)
129129+ .show(|_| {});
130130+ state.quit_to_menu(app.clone()).await;
131131+ }
132132+ }
133133+ });
134134+ }
135135+136136+ pub fn get_menu(&self) -> Result<&PlayerProfile> {
137137+ match self {
138138+ AppState::Menu(player_profile) => Ok(player_profile),
139139+ _ => Err("Not on menu screen".to_string()),
140140+ }
141141+ }
142142+143143+ pub fn get_menu_mut(&mut self) -> Result<&mut PlayerProfile> {
144144+ match self {
145145+ AppState::Menu(player_profile) => Ok(player_profile),
146146+ _ => Err("Not on menu screen".to_string()),
147147+ }
148148+ }
149149+150150+ pub fn get_lobby(&self) -> Result<Arc<Lobby>> {
151151+ if let AppState::Lobby(lobby) = self {
152152+ Ok(lobby.clone())
153153+ } else {
154154+ Err("Not on lobby screen".to_string())
155155+ }
156156+ }
157157+158158+ pub fn get_game(&self) -> Result<Arc<Game>> {
159159+ if let AppState::Game(game, _) = self {
160160+ Ok(game.clone())
161161+ } else {
162162+ Err("Not on game screen".to_string())
163163+ }
164164+ }
165165+166166+ pub fn get_profiles(&self) -> Result<&HashMap<Uuid, PlayerProfile>> {
167167+ if let AppState::Game(_, profiles) = self {
168168+ Ok(profiles)
169169+ } else {
170170+ Err("Not on game screen".to_string())
171171+ }
172172+ }
173173+174174+ pub fn get_replay(&self) -> Result<AppGameHistory> {
175175+ if let AppState::Replay(history) = self {
176176+ Ok(history.clone())
177177+ } else {
178178+ Err("Not on replay screen".to_string())
179179+ }
180180+ }
181181+182182+ fn emit_screen_change(app: &AppHandle, screen: AppScreen) {
183183+ if let Err(why) = ChangeScreen(screen).emit(app) {
184184+ warn!("Error emitting screen change: {why:?}");
185185+ }
186186+ }
187187+188188+ pub fn complete_setup(&mut self, app: &AppHandle, profile: PlayerProfile) -> Result {
189189+ if let AppState::Setup = self {
190190+ write_profile_to_store(app, profile.clone());
191191+ *self = AppState::Menu(profile);
192192+ Self::emit_screen_change(app, AppScreen::Menu);
193193+ Ok(())
194194+ } else {
195195+ Err("Must be on the Setup screen".to_string())
196196+ }
197197+ }
198198+199199+ pub fn replay_game(&mut self, app: &AppHandle, id: UtcDT) -> Result {
200200+ if let AppState::Menu(_) = self {
201201+ let history = AppGameHistory::get_history(app, id)
202202+ .context("Failed to read history")
203203+ .map_err(|e| e.to_string())?;
204204+ *self = AppState::Replay(history);
205205+ Self::emit_screen_change(app, AppScreen::Replay);
206206+ Ok(())
207207+ } else {
208208+ Err("Not on menu screen".to_string())
209209+ }
210210+ }
211211+212212+ fn lobby_loop(app: AppHandle, lobby: Arc<Lobby>) {
213213+ tokio::spawn(async move {
214214+ let res = lobby.main_loop().await;
215215+ let app_game = app.clone();
216216+ let state_handle = app.state::<AppStateHandle>();
217217+ let mut state = state_handle.write().await;
218218+ match res {
219219+ Ok(Some(start)) => {
220220+ info!("Starting Game");
221221+ state.start_game(app_game, start).await;
222222+ }
223223+ Ok(None) => {
224224+ info!("User quit lobby");
225225+ }
226226+ Err(why) => {
227227+ error!("Lobby Error: {why}");
228228+ error_dialog(&app_game, &format!("Error joining the lobby: {why}"));
229229+ state.quit_to_menu(app_game).await;
230230+ }
231231+ }
232232+ });
233233+ }
234234+235235+ pub async fn start_lobby(
236236+ &mut self,
237237+ join_code: Option<String>,
238238+ app: AppHandle,
239239+ settings: GameSettings,
240240+ ) {
241241+ if let AppState::Menu(profile) = self {
242242+ let host = join_code.is_none();
243243+ let room_code = if let Some(code) = join_code {
244244+ code.to_ascii_uppercase()
245245+ } else {
246246+ match request_room_code().await {
247247+ Ok(code) => code,
248248+ Err(why) => {
249249+ error_dialog(&app, &format!("Couldn't create a lobby\n\n{why:?}"));
250250+ return;
251251+ }
252252+ }
253253+ };
254254+ let state_updates = TauriStateUpdateSender::<LobbyStateUpdate>::new(&app);
255255+ let lobby =
256256+ Lobby::new(&room_code, host, profile.clone(), settings, state_updates).await;
257257+ match lobby {
258258+ Ok(lobby) => {
259259+ *self = AppState::Lobby(lobby.clone());
260260+ Self::lobby_loop(app.clone(), lobby);
261261+ Self::emit_screen_change(&app, AppScreen::Lobby);
262262+ }
263263+ Err(why) => {
264264+ error_dialog(
265265+ &app,
266266+ &format!("Couldn't connect you to the lobby\n\n{why:?}"),
267267+ );
268268+ }
269269+ }
270270+ }
271271+ }
272272+273273+ pub async fn quit_to_menu(&mut self, app: AppHandle) {
274274+ let profile = match self {
275275+ AppState::Setup => None,
276276+ AppState::Menu(_) => {
277277+ warn!("Already on menu!");
278278+ return;
279279+ }
280280+ AppState::Lobby(lobby) => {
281281+ lobby.quit_lobby().await;
282282+ read_profile_from_store(&app)
283283+ }
284284+ AppState::Game(game, _) => {
285285+ game.quit_game().await;
286286+ read_profile_from_store(&app)
287287+ }
288288+ AppState::Replay(_) => read_profile_from_store(&app),
289289+ };
290290+ let screen = if let Some(profile) = profile {
291291+ *self = AppState::Menu(profile);
292292+ AppScreen::Menu
293293+ } else {
294294+ *self = AppState::Setup;
295295+ AppScreen::Setup
296296+ };
297297+298298+ Self::emit_screen_change(&app, screen);
299299+ }
300300+}
+1-1
manhunt-logic/src/lib.rs
···1010mod tests;
1111mod transport;
12121313-pub use game::{Game, StateUpdateSender};
1313+pub use game::{Game, StateUpdateSender, UtcDT};
1414pub use game_events::GameEvent;
1515pub use game_state::{GameHistory, GameUiState};
1616pub use lobby::{Lobby, LobbyMessage, LobbyState, StartGameInfo};