From ba235a9b4cd255bcfbf26e523e6c1e11d87a68f0 Mon Sep 17 00:00:00 2001 From: WiseDev <83840010+wisedevik@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:19:38 +0300 Subject: [PATCH] matchmake two players before falling back to a bot matchmaking now queues. the first player in waits, the second one to arrive pairs with them, and both get the same battle: one shared session keyed by both accounts, one snapshot, the client working out which side it is from the account ids it already carries. if nobody turns up within ten seconds the ticker polls the queue out and builds the bot battle instead. cancelling or disconnecting leaves the queue. also fixes the movement component tail: the extracted layout counts "n + 18" vints including the path length itself, so seventeen follow the path, not eighteen. the verifier read one too many and every snapshot carrying a moving unit came apart after it - which is what the guard caught and refused to send, rather than the client aborting on it. --- crates/game-service/src/battle_session.rs | 47 +++++++----- crates/game-service/src/lib.rs | 2 + crates/game-service/src/matchmaker.rs | 57 +++++++++++++++ crates/game-service/src/service.rs | 87 ++++++++++++++++++++--- crates/gateway/src/backend.rs | 6 ++ crates/gateway/src/message_manager.rs | 4 ++ crates/logic/src/battle/verify.rs | 3 +- crates/service-rpc/src/game.rs | 4 ++ 8 files changed, 181 insertions(+), 29 deletions(-) create mode 100644 crates/game-service/src/matchmaker.rs 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<()>; }