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)]