maintain the combat timers the way the client does

audit findings #4/#7/#8. resolve_attacks only ever wrote hit_timer, and
with the wrong model; field_52 (load), field_60 (dash), field_64
(special index) were left at 0 forever. all four are hashed by
LogicCombatComponent::encode, so the checksum broke the instant anything
could shoot.

now, mirroring LogicCombatComponent::update and updateHitTimer:
- field_52 and field_60 decrement by 50 every tick, unconditionally, for
  every combat component - the two lines at the top of update.
- hit_timer is seeded with LoadTime the first time, advances by the 50ms
  step, and a shot lands on each HitSpeed boundary the accumulator
  crosses (field18/HitSpeed rising past its previous quotient), instead
  of "fire at load_time+hit_speed then reset". on a shot field_52 is set
  back to LoadTime and field_64 cycles through SpecialAttackInterval.

still not bit-exact for combat: the state field (2 while attacking),
field_68 recovery, buff-scaled hit speed, and projectiles-as-objects are
their own ports. this closes the "timers never move" break; damage
lands under the new model (harness).
This commit is contained in:
WiseDev 2026-08-24 09:54:07 +03:00
parent a9a2e048ee
commit 2933d2ff7e
2 changed files with 117 additions and 14 deletions

View file

@ -413,3 +413,68 @@ fn a_deployed_troop_counts_down_and_holds_still_until_ready() {
.position();
assert_ne!(moved, spawn_pos, "a deployed troop moves");
}
#[test]
fn a_troop_in_range_damages_the_tower_with_the_client_hit_model() {
let root = assets();
if let Ok(t) = LogicDataTables::load_from_dir(&root) {
LogicDataTables::install(Arc::new(t));
}
let builder = BattleBuilder::new(&root);
let mut mode = builder
.build(
LogicDataRef::by_name(table::LOCATIONS, "PvP_goblin"),
LogicDataRef::None,
LogicDataRef::by_name(table::ARENAS, "Arena_T"),
vec![avatar(5), avatar(0)],
[None, None],
1,
)
.expect("battle");
let (tower_id, tower_pos, start_hp) = mode
.battle
.objects
.objects
.iter()
.find_map(|e| match &e.body {
logic::battle::LogicObjectBody::Character(_) if e.owner_index() == 1 => {
let hp = e
.components
.get(2)
.and_then(|c| c.as_ref())
.and_then(|c| match c {
logic::battle::LogicComponent::Hitpoint(h) => Some(h.hitpoints),
_ => None,
})?;
Some((e.global_id, e.position(), hp))
}
_ => None,
})
.expect("an enemy princess tower");
let entries = builder.summon(
&LogicDataRef::spell("Knight"),
LogicVector2::new(tower_pos.0, tower_pos.1 - 800),
0,
0,
100,
);
for entry in entries {
mode.battle.objects.push(entry);
}
for tick in 1..200 {
mode.battle.tick(tick);
}
let hp = mode
.battle
.objects
.objects
.iter()
.find(|e| e.global_id == tower_id)
.and_then(|e| e.components.get(2))
.and_then(|c| c.as_ref())
.and_then(|c| match c {
logic::battle::LogicComponent::Hitpoint(h) => Some(h.hitpoints),
_ => None,
})
.expect("tower still present");
assert!(hp < start_hp, "the knight's attacks should reduce tower hp ({hp} !< {start_hp})");
}

View file

@ -6,6 +6,7 @@ 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_SPECIAL_ATTACK_INTERVAL_COLUMN: &str = "SpecialAttackInterval";
pub const CHARACTER_STATE_MOVING: i32 = 1;
pub const CHARACTER_STATE_DEPLOY: i32 = 5;
pub const MANA_ACCUMULATOR_STEP: i32 = 5000;
@ -570,30 +571,67 @@ impl LogicBattle {
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
|| self.objects.objects[index].is_deploying()
{
continue;
}
let alive = self.objects.objects[index].is_alive();
let deploying = self.objects.objects[index].is_deploying();
let special_interval = self.objects.objects[index]
.data
.data()
.map(|row| row.int(CHARACTER_SPECIAL_ATTACK_INTERVAL_COLUMN))
.unwrap_or(0);
let hit_speed = stats.hit_speed;
let load_time = stats.load_time.max(0);
let range = stats.range;
let damage = stats.damage;
let Some(component) = self.objects.objects[index].combat_mut() else {
continue;
};
component.field_52 = (component.field_52 - TICK_MILLISECONDS).max(0);
component.field_60 = (component.field_60 - TICK_MILLISECONDS).max(0);
if !alive || deploying || hit_speed < 1 {
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) {
let reach = 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));
let before = component.hit_timer;
let mut seeded = component.hit_timer;
if seeded == 0 && !component.flag_73 {
if load_time <= hit_speed {
seeded = load_time - component.field_52;
component.hit_timer = seeded;
component.field_52 = load_time;
} else if component.field_52 > hit_speed {
component.hit_timer = 0;
continue;
} else {
component.field_52 = 0;
seeded = 0;
}
}
if component.flag_73 {
component.hit_timer = seeded + hit_speed - seeded % hit_speed.max(1);
component.flag_73 = false;
} else {
component.hit_timer = seeded + TICK_MILLISECONDS;
}
if component.hit_timer / hit_speed.max(1) > before / hit_speed.max(1) {
component.field_52 = load_time;
if special_interval >= 2 {
component.field_64 = if component.field_64 == special_interval - 1 {
0
} else {
component.field_64 + 1
};
}
hits.push((target, damage));
}
}
for (target, amount) in hits {