diff --git a/Cargo.lock b/Cargo.lock index 8042e7b..7c939f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -970,6 +970,46 @@ dependencies = [ "inout", ] +[[package]] +name = "clap" +version = "4.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "clap_lex" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" + [[package]] name = "colog" version = "1.3.0" @@ -1442,6 +1482,12 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "doctest-file" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac81fa3e28d21450aa4d2ac065992ba96a1d7303efbce51a95f4fd175b67562" + [[package]] name = "dpi" version = "0.1.2" @@ -2637,6 +2683,21 @@ dependencies = [ "webrtc-util", ] +[[package]] +name = "interprocess" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d941b405bd2322993887859a8ee6ac9134945a24ec5ec763a8a962fc64dfec2d" +dependencies = [ + "doctest-file", + "futures-core", + "libc", + "recvmsg", + "tokio", + "widestring", + "windows-sys 0.52.0", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2987,6 +3048,20 @@ dependencies = [ "uuid", ] +[[package]] +name = "manhunt-testing" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "interprocess", + "manhunt-logic", + "manhunt-transport", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "manhunt-transport" version = "0.1.0" @@ -4340,6 +4415,12 @@ dependencies = [ "yasna", ] +[[package]] +name = "recvmsg" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175" + [[package]] name = "redox_syscall" version = "0.5.13" @@ -6938,6 +7019,12 @@ dependencies = [ "windows-core", ] +[[package]] +name = "widestring" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d" + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 6d2cee8..1bbcec4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["manhunt-app", "manhunt-logic", "manhunt-signaling", "manhunt-transport"] +members = ["manhunt-app", "manhunt-logic", "manhunt-signaling", "manhunt-testing", "manhunt-transport"] resolver = "3" [profile.release] diff --git a/manhunt-logic/src/game.rs b/manhunt-logic/src/game.rs index 2d32116..3abb9ec 100644 --- a/manhunt-logic/src/game.rs +++ b/manhunt-logic/src/game.rs @@ -4,7 +4,7 @@ use std::{sync::Arc, time::Duration}; use tokio_util::sync::CancellationToken; use uuid::Uuid; -use tokio::sync::RwLock; +use tokio::sync::{RwLock, RwLockWriteGuard}; use crate::StartGameInfo; use crate::{prelude::*, transport::TransportMessage}; @@ -313,6 +313,10 @@ impl Game { res } + + pub async fn lock_state(&self) -> RwLockWriteGuard<'_, GameState> { + self.state.write().await + } } #[cfg(test)] diff --git a/manhunt-logic/src/game_state.rs b/manhunt-logic/src/game_state.rs index 3ce97ab..50f3d27 100644 --- a/manhunt-logic/src/game_state.rs +++ b/manhunt-logic/src/game_state.rs @@ -336,9 +336,8 @@ impl GameState { self.held_powerup = choice; } - #[cfg(test)] - pub fn force_set_powerup(&mut self, typ: PowerUpType) { - self.held_powerup = Some(typ); + pub fn force_set_powerup(&mut self, powerup_type: PowerUpType) { + self.held_powerup = Some(powerup_type); } pub fn peek_powerup(&self) -> Option<&PowerUpType> { diff --git a/manhunt-logic/src/lib.rs b/manhunt-logic/src/lib.rs index 965d7fb..b112c00 100644 --- a/manhunt-logic/src/lib.rs +++ b/manhunt-logic/src/lib.rs @@ -15,6 +15,7 @@ pub use game_events::GameEvent; pub use game_state::{GameHistory, GameUiState}; pub use lobby::{Lobby, LobbyMessage, LobbyState, StartGameInfo}; pub use location::{Location, LocationService}; +pub use powerups::PowerUpType; pub use profile::PlayerProfile; pub use settings::GameSettings; pub use transport::{MsgPair, Transport, TransportMessage}; diff --git a/manhunt-testing/Cargo.toml b/manhunt-testing/Cargo.toml new file mode 100644 index 0000000..b4d93f0 --- /dev/null +++ b/manhunt-testing/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "manhunt-testing" +version = "0.1.0" +edition = "2024" + +[lib] +name = "manhunt_test_shared" +path = "src/lib.rs" + +[[bin]] +name = "manhunt-test-daemon" +path = "src/daemon.rs" + +[[bin]] +name = "manhunt-test-driver" +path = "src/driver.rs" + +[dependencies] +anyhow = "1.0.98" +clap = { version = "4.5.40", features = ["derive"] } +interprocess = { version = "2.2.3", features = ["tokio"] } +manhunt-logic = { version = "0.1.0", path = "../manhunt-logic" } +manhunt-transport = { version = "0.1.0", path = "../manhunt-transport" } +serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.140" +tokio = { version = "1.45.1", features = ["macros", "sync", "time", "rt", "test-util", "signal"] } diff --git a/manhunt-testing/src/daemon.rs b/manhunt-testing/src/daemon.rs new file mode 100644 index 0000000..8485ef4 --- /dev/null +++ b/manhunt-testing/src/daemon.rs @@ -0,0 +1,329 @@ +#![allow(clippy::result_large_err)] + +use manhunt_logic::{ + Game as BaseGame, GameSettings, Lobby as BaseLobby, Location, LocationService, PlayerProfile, + StartGameInfo, StateUpdateSender, +}; +use manhunt_test_shared::*; +use manhunt_transport::{MatchboxTransport, request_room_code}; +use std::{sync::Arc, time::Duration}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + sync::{Mutex, mpsc}, +}; + +struct DummyLocationService; + +impl LocationService for DummyLocationService { + fn get_loc(&self) -> Option { + Some(Location { + lat: 0.0, + long: 0.0, + heading: None, + }) + } +} + +struct UpdateSender(mpsc::Sender<()>); + +impl StateUpdateSender for UpdateSender { + fn send_update(&self) { + let tx = self.0.clone(); + tokio::spawn(async move { + tx.send(()).await.expect("Failed to send"); + }); + } +} + +type Game = BaseGame; +type Lobby = BaseLobby; + +#[derive(Default)] +enum DaemonScreen { + #[default] + PreConnect, + Lobby(Arc), + Game(Arc), +} + +impl DaemonScreen { + pub fn as_update(&self) -> ScreenUpdate { + match self { + Self::PreConnect => ScreenUpdate::PreConnect, + Self::Game(_) => ScreenUpdate::Game, + Self::Lobby(_) => ScreenUpdate::Lobby, + } + } +} + +type StateHandle = Arc>; + +struct DaemonState { + screen: DaemonScreen, + profile: PlayerProfile, + responses: mpsc::Sender, + updates: (mpsc::Sender<()>, Mutex>), +} + +impl DaemonState { + pub fn new(name: impl Into, responses: mpsc::Sender) -> Self { + tokio::time::pause(); + let screen = DaemonScreen::default(); + let (tx, rx) = mpsc::channel(2); + Self { + screen, + responses, + profile: PlayerProfile { + display_name: name.into(), + pfp_base64: None, + }, + updates: (tx, Mutex::new(rx)), + } + } + + async fn change_screen(&mut self, new_screen: DaemonScreen) { + let update = new_screen.as_update(); + self.screen = new_screen; + self.push_resp(update).await; + } + + async fn lobby_loop(&self, handle: StateHandle) { + if let DaemonScreen::Lobby(lobby) = &self.screen { + let lobby = lobby.clone(); + tokio::spawn(async move { + let res = lobby.main_loop().await; + let handle2 = handle.clone(); + let mut state = handle.lock().await; + match res { + Ok(Some(start)) => { + state.start_game(handle2, start).await; + } + Ok(None) => { + state.change_screen(DaemonScreen::PreConnect).await; + } + Err(why) => { + state.push_resp(why).await; + state.change_screen(DaemonScreen::PreConnect).await; + } + } + }); + } + } + + async fn game_loop(&self, handle: StateHandle) { + if let DaemonScreen::Game(game) = &self.screen { + let game = game.clone(); + tokio::spawn(async move { + let res = game.main_loop().await; + let mut state = handle.lock().await; + match res { + Ok(Some(history)) => { + state.push_resp(history).await; + } + Ok(None) => {} + Err(why) => { + state.push_resp(why).await; + } + } + state.change_screen(DaemonScreen::PreConnect).await; + }); + } + } + + async fn push_resp(&self, resp: impl Into) { + self.responses + .send(resp.into()) + .await + .expect("Failed to push response"); + } + + fn sender(&self) -> UpdateSender { + UpdateSender(self.updates.0.clone()) + } + + const INTERVAL: Duration = Duration::from_secs(1); + + async fn start_game(&mut self, handle: StateHandle, start: StartGameInfo) { + if let DaemonScreen::Lobby(lobby) = &self.screen { + let transport = lobby.clone_transport(); + let updates = self.sender(); + let location = DummyLocationService; + + let game = Game::new(Self::INTERVAL, start, transport, location, updates); + + self.change_screen(DaemonScreen::Game(Arc::new(game))).await; + self.game_loop(handle).await; + } + } + + pub async fn create_lobby(&mut self, handle: StateHandle, settings: GameSettings) -> Result { + let sender = self.sender(); + + let code = request_room_code() + .await + .context("Failed to get room code")?; + + let lobby = Lobby::new(&code, true, self.profile.clone(), settings, sender) + .await + .context("Failed to start lobby")?; + + self.change_screen(DaemonScreen::Lobby(lobby)).await; + self.lobby_loop(handle).await; + + Ok(()) + } + + pub async fn join_lobby(&mut self, handle: StateHandle, code: &str) -> Result { + let sender = self.sender(); + // TODO: Lobby should not require this on join, use an [Option]? + let settings = GameSettings::default(); + + let lobby = Lobby::new(code, false, self.profile.clone(), settings, sender) + .await + .context("Failed to join lobby")?; + + self.change_screen(DaemonScreen::Lobby(lobby)).await; + self.lobby_loop(handle).await; + + Ok(()) + } + + fn assert_screen(&self, expected: ScreenUpdate) -> Result<(), TestingResponse> { + if self.screen.as_update() == expected { + Ok(()) + } else { + Err(TestingResponse::WrongScreen) + } + } + + async fn process_lobby_req(&mut self, req: LobbyRequest) { + if let DaemonScreen::Lobby(lobby) = &self.screen { + let lobby = lobby.clone(); + match req { + LobbyRequest::SwitchTeams(seeker) => lobby.switch_teams(seeker).await, + LobbyRequest::HostStartGame => lobby.start_game().await, + LobbyRequest::HostUpdateSettings(game_settings) => { + lobby.update_settings(game_settings).await + } + LobbyRequest::Leave => lobby.quit_lobby().await, + } + } + } + + async fn process_game_req(&mut self, req: GameRequest) { + if let DaemonScreen::Game(game) = &self.screen { + let game = game.clone(); + match req { + GameRequest::NextTick => tokio::time::sleep(Self::INTERVAL).await, + GameRequest::MarkCaught => game.mark_caught().await, + GameRequest::GetPowerup => game.get_powerup().await, + GameRequest::UsePowerup => game.use_powerup().await, + GameRequest::ForcePowerup(power_up_type) => { + let mut state = game.lock_state().await; + state.force_set_powerup(power_up_type); + } + GameRequest::Quit => game.quit_game().await, + } + } + } + + pub async fn process_req( + &mut self, + handle: StateHandle, + req: TestingRequest, + ) -> Result<(), TestingResponse> { + match req { + TestingRequest::StartLobby(game_settings) => { + self.assert_screen(ScreenUpdate::PreConnect)?; + self.create_lobby(handle, game_settings).await?; + } + TestingRequest::JoinLobby(code) => { + self.assert_screen(ScreenUpdate::PreConnect)?; + self.join_lobby(handle, &code).await?; + } + TestingRequest::LobbyReq(lobby_request) => { + self.assert_screen(ScreenUpdate::Lobby)?; + self.process_lobby_req(lobby_request).await; + } + TestingRequest::GameReq(game_request) => { + self.assert_screen(ScreenUpdate::Game)?; + self.process_game_req(game_request).await; + } + } + Ok(()) + } +} + +use interprocess::local_socket::{ListenerOptions, tokio::prelude::*}; + +const CLI_MSG: &str = "Usage: manhunt-test-daemon SOCKET_NAME PLAYER_NAME"; + +#[tokio::main(flavor = "current_thread")] +pub async fn main() -> Result { + let args = std::env::args().collect::>(); + let raw_socket_name = args.get(1).cloned().expect(CLI_MSG); + let player_name = args.get(2).cloned().expect(CLI_MSG); + let socket_name = get_socket_name(raw_socket_name)?; + let opts = ListenerOptions::new().name(socket_name); + let listener = opts.create_tokio().context("Failed to bind to socket")?; + let (resp_tx, mut resp_rx) = mpsc::channel::(40); + + let handle = Arc::new(Mutex::new(DaemonState::new(player_name, resp_tx))); + + eprintln!("Testing Daemon Ready"); + + 'server: loop { + let res = tokio::select! { + res = listener.accept() => { + res + }, + Ok(_) = tokio::signal::ctrl_c() => { + break 'server; + } + }; + + match res { + Ok(stream) => { + let mut recv = BufReader::new(&stream); + let mut send = &stream; + + let mut buffer = String::with_capacity(256); + + loop { + tokio::select! { + Ok(_) = tokio::signal::ctrl_c() => { + break 'server; + } + res = recv.read_line(&mut buffer) => { + match res { + Ok(0) => { + break; + } + Ok(_amnt) => { + let req = serde_json::from_str(&buffer).expect("Failed to parse"); + buffer.clear(); + let handle2 = handle.clone(); + let mut state = handle.lock().await; + if let Err(resp) = state.process_req(handle2, req).await { + let encoded = serde_json::to_vec(&resp).expect("Failed to encode"); + send.write_all(&encoded).await.expect("Failed to send"); + } + } + Err(why) => { + eprintln!("Read Error: {why:?}"); + } + } + } + Some(resp) = resp_rx.recv() => { + let encoded = serde_json::to_vec(&resp).expect("Failed to encode"); + send.write_all(&encoded).await.expect("Failed to send"); + } + } + } + } + Err(why) => eprintln!("Error from connection: {why:?}"), + } + } + + Ok(()) +} diff --git a/manhunt-testing/src/driver.rs b/manhunt-testing/src/driver.rs new file mode 100644 index 0000000..6c33441 --- /dev/null +++ b/manhunt-testing/src/driver.rs @@ -0,0 +1,105 @@ +use clap::{Parser, Subcommand, ValueEnum}; +use interprocess::local_socket::{tokio::Stream, traits::tokio::Stream as _}; +use manhunt_logic::PowerUpType; +use manhunt_test_shared::{get_socket_name, prelude::*}; + +#[derive(Parser)] +struct Cli { + /// Path to the UNIX domain socket the test daemon is listening on + socket: String, + + #[command(subcommand)] + command: Commands, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] +enum Role { + Seeker, + Hider, +} + +#[derive(Subcommand)] +enum LobbyCommand { + /// Switch teams between seekers and hiders + SwitchTeams { + /// The role you want to become + #[arg(value_enum)] + role: Role, + }, + /// (Host) Sync game settings to players + SyncSettings, + /// (Host) Start the game for everyone + StartGame, + /// Quit to the main menu + Quit, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] +enum PowerUpTypeValue { + PingSeeker, + PingAllSeekers, + ForcePingOther, +} + +impl From for PowerUpType { + fn from(value: PowerUpTypeValue) -> Self { + match value { + PowerUpTypeValue::PingSeeker => PowerUpType::PingSeeker, + PowerUpTypeValue::PingAllSeekers => PowerUpType::PingAllSeekers, + PowerUpTypeValue::ForcePingOther => PowerUpType::ForcePingOther, + } + } +} + +#[derive(Subcommand)] +enum GameCommand { + /// Mark the local player as caught for everyone + MarkCaught, + /// Get a currently available powerup + GetPowerup, + /// Use the held powerup of the local player + UsePowerup, + /// Force set the held powerup to the given type + ForcePowerup { + #[arg(value_enum)] + ptype: PowerUpTypeValue, + }, + /// Quit the game + Quit, +} + +#[derive(Subcommand)] +enum Commands { + /// Create a lobby + Create, + /// Join a lobby + Join { + /// The join code for the lobby + join_code: String, + }, + /// Execute a command in an active lobby + #[command(subcommand)] + Lobby(LobbyCommand), + /// Execute a command in an active game + #[command(subcommand)] + Game(GameCommand), +} + +#[tokio::main] +async fn main() -> Result { + let cli = Cli::parse(); + + let socket_name = get_socket_name(cli.socket.clone()).context("Failed to get socket name")?; + + let stream = Stream::connect(socket_name) + .await + .context("Failed to connect to socket")?; + + let mut responses = Vec::with_capacity(5); + + loop { + + } + + Ok(()) +} diff --git a/manhunt-testing/src/lib.rs b/manhunt-testing/src/lib.rs new file mode 100644 index 0000000..4591d7e --- /dev/null +++ b/manhunt-testing/src/lib.rs @@ -0,0 +1,126 @@ +use interprocess::local_socket::{GenericNamespaced, Name, ToNsName}; +use manhunt_logic::{GameHistory, GameSettings, GameUiState, LobbyState, PowerUpType}; +use serde::{Deserialize, Serialize}; + +pub mod prelude { + pub use anyhow::{Context, anyhow, bail}; + pub type Result = std::result::Result; +} + +pub use prelude::*; + +pub fn get_socket_name(base_name: String) -> Result> { + base_name + .to_ns_name::() + .context("Failed to parse socket name") +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum LobbyRequest { + SwitchTeams(bool), + HostStartGame, + HostUpdateSettings(GameSettings), + Leave, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum GameRequest { + NextTick, + MarkCaught, + GetPowerup, + UsePowerup, + ForcePowerup(PowerUpType), + Quit, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum TestingRequest { + StartLobby(GameSettings), + JoinLobby(String), + LobbyReq(LobbyRequest), + GameReq(GameRequest), +} + +impl From for TestingRequest { + fn from(val: LobbyRequest) -> Self { + TestingRequest::LobbyReq(val) + } +} + +impl From for TestingRequest { + fn from(val: GameRequest) -> Self { + TestingRequest::GameReq(val) + } +} + +impl TryInto for TestingRequest { + type Error = TestingResponse; + + fn try_into(self) -> Result { + if let Self::LobbyReq(lr) = self { + Ok(lr) + } else { + Err(TestingResponse::WrongScreen) + } + } +} + +impl TryInto for TestingRequest { + type Error = TestingResponse; + + fn try_into(self) -> Result { + if let Self::GameReq(gr) = self { + Ok(gr) + } else { + Err(TestingResponse::WrongScreen) + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum ScreenUpdate { + PreConnect, + Lobby, + Game, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum TestingResponse { + Complete, + ScreenChanged(ScreenUpdate), + LobbyStateUpdate(LobbyState), + GameStateUpdate(GameUiState), + GameOver(GameHistory), + WrongScreen, + Error(String), +} + +impl From for TestingResponse { + fn from(val: GameHistory) -> Self { + TestingResponse::GameOver(val) + } +} + +impl From for TestingResponse { + fn from(value: anyhow::Error) -> Self { + TestingResponse::Error(value.to_string()) + } +} + +impl From for TestingResponse { + fn from(val: ScreenUpdate) -> Self { + TestingResponse::ScreenChanged(val) + } +} + +impl From for TestingResponse { + fn from(val: LobbyState) -> Self { + TestingResponse::LobbyStateUpdate(val) + } +} + +impl From for TestingResponse { + fn from(val: GameUiState) -> Self { + TestingResponse::GameStateUpdate(val) + } +}