give the server its own battle clock and end condition

LogicBattle::isEndConditionMatched, transcribed: the battle is over when
end_counter is positive, when either king is dead, when tick/20 seconds
reach MatchLength + OvertimeSeconds, or - once past MatchLength - when
the crowns differ. the divisor is the 0x66666667/2^35 multiply in the
client, which is a divide by twenty, so the battle runs at 20 ticks a
second.

crowns come from LogicSummoner::getStars: three when the enemy king is
down, otherwise two minus the enemy princess towers still standing,
which is exactly what leader_towers holds.

BattleRegistry keeps the LogicGameMode we built for each account and
advances it on the tick the client reports in its turn message, so the
server now tracks the clock, the crowns and whether the battle is over,
and drops the session when the player goes home. the two isSummoner
guards in the overtime branch are left out: they only fire for an object
that is not a summoner, which a king tower always is.
This commit is contained in:
WiseDev 2026-08-23 12:01:26 +03:00
parent a8dcd683b2
commit 7a0ba1b652
6 changed files with 165 additions and 4 deletions

View file

@ -0,0 +1,65 @@
use std::collections::HashMap;
use logic::battle::{LogicGameMode, BATTLE_TICKS_PER_SECOND};
use service_rpc::AccountRef;
use tokio::sync::Mutex;
pub struct BattleSession {
mode: LogicGameMode,
tick: i32,
}
impl BattleSession {
pub fn new(mode: LogicGameMode) -> Self {
Self { mode, tick: 0 }
}
pub fn mode(&self) -> &LogicGameMode {
&self.mode
}
pub fn tick(&self) -> i32 {
self.tick
}
pub fn seconds(&self) -> i32 {
self.tick / BATTLE_TICKS_PER_SECOND
}
pub fn advance_to(&mut self, tick: i32) {
if tick > self.tick {
self.tick = tick;
self.mode.time.tick = tick;
}
}
pub fn is_finished(&self) -> bool {
self.mode.battle.is_end_condition_matched(self.tick)
}
pub fn stars(&self) -> (i32, i32) {
(self.mode.battle.stars(0), self.mode.battle.stars(1))
}
}
#[derive(Default)]
pub struct BattleRegistry {
sessions: Mutex<HashMap<AccountRef, BattleSession>>,
}
impl BattleRegistry {
pub async fn start(&self, account: AccountRef, mode: LogicGameMode) {
self.sessions
.lock()
.await
.insert(account, BattleSession::new(mode));
}
pub async fn finish(&self, account: AccountRef) -> Option<BattleSession> {
self.sessions.lock().await.remove(&account)
}
pub async fn advance(&self, account: AccountRef, tick: i32) -> Option<BattleProgress> {
let mut sessions = self.sessions.lock().await;
let session = sessions.get_mut(&account)?;
session.advance_to(tick);
Some(BattleProgress {
seconds: session.seconds(),
finished: session.is_finished(),
stars: session.stars(),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BattleProgress {
pub seconds: i32,
pub finished: bool,
pub stars: (i32, i32),
}

View file

@ -1,4 +1,5 @@
pub mod battle; pub mod battle;
pub mod battle_session;
pub mod bot; pub mod bot;
pub mod catalog; pub mod catalog;
pub mod config; pub mod config;
@ -10,6 +11,7 @@ pub mod shop;
pub mod store; pub mod store;
pub mod time; pub mod time;
pub use battle::BattleBuilder; pub use battle::BattleBuilder;
pub use battle_session::{BattleProgress, BattleRegistry, BattleSession};
pub use bot::BotPlayer; pub use bot::BotPlayer;
pub use catalog::{Catalog, ARENA_FALLBACK_INSTANCE, GOLD_RESOURCE_FALLBACK_INSTANCE}; pub use catalog::{Catalog, ARENA_FALLBACK_INSTANCE, GOLD_RESOURCE_FALLBACK_INSTANCE};
pub use config::{CardRef, DataSelector, GameConfig, StarterProfile}; pub use config::{CardRef, DataSelector, GameConfig, StarterProfile};

View file

@ -11,6 +11,7 @@ use service_rpc::{
}; };
use titan::{Message, MessageMeta, Payload}; use titan::{Message, MessageMeta, Payload};
use crate::battle::BattleBuilder; use crate::battle::BattleBuilder;
use crate::battle_session::BattleRegistry;
use crate::catalog::Catalog; use crate::catalog::Catalog;
use crate::config::GameConfig; use crate::config::GameConfig;
use crate::bot::BotPlayer; use crate::bot::BotPlayer;
@ -33,6 +34,7 @@ pub struct GameService {
catalog: Arc<Catalog>, catalog: Arc<Catalog>,
shop: Arc<ShopCatalog>, shop: Arc<ShopCatalog>,
battles: Arc<BattleBuilder>, battles: Arc<BattleBuilder>,
running_battles: BattleRegistry,
sessions: HomeModeRegistry, sessions: HomeModeRegistry,
} }
impl GameService { impl GameService {
@ -63,6 +65,7 @@ impl GameService {
catalog, catalog,
shop, shop,
battles, battles,
running_battles: BattleRegistry::default(),
sessions: HomeModeRegistry::default(), sessions: HomeModeRegistry::default(),
})) }))
} }
@ -150,6 +153,7 @@ impl GameService {
format!("{} trailing byte(s)", report.trailing) format!("{} trailing byte(s)", report.trailing)
}))); })));
} }
self.running_battles.start(account, battle).await;
Ok(vec![encode(&SectorStateMessage::new(snapshot))?]) Ok(vec![encode(&SectorStateMessage::new(snapshot))?])
} }
async fn roller(&self, account: AccountRef) -> RpcResult<RewardRoller> { async fn roller(&self, account: AccountRef) -> RpcResult<RewardRoller> {
@ -197,6 +201,7 @@ impl GameApi for GameService {
.sessions .sessions
.get_or_open(account, &profile, unix_seconds() as i32) .get_or_open(account, &profile, unix_seconds() as i32)
.await; .await;
let mut came_back_from_battle = false;
let (home_data, checksum, cycle, on_sale): ( let (home_data, checksum, cycle, on_sale): (
OwnHomeDataMessage, OwnHomeDataMessage,
i32, i32,
@ -205,8 +210,8 @@ impl GameApi for GameService {
) = { ) = {
let mut home = session.lock().await; let mut home = session.lock().await;
if kind == HomeRequestKind::GoHome && home.is_home_logic_stopped() { if kind == HomeRequestKind::GoHome && home.is_home_logic_stopped() {
tracing::info!(%account, "client came back from the battle, resuming home logic");
home.resume_home_logic(); home.resume_home_logic();
came_back_from_battle = true;
} }
( (
home.own_home_data(self.config.random_seed), home.own_home_data(self.config.random_seed),
@ -219,6 +224,16 @@ impl GameApi for GameService {
.collect(), .collect(),
) )
}; };
if came_back_from_battle {
let battle = self.running_battles.finish(account).await;
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()),
"client came back from the battle, resuming home logic"
);
}
tracing::info!( tracing::info!(
%account, %account,
?kind, ?kind,
@ -291,10 +306,13 @@ impl GameApi for GameService {
(home.end_client_turn(&turn, &roller, &self.shop), in_battle) (home.end_client_turn(&turn, &roller, &self.shop), in_battle)
}; };
if in_battle { if in_battle {
let progress = self.running_battles.advance(account, turn.tick).await;
tracing::info!( tracing::info!(
%account, %account,
tick = turn.tick, tick = turn.tick,
checksum = turn.checksum, seconds = progress.map(|state| state.seconds),
finished = progress.map(|state| state.finished),
stars = ?progress.map(|state| state.stars),
commands = ?turn commands = ?turn
.commands .commands
.iter() .iter()

View file

@ -1,4 +1,5 @@
use titan::{ByteStreamReader, ByteStreamWriter, LogicLong, Payload, Result}; use titan::{ByteStreamReader, ByteStreamWriter, LogicLong, Payload, Result};
use crate::battle::logic_game_object::LogicGameObjectEntry;
use crate::battle::logic_game_object_manager::LogicGameObjectManager; use crate::battle::logic_game_object_manager::LogicGameObjectManager;
use crate::battle::logic_game_object_ref::LogicGameObjectRef; use crate::battle::logic_game_object_ref::LogicGameObjectRef;
use crate::data::LogicDataRef; use crate::data::LogicDataRef;
@ -7,6 +8,11 @@ pub const BATTLE_TYPE_PVP: i32 = 0;
pub const BATTLE_TYPE_NPC: i32 = 1; pub const BATTLE_TYPE_NPC: i32 = 1;
pub const BATTLE_TYPE_REPLAY: i32 = 3; pub const BATTLE_TYPE_REPLAY: i32 = 3;
pub const BATTLE_INT_ARRAY: usize = 8; pub const BATTLE_INT_ARRAY: usize = 8;
pub const BATTLE_TICKS_PER_SECOND: i32 = 20;
pub const LEADER_TOWER_COUNT: i32 = 2;
pub const CROWNS_FOR_LEADER: i32 = 3;
pub const LOCATION_MATCH_LENGTH_COLUMN: &str = "MatchLength";
pub const LOCATION_OVERTIME_COLUMN: &str = "OvertimeSeconds";
pub const BATTLE_TRAILING_INTS: usize = 6; pub const BATTLE_TRAILING_INTS: usize = 6;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogicBattle { pub struct LogicBattle {
@ -67,6 +73,61 @@ impl LogicBattle {
pub fn is_npc_battle(&self) -> bool { pub fn is_npc_battle(&self) -> bool {
self.battle_type == BATTLE_TYPE_NPC && !self.npc.is_none() self.battle_type == BATTLE_TYPE_NPC && !self.npc.is_none()
} }
pub fn match_length_seconds(&self) -> i32 {
self.location
.data()
.map(|row| row.int(LOCATION_MATCH_LENGTH_COLUMN))
.unwrap_or(0)
}
pub fn overtime_length_seconds(&self) -> i32 {
self.location
.data()
.map(|row| row.int(LOCATION_OVERTIME_COLUMN))
.unwrap_or(0)
}
pub fn leader(&self, index: usize) -> Option<&LogicGameObjectEntry> {
let id = self.leaders.get(index)?;
self.objects
.objects
.iter()
.find(|entry| entry.global_id == *id)
}
pub fn is_leader_alive(&self, index: usize) -> bool {
self.leader(index).map(|entry| entry.is_alive()).unwrap_or(false)
}
pub fn stars(&self, index: usize) -> i32 {
let other = 1 - index.min(1);
if !self.is_leader_alive(other) {
return CROWNS_FOR_LEADER;
}
let standing = self
.leader_towers
.get(other)
.map(|towers| towers.len() as i32)
.unwrap_or(0)
.min(LEADER_TOWER_COUNT);
LEADER_TOWER_COUNT - standing
}
pub fn is_end_condition_matched(&self, tick: i32) -> bool {
if self.end_counter > 0 {
return true;
}
if !self.is_leader_alive(0) || !self.is_leader_alive(1) {
return true;
}
let match_length = self.match_length_seconds();
if match_length < 1 {
return false;
}
let seconds = tick / BATTLE_TICKS_PER_SECOND;
if seconds >= match_length + self.overtime_length_seconds() {
return true;
}
if seconds < match_length {
return false;
}
self.stars(0) != self.stars(1)
}
} }
impl Payload for LogicBattle { impl Payload for LogicBattle {
fn encode(&self, writer: &mut ByteStreamWriter) -> Result<()> { fn encode(&self, writer: &mut ByteStreamWriter) -> Result<()> {

View file

@ -58,6 +58,21 @@ pub struct LogicGameObjectEntry {
pub components: [Option<crate::battle::logic_component::LogicComponent>; COMPONENT_PASSES], pub components: [Option<crate::battle::logic_component::LogicComponent>; COMPONENT_PASSES],
} }
impl LogicGameObjectEntry { impl LogicGameObjectEntry {
pub fn hitpoints(&self) -> Option<i32> {
match self
.components
.get(crate::battle::logic_component::COMPONENT_HITPOINT)?
.as_ref()?
{
crate::battle::logic_component::LogicComponent::Hitpoint(component) => {
Some(component.hitpoints)
}
_ => None,
}
}
pub fn is_alive(&self) -> bool {
self.hitpoints().map(|value| value > 0).unwrap_or(false)
}
pub fn new( pub fn new(
data: LogicDataRef, data: LogicDataRef,
global_id: LogicGameObjectRef, global_id: LogicGameObjectRef,

View file

@ -11,8 +11,8 @@ mod logic_time;
mod logic_tutorial_manager; mod logic_tutorial_manager;
mod verify; mod verify;
pub use logic_battle::{ pub use logic_battle::{
LogicBattle, BATTLE_INT_ARRAY, BATTLE_TRAILING_INTS, BATTLE_TYPE_NPC, BATTLE_TYPE_PVP, LogicBattle, BATTLE_INT_ARRAY, BATTLE_TICKS_PER_SECOND, BATTLE_TRAILING_INTS, BATTLE_TYPE_NPC,
BATTLE_TYPE_REPLAY, BATTLE_TYPE_PVP, BATTLE_TYPE_REPLAY, CROWNS_FOR_LEADER, LEADER_TOWER_COUNT,
}; };
pub use logic_character::{ pub use logic_character::{
LogicCharacter, LogicSummoner, LogicSummonerDeck, DEFAULT_SIZE, DIRECTION_BOTTOM, DIRECTION_TOP, LogicCharacter, LogicSummoner, LogicSummonerDeck, DEFAULT_SIZE, DIRECTION_BOTTOM, DIRECTION_TOP,