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:
parent
0451fcdf43
commit
82f00ac1bc
4 changed files with 111 additions and 3 deletions
|
|
@ -223,12 +223,16 @@ impl BattleBuilder {
|
||||||
} else {
|
} else {
|
||||||
LogicObjectBody::Character(Box::new(character))
|
LogicObjectBody::Character(Box::new(character))
|
||||||
};
|
};
|
||||||
LogicGameObjectEntry::new(
|
let mut entry = LogicGameObjectEntry::new(
|
||||||
data,
|
data,
|
||||||
LogicGameObjectRef::of(CHARACTER_OBJECT_TYPE + 1, instance),
|
LogicGameObjectRef::of(CHARACTER_OBJECT_TYPE + 1, instance),
|
||||||
body,
|
body,
|
||||||
self.components_for(hitpoints, false),
|
self.components_for(hitpoints, false),
|
||||||
)
|
);
|
||||||
|
if summoner {
|
||||||
|
entry.body.base_mut().component_mask &= !(1 << logic::battle::COMPONENT_COMBAT);
|
||||||
|
}
|
||||||
|
entry
|
||||||
}
|
}
|
||||||
pub fn build(
|
pub fn build(
|
||||||
&self,
|
&self,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ pub fn snapshot_interval_ticks() -> i32 {
|
||||||
.unwrap_or(SNAPSHOT_INTERVAL_TICKS)
|
.unwrap_or(SNAPSHOT_INTERVAL_TICKS)
|
||||||
}
|
}
|
||||||
pub const MAX_CATCH_UP_TICKS: i32 = 40;
|
pub const MAX_CATCH_UP_TICKS: i32 = 40;
|
||||||
|
pub const CHECKSUM_HISTORY: usize = 256;
|
||||||
pub const BOT_DEPLOY_AHEAD: i32 = 2000;
|
pub const BOT_DEPLOY_AHEAD: i32 = 2000;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use logic::battle::{
|
use logic::battle::{
|
||||||
|
|
@ -32,6 +33,7 @@ pub struct BattleSession {
|
||||||
next_snapshot: i32,
|
next_snapshot: i32,
|
||||||
next_bot_play: i32,
|
next_bot_play: i32,
|
||||||
pub pushed_snapshots: u32,
|
pub pushed_snapshots: u32,
|
||||||
|
recent_checksums: std::collections::VecDeque<(i32, i32)>,
|
||||||
next_instance: i32,
|
next_instance: i32,
|
||||||
announced: bool,
|
announced: bool,
|
||||||
pending_bot_play: Option<(LogicDataRef, LogicVector2, i32)>,
|
pending_bot_play: Option<(LogicDataRef, LogicVector2, i32)>,
|
||||||
|
|
@ -61,6 +63,7 @@ impl BattleSession {
|
||||||
next_snapshot: snapshot_interval_ticks(),
|
next_snapshot: snapshot_interval_ticks(),
|
||||||
next_bot_play: crate::bot::BOT_OPENING_DELAY_SECONDS * BATTLE_TICKS_PER_SECOND,
|
next_bot_play: crate::bot::BOT_OPENING_DELAY_SECONDS * BATTLE_TICKS_PER_SECOND,
|
||||||
pushed_snapshots: 0,
|
pushed_snapshots: 0,
|
||||||
|
recent_checksums: std::collections::VecDeque::with_capacity(CHECKSUM_HISTORY),
|
||||||
next_instance: first_instance,
|
next_instance: first_instance,
|
||||||
announced: false,
|
announced: false,
|
||||||
pending_bot_play: None,
|
pending_bot_play: None,
|
||||||
|
|
@ -235,6 +238,12 @@ impl BattleSession {
|
||||||
self.mode.time.tick = self.tick;
|
self.mode.time.tick = self.tick;
|
||||||
self.release_queued(self.tick);
|
self.release_queued(self.tick);
|
||||||
self.mode.battle.tick(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 {
|
if self.pushes_snapshots() && self.tick >= self.next_snapshot {
|
||||||
self.next_snapshot = self.tick + snapshot_interval_ticks();
|
self.next_snapshot = self.tick + snapshot_interval_ticks();
|
||||||
if let Some(message) = self.snapshot_message() {
|
if let Some(message) = self.snapshot_message() {
|
||||||
|
|
@ -292,6 +301,13 @@ impl BattleSession {
|
||||||
pub fn checksum(&self) -> Option<i32> {
|
pub fn checksum(&self) -> Option<i32> {
|
||||||
self.mode.calculate_checksum().ok()
|
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) {
|
pub fn towers_standing(&self) -> (usize, usize) {
|
||||||
(
|
(
|
||||||
self.mode.battle.leader_towers[0].len(),
|
self.mode.battle.leader_towers[0].len(),
|
||||||
|
|
@ -410,7 +426,7 @@ impl BattleRegistry {
|
||||||
stars: session.stars(),
|
stars: session.stars(),
|
||||||
objects: session.object_count(),
|
objects: session.object_count(),
|
||||||
towers: session.towers_standing(),
|
towers: session.towers_standing(),
|
||||||
checksum: session.checksum(),
|
checksum: session.checksum_at(tick).or_else(|| session.checksum()),
|
||||||
mana: session.mana(),
|
mana: session.mana(),
|
||||||
pushed: session.pushed_snapshots,
|
pushed: session.pushed_snapshots,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -296,3 +296,49 @@ fn a_finished_battle_stops_ticking() {
|
||||||
"a finished battle carried on 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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: &str = "MANA_REGEN_MS";
|
||||||
pub const GLOBAL_MANA_REGEN_END: &str = "MANA_REGEN_MS_END";
|
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_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_SPEED: &str = "Speed";
|
||||||
pub const COLUMN_SIGHT_RANGE: &str = "SightRange";
|
pub const COLUMN_SIGHT_RANGE: &str = "SightRange";
|
||||||
pub const COLUMN_RANGE: &str = "Range";
|
pub const COLUMN_RANGE: &str = "Range";
|
||||||
|
|
@ -196,6 +199,7 @@ impl LogicBattle {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn tick(&mut self, tick: i32) {
|
pub fn tick(&mut self, tick: i32) {
|
||||||
|
self.activate_summoners();
|
||||||
self.advance_deploy();
|
self.advance_deploy();
|
||||||
self.regenerate_mana(tick);
|
self.regenerate_mana(tick);
|
||||||
self.retarget();
|
self.retarget();
|
||||||
|
|
@ -204,6 +208,44 @@ impl LogicBattle {
|
||||||
self.resolve_attacks();
|
self.resolve_attacks();
|
||||||
self.remove_dead();
|
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) {
|
fn advance_deploy(&mut self) {
|
||||||
for entry in self.objects.objects.iter_mut() {
|
for entry in self.objects.objects.iter_mut() {
|
||||||
let deploy_time = entry
|
let deploy_time = entry
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue