From 14786cbc92b73dfa040228fbe0e22631ee3231da Mon Sep 17 00:00:00 2001 From: WiseDev <83840010+wisedevik@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:37:02 +0300 Subject: [PATCH] never take a leader off the board the client crashed in LogicBattle::resetSimulatedManaTimers again, and this time it was ours: remove_dead was dropping a king tower once it fell, while battle.leaders still pointed at it. that function reads both leaders straight out of the battle and calls a virtual on each without a null check, so the next state to arrive killed the client. the client's own rule is one line: LogicCharacter::shouldDestruct() { if (this[209]) return 0; ... } isLeader, never destructs. remove_dead now keeps them the same way, and the harness watches every tick of a full match for a leader that has gone missing - it caught this one at tick 693. --- crates/game-service/tests/walk_to_tower.rs | 49 +++++++++++++++++++++ crates/logic/src/battle/logic_simulation.rs | 7 ++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/crates/game-service/tests/walk_to_tower.rs b/crates/game-service/tests/walk_to_tower.rs index 864abf4..2cd8eef 100644 --- a/crates/game-service/tests/walk_to_tower.rs +++ b/crates/game-service/tests/walk_to_tower.rs @@ -122,6 +122,19 @@ fn the_bot_deploys_in_front_of_its_own_towers() { } } session.advance_to(tick); + for (side, leader) in session.mode().battle.leaders.iter().enumerate() { + let alive = session + .mode() + .battle + .objects + .objects + .iter() + .any(|entry| entry.global_id == *leader); + assert!( + alive, + "t={tick}: side {side}'s leader {leader:?} is no longer in the battle" + ); + } if session.is_finished() || session.towers_standing().0 == 0 { println!("battle over at t={tick} after {plays} bot plays"); break; @@ -205,3 +218,39 @@ fn a_bot_troop_walks_at_the_player_towers() { "the bot troop walked away from the player towers to {end:?}" ); } +#[test] +fn both_leaders_resolve_to_a_summoner() { + let root = assets(); + if let Ok(tables) = LogicDataTables::load_from_dir(&root) { + LogicDataTables::install(Arc::new(tables)); + } + let builder = BattleBuilder::new(&root); + let 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"); + println!("leaders: {:?}", mode.battle.leaders); + for (side, leader) in mode.battle.leaders.iter().enumerate() { + assert!( + !leader.is_none(), + "side {side} has no leader; LogicBattle::resetSimulatedManaTimers reads both \ + without checking for null and takes the client down with it" + ); + let found = mode + .battle + .objects + .objects + .iter() + .find(|entry| entry.global_id == *leader); + assert!( + found.is_some(), + "side {side}'s leader {leader:?} matches no object in the battle" + ); + } +} diff --git a/crates/logic/src/battle/logic_simulation.rs b/crates/logic/src/battle/logic_simulation.rs index 2997a1b..9406aca 100644 --- a/crates/logic/src/battle/logic_simulation.rs +++ b/crates/logic/src/battle/logic_simulation.rs @@ -514,17 +514,20 @@ impl LogicBattle { } } fn remove_dead(&mut self) { + let leaders = self.leaders; let dead: Vec = self .objects .objects .iter() - .filter(|entry| !entry.is_alive()) + .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(LogicGameObjectEntry::is_alive); + 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)); }