scroll.server/crates/game-service/src/battle_session.rs
WiseDev 9230a212ef keep the path instead of finding it again every tick
the tick was running A* over the whole 36x64 grid for every unit, every
tick. catching up ten seconds meant thousands of searches inside the
session lock, so the tick loop never finished, snapshots never went out,
and the client - which refuses to send a command while
isFullUpdatePending is true - sat there showing the connection icon and
would not spawn anything. ctrl-c looked like a hang for the same reason:
a task stuck in that loop.

the client does not do this either. LogicMovementComponent carries a
path array precisely so the route is found once and walked. we keep the
route and the goal it was found for, drop a node once we are within
250 units of it, and only search again when the goal moves or the route
runs out.

advance_to also refuses to simulate more than forty ticks in one call,
so a late tick can never turn into an unbounded loop under the lock.
2026-08-23 14:03:26 +03:00

391 lines
14 KiB
Rust

use std::collections::HashMap;
use std::sync::Arc;
pub const SNAPSHOT_INTERVAL_TICKS: i32 = 20;
pub const MAX_CATCH_UP_TICKS: i32 = 40;
use std::time::Instant;
use logic::battle::{
LogicGameMode, LogicGameObjectEntry, LogicVector2, BATTLE_TICKS_PER_SECOND, BATTLE_TYPE_PVP,
};
use logic::battle::{verify_snapshot, LogicBattleEvent};
use logic::SectorStateMessage;
use logic::{BattleEventMessage, LogicDataRef, LogicRandom};
use titan::LogicLong;
use service_rpc::{AccountRef, WireMessage};
use tokio::sync::Mutex;
pub struct BattleSession {
mode: LogicGameMode,
tick: i32,
queued: Vec<(i32, Vec<LogicGameObjectEntry>)>,
outbound: Vec<WireMessage>,
started_at: Instant,
random: LogicRandom,
taunts: Vec<LogicDataRef>,
next_bot_emote: i32,
next_snapshot: i32,
next_bot_play: i32,
pub pushed_snapshots: u32,
pending_bot_play: Option<(LogicDataRef, LogicVector2, i32)>,
}
impl BattleSession {
pub fn new(mode: LogicGameMode, taunts: Vec<LogicDataRef>) -> 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,
next_snapshot: SNAPSHOT_INTERVAL_TICKS,
next_bot_play: 0,
pushed_snapshots: 0,
pending_bot_play: None,
}
}
pub fn take_bot_play(&mut self) -> Option<(LogicDataRef, LogicVector2, i32)> {
self.pending_bot_play.take()
}
pub fn bot_play(&mut self) -> Option<(LogicDataRef, LogicVector2, i32)> {
let deck = self.mode.battle.decks.get(1)?.as_ref()?;
let filled: Vec<LogicDataRef> = deck
.slots
.iter()
.flatten()
.map(|spell| spell.data.clone())
.collect();
if filled.is_empty() {
return None;
}
let card = filled
.get(self.random.next(filled.len() as i32).max(0) as usize)?
.clone();
let towers: Vec<(i32, i32)> = self
.mode
.battle
.objects
.objects
.iter()
.filter(|entry| entry.owner_index() == crate::bot::BOT_OWNER_INDEX)
.map(|entry| entry.position())
.collect();
let leader = self.mode.battle.leader(crate::bot::BOT_OWNER_INDEX as usize)?;
let (king_x, king_y) = leader.position();
let ahead = towers
.iter()
.map(|(_, y)| *y)
.fold(king_y, |best, y| if (y - king_y).abs() > (best - king_y).abs() { y } else { best });
let lane_x = towers
.iter()
.filter(|(_, y)| *y == ahead)
.map(|(x, _)| *x)
.collect::<Vec<_>>();
let x = lane_x
.get(self.random.next(lane_x.len().max(1) as i32).max(0) as usize)
.copied()
.unwrap_or(king_x);
Some((card, LogicVector2::new(x, ahead), self.next_instance()))
}
pub fn pushes_snapshots(&self) -> bool {
self.mode.battle.battle_type == BATTLE_TYPE_PVP
}
pub fn mana(&self) -> Option<i32> {
self.mode.battle.objects.objects.iter().find_map(|entry| {
match &entry.body {
logic::battle::LogicObjectBody::Summoner(summoner)
if entry.owner_index() == 0 =>
{
Some(summoner.mana)
}
_ => None,
}
})
}
fn snapshot_message(&mut self) -> Option<WireMessage> {
let snapshot = self.mode.snapshot().ok()?;
let report = verify_snapshot(&snapshot);
tracing::debug!(
tick = self.tick,
bytes = snapshot.len(),
ok = report.is_ok(),
"built a battle snapshot"
);
if !report.is_ok() {
tracing::error!(
bytes = snapshot.len(),
trailing = report.trailing,
error = report.error.as_deref().unwrap_or("-"),
"the battle snapshot does not read back, not pushing it"
);
return None;
}
crate::wire::encode(&SectorStateMessage::new(snapshot)).ok()
}
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<LogicBattleEvent> {
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<WireMessage> {
std::mem::take(&mut self.outbound)
}
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 queue(&mut self, at_tick: i32, entries: Vec<LogicGameObjectEntry>) {
if entries.is_empty() {
return;
}
self.queued.push((at_tick.max(self.tick + 1), entries));
}
pub fn advance_to(&mut self, tick: i32) {
let tick = tick.min(self.tick + MAX_CATCH_UP_TICKS);
while self.tick < tick {
self.tick += 1;
self.mode.time.tick = self.tick;
self.release_queued(self.tick);
self.mode.battle.tick(self.tick);
if self.pushes_snapshots() && self.tick >= self.next_snapshot {
self.next_snapshot = self.tick + SNAPSHOT_INTERVAL_TICKS;
if let Some(message) = self.snapshot_message() {
self.pushed_snapshots += 1;
self.outbound.push(message);
}
}
if self.tick >= self.next_bot_play {
let span = crate::bot::BOT_PLAY_MAX_SECONDS - crate::bot::BOT_PLAY_MIN_SECONDS;
let wait = crate::bot::BOT_PLAY_MIN_SECONDS + self.random.next(span.max(1));
self.next_bot_play = self.tick + wait * BATTLE_TICKS_PER_SECOND;
if let Some((card, position, instance)) = self.bot_play() {
self.pending_bot_play = Some((card, position, instance));
}
}
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);
}
}
}
}
}
fn release_queued(&mut self, tick: i32) {
let mut due = Vec::new();
self.queued.retain(|(at, entries)| {
if *at <= tick {
due.extend(entries.iter().cloned());
false
} else {
true
}
});
for entry in due {
self.mode.battle.objects.push(entry);
}
}
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))
}
pub fn checksum(&self) -> Option<i32> {
self.mode.calculate_checksum().ok()
}
pub fn towers_standing(&self) -> (usize, usize) {
(
self.mode.battle.leader_towers[0].len(),
self.mode.battle.leader_towers[1].len(),
)
}
pub fn owner_of(&self, account: LogicLong) -> Option<i32> {
self.mode
.battle
.account_ids
.iter()
.position(|id| *id == account)
.map(|index| index as i32)
}
pub fn next_instance(&self) -> i32 {
self.mode
.battle
.objects
.objects
.iter()
.filter_map(|entry| entry.global_id.0.map(|id| id.instance_id))
.max()
.map(|last| last + 1)
.unwrap_or(0)
}
pub fn deck_card(&self, owner: i32, slot: i32) -> Option<LogicDataRef> {
let deck = self.mode.battle.decks.get(owner.max(0) as usize)?.as_ref()?;
let spell = deck.slots.get(slot.max(0) as usize)?.as_ref()?;
Some(spell.data.clone())
}
pub fn push(&mut self, entry: LogicGameObjectEntry) {
self.mode.battle.objects.push(entry);
}
pub fn object_count(&self) -> usize {
self.mode.battle.objects.objects.len()
}
}
#[derive(Default)]
pub struct BattleRegistry {
sessions: Mutex<HashMap<AccountRef, Arc<Mutex<BattleSession>>>>,
}
impl BattleRegistry {
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));
}
}
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 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);
if !entries.is_empty() {
tracing::debug!(card = %card, count = entries.len(), "the bot played a card");
for entry in entries {
session.push(entry);
}
}
}
session.take_outbound()
}
pub async fn answer_emote(&self, account: AccountRef) {
let Some(handle) = self.session(account).await else {
return;
};
let mut session = handle.lock().await;
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(handle) = self.session(account).await {
handle.lock().await.push_outbound(message);
}
}
pub async fn advance(&self, account: AccountRef, tick: i32) -> Option<BattleProgress> {
let handle = self.session(account).await?;
let mut session = handle.lock().await;
session.advance_to(tick);
Some(BattleProgress {
seconds: session.seconds(),
finished: session.is_finished(),
stars: session.stars(),
objects: session.object_count(),
towers: session.towers_standing(),
checksum: session.checksum(),
mana: session.mana(),
pushed: session.pushed_snapshots,
})
}
pub async fn play<F>(
&self,
account: AccountRef,
executor: LogicLong,
slot: i32,
position: logic::battle::LogicVector2,
at_tick: i32,
summon: F,
) -> usize
where
F: Fn(&LogicDataRef, logic::battle::LogicVector2, i32, i32) -> Vec<LogicGameObjectEntry>,
{
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;
};
let Some(card) = session.deck_card(owner, slot) else {
return 0;
};
let entries = summon(&card, position, owner, session.next_instance());
let spawned = entries.len();
session.queue(at_tick, entries);
spawned
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BattleProgress {
pub seconds: i32,
pub finished: bool,
pub stars: (i32, i32),
pub objects: usize,
pub towers: (usize, usize),
pub checksum: Option<i32>,
pub mana: Option<i32>,
pub pushed: u32,
}