matchmake against a bot instead of searching forever

537 only tells the client's ui to start searching, the handshake after it was
missing: server sends 24106 StopHomeLogic, client answers 14105, server sends
21903. that last step now builds the same snapshot the npc mission does, using
npcs row 0 as the opponent.

battle type stays 1 so it runs on the client's offline path. a real pvp battle
is type 0 and needs the udp sector channel, which does not exist here.

14107 CancelMatchmake now answers 24125 instead of being ignored.
This commit is contained in:
WiseDev 2026-08-23 10:28:28 +03:00
parent 774e7c60cf
commit 6dc4caede9
9 changed files with 141 additions and 34 deletions

View file

@ -26,6 +26,7 @@ fn diamonds() -> LogicDataRef {
pub struct TurnResult { pub struct TurnResult {
pub claims: Vec<LogicClaimRewardCommand>, pub claims: Vec<LogicClaimRewardCommand>,
pub purchases: Vec<Purchase>, pub purchases: Vec<Purchase>,
pub matchmake: bool,
pub out_of_sync: Option<OutOfSyncMessage>, pub out_of_sync: Option<OutOfSyncMessage>,
pub changed: bool, pub changed: bool,
} }
@ -214,6 +215,7 @@ impl HomeMode {
} }
} }
} }
CommandOutcome::MatchmakeStarted => result.matchmake = true,
CommandOutcome::Applied => result.changed = true, CommandOutcome::Applied => result.changed = true,
CommandOutcome::Ignored => {} CommandOutcome::Ignored => {}
} }

View file

@ -1,9 +1,9 @@
use std::sync::Arc; use std::sync::Arc;
use logic::table; use logic::{table, LogicDataTables};
use logic::{ use logic::{
AvailableServerCommandMessage, EndClientTurnMessage, LogicCommandManager, LogicDataRef, AvailableServerCommandMessage, EndClientTurnMessage, LogicCommandManager, LogicDataRef,
LogicShopSeedChangedCommand, OutOfSyncMessage, OwnHomeDataMessage, SectorStateMessage, LogicShopSeedChangedCommand, OutOfSyncMessage, OwnHomeDataMessage, SectorStateMessage,
StartMissionMessage, StartMissionMessage, StopHomeLogicMessage,
}; };
use service_rpc::{ use service_rpc::{
AccountRef, GameApi, GameRequest, GameResponse, HomeRequestKind, RpcError, RpcResult, AccountRef, GameApi, GameRequest, GameResponse, HomeRequestKind, RpcError, RpcResult,
@ -77,6 +77,47 @@ impl GameService {
pub fn shop(&self) -> &Arc<ShopCatalog> { pub fn shop(&self) -> &Arc<ShopCatalog> {
&self.shop &self.shop
} }
async fn sector_state_for(
&self,
account: AccountRef,
npc: LogicDataRef,
) -> RpcResult<Vec<WireMessage>> {
let profile = self.profile(account).await?;
let avatar = build_avatar(&profile);
let location = npc
.data()
.map(|row| LogicDataRef::by_name(table::LOCATIONS, row.string("Location")))
.unwrap_or_default();
let battle = self
.battles
.build(
location.clone(),
npc.clone(),
profile.arena.clone(),
vec![avatar.clone(), avatar],
self.config.random_seed,
)
.map_err(|error| RpcError::Rejected(error.to_string()))?;
let snapshot = battle
.snapshot()
.map_err(|error| RpcError::Rejected(error.to_string()))?;
tracing::info!(
%account,
npc = %npc,
location = %location,
objects = battle.battle.objects.objects.len(),
bytes = snapshot.len(),
"sending a battle sector state"
);
Ok(vec![encode(&SectorStateMessage::new(snapshot))?])
}
fn bot_opponent(&self) -> LogicDataRef {
LogicDataTables::instance()
.table(table::NPCS)
.and_then(|rows| rows.get_at(0).cloned())
.map(LogicDataRef::from)
.unwrap_or_default()
}
async fn roller(&self, account: AccountRef) -> RpcResult<RewardRoller> { async fn roller(&self, account: AccountRef) -> RpcResult<RewardRoller> {
let profile = self.profile(account).await?; let profile = self.profile(account).await?;
let fallback: Vec<LogicDataRef> = profile let fallback: Vec<LogicDataRef> = profile
@ -178,35 +219,21 @@ impl GameApi for GameService {
) -> RpcResult<Vec<WireMessage>> { ) -> RpcResult<Vec<WireMessage>> {
let mission = StartMissionMessage::from_bytes(&payload) let mission = StartMissionMessage::from_bytes(&payload)
.map_err(|error| RpcError::Rejected(error.to_string()))?; .map_err(|error| RpcError::Rejected(error.to_string()))?;
let profile = self.profile(account).await?; self.sector_state_for(account, mission.npc).await
let avatar = build_avatar(&profile); }
let location = mission async fn home_logic_stopped(
.npc &self,
.data() account: AccountRef,
.map(|npc| LogicDataRef::by_name(table::LOCATIONS, npc.string("Location"))) _payload: Vec<u8>,
.unwrap_or_default(); ) -> RpcResult<Vec<WireMessage>> {
let battle = self let npc = self.bot_opponent();
.battles if npc.is_none() {
.build( return Err(RpcError::Rejected(
location.clone(), "npcs.csv has no bot to match against".into(),
mission.npc.clone(), ));
profile.arena.clone(), }
vec![avatar.clone(), avatar], tracing::info!(%account, bot = %npc, "matchmaking against a bot");
self.config.random_seed, self.sector_state_for(account, npc).await
)
.map_err(|error| RpcError::Rejected(error.to_string()))?;
let snapshot = battle
.snapshot()
.map_err(|error| RpcError::Rejected(error.to_string()))?;
tracing::info!(
%account,
npc = %mission.npc,
location = %location,
objects = battle.battle.objects.objects.len(),
bytes = snapshot.len(),
"sending a battle sector state"
);
Ok(vec![encode(&SectorStateMessage::new(snapshot))?])
} }
async fn end_client_turn( async fn end_client_turn(
&self, &self,
@ -238,6 +265,10 @@ impl GameApi for GameService {
"end client turn" "end client turn"
); );
let mut replies = Vec::new(); let mut replies = Vec::new();
if result.matchmake {
tracing::info!(%account, "client started matchmaking, stopping home logic");
replies.push(encode(&StopHomeLogicMessage::default())?);
}
if let Some(out_of_sync) = result.out_of_sync { if let Some(out_of_sync) = result.out_of_sync {
tracing::warn!( tracing::warn!(
%account, %account,
@ -304,6 +335,9 @@ impl RpcService for GameService {
GameRequest::StartMission { account, payload } => Ok(GameResponse::messages( GameRequest::StartMission { account, payload } => Ok(GameResponse::messages(
self.start_mission(account, payload).await?, self.start_mission(account, payload).await?,
)), )),
GameRequest::HomeLogicStopped { account, payload } => Ok(GameResponse::messages(
self.home_logic_stopped(account, payload).await?,
)),
GameRequest::EndClientTurn { account, payload } => Ok(GameResponse::messages( GameRequest::EndClientTurn { account, payload } => Ok(GameResponse::messages(
self.end_client_turn(account, payload).await?, self.end_client_turn(account, payload).await?,
)), )),

View file

@ -100,6 +100,17 @@ impl GameApi for RemoteGame {
.await? .await?
.into_messages()) .into_messages())
} }
async fn home_logic_stopped(
&self,
account: AccountRef,
payload: Vec<u8>,
) -> RpcResult<Vec<WireMessage>> {
Ok(self
.client
.call(&GameRequest::HomeLogicStopped { account, payload })
.await?
.into_messages())
}
async fn end_client_turn( async fn end_client_turn(
&self, &self,
account: AccountRef, account: AccountRef,

View file

@ -1,8 +1,8 @@
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
use logic::{ use logic::{
message_type, ClientCapabilitiesMessage, GoHomeMessage, KeepAliveServerMessage, message_type, CancelMatchmakeDoneMessage, ClientCapabilitiesMessage, GoHomeMessage,
LoginFailedMessage, LoginMessage, LoginOkMessage, ServerErrorMessage, KeepAliveServerMessage, LoginFailedMessage, LoginMessage, LoginOkMessage, ServerErrorMessage,
}; };
use service_rpc::{ use service_rpc::{
AccountRef, DeviceInfo, HomeRequestKind, LoginOutcome, Session as AuthSession, WireMessage, AccountRef, DeviceInfo, HomeRequestKind, LoginOutcome, Session as AuthSession, WireMessage,
@ -88,6 +88,33 @@ impl MessageManager {
let _ = incoming.downcast::<GoHomeMessage>(); let _ = incoming.downcast::<GoHomeMessage>();
self.push_home(HomeRequestKind::GoHome).await self.push_home(HomeRequestKind::GoHome).await
} }
message_type::CANCEL_MATCHMAKE => {
tracing::info!(peer = %self.peer, "client cancelled matchmaking");
self.sender
.send_message(&CancelMatchmakeDoneMessage::default())
.await?;
Ok(())
}
message_type::HOME_LOGIC_STOPPED => {
let Some(account) = self.account else {
return Ok(());
};
match self
.backends
.game
.home_logic_stopped(account, incoming.payload)
.await
{
Ok(replies) => self.push_wire_messages(replies).await?,
Err(error) => {
tracing::warn!(peer = %self.peer, %error, "could not match the player");
self.sender
.send_message(&CancelMatchmakeDoneMessage::default())
.await?;
}
}
Ok(())
}
message_type::START_MISSION => { message_type::START_MISSION => {
let Some(account) = self.account else { let Some(account) = self.account else {
return Ok(()); return Ok(());

View file

@ -18,6 +18,7 @@ pub enum CommandOutcome {
data: LogicDataRef, data: LogicDataRef,
count: i32, count: i32,
}, },
MatchmakeStarted,
Applied, Applied,
Rejected(&'static str), Rejected(&'static str),
Ignored, Ignored,

View file

@ -43,6 +43,6 @@ impl Execute for LogicStartMatchmakeCommand {
if mode.is_claiming_reward() { if mode.is_claiming_reward() {
return CommandOutcome::Rejected("a reward claim is in progress"); return CommandOutcome::Rejected("a reward claim is in progress");
} }
CommandOutcome::Ignored CommandOutcome::MatchmakeStarted
} }
} }

View file

@ -0,0 +1,18 @@
use titan::Message;
use crate::commands::LogicCommand;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Message)]
#[message(id = 24106, direction = "server", name = "StopHomeLogicMessage")]
pub struct StopHomeLogicMessage {}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Message)]
#[message(id = 24125, direction = "server", name = "CancelMatchmakeDoneMessage")]
pub struct CancelMatchmakeDoneMessage {}
#[derive(Debug, Default, Message)]
#[message(id = 14105, direction = "client", name = "HomeLogicStoppedMessage")]
#[codec(partial)]
pub struct HomeLogicStoppedMessage {
#[codec(vint)]
pub tick: i32,
#[codec(vint)]
pub checksum: i32,
pub commands: Vec<Box<dyn LogicCommand>>,
}

View file

@ -8,6 +8,7 @@ mod keep_alive;
mod login; mod login;
mod login_failed; mod login_failed;
mod login_ok; mod login_ok;
mod matchmake;
mod out_of_sync; mod out_of_sync;
mod own_home_data; mod own_home_data;
mod sector_state; mod sector_state;
@ -28,6 +29,7 @@ pub use keep_alive::{KeepAliveMessage, KeepAliveServerMessage};
pub use login::LoginMessage; pub use login::LoginMessage;
pub use login_failed::{LoginFailedMessage, LoginFailureReason}; pub use login_failed::{LoginFailedMessage, LoginFailureReason};
pub use login_ok::LoginOkMessage; pub use login_ok::LoginOkMessage;
pub use matchmake::{CancelMatchmakeDoneMessage, HomeLogicStoppedMessage, StopHomeLogicMessage};
pub use out_of_sync::OutOfSyncMessage; pub use out_of_sync::OutOfSyncMessage;
pub use own_home_data::OwnHomeDataMessage; pub use own_home_data::OwnHomeDataMessage;
pub use sector_state::SectorStateMessage; pub use sector_state::SectorStateMessage;
@ -45,6 +47,8 @@ pub mod message_type {
pub const GO_HOME: u16 = 14101; pub const GO_HOME: u16 = 14101;
pub const END_CLIENT_TURN: u16 = 14102; pub const END_CLIENT_TURN: u16 = 14102;
pub const START_MISSION: u16 = 14104; pub const START_MISSION: u16 = 14104;
pub const HOME_LOGIC_STOPPED: u16 = 14105;
pub const CANCEL_MATCHMAKE: u16 = 14107;
pub const SERVER_HELLO: u16 = 20100; pub const SERVER_HELLO: u16 = 20100;
pub const LOGIN_FAILED: u16 = 20103; pub const LOGIN_FAILED: u16 = 20103;
pub const LOGIN_OK: u16 = 20104; pub const LOGIN_OK: u16 = 20104;

View file

@ -48,6 +48,11 @@ pub enum GameRequest {
#[serde(with = "crate::base64::serde_bytes")] #[serde(with = "crate::base64::serde_bytes")]
payload: Vec<u8>, payload: Vec<u8>,
}, },
HomeLogicStopped {
account: AccountRef,
#[serde(with = "crate::base64::serde_bytes")]
payload: Vec<u8>,
},
Disconnect { Disconnect {
account: AccountRef, account: AccountRef,
}, },
@ -82,6 +87,11 @@ pub trait GameApi: Send + Sync + 'static {
account: AccountRef, account: AccountRef,
payload: Vec<u8>, payload: Vec<u8>,
) -> RpcResult<Vec<WireMessage>>; ) -> RpcResult<Vec<WireMessage>>;
async fn home_logic_stopped(
&self,
account: AccountRef,
payload: Vec<u8>,
) -> RpcResult<Vec<WireMessage>>;
async fn end_client_turn( async fn end_client_turn(
&self, &self,
account: AccountRef, account: AccountRef,