From eb2c705342ebcda9e5c8bd9b39819145255d1090 Mon Sep 17 00:00:00 2001
From: konsumlamm <konsumlamm@gmail.com>
Date: Wed, 22 May 2024 15:03:40 +0200
Subject: [PATCH] Implement cooldowns

---
 liberica/src/lib/bindings.ts |  14 +--
 liberica/src/page/Game.tsx   |  10 ++-
 robusta/src/gadgets.rs       |  21 ++++-
 robusta/src/main.rs          | 160 +++++++++++++++++------------------
 robusta/src/ws_message.rs    |   8 +-
 5 files changed, 118 insertions(+), 95 deletions(-)

diff --git a/liberica/src/lib/bindings.ts b/liberica/src/lib/bindings.ts
index d1699a3..95dc3f6 100644
--- a/liberica/src/lib/bindings.ts
+++ b/liberica/src/lib/bindings.ts
@@ -1,7 +1,5 @@
 // This file has been generated by Specta. DO NOT EDIT.
 
-export type CreateTeamError = "InvalidName" | "NameAlreadyExists"
-
 /**
  * Information about a tram station.
  */
@@ -11,19 +9,21 @@ export type ClientResponse = { GameState: GameState } | { MrXGadget: MrXGadget }
 
 export type MrXGadget = { AlternativeFacts: { stop_id: string } } | { Midjourney: { image: number[] } } | "NotFound" | "Teleport" | "Shifter"
 
-export type GameState = { teams: TeamState[]; trains: Train[] }
+export type GameState = { teams: TeamState[]; trains: Train[]; position_cooldown: number | null; detective_gadget_cooldown: number | null; mr_x_gadget_cooldown: number | null }
 
-export type Team = { id: number; name: string; color: string; kind: TeamKind }
+export type CreateTeam = { name: string; color: string; kind: TeamKind }
 
 export type DetectiveGadget = { Stop: { stop_id: string } } | "OutOfOrder" | "Shackles"
 
-export type TeamState = { team: Team; long: number; lat: number; on_train: string | null }
+export type CreateTeamError = "InvalidName" | "NameAlreadyExists"
 
 export type TeamKind = "MrX" | "Detective" | "Observer"
 
-export type CreateTeam = { name: string; color: string; kind: TeamKind }
-
 export type ClientMessage = { Position: { long: number; lat: number } } | { SetTeamPosition: { long: number; lat: number } } | { JoinTeam: { team_id: number } } | { EmbarkTrain: { train_id: string } } | "DisembarkTrain" | { MrXGadget: MrXGadget } | { DetectiveGadget: DetectiveGadget } | { Message: string }
 
+export type TeamState = { team: Team; long: number; lat: number; on_train: string | null }
+
 export type Train = { id: number; long: number; lat: number; line_id: string; line_name: string; direction: string }
 
+export type Team = { id: number; name: string; color: string; kind: TeamKind }
+
diff --git a/liberica/src/page/Game.tsx b/liberica/src/page/Game.tsx
index da173e2..1292036 100644
--- a/liberica/src/page/Game.tsx
+++ b/liberica/src/page/Game.tsx
@@ -10,7 +10,13 @@ import { useTranslation } from "react-i18next";
 
 export function Game() {
     const [ws, setWS] = useState<WebSocketApi>();
-    const [gs, setGameState] = useState<GameState>({ teams: [], trains: [] });
+    const [gs, setGameState] = useState<GameState>({
+        teams: [],
+        trains: [],
+        position_cooldown: null,
+        mr_x_gadget_cooldown: null,
+        detective_gadget_cooldown: null,
+    });
     const [embarkedTrain, setEmbarkedTrain] = useState<Train>();
     const team: Team | undefined = useLocation().state ?? undefined; // this is how Home passes the team
     const { t } = useTranslation();
@@ -29,8 +35,6 @@ export function Game() {
         }
     }
 
-    const [showGadgetMenu, setShowGadgetMenu] = useState(false)
-
     useEffect(() => {
         const socket = createWebSocketConnection();
 
diff --git a/robusta/src/gadgets.rs b/robusta/src/gadgets.rs
index 526f1e7..d2c5304 100644
--- a/robusta/src/gadgets.rs
+++ b/robusta/src/gadgets.rs
@@ -22,6 +22,7 @@ pub enum DetectiveGadget {
 #[derive(Debug)]
 pub struct GadgetState<T> {
     can_be_used: bool,
+    cooldown: Option<f32>,
     used: HashSet<mem::Discriminant<T>>,
 }
 
@@ -29,13 +30,29 @@ impl<T> GadgetState<T> {
     pub fn new() -> Self {
         Self {
             can_be_used: false,
+            cooldown: None,
             used: HashSet::new(),
         }
     }
 
-    pub fn try_use(&mut self, gadget: &T) -> bool {
-        if self.can_be_used && self.used.insert(mem::discriminant(gadget)) {
+    pub fn update_time(&mut self, delta: f32) {
+        if let Some(cooldown) = self.cooldown.as_mut() {
+            *cooldown -= delta;
+            if *cooldown < 0.0 {
+                self.can_be_used = true;
+                self.cooldown = None;
+            }
+        }
+    }
+
+    pub fn remaining(&self) -> Option<f32> {
+        self.cooldown
+    }
+
+    pub fn try_use(&mut self, gadget: &T, cooldown: f32) -> bool {
+        if self.can_be_used && self.cooldown.is_none() && self.used.insert(mem::discriminant(gadget)) {
             self.can_be_used = false;
+            self.cooldown = Some(cooldown);
             true
         } else {
             false
diff --git a/robusta/src/main.rs b/robusta/src/main.rs
index 4df0dfc..f6f09f4 100644
--- a/robusta/src/main.rs
+++ b/robusta/src/main.rs
@@ -18,6 +18,7 @@ use axum::{
 use futures_util::SinkExt;
 use reqwest::StatusCode;
 use tokio::sync::mpsc::{Receiver, Sender};
+use tokio::time::Instant;
 use tower::util::ServiceExt;
 use tower_http::{
     cors::CorsLayer,
@@ -26,7 +27,7 @@ use tower_http::{
 use tracing::{error, info, warn, Level};
 use tracing_appender::rolling::{self, Rotation};
 
-use crate::gadgets::{GadgetState, DetectiveGadget, MrXGadget};
+use crate::gadgets::{DetectiveGadget, GadgetState, MrXGadget};
 use crate::kvv::LineDepartures;
 use crate::unique_id::UniqueIdGen;
 use crate::ws_message::{ClientMessage, ClientResponse, GameState, Team, TeamKind, TeamState};
@@ -42,8 +43,8 @@ const TEAMS_FILE: &str = "teams.json";
 /// The name used for the Mr. X team.
 const MRX: &str = "Mr. X";
 
-/// The interval between position broadcasts and gadgets uses (10 min).
-const COOLDOWN: Duration = Duration::from_secs(600);
+/// The interval between position broadcasts and gadgets uses in seconds (10 min).
+const COOLDOWN: f32 = 600.0;
 
 #[derive(Debug)]
 enum InputMessage {
@@ -78,8 +79,6 @@ struct AppState {
     pub connections: Vec<ClientConnection>,
     pub client_id_gen: UniqueIdGen,
     pub team_id_gen: UniqueIdGen,
-    pub mr_x_gadgets: GadgetState<MrXGadget>,
-    pub detective_gadgets: GadgetState<DetectiveGadget>,
 }
 
 impl AppState {
@@ -90,8 +89,6 @@ impl AppState {
             connections: Vec::new(),
             client_id_gen: UniqueIdGen::new(),
             team_id_gen: UniqueIdGen::new(),
-            mr_x_gadgets: GadgetState::new(),
-            detective_gadgets: GadgetState::new(),
         }
     }
 
@@ -116,7 +113,7 @@ impl AppState {
     }
 }
 
-type SharedState = std::sync::Arc<tokio::sync::Mutex<AppState>>;
+type SharedState = Arc<tokio::sync::Mutex<AppState>>;
 
 async fn handler(ws: WebSocketUpgrade, State(state): State<SharedState>) -> Response {
     let (send, rec) = tokio::sync::mpsc::channel(100);
@@ -368,7 +365,7 @@ fn load_state(send: Sender<InputMessage>) -> SharedState {
     }
     state.teams = teams;
 
-    std::sync::Arc::new(tokio::sync::Mutex::new(state))
+    Arc::new(tokio::sync::Mutex::new(state))
 }
 
 enum SpecialPos {
@@ -377,7 +374,7 @@ enum SpecialPos {
     NotFound,
 }
 
-async fn run_game_loop(mut recv: Receiver<InputMessage>, shared_state: SharedState) {
+async fn run_game_loop(mut recv: Receiver<InputMessage>, state: SharedState) {
     let mut departures = HashMap::new();
     let mut log_file = rolling::Builder::new()
         .rotation(Rotation::DAILY)
@@ -387,13 +384,46 @@ async fn run_game_loop(mut recv: Receiver<InputMessage>, shared_state: SharedSta
         .build("logs")
         .expect("failed to initialize rolling file appender");
 
-    let send_pos = start_game(Arc::clone(&shared_state)).await;
+    let mut special_pos = None;
 
-    let mut interval = tokio::time::interval(Duration::from_millis(500));
+    let mut interval = tokio::time::interval(Duration::from_millis(100));
+
+    let mut time = Instant::now();
+    let mut position_cooldown = None;
+    let mut mr_x_gadgets = GadgetState::new();
+    let mut detective_gadgets = GadgetState::new();
     loop {
         interval.tick().await;
 
-        let mut state = shared_state.lock().await;
+        let old_time = time;
+        time = Instant::now();
+        let delta = (time - old_time).as_secs_f32();
+        if let Some(cooldown) = position_cooldown.as_mut() {
+            *cooldown -= delta;
+            if *cooldown < 0.0 {
+                position_cooldown = Some(COOLDOWN);
+                // broadcast Mr. X position
+                match special_pos {
+                    Some(SpecialPos::Stop(stop_id)) => {
+                        // TODO: broadcast stop id
+                    }
+                    Some(SpecialPos::Image(image)) => {
+                        // TODO: broadcast image
+                    }
+                    Some(SpecialPos::NotFound) => {
+                        // TODO: broadcast 404
+                    }
+                    None => {
+                        // TODO: broadcast Mr. X position
+                    }
+                }
+                special_pos = None;
+            }
+        }
+        mr_x_gadgets.update_time(delta);
+        detective_gadgets.update_time(delta);
+
+        let mut state = state.lock().await;
         while let Ok(msg) = recv.try_recv() {
             match msg {
                 InputMessage::Client(msg, id) => {
@@ -434,45 +464,41 @@ async fn run_game_loop(mut recv: Receiver<InputMessage>, shared_state: SharedSta
                         ClientMessage::MrXGadget(gadget) => {
                             use MrXGadget::*;
 
-                            if state.team_by_client_id(id).map(|ts| ts.team.kind != TeamKind::MrX).unwrap_or(true) {
+                            if state
+                                .team_by_client_id(id)
+                                .map(|ts| ts.team.kind != TeamKind::MrX)
+                                .unwrap_or(true)
+                            {
                                 warn!("Client {} tried to use MrX Gadget, but is not MrX", id);
                                 continue;
                             }
-                            if state.mr_x_gadgets.try_use(&gadget) {
-                                let state = Arc::clone(&shared_state);
-                                tokio::spawn(async move {
-                                    tokio::time::sleep(COOLDOWN).await;
-                                    state.lock().await.mr_x_gadgets.allow_use();
-                                });
-                            } else {
+                            if !mr_x_gadgets.try_use(&gadget, COOLDOWN) {
                                 warn!("Client {} tried to use MrX Gadget, but is not allowed to", id);
                                 continue;
                             }
 
                             match &gadget {
                                 AlternativeFacts { stop_id } => {
-                                    if send_pos.send(SpecialPos::Stop(stop_id.clone())).await.is_err() {
-                                        error!("special position channel closed");
-                                    }
+                                    special_pos = Some(SpecialPos::Stop(stop_id.clone()));
                                     continue;
                                 }
                                 Midjourney { image } => {
-                                    if send_pos.send(SpecialPos::Image(image.clone())).await.is_err() {
-                                        error!("special position channel closed");
-                                    }
-                                    continue;
+                                    special_pos = Some(SpecialPos::Image(image.clone()));
                                 }
                                 NotFound => {
-                                    if send_pos.send(SpecialPos::NotFound).await.is_err() {
-                                        error!("special position channel closed");
-                                    }
+                                    special_pos = Some(SpecialPos::NotFound);
                                     continue;
                                 }
                                 Teleport => {}
                                 Shifter => {}
                             }
                             for connection in state.connections.iter_mut() {
-                                if connection.send.send(ClientResponse::MrXGadget(gadget.clone())).await.is_err() {
+                                if connection
+                                    .send
+                                    .send(ClientResponse::MrXGadget(gadget.clone()))
+                                    .await
+                                    .is_err()
+                                {
                                     continue;
                                 }
                             }
@@ -480,17 +506,15 @@ async fn run_game_loop(mut recv: Receiver<InputMessage>, shared_state: SharedSta
                         ClientMessage::DetectiveGadget(gadget) => {
                             use DetectiveGadget::*;
 
-                            if state.team_by_client_id(id).map(|ts| ts.team.kind != TeamKind::Detective).unwrap_or(true) {
+                            if state
+                                .team_by_client_id(id)
+                                .map(|ts| ts.team.kind != TeamKind::Detective)
+                                .unwrap_or(true)
+                            {
                                 warn!("Client {} tried to use Detective Gadget, but is not Detective", id);
                                 continue;
                             }
-                            if state.detective_gadgets.try_use(&gadget) {
-                                let state = Arc::clone(&shared_state);
-                                tokio::spawn(async move {
-                                    tokio::time::sleep(COOLDOWN).await;
-                                    state.lock().await.detective_gadgets.allow_use();
-                                });
-                            } else {
+                            if !detective_gadgets.try_use(&gadget, COOLDOWN) {
                                 warn!("Client {} tried to use Detective Gadget, but is not allowed to", id);
                                 continue;
                             }
@@ -498,14 +522,19 @@ async fn run_game_loop(mut recv: Receiver<InputMessage>, shared_state: SharedSta
                             match &gadget {
                                 Stop { stop_id } => {
                                     // TODO: mark stop as blocked for the next 20 mins
-                                },
+                                }
                                 OutOfOrder => {
                                     // TODO: immediately broadcast Mr. X position
-                                },
+                                }
                                 Shackles => {}
                             }
                             for connection in state.connections.iter_mut() {
-                                if connection.send.send(ClientResponse::DetectiveGadget(gadget.clone())).await.is_err() {
+                                if connection
+                                    .send
+                                    .send(ClientResponse::DetectiveGadget(gadget.clone()))
+                                    .await
+                                    .is_err()
+                                {
                                     continue;
                                 }
                             }
@@ -539,6 +568,9 @@ async fn run_game_loop(mut recv: Receiver<InputMessage>, shared_state: SharedSta
         let game_state = GameState {
             teams: state.teams.clone(),
             trains,
+            position_cooldown,
+            mr_x_gadget_cooldown: mr_x_gadgets.remaining(),
+            detective_gadget_cooldown: detective_gadgets.remaining(),
         };
         writeln!(
             log_file,
@@ -558,6 +590,9 @@ async fn run_game_loop(mut recv: Receiver<InputMessage>, shared_state: SharedSta
                     .cloned()
                     .collect(),
                 trains: game_state.trains.clone(),
+                position_cooldown: game_state.position_cooldown,
+                mr_x_gadget_cooldown: game_state.mr_x_gadget_cooldown,
+                detective_gadget_cooldown: game_state.detective_gadget_cooldown,
             };
             if let Err(err) = connection
                 .send
@@ -570,42 +605,3 @@ async fn run_game_loop(mut recv: Receiver<InputMessage>, shared_state: SharedSta
         }
     }
 }
-
-async fn start_game(state: SharedState) -> Sender<SpecialPos> {
-    let (send_pos, mut recv_pos) = tokio::sync::mpsc::channel(1);
-
-    tokio::spawn(async move {
-        let mut interval = tokio::time::interval(COOLDOWN);
-        interval.tick().await;
-        // TODO: broadcast Mr. X start
-        interval.tick().await;
-        {
-            let mut state = state.lock().await;
-            state.mr_x_gadgets.allow_use();
-            state.detective_gadgets.allow_use();
-        }
-        // TODO: broadcast Detective start
-        loop {
-            use SpecialPos::*;
-
-            interval.tick().await;
-            // broadcast Mr. X position
-            match recv_pos.try_recv() {
-                Ok(Stop(stop_id)) => {
-                    // TODO: broadcast stop id
-                }
-                Ok(Image(image)) => {
-                    // TODO: broadcast image
-                }
-                Ok(NotFound) => {
-                    // TODO: broadcast 404
-                }
-                Err(_) => {
-                    // TODO: broadcast Mr. X position
-                }
-            }
-        }
-    });
-
-    send_pos
-}
diff --git a/robusta/src/ws_message.rs b/robusta/src/ws_message.rs
index fda9bd3..2f0b446 100644
--- a/robusta/src/ws_message.rs
+++ b/robusta/src/ws_message.rs
@@ -1,6 +1,6 @@
 use serde::{Deserialize, Serialize};
 
-use crate::gadgets::{MrXGadget, DetectiveGadget};
+use crate::gadgets::{DetectiveGadget, MrXGadget};
 
 #[derive(specta::Type, Clone, Deserialize, Debug)]
 pub enum ClientMessage {
@@ -25,6 +25,12 @@ pub enum ClientResponse {
 pub struct GameState {
     pub teams: Vec<TeamState>,
     pub trains: Vec<Train>,
+    // in seconds
+    pub position_cooldown: Option<f32>,
+    // in seconds
+    pub detective_gadget_cooldown: Option<f32>,
+    // in seconds
+    pub mr_x_gadget_cooldown: Option<f32>,
 }
 
 #[derive(specta::Type, Default, Clone, Serialize, Deserialize, Debug)]