diff --git a/crates/game-service/src/battle_session.rs b/crates/game-service/src/battle_session.rs index 54f2efb..0c1f427 100644 --- a/crates/game-service/src/battle_session.rs +++ b/crates/game-service/src/battle_session.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::sync::Arc; pub const SNAPSHOT_INTERVAL_TICKS: i32 = 20; use std::time::Instant; use logic::battle::{LogicGameMode, LogicGameObjectEntry, LogicVector2, BATTLE_TICKS_PER_SECOND}; @@ -236,26 +237,38 @@ impl BattleSession { } #[derive(Default)] pub struct BattleRegistry { - sessions: Mutex>, + sessions: Mutex>>>, } impl BattleRegistry { - pub async fn start(&self, account: AccountRef, mode: LogicGameMode, taunts: Vec) { - self.sessions - .lock() - .await - .insert(account, BattleSession::new(mode, taunts)); + pub async fn start( + &self, + players: &[AccountRef], + mode: LogicGameMode, + taunts: Vec, + ) { + let session = Arc::new(Mutex::new(BattleSession::new(mode, taunts))); + let mut sessions = self.sessions.lock().await; + for account in players { + sessions.insert(*account, Arc::clone(&session)); + } } - pub async fn finish(&self, account: AccountRef) -> Option { + async fn session(&self, account: AccountRef) -> Option>> { + self.sessions.lock().await.get(&account).cloned() + } + pub async fn is_running(&self, account: AccountRef) -> bool { + self.sessions.lock().await.contains_key(&account) + } + pub async fn finish(&self, account: AccountRef) -> Option>> { self.sessions.lock().await.remove(&account) } pub async fn tick(&self, account: AccountRef, summon: F) -> Vec where F: Fn(&LogicDataRef, LogicVector2, i32, i32) -> Vec, { - let mut sessions = self.sessions.lock().await; - let Some(session) = sessions.get_mut(&account) else { + let Some(handle) = self.session(account).await else { return Vec::new(); }; + let mut session = handle.lock().await; session.advance_to_now(); if let Some((card, position, instance)) = session.take_bot_play() { let entries = summon(&card, position, crate::bot::BOT_OWNER_INDEX, instance); @@ -269,10 +282,10 @@ impl BattleRegistry { session.take_outbound() } pub async fn answer_emote(&self, account: AccountRef) { - let mut sessions = self.sessions.lock().await; - let Some(session) = sessions.get_mut(&account) else { + let Some(handle) = self.session(account).await else { return; }; + let mut session = handle.lock().await; let Some(event) = session.bot_emote() else { return; }; @@ -281,13 +294,13 @@ impl BattleRegistry { } } pub async fn send(&self, account: AccountRef, message: WireMessage) { - if let Some(session) = self.sessions.lock().await.get_mut(&account) { - session.push_outbound(message); + if let Some(handle) = self.session(account).await { + handle.lock().await.push_outbound(message); } } pub async fn advance(&self, account: AccountRef, tick: i32) -> Option { - let mut sessions = self.sessions.lock().await; - let session = sessions.get_mut(&account)?; + let handle = self.session(account).await?; + let mut session = handle.lock().await; session.advance_to(tick); Some(BattleProgress { seconds: session.seconds(), @@ -308,10 +321,10 @@ impl BattleRegistry { where F: Fn(&LogicDataRef, logic::battle::LogicVector2, i32, i32) -> Vec, { - let mut sessions = self.sessions.lock().await; - let Some(session) = sessions.get_mut(&account) else { + let Some(handle) = self.session(account).await else { return 0; }; + let mut session = handle.lock().await; let Some(owner) = session.owner_of(executor) else { return 0; }; diff --git a/crates/game-service/src/lib.rs b/crates/game-service/src/lib.rs index 5cfbc89..1cf7827 100644 --- a/crates/game-service/src/lib.rs +++ b/crates/game-service/src/lib.rs @@ -5,6 +5,7 @@ pub mod catalog; pub mod config; pub mod home; pub mod home_mode; +pub mod matchmaker; pub mod rewards; pub mod service; pub mod shop; @@ -18,6 +19,7 @@ pub use catalog::{Catalog, ARENA_FALLBACK_INSTANCE, GOLD_RESOURCE_FALLBACK_INSTA pub use config::{CardRef, DataSelector, GameConfig, StarterProfile}; pub use home::{build_avatar, build_deck, build_home}; pub use home_mode::{HomeMode, HomeModeRegistry, TurnResult}; +pub use matchmaker::{Matched, Matchmaker}; pub use rewards::RewardRoller; pub use service::GameService; pub use shop::{ShopCatalog, ShopEntry, ShopOffer}; diff --git a/crates/game-service/src/matchmaker.rs b/crates/game-service/src/matchmaker.rs new file mode 100644 index 0000000..0589eec --- /dev/null +++ b/crates/game-service/src/matchmaker.rs @@ -0,0 +1,57 @@ +use std::collections::HashMap; +use std::time::{Duration, Instant}; +use service_rpc::AccountRef; +use tokio::sync::Mutex; +pub const MATCHMAKE_WAIT: Duration = Duration::from_secs(10); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Matched { + Waiting, + Player(AccountRef), + Bot, +} +#[derive(Default)] +pub struct Matchmaker { + waiting: Mutex>, +} +impl Matchmaker { + pub async fn enter(&self, account: AccountRef) -> Matched { + let mut waiting = self.waiting.lock().await; + let opponent = waiting + .keys() + .find(|other| **other != account) + .copied(); + if let Some(opponent) = opponent { + waiting.remove(&opponent); + waiting.remove(&account); + return Matched::Player(opponent); + } + waiting.entry(account).or_insert_with(Instant::now); + Matched::Waiting + } + pub async fn poll(&self, account: AccountRef) -> Matched { + let mut waiting = self.waiting.lock().await; + let Some(since) = waiting.get(&account).copied() else { + return Matched::Waiting; + }; + if let Some(opponent) = waiting + .keys() + .find(|other| **other != account) + .copied() + { + waiting.remove(&opponent); + waiting.remove(&account); + return Matched::Player(opponent); + } + if since.elapsed() >= MATCHMAKE_WAIT { + waiting.remove(&account); + return Matched::Bot; + } + Matched::Waiting + } + pub async fn leave(&self, account: AccountRef) { + self.waiting.lock().await.remove(&account); + } + pub async fn waiting_count(&self) -> usize { + self.waiting.lock().await.len() + } +} diff --git a/crates/game-service/src/service.rs b/crates/game-service/src/service.rs index 25d44e5..965ac11 100644 --- a/crates/game-service/src/service.rs +++ b/crates/game-service/src/service.rs @@ -13,6 +13,7 @@ use service_rpc::{ use titan::{Message, MessageMeta, Payload}; use crate::battle::BattleBuilder; use crate::battle_session::BattleRegistry; +use crate::matchmaker::{Matched, Matchmaker}; use crate::catalog::Catalog; use crate::config::GameConfig; use crate::bot::BotPlayer; @@ -36,6 +37,7 @@ pub struct GameService { shop: Arc, battles: Arc, running_battles: BattleRegistry, + matchmaker: Matchmaker, sessions: HomeModeRegistry, } impl GameService { @@ -67,6 +69,7 @@ impl GameService { shop, battles, running_battles: BattleRegistry::default(), + matchmaker: Matchmaker::default(), sessions: HomeModeRegistry::default(), })) } @@ -93,6 +96,14 @@ impl GameService { &self, account: AccountRef, npc: LogicDataRef, + ) -> RpcResult> { + self.sector_state_between(account, None, npc).await + } + async fn sector_state_between( + &self, + account: AccountRef, + opponent_account: Option, + npc: LogicDataRef, ) -> RpcResult> { let profile = self.profile(account).await?; let avatar = build_avatar(&profile); @@ -108,13 +119,22 @@ impl GameService { location_of(&npc, NPC_LOCATION_COLUMN) }; let deck = build_deck(&profile); - let (opponent, opponent_deck) = if npc.is_none() { - let bot = BotPlayer::against(&avatar, &deck); - (bot.avatar, bot.deck) - } else { - (avatar.clone(), deck.clone()) + let (opponent, opponent_deck) = match opponent_account { + Some(other) => { + let other_profile = self.profile(other).await?; + (build_avatar(&other_profile), build_deck(&other_profile)) + } + None if npc.is_none() => { + let bot = BotPlayer::against(&avatar, &deck); + (bot.avatar, bot.deck) + } + None => (avatar.clone(), deck.clone()), }; let opponent_name = opponent.name.clone(); + let players: Vec = match opponent_account { + Some(other) => vec![account, other], + None => vec![account], + }; let battle = self .battles .build( @@ -155,7 +175,7 @@ impl GameService { }))); } self.running_battles - .start(account, battle, crate::bot::menu_taunts()) + .start(&players, battle, crate::bot::menu_taunts()) .await; Ok(vec![encode(&SectorStateMessage::new(snapshot))?]) } @@ -222,11 +242,18 @@ impl GameApi for GameService { }; if came_back_from_battle { let battle = self.running_battles.finish(account).await; + let summary = match battle { + Some(handle) => { + let state = handle.lock().await; + Some((state.seconds(), state.is_finished(), state.stars())) + } + None => None, + }; tracing::info!( %account, - seconds = battle.as_ref().map(|state| state.seconds()), - finished = battle.as_ref().map(|state| state.is_finished()), - stars = ?battle.as_ref().map(|state| state.stars()), + seconds = summary.map(|state| state.0), + finished = summary.map(|state| state.1), + stars = ?summary.map(|state| state.2), "client came back from the battle, resuming home logic" ); } @@ -275,8 +302,22 @@ impl GameApi for GameService { account: AccountRef, _payload: Vec, ) -> RpcResult> { - tracing::info!(%account, "matchmaking against a bot player"); - self.sector_state_for(account, LogicDataRef::None).await + match self.matchmaker.enter(account).await { + Matched::Player(opponent) => { + tracing::info!(%account, %opponent, "matched two players"); + self.sector_state_between(account, Some(opponent), LogicDataRef::None) + .await + } + Matched::Bot => { + self.sector_state_between(account, None, LogicDataRef::None) + .await + } + Matched::Waiting => { + let queue = self.matchmaker.waiting_count().await; + tracing::info!(%account, queue, "waiting for an opponent"); + Ok(Vec::new()) + } + } } async fn end_client_turn( &self, @@ -394,6 +435,21 @@ impl GameApi for GameService { Ok(replies) } async fn battle_tick(&self, account: AccountRef) -> RpcResult> { + if !self.running_battles.is_running(account).await { + return match self.matchmaker.poll(account).await { + Matched::Player(opponent) => { + tracing::info!(%account, %opponent, "matched two players"); + self.sector_state_between(account, Some(opponent), LogicDataRef::None) + .await + } + Matched::Bot => { + tracing::info!(%account, "no opponent turned up, matching against a bot"); + self.sector_state_between(account, None, LogicDataRef::None) + .await + } + Matched::Waiting => Ok(Vec::new()), + }; + } Ok(self .running_battles .tick(account, |card, position, owner, instance| { @@ -414,7 +470,12 @@ impl GameApi for GameService { self.running_battles.answer_emote(account).await; Ok(()) } + async fn cancel_matchmake(&self, account: AccountRef) -> RpcResult<()> { + self.matchmaker.leave(account).await; + Ok(()) + } async fn disconnect(&self, account: AccountRef) -> RpcResult<()> { + self.matchmaker.leave(account).await; self.persist(account).await?; let sessions = self.sessions.session_count().await; tracing::debug!(%account, sessions, "session closed"); @@ -449,6 +510,10 @@ impl RpcService for GameService { GameRequest::BattleTick { account } => Ok(GameResponse::messages( self.battle_tick(account).await?, )), + GameRequest::CancelMatchmake { account } => { + self.cancel_matchmake(account).await?; + Ok(GameResponse::Empty) + } GameRequest::BattleEvent { account, payload } => { self.battle_event(account, payload).await?; Ok(GameResponse::Empty) diff --git a/crates/gateway/src/backend.rs b/crates/gateway/src/backend.rs index 0db1d2b..7990d23 100644 --- a/crates/gateway/src/backend.rs +++ b/crates/gateway/src/backend.rs @@ -135,6 +135,12 @@ impl GameApi for RemoteGame { .await?; Ok(()) } + async fn cancel_matchmake(&self, account: AccountRef) -> RpcResult<()> { + self.client + .call(&GameRequest::CancelMatchmake { account }) + .await?; + Ok(()) + } async fn disconnect(&self, account: AccountRef) -> RpcResult<()> { self.client .call(&GameRequest::Disconnect { account }) diff --git a/crates/gateway/src/message_manager.rs b/crates/gateway/src/message_manager.rs index fabe2eb..d86d68a 100644 --- a/crates/gateway/src/message_manager.rs +++ b/crates/gateway/src/message_manager.rs @@ -115,6 +115,10 @@ impl MessageManager { } message_type::CANCEL_MATCHMAKE => { tracing::info!(peer = %self.peer, "client cancelled matchmaking"); + self.stop_battle_ticker(); + if let Some(account) = self.account { + let _ = self.backends.game.cancel_matchmake(account).await; + } self.sender .send_message(&CancelMatchmakeDoneMessage::default()) .await?; diff --git a/crates/logic/src/battle/verify.rs b/crates/logic/src/battle/verify.rs index f9fb59f..8f5ed48 100644 --- a/crates/logic/src/battle/verify.rs +++ b/crates/logic/src/battle/verify.rs @@ -4,6 +4,7 @@ use crate::data::{table, LogicDataRef, LogicDataTables}; use crate::model::DECK_SLOT_COUNT; pub const BUFF_ARRAY_TABLE: i32 = table::DAMAGE_TYPES; pub const MOVEMENT_SPEED_COLUMN: &str = "Speed"; +pub const MOVEMENT_TAIL_VINTS: usize = 17; #[derive(Debug, Clone, PartialEq, Eq)] pub struct SnapshotReport { pub steps: Vec<(String, usize)>, @@ -98,7 +99,7 @@ impl<'a, 'b> Verifier<'a, 'b> { } let path = self.reader.read_vint()?.max(0) as usize; self.vints(path)?; - self.vints(18)?; + self.vints(MOVEMENT_TAIL_VINTS)?; Ok(()) } fn combat(&mut self) -> Result<()> { diff --git a/crates/service-rpc/src/game.rs b/crates/service-rpc/src/game.rs index e550d7e..81816ab 100644 --- a/crates/service-rpc/src/game.rs +++ b/crates/service-rpc/src/game.rs @@ -56,6 +56,9 @@ pub enum GameRequest { BattleTick { account: AccountRef, }, + CancelMatchmake { + account: AccountRef, + }, BattleEvent { account: AccountRef, #[serde(with = "crate::base64::serde_bytes")] @@ -107,5 +110,6 @@ pub trait GameApi: Send + Sync + 'static { ) -> RpcResult>; async fn battle_tick(&self, account: AccountRef) -> RpcResult>; async fn battle_event(&self, account: AccountRef, payload: Vec) -> RpcResult<()>; + async fn cancel_matchmake(&self, account: AccountRef) -> RpcResult<()>; async fn disconnect(&self, account: AccountRef) -> RpcResult<()>; }