scroll.server/crates/game-service/src/battle_session.rs
WiseDev f072c16244 make the snapshot interval settable to test the model theory
two results this round, both negative and both useful.

the account ids are right end to end, so isOwnedByBottomPlayer picks the
branch it should. and every character row carries the export names it
needs - knight, goblin and barbarian all have a filename, a blue and a
red prefix and UseAnimator set - so neither "empty prefix" nor "wrong
row" survives. Debugger::error is __noreturn, and the client does not
abort, so Character::Character is not failing to build animations
either: reached, it works.

with the checksum agreeing byte for byte, the objects in the client are
identical to ours. that leaves only the sprite never being made, or
being made and torn down again - and every push re-runs decode, which
destroys whatever it cannot match. five times a second nothing would
ever be seen. SCROLL_SNAPSHOT_INTERVAL_TICKS widens the gap so one run
can tell.
2026-08-23 16:34:07 +03:00

485 lines
17 KiB
Rust

use std::collections::HashMap;
use std::sync::Arc;
pub const SNAPSHOT_INTERVAL_TICKS: i32 = 4;
pub fn snapshot_interval_ticks() -> i32 {
std::env::var("SCROLL_SNAPSHOT_INTERVAL_TICKS")
.ok()
.and_then(|value| value.parse::<i32>().ok())
.filter(|ticks| *ticks > 0)
.unwrap_or(SNAPSHOT_INTERVAL_TICKS)
}
pub const MAX_CATCH_UP_TICKS: i32 = 40;
use std::time::Instant;
use logic::battle::{
LogicGameMode, LogicGameObjectEntry, LogicGameObjectRef, LogicVector2,
BATTLE_TICKS_PER_SECOND, BATTLE_TYPE_PVP, CHARACTER_OBJECT_TYPE,
};
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,
next_instance: i32,
announced: bool,
pending_bot_play: Option<(LogicDataRef, LogicVector2, i32)>,
}
impl BattleSession {
pub fn new(mode: LogicGameMode, taunts: Vec<LogicDataRef>) -> Self {
let first_instance = 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);
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: crate::bot::BOT_OPENING_DELAY_SECONDS * BATTLE_TICKS_PER_SECOND,
pushed_snapshots: 0,
next_instance: first_instance,
announced: false,
pending_bot_play: None,
}
}
fn reannounce(&mut self) {
let leaders = self.mode.battle.leaders;
let mut remap: Vec<(LogicGameObjectRef, LogicGameObjectRef)> = Vec::new();
for entry in self.mode.battle.objects.objects.iter_mut() {
if leaders.contains(&entry.global_id) {
continue;
}
let fresh = LogicGameObjectRef::of(CHARACTER_OBJECT_TYPE + 1, self.next_instance);
self.next_instance += 1;
remap.push((entry.global_id, fresh));
entry.global_id = fresh;
}
for towers in self.mode.battle.leader_towers.iter_mut() {
for tower in towers.iter_mut() {
if let Some((_, now)) = remap.iter().find(|(was, _)| was == tower) {
*tower = *now;
}
}
}
self.mode
.battle
.objects
.objects
.sort_by_key(|entry| entry.global_id.0.map(|id| (id.class_id, id.instance_id)));
}
pub fn announce(&mut self) -> bool {
if self.announced {
return false;
}
self.announced = true;
self.reannounce();
self.next_snapshot = self.tick;
true
}
pub fn spend_mana(&mut self, owner: i32, cost: i32) {
for entry in self.mode.battle.objects.objects.iter_mut() {
if entry.owner_index() != owner {
continue;
}
if let logic::battle::LogicObjectBody::Summoner(summoner) = &mut entry.body {
summoner.mana = (summoner.mana - cost).max(0);
return;
}
}
}
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,
}
})
}
pub fn snapshot_message(&mut self) -> Option<WireMessage> {
let snapshot = self.mode.snapshot().ok()?;
let report = verify_snapshot(&snapshot);
let unit = self
.mode
.battle
.objects
.objects
.iter()
.find(|entry| entry.stats().speed > 0)
.map(|entry| (entry.position(), entry.owner_index()));
tracing::debug!(
tick = self.tick,
bytes = snapshot.len(),
ok = report.is_ok(),
unit = ?unit,
"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 {
tracing::info!(
tick,
global_id = ?entry.global_id.0,
data = %entry.data,
owner = entry.owner_index(),
position = ?entry.position(),
"spawning"
);
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.next_instance
}
pub fn reserve_instances(&mut self, count: usize) {
self.next_instance += count.max(1) as i32;
}
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);
session.reserve_instances(entries.len());
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 resend(&self, account: AccountRef) -> Option<WireMessage> {
let handle = self.session(account).await?;
let mut session = handle.lock().await;
session.snapshot_message()
}
pub async fn announce_once(&self, account: AccountRef) -> bool {
let Some(handle) = self.session(account).await else {
return false;
};
let mut session = handle.lock().await;
session.announce()
}
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 cost = card.data().map(|row| row.int("ManaCost")).unwrap_or(0);
session.spend_mana(owner, cost);
let entries = summon(&card, position, owner, session.next_instance());
session.reserve_instances(entries.len());
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,
}