scroll.server/crates/game-service/tests/walk_to_tower.rs
WiseDev c18ce37a42 gate the heartbeat off; it was resyncing the client every turn
the live log showed the client requesting a full sector state every
500ms - one per turn, in lockstep with the heartbeat. the heartbeat
verifies the client's predicted checksum against ours, and the sim is
not yet bit-exact, so most turns mismatch, flip the client out of sync,
and it re-downloads the whole state. that resync storm is the "86%,
70%..." loading flicker at battle start and it hard-resets the battle
scene every turn, so nothing on screen can settle.

the heartbeat now stays off unless SCROLL_HEARTBEAT=1. this returns the
client to the snapshot-only path (a hard reset every 4 ticks, but no
per-turn off-sync request). turn it back on once checksums reliably
agree.
2026-08-24 20:13:16 +03:00

589 lines
20 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.clone())],
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;
}
#[test]
fn a_deployed_troop_counts_down_and_holds_still_until_ready() {
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");
let entries = builder.summon(
&LogicDataRef::spell("Knight"),
LogicVector2::new(9000, 20000),
0,
0,
100,
);
let id = entries[0].global_id;
let deploy = entries[0]
.data
.data()
.map(|r| r.int("DeployTime"))
.unwrap_or(0);
assert!(deploy >= 1, "the knight has a deploy time");
let spawn_pos = entries[0].position();
for entry in entries {
mode.battle.objects.push(entry);
}
let timer_of = |mode: &logic::battle::LogicGameMode| {
mode.battle
.objects
.objects
.iter()
.find(|e| e.global_id == id)
.and_then(|e| match &e.body {
logic::battle::LogicObjectBody::Character(c) => Some(c.deploy_timer),
_ => None,
})
};
mode.battle.tick(1);
assert_eq!(timer_of(&mode), Some(deploy - 50), "deploy timer counts down");
let here = mode
.battle
.objects
.objects
.iter()
.find(|e| e.global_id == id)
.unwrap()
.position();
assert_eq!(here, spawn_pos, "a deploying troop holds still");
for tick in 2..(deploy / 50 + 40) {
mode.battle.tick(tick);
}
assert_eq!(timer_of(&mode), Some(0), "deploy finished");
let moved = mode
.battle
.objects
.objects
.iter()
.find(|e| e.global_id == id)
.unwrap()
.position();
assert_ne!(moved, spawn_pos, "a deployed troop moves");
}
#[test]
fn a_troop_in_range_damages_the_tower_with_the_client_hit_model() {
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");
let (tower_id, tower_pos, start_hp) = mode
.battle
.objects
.objects
.iter()
.find_map(|e| match &e.body {
logic::battle::LogicObjectBody::Character(_) if e.owner_index() == 1 => {
let hp = e
.components
.get(2)
.and_then(|c| c.as_ref())
.and_then(|c| match c {
logic::battle::LogicComponent::Hitpoint(h) => Some(h.hitpoints),
_ => None,
})?;
Some((e.global_id, e.position(), hp))
}
_ => None,
})
.expect("an enemy princess tower");
let entries = builder.summon(
&LogicDataRef::spell("Knight"),
LogicVector2::new(tower_pos.0, tower_pos.1 - 800),
0,
0,
100,
);
for entry in entries {
mode.battle.objects.push(entry);
}
for tick in 1..200 {
mode.battle.tick(tick);
}
let hp = mode
.battle
.objects
.objects
.iter()
.find(|e| e.global_id == tower_id)
.and_then(|e| e.components.get(2))
.and_then(|c| c.as_ref())
.and_then(|c| match c {
logic::battle::LogicComponent::Hitpoint(h) => Some(h.hitpoints),
_ => None,
})
.expect("tower still present");
assert!(hp < start_hp, "the knight's attacks should reduce tower hp ({hp} !< {start_hp})");
}
#[test]
fn the_session_emits_a_heartbeat_every_turn() {
let root = assets();
if let Ok(t) = LogicDataTables::load_from_dir(&root) {
LogicDataTables::install(Arc::new(t));
}
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");
std::env::set_var("SCROLL_HEARTBEAT", "1");
let mut session = game_service::battle_session::BattleSession::new(mode, Vec::new());
session.advance_to(25);
let out = session.take_outbound();
std::env::remove_var("SCROLL_HEARTBEAT");
let heartbeats: Vec<&service_rpc::WireMessage> = out
.iter()
.filter(|m| m.message_type == 21902)
.collect();
assert_eq!(heartbeats.len(), 2, "expected a heartbeat at ticks 10 and 20");
let mut reader = titan::ByteStreamReader::new(&heartbeats[0].payload);
let turn = reader.read_vint().unwrap();
let checksum = reader.read_vint().unwrap();
assert_eq!(turn, 1);
assert_eq!(Some(checksum), session.checksum_at(10));
}
#[test]
fn playing_a_card_cycles_the_summoner_hand() {
let root = assets();
if let Ok(t) = LogicDataTables::load_from_dir(&root) {
LogicDataTables::install(Arc::new(t));
}
let mut deck = logic::model::LogicSpellDeck::default();
let names = [
"Knight", "Archers", "Goblins", "Giant", "Fireball", "Arrows", "Minions", "Musketeer",
];
for (slot, name) in names.iter().enumerate().take(deck.slots.len()) {
deck.slots[slot] = Some(logic::model::LogicSpell {
data: LogicDataRef::spell(name),
..logic::model::LogicSpell::default()
});
}
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)],
[Some(deck.clone()), Some(deck.clone())],
7,
)
.expect("battle");
let mut session = game_service::battle_session::BattleSession::new(mode, Vec::new());
let hand_of = |session: &game_service::battle_session::BattleSession| {
session
.mode()
.battle
.objects
.objects
.iter()
.find_map(|e| match &e.body {
logic::battle::LogicObjectBody::Summoner(s) if e.owner_index() == 0 => {
s.deck.as_ref().map(|d| (d.hand, d.used_pile.clone(), d.spell_cooldown))
}
_ => None,
})
.expect("player summoner with a deck")
};
let (hand0, used0, _) = hand_of(&session);
let filled = deck.filled_slot_count();
assert!(filled >= 5, "need a draw pile for this test");
let played = hand0[0];
assert!(played >= 0, "hand slot 0 holds a card");
session.play_from_hand(0, played);
let (hand1, used1, _) = hand_of(&session);
assert_eq!(hand1[0], -1, "the played slot is now empty");
assert!(used1.contains(&played), "the card went to the used pile");
assert_eq!(used1.len(), used0.len() + 1);
for _ in 0..6 {
session.advance_to(session.tick() + MAX_CATCH_UP_TICKS);
}
let (hand2, _, _) = hand_of(&session);
assert_ne!(hand2[0], -1, "the slot refilled from the draw pile");
assert_ne!(hand2[0], played, "with a different card than the one played");
}
#[test]
fn a_multi_unit_card_spreads_its_units() {
let root = assets();
if let Ok(t) = LogicDataTables::load_from_dir(&root) {
LogicDataTables::install(Arc::new(t));
}
let builder = BattleBuilder::new(&root);
let entries = builder.summon(&LogicDataRef::spell("Goblins"), LogicVector2::new(9000, 14500), 0, 0, 100);
assert!(entries.len() >= 3, "goblins summon multiple units");
let positions: Vec<(i32, i32)> = entries.iter().map(|e| e.position()).collect();
for i in 0..positions.len() {
for j in (i + 1)..positions.len() {
assert_ne!(positions[i], positions[j], "units must not stack on one point");
}
}
}