the hook showed a battle at tick 4177 - two hundred and eight seconds of a hundred and eighty second match - sitting at three crowns to nil with the player holding no towers at all. advance_to never asked whether the battle was over, so the clock ran past the end, the bot kept playing cards, and the snapshots kept coming. anything the player put down after that walked into an army that had been piling up for a minute, which is what "my units do not spawn" actually looked like. is_end_condition_matched was right all along - it reads the leaders and the clock and says so. nothing called it. now the tick loop breaks on it, and the harness walks a match to its end and checks the tick stops moving. two things ruled out while looking. the account ids are right: the client reads its own as 0-5, finds itself at index 0 and takes the bottom avatar, and the 0-0 lookups in the trace are it resolving the bot. and "visitor" is cosmetic - getHomeTeamIndex defaults to 1 when both avatars share an arena, which ours do.
298 lines
10 KiB
Rust
298 lines
10 KiB
Rust
use std::path::Path;
|
|
use std::sync::Arc;
|
|
use game_service::battle::BattleBuilder;
|
|
use game_service::battle_session::MAX_CATCH_UP_TICKS;
|
|
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", "Cannon", "GoblinHut", "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);
|
|
let entries_debug = entries.clone();
|
|
session.reserve_instances(entries.len());
|
|
for entry in entries {
|
|
session.push(entry);
|
|
}
|
|
assert!(
|
|
(17000..25000).contains(&at.y) && (at.x == 3500 || at.x == 14500),
|
|
"the bot deployed at {:?}, which is not in front of one of its towers",
|
|
(at.x, at.y)
|
|
);
|
|
plays += 1;
|
|
if plays <= 8 {
|
|
println!(
|
|
"bot played {:?} at {:?}, {} entries, speeds {:?}",
|
|
card,
|
|
(at.x, at.y),
|
|
entries_debug.len(),
|
|
entries_debug.iter().map(|e| e.stats().speed).collect::<Vec<_>>()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
for entry in session.mode().battle.objects.objects.iter() {
|
|
let (x, y) = entry.position();
|
|
if !((500..=17500).contains(&x) && (1000..=31000).contains(&y)) {
|
|
println!(
|
|
"t={tick}: object 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:?}"
|
|
);
|
|
}
|
|
#[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"
|
|
);
|
|
}
|
|
}
|
|
#[test]
|
|
fn a_finished_battle_stops_ticking() {
|
|
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");
|
|
let mut session = game_service::battle_session::BattleSession::new(mode, Vec::new());
|
|
for _ in 0..400 {
|
|
session.advance_to(session.tick() + MAX_CATCH_UP_TICKS);
|
|
}
|
|
assert!(session.is_finished(), "the battle never reached its end");
|
|
let settled = session.tick();
|
|
session.advance_to(settled + 10_000);
|
|
assert_eq!(
|
|
session.tick(),
|
|
settled,
|
|
"a finished battle carried on ticking"
|
|
);
|
|
}
|