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

View file

@ -1,7 +1,9 @@
use std::collections::HashMap; use std::collections::HashMap;
pub const SNAPSHOT_INTERVAL_TICKS: i32 = 20;
use std::time::Instant; use std::time::Instant;
use logic::battle::{LogicGameMode, LogicGameObjectEntry, BATTLE_TICKS_PER_SECOND}; 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 logic::{BattleEventMessage, LogicDataRef, LogicRandom};
use titan::LogicLong; use titan::LogicLong;
use service_rpc::{AccountRef, WireMessage}; use service_rpc::{AccountRef, WireMessage};
@ -15,6 +17,7 @@ pub struct BattleSession {
random: LogicRandom, random: LogicRandom,
taunts: Vec<LogicDataRef>, taunts: Vec<LogicDataRef>,
next_bot_emote: i32, next_bot_emote: i32,
next_snapshot: i32,
} }
impl BattleSession { impl BattleSession {
pub fn new(mode: LogicGameMode, taunts: Vec<LogicDataRef>) -> Self { pub fn new(mode: LogicGameMode, taunts: Vec<LogicDataRef>) -> Self {
@ -29,8 +32,23 @@ impl BattleSession {
random, random,
taunts, taunts,
next_bot_emote, 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 { 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 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)); let seconds = crate::bot::BOT_EMOTE_MIN_SECONDS + random.next(span.max(1));
@ -93,6 +111,12 @@ impl BattleSession {
self.tick += 1; self.tick += 1;
self.release_queued(self.tick); self.release_queued(self.tick);
self.mode.battle.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 { if self.tick >= self.next_bot_emote {
self.next_bot_emote = Self::roll_emote_tick(&mut self.random, self.tick); self.next_bot_emote = Self::roll_emote_tick(&mut self.random, self.tick);
if let Some(event) = self.bot_emote() { if let Some(event) = self.bot_emote() {

View file

@ -1,4 +1,5 @@
use titan::{ByteStreamWriter, Payload, Result}; use titan::{ByteStreamWriter, Payload, Result};
use crate::battle::logic_game_object::LogicVector2;
use crate::battle::logic_game_object_ref::LogicGameObjectRef; use crate::battle::logic_game_object_ref::LogicGameObjectRef;
pub const COMPONENT_COMBAT: usize = 0; pub const COMPONENT_COMBAT: usize = 0;
pub const COMPONENT_MOVEMENT: usize = 1; pub const COMPONENT_MOVEMENT: usize = 1;
@ -41,6 +42,86 @@ impl LogicCombatComponent {
Ok(()) 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)] #[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct LogicHitpointComponent { pub struct LogicHitpointComponent {
pub hitpoints: i32, pub hitpoints: i32,
@ -114,6 +195,7 @@ impl LogicCharacterBuffComponent {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum LogicComponent { pub enum LogicComponent {
Combat(LogicCombatComponent), Combat(LogicCombatComponent),
Movement(LogicMovementComponent),
Hitpoint(LogicHitpointComponent), Hitpoint(LogicHitpointComponent),
Buff(LogicCharacterBuffComponent), Buff(LogicCharacterBuffComponent),
} }
@ -121,6 +203,7 @@ impl LogicComponent {
pub fn encode(&self, writer: &mut ByteStreamWriter) -> Result<()> { pub fn encode(&self, writer: &mut ByteStreamWriter) -> Result<()> {
match self { match self {
LogicComponent::Combat(component) => component.encode(writer), LogicComponent::Combat(component) => component.encode(writer),
LogicComponent::Movement(component) => component.encode(writer),
LogicComponent::Hitpoint(component) => component.encode(writer), LogicComponent::Hitpoint(component) => component.encode(writer),
LogicComponent::Buff(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, LogicCharacter, LogicSummoner, LogicSummonerDeck, DEFAULT_SIZE, DIRECTION_BOTTOM, DIRECTION_TOP,
}; };
pub use logic_component::{ pub use logic_component::{
LogicMovementComponent,
LogicCharacterBuffComponent, LogicCombatComponent, LogicComponent, LogicHitpointComponent, LogicCharacterBuffComponent, LogicCombatComponent, LogicComponent, LogicHitpointComponent,
COMPONENT_BUFF, COMPONENT_COMBAT, COMPONENT_HITPOINT, COMPONENT_MOVEMENT, 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::data::{table, LogicDataRef, LogicDataTables};
use crate::model::DECK_SLOT_COUNT; use crate::model::DECK_SLOT_COUNT;
pub const BUFF_ARRAY_TABLE: i32 = table::DAMAGE_TYPES; pub const BUFF_ARRAY_TABLE: i32 = table::DAMAGE_TYPES;
pub const MOVEMENT_SPEED_COLUMN: &str = "Speed";
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapshotReport { pub struct SnapshotReport {
pub steps: Vec<(String, usize)>, pub steps: Vec<(String, usize)>,
@ -91,6 +92,15 @@ impl<'a, 'b> Verifier<'a, 'b> {
self.vints(4)?; self.vints(4)?;
Ok(()) 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<()> { fn combat(&mut self) -> Result<()> {
self.reader.read_boolean()?; self.reader.read_boolean()?;
self.reader.read_boolean()?; self.reader.read_boolean()?;
@ -201,16 +211,9 @@ impl<'a, 'b> Verifier<'a, 'b> {
for _ in 0..count { for _ in 0..count {
self.global_id()?; 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() let summoner = LogicDataTables::instance()
.data_by_name(table::CHARACTERS_COMBINED, "KingTower") .data_by_name(table::CHARACTERS_COMBINED, "KingTower")
.map(|row| row.global_id()) .map(|row| row.global_id());
.filter(|_| summoner_bodies);
self.mark("object_bodies"); self.mark("object_bodies");
for entry in &data { for entry in &data {
let is_summoner = entry.global_id().is_some() && entry.global_id() == summoner; 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); .unwrap_or(0);
self.mark("components"); self.mark("components");
for pass in 0..4 { 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 { match pass {
0 => self.combat()?, 0 => self.combat()?,
1 if moves => self.movement()?,
2 => self.hitpoint()?, 2 => self.hitpoint()?,
3 => self.buff(buff_rows)?, 3 => self.buff(buff_rows)?,
_ => {} _ => {}