From 3bb923cd17139fc4d1040bd74844066c050f189d Mon Sep 17 00:00:00 2001 From: WiseDev <83840010+wisedevik@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:14:23 +0300 Subject: [PATCH] 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. --- crates/game-service/src/battle.rs | 15 ++-- crates/game-service/src/battle_session.rs | 26 ++++++- crates/logic/src/battle/logic_component.rs | 83 ++++++++++++++++++++++ crates/logic/src/battle/mod.rs | 1 + crates/logic/src/battle/verify.rs | 26 ++++--- 5 files changed, 136 insertions(+), 15 deletions(-) diff --git a/crates/game-service/src/battle.rs b/crates/game-service/src/battle.rs index ed953fb..f8813cf 100644 --- a/crates/game-service/src/battle.rs +++ b/crates/game-service/src/battle.rs @@ -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; COMPONENT_PASSES] { + fn components_for(&self, hitpoints: i32, moves: bool) -> [Option; 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( diff --git a/crates/game-service/src/battle_session.rs b/crates/game-service/src/battle_session.rs index bf3ea0b..116bbb5 100644 --- a/crates/game-service/src/battle_session.rs +++ b/crates/game-service/src/battle_session.rs @@ -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, next_bot_emote: i32, + next_snapshot: i32, } impl BattleSession { pub fn new(mode: LogicGameMode, taunts: Vec) -> Self { @@ -29,8 +32,23 @@ impl BattleSession { random, taunts, next_bot_emote, + next_snapshot: SNAPSHOT_INTERVAL_TICKS, } } + fn snapshot_message(&mut self) -> Option { + 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() { diff --git a/crates/logic/src/battle/logic_component.rs b/crates/logic/src/battle/logic_component.rs index 892070b..960949d 100644 --- a/crates/logic/src/battle/logic_component.rs +++ b/crates/logic/src/battle/logic_component.rs @@ -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, + 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), } diff --git a/crates/logic/src/battle/mod.rs b/crates/logic/src/battle/mod.rs index f1d1912..062c34f 100644 --- a/crates/logic/src/battle/mod.rs +++ b/crates/logic/src/battle/mod.rs @@ -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, }; diff --git a/crates/logic/src/battle/verify.rs b/crates/logic/src/battle/verify.rs index 21ac7a4..f9fb59f 100644 --- a/crates/logic/src/battle/verify.rs +++ b/crates/logic/src/battle/verify.rs @@ -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)?, _ => {}