use game_service::battle::BattleBuilder; use game_service::battle_session::MAX_CATCH_UP_TICKS; use logic::battle::{verify_snapshot, LogicVector2, BATTLE_TICKS_PER_SECOND}; use logic::data::{table, LogicDataRef, LogicDataTables}; use logic::model::LogicClientAvatar; use std::path::Path; use std::sync::Arc; 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 ranged_fire_roundtrips() { 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 spell = LogicDataRef::spell("Archer"); let entries = builder.summon(&spell, LogicVector2::new(14500, 23500), 0, 0, 100); assert!(!entries.is_empty(), "the archer spell summons nothing"); for entry in entries { mode.battle.objects.push(entry); } for tick in 0..(15 * BATTLE_TICKS_PER_SECOND) { mode.battle.tick(tick); let snapshot = mode.snapshot(&[]).expect("snapshot encodes"); let report = verify_snapshot(&snapshot); assert!( report.is_ok(), "t={tick}: snapshot did not read back: {:?} trailing={}", report.error, report.trailing ); } } #[test] fn troop_walks_and_stops() { 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 < 3000.0, "the knight never reached the enemy tower, stopped {reach:.0} away at {end:?}" ); } #[test] fn bot_deploys_at_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, level)) = session.bot_play() { let entries = builder.summon(&card, at, 1, level, 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::>() ); } } } 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}: leader {leader:?} gone" ); } 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 !((0..=17999).contains(&x) && (0..=31999).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, "bot never played"); } #[test] fn bot_troop_walks() { 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, "bot troop at {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(), "leader {leader:?} not found" ); } } #[test] fn finished_battle_stops() { 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(), "battle never ended"); let settled = session.tick(); session.advance_to(settled + 10_000); assert_eq!( session.tick(), settled, "a finished battle carried on ticking" ); } #[test] fn dormant_king_combat_bit() { 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, "combat bit not cleared"); kings += 1; } logic::battle::LogicObjectBody::Character(_) => { assert_eq!(mask, 13, "a princess tower keeps its combat bit"); princesses += 1; } logic::battle::LogicObjectBody::Projectile(_) => {} } } 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, "idle hash mismatch"); let _ = a; } #[test] fn deploy_countdown_holds() { 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 in_range_damage() { 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 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, "wrong card" ); } #[test] fn multi_unit_spread() { 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" ); } } } #[test] fn snapshot_with_command_roundtrips() { let root = assets(); let tables = LogicDataTables::load_from_dir(&root).expect("tables"); 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 played = logic::LogicDoSpellCommand { header: logic::LogicCommandHeader { tick_when_given: 125, execute_tick: 145, executor_account_id: LogicLong::new(0, 5), }, deck_slot: 0, spell: Some(logic::model::LogicSpell { data: LogicDataRef::spell("Knight"), ..Default::default() }), position: LogicVector2::new(10500, 12500), }; let mut writer = titan::ByteStreamWriter::new(); logic::LogicCommandManager::encode_command(&mut writer, &played).expect("command encodes"); let encoded = writer.into_inner(); for commands in [ Vec::new(), vec![encoded.clone()], vec![encoded.clone(), encoded], ] { let snapshot = mode.snapshot(&commands).expect("snapshot encodes"); let report = verify_snapshot(&snapshot); assert!( report.is_ok(), "{} command(s): snapshot did not read back: {:?} trailing={}", commands.len(), report.error, report.trailing ); } } #[test] fn deploy_search_matches_client() { let root = assets(); let tables = LogicDataTables::load_from_dir(&root).expect("tables"); 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 tilemap = mode.battle.tilemap.as_ref().expect("tilemap"); let knight = LogicDataRef::by_name(table::CHARACTERS_COMBINED, "Knight"); let blockers = mode.battle.deploy_blockers(true); assert_eq!(blockers.len(), 6); assert!(blockers.contains(&logic::battle::DeployBlocker { x: 3500, y: 25500, size_w: 11, size_h: 21 })); assert!(blockers.contains(&logic::battle::DeployBlocker { x: 3500, y: 6500, size_w: 3, size_h: 3 })); for (raw, expected) in [ ((0, 0), (500, 1500)), ((9000, 15900), (8500, 14500)), ((9000, 22000), (8500, 14500)), ((3500, 22000), (3500, 14500)), ((3500, 10000), (3500, 10500)), ((12500, 9500), (12500, 9500)), ] { let found = logic::battle::find_position_for_spell( knight.as_character().as_ref(), LogicVector2::new(raw.0, raw.1), tilemap, &blockers, false, ) .unwrap_or_else(|| panic!("{raw:?} should resolve to a position")); assert_eq!((found.x, found.y), expected, "drop at {raw:?} landed wrong"); } } #[test] fn refused_drop_spawns_nothing() { let root = assets(); let tables = LogicDataTables::load_from_dir(&root).expect("tables"); 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 tilemap = mode.battle.tilemap.as_ref().expect("tilemap"); let knight = LogicDataRef::spell("Knight"); for (tile, raw) in [((25, 31), (12750, 15750)), ((0, 0), (0, 0))] { assert!( !tilemap.can_place_egg(tile.0, tile.1), "tile {tile:?} should refuse a deploy" ); let point = LogicVector2::new(raw.0, raw.1); assert_eq!( logic::battle::check_spell_position(tilemap, point.x, point.y, true), 5 ); assert!(mode .battle .resolve_spell_position(&knight, point, 0) .is_none()); } assert_eq!( logic::battle::check_spell_position(tilemap, 12750, 15750, false), 0 ); } #[test] fn mana_already_committed_cannot_be_spent_twice() { 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"); let mut session = game_service::battle_session::BattleSession::new(mode, Vec::new()); session.advance_to(120); let available = session.simulated_mana(0); assert!(available > 0, "no mana by t120"); let entries = builder.summon( &LogicDataRef::spell("Knight"), LogicVector2::new(9500, 14500), 0, 0, session.next_instance(), ); session.queue( 120, LogicVector2::new(9500, 14500), 0, available, 0, entries, ); assert_eq!( session.simulated_mana(0), 0, "a card in flight has already claimed its mana" ); }