diff --git a/crates/game-service/src/battle.rs b/crates/game-service/src/battle.rs index 6eda1a5..fc2a025 100644 --- a/crates/game-service/src/battle.rs +++ b/crates/game-service/src/battle.rs @@ -1,25 +1,17 @@ -use std::path::{Path, PathBuf}; use logic::battle::{ LogicBattle, LogicCharacter, LogicCharacterBuffComponent, LogicCombatComponent, LogicComponent, LogicGameMode, LogicGameObject, LogicGameObjectEntry, LogicGameObjectManager, LogicGameObjectRef, LogicHitpointComponent, LogicMovementComponent, LogicObjectBody, - LogicSummoner, LogicSummonerDeck, - LogicTilemap, - LogicTime, LogicVector2, BATTLE_TYPE_PVP, CHARACTER_OBJECT_TYPE, COMPONENT_PASSES, - DIRECTION_BOTTOM, - DIRECTION_TOP, SUBTILE_UNITS, + LogicSummoner, LogicSummonerDeck, LogicTilemap, LogicTime, LogicVector2, BATTLE_TYPE_PVP, + CHARACTER_OBJECT_TYPE, COMPONENT_PASSES, DIRECTION_BOTTOM, DIRECTION_TOP, SUBTILE_UNITS, }; use logic::model::{LogicClientAvatar, LogicSpellDeck}; use logic::{table, LogicDataRef, LogicDataTables, LogicGlobals, LogicRandom}; -pub const LOCATION_FILE_COLUMN: &str = "FileName"; +use std::path::{Path, PathBuf}; pub const CHARACTER_KING_TOWER: &str = "KingTower"; pub const CHARACTER_PRINCESS_TOWER: &str = "PrincessTower"; pub const BUFF_ARRAY_TABLE: i32 = table::DAMAGE_TYPES; pub const START_MANA_GLOBAL: &str = "START_MANA"; -pub const CHARACTER_HITPOINTS_COLUMN: &str = "Hitpoints"; -pub const CHARACTER_SPEED_COLUMN: &str = "Speed"; -pub const SPELL_SUMMON_CHARACTER_COLUMN: &str = "SummonCharacter"; -pub const SPELL_SUMMON_NUMBER_COLUMN: &str = "SummonNumber"; pub struct CharacterSpec { pub data: LogicDataRef, pub instance: i32, @@ -81,8 +73,8 @@ impl BattleBuilder { } pub fn tilemap_for(&self, location: &LogicDataRef) -> std::io::Result { let file_name = location - .data() - .map(|data| data.string(LOCATION_FILE_COLUMN).to_owned()) + .as_location() + .map(|location| location.file_name().to_owned()) .unwrap_or_default(); if file_name.is_empty() { return Err(std::io::Error::other("the location names no tilemap")); @@ -90,18 +82,26 @@ impl BattleBuilder { LogicTilemap::load(&self.root, &file_name) } fn hitpoints_of(data: &LogicDataRef, level_index: i32) -> i32 { - data.data() - .map(|row| row.int_at(CHARACTER_HITPOINTS_COLUMN, level_index.max(0) as usize)) + data.as_character() + .map(|character| character.hitpoints(level_index.max(0) as usize)) .filter(|value| *value > 0) .unwrap_or(1) } - fn components_for(&self, hitpoints: i32, moves: bool) -> [Option; COMPONENT_PASSES] { + fn components_for( + &self, + hitpoints: i32, + moves: bool, + life_time: i32, + ) -> [Option; COMPONENT_PASSES] { + let hitpoint = if life_time >= 1 { + LogicHitpointComponent::with_lifetime(hitpoints, life_time) + } else { + LogicHitpointComponent::healthy(hitpoints) + }; [ Some(LogicComponent::Combat(LogicCombatComponent::default())), moves.then(|| LogicComponent::Movement(LogicMovementComponent::default())), - Some(LogicComponent::Hitpoint(LogicHitpointComponent::healthy( - hitpoints, - ))), + Some(LogicComponent::Hitpoint(hitpoint)), Some(LogicComponent::Buff(LogicCharacterBuffComponent::empty( self.buff_type_count, ))), @@ -117,14 +117,28 @@ impl BattleBuilder { } = spec; let hitpoints = Self::hitpoints_of(&data, level_index); let moves = data - .data() - .map(|row| row.int(CHARACTER_SPEED_COLUMN) > 0) + .as_character() + .map(|character| character.speed() > 0) .unwrap_or(false); let deploy_time = data - .data() - .map(|row| row.int(logic::battle::CHARACTER_DEPLOY_TIME_COLUMN)) + .as_character() + .map(|character| character.deploy_time()) .unwrap_or(0) .max(0); + let spawn_start = data + .as_character() + .map(|character| character.spawn_start_time()) + .unwrap_or(0) + .max(0); + let spawn_limit = data + .as_character() + .map(|character| character.spawn_limit()) + .unwrap_or(0) + .max(0); + let life_time = data + .as_character() + .map(|character| character.life_time()) + .unwrap_or(0); let character = LogicCharacter { level_index, base: LogicGameObject { @@ -148,9 +162,11 @@ impl BattleBuilder { 0 }, deploy_timer: deploy_time, + spawn_timer: spawn_start, + remaining_spawn_count: spawn_limit, ..LogicCharacter::default() }; - let components = self.components_for(hitpoints, moves); + let components = self.components_for(hitpoints, moves, life_time); LogicGameObjectEntry::new( data, LogicGameObjectRef::of(CHARACTER_OBJECT_TYPE + 1, instance), @@ -166,10 +182,10 @@ impl BattleBuilder { level_index: i32, first_instance: i32, ) -> Vec { - let Some(row) = spell.data() else { + let Some(summon) = spell.as_spell() else { return Vec::new(); }; - let name = row.string(SPELL_SUMMON_CHARACTER_COLUMN).to_owned(); + let name = summon.summon_character().to_owned(); if name.is_empty() { return Vec::new(); } @@ -178,15 +194,14 @@ impl BattleBuilder { tracing::warn!(spell = %spell, character = name, "the spell summons a character the tables do not have"); return Vec::new(); } - let count = row.int(SPELL_SUMMON_NUMBER_COLUMN).max(1); + let count = summon.summon_number().max(1); let collision_radius = data - .data() - .map(|row| row.int("CollisionRadius")) + .as_character() + .map(|character| character.collision_radius()) .unwrap_or(0); (0..count) .map(|index| { - let (dx, dy) = - logic::spawn_offset(index, count, collision_radius, owner == 0); + let (dx, dy) = logic::spawn_offset(index, count, collision_radius, owner == 0); self.character(CharacterSpec { data: data.clone(), instance: first_instance + index, @@ -241,7 +256,7 @@ impl BattleBuilder { data, LogicGameObjectRef::of(CHARACTER_OBJECT_TYPE + 1, instance), body, - self.components_for(hitpoints, false), + self.components_for(hitpoints, false, 0), ); if summoner { entry.body.base_mut().component_mask &= !(1 << logic::battle::COMPONENT_COMBAT); @@ -268,8 +283,14 @@ impl BattleBuilder { )); } let deck_slots = [ - decks[0].as_ref().map(LogicSpellDeck::filled_slot_count).unwrap_or(0), - decks[1].as_ref().map(LogicSpellDeck::filled_slot_count).unwrap_or(0), + decks[0] + .as_ref() + .map(LogicSpellDeck::filled_slot_count) + .unwrap_or(0), + decks[1] + .as_ref() + .map(LogicSpellDeck::filled_slot_count) + .unwrap_or(0), ]; let mut objects = LogicGameObjectManager::default(); let mut leaders = [LogicGameObjectRef::NONE; 2]; diff --git a/crates/game-service/src/battle_session.rs b/crates/game-service/src/battle_session.rs index 5271a97..0142d04 100644 --- a/crates/game-service/src/battle_session.rs +++ b/crates/game-service/src/battle_session.rs @@ -1,9 +1,34 @@ use std::collections::HashMap; use std::sync::Arc; pub const SNAPSHOT_INTERVAL_TICKS: i32 = 4; -pub fn heartbeats_enabled() -> bool { - std::env::var("SCROLL_HEARTBEAT").map(|v| v == "1").unwrap_or(false) +#[derive(Debug)] +struct QueuedDeploy { + born: i32, + owner: i32, + mana_cost: i32, + deck_slot: i32, + entries: Vec, } +#[derive(Debug, Clone)] +struct PendingCommand { + tick_when_given: i32, + encoded: Vec, + sent: bool, +} +fn dump_enabled() -> bool { + std::env::var("SCROLL_TRACE_TICK").is_ok() +} +pub fn heartbeats_enabled() -> bool { + std::env::var("SCROLL_HEARTBEAT") + .map(|v| v == "1") + .unwrap_or(false) +} +pub fn test_script_enabled() -> bool { + std::env::var("SCROLL_TEST_SCRIPT") + .map(|v| v == "1") + .unwrap_or(false) +} +pub const TEST_SCRIPT_INTERVAL_TICKS: i32 = 30; pub fn snapshot_interval_ticks() -> i32 { std::env::var("SCROLL_SNAPSHOT_INTERVAL_TICKS") .ok() @@ -14,21 +39,29 @@ pub fn snapshot_interval_ticks() -> i32 { pub const MAX_CATCH_UP_TICKS: i32 = 40; pub const CHECKSUM_HISTORY: usize = 256; pub const TICKS_PER_TURN: i32 = 10; +pub const COMMAND_DELAY_TICKS: i32 = 20; +pub const DEPLOY_BIRTH_TICKS: i32 = COMMAND_DELAY_TICKS + 1; pub const BOT_DEPLOY_AHEAD: i32 = 2000; -use std::time::Instant; +use logic::battle::{verify_snapshot, LogicBattleEvent}; use logic::battle::{ LogicGameMode, LogicGameObjectEntry, LogicVector2, BATTLE_TICKS_PER_SECOND, BATTLE_TYPE_PVP, }; -use logic::battle::{verify_snapshot, LogicBattleEvent}; -use logic::{SectorHeartbeatMessage, SectorStateMessage}; +use logic::messages::{ + BattleResultMessage, BATTLE_RESULT_DRAW, BATTLE_RESULT_LOSE, BATTLE_RESULT_WIN, +}; use logic::{BattleEventMessage, LogicDataRef, LogicRandom}; -use titan::LogicLong; +use logic::{SectorHeartbeatMessage, SectorStateMessage}; use service_rpc::{AccountRef, WireMessage}; +use std::time::Instant; +use titan::{ByteStreamWriter, LogicLong}; use tokio::sync::Mutex; pub struct BattleSession { mode: LogicGameMode, tick: i32, - queued: Vec<(i32, Vec)>, + queued: Vec, + pending_commands: Vec, + state_owed: bool, + last_fire_tick: Option, outbound: Vec, started_at: Instant, random: LogicRandom, @@ -40,7 +73,8 @@ pub struct BattleSession { recent_checksums: std::collections::VecDeque<(i32, i32)>, next_instance: i32, announced: bool, - pending_bot_play: Option<(LogicDataRef, LogicVector2, i32)>, + pending_bot_play: Option<(LogicDataRef, LogicVector2, i32, i32)>, + result_sent: bool, } impl BattleSession { pub fn new(mode: LogicGameMode, taunts: Vec) -> Self { @@ -59,6 +93,9 @@ impl BattleSession { mode, tick: 0, queued: Vec::new(), + pending_commands: Vec::new(), + state_owed: false, + last_fire_tick: None, outbound: Vec::new(), started_at: Instant::now(), random, @@ -71,6 +108,7 @@ impl BattleSession { next_instance: first_instance, announced: false, pending_bot_play: None, + result_sent: false, } } pub fn announce(&mut self) -> bool { @@ -100,6 +138,29 @@ impl BattleSession { return; } } + pub fn simulated_mana(&self, owner: i32) -> i32 { + let committed: i32 = self + .queued + .iter() + .filter(|deploy| deploy.owner == owner) + .map(|deploy| deploy.mana_cost) + .sum(); + self.mana_of(owner).saturating_sub(committed).max(0) + } + fn mana_of(&self, owner: i32) -> i32 { + self.mode + .battle + .objects + .objects + .iter() + .find_map(|entry| match (&entry.body, entry.owner_index()) { + (logic::battle::LogicObjectBody::Summoner(summoner), o) if o == owner => { + Some(summoner.mana) + } + _ => None, + }) + .unwrap_or(0) + } pub fn spend_mana(&mut self, owner: i32, cost: i32) { for entry in self.mode.battle.objects.objects.iter_mut() { if entry.owner_index() != owner { @@ -111,24 +172,55 @@ impl BattleSession { } } } - pub fn take_bot_play(&mut self) -> Option<(LogicDataRef, LogicVector2, i32)> { + pub fn take_bot_play(&mut self) -> Option<(LogicDataRef, LogicVector2, i32, i32)> { self.pending_bot_play.take() } - pub fn bot_play(&mut self) -> Option<(LogicDataRef, LogicVector2, i32)> { + pub fn bot_play(&mut self) -> Option<(LogicDataRef, LogicVector2, i32, i32)> { let deck = self.mode.battle.decks.get(1)?.as_ref()?; - let filled: Vec = deck + let filled: Vec<(LogicDataRef, i32)> = deck .slots .iter() .flatten() - .map(|spell| spell.data.clone()) + .map(|spell| (spell.data.clone(), spell.level_index)) .collect(); if filled.is_empty() { return None; } - let card = filled + if test_script_enabled() { + let slot = ((self.tick / TEST_SCRIPT_INTERVAL_TICKS).max(0) as usize) % filled.len(); + let (card, level) = filled[slot].clone(); + let leader = self + .mode + .battle + .leader(crate::bot::BOT_OWNER_INDEX as usize)?; + let (king_x, king_y) = leader.position(); + let (x, tower_y) = self.mode.battle.leader_towers[crate::bot::BOT_OWNER_INDEX as usize] + .iter() + .find_map(|tower| { + self.mode + .battle + .objects + .objects + .iter() + .find(|entry| entry.global_id == *tower && entry.is_alive()) + }) + .map(|entry| entry.position()) + .unwrap_or((king_x, king_y)); + let ahead = tower_y + (tower_y - king_y).signum() * BOT_DEPLOY_AHEAD; + return Some(( + card, + LogicVector2::new(x, ahead), + self.next_instance(), + level, + )); + } + let (card, level) = filled .get(self.random.next(filled.len() as i32).max(0) as usize)? .clone(); - 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 towers: Vec<(i32, i32)> = self.mode.battle.leader_towers [crate::bot::BOT_OWNER_INDEX as usize] @@ -148,25 +240,39 @@ impl BattleSession { .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(), + level, + )) } pub fn pushes_snapshots(&self) -> bool { self.mode.battle.battle_type == BATTLE_TYPE_PVP } pub fn mana(&self) -> Option { - self.mode.battle.objects.objects.iter().find_map(|entry| { - match &entry.body { - logic::battle::LogicObjectBody::Summoner(summoner) - if entry.owner_index() == 0 => - { + self.mode + .battle + .objects + .objects + .iter() + .find_map(|entry| match &entry.body { + logic::battle::LogicObjectBody::Summoner(summoner) if entry.owner_index() == 0 => { Some(summoner.mana) } _ => None, - } - }) + }) } pub fn snapshot_message(&mut self) -> Option { - let snapshot = self.mode.snapshot().ok()?; + let commands = self.commands_to_push(); + if !commands.is_empty() { + tracing::info!( + tick = self.tick, + commands = commands.len(), + "state push carries the client's own commands" + ); + } + let snapshot = self.mode.snapshot(&commands).ok()?; let report = verify_snapshot(&snapshot); let unit = self .mode @@ -245,22 +351,75 @@ impl BattleSession { pub fn seconds(&self) -> i32 { self.tick / BATTLE_TICKS_PER_SECOND } - pub fn queue(&mut self, at_tick: i32, entries: Vec) { + pub fn queue( + &mut self, + at_tick: i32, + deploy: logic::battle::LogicVector2, + owner: i32, + mana_cost: i32, + deck_slot: i32, + mut entries: Vec, + ) -> i32 { if entries.is_empty() { - return; + return (at_tick + DEPLOY_BIRTH_TICKS).max(self.tick + 1); } - self.queued.push((at_tick.max(self.tick + 1), entries)); + if let Some(tilemap) = self.mode.battle.tilemap.as_ref() { + let lane = logic::battle::get_lane_id(tilemap, deploy.x, deploy.y); + for entry in entries.iter_mut() { + entry.body.set_lane_id(lane); + } + } + let due = at_tick + DEPLOY_BIRTH_TICKS; + let born = due.max(self.tick + 1); + let late = born.saturating_sub(due).max(0); + if late > 0 && at_tick > 0 { + let elapsed = late.saturating_mul(logic::battle::TICK_MILLISECONDS); + let moves = |entry: &LogicGameObjectEntry| entry.stats().speed >= 1; + for entry in entries.iter_mut() { + let can_move = moves(entry); + if let logic::battle::LogicObjectBody::Character(character) = &mut entry.body { + if character.deploy_timer > 0 { + character.deploy_timer = (character.deploy_timer - elapsed).max(0); + if character.deploy_timer == 0 { + character.state = if can_move { + logic::battle::CHARACTER_STATE_MOVING + } else { + 0 + }; + } + } + } + } + } + self.queued.push(QueuedDeploy { + born, + owner, + mana_cost, + deck_slot, + entries, + }); + born } pub fn advance_to(&mut self, tick: i32) { let tick = tick.min(self.tick + MAX_CATCH_UP_TICKS); while self.tick < tick { if self.mode.battle.is_end_condition_matched(self.tick) { + if !self.result_sent { + self.result_sent = true; + self.emit_battle_result(); + } break; } self.tick += 1; self.mode.time.tick = self.tick; - self.release_queued(self.tick); + let deployed = self.release_queued(self.tick); + let tick = self.tick; + self.pending_commands + .retain(|command| tick <= command.tick_when_given + COMMAND_DELAY_TICKS); self.mode.battle.tick(self.tick); + let spawned_counter = self.mode.battle.objects.instance_counters + [logic::battle::CHARACTER_OBJECT_TYPE as usize]; + self.next_instance = self.next_instance.max(spawned_counter); let this_checksum = self.mode.calculate_checksum().ok(); if let Some(checksum) = this_checksum { if self.recent_checksums.len() >= CHECKSUM_HISTORY { @@ -268,32 +427,95 @@ impl BattleSession { } self.recent_checksums.push_back((self.tick, checksum)); } - if heartbeats_enabled() - && self.pushes_snapshots() - && self.tick % TICKS_PER_TURN == 0 - { - if let Some(checksum) = this_checksum { - let turn = self.tick / TICKS_PER_TURN; - if let Ok(message) = - crate::wire::encode(&SectorHeartbeatMessage::new(turn, checksum)) - { - self.outbound.push(message); + if let Ok(dump) = std::env::var("SCROLL_TRACE_TICK") { + if dump == "auto" || dump.parse::().ok() == Some(self.tick) { + titan::checksum::checksum_trace_start(); + let dumped = self.mode.calculate_checksum().ok(); + let fields = titan::checksum::checksum_trace_take(); + let path = format!("/tmp/scroll-server-trace-{}.txt", self.tick); + let mut index = 0usize; + let mut lines = Vec::with_capacity(fields.len() + 1); + lines.push(format!("checksum={dumped:?}")); + for field in &fields { + if field.starts_with('#') { + lines.push(field.clone()); + } else { + index += 1; + lines.push(format!("{index}\t{field}")); + } + } + let _ = std::fs::write(&path, lines.join("\n")); + if dump != "auto" { + tracing::warn!(tick = self.tick, fields = fields.len(), path = %path, "dumped checksum field trace"); } } } - if self.pushes_snapshots() && self.tick >= self.next_snapshot { + if heartbeats_enabled() && self.pushes_snapshots() && self.tick % TICKS_PER_TURN == 0 { + if let Some(checksum) = this_checksum { + let turn = self.tick / TICKS_PER_TURN; + let mut commands = Vec::new(); + for command in self.pending_commands.iter_mut().filter(|c| !c.sent) { + command.sent = true; + commands.push(command.encoded.clone()); + } + if !commands.is_empty() { + tracing::info!( + tick, + turn, + commands = commands.len(), + "handing commands back to the client" + ); + } + let message = match (dump_enabled(), self.mode.checksum_stream()) { + (true, Ok((_, stream))) => SectorHeartbeatMessage::with_tick_data( + turn, checksum, &commands, &stream, + ), + _ => SectorHeartbeatMessage::with_commands(turn, checksum, &commands), + }; + if let Ok(message) = crate::wire::encode(&message) { + self.outbound.push(message); + } + if !commands.is_empty() { + if let Some(fires_at) = self.next_command_fire_tick() { + self.next_snapshot = self.next_snapshot.max(fires_at); + } + } + } + } + if deployed { + self.next_snapshot = self.tick + snapshot_interval_ticks(); + } + if self.state_owed && self.next_command_fire_tick().is_none() { + self.state_owed = false; + if let Some(message) = self.snapshot_message() { + self.pushed_snapshots += 1; + self.outbound.push(message); + self.next_snapshot = self.tick + snapshot_interval_ticks(); + } + } + if !deployed && self.pushes_snapshots() && self.tick >= self.next_snapshot { + let carried = self.commands_to_push(); self.next_snapshot = self.tick + snapshot_interval_ticks(); if let Some(message) = self.snapshot_message() { self.pushed_snapshots += 1; self.outbound.push(message); + if !carried.is_empty() { + if let Some(fires_at) = self.next_command_fire_tick() { + self.next_snapshot = self.next_snapshot.max(fires_at); + } + } } } if self.tick >= self.next_bot_play { - let span = crate::bot::BOT_PLAY_MAX_SECONDS - crate::bot::BOT_PLAY_MIN_SECONDS; - let wait = crate::bot::BOT_PLAY_MIN_SECONDS + self.random.next(span.max(1)); - self.next_bot_play = self.tick + wait * BATTLE_TICKS_PER_SECOND; - if let Some((card, position, instance)) = self.bot_play() { - self.pending_bot_play = Some((card, position, instance)); + self.next_bot_play = if test_script_enabled() { + self.tick + TEST_SCRIPT_INTERVAL_TICKS + } else { + let span = crate::bot::BOT_PLAY_MAX_SECONDS - crate::bot::BOT_PLAY_MIN_SECONDS; + let wait = crate::bot::BOT_PLAY_MIN_SECONDS + self.random.next(span.max(1)); + self.tick + wait * BATTLE_TICKS_PER_SECOND + }; + if let Some((card, position, instance, level)) = self.bot_play() { + self.pending_bot_play = Some((card, position, instance, level)); } } if self.tick >= self.next_bot_emote { @@ -306,17 +528,58 @@ impl BattleSession { } } } - fn release_queued(&mut self, tick: i32) { + fn emit_battle_result(&mut self) { + self.mode.battle.resolve_winner(self.tick); + let winner = self.mode.battle.winner_index; + let own_stars = self.mode.battle.stars(0); + let opponent_stars = self.mode.battle.stars(1); + let result = if winner == 0 { + BATTLE_RESULT_WIN + } else if winner == 1 { + BATTLE_RESULT_LOSE + } else { + BATTLE_RESULT_DRAW + }; + let message = BattleResultMessage::result(result, own_stars, opponent_stars, 0, 0, 0, None); + if let Ok(wire) = crate::wire::encode(&message) { + self.outbound.push(wire); + tracing::info!( + tick = self.tick, + winner, + own_stars, + opponent_stars, + "battle ended, sent result" + ); + } + } + fn release_queued(&mut self, tick: i32) -> bool { let mut due = Vec::new(); - self.queued.retain(|(at, entries)| { - if *at <= tick { - due.extend(entries.iter().cloned()); + self.queued.retain(|deploy| { + if deploy.born <= tick { + due.push(( + deploy.owner, + deploy.mana_cost, + deploy.deck_slot, + deploy.entries.clone(), + )); false } else { true } }); - for entry in due { + let released = !due.is_empty(); + if released { + self.last_fire_tick = Some(tick); + } + let mut entries = Vec::new(); + for (owner, mana_cost, deck_slot, deployed) in due { + self.spend_mana(owner, mana_cost); + self.play_from_hand(owner, deck_slot); + entries.extend(deployed); + } + let due = entries; + for mut entry in due { + self.clamp_into_arena(&mut entry); tracing::info!( tick, global_id = ?entry.global_id.0, @@ -328,6 +591,7 @@ impl BattleSession { ); self.mode.battle.objects.push(entry); } + released } pub fn is_finished(&self) -> bool { self.mode.battle.is_end_condition_matched(self.tick) @@ -362,17 +626,116 @@ impl BattleSession { pub fn next_instance(&self) -> i32 { self.next_instance } + pub fn character_counter(&self) -> i32 { + self.mode.battle.objects.instance_counters[logic::battle::CHARACTER_OBJECT_TYPE as usize] + } pub fn reserve_instances(&mut self, count: usize) { self.next_instance += count.max(1) as i32; } pub fn deck_card(&self, owner: i32, slot: i32) -> Option { - let deck = self.mode.battle.decks.get(owner.max(0) as usize)?.as_ref()?; - let spell = deck.slots.get(slot.max(0) as usize)?.as_ref()?; - Some(spell.data.clone()) + self.deck_slot(owner, slot).map(|(data, _)| data) } - pub fn push(&mut self, entry: LogicGameObjectEntry) { + pub fn deck_slot(&self, owner: i32, slot: i32) -> Option<(LogicDataRef, i32)> { + let deck = self + .mode + .battle + .decks + .get(owner.max(0) as usize)? + .as_ref()?; + let spell = deck.slots.get(slot.max(0) as usize)?.as_ref()?; + Some((spell.data.clone(), spell.level_index)) + } + pub fn deliver_command(&mut self, tick_when_given: i32, command: &dyn logic::LogicCommand) { + let mut writer = ByteStreamWriter::new(); + if logic::LogicCommandManager::encode_command(&mut writer, command).is_err() { + tracing::warn!( + command = command.name(), + "could not encode the command to hand back" + ); + return; + } + self.pending_commands.push(PendingCommand { + tick_when_given, + encoded: writer.into_inner(), + sent: false, + }); + } + pub fn state_on_request(&mut self) -> Option { + let skipping_a_firing_tick = self + .fired_command_ticks() + .any(|fires_at| self.tick > fires_at && self.tick <= fires_at + 2); + if skipping_a_firing_tick { + self.state_owed = true; + return None; + } + self.snapshot_message() + } + fn fired_command_ticks(&self) -> impl Iterator + '_ { + self.pending_commands + .iter() + .map(|command| command.tick_when_given + DEPLOY_BIRTH_TICKS) + .chain(self.queued.iter().map(|deploy| deploy.born)) + .chain(self.last_fire_tick) + } + fn next_command_fire_tick(&self) -> Option { + let commands = self + .pending_commands + .iter() + .map(|command| command.tick_when_given + DEPLOY_BIRTH_TICKS); + let deploys = self.queued.iter().map(|deploy| deploy.born); + commands + .chain(deploys) + .filter(|fires_at| *fires_at > self.tick) + .max() + } + fn commands_to_push(&self) -> Vec> { + self.pending_commands + .iter() + .filter(|command| self.tick <= command.tick_when_given + COMMAND_DELAY_TICKS) + .map(|command| command.encoded.clone()) + .collect() + } + fn clamp_into_arena(&self, entry: &mut LogicGameObjectEntry) { + let Some(tilemap) = self.mode.battle.tilemap.as_ref() else { + return; + }; + let units = logic::battle::SUBTILE_UNITS; + let position = entry.position(); + let x = position.0.clamp(250, units * tilemap.width() - 250); + let y = position.1.clamp(250, units * tilemap.height() - 250); + entry.body.base_mut().position = logic::battle::LogicVector2::new(x, y); + } + pub fn push_group( + &mut self, + deploy: logic::battle::LogicVector2, + entries: Vec, + ) { + let lane = self + .mode + .battle + .tilemap + .as_ref() + .map(|tilemap| logic::battle::get_lane_id(tilemap, deploy.x, deploy.y)); + for mut entry in entries { + self.clamp_into_arena(&mut entry); + if let Some(lane) = lane { + entry.body.set_lane_id(lane); + } + self.mode.battle.objects.push(entry); + } + } + pub fn push(&mut self, mut entry: LogicGameObjectEntry) { + self.clamp_into_arena(&mut entry); + self.assign_lane_id(&mut entry); self.mode.battle.objects.push(entry); } + fn assign_lane_id(&self, entry: &mut LogicGameObjectEntry) { + if let Some(tilemap) = self.mode.battle.tilemap.as_ref() { + let pos = entry.position(); + let lane = logic::battle::spawn_lane_of(tilemap, pos.0, pos.1); + entry.body.set_lane_id(lane); + } + } pub fn object_count(&self) -> usize { self.mode.battle.objects.objects.len() } @@ -405,20 +768,36 @@ impl BattleRegistry { } pub async fn tick(&self, account: AccountRef, summon: F) -> Vec where - F: Fn(&LogicDataRef, LogicVector2, i32, i32) -> Vec, + F: Fn(&LogicDataRef, LogicVector2, i32, i32, i32) -> Vec, { let Some(handle) = self.session(account).await else { return Vec::new(); }; let mut session = handle.lock().await; session.advance_to_now(); - if let Some((card, position, instance)) = session.take_bot_play() { - let entries = summon(&card, position, crate::bot::BOT_OWNER_INDEX, instance); - session.reserve_instances(entries.len()); - if !entries.is_empty() { - tracing::debug!(card = %card, count = entries.len(), "the bot played a card"); - for entry in entries { - session.push(entry); + if let Some((card, position, instance, level)) = session.take_bot_play() { + let adjusted = session.mode().battle.resolve_spell_position( + &card, + position, + crate::bot::BOT_OWNER_INDEX, + ); + match adjusted { + None => { + tracing::debug!(card = %card, "the bot picked an illegal drop point, skipping"); + } + Some(adjusted) => { + let entries = summon( + &card, + adjusted, + crate::bot::BOT_OWNER_INDEX, + level, + instance, + ); + session.reserve_instances(entries.len()); + if !entries.is_empty() { + tracing::debug!(card = %card, count = entries.len(), "the bot played a card"); + session.push_group(adjusted, entries); + } } } } @@ -439,7 +818,7 @@ impl BattleRegistry { pub async fn resend(&self, account: AccountRef) -> Option { let handle = self.session(account).await?; let mut session = handle.lock().await; - session.snapshot_message() + session.state_on_request() } pub async fn announce_once(&self, account: AccountRef) -> bool { let Some(handle) = self.session(account).await else { @@ -471,15 +850,25 @@ impl BattleRegistry { pub async fn play( &self, account: AccountRef, - executor: LogicLong, - slot: i32, - position: logic::battle::LogicVector2, - at_tick: i32, + played: &logic::LogicDoSpellCommand, summon: F, ) -> usize where - F: Fn(&LogicDataRef, logic::battle::LogicVector2, i32, i32) -> Vec, + F: Fn( + &LogicDataRef, + logic::battle::LogicVector2, + i32, + i32, + i32, + ) -> Vec, { + let executor = played.header.executor_account_id; + let slot = played.deck_slot; + let position = played.position; + let at_tick = played + .header + .tick_when_given + .max(played.header.execute_tick); let Some(handle) = self.session(account).await else { return 0; }; @@ -487,16 +876,68 @@ impl BattleRegistry { let Some(owner) = session.owner_of(executor) else { return 0; }; - let Some(card) = session.deck_card(owner, slot) else { + let Some((card, level)) = session.deck_slot(owner, slot) else { return 0; }; - let cost = card.data().map(|row| row.int("ManaCost")).unwrap_or(0); - session.spend_mana(owner, cost); - session.play_from_hand(owner, slot); - let entries = summon(&card, position, owner, session.next_instance()); + let Some(adjusted) = session + .mode() + .battle + .resolve_spell_position(&card, position, owner) + else { + tracing::info!( + owner, + slot, + at_tick, + raw = ?(position.x, position.y), + card = %card, + "the drop point is not legal, spawning nothing" + ); + session.deliver_command(at_tick, played); + return 0; + }; + let cost = card.as_spell().map(|spell| spell.mana_cost()).unwrap_or(0); + let affordable = session.simulated_mana(owner); + if cost > affordable { + tracing::info!( + owner, + slot, + at_tick, + cost, + affordable, + card = %card, + "not enough mana once the cards already in flight are counted" + ); + session.deliver_command(at_tick, played); + return 0; + } + let entries = summon(&card, adjusted, owner, level, session.next_instance()); session.reserve_instances(entries.len()); let spawned = entries.len(); - session.queue(at_tick, entries); + tracing::info!( + owner, + slot, + at_tick, + server_tick = session.tick(), + late = (session.tick() + 1 - at_tick).max(0), + assigned = session.next_instance(), + counter = session.character_counter(), + units = spawned, + raw = ?(position.x, position.y), + adjusted = ?(adjusted.x, adjusted.y), + card = %card, + "card played" + ); + let born = session.queue(at_tick, adjusted, owner, cost, slot, entries); + let echo = logic::LogicDoSpellCommand { + header: logic::LogicCommandHeader { + execute_tick: born, + ..played.header + }, + deck_slot: played.deck_slot, + spell: played.spell.clone(), + position: played.position, + }; + session.deliver_command(at_tick, &echo); spawned } } diff --git a/crates/game-service/src/bot.rs b/crates/game-service/src/bot.rs index e496185..3115e0e 100644 --- a/crates/game-service/src/bot.rs +++ b/crates/game-service/src/bot.rs @@ -40,7 +40,10 @@ fn predefined_deck() -> Option { let name = row.string_at(DECK_SPELLS_COLUMN, slot); let data = LogicDataRef::by_name(table::SPELLS, name); if data.is_none() { - tracing::warn!(card = name, "predefined deck names a card the tables do not have"); + tracing::warn!( + card = name, + "predefined deck names a card the tables do not have" + ); return None; } let level = row.int_at(DECK_LEVEL_COLUMN, slot).max(1); diff --git a/crates/game-service/src/catalog.rs b/crates/game-service/src/catalog.rs index 0c9342c..e32f193 100644 --- a/crates/game-service/src/catalog.rs +++ b/crates/game-service/src/catalog.rs @@ -1,7 +1,7 @@ +use crate::config::{CardRef, DataSelector}; +use logic::{table, LogicDataRef, LogicDataTables}; use std::path::Path; use std::sync::Arc; -use logic::{table, LogicDataRef, LogicDataTables}; -use crate::config::{CardRef, DataSelector}; pub const GOLD_RESOURCE_FALLBACK_INSTANCE: i32 = 1; pub const ARENA_FALLBACK_INSTANCE: i32 = 1; pub struct Catalog { diff --git a/crates/game-service/src/config.rs b/crates/game-service/src/config.rs index 139273a..5c60114 100644 --- a/crates/game-service/src/config.rs +++ b/crates/game-service/src/config.rs @@ -1,5 +1,5 @@ -use std::path::PathBuf; use serde::{Deserialize, Serialize}; +use std::path::PathBuf; use storage::DatabaseConfig; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(untagged)] diff --git a/crates/game-service/src/home.rs b/crates/game-service/src/home.rs index 7f3eeeb..d8ac2f9 100644 --- a/crates/game-service/src/home.rs +++ b/crates/game-service/src/home.rs @@ -1,15 +1,15 @@ +use crate::catalog::GOLD_RESOURCE_FALLBACK_INSTANCE; +use crate::store::{OwnedCard, PlayerProfile, StoredChest}; +use logic::commands::COMMODITY_ACHIEVEMENT_PROGRESS; +use logic::data::LogicDataTables; +use logic::data::RESOURCE_GOLD; use logic::model::{ LogicChest, LogicClientAvatar, LogicClientHome, LogicCommodityStore, LogicDataSlot, LogicSpell, LogicSpellCollection, LogicSpellDeck, LogicTimer, }; -use titan::LogicLong; -use logic::data::RESOURCE_GOLD; use logic::{table, LogicDataRef}; -use logic::data::LogicDataTables; -use logic::commands::COMMODITY_ACHIEVEMENT_PROGRESS; use std::sync::Arc; -use crate::catalog::GOLD_RESOURCE_FALLBACK_INSTANCE; -use crate::store::{OwnedCard, PlayerProfile, StoredChest}; +use titan::LogicLong; pub const COMMODITY_RESOURCES: usize = 0; fn spell_of(owned: &OwnedCard) -> LogicSpell { LogicSpell { @@ -156,7 +156,10 @@ pub fn build_avatar(profile: &PlayerProfile) -> LogicClientAvatar { COMMODITY_RESOURCES, vec![LogicDataSlot::new(gold_resource(profile), profile.gold)], ); - commodities.set(COMMODITY_ACHIEVEMENT_PROGRESS, achievement_progress(profile)); + commodities.set( + COMMODITY_ACHIEVEMENT_PROGRESS, + achievement_progress(profile), + ); LogicClientAvatar { avatar_id: account, account_id: account, diff --git a/crates/game-service/src/home_mode.rs b/crates/game-service/src/home_mode.rs index 8a09333..534b4a7 100644 --- a/crates/game-service/src/home_mode.rs +++ b/crates/game-service/src/home_mode.rs @@ -1,5 +1,7 @@ -use std::collections::HashMap; -use std::sync::Arc; +use crate::home::{build_avatar, build_home}; +use crate::rewards::RewardRoller; +use crate::shop::{Purchase, ShopCatalog, ShopCycle, ShopEntry}; +use crate::store::{OwnedCard, PlayerProfile, StoredChest}; use logic::data::{RESOURCE_DIAMONDS, RESOURCE_GOLD}; use logic::{ chest_source, AvailableServerCommandMessage, CommandOutcome, EndClientTurnMessage, @@ -8,11 +10,9 @@ use logic::{ }; use logic::{table, LogicGlobals}; use service_rpc::AccountRef; +use std::collections::HashMap; +use std::sync::Arc; use tokio::sync::{Mutex, RwLock}; -use crate::home::{build_avatar, build_home}; -use crate::rewards::RewardRoller; -use crate::shop::{Purchase, ShopCatalog, ShopCycle, ShopEntry}; -use crate::store::{OwnedCard, PlayerProfile, StoredChest}; pub const MAX_FAST_FORWARD_TICKS: i32 = 20 * 60 * 60; pub const CHEST_FREE: &str = "Free"; pub const CHEST_CROWN: &str = "Star"; diff --git a/crates/game-service/src/main.rs b/crates/game-service/src/main.rs index d9ceefe..49bdeb0 100644 --- a/crates/game-service/src/main.rs +++ b/crates/game-service/src/main.rs @@ -1,6 +1,6 @@ -use std::sync::Arc; use game_service::{GameConfig, GameService}; use service_rpc::serve; +use std::sync::Arc; use tokio::net::TcpListener; #[tokio::main] async fn main() -> Result<(), Box> { diff --git a/crates/game-service/src/matchmaker.rs b/crates/game-service/src/matchmaker.rs index 0589eec..04e5eef 100644 --- a/crates/game-service/src/matchmaker.rs +++ b/crates/game-service/src/matchmaker.rs @@ -1,6 +1,6 @@ +use service_rpc::AccountRef; use std::collections::HashMap; use std::time::{Duration, Instant}; -use service_rpc::AccountRef; use tokio::sync::Mutex; pub const MATCHMAKE_WAIT: Duration = Duration::from_secs(10); #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -16,10 +16,7 @@ pub struct Matchmaker { impl Matchmaker { pub async fn enter(&self, account: AccountRef) -> Matched { let mut waiting = self.waiting.lock().await; - let opponent = waiting - .keys() - .find(|other| **other != account) - .copied(); + let opponent = waiting.keys().find(|other| **other != account).copied(); if let Some(opponent) = opponent { waiting.remove(&opponent); waiting.remove(&account); @@ -33,11 +30,7 @@ impl Matchmaker { let Some(since) = waiting.get(&account).copied() else { return Matched::Waiting; }; - if let Some(opponent) = waiting - .keys() - .find(|other| **other != account) - .copied() - { + if let Some(opponent) = waiting.keys().find(|other| **other != account).copied() { waiting.remove(&opponent); waiting.remove(&account); return Matched::Player(opponent); diff --git a/crates/game-service/src/rewards.rs b/crates/game-service/src/rewards.rs index 1b399af..067313b 100644 --- a/crates/game-service/src/rewards.rs +++ b/crates/game-service/src/rewards.rs @@ -1,9 +1,9 @@ +use crate::catalog::Catalog; use logic::data::{RARITY_COMMON, RARITY_EPIC, RARITY_RARE}; use logic::model::LogicSpell; use logic::{LogicDataRef, LogicReward}; use rand::seq::SliceRandom; use rand::Rng; -use crate::catalog::Catalog; pub struct RewardRoller { pool: Vec, } diff --git a/crates/game-service/src/service.rs b/crates/game-service/src/service.rs index 7a55724..0a8f6e7 100644 --- a/crates/game-service/src/service.rs +++ b/crates/game-service/src/service.rs @@ -1,34 +1,32 @@ -use std::sync::Arc; +use crate::battle::BattleBuilder; +use crate::battle_session::BattleRegistry; +use crate::bot::BotPlayer; +use crate::catalog::Catalog; +use crate::config::GameConfig; +use crate::home::{build_avatar, build_deck}; +use crate::home_mode::{available_server_command, HomeModeRegistry}; +use crate::matchmaker::{Matched, Matchmaker}; +use crate::rewards::RewardRoller; +use crate::shop::{ShopCatalog, ShopCycle}; +use crate::store::{connect_failed, unavailable, PlayerProfile, ProfileStore}; +use crate::time::unix_seconds; use logic::table; use logic::{ AvailableServerCommandMessage, EndClientTurnMessage, LogicCommandManager, LogicDataRef, - LogicShopSeedChangedCommand, OutOfSyncMessage, OwnHomeDataMessage, - SectorCommandMessage, SendBattleEventMessage, - StartMissionMessage, StopHomeLogicMessage, + LogicShopSeedChangedCommand, OutOfSyncMessage, OwnHomeDataMessage, SectorCommandMessage, + SendBattleEventMessage, StartMissionMessage, StopHomeLogicMessage, }; use service_rpc::{ AccountRef, GameApi, GameRequest, GameResponse, HomeRequestKind, RpcError, RpcResult, RpcService, WireMessage, }; +use std::sync::Arc; use titan::{Message, MessageMeta, Payload}; -use crate::battle::BattleBuilder; -use crate::battle_session::BattleRegistry; -use crate::matchmaker::{Matched, Matchmaker}; -use crate::catalog::Catalog; -use crate::config::GameConfig; -use crate::bot::BotPlayer; -use crate::home::{build_avatar, build_deck}; -use crate::home_mode::{available_server_command, HomeModeRegistry}; -use crate::rewards::RewardRoller; -use crate::shop::{ShopCatalog, ShopCycle}; -use crate::store::{connect_failed, unavailable, PlayerProfile, ProfileStore}; -use crate::time::unix_seconds; -pub const ARENA_PVP_LOCATION_COLUMN: &str = "PvpLocation"; -pub const NPC_LOCATION_COLUMN: &str = "Location"; -fn location_of(data: &LogicDataRef, column: &str) -> LogicDataRef { - data.data() - .map(|row| LogicDataRef::by_name(table::LOCATIONS, row.string(column))) - .unwrap_or_default() +fn location_named(name: &str) -> LogicDataRef { + if name.is_empty() { + return LogicDataRef::None; + } + LogicDataRef::by_name(table::LOCATIONS, name) } pub struct GameService { config: GameConfig, @@ -114,9 +112,15 @@ impl GameService { .await .stop_home_logic(); let location = if npc.is_none() { - location_of(&profile.arena, ARENA_PVP_LOCATION_COLUMN) + profile + .arena + .as_arena() + .map(|arena| location_named(arena.pvp_location())) + .unwrap_or_default() } else { - location_of(&npc, NPC_LOCATION_COLUMN) + npc.as_npc() + .map(|npc| location_named(npc.location())) + .unwrap_or_default() }; let deck = build_deck(&profile); let (opponent, opponent_deck) = match opponent_account { @@ -339,16 +343,9 @@ impl GameApi for GameService { }; spawned += self .running_battles - .play( - account, - played.header.executor_account_id, - played.deck_slot, - played.position, - played.header.execute_tick, - |card, position, owner, instance| { - self.battles.summon(card, position, owner, 0, instance) - }, - ) + .play(account, played, |card, position, owner, level, instance| { + self.battles.summon(card, position, owner, level, instance) + }) .await; } let progress = self.running_battles.advance(account, turn.tick).await; @@ -444,12 +441,15 @@ impl GameApi for GameService { } let messages = self .running_battles - .tick(account, |card, position, owner, instance| { - self.battles.summon(card, position, owner, 0, instance) + .tick(account, |card, position, owner, level, instance| { + self.battles.summon(card, position, owner, level, instance) }) .await; if !messages.is_empty() { - let kinds: Vec = messages.iter().map(|message| message.message_type).collect(); + let kinds: Vec = messages + .iter() + .map(|message| message.message_type) + .collect(); tracing::debug!(%account, sent = ?kinds, "battle tick"); } Ok(messages) @@ -468,7 +468,12 @@ impl GameApi for GameService { Ok(()) } async fn request_sector_state(&self, account: AccountRef) -> RpcResult> { - Ok(self.running_battles.resend(account).await.into_iter().collect()) + Ok(self + .running_battles + .resend(account) + .await + .into_iter() + .collect()) } async fn sector_command(&self, account: AccountRef, payload: Vec) -> RpcResult<()> { let Ok(sector) = SectorCommandMessage::from_bytes(&payload) else { @@ -483,23 +488,16 @@ impl GameApi for GameService { .await; let server_checksum = progress.and_then(|state| state.checksum); let mut spawned = 0; - if let Some(played) = sector - .command - .as_ref() - .and_then(|command| command.as_any().downcast_ref::()) - { + if let Some(played) = sector.command.as_ref().and_then(|command| { + command + .as_any() + .downcast_ref::() + }) { spawned = self .running_battles - .play( - account, - played.header.executor_account_id, - played.deck_slot, - played.position, - played.header.execute_tick, - |card, position, owner, instance| { - self.battles.summon(card, position, owner, 0, instance) - }, - ) + .play(account, played, |card, position, owner, level, instance| { + self.battles.summon(card, position, owner, level, instance) + }) .await; } tracing::info!( @@ -556,9 +554,9 @@ impl RpcService for GameService { GameRequest::EndClientTurn { account, payload } => Ok(GameResponse::messages( self.end_client_turn(account, payload).await?, )), - GameRequest::BattleTick { account } => Ok(GameResponse::messages( - self.battle_tick(account).await?, - )), + GameRequest::BattleTick { account } => { + Ok(GameResponse::messages(self.battle_tick(account).await?)) + } GameRequest::RequestSectorState { account } => Ok(GameResponse::messages( self.request_sector_state(account).await?, )), diff --git a/crates/game-service/src/shop/catalog.rs b/crates/game-service/src/shop/catalog.rs index 36aacce..4e786b0 100644 --- a/crates/game-service/src/shop/catalog.rs +++ b/crates/game-service/src/shop/catalog.rs @@ -1,6 +1,6 @@ -use std::path::Path; -use serde::{Deserialize, Serialize}; use crate::shop::entry::ShopEntry; +use serde::{Deserialize, Serialize}; +use std::path::Path; pub const DEFAULT_SHOP: &str = include_str!("default_shop.json"); #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ShopOffer { diff --git a/crates/game-service/src/shop/entry.rs b/crates/game-service/src/shop/entry.rs index c5e1d90..99888d1 100644 --- a/crates/game-service/src/shop/entry.rs +++ b/crates/game-service/src/shop/entry.rs @@ -1,7 +1,7 @@ -use std::fmt; -use std::str::FromStr; use logic::{table, LogicDataRef}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::fmt; +use std::str::FromStr; pub const SEPARATOR: char = '#'; pub const QUALIFIER: char = ':'; pub const LOOKUP: &[(&str, i32)] = &[ diff --git a/crates/game-service/src/store/mod.rs b/crates/game-service/src/store/mod.rs index 37563a1..5e3e2e5 100644 --- a/crates/game-service/src/store/mod.rs +++ b/crates/game-service/src/store/mod.rs @@ -1,11 +1,11 @@ mod postgres; mod profile; -use service_rpc::RpcError; -use storage::StorageError; pub use postgres::ProfileStore; pub use profile::{ OwnedCard, PlayerProfile, StoredChest, CARD_HOLDER_COLLECTION, CARD_HOLDER_DECK, }; +use service_rpc::RpcError; +use storage::StorageError; pub fn unavailable(error: StorageError) -> RpcError { RpcError::Unavailable(error.to_string()) } diff --git a/crates/game-service/src/store/postgres.rs b/crates/game-service/src/store/postgres.rs index b33cb6d..3fcd02e 100644 --- a/crates/game-service/src/store/postgres.rs +++ b/crates/game-service/src/store/postgres.rs @@ -1,12 +1,12 @@ -use logic::LogicDataRef; -use service_rpc::AccountRef; -use storage::sqlx::{Postgres, QueryBuilder}; -use storage::{Database, DatabaseConfig, PgRow, Result, Row}; use crate::shop::Purchase; use crate::store::{ OwnedCard, PlayerProfile, StoredChest, CARD_HOLDER_COLLECTION, CARD_HOLDER_DECK, }; use crate::time::unix_seconds; +use logic::LogicDataRef; +use service_rpc::AccountRef; +use storage::sqlx::{Postgres, QueryBuilder}; +use storage::{Database, DatabaseConfig, PgRow, Result, Row}; pub struct ProfileStore { database: Database, } diff --git a/crates/game-service/src/store/profile.rs b/crates/game-service/src/store/profile.rs index 93402b4..524187e 100644 --- a/crates/game-service/src/store/profile.rs +++ b/crates/game-service/src/store/profile.rs @@ -1,7 +1,7 @@ -use service_rpc::AccountRef; -use logic::LogicDataRef; use crate::catalog::Catalog; use crate::config::StarterProfile; +use logic::LogicDataRef; +use service_rpc::AccountRef; pub const CARD_HOLDER_DECK: i16 = 0; pub const CARD_HOLDER_COLLECTION: i16 = 1; #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/game-service/tests/achievements_and_levels.rs b/crates/game-service/tests/achievements_and_levels.rs index 142070c..f10a0df 100644 --- a/crates/game-service/tests/achievements_and_levels.rs +++ b/crates/game-service/tests/achievements_and_levels.rs @@ -1,11 +1,11 @@ -use std::path::Path; -use std::sync::Arc; use game_service::home::build_avatar; use logic::commands::COMMODITY_ACHIEVEMENT_PROGRESS; -use logic::{CommandOutcome, LogicClaimAchievementRewardCommand, LogicCommandHeader}; use logic::data::{table, LogicDataRef, LogicDataTables}; use logic::home::LogicHomeMode; use logic::model::LogicClientHome; +use logic::{CommandOutcome, LogicClaimAchievementRewardCommand, LogicCommandHeader}; +use std::path::Path; +use std::sync::Arc; fn tables() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets"); if let Ok(t) = LogicDataTables::load_from_dir(&root) { @@ -20,9 +20,17 @@ fn adding_exp_rolls_over_into_levels() { home.avatar_mut().exp_points = 0; home.add_exp(25); assert_eq!(home.avatar().exp_level, 2, "25 exp should reach level 2"); - assert_eq!(home.avatar().exp_points, 5, "5 exp should carry into level 2"); + assert_eq!( + home.avatar().exp_points, + 5, + "5 exp should carry into level 2" + ); home.add_exp(50); - assert_eq!(home.avatar().exp_level, 3, "another 50 should reach level 3"); + assert_eq!( + home.avatar().exp_level, + 3, + "another 50 should reach level 3" + ); assert_eq!(home.avatar().exp_points, 5); } #[test] @@ -61,7 +69,10 @@ fn a_completed_findcard_achievement_can_be_claimed() { .get(COMMODITY_ACHIEVEMENT_PROGRESS) .map(|slots| slots.iter().filter(|s| s.count >= required).count()) .unwrap_or(0); - assert!(progress > 0, "the served progress should complete a findcard tier"); + assert!( + progress > 0, + "the served progress should complete a findcard tier" + ); let mut home = LogicHomeMode::new(LogicClientHome::default(), avatar, 0); let before_level = home.avatar().exp_level; let before_points = home.avatar().exp_points; @@ -70,10 +81,17 @@ fn a_completed_findcard_achievement_can_be_claimed() { header: LogicCommandHeader::default(), }; let outcome = home.execute(&command); - assert_eq!(outcome, CommandOutcome::Applied, "the claim should be accepted"); + assert_eq!( + outcome, + CommandOutcome::Applied, + "the claim should be accepted" + ); assert!( home.avatar().exp_points != before_points || home.avatar().exp_level != before_level, "claiming should have granted exp" ); - assert!(matches!(home.execute(&command), CommandOutcome::Rejected(_))); + assert!(matches!( + home.execute(&command), + CommandOutcome::Rejected(_) + )); } diff --git a/crates/game-service/tests/combat_timer_phase.rs b/crates/game-service/tests/combat_timer_phase.rs new file mode 100644 index 0000000..143d047 --- /dev/null +++ b/crates/game-service/tests/combat_timer_phase.rs @@ -0,0 +1,100 @@ +use game_service::battle::BattleBuilder; +use logic::battle::{LogicComponent, LogicObjectBody, LogicVector2, COMPONENT_COMBAT}; +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 a_ranged_shooter_updates_twice_on_the_tick_it_fires() { + 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"); + for entry in builder.summon(&spell, LogicVector2::new(14500, 23500), 0, 0, 100) { + mode.battle.objects.push(entry); + } + const ARCHER_LOAD_TIME: i32 = 1000; + let load_timer_of = |mode: &logic::battle::LogicGameMode, id: i32| -> Option { + mode.battle.objects.objects.iter().find_map(|entry| { + if entry.global_id.0?.instance_id != id { + return None; + } + match entry.components.get(COMPONENT_COMBAT)? { + Some(LogicComponent::Combat(combat)) => Some(combat.load_timer), + _ => None, + } + }) + }; + let archer_ref = mode + .battle + .objects + .objects + .iter() + .find(|entry| entry.data.name() == "Archer") + .map(|entry| entry.global_id) + .expect("the archer is on the board"); + let archer = archer_ref + .0 + .expect("the archer has a global id") + .instance_id; + let mut shots = 0usize; + let mut seen: Vec = Vec::new(); + for tick in 0..(20 * 20) { + mode.battle.tick(tick); + let fresh: Vec<_> = mode + .battle + .objects + .objects + .iter() + .filter_map(|entry| match &entry.body { + LogicObjectBody::Projectile(shot) if shot.source == archer_ref => { + Some(entry.global_id) + } + _ => None, + }) + .filter(|id| !seen.contains(id)) + .collect(); + for id in &fresh { + seen.push(*id); + } + if fresh.is_empty() { + continue; + } + shots += 1; + let load_timer = load_timer_of(&mode, archer).expect("the archer has a combat component"); + assert_eq!( + load_timer, + ARCHER_LOAD_TIME - logic::battle::TICK_MILLISECONDS, + "t={tick}: the shooter did not take its second update on the firing tick" + ); + } + assert!( + shots > 1, + "the archer fired {shots} times, too few to prove anything" + ); +} diff --git a/crates/game-service/tests/deploy_birth_tick.rs b/crates/game-service/tests/deploy_birth_tick.rs new file mode 100644 index 0000000..bf0104b --- /dev/null +++ b/crates/game-service/tests/deploy_birth_tick.rs @@ -0,0 +1,148 @@ +use game_service::battle::BattleBuilder; +use game_service::battle_session::{BattleSession, COMMAND_DELAY_TICKS, DEPLOY_BIRTH_TICKS}; +use logic::battle::LogicVector2; +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 a_card_lands_one_tick_after_the_command_fires() { + 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 mut session = BattleSession::new(mode, Vec::new()); + let at_tick = 0; + let spell = LogicDataRef::spell("Knight"); + let entries = builder.summon(&spell, LogicVector2::new(14500, 23500), 0, 0, 100); + assert!(!entries.is_empty(), "the knight spell summons nothing"); + let before = session.object_count(); + let born = session.queue(at_tick, LogicVector2::new(14500, 23500), 0, 3, 0, entries); + assert_eq!( + born, + at_tick + DEPLOY_BIRTH_TICKS, + "the birth tick must be one past the firing tick" + ); + assert_eq!( + DEPLOY_BIRTH_TICKS, + COMMAND_DELAY_TICKS + 1, + "the command still fires at age twenty; only the label the troops are first hashed under moves" + ); + session.advance_to(born - 1); + assert_eq!( + session.object_count(), + before, + "the troops appeared on the firing tick, a tick before the client hashes them" + ); + session.advance_to(born); + assert!( + session.object_count() > before, + "the troops never arrived on the birth tick" + ); +} +#[test] +fn a_state_request_waits_for_the_cards_in_the_air() { + 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 mut session = BattleSession::new(mode, Vec::new()); + assert!( + session.state_on_request().is_some(), + "with nothing in the air a request must be answered at once" + ); + let first = session.queue( + 0, + LogicVector2::new(14500, 23500), + 0, + 3, + 0, + builder.summon( + &LogicDataRef::spell("Archer"), + LogicVector2::new(14500, 23500), + 0, + 0, + 100, + ), + ); + session.advance_to(15); + let second = session.queue( + 15, + LogicVector2::new(14000, 23500), + 0, + 3, + 1, + builder.summon( + &LogicDataRef::spell("Bomber"), + LogicVector2::new(14000, 23500), + 0, + 0, + 100, + ), + ); + assert!(second > first, "the second card must land after the first"); + let mut refused = Vec::new(); + for tick in 16..(second + 4) { + if session.state_on_request().is_none() { + refused.push(session.tick()); + } + session.advance_to(tick); + } + for tick in &refused { + let near_a_firing_tick = [first, second] + .iter() + .any(|fires_at| tick > fires_at && tick <= &(fires_at + 2)); + assert!( + near_a_firing_tick, + "t={tick}: a state was refused away from any firing tick ({first}, {second}); while a full update is pending that silently eats the player's cards" + ); + } + assert!( + !refused.is_empty(), + "the firing ticks were never protected at all" + ); + session.advance_to(second + 1); + let mut paid = false; + for _ in 0..8 { + if session.take_outbound().into_iter().count() > 0 { + paid = true; + break; + } + session.advance_to(session.tick() + 1); + } + assert!(paid, "the owed state was never sent after the cards landed"); +} diff --git a/crates/game-service/tests/level_scaling.rs b/crates/game-service/tests/level_scaling.rs new file mode 100644 index 0000000..c937c33 --- /dev/null +++ b/crates/game-service/tests/level_scaling.rs @@ -0,0 +1,45 @@ +use logic::data::{table, LogicDataRef, LogicDataTables}; +use std::path::Path; +use std::sync::Arc; +fn tables() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets"); + LogicDataTables::install(Arc::new( + LogicDataTables::load_from_dir(&root).expect("tables"), + )); +} +#[test] +fn a_single_valued_column_is_scaled_not_indexed() { + tables(); + let spear = LogicDataRef::by_name(table::PROJECTILES, "SpearGoblinProjectile") + .as_projectile() + .expect("SpearGoblinProjectile"); + assert_eq!(spear.damage(0), 24); + assert_eq!(spear.damage(2), 29, "the level that crashed the client"); + assert_eq!(spear.damage(6), 42, "the bot's deck level"); +} +#[test] +fn a_tower_scales_at_its_own_percentage() { + tables(); + let princess = LogicDataRef::by_name(table::BUILDINGS, "PrincessTower") + .as_character() + .expect("PrincessTower"); + let base = princess.hitpoints(0); + assert!(base > 0); + assert_eq!(princess.hitpoints(1), base * 109 / 100); + assert_eq!(princess.hitpoints(2), base * (109 * 109 / 100) / 100); + let king = LogicDataRef::by_name(table::BUILDINGS, "KingTower") + .as_character() + .expect("KingTower"); + let king_base = king.hitpoints(0); + assert_eq!(king.hitpoints(1), king_base * 108 / 100); +} +#[test] +fn an_ordinary_card_scales_at_the_card_percentage() { + tables(); + let knight = LogicDataRef::by_name(table::CHARACTERS_COMBINED, "Knight") + .as_character() + .expect("Knight"); + let base = knight.hitpoints(0); + assert_eq!(knight.hitpoints(1), base * 110 / 100); + assert_eq!(knight.hitpoints(3), base * 133 / 100); +} diff --git a/crates/game-service/tests/projectile_references.rs b/crates/game-service/tests/projectile_references.rs new file mode 100644 index 0000000..9489041 --- /dev/null +++ b/crates/game-service/tests/projectile_references.rs @@ -0,0 +1,161 @@ +use game_service::battle::BattleBuilder; +use logic::battle::{LogicObjectBody, LogicVector2}; +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 a_projectile_drops_its_reference_to_an_object_that_died() { + 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("SkeletonArmy"); + for entry in builder.summon(&spell, LogicVector2::new(14500, 22000), 0, 0, 100) { + mode.battle.objects.push(entry); + } + let mut projectiles_seen = 0usize; + let mut stale = Vec::new(); + for tick in 0..600 { + mode.battle.tick(tick); + let alive: Vec<_> = mode + .battle + .objects + .objects + .iter() + .map(|entry| entry.global_id) + .collect(); + for entry in &mode.battle.objects.objects { + let LogicObjectBody::Projectile(projectile) = &entry.body else { + continue; + }; + projectiles_seen += 1; + for (name, reference) in [("target", projectile.target), ("source", projectile.source)] + { + if reference.is_none() { + continue; + } + if !alive.contains(&reference) { + stale.push(format!( + "t={tick}: projectile {:?} still points {name} at {:?}, which is off the board", + entry.global_id.0, reference.0 + )); + } + } + } + } + assert!( + projectiles_seen > 0, + "no projectile was ever in the air, so the test proved nothing" + ); + assert!( + stale.is_empty(), + "a projectile outlived one of its references:\n{}", + stale.join("\n") + ); +} +#[test] +fn pending_physical_damage_matches_the_shots_in_the_air() { + 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"); + for spell in ["Archer", "SkeletonArmy"] { + for entry in builder.summon( + &LogicDataRef::spell(spell), + LogicVector2::new(14500, 22000), + 0, + 0, + 100, + ) { + mode.battle.objects.push(entry); + } + } + let mut ever_positive = false; + for tick in 0..800 { + mode.battle.tick(tick); + let mut owed: Vec<(logic::battle::LogicGameObjectRef, i32)> = Vec::new(); + for entry in &mode.battle.objects.objects { + let LogicObjectBody::Projectile(shot) = &entry.body else { + continue; + }; + if shot.destroyed || shot.target.is_none() { + continue; + } + let Some(data) = entry.data.as_projectile() else { + continue; + }; + if !data.uses_pending_physical_damage() { + continue; + } + let damage = data.damage(shot.level_index.max(0) as usize); + match owed.iter_mut().find(|(t, _)| *t == shot.target) { + Some((_, total)) => *total += damage, + None => owed.push((shot.target, damage)), + } + } + for entry in &mode.battle.objects.objects { + let held = match &entry.body { + LogicObjectBody::Character(character) => character.pending_physical_damage, + LogicObjectBody::Summoner(summoner) => summoner.character.pending_physical_damage, + LogicObjectBody::Projectile(_) => continue, + }; + assert!( + held >= 0, + "t={tick}: {:?} holds a NEGATIVE pending of {held}, which aborts the client", + entry.global_id.0 + ); + let want = owed + .iter() + .find(|(t, _)| *t == entry.global_id) + .map(|(_, total)| *total) + .unwrap_or(0); + assert_eq!( + held, want, + "t={tick}: {:?} holds {held} pending but the shots in the air owe {want}", + entry.global_id.0 + ); + if held > 0 { + ever_positive = true; + } + } + } + assert!( + ever_positive, + "no shot ever registered pending damage, so the test proved nothing" + ); +} diff --git a/crates/game-service/tests/walk_to_tower.rs b/crates/game-service/tests/walk_to_tower.rs index 6433a07..05959fc 100644 --- a/crates/game-service/tests/walk_to_tower.rs +++ b/crates/game-service/tests/walk_to_tower.rs @@ -1,10 +1,10 @@ -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::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") @@ -20,6 +20,40 @@ fn avatar(low: i32) -> LogicClientAvatar { } } #[test] +fn a_ranged_troop_fires_and_the_snapshot_reads_back() { + 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 a_troop_walks_at_the_enemy_towers_and_stops_in_range() { let root = assets(); let tables = LogicDataTables::load_from_dir(&root).expect("tables"); @@ -80,7 +114,7 @@ fn a_troop_walks_at_the_enemy_towers_and_stops_in_range() { 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, + reach < 3000.0, "the knight never reached the enemy tower, stopped {reach:.0} away at {end:?}" ); } @@ -92,7 +126,10 @@ fn the_bot_deploys_in_front_of_its_own_towers() { } let builder = BattleBuilder::new(&root); let mut deck = logic::model::LogicSpellDeck::default(); - for (slot, name) in ["Knight", "Cannon", "GoblinHut", "Giant"].iter().enumerate() { + 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() @@ -112,8 +149,8 @@ fn the_bot_deploys_in_front_of_its_own_towers() { 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); + 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 { @@ -126,14 +163,17 @@ fn the_bot_deploys_in_front_of_its_own_towers() { ); 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::>() - ); - } + 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); @@ -156,7 +196,7 @@ fn the_bot_deploys_in_front_of_its_own_towers() { } for entry in session.mode().battle.objects.objects.iter() { let (x, y) = entry.position(); - if !((500..=17500).contains(&x) && (1000..=31000).contains(&y)) { + if !((0..=17999).contains(&x) && (0..=31999).contains(&y)) { println!( "t={tick}: object owner={} speed={} alive={} at ({x}, {y})", entry.owner_index(), @@ -329,6 +369,7 @@ fn a_dormant_king_clears_its_combat_bit_and_towers_do_not() { assert_eq!(mask, 13, "a princess tower keeps its combat bit"); princesses += 1; } + logic::battle::LogicObjectBody::Projectile(_) => {} } } assert_eq!(kings, 2); @@ -389,7 +430,11 @@ fn a_deployed_troop_counts_down_and_holds_still_until_ready() { }) }; mode.battle.tick(1); - assert_eq!(timer_of(&mode), Some(deploy - 50), "deploy timer counts down"); + assert_eq!( + timer_of(&mode), + Some(deploy - 50), + "deploy timer counts down" + ); let here = mode .battle .objects @@ -476,7 +521,10 @@ fn a_troop_in_range_damages_the_tower_with_the_client_hit_model() { _ => None, }) .expect("tower still present"); - assert!(hp < start_hp, "the knight's attacks should reduce tower hp ({hp} !< {start_hp})"); + assert!( + hp < start_hp, + "the knight's attacks should reduce tower hp ({hp} !< {start_hp})" + ); } #[test] fn the_session_emits_a_heartbeat_every_turn() { @@ -500,11 +548,13 @@ fn the_session_emits_a_heartbeat_every_turn() { 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 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(); @@ -519,7 +569,14 @@ fn playing_a_card_cycles_the_summoner_hand() { } let mut deck = logic::model::LogicSpellDeck::default(); let names = [ - "Knight", "Archers", "Goblins", "Giant", "Fireball", "Arrows", "Minions", "Musketeer", + "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 { @@ -547,9 +604,10 @@ fn playing_a_card_cycles_the_summoner_hand() { .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)) - } + 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") @@ -569,7 +627,10 @@ fn playing_a_card_cycles_the_summoner_hand() { } 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"); + assert_ne!( + hand2[0], played, + "with a different card than the one played" + ); } #[test] fn a_multi_unit_card_spreads_its_units() { @@ -578,12 +639,200 @@ fn a_multi_unit_card_spreads_its_units() { LogicDataTables::install(Arc::new(t)); } let builder = BattleBuilder::new(&root); - let entries = builder.summon(&LogicDataRef::spell("Goblins"), LogicVector2::new(9000, 14500), 0, 0, 100); + 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"); + assert_ne!( + positions[i], positions[j], + "units must not stack on one point" + ); } } } +#[test] +fn a_snapshot_carrying_a_command_still_reads_back() { + 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 the_search_moves_a_drop_the_way_the_client_does() { + 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 a_drop_the_client_refuses_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, "the player should have mana by tick 120"); + 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" + ); +} diff --git a/crates/gateway/src/backend.rs b/crates/gateway/src/backend.rs index 21df291..5fa9b27 100644 --- a/crates/gateway/src/backend.rs +++ b/crates/gateway/src/backend.rs @@ -1,8 +1,8 @@ -use std::sync::Arc; use service_rpc::{ AccountRef, AuthApi, AuthRequest, AuthResponse, DeviceInfo, GameApi, GameRequest, GameResponse, HomeRequestKind, LoginOutcome, RpcClient, RpcError, RpcResult, WireMessage, }; +use std::sync::Arc; pub struct Backends { pub auth: Arc, pub game: Arc, diff --git a/crates/gateway/src/bin/scroll-probe.rs b/crates/gateway/src/bin/scroll-probe.rs index 94a884e..acc40ca 100644 --- a/crates/gateway/src/bin/scroll-probe.rs +++ b/crates/gateway/src/bin/scroll-probe.rs @@ -1,9 +1,9 @@ -use std::time::Duration; use logic::{ message_type, AvailableServerCommandMessage, EndClientTurnMessage, KeepAliveMessage, LogicClaimRewardCommand, LogicCollectFreeChestCommand, LogicCommandHeader, LogicDataTables, LoginMessage, LoginOkMessage, OwnHomeDataMessage, }; +use std::time::Duration; use titan::crypto::SessionCipher; use titan::{FrameCodec, FrameHeader, MessageFrame, MessageMeta, Payload, HEADER_LEN}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; diff --git a/crates/gateway/src/lib.rs b/crates/gateway/src/lib.rs index 4b0699f..6f5cb1e 100644 --- a/crates/gateway/src/lib.rs +++ b/crates/gateway/src/lib.rs @@ -2,12 +2,12 @@ pub mod backend; pub mod config; pub mod message_manager; pub mod session; -use std::sync::Arc; -use tokio::net::TcpListener; pub use backend::{Backends, RemoteAuth, RemoteGame}; pub use config::GatewayConfig; pub use message_manager::MessageManager; pub use session::{Session, SessionError}; +use std::sync::Arc; +use tokio::net::TcpListener; pub async fn bind(config: &GatewayConfig) -> std::io::Result { TcpListener::bind(&config.listen).await } diff --git a/crates/gateway/src/message_manager.rs b/crates/gateway/src/message_manager.rs index 2ff2eb4..ac07c89 100644 --- a/crates/gateway/src/message_manager.rs +++ b/crates/gateway/src/message_manager.rs @@ -1,7 +1,5 @@ -use std::net::SocketAddr; -use std::sync::Arc; -use std::time::Duration; -use tokio::task::JoinHandle; +use crate::backend::Backends; +use crate::config::GatewayConfig; use logic::{ message_type, CancelMatchmakeDoneMessage, ClientCapabilitiesMessage, GoHomeMessage, KeepAliveServerMessage, LoginFailedMessage, LoginMessage, LoginOkMessage, ServerErrorMessage, @@ -9,9 +7,11 @@ use logic::{ use service_rpc::{ AccountRef, DeviceInfo, HomeRequestKind, LoginOutcome, Session as AuthSession, WireMessage, }; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; use titan::{Incoming, LogicLong, MessagingSender, OutboundMessage}; -use crate::backend::Backends; -use crate::config::GatewayConfig; +use tokio::task::JoinHandle; #[derive(Debug, thiserror::Error)] pub enum RoutingError { #[error("transport: {0}")] diff --git a/crates/gateway/src/session.rs b/crates/gateway/src/session.rs index e55cf8c..56d5488 100644 --- a/crates/gateway/src/session.rs +++ b/crates/gateway/src/session.rs @@ -1,11 +1,11 @@ +use crate::backend::Backends; +use crate::config::GatewayConfig; +use crate::message_manager::{MessageManager, RoutingError}; use std::net::SocketAddr; use std::sync::Arc; use titan::crypto::SessionCipher; use titan::{MessageRegistry, Messaging, MessagingConfig}; use tokio::net::TcpStream; -use crate::backend::Backends; -use crate::config::GatewayConfig; -use crate::message_manager::{MessageManager, RoutingError}; #[derive(Debug, thiserror::Error)] pub enum SessionError { #[error("io: {0}")]