diff --git a/crates/game-service/src/battle_session.rs b/crates/game-service/src/battle_session.rs index 7fe9ba8..960fe1d 100644 --- a/crates/game-service/src/battle_session.rs +++ b/crates/game-service/src/battle_session.rs @@ -1,22 +1,78 @@ use std::collections::HashMap; +use std::time::Instant; use logic::battle::{LogicGameMode, LogicGameObjectEntry, BATTLE_TICKS_PER_SECOND}; -use logic::LogicDataRef; +use logic::battle::LogicBattleEvent; +use logic::{BattleEventMessage, LogicDataRef, LogicRandom}; use titan::LogicLong; -use service_rpc::AccountRef; +use service_rpc::{AccountRef, WireMessage}; use tokio::sync::Mutex; pub struct BattleSession { mode: LogicGameMode, tick: i32, queued: Vec<(i32, Vec)>, + outbound: Vec, + started_at: Instant, + random: LogicRandom, + taunts: Vec, + next_bot_emote: i32, } impl BattleSession { - pub fn new(mode: LogicGameMode) -> Self { + pub fn new(mode: LogicGameMode, taunts: Vec) -> Self { + let mut random = LogicRandom::new(mode.random_seed); + let next_bot_emote = Self::roll_emote_tick(&mut random, 0); Self { mode, tick: 0, queued: Vec::new(), + outbound: Vec::new(), + started_at: Instant::now(), + random, + taunts, + next_bot_emote, } } + fn roll_emote_tick(random: &mut LogicRandom, from: i32) -> i32 { + let span = crate::bot::BOT_EMOTE_MAX_SECONDS - crate::bot::BOT_EMOTE_MIN_SECONDS; + let seconds = crate::bot::BOT_EMOTE_MIN_SECONDS + random.next(span.max(1)); + from + seconds * BATTLE_TICKS_PER_SECOND + } + pub fn opponent_account(&self) -> LogicLong { + self.mode + .battle + .account_ids + .get(1) + .copied() + .unwrap_or_default() + } + pub fn bot_emote(&mut self) -> Option { + if self.taunts.is_empty() { + return None; + } + let index = self.random.next(self.taunts.len() as i32).max(0) as usize; + let taunt = self.taunts.get(index)?; + let instance = taunt.global_id().map(|id| id.instance_id).unwrap_or(0); + Some(LogicBattleEvent { + event_type: 1, + account_id: self.opponent_account(), + ticks: vec![self.tick], + coords: Vec::new(), + params: vec![instance], + }) + } + pub fn elapsed_tick(&self) -> i32 { + let millis = self.started_at.elapsed().as_millis() as i64; + (millis * BATTLE_TICKS_PER_SECOND as i64 / 1000) as i32 + } + pub fn advance_to_now(&mut self) { + let tick = self.elapsed_tick(); + self.advance_to(tick); + } + pub fn push_outbound(&mut self, message: WireMessage) { + self.outbound.push(message); + } + pub fn take_outbound(&mut self) -> Vec { + std::mem::take(&mut self.outbound) + } pub fn mode(&self) -> &LogicGameMode { &self.mode } @@ -36,6 +92,14 @@ impl BattleSession { while self.tick < tick { self.tick += 1; self.release_queued(self.tick); + if self.tick >= self.next_bot_emote { + self.next_bot_emote = Self::roll_emote_tick(&mut self.random, self.tick); + if let Some(event) = self.bot_emote() { + if let Ok(message) = crate::wire::encode(&BattleEventMessage::new(event)) { + self.outbound.push(message); + } + } + } } self.mode.time.tick = self.tick; } @@ -95,15 +159,40 @@ pub struct BattleRegistry { sessions: Mutex>, } impl BattleRegistry { - pub async fn start(&self, account: AccountRef, mode: LogicGameMode) { + pub async fn start(&self, account: AccountRef, mode: LogicGameMode, taunts: Vec) { self.sessions .lock() .await - .insert(account, BattleSession::new(mode)); + .insert(account, BattleSession::new(mode, taunts)); } pub async fn finish(&self, account: AccountRef) -> Option { self.sessions.lock().await.remove(&account) } + pub async fn tick(&self, account: AccountRef) -> Vec { + let mut sessions = self.sessions.lock().await; + let Some(session) = sessions.get_mut(&account) else { + return Vec::new(); + }; + session.advance_to_now(); + 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 { + return; + }; + let Some(event) = session.bot_emote() else { + return; + }; + if let Ok(message) = crate::wire::encode(&BattleEventMessage::new(event)) { + session.push_outbound(message); + } + } + pub async fn send(&self, account: AccountRef, message: WireMessage) { + if let Some(session) = self.sessions.lock().await.get_mut(&account) { + session.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)?; diff --git a/crates/game-service/src/bot.rs b/crates/game-service/src/bot.rs index 62b0409..9433506 100644 --- a/crates/game-service/src/bot.rs +++ b/crates/game-service/src/bot.rs @@ -2,8 +2,23 @@ use logic::model::{LogicClientAvatar, LogicSpell, LogicSpellDeck, DECK_SLOT_COUN use logic::{table, LogicDataRef, LogicDataTables}; use titan::LogicLong; pub const BOT_NAME: &str = "Bot"; +pub const TAUNT_MENU_COLUMN: &str = "TauntMenu"; +pub const BOT_EMOTE_MIN_SECONDS: i32 = 12; +pub const BOT_EMOTE_MAX_SECONDS: i32 = 30; pub const DECK_SPELLS_COLUMN: &str = "Spells"; pub const DECK_LEVEL_COLUMN: &str = "SpellLevel"; +pub fn menu_taunts() -> Vec { + LogicDataTables::instance() + .table(table::TAUNTS) + .map(|rows| { + rows.iter() + .filter(|row| row.boolean(TAUNT_MENU_COLUMN)) + .cloned() + .map(LogicDataRef::from) + .collect() + }) + .unwrap_or_default() +} pub struct BotPlayer { pub avatar: LogicClientAvatar, pub deck: LogicSpellDeck, diff --git a/crates/game-service/src/lib.rs b/crates/game-service/src/lib.rs index 0569391..5cfbc89 100644 --- a/crates/game-service/src/lib.rs +++ b/crates/game-service/src/lib.rs @@ -10,6 +10,7 @@ pub mod service; pub mod shop; pub mod store; pub mod time; +pub mod wire; pub use battle::BattleBuilder; pub use battle_session::{BattleProgress, BattleRegistry, BattleSession}; pub use bot::BotPlayer; diff --git a/crates/game-service/src/service.rs b/crates/game-service/src/service.rs index a5f6e1f..c691e3d 100644 --- a/crates/game-service/src/service.rs +++ b/crates/game-service/src/service.rs @@ -3,6 +3,7 @@ use logic::table; use logic::{ AvailableServerCommandMessage, EndClientTurnMessage, LogicCommandManager, LogicDataRef, LogicShopSeedChangedCommand, OutOfSyncMessage, OwnHomeDataMessage, SectorStateMessage, + SendBattleEventMessage, StartMissionMessage, StopHomeLogicMessage, }; use service_rpc::{ @@ -153,7 +154,9 @@ impl GameService { format!("{} trailing byte(s)", report.trailing) }))); } - self.running_battles.start(account, battle).await; + self.running_battles + .start(account, battle, crate::bot::menu_taunts()) + .await; Ok(vec![encode(&SectorStateMessage::new(snapshot))?]) } async fn roller(&self, account: AccountRef) -> RpcResult { @@ -180,14 +183,7 @@ fn encode(message: &M) -> RpcResult where M: MessageMeta + Message, { - let payload = message - .to_bytes() - .map_err(|error| RpcError::Rejected(error.to_string()))?; - Ok(WireMessage::new( - M::MESSAGE_TYPE, - M::MESSAGE_VERSION, - payload, - )) + crate::wire::encode(message) } #[async_trait::async_trait] impl GameApi for GameService { @@ -397,6 +393,22 @@ impl GameApi for GameService { } Ok(replies) } + async fn battle_tick(&self, account: AccountRef) -> RpcResult> { + Ok(self.running_battles.tick(account).await) + } + async fn battle_event(&self, account: AccountRef, payload: Vec) -> RpcResult<()> { + let Ok(sent) = SendBattleEventMessage::from_bytes(&payload) else { + return Ok(()); + }; + tracing::debug!( + %account, + event_type = sent.event.event_type, + params = ?sent.event.params, + "battle event from the player" + ); + self.running_battles.answer_emote(account).await; + Ok(()) + } async fn disconnect(&self, account: AccountRef) -> RpcResult<()> { self.persist(account).await?; let sessions = self.sessions.session_count().await; @@ -429,6 +441,13 @@ impl RpcService for GameService { GameRequest::EndClientTurn { account, payload } => Ok(GameResponse::messages( self.end_client_turn(account, payload).await?, )), + GameRequest::BattleTick { account } => Ok(GameResponse::messages( + self.battle_tick(account).await?, + )), + GameRequest::BattleEvent { account, payload } => { + self.battle_event(account, payload).await?; + Ok(GameResponse::Empty) + } GameRequest::Disconnect { account } => { self.disconnect(account).await?; Ok(GameResponse::Empty) diff --git a/crates/game-service/src/wire.rs b/crates/game-service/src/wire.rs new file mode 100644 index 0000000..d6bac1e --- /dev/null +++ b/crates/game-service/src/wire.rs @@ -0,0 +1,15 @@ +use service_rpc::{RpcError, RpcResult, WireMessage}; +use titan::{Message, MessageMeta}; +pub fn encode(message: &M) -> RpcResult +where + M: MessageMeta + Message, +{ + let payload = message + .to_bytes() + .map_err(|error| RpcError::Rejected(error.to_string()))?; + Ok(WireMessage::new( + M::MESSAGE_TYPE, + M::MESSAGE_VERSION, + payload, + )) +} diff --git a/crates/gateway/src/backend.rs b/crates/gateway/src/backend.rs index dd49138..0db1d2b 100644 --- a/crates/gateway/src/backend.rs +++ b/crates/gateway/src/backend.rs @@ -122,6 +122,19 @@ impl GameApi for RemoteGame { .await? .into_messages()) } + async fn battle_tick(&self, account: AccountRef) -> RpcResult> { + Ok(self + .client + .call(&GameRequest::BattleTick { account }) + .await? + .into_messages()) + } + async fn battle_event(&self, account: AccountRef, payload: Vec) -> RpcResult<()> { + self.client + .call(&GameRequest::BattleEvent { account, payload }) + .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 fb6c4aa..fabe2eb 100644 --- a/crates/gateway/src/message_manager.rs +++ b/crates/gateway/src/message_manager.rs @@ -1,9 +1,10 @@ use std::net::SocketAddr; use std::sync::Arc; +use std::time::Duration; +use tokio::task::JoinHandle; use logic::{ message_type, CancelMatchmakeDoneMessage, ClientCapabilitiesMessage, GoHomeMessage, - KeepAliveServerMessage, LoginFailedMessage, LoginMessage, LoginOkMessage, SendBattleEventMessage, - ServerErrorMessage, + KeepAliveServerMessage, LoginFailedMessage, LoginMessage, LoginOkMessage, ServerErrorMessage, }; use service_rpc::{ AccountRef, DeviceInfo, HomeRequestKind, LoginOutcome, Session as AuthSession, WireMessage, @@ -21,12 +22,19 @@ pub enum RoutingError { Unauthenticated(u16), } type RoutingResult = Result; +pub const BATTLE_TICK_INTERVAL: Duration = Duration::from_millis(200); pub struct MessageManager { peer: SocketAddr, config: Arc, backends: Arc, sender: MessagingSender, account: Option, + battle_ticker: Option>, +} +impl Drop for MessageManager { + fn drop(&mut self) { + self.stop_battle_ticker(); + } } impl MessageManager { pub fn new( @@ -41,6 +49,7 @@ impl MessageManager { backends, sender, account: None, + battle_ticker: None, } } pub fn account(&self) -> Option { @@ -87,16 +96,20 @@ impl MessageManager { } message_type::GO_HOME => { let _ = incoming.downcast::(); + self.stop_battle_ticker(); self.push_home(HomeRequestKind::GoHome).await } message_type::SEND_BATTLE_EVENT => { - if let Some(sent) = incoming.downcast::() { - tracing::debug!( - peer = %self.peer, - event_type = sent.event.event_type, - params = ?sent.event.params, - "battle event" - ); + let Some(account) = self.account else { + return Ok(()); + }; + if let Err(error) = self + .backends + .game + .battle_event(account, incoming.payload) + .await + { + tracing::warn!(peer = %self.peer, %error, "could not deliver the battle event"); } Ok(()) } @@ -117,7 +130,10 @@ impl MessageManager { .home_logic_stopped(account, incoming.payload) .await { - Ok(replies) => self.push_wire_messages(replies).await?, + Ok(replies) => { + self.push_wire_messages(replies).await?; + self.start_battle_ticker(); + } Err(error) => { tracing::warn!(peer = %self.peer, %error, "could not match the player"); self.sender @@ -137,7 +153,10 @@ impl MessageManager { .start_mission(account, incoming.payload) .await { - Ok(replies) => self.push_wire_messages(replies).await?, + Ok(replies) => { + self.push_wire_messages(replies).await?; + self.start_battle_ticker(); + } Err(error) => { tracing::warn!(peer = %self.peer, %error, "could not start the mission"); self.sender @@ -264,6 +283,48 @@ impl MessageManager { let messages = self.backends.game.load_home(account, kind).await?; self.push_wire_messages(messages).await } + fn start_battle_ticker(&mut self) { + let Some(account) = self.account else { + return; + }; + self.stop_battle_ticker(); + let backends = Arc::clone(&self.backends); + let sender = self.sender.clone(); + let peer = self.peer; + self.battle_ticker = Some(tokio::spawn(async move { + let mut ticker = tokio::time::interval(BATTLE_TICK_INTERVAL); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + ticker.tick().await; + if sender.is_closed() { + return; + } + match backends.game.battle_tick(account).await { + Ok(messages) => { + for message in messages { + let outbound = OutboundMessage::raw( + message.message_type, + message.message_version, + message.payload, + ); + if sender.send(outbound).await.is_err() { + return; + } + } + } + Err(error) => { + tracing::warn!(%peer, %error, "the battle tick failed, stopping the ticker"); + return; + } + } + } + })); + } + fn stop_battle_ticker(&mut self) { + if let Some(handle) = self.battle_ticker.take() { + handle.abort(); + } + } async fn push_wire_messages(&self, messages: Vec) -> RoutingResult<()> { for message in messages { self.sender diff --git a/crates/logic/src/messages/battle_event.rs b/crates/logic/src/messages/battle_event.rs index 4d9f1a7..d96e9d6 100644 --- a/crates/logic/src/messages/battle_event.rs +++ b/crates/logic/src/messages/battle_event.rs @@ -5,3 +5,13 @@ use crate::battle::LogicBattleEvent; pub struct SendBattleEventMessage { pub event: LogicBattleEvent, } +#[derive(Debug, Default, Clone, PartialEq, Eq, Message)] +#[message(id = 22952, direction = "server", name = "BattleEventMessage")] +pub struct BattleEventMessage { + pub event: LogicBattleEvent, +} +impl BattleEventMessage { + pub fn new(event: LogicBattleEvent) -> Self { + Self { event } + } +} diff --git a/crates/logic/src/messages/mod.rs b/crates/logic/src/messages/mod.rs index 3fd1fdb..1a49fa5 100644 --- a/crates/logic/src/messages/mod.rs +++ b/crates/logic/src/messages/mod.rs @@ -33,7 +33,7 @@ pub use login_ok::LoginOkMessage; pub use matchmake::{CancelMatchmakeDoneMessage, HomeLogicStoppedMessage, StopHomeLogicMessage}; pub use out_of_sync::OutOfSyncMessage; pub use own_home_data::OwnHomeDataMessage; -pub use battle_event::SendBattleEventMessage; +pub use battle_event::{BattleEventMessage, SendBattleEventMessage}; pub use sector_state::SectorStateMessage; pub use server_error::ServerErrorMessage; pub use start_mission::StartMissionMessage; @@ -52,6 +52,7 @@ pub mod message_type { pub const HOME_LOGIC_STOPPED: u16 = 14105; pub const CANCEL_MATCHMAKE: u16 = 14107; pub const SEND_BATTLE_EVENT: u16 = 12951; + pub const BATTLE_EVENT: u16 = 22952; pub const SERVER_HELLO: u16 = 20100; pub const LOGIN_FAILED: u16 = 20103; pub const LOGIN_OK: u16 = 20104; diff --git a/crates/service-rpc/src/game.rs b/crates/service-rpc/src/game.rs index d1fa0f9..e550d7e 100644 --- a/crates/service-rpc/src/game.rs +++ b/crates/service-rpc/src/game.rs @@ -53,6 +53,14 @@ pub enum GameRequest { #[serde(with = "crate::base64::serde_bytes")] payload: Vec, }, + BattleTick { + account: AccountRef, + }, + BattleEvent { + account: AccountRef, + #[serde(with = "crate::base64::serde_bytes")] + payload: Vec, + }, Disconnect { account: AccountRef, }, @@ -97,5 +105,7 @@ pub trait GameApi: Send + Sync + 'static { account: AccountRef, payload: Vec, ) -> RpcResult>; + async fn battle_tick(&self, account: AccountRef) -> RpcResult>; + async fn battle_event(&self, account: AccountRef, payload: Vec) -> RpcResult<()>; async fn disconnect(&self, account: AccountRef) -> RpcResult<()>; }