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.
This commit is contained in:
WiseDev 2026-08-24 09:32:44 +03:00
parent 0451fcdf43
commit 82f00ac1bc
4 changed files with 111 additions and 3 deletions

View file

@ -223,12 +223,16 @@ impl BattleBuilder {
} else {
LogicObjectBody::Character(Box::new(character))
};
LogicGameObjectEntry::new(
let mut entry = LogicGameObjectEntry::new(
data,
LogicGameObjectRef::of(CHARACTER_OBJECT_TYPE + 1, instance),
body,
self.components_for(hitpoints, false),
)
);
if summoner {
entry.body.base_mut().component_mask &= !(1 << logic::battle::COMPONENT_COMBAT);
}
entry
}
pub fn build(
&self,

View file

@ -9,6 +9,7 @@ pub fn snapshot_interval_ticks() -> i32 {
.unwrap_or(SNAPSHOT_INTERVAL_TICKS)
}
pub const MAX_CATCH_UP_TICKS: i32 = 40;
pub const CHECKSUM_HISTORY: usize = 256;
pub const BOT_DEPLOY_AHEAD: i32 = 2000;
use std::time::Instant;
use logic::battle::{
@ -32,6 +33,7 @@ pub struct BattleSession {
next_snapshot: i32,
next_bot_play: i32,
pub pushed_snapshots: u32,
recent_checksums: std::collections::VecDeque<(i32, i32)>,
next_instance: i32,
announced: bool,
pending_bot_play: Option<(LogicDataRef, LogicVector2, i32)>,
@ -61,6 +63,7 @@ impl BattleSession {
next_snapshot: snapshot_interval_ticks(),
next_bot_play: crate::bot::BOT_OPENING_DELAY_SECONDS * BATTLE_TICKS_PER_SECOND,
pushed_snapshots: 0,
recent_checksums: std::collections::VecDeque::with_capacity(CHECKSUM_HISTORY),
next_instance: first_instance,
announced: false,
pending_bot_play: None,
@ -235,6 +238,12 @@ impl BattleSession {
self.mode.time.tick = self.tick;
self.release_queued(self.tick);
self.mode.battle.tick(self.tick);
if let Ok(checksum) = self.mode.calculate_checksum() {
if self.recent_checksums.len() >= CHECKSUM_HISTORY {
self.recent_checksums.pop_front();
}
self.recent_checksums.push_back((self.tick, checksum));
}
if self.pushes_snapshots() && self.tick >= self.next_snapshot {
self.next_snapshot = self.tick + snapshot_interval_ticks();
if let Some(message) = self.snapshot_message() {
@ -292,6 +301,13 @@ impl BattleSession {
pub fn checksum(&self) -> Option<i32> {
self.mode.calculate_checksum().ok()
}
pub fn checksum_at(&self, tick: i32) -> Option<i32> {
self.recent_checksums
.iter()
.rev()
.find(|(at, _)| *at == tick)
.map(|(_, checksum)| *checksum)
}
pub fn towers_standing(&self) -> (usize, usize) {
(
self.mode.battle.leader_towers[0].len(),
@ -410,7 +426,7 @@ impl BattleRegistry {
stars: session.stars(),
objects: session.object_count(),
towers: session.towers_standing(),
checksum: session.checksum(),
checksum: session.checksum_at(tick).or_else(|| session.checksum()),
mana: session.mana(),
pushed: session.pushed_snapshots,
})

View file

@ -296,3 +296,49 @@ fn a_finished_battle_stops_ticking() {
"a finished battle carried on ticking"
);
}
#[test]
fn a_dormant_king_clears_its_combat_bit_and_towers_do_not() {
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");
for tick in 1..80 {
mode.battle.tick(tick);
}
let mut kings = 0;
let mut princesses = 0;
for entry in mode.battle.objects.objects.iter() {
let mask = entry.body.base().component_mask;
match &entry.body {
logic::battle::LogicObjectBody::Summoner(_) => {
assert_eq!(mask, 12, "a dormant king should clear its combat bit");
kings += 1;
}
logic::battle::LogicObjectBody::Character(_) => {
assert_eq!(mask, 13, "a princess tower keeps its combat bit");
princesses += 1;
}
}
}
assert_eq!(kings, 2);
assert_eq!(princesses, 4);
let a = mode.calculate_checksum().unwrap();
mode.time.tick = 200;
mode.battle.tick(200);
let b = mode.calculate_checksum().unwrap();
mode.time.tick = 200;
let c = mode.calculate_checksum().unwrap();
assert_eq!(b, c, "hashing the same idle state twice must agree");
let _ = a;
}

View file

@ -13,6 +13,9 @@ 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";
@ -196,6 +199,7 @@ impl LogicBattle {
}
}
pub fn tick(&mut self, tick: i32) {
self.activate_summoners();
self.advance_deploy();
self.regenerate_mana(tick);
self.retarget();
@ -204,6 +208,44 @@ impl LogicBattle {
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