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.
This commit is contained in:
WiseDev 2026-08-23 17:29:40 +03:00
parent 3d0b75b444
commit f5a8c39ab2
3 changed files with 252 additions and 21 deletions

View file

@ -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 BOT_DEPLOY_AHEAD: i32 = 2000;
use std::time::Instant; use std::time::Instant;
use logic::battle::{ use logic::battle::{
LogicGameMode, LogicGameObjectEntry, LogicVector2, BATTLE_TICKS_PER_SECOND, BATTLE_TYPE_PVP, LogicGameMode, LogicGameObjectEntry, LogicVector2, BATTLE_TICKS_PER_SECOND, BATTLE_TYPE_PVP,
@ -107,24 +108,35 @@ impl BattleSession {
.objects .objects
.objects .objects
.iter() .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()) .map(|entry| entry.position())
.collect(); .collect();
let leader = self.mode.battle.leader(crate::bot::BOT_OWNER_INDEX as usize)?; let leader = self.mode.battle.leader(crate::bot::BOT_OWNER_INDEX as usize)?;
let (king_x, king_y) = leader.position(); let (king_x, king_y) = leader.position();
let ahead = towers let front = towers
.iter() .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::<Vec<_>>();
let x = lane_x
.get(self.random.next(lane_x.len().max(1) as i32).max(0) as usize)
.copied() .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())) Some((card, LogicVector2::new(x, ahead), self.next_instance()))
} }
pub fn pushes_snapshots(&self) -> bool { pub fn pushes_snapshots(&self) -> bool {

View file

@ -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:?}"
);
}

View file

@ -268,20 +268,24 @@ impl LogicBattle {
fn default_target(&self, index: usize) -> Option<usize> { fn default_target(&self, index: usize) -> Option<usize> {
let owner = self.objects.objects[index].owner_index(); let owner = self.objects.objects[index].owner_index();
let from = self.objects.objects[index].position(); 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() { for (other, entry) in self.objects.objects.iter().enumerate() {
if other == index || entry.owner_index() == owner || !entry.is_alive() { if other == index || entry.owner_index() == owner || !entry.is_alive() {
continue; continue;
} }
if !entry.stats().is_building() {
continue;
}
let distance = distance_squared(from, entry.position()); let distance = distance_squared(from, entry.position());
if best.map(|(closest, _)| distance < closest).unwrap_or(true) { let closer = |best: &Option<(i32, usize)>| {
best = Some((distance, other)); 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( fn next_waypoint(
&mut self, &mut self,
@ -446,14 +450,22 @@ impl LogicBattle {
if push.2 == 0 { if push.2 == 0 {
continue; 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 let (limit_x, limit_y) = self
.tilemap .tilemap
.as_ref() .as_ref()
.map(|map| (map.width() * SUBTILE_UNITS - 1, map.height() * SUBTILE_UNITS - 1)) .map(|map| (map.width() * SUBTILE_UNITS - 1, map.height() * SUBTILE_UNITS - 1))
.unwrap_or((i32::MAX, i32::MAX)); .unwrap_or((i32::MAX, i32::MAX));
let base = self.objects.objects[index].body.base_mut(); 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.x = (base.position.x + shove.0 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.y = (base.position.y + shove.1 as i32).clamp(0, limit_y);
} }
} }
fn resolve_attacks(&mut self) { fn resolve_attacks(&mut self) {