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.
344 lines
12 KiB
Rust
344 lines
12 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"
|
|
);
|
|
}
|
|
#[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;
|
|
}
|