encode the movement component and push snapshots

a character with Speed gets a LogicMovementComponent on the client, and
until now we had no encoder for it, so any snapshot carrying a unit
would have desynced. the layout is four booleans, a path length, that
many path nodes, and eighteen more vints - one conditional, no data
driven ones. charge time defaults to the -1 the client uses when
ChargeRange is empty, which is every card but the Prince.

with that in place the session pushes a fresh SectorStateMessage once a
second down the battle ticker. every push runs through verify_snapshot
first and is dropped rather than sent if it does not read back, the same
guard that caught the decks.

the verifier learned the movement pass, and lost the leftover
SCROLL_BATTLE_SUMMONER switch that the builder had already shed.
This commit is contained in:
WiseDev 2026-08-23 13:14:23 +03:00
parent 054321ad50
commit 3bb923cd17
5 changed files with 136 additions and 15 deletions

View file

@ -2,7 +2,8 @@ use std::path::{Path, PathBuf};
use logic::battle::{
LogicBattle, LogicCharacter, LogicCharacterBuffComponent, LogicCombatComponent, LogicComponent,
LogicGameMode, LogicGameObject, LogicGameObjectEntry, LogicGameObjectManager,
LogicGameObjectRef, LogicHitpointComponent, LogicObjectBody, LogicSummoner, LogicSummonerDeck,
LogicGameObjectRef, LogicHitpointComponent, LogicMovementComponent, LogicObjectBody,
LogicSummoner, LogicSummonerDeck,
LogicTilemap,
LogicTime, LogicVector2, BATTLE_TYPE_NPC, CHARACTER_OBJECT_TYPE, COMPONENT_PASSES,
DIRECTION_BOTTOM,
@ -92,10 +93,10 @@ impl BattleBuilder {
.filter(|value| *value > 0)
.unwrap_or(1)
}
fn components_for(&self, hitpoints: i32) -> [Option<LogicComponent>; COMPONENT_PASSES] {
fn components_for(&self, hitpoints: i32, moves: bool) -> [Option<LogicComponent>; COMPONENT_PASSES] {
[
Some(LogicComponent::Combat(LogicCombatComponent::default())),
None,
moves.then(|| LogicComponent::Movement(LogicMovementComponent::default())),
Some(LogicComponent::Hitpoint(LogicHitpointComponent::healthy(
hitpoints,
))),
@ -113,6 +114,10 @@ impl BattleBuilder {
level_index,
} = spec;
let hitpoints = Self::hitpoints_of(&data, level_index);
let moves = data
.data()
.map(|row| row.int(CHARACTER_SPEED_COLUMN) > 0)
.unwrap_or(false);
let character = LogicCharacter {
level_index,
base: LogicGameObject {
@ -130,7 +135,7 @@ impl BattleBuilder {
),
..LogicCharacter::default()
};
let components = self.components_for(hitpoints);
let components = self.components_for(hitpoints, moves);
LogicGameObjectEntry::new(
data,
LogicGameObjectRef::of(CHARACTER_OBJECT_TYPE + 1, instance),
@ -215,7 +220,7 @@ impl BattleBuilder {
data,
LogicGameObjectRef::of(CHARACTER_OBJECT_TYPE + 1, instance),
body,
self.components_for(hitpoints),
self.components_for(hitpoints, false),
)
}
pub fn build(

View file

@ -1,7 +1,9 @@
use std::collections::HashMap;
pub const SNAPSHOT_INTERVAL_TICKS: i32 = 20;
use std::time::Instant;
use logic::battle::{LogicGameMode, LogicGameObjectEntry, BATTLE_TICKS_PER_SECOND};
use logic::battle::LogicBattleEvent;
use logic::battle::{verify_snapshot, LogicBattleEvent};
use logic::SectorStateMessage;
use logic::{BattleEventMessage, LogicDataRef, LogicRandom};
use titan::LogicLong;
use service_rpc::{AccountRef, WireMessage};
@ -15,6 +17,7 @@ pub struct BattleSession {
random: LogicRandom,
taunts: Vec<LogicDataRef>,
next_bot_emote: i32,
next_snapshot: i32,
}
impl BattleSession {
pub fn new(mode: LogicGameMode, taunts: Vec<LogicDataRef>) -> Self {
@ -29,8 +32,23 @@ impl BattleSession {
random,
taunts,
next_bot_emote,
next_snapshot: SNAPSHOT_INTERVAL_TICKS,
}
}
fn snapshot_message(&mut self) -> Option<WireMessage> {
let snapshot = self.mode.snapshot().ok()?;
let report = verify_snapshot(&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));
@ -93,6 +111,12 @@ impl BattleSession {
self.tick += 1;
self.release_queued(self.tick);
self.mode.battle.tick();
if self.tick >= self.next_snapshot {
self.next_snapshot = self.tick + SNAPSHOT_INTERVAL_TICKS;
if let Some(message) = self.snapshot_message() {
self.outbound.push(message);
}
}
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() {

View file

@ -1,4 +1,5 @@
use titan::{ByteStreamWriter, Payload, Result};
use crate::battle::logic_game_object::LogicVector2;
use crate::battle::logic_game_object_ref::LogicGameObjectRef;
pub const COMPONENT_COMBAT: usize = 0;
pub const COMPONENT_MOVEMENT: usize = 1;
@ -41,6 +42,86 @@ impl LogicCombatComponent {
Ok(())
}
}
pub const CHARGE_TIME_DISABLED: i32 = -1;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogicMovementComponent {
pub is_pushed_back: bool,
pub is_stopped: bool,
pub is_panicking: bool,
pub path_node_reached: bool,
pub path: Vec<i32>,
pub collision_push_count: i32,
pub collision_push_sum_x: i32,
pub collision_push_sum_y: i32,
pub panic_timer: i32,
pub panic_target_x: i32,
pub panic_target_y: i32,
pub panic_origin_x: i32,
pub panic_origin_y: i32,
pub charge_time: i32,
pub pushback_target: LogicVector2,
pub avoidance_side_step: i32,
pub path_target_normal: LogicVector2,
pub pushback_speed: i32,
pub move_timer: i32,
pub jump_distance: i32,
}
impl Default for LogicMovementComponent {
fn default() -> Self {
Self {
is_pushed_back: false,
is_stopped: false,
is_panicking: false,
path_node_reached: false,
path: Vec::new(),
collision_push_count: 0,
collision_push_sum_x: 0,
collision_push_sum_y: 0,
panic_timer: 0,
panic_target_x: 0,
panic_target_y: 0,
panic_origin_x: 0,
panic_origin_y: 0,
charge_time: CHARGE_TIME_DISABLED,
pushback_target: LogicVector2::default(),
avoidance_side_step: 0,
path_target_normal: LogicVector2::default(),
pushback_speed: 0,
move_timer: 0,
jump_distance: 0,
}
}
}
impl LogicMovementComponent {
pub fn encode(&self, writer: &mut ByteStreamWriter) -> Result<()> {
writer.write_boolean(self.is_pushed_back);
writer.write_boolean(self.is_stopped);
writer.write_boolean(self.is_panicking);
writer.write_boolean(self.path_node_reached);
writer.write_vint(self.path.len() as i32);
for node in &self.path {
writer.write_vint(*node);
}
writer.write_vint(self.collision_push_count);
writer.write_vint(self.collision_push_sum_x);
writer.write_vint(self.collision_push_sum_y);
writer.write_vint(self.panic_timer);
writer.write_vint(self.panic_target_x);
writer.write_vint(self.panic_target_y);
writer.write_vint(self.panic_origin_x);
writer.write_vint(self.panic_origin_y);
writer.write_vint(self.charge_time);
writer.write_vint(self.pushback_target.x);
writer.write_vint(self.pushback_target.y);
writer.write_vint(self.avoidance_side_step);
writer.write_vint(self.path_target_normal.x);
writer.write_vint(self.path_target_normal.y);
writer.write_vint(self.pushback_speed);
writer.write_vint(self.move_timer);
writer.write_vint(self.jump_distance);
Ok(())
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct LogicHitpointComponent {
pub hitpoints: i32,
@ -114,6 +195,7 @@ impl LogicCharacterBuffComponent {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LogicComponent {
Combat(LogicCombatComponent),
Movement(LogicMovementComponent),
Hitpoint(LogicHitpointComponent),
Buff(LogicCharacterBuffComponent),
}
@ -121,6 +203,7 @@ impl LogicComponent {
pub fn encode(&self, writer: &mut ByteStreamWriter) -> Result<()> {
match self {
LogicComponent::Combat(component) => component.encode(writer),
LogicComponent::Movement(component) => component.encode(writer),
LogicComponent::Hitpoint(component) => component.encode(writer),
LogicComponent::Buff(component) => component.encode(writer),
}

View file

@ -19,6 +19,7 @@ pub use logic_character::{
LogicCharacter, LogicSummoner, LogicSummonerDeck, DEFAULT_SIZE, DIRECTION_BOTTOM, DIRECTION_TOP,
};
pub use logic_component::{
LogicMovementComponent,
LogicCharacterBuffComponent, LogicCombatComponent, LogicComponent, LogicHitpointComponent,
COMPONENT_BUFF, COMPONENT_COMBAT, COMPONENT_HITPOINT, COMPONENT_MOVEMENT,
};

View file

@ -3,6 +3,7 @@ use crate::battle::logic_game_mode::{SECTION_BATTLE, SECTION_TUTORIAL};
use crate::data::{table, LogicDataRef, LogicDataTables};
use crate::model::DECK_SLOT_COUNT;
pub const BUFF_ARRAY_TABLE: i32 = table::DAMAGE_TYPES;
pub const MOVEMENT_SPEED_COLUMN: &str = "Speed";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapshotReport {
pub steps: Vec<(String, usize)>,
@ -91,6 +92,15 @@ impl<'a, 'b> Verifier<'a, 'b> {
self.vints(4)?;
Ok(())
}
fn movement(&mut self) -> Result<()> {
for _ in 0..4 {
self.reader.read_boolean()?;
}
let path = self.reader.read_vint()?.max(0) as usize;
self.vints(path)?;
self.vints(18)?;
Ok(())
}
fn combat(&mut self) -> Result<()> {
self.reader.read_boolean()?;
self.reader.read_boolean()?;
@ -201,16 +211,9 @@ impl<'a, 'b> Verifier<'a, 'b> {
for _ in 0..count {
self.global_id()?;
}
let summoner_bodies = !matches!(
std::env::var("SCROLL_BATTLE_SUMMONER")
.unwrap_or_default()
.as_str(),
"0" | "false" | "off"
);
let summoner = LogicDataTables::instance()
.data_by_name(table::CHARACTERS_COMBINED, "KingTower")
.map(|row| row.global_id())
.filter(|_| summoner_bodies);
.map(|row| row.global_id());
self.mark("object_bodies");
for entry in &data {
let is_summoner = entry.global_id().is_some() && entry.global_id() == summoner;
@ -227,9 +230,14 @@ impl<'a, 'b> Verifier<'a, 'b> {
.unwrap_or(0);
self.mark("components");
for pass in 0..4 {
for _ in &data {
for entry in &data {
let moves = entry
.data()
.map(|row| row.int(MOVEMENT_SPEED_COLUMN) > 0)
.unwrap_or(false);
match pass {
0 => self.combat()?,
1 if moves => self.movement()?,
2 => self.hitpoint()?,
3 => self.buff(buff_rows)?,
_ => {}