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.
This commit is contained in:
parent
3f0cf3d1d4
commit
ba235a9b4c
8 changed files with 181 additions and 29 deletions
|
|
@ -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<HashMap<AccountRef, BattleSession>>,
|
||||
sessions: Mutex<HashMap<AccountRef, Arc<Mutex<BattleSession>>>>,
|
||||
}
|
||||
impl BattleRegistry {
|
||||
pub async fn start(&self, account: AccountRef, mode: LogicGameMode, taunts: Vec<LogicDataRef>) {
|
||||
self.sessions
|
||||
.lock()
|
||||
.await
|
||||
.insert(account, BattleSession::new(mode, taunts));
|
||||
pub async fn start(
|
||||
&self,
|
||||
players: &[AccountRef],
|
||||
mode: LogicGameMode,
|
||||
taunts: Vec<LogicDataRef>,
|
||||
) {
|
||||
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<BattleSession> {
|
||||
async fn session(&self, account: AccountRef) -> Option<Arc<Mutex<BattleSession>>> {
|
||||
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<Arc<Mutex<BattleSession>>> {
|
||||
self.sessions.lock().await.remove(&account)
|
||||
}
|
||||
pub async fn tick<F>(&self, account: AccountRef, summon: F) -> Vec<WireMessage>
|
||||
where
|
||||
F: Fn(&LogicDataRef, LogicVector2, i32, i32) -> Vec<LogicGameObjectEntry>,
|
||||
{
|
||||
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<BattleProgress> {
|
||||
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<LogicGameObjectEntry>,
|
||||
{
|
||||
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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
57
crates/game-service/src/matchmaker.rs
Normal file
57
crates/game-service/src/matchmaker.rs
Normal file
|
|
@ -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<HashMap<AccountRef, Instant>>,
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ShopCatalog>,
|
||||
battles: Arc<BattleBuilder>,
|
||||
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<Vec<WireMessage>> {
|
||||
self.sector_state_between(account, None, npc).await
|
||||
}
|
||||
async fn sector_state_between(
|
||||
&self,
|
||||
account: AccountRef,
|
||||
opponent_account: Option<AccountRef>,
|
||||
npc: LogicDataRef,
|
||||
) -> RpcResult<Vec<WireMessage>> {
|
||||
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<AccountRef> = 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<u8>,
|
||||
) -> RpcResult<Vec<WireMessage>> {
|
||||
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<Vec<WireMessage>> {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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<()> {
|
||||
|
|
|
|||
|
|
@ -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<Vec<WireMessage>>;
|
||||
async fn battle_tick(&self, account: AccountRef) -> RpcResult<Vec<WireMessage>>;
|
||||
async fn battle_event(&self, account: AccountRef, payload: Vec<u8>) -> RpcResult<()>;
|
||||
async fn cancel_matchmake(&self, account: AccountRef) -> RpcResult<()>;
|
||||
async fn disconnect(&self, account: AccountRef) -> RpcResult<()>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue