resolve the deploy birth tick and stop wedging the client's own commands
- a card lands one tick after its command fires, since the client hashes after LogicTime::increaseTick, not before - answer RequestSectorState immediately except in the two ticks after a firing tick, where a full update would teleport the client past the command it just queued - the old blanket refusal left every card the player tapped silently deleted while it held - carry both pending commands and queued deploys in the same delivery ledger so the quiet window covers what the player actually paid for - add the regression tests for the birth tick and the deferred answer
This commit is contained in:
parent
07f0ee5427
commit
a585b0c18d
27 changed files with 1434 additions and 254 deletions
|
|
@ -1,25 +1,17 @@
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use logic::battle::{
|
use logic::battle::{
|
||||||
LogicBattle, LogicCharacter, LogicCharacterBuffComponent, LogicCombatComponent, LogicComponent,
|
LogicBattle, LogicCharacter, LogicCharacterBuffComponent, LogicCombatComponent, LogicComponent,
|
||||||
LogicGameMode, LogicGameObject, LogicGameObjectEntry, LogicGameObjectManager,
|
LogicGameMode, LogicGameObject, LogicGameObjectEntry, LogicGameObjectManager,
|
||||||
LogicGameObjectRef, LogicHitpointComponent, LogicMovementComponent, LogicObjectBody,
|
LogicGameObjectRef, LogicHitpointComponent, LogicMovementComponent, LogicObjectBody,
|
||||||
LogicSummoner, LogicSummonerDeck,
|
LogicSummoner, LogicSummonerDeck, LogicTilemap, LogicTime, LogicVector2, BATTLE_TYPE_PVP,
|
||||||
LogicTilemap,
|
CHARACTER_OBJECT_TYPE, COMPONENT_PASSES, DIRECTION_BOTTOM, DIRECTION_TOP, SUBTILE_UNITS,
|
||||||
LogicTime, LogicVector2, BATTLE_TYPE_PVP, CHARACTER_OBJECT_TYPE, COMPONENT_PASSES,
|
|
||||||
DIRECTION_BOTTOM,
|
|
||||||
DIRECTION_TOP, SUBTILE_UNITS,
|
|
||||||
};
|
};
|
||||||
use logic::model::{LogicClientAvatar, LogicSpellDeck};
|
use logic::model::{LogicClientAvatar, LogicSpellDeck};
|
||||||
use logic::{table, LogicDataRef, LogicDataTables, LogicGlobals, LogicRandom};
|
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_KING_TOWER: &str = "KingTower";
|
||||||
pub const CHARACTER_PRINCESS_TOWER: &str = "PrincessTower";
|
pub const CHARACTER_PRINCESS_TOWER: &str = "PrincessTower";
|
||||||
pub const BUFF_ARRAY_TABLE: i32 = table::DAMAGE_TYPES;
|
pub const BUFF_ARRAY_TABLE: i32 = table::DAMAGE_TYPES;
|
||||||
pub const START_MANA_GLOBAL: &str = "START_MANA";
|
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 struct CharacterSpec {
|
||||||
pub data: LogicDataRef,
|
pub data: LogicDataRef,
|
||||||
pub instance: i32,
|
pub instance: i32,
|
||||||
|
|
@ -81,8 +73,8 @@ impl BattleBuilder {
|
||||||
}
|
}
|
||||||
pub fn tilemap_for(&self, location: &LogicDataRef) -> std::io::Result<LogicTilemap> {
|
pub fn tilemap_for(&self, location: &LogicDataRef) -> std::io::Result<LogicTilemap> {
|
||||||
let file_name = location
|
let file_name = location
|
||||||
.data()
|
.as_location()
|
||||||
.map(|data| data.string(LOCATION_FILE_COLUMN).to_owned())
|
.map(|location| location.file_name().to_owned())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if file_name.is_empty() {
|
if file_name.is_empty() {
|
||||||
return Err(std::io::Error::other("the location names no tilemap"));
|
return Err(std::io::Error::other("the location names no tilemap"));
|
||||||
|
|
@ -90,18 +82,26 @@ impl BattleBuilder {
|
||||||
LogicTilemap::load(&self.root, &file_name)
|
LogicTilemap::load(&self.root, &file_name)
|
||||||
}
|
}
|
||||||
fn hitpoints_of(data: &LogicDataRef, level_index: i32) -> i32 {
|
fn hitpoints_of(data: &LogicDataRef, level_index: i32) -> i32 {
|
||||||
data.data()
|
data.as_character()
|
||||||
.map(|row| row.int_at(CHARACTER_HITPOINTS_COLUMN, level_index.max(0) as usize))
|
.map(|character| character.hitpoints(level_index.max(0) as usize))
|
||||||
.filter(|value| *value > 0)
|
.filter(|value| *value > 0)
|
||||||
.unwrap_or(1)
|
.unwrap_or(1)
|
||||||
}
|
}
|
||||||
fn components_for(&self, hitpoints: i32, moves: bool) -> [Option<LogicComponent>; COMPONENT_PASSES] {
|
fn components_for(
|
||||||
|
&self,
|
||||||
|
hitpoints: i32,
|
||||||
|
moves: bool,
|
||||||
|
life_time: i32,
|
||||||
|
) -> [Option<LogicComponent>; COMPONENT_PASSES] {
|
||||||
|
let hitpoint = if life_time >= 1 {
|
||||||
|
LogicHitpointComponent::with_lifetime(hitpoints, life_time)
|
||||||
|
} else {
|
||||||
|
LogicHitpointComponent::healthy(hitpoints)
|
||||||
|
};
|
||||||
[
|
[
|
||||||
Some(LogicComponent::Combat(LogicCombatComponent::default())),
|
Some(LogicComponent::Combat(LogicCombatComponent::default())),
|
||||||
moves.then(|| LogicComponent::Movement(LogicMovementComponent::default())),
|
moves.then(|| LogicComponent::Movement(LogicMovementComponent::default())),
|
||||||
Some(LogicComponent::Hitpoint(LogicHitpointComponent::healthy(
|
Some(LogicComponent::Hitpoint(hitpoint)),
|
||||||
hitpoints,
|
|
||||||
))),
|
|
||||||
Some(LogicComponent::Buff(LogicCharacterBuffComponent::empty(
|
Some(LogicComponent::Buff(LogicCharacterBuffComponent::empty(
|
||||||
self.buff_type_count,
|
self.buff_type_count,
|
||||||
))),
|
))),
|
||||||
|
|
@ -117,14 +117,28 @@ impl BattleBuilder {
|
||||||
} = spec;
|
} = spec;
|
||||||
let hitpoints = Self::hitpoints_of(&data, level_index);
|
let hitpoints = Self::hitpoints_of(&data, level_index);
|
||||||
let moves = data
|
let moves = data
|
||||||
.data()
|
.as_character()
|
||||||
.map(|row| row.int(CHARACTER_SPEED_COLUMN) > 0)
|
.map(|character| character.speed() > 0)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
let deploy_time = data
|
let deploy_time = data
|
||||||
.data()
|
.as_character()
|
||||||
.map(|row| row.int(logic::battle::CHARACTER_DEPLOY_TIME_COLUMN))
|
.map(|character| character.deploy_time())
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
.max(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 {
|
let character = LogicCharacter {
|
||||||
level_index,
|
level_index,
|
||||||
base: LogicGameObject {
|
base: LogicGameObject {
|
||||||
|
|
@ -148,9 +162,11 @@ impl BattleBuilder {
|
||||||
0
|
0
|
||||||
},
|
},
|
||||||
deploy_timer: deploy_time,
|
deploy_timer: deploy_time,
|
||||||
|
spawn_timer: spawn_start,
|
||||||
|
remaining_spawn_count: spawn_limit,
|
||||||
..LogicCharacter::default()
|
..LogicCharacter::default()
|
||||||
};
|
};
|
||||||
let components = self.components_for(hitpoints, moves);
|
let components = self.components_for(hitpoints, moves, life_time);
|
||||||
LogicGameObjectEntry::new(
|
LogicGameObjectEntry::new(
|
||||||
data,
|
data,
|
||||||
LogicGameObjectRef::of(CHARACTER_OBJECT_TYPE + 1, instance),
|
LogicGameObjectRef::of(CHARACTER_OBJECT_TYPE + 1, instance),
|
||||||
|
|
@ -166,10 +182,10 @@ impl BattleBuilder {
|
||||||
level_index: i32,
|
level_index: i32,
|
||||||
first_instance: i32,
|
first_instance: i32,
|
||||||
) -> Vec<LogicGameObjectEntry> {
|
) -> Vec<LogicGameObjectEntry> {
|
||||||
let Some(row) = spell.data() else {
|
let Some(summon) = spell.as_spell() else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
let name = row.string(SPELL_SUMMON_CHARACTER_COLUMN).to_owned();
|
let name = summon.summon_character().to_owned();
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
return Vec::new();
|
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");
|
tracing::warn!(spell = %spell, character = name, "the spell summons a character the tables do not have");
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
let count = row.int(SPELL_SUMMON_NUMBER_COLUMN).max(1);
|
let count = summon.summon_number().max(1);
|
||||||
let collision_radius = data
|
let collision_radius = data
|
||||||
.data()
|
.as_character()
|
||||||
.map(|row| row.int("CollisionRadius"))
|
.map(|character| character.collision_radius())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
(0..count)
|
(0..count)
|
||||||
.map(|index| {
|
.map(|index| {
|
||||||
let (dx, dy) =
|
let (dx, dy) = logic::spawn_offset(index, count, collision_radius, owner == 0);
|
||||||
logic::spawn_offset(index, count, collision_radius, owner == 0);
|
|
||||||
self.character(CharacterSpec {
|
self.character(CharacterSpec {
|
||||||
data: data.clone(),
|
data: data.clone(),
|
||||||
instance: first_instance + index,
|
instance: first_instance + index,
|
||||||
|
|
@ -241,7 +256,7 @@ impl BattleBuilder {
|
||||||
data,
|
data,
|
||||||
LogicGameObjectRef::of(CHARACTER_OBJECT_TYPE + 1, instance),
|
LogicGameObjectRef::of(CHARACTER_OBJECT_TYPE + 1, instance),
|
||||||
body,
|
body,
|
||||||
self.components_for(hitpoints, false),
|
self.components_for(hitpoints, false, 0),
|
||||||
);
|
);
|
||||||
if summoner {
|
if summoner {
|
||||||
entry.body.base_mut().component_mask &= !(1 << logic::battle::COMPONENT_COMBAT);
|
entry.body.base_mut().component_mask &= !(1 << logic::battle::COMPONENT_COMBAT);
|
||||||
|
|
@ -268,8 +283,14 @@ impl BattleBuilder {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let deck_slots = [
|
let deck_slots = [
|
||||||
decks[0].as_ref().map(LogicSpellDeck::filled_slot_count).unwrap_or(0),
|
decks[0]
|
||||||
decks[1].as_ref().map(LogicSpellDeck::filled_slot_count).unwrap_or(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 objects = LogicGameObjectManager::default();
|
||||||
let mut leaders = [LogicGameObjectRef::NONE; 2];
|
let mut leaders = [LogicGameObjectRef::NONE; 2];
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,34 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
pub const SNAPSHOT_INTERVAL_TICKS: i32 = 4;
|
pub const SNAPSHOT_INTERVAL_TICKS: i32 = 4;
|
||||||
pub fn heartbeats_enabled() -> bool {
|
#[derive(Debug)]
|
||||||
std::env::var("SCROLL_HEARTBEAT").map(|v| v == "1").unwrap_or(false)
|
struct QueuedDeploy {
|
||||||
|
born: i32,
|
||||||
|
owner: i32,
|
||||||
|
mana_cost: i32,
|
||||||
|
deck_slot: i32,
|
||||||
|
entries: Vec<LogicGameObjectEntry>,
|
||||||
}
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct PendingCommand {
|
||||||
|
tick_when_given: i32,
|
||||||
|
encoded: Vec<u8>,
|
||||||
|
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 {
|
pub fn snapshot_interval_ticks() -> i32 {
|
||||||
std::env::var("SCROLL_SNAPSHOT_INTERVAL_TICKS")
|
std::env::var("SCROLL_SNAPSHOT_INTERVAL_TICKS")
|
||||||
.ok()
|
.ok()
|
||||||
|
|
@ -14,21 +39,29 @@ pub fn snapshot_interval_ticks() -> i32 {
|
||||||
pub const MAX_CATCH_UP_TICKS: i32 = 40;
|
pub const MAX_CATCH_UP_TICKS: i32 = 40;
|
||||||
pub const CHECKSUM_HISTORY: usize = 256;
|
pub const CHECKSUM_HISTORY: usize = 256;
|
||||||
pub const TICKS_PER_TURN: i32 = 10;
|
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;
|
pub const BOT_DEPLOY_AHEAD: i32 = 2000;
|
||||||
use std::time::Instant;
|
use logic::battle::{verify_snapshot, LogicBattleEvent};
|
||||||
use logic::battle::{
|
use logic::battle::{
|
||||||
LogicGameMode, LogicGameObjectEntry, LogicVector2, BATTLE_TICKS_PER_SECOND, BATTLE_TYPE_PVP,
|
LogicGameMode, LogicGameObjectEntry, LogicVector2, BATTLE_TICKS_PER_SECOND, BATTLE_TYPE_PVP,
|
||||||
};
|
};
|
||||||
use logic::battle::{verify_snapshot, LogicBattleEvent};
|
use logic::messages::{
|
||||||
use logic::{SectorHeartbeatMessage, SectorStateMessage};
|
BattleResultMessage, BATTLE_RESULT_DRAW, BATTLE_RESULT_LOSE, BATTLE_RESULT_WIN,
|
||||||
|
};
|
||||||
use logic::{BattleEventMessage, LogicDataRef, LogicRandom};
|
use logic::{BattleEventMessage, LogicDataRef, LogicRandom};
|
||||||
use titan::LogicLong;
|
use logic::{SectorHeartbeatMessage, SectorStateMessage};
|
||||||
use service_rpc::{AccountRef, WireMessage};
|
use service_rpc::{AccountRef, WireMessage};
|
||||||
|
use std::time::Instant;
|
||||||
|
use titan::{ByteStreamWriter, LogicLong};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
pub struct BattleSession {
|
pub struct BattleSession {
|
||||||
mode: LogicGameMode,
|
mode: LogicGameMode,
|
||||||
tick: i32,
|
tick: i32,
|
||||||
queued: Vec<(i32, Vec<LogicGameObjectEntry>)>,
|
queued: Vec<QueuedDeploy>,
|
||||||
|
pending_commands: Vec<PendingCommand>,
|
||||||
|
state_owed: bool,
|
||||||
|
last_fire_tick: Option<i32>,
|
||||||
outbound: Vec<WireMessage>,
|
outbound: Vec<WireMessage>,
|
||||||
started_at: Instant,
|
started_at: Instant,
|
||||||
random: LogicRandom,
|
random: LogicRandom,
|
||||||
|
|
@ -40,7 +73,8 @@ pub struct BattleSession {
|
||||||
recent_checksums: std::collections::VecDeque<(i32, i32)>,
|
recent_checksums: std::collections::VecDeque<(i32, i32)>,
|
||||||
next_instance: i32,
|
next_instance: i32,
|
||||||
announced: bool,
|
announced: bool,
|
||||||
pending_bot_play: Option<(LogicDataRef, LogicVector2, i32)>,
|
pending_bot_play: Option<(LogicDataRef, LogicVector2, i32, i32)>,
|
||||||
|
result_sent: bool,
|
||||||
}
|
}
|
||||||
impl BattleSession {
|
impl BattleSession {
|
||||||
pub fn new(mode: LogicGameMode, taunts: Vec<LogicDataRef>) -> Self {
|
pub fn new(mode: LogicGameMode, taunts: Vec<LogicDataRef>) -> Self {
|
||||||
|
|
@ -59,6 +93,9 @@ impl BattleSession {
|
||||||
mode,
|
mode,
|
||||||
tick: 0,
|
tick: 0,
|
||||||
queued: Vec::new(),
|
queued: Vec::new(),
|
||||||
|
pending_commands: Vec::new(),
|
||||||
|
state_owed: false,
|
||||||
|
last_fire_tick: None,
|
||||||
outbound: Vec::new(),
|
outbound: Vec::new(),
|
||||||
started_at: Instant::now(),
|
started_at: Instant::now(),
|
||||||
random,
|
random,
|
||||||
|
|
@ -71,6 +108,7 @@ impl BattleSession {
|
||||||
next_instance: first_instance,
|
next_instance: first_instance,
|
||||||
announced: false,
|
announced: false,
|
||||||
pending_bot_play: None,
|
pending_bot_play: None,
|
||||||
|
result_sent: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn announce(&mut self) -> bool {
|
pub fn announce(&mut self) -> bool {
|
||||||
|
|
@ -100,6 +138,29 @@ impl BattleSession {
|
||||||
return;
|
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) {
|
pub fn spend_mana(&mut self, owner: i32, cost: i32) {
|
||||||
for entry in self.mode.battle.objects.objects.iter_mut() {
|
for entry in self.mode.battle.objects.objects.iter_mut() {
|
||||||
if entry.owner_index() != owner {
|
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()
|
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 deck = self.mode.battle.decks.get(1)?.as_ref()?;
|
||||||
let filled: Vec<LogicDataRef> = deck
|
let filled: Vec<(LogicDataRef, i32)> = deck
|
||||||
.slots
|
.slots
|
||||||
.iter()
|
.iter()
|
||||||
.flatten()
|
.flatten()
|
||||||
.map(|spell| spell.data.clone())
|
.map(|spell| (spell.data.clone(), spell.level_index))
|
||||||
.collect();
|
.collect();
|
||||||
if filled.is_empty() {
|
if filled.is_empty() {
|
||||||
return None;
|
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)?
|
.get(self.random.next(filled.len() as i32).max(0) as usize)?
|
||||||
.clone();
|
.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 (king_x, king_y) = leader.position();
|
||||||
let towers: Vec<(i32, i32)> = self.mode.battle.leader_towers
|
let towers: Vec<(i32, i32)> = self.mode.battle.leader_towers
|
||||||
[crate::bot::BOT_OWNER_INDEX as usize]
|
[crate::bot::BOT_OWNER_INDEX as usize]
|
||||||
|
|
@ -148,25 +240,39 @@ impl BattleSession {
|
||||||
.copied()
|
.copied()
|
||||||
.unwrap_or((king_x, king_y));
|
.unwrap_or((king_x, king_y));
|
||||||
let ahead = tower_y + (tower_y - king_y).signum() * BOT_DEPLOY_AHEAD;
|
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 {
|
pub fn pushes_snapshots(&self) -> bool {
|
||||||
self.mode.battle.battle_type == BATTLE_TYPE_PVP
|
self.mode.battle.battle_type == BATTLE_TYPE_PVP
|
||||||
}
|
}
|
||||||
pub fn mana(&self) -> Option<i32> {
|
pub fn mana(&self) -> Option<i32> {
|
||||||
self.mode.battle.objects.objects.iter().find_map(|entry| {
|
self.mode
|
||||||
match &entry.body {
|
.battle
|
||||||
logic::battle::LogicObjectBody::Summoner(summoner)
|
.objects
|
||||||
if entry.owner_index() == 0 =>
|
.objects
|
||||||
{
|
.iter()
|
||||||
|
.find_map(|entry| match &entry.body {
|
||||||
|
logic::battle::LogicObjectBody::Summoner(summoner) if entry.owner_index() == 0 => {
|
||||||
Some(summoner.mana)
|
Some(summoner.mana)
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
})
|
||||||
})
|
|
||||||
}
|
}
|
||||||
pub fn snapshot_message(&mut self) -> Option<WireMessage> {
|
pub fn snapshot_message(&mut self) -> Option<WireMessage> {
|
||||||
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 report = verify_snapshot(&snapshot);
|
||||||
let unit = self
|
let unit = self
|
||||||
.mode
|
.mode
|
||||||
|
|
@ -245,22 +351,75 @@ impl BattleSession {
|
||||||
pub fn seconds(&self) -> i32 {
|
pub fn seconds(&self) -> i32 {
|
||||||
self.tick / BATTLE_TICKS_PER_SECOND
|
self.tick / BATTLE_TICKS_PER_SECOND
|
||||||
}
|
}
|
||||||
pub fn queue(&mut self, at_tick: i32, entries: Vec<LogicGameObjectEntry>) {
|
pub fn queue(
|
||||||
|
&mut self,
|
||||||
|
at_tick: i32,
|
||||||
|
deploy: logic::battle::LogicVector2,
|
||||||
|
owner: i32,
|
||||||
|
mana_cost: i32,
|
||||||
|
deck_slot: i32,
|
||||||
|
mut entries: Vec<LogicGameObjectEntry>,
|
||||||
|
) -> i32 {
|
||||||
if entries.is_empty() {
|
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) {
|
pub fn advance_to(&mut self, tick: i32) {
|
||||||
let tick = tick.min(self.tick + MAX_CATCH_UP_TICKS);
|
let tick = tick.min(self.tick + MAX_CATCH_UP_TICKS);
|
||||||
while self.tick < tick {
|
while self.tick < tick {
|
||||||
if self.mode.battle.is_end_condition_matched(self.tick) {
|
if self.mode.battle.is_end_condition_matched(self.tick) {
|
||||||
|
if !self.result_sent {
|
||||||
|
self.result_sent = true;
|
||||||
|
self.emit_battle_result();
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
self.tick += 1;
|
self.tick += 1;
|
||||||
self.mode.time.tick = self.tick;
|
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);
|
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();
|
let this_checksum = self.mode.calculate_checksum().ok();
|
||||||
if let Some(checksum) = this_checksum {
|
if let Some(checksum) = this_checksum {
|
||||||
if self.recent_checksums.len() >= CHECKSUM_HISTORY {
|
if self.recent_checksums.len() >= CHECKSUM_HISTORY {
|
||||||
|
|
@ -268,32 +427,95 @@ impl BattleSession {
|
||||||
}
|
}
|
||||||
self.recent_checksums.push_back((self.tick, checksum));
|
self.recent_checksums.push_back((self.tick, checksum));
|
||||||
}
|
}
|
||||||
if heartbeats_enabled()
|
if let Ok(dump) = std::env::var("SCROLL_TRACE_TICK") {
|
||||||
&& self.pushes_snapshots()
|
if dump == "auto" || dump.parse::<i32>().ok() == Some(self.tick) {
|
||||||
&& self.tick % TICKS_PER_TURN == 0
|
titan::checksum::checksum_trace_start();
|
||||||
{
|
let dumped = self.mode.calculate_checksum().ok();
|
||||||
if let Some(checksum) = this_checksum {
|
let fields = titan::checksum::checksum_trace_take();
|
||||||
let turn = self.tick / TICKS_PER_TURN;
|
let path = format!("/tmp/scroll-server-trace-{}.txt", self.tick);
|
||||||
if let Ok(message) =
|
let mut index = 0usize;
|
||||||
crate::wire::encode(&SectorHeartbeatMessage::new(turn, checksum))
|
let mut lines = Vec::with_capacity(fields.len() + 1);
|
||||||
{
|
lines.push(format!("checksum={dumped:?}"));
|
||||||
self.outbound.push(message);
|
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();
|
self.next_snapshot = self.tick + snapshot_interval_ticks();
|
||||||
if let Some(message) = self.snapshot_message() {
|
if let Some(message) = self.snapshot_message() {
|
||||||
self.pushed_snapshots += 1;
|
self.pushed_snapshots += 1;
|
||||||
self.outbound.push(message);
|
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 {
|
if self.tick >= self.next_bot_play {
|
||||||
let span = crate::bot::BOT_PLAY_MAX_SECONDS - crate::bot::BOT_PLAY_MIN_SECONDS;
|
self.next_bot_play = if test_script_enabled() {
|
||||||
let wait = crate::bot::BOT_PLAY_MIN_SECONDS + self.random.next(span.max(1));
|
self.tick + TEST_SCRIPT_INTERVAL_TICKS
|
||||||
self.next_bot_play = self.tick + wait * BATTLE_TICKS_PER_SECOND;
|
} else {
|
||||||
if let Some((card, position, instance)) = self.bot_play() {
|
let span = crate::bot::BOT_PLAY_MAX_SECONDS - crate::bot::BOT_PLAY_MIN_SECONDS;
|
||||||
self.pending_bot_play = Some((card, position, instance));
|
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 {
|
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();
|
let mut due = Vec::new();
|
||||||
self.queued.retain(|(at, entries)| {
|
self.queued.retain(|deploy| {
|
||||||
if *at <= tick {
|
if deploy.born <= tick {
|
||||||
due.extend(entries.iter().cloned());
|
due.push((
|
||||||
|
deploy.owner,
|
||||||
|
deploy.mana_cost,
|
||||||
|
deploy.deck_slot,
|
||||||
|
deploy.entries.clone(),
|
||||||
|
));
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
true
|
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!(
|
tracing::info!(
|
||||||
tick,
|
tick,
|
||||||
global_id = ?entry.global_id.0,
|
global_id = ?entry.global_id.0,
|
||||||
|
|
@ -328,6 +591,7 @@ impl BattleSession {
|
||||||
);
|
);
|
||||||
self.mode.battle.objects.push(entry);
|
self.mode.battle.objects.push(entry);
|
||||||
}
|
}
|
||||||
|
released
|
||||||
}
|
}
|
||||||
pub fn is_finished(&self) -> bool {
|
pub fn is_finished(&self) -> bool {
|
||||||
self.mode.battle.is_end_condition_matched(self.tick)
|
self.mode.battle.is_end_condition_matched(self.tick)
|
||||||
|
|
@ -362,17 +626,116 @@ impl BattleSession {
|
||||||
pub fn next_instance(&self) -> i32 {
|
pub fn next_instance(&self) -> i32 {
|
||||||
self.next_instance
|
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) {
|
pub fn reserve_instances(&mut self, count: usize) {
|
||||||
self.next_instance += count.max(1) as i32;
|
self.next_instance += count.max(1) as i32;
|
||||||
}
|
}
|
||||||
pub fn deck_card(&self, owner: i32, slot: i32) -> Option<LogicDataRef> {
|
pub fn deck_card(&self, owner: i32, slot: i32) -> Option<LogicDataRef> {
|
||||||
let deck = self.mode.battle.decks.get(owner.max(0) as usize)?.as_ref()?;
|
self.deck_slot(owner, slot).map(|(data, _)| data)
|
||||||
let spell = deck.slots.get(slot.max(0) as usize)?.as_ref()?;
|
|
||||||
Some(spell.data.clone())
|
|
||||||
}
|
}
|
||||||
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<WireMessage> {
|
||||||
|
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<Item = i32> + '_ {
|
||||||
|
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<i32> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<LogicGameObjectEntry>,
|
||||||
|
) {
|
||||||
|
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);
|
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 {
|
pub fn object_count(&self) -> usize {
|
||||||
self.mode.battle.objects.objects.len()
|
self.mode.battle.objects.objects.len()
|
||||||
}
|
}
|
||||||
|
|
@ -405,20 +768,36 @@ impl BattleRegistry {
|
||||||
}
|
}
|
||||||
pub async fn tick<F>(&self, account: AccountRef, summon: F) -> Vec<WireMessage>
|
pub async fn tick<F>(&self, account: AccountRef, summon: F) -> Vec<WireMessage>
|
||||||
where
|
where
|
||||||
F: Fn(&LogicDataRef, LogicVector2, i32, i32) -> Vec<LogicGameObjectEntry>,
|
F: Fn(&LogicDataRef, LogicVector2, i32, i32, i32) -> Vec<LogicGameObjectEntry>,
|
||||||
{
|
{
|
||||||
let Some(handle) = self.session(account).await else {
|
let Some(handle) = self.session(account).await else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
let mut session = handle.lock().await;
|
let mut session = handle.lock().await;
|
||||||
session.advance_to_now();
|
session.advance_to_now();
|
||||||
if let Some((card, position, instance)) = session.take_bot_play() {
|
if let Some((card, position, instance, level)) = session.take_bot_play() {
|
||||||
let entries = summon(&card, position, crate::bot::BOT_OWNER_INDEX, instance);
|
let adjusted = session.mode().battle.resolve_spell_position(
|
||||||
session.reserve_instances(entries.len());
|
&card,
|
||||||
if !entries.is_empty() {
|
position,
|
||||||
tracing::debug!(card = %card, count = entries.len(), "the bot played a card");
|
crate::bot::BOT_OWNER_INDEX,
|
||||||
for entry in entries {
|
);
|
||||||
session.push(entry);
|
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<WireMessage> {
|
pub async fn resend(&self, account: AccountRef) -> Option<WireMessage> {
|
||||||
let handle = self.session(account).await?;
|
let handle = self.session(account).await?;
|
||||||
let mut session = handle.lock().await;
|
let mut session = handle.lock().await;
|
||||||
session.snapshot_message()
|
session.state_on_request()
|
||||||
}
|
}
|
||||||
pub async fn announce_once(&self, account: AccountRef) -> bool {
|
pub async fn announce_once(&self, account: AccountRef) -> bool {
|
||||||
let Some(handle) = self.session(account).await else {
|
let Some(handle) = self.session(account).await else {
|
||||||
|
|
@ -471,15 +850,25 @@ impl BattleRegistry {
|
||||||
pub async fn play<F>(
|
pub async fn play<F>(
|
||||||
&self,
|
&self,
|
||||||
account: AccountRef,
|
account: AccountRef,
|
||||||
executor: LogicLong,
|
played: &logic::LogicDoSpellCommand,
|
||||||
slot: i32,
|
|
||||||
position: logic::battle::LogicVector2,
|
|
||||||
at_tick: i32,
|
|
||||||
summon: F,
|
summon: F,
|
||||||
) -> usize
|
) -> usize
|
||||||
where
|
where
|
||||||
F: Fn(&LogicDataRef, logic::battle::LogicVector2, i32, i32) -> Vec<LogicGameObjectEntry>,
|
F: Fn(
|
||||||
|
&LogicDataRef,
|
||||||
|
logic::battle::LogicVector2,
|
||||||
|
i32,
|
||||||
|
i32,
|
||||||
|
i32,
|
||||||
|
) -> Vec<LogicGameObjectEntry>,
|
||||||
{
|
{
|
||||||
|
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 {
|
let Some(handle) = self.session(account).await else {
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
@ -487,16 +876,68 @@ impl BattleRegistry {
|
||||||
let Some(owner) = session.owner_of(executor) else {
|
let Some(owner) = session.owner_of(executor) else {
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
let Some(card) = session.deck_card(owner, slot) else {
|
let Some((card, level)) = session.deck_slot(owner, slot) else {
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
let cost = card.data().map(|row| row.int("ManaCost")).unwrap_or(0);
|
let Some(adjusted) = session
|
||||||
session.spend_mana(owner, cost);
|
.mode()
|
||||||
session.play_from_hand(owner, slot);
|
.battle
|
||||||
let entries = summon(&card, position, owner, session.next_instance());
|
.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());
|
session.reserve_instances(entries.len());
|
||||||
let spawned = 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
|
spawned
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,10 @@ fn predefined_deck() -> Option<LogicSpellDeck> {
|
||||||
let name = row.string_at(DECK_SPELLS_COLUMN, slot);
|
let name = row.string_at(DECK_SPELLS_COLUMN, slot);
|
||||||
let data = LogicDataRef::by_name(table::SPELLS, name);
|
let data = LogicDataRef::by_name(table::SPELLS, name);
|
||||||
if data.is_none() {
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
let level = row.int_at(DECK_LEVEL_COLUMN, slot).max(1);
|
let level = row.int_at(DECK_LEVEL_COLUMN, slot).max(1);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
|
use crate::config::{CardRef, DataSelector};
|
||||||
|
use logic::{table, LogicDataRef, LogicDataTables};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use logic::{table, LogicDataRef, LogicDataTables};
|
|
||||||
use crate::config::{CardRef, DataSelector};
|
|
||||||
pub const GOLD_RESOURCE_FALLBACK_INSTANCE: i32 = 1;
|
pub const GOLD_RESOURCE_FALLBACK_INSTANCE: i32 = 1;
|
||||||
pub const ARENA_FALLBACK_INSTANCE: i32 = 1;
|
pub const ARENA_FALLBACK_INSTANCE: i32 = 1;
|
||||||
pub struct Catalog {
|
pub struct Catalog {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use std::path::PathBuf;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
use storage::DatabaseConfig;
|
use storage::DatabaseConfig;
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(untagged)]
|
#[serde(untagged)]
|
||||||
|
|
|
||||||
|
|
@ -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::{
|
use logic::model::{
|
||||||
LogicChest, LogicClientAvatar, LogicClientHome, LogicCommodityStore, LogicDataSlot, LogicSpell,
|
LogicChest, LogicClientAvatar, LogicClientHome, LogicCommodityStore, LogicDataSlot, LogicSpell,
|
||||||
LogicSpellCollection, LogicSpellDeck, LogicTimer,
|
LogicSpellCollection, LogicSpellDeck, LogicTimer,
|
||||||
};
|
};
|
||||||
use titan::LogicLong;
|
|
||||||
use logic::data::RESOURCE_GOLD;
|
|
||||||
use logic::{table, LogicDataRef};
|
use logic::{table, LogicDataRef};
|
||||||
use logic::data::LogicDataTables;
|
|
||||||
use logic::commands::COMMODITY_ACHIEVEMENT_PROGRESS;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use crate::catalog::GOLD_RESOURCE_FALLBACK_INSTANCE;
|
use titan::LogicLong;
|
||||||
use crate::store::{OwnedCard, PlayerProfile, StoredChest};
|
|
||||||
pub const COMMODITY_RESOURCES: usize = 0;
|
pub const COMMODITY_RESOURCES: usize = 0;
|
||||||
fn spell_of(owned: &OwnedCard) -> LogicSpell {
|
fn spell_of(owned: &OwnedCard) -> LogicSpell {
|
||||||
LogicSpell {
|
LogicSpell {
|
||||||
|
|
@ -156,7 +156,10 @@ pub fn build_avatar(profile: &PlayerProfile) -> LogicClientAvatar {
|
||||||
COMMODITY_RESOURCES,
|
COMMODITY_RESOURCES,
|
||||||
vec![LogicDataSlot::new(gold_resource(profile), profile.gold)],
|
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 {
|
LogicClientAvatar {
|
||||||
avatar_id: account,
|
avatar_id: account,
|
||||||
account_id: account,
|
account_id: account,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
use std::collections::HashMap;
|
use crate::home::{build_avatar, build_home};
|
||||||
use std::sync::Arc;
|
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::data::{RESOURCE_DIAMONDS, RESOURCE_GOLD};
|
||||||
use logic::{
|
use logic::{
|
||||||
chest_source, AvailableServerCommandMessage, CommandOutcome, EndClientTurnMessage,
|
chest_source, AvailableServerCommandMessage, CommandOutcome, EndClientTurnMessage,
|
||||||
|
|
@ -8,11 +10,9 @@ use logic::{
|
||||||
};
|
};
|
||||||
use logic::{table, LogicGlobals};
|
use logic::{table, LogicGlobals};
|
||||||
use service_rpc::AccountRef;
|
use service_rpc::AccountRef;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
use tokio::sync::{Mutex, RwLock};
|
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 MAX_FAST_FORWARD_TICKS: i32 = 20 * 60 * 60;
|
||||||
pub const CHEST_FREE: &str = "Free";
|
pub const CHEST_FREE: &str = "Free";
|
||||||
pub const CHEST_CROWN: &str = "Star";
|
pub const CHEST_CROWN: &str = "Star";
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use std::sync::Arc;
|
|
||||||
use game_service::{GameConfig, GameService};
|
use game_service::{GameConfig, GameService};
|
||||||
use service_rpc::serve;
|
use service_rpc::serve;
|
||||||
|
use std::sync::Arc;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
|
use service_rpc::AccountRef;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use service_rpc::AccountRef;
|
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
pub const MATCHMAKE_WAIT: Duration = Duration::from_secs(10);
|
pub const MATCHMAKE_WAIT: Duration = Duration::from_secs(10);
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|
@ -16,10 +16,7 @@ pub struct Matchmaker {
|
||||||
impl Matchmaker {
|
impl Matchmaker {
|
||||||
pub async fn enter(&self, account: AccountRef) -> Matched {
|
pub async fn enter(&self, account: AccountRef) -> Matched {
|
||||||
let mut waiting = self.waiting.lock().await;
|
let mut waiting = self.waiting.lock().await;
|
||||||
let opponent = waiting
|
let opponent = waiting.keys().find(|other| **other != account).copied();
|
||||||
.keys()
|
|
||||||
.find(|other| **other != account)
|
|
||||||
.copied();
|
|
||||||
if let Some(opponent) = opponent {
|
if let Some(opponent) = opponent {
|
||||||
waiting.remove(&opponent);
|
waiting.remove(&opponent);
|
||||||
waiting.remove(&account);
|
waiting.remove(&account);
|
||||||
|
|
@ -33,11 +30,7 @@ impl Matchmaker {
|
||||||
let Some(since) = waiting.get(&account).copied() else {
|
let Some(since) = waiting.get(&account).copied() else {
|
||||||
return Matched::Waiting;
|
return Matched::Waiting;
|
||||||
};
|
};
|
||||||
if let Some(opponent) = waiting
|
if let Some(opponent) = waiting.keys().find(|other| **other != account).copied() {
|
||||||
.keys()
|
|
||||||
.find(|other| **other != account)
|
|
||||||
.copied()
|
|
||||||
{
|
|
||||||
waiting.remove(&opponent);
|
waiting.remove(&opponent);
|
||||||
waiting.remove(&account);
|
waiting.remove(&account);
|
||||||
return Matched::Player(opponent);
|
return Matched::Player(opponent);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
|
use crate::catalog::Catalog;
|
||||||
use logic::data::{RARITY_COMMON, RARITY_EPIC, RARITY_RARE};
|
use logic::data::{RARITY_COMMON, RARITY_EPIC, RARITY_RARE};
|
||||||
use logic::model::LogicSpell;
|
use logic::model::LogicSpell;
|
||||||
use logic::{LogicDataRef, LogicReward};
|
use logic::{LogicDataRef, LogicReward};
|
||||||
use rand::seq::SliceRandom;
|
use rand::seq::SliceRandom;
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use crate::catalog::Catalog;
|
|
||||||
pub struct RewardRoller {
|
pub struct RewardRoller {
|
||||||
pool: Vec<LogicDataRef>,
|
pool: Vec<LogicDataRef>,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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::table;
|
||||||
use logic::{
|
use logic::{
|
||||||
AvailableServerCommandMessage, EndClientTurnMessage, LogicCommandManager, LogicDataRef,
|
AvailableServerCommandMessage, EndClientTurnMessage, LogicCommandManager, LogicDataRef,
|
||||||
LogicShopSeedChangedCommand, OutOfSyncMessage, OwnHomeDataMessage,
|
LogicShopSeedChangedCommand, OutOfSyncMessage, OwnHomeDataMessage, SectorCommandMessage,
|
||||||
SectorCommandMessage, SendBattleEventMessage,
|
SendBattleEventMessage, StartMissionMessage, StopHomeLogicMessage,
|
||||||
StartMissionMessage, StopHomeLogicMessage,
|
|
||||||
};
|
};
|
||||||
use service_rpc::{
|
use service_rpc::{
|
||||||
AccountRef, GameApi, GameRequest, GameResponse, HomeRequestKind, RpcError, RpcResult,
|
AccountRef, GameApi, GameRequest, GameResponse, HomeRequestKind, RpcError, RpcResult,
|
||||||
RpcService, WireMessage,
|
RpcService, WireMessage,
|
||||||
};
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
use titan::{Message, MessageMeta, Payload};
|
use titan::{Message, MessageMeta, Payload};
|
||||||
use crate::battle::BattleBuilder;
|
fn location_named(name: &str) -> LogicDataRef {
|
||||||
use crate::battle_session::BattleRegistry;
|
if name.is_empty() {
|
||||||
use crate::matchmaker::{Matched, Matchmaker};
|
return LogicDataRef::None;
|
||||||
use crate::catalog::Catalog;
|
}
|
||||||
use crate::config::GameConfig;
|
LogicDataRef::by_name(table::LOCATIONS, name)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
pub struct GameService {
|
pub struct GameService {
|
||||||
config: GameConfig,
|
config: GameConfig,
|
||||||
|
|
@ -114,9 +112,15 @@ impl GameService {
|
||||||
.await
|
.await
|
||||||
.stop_home_logic();
|
.stop_home_logic();
|
||||||
let location = if npc.is_none() {
|
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 {
|
} 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 deck = build_deck(&profile);
|
||||||
let (opponent, opponent_deck) = match opponent_account {
|
let (opponent, opponent_deck) = match opponent_account {
|
||||||
|
|
@ -339,16 +343,9 @@ impl GameApi for GameService {
|
||||||
};
|
};
|
||||||
spawned += self
|
spawned += self
|
||||||
.running_battles
|
.running_battles
|
||||||
.play(
|
.play(account, played, |card, position, owner, level, instance| {
|
||||||
account,
|
self.battles.summon(card, position, owner, level, instance)
|
||||||
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)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
let progress = self.running_battles.advance(account, turn.tick).await;
|
let progress = self.running_battles.advance(account, turn.tick).await;
|
||||||
|
|
@ -444,12 +441,15 @@ impl GameApi for GameService {
|
||||||
}
|
}
|
||||||
let messages = self
|
let messages = self
|
||||||
.running_battles
|
.running_battles
|
||||||
.tick(account, |card, position, owner, instance| {
|
.tick(account, |card, position, owner, level, instance| {
|
||||||
self.battles.summon(card, position, owner, 0, instance)
|
self.battles.summon(card, position, owner, level, instance)
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
if !messages.is_empty() {
|
if !messages.is_empty() {
|
||||||
let kinds: Vec<u16> = messages.iter().map(|message| message.message_type).collect();
|
let kinds: Vec<u16> = messages
|
||||||
|
.iter()
|
||||||
|
.map(|message| message.message_type)
|
||||||
|
.collect();
|
||||||
tracing::debug!(%account, sent = ?kinds, "battle tick");
|
tracing::debug!(%account, sent = ?kinds, "battle tick");
|
||||||
}
|
}
|
||||||
Ok(messages)
|
Ok(messages)
|
||||||
|
|
@ -468,7 +468,12 @@ impl GameApi for GameService {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn request_sector_state(&self, account: AccountRef) -> RpcResult<Vec<WireMessage>> {
|
async fn request_sector_state(&self, account: AccountRef) -> RpcResult<Vec<WireMessage>> {
|
||||||
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<u8>) -> RpcResult<()> {
|
async fn sector_command(&self, account: AccountRef, payload: Vec<u8>) -> RpcResult<()> {
|
||||||
let Ok(sector) = SectorCommandMessage::from_bytes(&payload) else {
|
let Ok(sector) = SectorCommandMessage::from_bytes(&payload) else {
|
||||||
|
|
@ -483,23 +488,16 @@ impl GameApi for GameService {
|
||||||
.await;
|
.await;
|
||||||
let server_checksum = progress.and_then(|state| state.checksum);
|
let server_checksum = progress.and_then(|state| state.checksum);
|
||||||
let mut spawned = 0;
|
let mut spawned = 0;
|
||||||
if let Some(played) = sector
|
if let Some(played) = sector.command.as_ref().and_then(|command| {
|
||||||
.command
|
command
|
||||||
.as_ref()
|
.as_any()
|
||||||
.and_then(|command| command.as_any().downcast_ref::<logic::LogicDoSpellCommand>())
|
.downcast_ref::<logic::LogicDoSpellCommand>()
|
||||||
{
|
}) {
|
||||||
spawned = self
|
spawned = self
|
||||||
.running_battles
|
.running_battles
|
||||||
.play(
|
.play(account, played, |card, position, owner, level, instance| {
|
||||||
account,
|
self.battles.summon(card, position, owner, level, instance)
|
||||||
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)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
|
|
@ -556,9 +554,9 @@ impl RpcService for GameService {
|
||||||
GameRequest::EndClientTurn { account, payload } => Ok(GameResponse::messages(
|
GameRequest::EndClientTurn { account, payload } => Ok(GameResponse::messages(
|
||||||
self.end_client_turn(account, payload).await?,
|
self.end_client_turn(account, payload).await?,
|
||||||
)),
|
)),
|
||||||
GameRequest::BattleTick { account } => Ok(GameResponse::messages(
|
GameRequest::BattleTick { account } => {
|
||||||
self.battle_tick(account).await?,
|
Ok(GameResponse::messages(self.battle_tick(account).await?))
|
||||||
)),
|
}
|
||||||
GameRequest::RequestSectorState { account } => Ok(GameResponse::messages(
|
GameRequest::RequestSectorState { account } => Ok(GameResponse::messages(
|
||||||
self.request_sector_state(account).await?,
|
self.request_sector_state(account).await?,
|
||||||
)),
|
)),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use std::path::Path;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use crate::shop::entry::ShopEntry;
|
use crate::shop::entry::ShopEntry;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::Path;
|
||||||
pub const DEFAULT_SHOP: &str = include_str!("default_shop.json");
|
pub const DEFAULT_SHOP: &str = include_str!("default_shop.json");
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct ShopOffer {
|
pub struct ShopOffer {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use std::fmt;
|
|
||||||
use std::str::FromStr;
|
|
||||||
use logic::{table, LogicDataRef};
|
use logic::{table, LogicDataRef};
|
||||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
|
use std::fmt;
|
||||||
|
use std::str::FromStr;
|
||||||
pub const SEPARATOR: char = '#';
|
pub const SEPARATOR: char = '#';
|
||||||
pub const QUALIFIER: char = ':';
|
pub const QUALIFIER: char = ':';
|
||||||
pub const LOOKUP: &[(&str, i32)] = &[
|
pub const LOOKUP: &[(&str, i32)] = &[
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
mod postgres;
|
mod postgres;
|
||||||
mod profile;
|
mod profile;
|
||||||
use service_rpc::RpcError;
|
|
||||||
use storage::StorageError;
|
|
||||||
pub use postgres::ProfileStore;
|
pub use postgres::ProfileStore;
|
||||||
pub use profile::{
|
pub use profile::{
|
||||||
OwnedCard, PlayerProfile, StoredChest, CARD_HOLDER_COLLECTION, CARD_HOLDER_DECK,
|
OwnedCard, PlayerProfile, StoredChest, CARD_HOLDER_COLLECTION, CARD_HOLDER_DECK,
|
||||||
};
|
};
|
||||||
|
use service_rpc::RpcError;
|
||||||
|
use storage::StorageError;
|
||||||
pub fn unavailable(error: StorageError) -> RpcError {
|
pub fn unavailable(error: StorageError) -> RpcError {
|
||||||
RpcError::Unavailable(error.to_string())
|
RpcError::Unavailable(error.to_string())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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::shop::Purchase;
|
||||||
use crate::store::{
|
use crate::store::{
|
||||||
OwnedCard, PlayerProfile, StoredChest, CARD_HOLDER_COLLECTION, CARD_HOLDER_DECK,
|
OwnedCard, PlayerProfile, StoredChest, CARD_HOLDER_COLLECTION, CARD_HOLDER_DECK,
|
||||||
};
|
};
|
||||||
use crate::time::unix_seconds;
|
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 {
|
pub struct ProfileStore {
|
||||||
database: Database,
|
database: Database,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use service_rpc::AccountRef;
|
|
||||||
use logic::LogicDataRef;
|
|
||||||
use crate::catalog::Catalog;
|
use crate::catalog::Catalog;
|
||||||
use crate::config::StarterProfile;
|
use crate::config::StarterProfile;
|
||||||
|
use logic::LogicDataRef;
|
||||||
|
use service_rpc::AccountRef;
|
||||||
pub const CARD_HOLDER_DECK: i16 = 0;
|
pub const CARD_HOLDER_DECK: i16 = 0;
|
||||||
pub const CARD_HOLDER_COLLECTION: i16 = 1;
|
pub const CARD_HOLDER_COLLECTION: i16 = 1;
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
use std::path::Path;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use game_service::home::build_avatar;
|
use game_service::home::build_avatar;
|
||||||
use logic::commands::COMMODITY_ACHIEVEMENT_PROGRESS;
|
use logic::commands::COMMODITY_ACHIEVEMENT_PROGRESS;
|
||||||
use logic::{CommandOutcome, LogicClaimAchievementRewardCommand, LogicCommandHeader};
|
|
||||||
use logic::data::{table, LogicDataRef, LogicDataTables};
|
use logic::data::{table, LogicDataRef, LogicDataTables};
|
||||||
use logic::home::LogicHomeMode;
|
use logic::home::LogicHomeMode;
|
||||||
use logic::model::LogicClientHome;
|
use logic::model::LogicClientHome;
|
||||||
|
use logic::{CommandOutcome, LogicClaimAchievementRewardCommand, LogicCommandHeader};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
fn tables() {
|
fn tables() {
|
||||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets");
|
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets");
|
||||||
if let Ok(t) = LogicDataTables::load_from_dir(&root) {
|
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.avatar_mut().exp_points = 0;
|
||||||
home.add_exp(25);
|
home.add_exp(25);
|
||||||
assert_eq!(home.avatar().exp_level, 2, "25 exp should reach level 2");
|
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);
|
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);
|
assert_eq!(home.avatar().exp_points, 5);
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -61,7 +69,10 @@ fn a_completed_findcard_achievement_can_be_claimed() {
|
||||||
.get(COMMODITY_ACHIEVEMENT_PROGRESS)
|
.get(COMMODITY_ACHIEVEMENT_PROGRESS)
|
||||||
.map(|slots| slots.iter().filter(|s| s.count >= required).count())
|
.map(|slots| slots.iter().filter(|s| s.count >= required).count())
|
||||||
.unwrap_or(0);
|
.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 mut home = LogicHomeMode::new(LogicClientHome::default(), avatar, 0);
|
||||||
let before_level = home.avatar().exp_level;
|
let before_level = home.avatar().exp_level;
|
||||||
let before_points = home.avatar().exp_points;
|
let before_points = home.avatar().exp_points;
|
||||||
|
|
@ -70,10 +81,17 @@ fn a_completed_findcard_achievement_can_be_claimed() {
|
||||||
header: LogicCommandHeader::default(),
|
header: LogicCommandHeader::default(),
|
||||||
};
|
};
|
||||||
let outcome = home.execute(&command);
|
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!(
|
assert!(
|
||||||
home.avatar().exp_points != before_points || home.avatar().exp_level != before_level,
|
home.avatar().exp_points != before_points || home.avatar().exp_level != before_level,
|
||||||
"claiming should have granted exp"
|
"claiming should have granted exp"
|
||||||
);
|
);
|
||||||
assert!(matches!(home.execute(&command), CommandOutcome::Rejected(_)));
|
assert!(matches!(
|
||||||
|
home.execute(&command),
|
||||||
|
CommandOutcome::Rejected(_)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
100
crates/game-service/tests/combat_timer_phase.rs
Normal file
100
crates/game-service/tests/combat_timer_phase.rs
Normal file
|
|
@ -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<i32> {
|
||||||
|
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<logic::battle::LogicGameObjectRef> = 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
148
crates/game-service/tests/deploy_birth_tick.rs
Normal file
148
crates/game-service/tests/deploy_birth_tick.rs
Normal file
|
|
@ -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");
|
||||||
|
}
|
||||||
45
crates/game-service/tests/level_scaling.rs
Normal file
45
crates/game-service/tests/level_scaling.rs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
161
crates/game-service/tests/projectile_references.rs
Normal file
161
crates/game-service/tests/projectile_references.rs
Normal file
|
|
@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
use std::path::Path;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use game_service::battle::BattleBuilder;
|
use game_service::battle::BattleBuilder;
|
||||||
use game_service::battle_session::MAX_CATCH_UP_TICKS;
|
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::data::{table, LogicDataRef, LogicDataTables};
|
||||||
use logic::model::LogicClientAvatar;
|
use logic::model::LogicClientAvatar;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
use titan::LogicLong;
|
use titan::LogicLong;
|
||||||
fn assets() -> std::path::PathBuf {
|
fn assets() -> std::path::PathBuf {
|
||||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets")
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets")
|
||||||
|
|
@ -20,6 +20,40 @@ fn avatar(low: i32) -> LogicClientAvatar {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[test]
|
#[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() {
|
fn a_troop_walks_at_the_enemy_towers_and_stops_in_range() {
|
||||||
let root = assets();
|
let root = assets();
|
||||||
let tables = LogicDataTables::load_from_dir(&root).expect("tables");
|
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);
|
let reach = ((end.0 - tower.0) as f64).hypot((end.1 - tower.1) as f64);
|
||||||
println!("distance to the near enemy tower: {reach:.0}");
|
println!("distance to the near enemy tower: {reach:.0}");
|
||||||
assert!(
|
assert!(
|
||||||
reach < 2500.0,
|
reach < 3000.0,
|
||||||
"the knight never reached the enemy tower, stopped {reach:.0} away at {end:?}"
|
"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 builder = BattleBuilder::new(&root);
|
||||||
let mut deck = logic::model::LogicSpellDeck::default();
|
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 {
|
deck.slots[slot] = Some(logic::model::LogicSpell {
|
||||||
data: LogicDataRef::spell(name),
|
data: LogicDataRef::spell(name),
|
||||||
..logic::model::LogicSpell::default()
|
..logic::model::LogicSpell::default()
|
||||||
|
|
@ -112,8 +149,8 @@ fn the_bot_deploys_in_front_of_its_own_towers() {
|
||||||
let mut plays = 0;
|
let mut plays = 0;
|
||||||
for tick in 0..(180 * BATTLE_TICKS_PER_SECOND) {
|
for tick in 0..(180 * BATTLE_TICKS_PER_SECOND) {
|
||||||
if tick % 20 == 0 {
|
if tick % 20 == 0 {
|
||||||
if let Some((card, at, instance)) = session.bot_play() {
|
if let Some((card, at, instance, level)) = session.bot_play() {
|
||||||
let entries = builder.summon(&card, at, 1, 0, instance);
|
let entries = builder.summon(&card, at, 1, level, instance);
|
||||||
let entries_debug = entries.clone();
|
let entries_debug = entries.clone();
|
||||||
session.reserve_instances(entries.len());
|
session.reserve_instances(entries.len());
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
|
|
@ -126,14 +163,17 @@ fn the_bot_deploys_in_front_of_its_own_towers() {
|
||||||
);
|
);
|
||||||
plays += 1;
|
plays += 1;
|
||||||
if plays <= 8 {
|
if plays <= 8 {
|
||||||
println!(
|
println!(
|
||||||
"bot played {:?} at {:?}, {} entries, speeds {:?}",
|
"bot played {:?} at {:?}, {} entries, speeds {:?}",
|
||||||
card,
|
card,
|
||||||
(at.x, at.y),
|
(at.x, at.y),
|
||||||
entries_debug.len(),
|
entries_debug.len(),
|
||||||
entries_debug.iter().map(|e| e.stats().speed).collect::<Vec<_>>()
|
entries_debug
|
||||||
);
|
.iter()
|
||||||
}
|
.map(|e| e.stats().speed)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
session.advance_to(tick);
|
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() {
|
for entry in session.mode().battle.objects.objects.iter() {
|
||||||
let (x, y) = entry.position();
|
let (x, y) = entry.position();
|
||||||
if !((500..=17500).contains(&x) && (1000..=31000).contains(&y)) {
|
if !((0..=17999).contains(&x) && (0..=31999).contains(&y)) {
|
||||||
println!(
|
println!(
|
||||||
"t={tick}: object owner={} speed={} alive={} at ({x}, {y})",
|
"t={tick}: object owner={} speed={} alive={} at ({x}, {y})",
|
||||||
entry.owner_index(),
|
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");
|
assert_eq!(mask, 13, "a princess tower keeps its combat bit");
|
||||||
princesses += 1;
|
princesses += 1;
|
||||||
}
|
}
|
||||||
|
logic::battle::LogicObjectBody::Projectile(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert_eq!(kings, 2);
|
assert_eq!(kings, 2);
|
||||||
|
|
@ -389,7 +430,11 @@ fn a_deployed_troop_counts_down_and_holds_still_until_ready() {
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
mode.battle.tick(1);
|
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
|
let here = mode
|
||||||
.battle
|
.battle
|
||||||
.objects
|
.objects
|
||||||
|
|
@ -476,7 +521,10 @@ fn a_troop_in_range_damages_the_tower_with_the_client_hit_model() {
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.expect("tower still present");
|
.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]
|
#[test]
|
||||||
fn the_session_emits_a_heartbeat_every_turn() {
|
fn the_session_emits_a_heartbeat_every_turn() {
|
||||||
|
|
@ -500,11 +548,13 @@ fn the_session_emits_a_heartbeat_every_turn() {
|
||||||
session.advance_to(25);
|
session.advance_to(25);
|
||||||
let out = session.take_outbound();
|
let out = session.take_outbound();
|
||||||
std::env::remove_var("SCROLL_HEARTBEAT");
|
std::env::remove_var("SCROLL_HEARTBEAT");
|
||||||
let heartbeats: Vec<&service_rpc::WireMessage> = out
|
let heartbeats: Vec<&service_rpc::WireMessage> =
|
||||||
.iter()
|
out.iter().filter(|m| m.message_type == 21902).collect();
|
||||||
.filter(|m| m.message_type == 21902)
|
assert_eq!(
|
||||||
.collect();
|
heartbeats.len(),
|
||||||
assert_eq!(heartbeats.len(), 2, "expected a heartbeat at ticks 10 and 20");
|
2,
|
||||||
|
"expected a heartbeat at ticks 10 and 20"
|
||||||
|
);
|
||||||
let mut reader = titan::ByteStreamReader::new(&heartbeats[0].payload);
|
let mut reader = titan::ByteStreamReader::new(&heartbeats[0].payload);
|
||||||
let turn = reader.read_vint().unwrap();
|
let turn = reader.read_vint().unwrap();
|
||||||
let checksum = 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 mut deck = logic::model::LogicSpellDeck::default();
|
||||||
let names = [
|
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()) {
|
for (slot, name) in names.iter().enumerate().take(deck.slots.len()) {
|
||||||
deck.slots[slot] = Some(logic::model::LogicSpell {
|
deck.slots[slot] = Some(logic::model::LogicSpell {
|
||||||
|
|
@ -547,9 +604,10 @@ fn playing_a_card_cycles_the_summoner_hand() {
|
||||||
.objects
|
.objects
|
||||||
.iter()
|
.iter()
|
||||||
.find_map(|e| match &e.body {
|
.find_map(|e| match &e.body {
|
||||||
logic::battle::LogicObjectBody::Summoner(s) if e.owner_index() == 0 => {
|
logic::battle::LogicObjectBody::Summoner(s) if e.owner_index() == 0 => s
|
||||||
s.deck.as_ref().map(|d| (d.hand, d.used_pile.clone(), d.spell_cooldown))
|
.deck
|
||||||
}
|
.as_ref()
|
||||||
|
.map(|d| (d.hand, d.used_pile.clone(), d.spell_cooldown)),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.expect("player summoner with a deck")
|
.expect("player summoner with a deck")
|
||||||
|
|
@ -569,7 +627,10 @@ fn playing_a_card_cycles_the_summoner_hand() {
|
||||||
}
|
}
|
||||||
let (hand2, _, _) = hand_of(&session);
|
let (hand2, _, _) = hand_of(&session);
|
||||||
assert_ne!(hand2[0], -1, "the slot refilled from the draw pile");
|
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]
|
#[test]
|
||||||
fn a_multi_unit_card_spreads_its_units() {
|
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));
|
LogicDataTables::install(Arc::new(t));
|
||||||
}
|
}
|
||||||
let builder = BattleBuilder::new(&root);
|
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");
|
assert!(entries.len() >= 3, "goblins summon multiple units");
|
||||||
let positions: Vec<(i32, i32)> = entries.iter().map(|e| e.position()).collect();
|
let positions: Vec<(i32, i32)> = entries.iter().map(|e| e.position()).collect();
|
||||||
for i in 0..positions.len() {
|
for i in 0..positions.len() {
|
||||||
for j in (i + 1)..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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
use std::sync::Arc;
|
|
||||||
use service_rpc::{
|
use service_rpc::{
|
||||||
AccountRef, AuthApi, AuthRequest, AuthResponse, DeviceInfo, GameApi, GameRequest, GameResponse,
|
AccountRef, AuthApi, AuthRequest, AuthResponse, DeviceInfo, GameApi, GameRequest, GameResponse,
|
||||||
HomeRequestKind, LoginOutcome, RpcClient, RpcError, RpcResult, WireMessage,
|
HomeRequestKind, LoginOutcome, RpcClient, RpcError, RpcResult, WireMessage,
|
||||||
};
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
pub struct Backends {
|
pub struct Backends {
|
||||||
pub auth: Arc<dyn AuthApi>,
|
pub auth: Arc<dyn AuthApi>,
|
||||||
pub game: Arc<dyn GameApi>,
|
pub game: Arc<dyn GameApi>,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
use std::time::Duration;
|
|
||||||
use logic::{
|
use logic::{
|
||||||
message_type, AvailableServerCommandMessage, EndClientTurnMessage, KeepAliveMessage,
|
message_type, AvailableServerCommandMessage, EndClientTurnMessage, KeepAliveMessage,
|
||||||
LogicClaimRewardCommand, LogicCollectFreeChestCommand, LogicCommandHeader, LogicDataTables,
|
LogicClaimRewardCommand, LogicCollectFreeChestCommand, LogicCommandHeader, LogicDataTables,
|
||||||
LoginMessage, LoginOkMessage, OwnHomeDataMessage,
|
LoginMessage, LoginOkMessage, OwnHomeDataMessage,
|
||||||
};
|
};
|
||||||
|
use std::time::Duration;
|
||||||
use titan::crypto::SessionCipher;
|
use titan::crypto::SessionCipher;
|
||||||
use titan::{FrameCodec, FrameHeader, MessageFrame, MessageMeta, Payload, HEADER_LEN};
|
use titan::{FrameCodec, FrameHeader, MessageFrame, MessageMeta, Payload, HEADER_LEN};
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@ pub mod backend;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod message_manager;
|
pub mod message_manager;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::net::TcpListener;
|
|
||||||
pub use backend::{Backends, RemoteAuth, RemoteGame};
|
pub use backend::{Backends, RemoteAuth, RemoteGame};
|
||||||
pub use config::GatewayConfig;
|
pub use config::GatewayConfig;
|
||||||
pub use message_manager::MessageManager;
|
pub use message_manager::MessageManager;
|
||||||
pub use session::{Session, SessionError};
|
pub use session::{Session, SessionError};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
pub async fn bind(config: &GatewayConfig) -> std::io::Result<TcpListener> {
|
pub async fn bind(config: &GatewayConfig) -> std::io::Result<TcpListener> {
|
||||||
TcpListener::bind(&config.listen).await
|
TcpListener::bind(&config.listen).await
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
use std::net::SocketAddr;
|
use crate::backend::Backends;
|
||||||
use std::sync::Arc;
|
use crate::config::GatewayConfig;
|
||||||
use std::time::Duration;
|
|
||||||
use tokio::task::JoinHandle;
|
|
||||||
use logic::{
|
use logic::{
|
||||||
message_type, CancelMatchmakeDoneMessage, ClientCapabilitiesMessage, GoHomeMessage,
|
message_type, CancelMatchmakeDoneMessage, ClientCapabilitiesMessage, GoHomeMessage,
|
||||||
KeepAliveServerMessage, LoginFailedMessage, LoginMessage, LoginOkMessage, ServerErrorMessage,
|
KeepAliveServerMessage, LoginFailedMessage, LoginMessage, LoginOkMessage, ServerErrorMessage,
|
||||||
|
|
@ -9,9 +7,11 @@ use logic::{
|
||||||
use service_rpc::{
|
use service_rpc::{
|
||||||
AccountRef, DeviceInfo, HomeRequestKind, LoginOutcome, Session as AuthSession, WireMessage,
|
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 titan::{Incoming, LogicLong, MessagingSender, OutboundMessage};
|
||||||
use crate::backend::Backends;
|
use tokio::task::JoinHandle;
|
||||||
use crate::config::GatewayConfig;
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum RoutingError {
|
pub enum RoutingError {
|
||||||
#[error("transport: {0}")]
|
#[error("transport: {0}")]
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
|
use crate::backend::Backends;
|
||||||
|
use crate::config::GatewayConfig;
|
||||||
|
use crate::message_manager::{MessageManager, RoutingError};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use titan::crypto::SessionCipher;
|
use titan::crypto::SessionCipher;
|
||||||
use titan::{MessageRegistry, Messaging, MessagingConfig};
|
use titan::{MessageRegistry, Messaging, MessagingConfig};
|
||||||
use tokio::net::TcpStream;
|
use tokio::net::TcpStream;
|
||||||
use crate::backend::Backends;
|
|
||||||
use crate::config::GatewayConfig;
|
|
||||||
use crate::message_manager::{MessageManager, RoutingError};
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum SessionError {
|
pub enum SessionError {
|
||||||
#[error("io: {0}")]
|
#[error("io: {0}")]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue