From f5a8c39ab2a9feeb5d12855cadceed53dbf7acbe Mon Sep 17 00:00:00 2001 From: WiseDev <83840010+wisedevik@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:29:40 +0300 Subject: [PATCH] stop a crowd shoving troops out of the arena built a harness first, so the simulation could be run without the client and the fault seen rather than argued about. it reproduces in twenty milliseconds what took a battle to observe. what it showed: a troop on its own walks at the enemy towers and stops at its range, both sides, correctly. put a lane full of them together and the ones behind get squeezed backwards past their own towers and into the edge of the map, where they stand hitting nothing. that is the y=250 and y=31750 frida read out of the client. the collision pass was clamped only by the arena, so a troop in a crowd took a shove every tick with nothing to bound it. it is now limited to half a step, which is the property that matters: a crowd can slow a troop but can never carry it backwards faster than it walks. two smaller ones alongside. bot_play treated every object it owned as a landmark, so once one of its own troops drifted it played the next card on top of it and the one after further out again; only buildings count now. and it deployed exactly on a tower's coordinates, leaving the collision pass to dig the troop out of a building it was born inside - it now stands in front. default_target falls back to any enemy when the buildings on that side are gone. the harness keeps all of it honest: drop the push bound and a troop is out of the arena by tick 140. --- crates/game-service/src/battle_session.rs | 36 ++-- crates/game-service/tests/walk_to_tower.rs | 207 ++++++++++++++++++++ crates/logic/src/battle/logic_simulation.rs | 30 ++- 3 files changed, 252 insertions(+), 21 deletions(-) create mode 100644 crates/game-service/tests/walk_to_tower.rs diff --git a/crates/game-service/src/battle_session.rs b/crates/game-service/src/battle_session.rs index 4b0bccb..e512089 100644 --- a/crates/game-service/src/battle_session.rs +++ b/crates/game-service/src/battle_session.rs @@ -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 BOT_DEPLOY_AHEAD: i32 = 2000; use std::time::Instant; use logic::battle::{ LogicGameMode, LogicGameObjectEntry, LogicVector2, BATTLE_TICKS_PER_SECOND, BATTLE_TYPE_PVP, @@ -107,24 +108,35 @@ impl BattleSession { .objects .objects .iter() - .filter(|entry| entry.owner_index() == crate::bot::BOT_OWNER_INDEX) + .filter(|entry| { + entry.owner_index() == crate::bot::BOT_OWNER_INDEX + && entry.stats().is_building() + && entry.is_alive() + }) .map(|entry| entry.position()) .collect(); let leader = self.mode.battle.leader(crate::bot::BOT_OWNER_INDEX as usize)?; let (king_x, king_y) = leader.position(); - let ahead = towers + let front = towers .iter() - .map(|(_, y)| *y) - .fold(king_y, |best, y| if (y - king_y).abs() > (best - king_y).abs() { y } else { best }); - let lane_x = towers - .iter() - .filter(|(_, y)| *y == ahead) - .map(|(x, _)| *x) - .collect::>(); - let x = lane_x - .get(self.random.next(lane_x.len().max(1) as i32).max(0) as usize) .copied() - .unwrap_or(king_x); + .fold((king_x, king_y), |best, tower| { + if (tower.1 - king_y).abs() > (best.1 - king_y).abs() { + tower + } else { + best + } + }); + let lane: Vec<(i32, i32)> = towers + .iter() + .copied() + .filter(|(_, y)| *y == front.1) + .collect(); + let (x, tower_y) = lane + .get(self.random.next(lane.len().max(1) as i32).max(0) as usize) + .copied() + .unwrap_or((king_x, king_y)); + let ahead = tower_y + (tower_y - king_y).signum() * BOT_DEPLOY_AHEAD; Some((card, LogicVector2::new(x, ahead), self.next_instance())) } pub fn pushes_snapshots(&self) -> bool { diff --git a/crates/game-service/tests/walk_to_tower.rs b/crates/game-service/tests/walk_to_tower.rs new file mode 100644 index 0000000..864abf4 --- /dev/null +++ b/crates/game-service/tests/walk_to_tower.rs @@ -0,0 +1,207 @@ +use std::path::Path; +use std::sync::Arc; +use game_service::battle::BattleBuilder; +use logic::battle::{LogicVector2, BATTLE_TICKS_PER_SECOND}; +use logic::data::{table, LogicDataRef, LogicDataTables}; +use logic::model::LogicClientAvatar; +use titan::LogicLong; +fn assets() -> std::path::PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets") +} +fn avatar(low: i32) -> LogicClientAvatar { + let account = LogicLong::new(0, low); + LogicClientAvatar { + avatar_id: account, + account_id: account, + home_id: account, + name_change_state: -1, + ..LogicClientAvatar::default() + } +} +#[test] +fn a_troop_walks_at_the_enemy_towers_and_stops_in_range() { + let root = assets(); + let tables = LogicDataTables::load_from_dir(&root).expect("tables"); + LogicDataTables::install(Arc::new(tables)); + 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 towers: Vec<(i32, (i32, i32))> = mode + .battle + .objects + .objects + .iter() + .map(|entry| (entry.owner_index(), entry.position())) + .collect(); + println!("towers: {towers:?}"); + let spell = LogicDataRef::spell("Knight"); + let entries = builder.summon(&spell, LogicVector2::new(9000, 20000), 0, 0, 100); + assert!(!entries.is_empty(), "the knight spell summons nothing"); + let id = entries[0].global_id; + for entry in entries { + mode.battle.objects.push(entry); + } + let mut last = None; + for tick in 0..(30 * BATTLE_TICKS_PER_SECOND) { + mode.battle.tick(tick); + let here = mode + .battle + .objects + .objects + .iter() + .find(|entry| entry.global_id == id) + .map(|entry| entry.position()); + if here.is_some() { + last = here; + } + if tick % 40 == 0 { + println!("t={tick:4} pos={here:?}"); + } + for entry in mode.battle.objects.objects.iter() { + let (x, y) = entry.position(); + assert!( + (0..=18000).contains(&x) && (0..=33000).contains(&y), + "t={tick}: an object left the arena at ({x}, {y})" + ); + } + } + let end = last.expect("the knight never existed"); + println!("last seen at {end:?}"); + let tower = (14500, 25500); + let reach = ((end.0 - tower.0) as f64).hypot((end.1 - tower.1) as f64); + println!("distance to the near enemy tower: {reach:.0}"); + assert!( + reach < 2500.0, + "the knight never reached the enemy tower, stopped {reach:.0} away at {end:?}" + ); +} +#[test] +fn the_bot_deploys_in_front_of_its_own_towers() { + let root = assets(); + if let Ok(tables) = LogicDataTables::load_from_dir(&root) { + LogicDataTables::install(Arc::new(tables)); + } + let builder = BattleBuilder::new(&root); + let mut deck = logic::model::LogicSpellDeck::default(); + for (slot, name) in ["Knight", "Archers", "Goblins", "Giant"].iter().enumerate() { + deck.slots[slot] = Some(logic::model::LogicSpell { + data: LogicDataRef::spell(name), + ..logic::model::LogicSpell::default() + }); + } + 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)], + [Some(deck.clone()), Some(deck)], + 7, + ) + .expect("battle"); + let mut session = game_service::battle_session::BattleSession::new(mode, Vec::new()); + let mut plays = 0; + for tick in 0..(180 * BATTLE_TICKS_PER_SECOND) { + if tick % 20 == 0 { + if let Some((card, at, instance)) = session.bot_play() { + let entries = builder.summon(&card, at, 1, 0, instance); + session.reserve_instances(entries.len()); + for entry in entries { + session.push(entry); + } + plays += 1; + if plays <= 6 { println!("bot played at {:?}", (at.x, at.y)); } + } + } + session.advance_to(tick); + if session.is_finished() || session.towers_standing().0 == 0 { + println!("battle over at t={tick} after {plays} bot plays"); + break; + } + for entry in session.mode().battle.objects.objects.iter() { + if entry.stats().speed < 1 { + continue; + } + let (x, y) = entry.position(); + if !((500..=17500).contains(&x) && (1000..=31000).contains(&y)) { + println!( + "t={tick}: troop owner={} speed={} alive={} at ({x}, {y})", + entry.owner_index(), + entry.stats().speed, + entry.is_alive() + ); + for other in session.mode().battle.objects.objects.iter() { + println!( + " owner={} speed={} alive={} at {:?}", + other.owner_index(), + other.stats().speed, + other.is_alive(), + other.position() + ); + } + panic!("a troop walked out of the arena"); + } + } + } + assert!(plays > 0, "the bot never played a card"); +} +#[test] +fn a_bot_troop_walks_at_the_player_towers() { + let root = assets(); + if let Ok(tables) = LogicDataTables::load_from_dir(&root) { + LogicDataTables::install(Arc::new(tables)); + } + 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 entries = builder.summon( + &LogicDataRef::spell("Knight"), + LogicVector2::new(14500, 23500), + 1, + 0, + 200, + ); + let id = entries[0].global_id; + for entry in entries { + mode.battle.objects.push(entry); + } + let mut last = None; + for tick in 0..(20 * BATTLE_TICKS_PER_SECOND) { + mode.battle.tick(tick); + let here = mode + .battle + .objects + .objects + .iter() + .find(|e| e.global_id == id) + .map(|e| e.position()); + if here.is_some() { + last = here; + } + if tick % 40 == 0 { + println!("t={tick:4} bot troop at {here:?}"); + } + } + let end = last.expect("the bot troop never existed"); + println!("bot troop finished at {end:?}"); + assert!( + end.1 < 23500, + "the bot troop walked away from the player towers to {end:?}" + ); +} diff --git a/crates/logic/src/battle/logic_simulation.rs b/crates/logic/src/battle/logic_simulation.rs index ed4d3fe..2997a1b 100644 --- a/crates/logic/src/battle/logic_simulation.rs +++ b/crates/logic/src/battle/logic_simulation.rs @@ -268,20 +268,24 @@ impl LogicBattle { fn default_target(&self, index: usize) -> Option { let owner = self.objects.objects[index].owner_index(); let from = self.objects.objects[index].position(); - let mut best: Option<(i32, usize)> = None; + 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; } - if !entry.stats().is_building() { - continue; - } let distance = distance_squared(from, entry.position()); - if best.map(|(closest, _)| distance < closest).unwrap_or(true) { - best = Some((distance, other)); + 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)); } } - best.map(|(_, other)| other) + building.or(anything).map(|(_, other)| other) } fn next_waypoint( &mut self, @@ -446,14 +450,22 @@ impl LogicBattle { 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 + (push.0 / push.2 as i64) as i32).clamp(0, limit_x); - base.position.y = (base.position.y + (push.1 / push.2 as i64) as i32).clamp(0, limit_y); + 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) {