scroll.server/crates/logic/src/battle/logic_simulation.rs
WiseDev 82f00ac1bc hash at the client's tick, and clear the dormant king's combat bit
audit findings #1 and #2, the pair that makes the idle-board checksum
mean something again.

#1 the server hashed at its own free-running tick. sector_command calls
advance(client_tick) but advance_to is forward-only and the 50ms ticker
already ran the session past it, so it hashed at session.tick, not the
client's - and the tick is the first field in the checksum, so it could
never agree even on a bit-exact board. the session now records the
checksum of every tick it simulates and advance() returns the one for
the tick the client actually reported.

#2 the real idle divergence. the king is dormant at full health, and
LogicSummoner::updateCombatComponentState clears the combat bit of its
component mask every tick (13 -> 12) while field_256 <= KING_ACTIVATE
_TIME_MS; the mask is hashed for every object, so a server holding 13
disagreed on every tick and re-agreed only on the snapshot tick. the
sim now mirrors it: field_256 stays 0 while the king is unhurt and both
princess towers stand, ramps by 50/tick once it takes damage or loses a
tower, and the combat bit turns on only past KING_ACTIVATE_TIME_MS. the
king is also built with the bit already clear. princess towers are plain
characters and keep bit0 - verified against the client, which routes
them through the base updateCombatComponentState that sets it.

harness: dormant kings read mask 12, all four princess towers 13, and an
idle state hashes the same twice.
2026-08-24 09:32:44 +03:00

595 lines
22 KiB
Rust

use crate::battle::logic_battle::LogicBattle;
use crate::battle::logic_battle::BATTLE_TICKS_PER_SECOND;
use crate::battle::logic_component::{LogicComponent, COMPONENT_COMBAT, COMPONENT_HITPOINT};
use crate::battle::logic_game_object::{LogicGameObjectEntry, LogicObjectBody};
use crate::battle::logic_tilemap::SUBTILE_UNITS;
use crate::data::LogicDataRef;
pub const TICK_MILLISECONDS: i32 = 50;
pub const CHARACTER_DEPLOY_TIME_COLUMN: &str = "DeployTime";
pub const CHARACTER_STATE_MOVING: i32 = 1;
pub const MANA_ACCUMULATOR_STEP: i32 = 5000;
pub const MANA_RATE_SCALE: i32 = 100;
pub const GLOBAL_MAX_MANA: &str = "MAX_MANA";
pub const GLOBAL_MANA_REGEN: &str = "MANA_REGEN_MS";
pub const GLOBAL_MANA_REGEN_END: &str = "MANA_REGEN_MS_END";
pub const GLOBAL_MANA_SPEED_UP_SECONDS: &str = "MANA_SPEED_UP_WHEN_REMAINING_SECONDS";
pub const GLOBAL_KING_ACTIVATE_TIME_MS: &str = "KING_ACTIVATE_TIME_MS";
pub const KING_ACTIVATION_STEP: i32 = 50;
pub const PRINCESS_TOWERS_TOTAL: i32 = 4;
pub const COLUMN_SPEED: &str = "Speed";
pub const COLUMN_SIGHT_RANGE: &str = "SightRange";
pub const COLUMN_RANGE: &str = "Range";
pub const COLUMN_COLLISION_RADIUS: &str = "CollisionRadius";
pub const COLUMN_HIT_SPEED: &str = "HitSpeed";
pub const COLUMN_LOAD_TIME: &str = "LoadTime";
pub const COLUMN_DAMAGE: &str = "Damage";
pub const COLUMN_ATTACKS_AIR: &str = "AttacksAir";
pub const COLUMN_ATTACKS_GROUND: &str = "AttacksGround";
pub const COLUMN_TARGET_ONLY_BUILDINGS: &str = "TargetOnlyBuildings";
pub const COLUMN_FLYING_HEIGHT: &str = "FlyingHeight";
pub const COLUMN_MASS: &str = "Mass";
pub const COLLISION_RADIUS_CAP: i32 = 500;
pub const COLLISION_OVERLAP_CAP: i32 = 300;
pub const COLLISION_PUSH_CAP: i32 = 300;
pub const MASS_MIN: i32 = 1;
pub const MASS_MAX: i32 = 20;
pub const MASS_FOR_STATIC: i32 = 20;
pub const COLUMN_PROJECTILE: &str = "Projectile";
pub const DISTANCE_SATURATION: i32 = 46340;
pub const WAYPOINT_REACHED: i32 = 250;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LogicCharacterStats {
pub speed: i32,
pub sight_range: i32,
pub range: i32,
pub collision_radius: i32,
pub hit_speed: i32,
pub load_time: i32,
pub damage: i32,
pub attacks_air: bool,
pub attacks_ground: bool,
pub target_only_buildings: bool,
pub flying: bool,
pub mass: i32,
}
impl LogicCharacterStats {
pub fn of(data: &LogicDataRef, level_index: i32) -> Self {
let Some(row) = data.data() else {
return Self::none();
};
let level = level_index.max(0) as usize;
let projectile = row.string(COLUMN_PROJECTILE);
let damage = if projectile.is_empty() {
row.int_at(COLUMN_DAMAGE, level)
} else {
LogicDataRef::by_name(crate::data::table::PROJECTILES, projectile)
.data()
.map(|shot| shot.int_at(COLUMN_DAMAGE, level))
.unwrap_or(0)
};
Self {
speed: row.int(COLUMN_SPEED),
sight_range: row.int(COLUMN_SIGHT_RANGE),
range: row.int(COLUMN_RANGE),
collision_radius: row.int(COLUMN_COLLISION_RADIUS),
hit_speed: row.int(COLUMN_HIT_SPEED),
load_time: row.int(COLUMN_LOAD_TIME),
damage,
attacks_air: row.boolean(COLUMN_ATTACKS_AIR),
attacks_ground: row.boolean(COLUMN_ATTACKS_GROUND),
target_only_buildings: row.boolean(COLUMN_TARGET_ONLY_BUILDINGS),
flying: row.int(COLUMN_FLYING_HEIGHT) > 0,
mass: row.int(COLUMN_MASS).clamp(MASS_MIN, MASS_MAX),
}
}
pub fn none() -> Self {
Self {
speed: 0,
sight_range: 0,
range: 0,
collision_radius: 0,
hit_speed: 0,
load_time: 0,
damage: 0,
attacks_air: false,
attacks_ground: false,
target_only_buildings: false,
flying: false,
mass: MASS_MIN,
}
}
pub fn is_building(&self) -> bool {
self.speed == 0
}
}
pub fn distance_squared(from: (i32, i32), to: (i32, i32)) -> i32 {
let dx = to.0 - from.0;
let dy = to.1 - from.1;
if dx.abs() > DISTANCE_SATURATION || dy.abs() > DISTANCE_SATURATION {
return i32::MAX;
}
let square_x = dx * dx;
if dy * dy >= (square_x ^ i32::MAX) {
return i32::MAX;
}
square_x + dy * dy
}
impl LogicGameObjectEntry {
pub fn position(&self) -> (i32, i32) {
let base = self.body.base();
(base.position.x, base.position.y)
}
pub fn owner_index(&self) -> i32 {
self.body.base().owner_index
}
pub fn level_index(&self) -> i32 {
self.body.level_index()
}
pub fn stats(&self) -> LogicCharacterStats {
LogicCharacterStats::of(&self.data, self.level_index())
}
pub fn damage(&mut self, amount: i32) {
if let Some(Some(LogicComponent::Hitpoint(component))) =
self.components.get_mut(COMPONENT_HITPOINT)
{
component.hitpoints = (component.hitpoints - amount.max(0)).max(0);
}
}
pub fn movement(&self) -> Option<&crate::battle::logic_component::LogicMovementComponent> {
match self
.components
.get(crate::battle::logic_component::COMPONENT_MOVEMENT)?
{
Some(LogicComponent::Movement(component)) => Some(component),
_ => None,
}
}
pub fn movement_mut(
&mut self,
) -> Option<&mut crate::battle::logic_component::LogicMovementComponent> {
match self
.components
.get_mut(crate::battle::logic_component::COMPONENT_MOVEMENT)?
{
Some(LogicComponent::Movement(component)) => Some(component),
_ => None,
}
}
pub fn combat_mut(
&mut self,
) -> Option<&mut crate::battle::logic_component::LogicCombatComponent> {
match self.components.get_mut(COMPONENT_COMBAT)? {
Some(LogicComponent::Combat(component)) => Some(component),
_ => None,
}
}
}
struct CollisionBody {
alive: bool,
position: (i32, i32),
stats: LogicCharacterStats,
owner: i32,
moves: bool,
}
impl LogicBattle {
fn regenerate_mana(&mut self, tick: i32) {
let max_mana = crate::LogicGlobals::number(GLOBAL_MAX_MANA).max(1);
let seconds_left = (self.match_length_seconds() - tick / BATTLE_TICKS_PER_SECOND).max(0);
let speed_up = crate::LogicGlobals::number(GLOBAL_MANA_SPEED_UP_SECONDS);
let regen = if seconds_left <= speed_up {
crate::LogicGlobals::number(GLOBAL_MANA_REGEN_END)
} else {
crate::LogicGlobals::number(GLOBAL_MANA_REGEN)
};
let step = regen.saturating_mul(MANA_RATE_SCALE) / max_mana;
if step < 1 {
return;
}
for entry in self.objects.objects.iter_mut() {
let LogicObjectBody::Summoner(summoner) = &mut entry.body else {
continue;
};
summoner.mana_regen_timer += MANA_ACCUMULATOR_STEP;
let gained = summoner.mana_regen_timer / step;
if gained < 1 {
continue;
}
summoner.mana_regen_timer -= gained * step;
summoner.mana = (summoner.mana + gained).clamp(0, max_mana);
}
}
pub fn tick(&mut self, tick: i32) {
self.activate_summoners();
self.advance_deploy();
self.regenerate_mana(tick);
self.retarget();
self.resolve_collisions();
self.move_objects();
self.resolve_attacks();
self.remove_dead();
}
fn activate_summoners(&mut self) {
let activate_ms = crate::LogicGlobals::number(GLOBAL_KING_ACTIVATE_TIME_MS).max(0);
let total = self
.tilemap
.as_ref()
.map(|map| map.princess_towers().len() as i32)
.filter(|count| *count > 0)
.unwrap_or(PRINCESS_TOWERS_TOTAL);
let standing = [
self.leader_towers.first().map(Vec::len).unwrap_or(0) as i32,
self.leader_towers.get(1).map(Vec::len).unwrap_or(0) as i32,
];
for entry in self.objects.objects.iter_mut() {
let full_health = match entry.components.get(COMPONENT_HITPOINT) {
Some(Some(LogicComponent::Hitpoint(hp))) => hp.hitpoints >= hp.base_hitpoints,
_ => true,
};
let owner = entry.owner_index().clamp(0, 1) as usize;
let LogicObjectBody::Summoner(summoner) = &mut entry.body else {
continue;
};
let princess_lost = standing[owner] < total / 2;
if summoner.field_256 == 0 {
if !full_health || princess_lost {
summoner.field_256 = KING_ACTIVATION_STEP;
}
} else if summoner.field_256 <= activate_ms {
summoner.field_256 += KING_ACTIVATION_STEP;
}
let active = summoner.field_256 > activate_ms;
let mask = &mut summoner.character.base.component_mask;
if active {
*mask |= 1 << COMPONENT_COMBAT;
} else {
*mask &= !(1 << COMPONENT_COMBAT);
}
}
}
fn advance_deploy(&mut self) {
for entry in self.objects.objects.iter_mut() {
let deploy_time = entry
.data
.data()
.map(|row| row.int(CHARACTER_DEPLOY_TIME_COLUMN))
.unwrap_or(0);
let LogicObjectBody::Character(character) = &mut entry.body else {
continue;
};
if character.deploy_timer < deploy_time {
character.deploy_timer =
(character.deploy_timer + TICK_MILLISECONDS).min(deploy_time);
}
}
}
fn retarget(&mut self) {
let snapshot: Vec<(i32, (i32, i32), LogicCharacterStats, bool)> = self
.objects
.objects
.iter()
.map(|entry| {
(
entry.owner_index(),
entry.position(),
entry.stats(),
entry.is_alive(),
)
})
.collect();
for index in 0..self.objects.objects.len() {
let (owner, position, stats, alive) = snapshot[index];
if !alive || stats.range < 1 {
continue;
}
let mut best: Option<(i32, usize)> = None;
for (other, (other_owner, other_position, other_stats, other_alive)) in
snapshot.iter().enumerate()
{
if other == index || !other_alive || *other_owner == owner {
continue;
}
if stats.target_only_buildings && !other_stats.is_building() {
continue;
}
if other_stats.flying && !stats.attacks_air {
continue;
}
if !other_stats.flying && !stats.attacks_ground {
continue;
}
let reach = stats.sight_range + other_stats.collision_radius;
let distance = distance_squared(position, *other_position);
if distance > reach.saturating_mul(reach) {
continue;
}
if best.map(|(closest, _)| distance < closest).unwrap_or(true) {
best = Some((distance, other));
}
}
let target = best.map(|(_, other)| other);
if let Some(component) = self.objects.objects[index].combat_mut() {
component.target_index = target;
}
}
}
fn default_target(&self, index: usize) -> Option<usize> {
let owner = self.objects.objects[index].owner_index();
let from = self.objects.objects[index].position();
let mut building: Option<(i32, usize)> = None;
let mut anything: Option<(i32, usize)> = None;
for (other, entry) in self.objects.objects.iter().enumerate() {
if other == index || entry.owner_index() == owner || !entry.is_alive() {
continue;
}
let distance = distance_squared(from, entry.position());
let closer = |best: &Option<(i32, usize)>| {
best.map(|(closest, _)| distance < closest).unwrap_or(true)
};
if entry.stats().is_building() && closer(&building) {
building = Some((distance, other));
}
if closer(&anything) {
anything = Some((distance, other));
}
}
building.or(anything).map(|(_, other)| other)
}
fn next_waypoint(
&mut self,
index: usize,
from: (i32, i32),
goal: (i32, i32),
flying: bool,
) -> (i32, i32) {
let stale = match self.objects.objects[index].movement() {
Some(movement) => movement.goal != Some(goal) || movement.path.is_empty(),
None => return goal,
};
if stale {
let path = self
.tilemap
.as_ref()
.map(|tilemap| crate::battle::find_path(tilemap, from, goal, flying))
.unwrap_or_default();
if let Some(movement) = self.objects.objects[index].movement_mut() {
movement.goal = Some(goal);
movement.route = path;
}
}
let reached = self
.objects
.objects
.get(index)
.and_then(LogicGameObjectEntry::movement)
.and_then(|movement| movement.route.first().copied())
.map(|node| {
let close = distance_squared(from, node) <= WAYPOINT_REACHED * WAYPOINT_REACHED;
(node, close)
});
match reached {
Some((node, true)) => {
if let Some(movement) = self.objects.objects[index].movement_mut() {
if !movement.route.is_empty() {
movement.route.remove(0);
}
}
let _ = node;
self.objects
.objects
.get(index)
.and_then(LogicGameObjectEntry::movement)
.and_then(|movement| movement.route.first().copied())
.unwrap_or(goal)
}
Some((node, false)) => node,
None => goal,
}
}
fn move_objects(&mut self) {
let positions: Vec<(i32, i32)> = self
.objects
.objects
.iter()
.map(LogicGameObjectEntry::position)
.collect();
for index in 0..self.objects.objects.len() {
let stats = self.objects.objects[index].stats();
if stats.speed < 1 || !self.objects.objects[index].is_alive() {
continue;
}
let target = match self.objects.objects[index]
.combat_mut()
.and_then(|component| component.target_index)
{
Some(target) => target,
None => match self.default_target(index) {
Some(target) => target,
None => continue,
},
};
let Some(goal) = positions.get(target).copied() else {
continue;
};
let from = positions[index];
let stand_off = stats.range;
let straight = crate::logic_sqrt(distance_squared(from, goal)) as i64;
if straight <= stand_off as i64 || straight == 0 {
continue;
}
let waypoint = self.next_waypoint(index, from, goal, stats.flying);
let dx = (waypoint.0 - from.0) as i64;
let dy = (waypoint.1 - from.1) as i64;
let leg = crate::logic_sqrt(distance_squared(from, waypoint)) as i64;
if leg == 0 {
continue;
}
let step = (stats.speed as i64)
.min(straight - stand_off as i64)
.min(leg);
let base = self.objects.objects[index].body.base_mut();
base.position.x = from.0 + (dx * step / leg) as i32;
base.position.y = from.1 + (dy * step / leg) as i32;
}
}
fn resolve_collisions(&mut self) {
let bodies: Vec<CollisionBody> = self
.objects
.objects
.iter()
.map(|entry| CollisionBody {
alive: entry.is_alive(),
position: entry.position(),
stats: entry.stats(),
owner: entry.owner_index(),
moves: entry.movement().is_some(),
})
.collect();
for index in 0..self.objects.objects.len() {
let CollisionBody {
alive,
position: mine,
stats,
owner,
moves,
} = &bodies[index];
if !alive || !moves || stats.collision_radius < 1 {
continue;
}
let mut push = (0i64, 0i64, 0i32);
for (other, body) in bodies.iter().enumerate() {
let (theirs, other_stats, other_moves) = (&body.position, &body.stats, body.moves);
if other == index || !body.alive || other_stats.collision_radius < 1 {
continue;
}
if stats.flying != other_stats.flying {
continue;
}
let reach = if other_moves {
stats.collision_radius
} else {
stats.collision_radius.min(COLLISION_RADIUS_CAP)
};
let sum = reach + other_stats.collision_radius;
let (mut dx, mut dy) = (mine.0 - theirs.0, mine.1 - theirs.1);
if dx.abs() > sum || dy.abs() > sum {
continue;
}
let mut square = dx * dx + dy * dy;
if square == 0 {
dx = 0;
dy = if *owner == 0 { -1 } else { 1 };
square = 1;
}
if square > sum.saturating_mul(sum) {
continue;
}
let distance = crate::logic_sqrt(square).max(1) as i64;
let other_mass = if other_stats.is_building() {
MASS_FOR_STATIC
} else {
other_stats.mass
};
let overlap = (sum - distance as i32).clamp(0, COLLISION_OVERLAP_CAP);
let strength =
((overlap * other_mass) / stats.mass.max(MASS_MIN) + 1).min(COLLISION_PUSH_CAP);
push.0 += (strength as i64 * dx as i64) / distance;
push.1 += (strength as i64 * dy as i64) / distance;
push.2 += 1;
}
if push.2 == 0 {
continue;
}
let mut shove = (push.0 / push.2 as i64, push.1 / push.2 as i64);
let allowance = (stats.speed / 2).max(1) as i64;
let travelled =
crate::logic_sqrt(distance_squared((0, 0), (shove.0 as i32, shove.1 as i32)))
as i64;
if travelled > allowance {
shove.0 = shove.0 * allowance / travelled;
shove.1 = shove.1 * allowance / travelled;
}
let (limit_x, limit_y) = self
.tilemap
.as_ref()
.map(|map| {
(
map.width() * SUBTILE_UNITS - 1,
map.height() * SUBTILE_UNITS - 1,
)
})
.unwrap_or((i32::MAX, i32::MAX));
let base = self.objects.objects[index].body.base_mut();
base.position.x = (base.position.x + shove.0 as i32).clamp(0, limit_x);
base.position.y = (base.position.y + shove.1 as i32).clamp(0, limit_y);
}
}
fn resolve_attacks(&mut self) {
let positions: Vec<(i32, i32)> = self
.objects
.objects
.iter()
.map(LogicGameObjectEntry::position)
.collect();
let radii: Vec<i32> = self
.objects
.objects
.iter()
.map(|entry| entry.stats().collision_radius)
.collect();
let mut hits: Vec<(usize, i32)> = Vec::new();
for index in 0..self.objects.objects.len() {
let stats = self.objects.objects[index].stats();
if !self.objects.objects[index].is_alive() || stats.hit_speed < 1 {
continue;
}
let Some(component) = self.objects.objects[index].combat_mut() else {
continue;
};
let Some(target) = component.target_index else {
component.hit_timer = 0;
continue;
};
let reach = stats.range + radii.get(target).copied().unwrap_or(0);
if distance_squared(positions[index], positions[target]) > reach.saturating_mul(reach) {
component.hit_timer = 0;
continue;
}
component.hit_timer += TICK_MILLISECONDS;
let period = stats.hit_speed.max(TICK_MILLISECONDS);
let wind_up = stats.load_time.max(0);
if component.hit_timer >= wind_up + period {
component.hit_timer = wind_up;
hits.push((target, stats.damage));
}
}
for (target, amount) in hits {
if let Some(entry) = self.objects.objects.get_mut(target) {
entry.damage(amount);
}
}
}
fn remove_dead(&mut self) {
let leaders = self.leaders;
let dead: Vec<crate::battle::logic_game_object_ref::LogicGameObjectRef> = self
.objects
.objects
.iter()
.filter(|entry| !entry.is_alive() && !leaders.contains(&entry.global_id))
.map(|entry| entry.global_id)
.collect();
if dead.is_empty() {
return;
}
self.objects
.objects
.retain(|entry| entry.is_alive() || leaders.contains(&entry.global_id));
for towers in self.leader_towers.iter_mut() {
towers.retain(|tower| !dead.contains(tower));
}
for index in 0..self.objects.objects.len() {
if let Some(component) = self.objects.objects[index].combat_mut() {
component.target_index = None;
}
}
}
}