diff --git a/liberica/src/lib/bindings.ts b/liberica/src/lib/bindings.ts index 6b8a150..2d0e7c2 100644 --- a/liberica/src/lib/bindings.ts +++ b/liberica/src/lib/bindings.ts @@ -5,21 +5,27 @@ */ export type Stop = { name: string; id: string; lat: number; lon: number } -export type TeamKind = "MrX" | "Detective" | "Observer" +export type ClientResponse = { GameState: GameState } | { MrXGadget: MrXGadget } | { DetectiveGadget: DetectiveGadget } | { MrXPosition: MrXPosition } | { GameStart: null } | { DetectiveStart: null } | { GameEnd: null } -export type Team = { id: number; name: string; color: string; kind: TeamKind } +export type MrXGadget = { AlternativeFacts: { stop_id: string } } | { Midjourney: { image: number[] } } | "NotFound" | "Teleport" | "Shifter" -export type ClientMessage = { Position: { long: number; lat: number } } | { SetTeamPosition: { long: number; lat: number } } | { JoinTeam: { team_id: number } } | { EmbarkTrain: { train_id: string } } | "DisembarkTrain" | { Message: string } +export type MrXPosition = { Stop: string } | { Image: number[] } | "NotFound" -export type CreateTeamError = "InvalidName" | "NameAlreadyExists" +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 DetectiveGadget = { Stop: { stop_id: string } } | "OutOfOrder" | "Shackles" -export type TeamState = { team: Team; long: number; lat: number; on_train: string | null } +export type TeamKind = "MrX" | "Detective" | "Observer" export type CreateTeam = { name: string; color: string; kind: TeamKind } -export type ClientResponse = { GameState: GameState } +export type Train = { id: number; long: number; lat: number; line_id: string; line_name: string; direction: string } + +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 Team = { id: number; name: string; color: string; kind: TeamKind } + +export type GameState = { teams: TeamState[]; trains: Train[]; position_cooldown?: number | null; detective_gadget_cooldown?: number | null; mr_x_gadget_cooldown?: number | null; blocked_stop?: string | null } -export type GameState = { teams: TeamState[]; trains: Train[] } +export type CreateTeamError = "InvalidName" | "NameAlreadyExists" diff --git a/liberica/src/page/Game.tsx b/liberica/src/page/Game.tsx index 31e157a..996ad66 100644 --- a/liberica/src/page/Game.tsx +++ b/liberica/src/page/Game.tsx @@ -10,7 +10,10 @@ import { useTranslation } from "react-i18next"; export function Game() { const [ws, setWS] = useState(); - const [gs, setGameState] = useState({ teams: [], trains: [] }); + const [gs, setGameState] = useState({ + teams: [], + trains: [], + }); const [embarkedTrain, setEmbarkedTrain] = useState(); const team = useLocation().state as Team | undefined; // this is how Home passes the team const { t } = useTranslation(); diff --git a/robusta/src/gadgets.rs b/robusta/src/gadgets.rs new file mode 100644 index 0000000..e86f65f --- /dev/null +++ b/robusta/src/gadgets.rs @@ -0,0 +1,63 @@ +use std::collections::HashSet; +use std::mem; + +use serde::{Deserialize, Serialize}; + +#[derive(specta::Type, Clone, Serialize, Deserialize, Debug)] +pub enum MrXGadget { + AlternativeFacts { stop_id: String }, + Midjourney { image: Vec }, + NotFound, + Teleport, + Shifter, +} + +#[derive(specta::Type, Clone, Serialize, Deserialize, Debug)] +pub enum DetectiveGadget { + Stop { stop_id: String }, + OutOfOrder, + Shackles, +} + +#[derive(Debug)] +pub struct GadgetState { + can_be_used: bool, + cooldown: Option, + used: HashSet>, +} + +impl GadgetState { + pub fn new() -> Self { + Self { + can_be_used: false, + cooldown: None, + used: HashSet::new(), + } + } + + pub fn update_time(&mut self, delta: f32) { + if let Some(cooldown) = self.cooldown.as_mut() { + *cooldown -= delta; + if *cooldown < 0.0 { + self.cooldown = None; + } + } + } + + pub fn remaining(&self) -> Option { + 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.cooldown = Some(cooldown); + true + } else { + false + } + } + + pub fn allow_use(&mut self) { + self.can_be_used = true; + } +} diff --git a/robusta/src/kvv.rs b/robusta/src/kvv.rs index cefe0ef..2460aaf 100644 --- a/robusta/src/kvv.rs +++ b/robusta/src/kvv.rs @@ -1,7 +1,7 @@ use chrono::{DateTime, Utc}; use futures_util::future::join_all; use lazy_static::lazy_static; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::OnceLock; @@ -16,7 +16,7 @@ use crate::ws_message::Train; const DEFAULT_WAIT_TIME: Duration = Duration::from_secs(30); /// Information about a tram station. -#[derive(Debug, Serialize, specta::Type, PartialEq)] +#[derive(Debug, Serialize, Deserialize, specta::Type, Clone, PartialEq)] pub struct Stop { /// human readable stop name pub name: String, diff --git a/robusta/src/main.rs b/robusta/src/main.rs index c514e4a..a392a22 100644 --- a/robusta/src/main.rs +++ b/robusta/src/main.rs @@ -16,8 +16,10 @@ use axum::{ Json, Router, }; use futures_util::SinkExt; +use point::Point; use reqwest::StatusCode; use tokio::sync::mpsc::{Receiver, Sender}; +use tokio::time::Instant; use tower::util::ServiceExt; use tower_http::{ cors::CorsLayer, @@ -26,10 +28,12 @@ use tower_http::{ use tracing::{error, info, warn, Level}; use tracing_appender::rolling::{self, Rotation}; +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}; +use crate::ws_message::{ClientMessage, ClientResponse, GameState, MrXPosition, Team, TeamKind, TeamState}; +mod gadgets; mod kvv; mod point; mod unique_id; @@ -40,6 +44,9 @@ 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 in seconds (10 min). +const COOLDOWN: f32 = 600.0; + #[derive(Debug)] enum InputMessage { Client(ClientMessage, u32), @@ -76,7 +83,7 @@ struct AppState { } impl AppState { - const fn new(game_logic_sender: Sender) -> Self { + fn new(game_logic_sender: Sender) -> Self { Self { teams: Vec::new(), game_logic_sender, @@ -94,6 +101,12 @@ impl AppState { self.connections.iter_mut().find(|x| x.id == id) } + fn team_by_client_id(&mut self, id: u32) -> Option<&TeamState> { + self.client(id) + .map(|x| x.team_id) + .and_then(|team_id| self.teams.iter().find(|ts| ts.team.id == team_id)) + } + fn team_mut_by_client_id(&mut self, id: u32) -> Option<&mut TeamState> { self.client(id) .map(|x| x.team_id) @@ -336,7 +349,7 @@ fn load_state(send: Sender) -> SharedState { .and_then(|x| serde_json::from_str::>(&x).ok()) .unwrap_or_default(); - let mut state = AppState::new(send.clone()); + let mut state = AppState::new(send); let max_id = teams.iter().map(|ts| ts.team.id).max().unwrap_or(0); state.team_id_gen.set_min(max_id + 1); if !teams.iter().any(|ts| ts.team.kind == TeamKind::MrX) { @@ -356,6 +369,12 @@ fn load_state(send: Sender) -> SharedState { Arc::new(tokio::sync::Mutex::new(state)) } +enum SpecialPos { + Stop(String), + Image(Vec), + NotFound, +} + async fn run_game_loop(mut recv: Receiver, state: SharedState) { let mut departures = HashMap::new(); let mut log_file = rolling::Builder::new() @@ -369,6 +388,11 @@ async fn run_game_loop(mut recv: Receiver, state: SharedState) { // the time for a single frame let mut interval = tokio::time::interval(Duration::from_millis(500)); + let mut running_state = RunningState::new(); + start_game(&state.lock().await.connections, &mut running_state).await; + let running_state = Arc::new(tokio::sync::Mutex::new(running_state)); + run_timer_loop(Arc::clone(&state), Arc::clone(&running_state)).await; + loop { interval.tick().await; @@ -411,6 +435,87 @@ async fn run_game_loop(mut recv: Receiver, state: SharedState) { team.on_train = None; } } + ClientMessage::MrXGadget(gadget) => { + use MrXGadget::*; + + 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; + } + let mut running_state = running_state.lock().await; + if !running_state.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 } => { + running_state.special_pos = Some(SpecialPos::Stop(stop_id.clone())); + continue; + } + Midjourney { image } => { + running_state.special_pos = Some(SpecialPos::Image(image.clone())); + } + NotFound => { + running_state.special_pos = Some(SpecialPos::NotFound); + continue; + } + Teleport => {} + Shifter => {} + } + broadcast(&state.connections, ClientResponse::MrXGadget(gadget)).await; + } + ClientMessage::DetectiveGadget(gadget) => { + use DetectiveGadget::*; + + 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; + } + let running_state_arc = &running_state; + let mut running_state = running_state.lock().await; + if !running_state.detective_gadgets.try_use(&gadget, COOLDOWN) { + warn!("Client {} tried to use Detective Gadget, but is not allowed to", id); + continue; + } + + broadcast(&state.connections, ClientResponse::DetectiveGadget(gadget.clone())).await; + match gadget { + Stop { stop_id } => { + running_state.blocked_stop = Some(stop_id); + let running_state = Arc::clone(&running_state_arc); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs_f32(2.0 * COOLDOWN)).await; + running_state.lock().await.blocked_stop = None; + }); + } + OutOfOrder => { + let mr_x = state + .teams + .iter() + .find(|ts| ts.team.kind == TeamKind::MrX) + .expect("no Mr. X"); + let stop = kvv::nearest_stop(Point { + latitude: mr_x.lat, + longitude: mr_x.long, + }); + broadcast( + &state.connections, + ClientResponse::MrXPosition(MrXPosition::Stop(stop.id.clone())), + ) + .await; + } + Shackles => {} + } + } } } InputMessage::Server(ServerMessage::Departures(deps)) => { @@ -439,9 +544,16 @@ async fn run_game_loop(mut recv: Receiver, state: SharedState) { } // log game state - let game_state = GameState { - teams: state.teams.clone(), - trains, + let game_state = { + let running_state = running_state.lock().await; + GameState { + teams: state.teams.clone(), + trains, + position_cooldown: running_state.position_cooldown, + mr_x_gadget_cooldown: running_state.mr_x_gadgets.remaining(), + detective_gadget_cooldown: running_state.detective_gadgets.remaining(), + blocked_stop: running_state.blocked_stop.clone(), + } }; writeln!( log_file, @@ -462,15 +574,114 @@ async fn run_game_loop(mut recv: Receiver, state: SharedState) { .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, + blocked_stop: game_state.blocked_stop.clone(), }; - if let Err(err) = connection - .send - .send(ClientResponse::GameState(game_state.clone())) - .await - { + if let Err(err) = connection.send.send(ClientResponse::GameState(game_state)).await { error!("failed to send game state to client {}: {}", connection.id, err); continue; } } } } + +async fn broadcast(connections: &[ClientConnection], message: ClientResponse) { + for connection in connections.iter() { + if let Err(err) = connection.send.send(message.clone()).await { + let message_type = match message { + ClientResponse::GameState(_) => "game state", + ClientResponse::MrXPosition(_) => "Mr. X position", + ClientResponse::MrXGadget(_) => "Mr. X gadget", + ClientResponse::DetectiveGadget(_) => "Detective gadget", + ClientResponse::GameStart() => "game start", + ClientResponse::DetectiveStart() => "detective start", + ClientResponse::GameEnd() => "game end", + }; + error!("failed to send {} to client {}: {}", message_type, connection.id, err); + } + } +} + +struct RunningState { + position_cooldown: Option, + mr_x_gadgets: GadgetState, + detective_gadgets: GadgetState, + special_pos: Option, + blocked_stop: Option, +} + +impl RunningState { + fn new() -> Self { + Self { + position_cooldown: None, + mr_x_gadgets: GadgetState::new(), + detective_gadgets: GadgetState::new(), + special_pos: None, + blocked_stop: None, + } + } +} + +async fn start_game(connections: &[ClientConnection], running_state: &mut RunningState) { + broadcast(connections, ClientResponse::GameStart()).await; + running_state.position_cooldown = Some(COOLDOWN); +} + +async fn end_game(connections: &[ClientConnection], running_state: &mut RunningState) { + broadcast(connections, ClientResponse::GameEnd()).await; + *running_state = RunningState::new(); +} + +async fn run_timer_loop(state: SharedState, running_state: Arc>) { + tokio::spawn(async move { + let mut warmup = true; + + let mut time = Instant::now(); + let mut interval = tokio::time::interval(Duration::from_millis(100)); + loop { + interval.tick().await; + let mut running_state = running_state.lock().await; + + let old_time = time; + time = Instant::now(); + let delta = (time - old_time).as_secs_f32(); + if let Some(cooldown) = running_state.position_cooldown.as_mut() { + *cooldown -= delta; + if *cooldown < 0.0 { + let state = state.lock().await; + running_state.position_cooldown = Some(COOLDOWN); + if warmup { + broadcast(&state.connections, ClientResponse::DetectiveStart()).await; + warmup = false; + running_state.mr_x_gadgets.allow_use(); + running_state.detective_gadgets.allow_use(); + } else { + // broadcast Mr. X position + let position = match running_state.special_pos.take() { + Some(SpecialPos::Stop(stop_id)) => MrXPosition::Stop(stop_id), + Some(SpecialPos::Image(image)) => MrXPosition::Image(image), + Some(SpecialPos::NotFound) => MrXPosition::NotFound, + None => { + let mr_x = state + .teams + .iter() + .find(|ts| ts.team.kind == TeamKind::MrX) + .expect("no Mr. X"); + let stop = kvv::nearest_stop(Point { + latitude: mr_x.lat, + longitude: mr_x.long, + }); + MrXPosition::Stop(stop.id.clone()) + } + }; + broadcast(&state.connections, ClientResponse::MrXPosition(position)).await; + } + } + } + running_state.mr_x_gadgets.update_time(delta); + running_state.detective_gadgets.update_time(delta); + } + }); +} diff --git a/robusta/src/ws_message.rs b/robusta/src/ws_message.rs index a3e4d70..a91a79d 100644 --- a/robusta/src/ws_message.rs +++ b/robusta/src/ws_message.rs @@ -1,5 +1,7 @@ use serde::{Deserialize, Serialize}; +use crate::gadgets::{DetectiveGadget, MrXGadget}; + #[derive(specta::Type, Clone, Deserialize, Debug)] pub enum ClientMessage { Position { long: f32, lat: f32 }, @@ -7,18 +9,44 @@ pub enum ClientMessage { JoinTeam { team_id: u32 }, EmbarkTrain { train_id: String }, DisembarkTrain, + MrXGadget(MrXGadget), + DetectiveGadget(DetectiveGadget), Message(String), } #[derive(specta::Type, Clone, Serialize, Deserialize, Debug)] pub enum ClientResponse { GameState(GameState), + MrXGadget(MrXGadget), + DetectiveGadget(DetectiveGadget), + MrXPosition(MrXPosition), + GameStart(), + DetectiveStart(), + GameEnd(), +} + +#[derive(specta::Type, Clone, Serialize, Deserialize, Debug)] +pub enum MrXPosition { + Stop(String), + Image(Vec), + NotFound, } #[derive(specta::Type, Default, Clone, Serialize, Deserialize, Debug)] pub struct GameState { pub teams: Vec, pub trains: Vec, + // in seconds + #[specta(optional)] + pub position_cooldown: Option, + // in seconds + #[specta(optional)] + pub detective_gadget_cooldown: Option, + // in seconds + #[specta(optional)] + pub mr_x_gadget_cooldown: Option, + #[specta(optional)] + pub blocked_stop: Option, } #[derive(specta::Type, Default, Clone, Serialize, Deserialize, Debug)]