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::{
|
||||
LogicBattle, LogicCharacter, LogicCharacterBuffComponent, LogicCombatComponent, LogicComponent,
|
||||
LogicGameMode, LogicGameObject, LogicGameObjectEntry, LogicGameObjectManager,
|
||||
LogicGameObjectRef, LogicHitpointComponent, LogicMovementComponent, LogicObjectBody,
|
||||
LogicSummoner, LogicSummonerDeck,
|
||||
LogicTilemap,
|
||||
LogicTime, LogicVector2, BATTLE_TYPE_PVP, CHARACTER_OBJECT_TYPE, COMPONENT_PASSES,
|
||||
DIRECTION_BOTTOM,
|
||||
DIRECTION_TOP, SUBTILE_UNITS,
|
||||
LogicSummoner, LogicSummonerDeck, LogicTilemap, LogicTime, LogicVector2, BATTLE_TYPE_PVP,
|
||||
CHARACTER_OBJECT_TYPE, COMPONENT_PASSES, DIRECTION_BOTTOM, DIRECTION_TOP, SUBTILE_UNITS,
|
||||
};
|
||||
use logic::model::{LogicClientAvatar, LogicSpellDeck};
|
||||
use logic::{table, LogicDataRef, LogicDataTables, LogicGlobals, LogicRandom};
|
||||
pub const LOCATION_FILE_COLUMN: &str = "FileName";
|
||||
use std::path::{Path, PathBuf};
|
||||
pub const CHARACTER_KING_TOWER: &str = "KingTower";
|
||||
pub const CHARACTER_PRINCESS_TOWER: &str = "PrincessTower";
|
||||
pub const BUFF_ARRAY_TABLE: i32 = table::DAMAGE_TYPES;
|
||||
pub const START_MANA_GLOBAL: &str = "START_MANA";
|
||||
pub const CHARACTER_HITPOINTS_COLUMN: &str = "Hitpoints";
|
||||
pub const CHARACTER_SPEED_COLUMN: &str = "Speed";
|
||||
pub const SPELL_SUMMON_CHARACTER_COLUMN: &str = "SummonCharacter";
|
||||
pub const SPELL_SUMMON_NUMBER_COLUMN: &str = "SummonNumber";
|
||||
pub struct CharacterSpec {
|
||||
pub data: LogicDataRef,
|
||||
pub instance: i32,
|
||||
|
|
@ -81,8 +73,8 @@ impl BattleBuilder {
|
|||
}
|
||||
pub fn tilemap_for(&self, location: &LogicDataRef) -> std::io::Result<LogicTilemap> {
|
||||
let file_name = location
|
||||
.data()
|
||||
.map(|data| data.string(LOCATION_FILE_COLUMN).to_owned())
|
||||
.as_location()
|
||||
.map(|location| location.file_name().to_owned())
|
||||
.unwrap_or_default();
|
||||
if file_name.is_empty() {
|
||||
return Err(std::io::Error::other("the location names no tilemap"));
|
||||
|
|
@ -90,18 +82,26 @@ impl BattleBuilder {
|
|||
LogicTilemap::load(&self.root, &file_name)
|
||||
}
|
||||
fn hitpoints_of(data: &LogicDataRef, level_index: i32) -> i32 {
|
||||
data.data()
|
||||
.map(|row| row.int_at(CHARACTER_HITPOINTS_COLUMN, level_index.max(0) as usize))
|
||||
data.as_character()
|
||||
.map(|character| character.hitpoints(level_index.max(0) as usize))
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(1)
|
||||
}
|
||||
fn components_for(&self, hitpoints: i32, moves: bool) -> [Option<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())),
|
||||
moves.then(|| LogicComponent::Movement(LogicMovementComponent::default())),
|
||||
Some(LogicComponent::Hitpoint(LogicHitpointComponent::healthy(
|
||||
hitpoints,
|
||||
))),
|
||||
Some(LogicComponent::Hitpoint(hitpoint)),
|
||||
Some(LogicComponent::Buff(LogicCharacterBuffComponent::empty(
|
||||
self.buff_type_count,
|
||||
))),
|
||||
|
|
@ -117,14 +117,28 @@ impl BattleBuilder {
|
|||
} = spec;
|
||||
let hitpoints = Self::hitpoints_of(&data, level_index);
|
||||
let moves = data
|
||||
.data()
|
||||
.map(|row| row.int(CHARACTER_SPEED_COLUMN) > 0)
|
||||
.as_character()
|
||||
.map(|character| character.speed() > 0)
|
||||
.unwrap_or(false);
|
||||
let deploy_time = data
|
||||
.data()
|
||||
.map(|row| row.int(logic::battle::CHARACTER_DEPLOY_TIME_COLUMN))
|
||||
.as_character()
|
||||
.map(|character| character.deploy_time())
|
||||
.unwrap_or(0)
|
||||
.max(0);
|
||||
let spawn_start = data
|
||||
.as_character()
|
||||
.map(|character| character.spawn_start_time())
|
||||
.unwrap_or(0)
|
||||
.max(0);
|
||||
let spawn_limit = data
|
||||
.as_character()
|
||||
.map(|character| character.spawn_limit())
|
||||
.unwrap_or(0)
|
||||
.max(0);
|
||||
let life_time = data
|
||||
.as_character()
|
||||
.map(|character| character.life_time())
|
||||
.unwrap_or(0);
|
||||
let character = LogicCharacter {
|
||||
level_index,
|
||||
base: LogicGameObject {
|
||||
|
|
@ -148,9 +162,11 @@ impl BattleBuilder {
|
|||
0
|
||||
},
|
||||
deploy_timer: deploy_time,
|
||||
spawn_timer: spawn_start,
|
||||
remaining_spawn_count: spawn_limit,
|
||||
..LogicCharacter::default()
|
||||
};
|
||||
let components = self.components_for(hitpoints, moves);
|
||||
let components = self.components_for(hitpoints, moves, life_time);
|
||||
LogicGameObjectEntry::new(
|
||||
data,
|
||||
LogicGameObjectRef::of(CHARACTER_OBJECT_TYPE + 1, instance),
|
||||
|
|
@ -166,10 +182,10 @@ impl BattleBuilder {
|
|||
level_index: i32,
|
||||
first_instance: i32,
|
||||
) -> Vec<LogicGameObjectEntry> {
|
||||
let Some(row) = spell.data() else {
|
||||
let Some(summon) = spell.as_spell() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let name = row.string(SPELL_SUMMON_CHARACTER_COLUMN).to_owned();
|
||||
let name = summon.summon_character().to_owned();
|
||||
if name.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
|
@ -178,15 +194,14 @@ impl BattleBuilder {
|
|||
tracing::warn!(spell = %spell, character = name, "the spell summons a character the tables do not have");
|
||||
return Vec::new();
|
||||
}
|
||||
let count = row.int(SPELL_SUMMON_NUMBER_COLUMN).max(1);
|
||||
let count = summon.summon_number().max(1);
|
||||
let collision_radius = data
|
||||
.data()
|
||||
.map(|row| row.int("CollisionRadius"))
|
||||
.as_character()
|
||||
.map(|character| character.collision_radius())
|
||||
.unwrap_or(0);
|
||||
(0..count)
|
||||
.map(|index| {
|
||||
let (dx, dy) =
|
||||
logic::spawn_offset(index, count, collision_radius, owner == 0);
|
||||
let (dx, dy) = logic::spawn_offset(index, count, collision_radius, owner == 0);
|
||||
self.character(CharacterSpec {
|
||||
data: data.clone(),
|
||||
instance: first_instance + index,
|
||||
|
|
@ -241,7 +256,7 @@ impl BattleBuilder {
|
|||
data,
|
||||
LogicGameObjectRef::of(CHARACTER_OBJECT_TYPE + 1, instance),
|
||||
body,
|
||||
self.components_for(hitpoints, false),
|
||||
self.components_for(hitpoints, false, 0),
|
||||
);
|
||||
if summoner {
|
||||
entry.body.base_mut().component_mask &= !(1 << logic::battle::COMPONENT_COMBAT);
|
||||
|
|
@ -268,8 +283,14 @@ impl BattleBuilder {
|
|||
));
|
||||
}
|
||||
let deck_slots = [
|
||||
decks[0].as_ref().map(LogicSpellDeck::filled_slot_count).unwrap_or(0),
|
||||
decks[1].as_ref().map(LogicSpellDeck::filled_slot_count).unwrap_or(0),
|
||||
decks[0]
|
||||
.as_ref()
|
||||
.map(LogicSpellDeck::filled_slot_count)
|
||||
.unwrap_or(0),
|
||||
decks[1]
|
||||
.as_ref()
|
||||
.map(LogicSpellDeck::filled_slot_count)
|
||||
.unwrap_or(0),
|
||||
];
|
||||
let mut objects = LogicGameObjectManager::default();
|
||||
let mut leaders = [LogicGameObjectRef::NONE; 2];
|
||||
|
|
|
|||
|
|
@ -1,9 +1,34 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
pub const SNAPSHOT_INTERVAL_TICKS: i32 = 4;
|
||||
pub fn heartbeats_enabled() -> bool {
|
||||
std::env::var("SCROLL_HEARTBEAT").map(|v| v == "1").unwrap_or(false)
|
||||
#[derive(Debug)]
|
||||
struct QueuedDeploy {
|
||||
born: i32,
|
||||
owner: i32,
|
||||
mana_cost: i32,
|
||||
deck_slot: i32,
|
||||
entries: Vec<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 {
|
||||
std::env::var("SCROLL_SNAPSHOT_INTERVAL_TICKS")
|
||||
.ok()
|
||||
|
|
@ -14,21 +39,29 @@ pub fn snapshot_interval_ticks() -> i32 {
|
|||
pub const MAX_CATCH_UP_TICKS: i32 = 40;
|
||||
pub const CHECKSUM_HISTORY: usize = 256;
|
||||
pub const TICKS_PER_TURN: i32 = 10;
|
||||
pub const COMMAND_DELAY_TICKS: i32 = 20;
|
||||
pub const DEPLOY_BIRTH_TICKS: i32 = COMMAND_DELAY_TICKS + 1;
|
||||
pub const BOT_DEPLOY_AHEAD: i32 = 2000;
|
||||
use std::time::Instant;
|
||||
use logic::battle::{verify_snapshot, LogicBattleEvent};
|
||||
use logic::battle::{
|
||||
LogicGameMode, LogicGameObjectEntry, LogicVector2, BATTLE_TICKS_PER_SECOND, BATTLE_TYPE_PVP,
|
||||
};
|
||||
use logic::battle::{verify_snapshot, LogicBattleEvent};
|
||||
use logic::{SectorHeartbeatMessage, SectorStateMessage};
|
||||
use logic::messages::{
|
||||
BattleResultMessage, BATTLE_RESULT_DRAW, BATTLE_RESULT_LOSE, BATTLE_RESULT_WIN,
|
||||
};
|
||||
use logic::{BattleEventMessage, LogicDataRef, LogicRandom};
|
||||
use titan::LogicLong;
|
||||
use logic::{SectorHeartbeatMessage, SectorStateMessage};
|
||||
use service_rpc::{AccountRef, WireMessage};
|
||||
use std::time::Instant;
|
||||
use titan::{ByteStreamWriter, LogicLong};
|
||||
use tokio::sync::Mutex;
|
||||
pub struct BattleSession {
|
||||
mode: LogicGameMode,
|
||||
tick: i32,
|
||||
queued: Vec<(i32, Vec<LogicGameObjectEntry>)>,
|
||||
queued: Vec<QueuedDeploy>,
|
||||
pending_commands: Vec<PendingCommand>,
|
||||
state_owed: bool,
|
||||
last_fire_tick: Option<i32>,
|
||||
outbound: Vec<WireMessage>,
|
||||
started_at: Instant,
|
||||
random: LogicRandom,
|
||||
|
|
@ -40,7 +73,8 @@ pub struct BattleSession {
|
|||
recent_checksums: std::collections::VecDeque<(i32, i32)>,
|
||||
next_instance: i32,
|
||||
announced: bool,
|
||||
pending_bot_play: Option<(LogicDataRef, LogicVector2, i32)>,
|
||||
pending_bot_play: Option<(LogicDataRef, LogicVector2, i32, i32)>,
|
||||
result_sent: bool,
|
||||
}
|
||||
impl BattleSession {
|
||||
pub fn new(mode: LogicGameMode, taunts: Vec<LogicDataRef>) -> Self {
|
||||
|
|
@ -59,6 +93,9 @@ impl BattleSession {
|
|||
mode,
|
||||
tick: 0,
|
||||
queued: Vec::new(),
|
||||
pending_commands: Vec::new(),
|
||||
state_owed: false,
|
||||
last_fire_tick: None,
|
||||
outbound: Vec::new(),
|
||||
started_at: Instant::now(),
|
||||
random,
|
||||
|
|
@ -71,6 +108,7 @@ impl BattleSession {
|
|||
next_instance: first_instance,
|
||||
announced: false,
|
||||
pending_bot_play: None,
|
||||
result_sent: false,
|
||||
}
|
||||
}
|
||||
pub fn announce(&mut self) -> bool {
|
||||
|
|
@ -100,6 +138,29 @@ impl BattleSession {
|
|||
return;
|
||||
}
|
||||
}
|
||||
pub fn simulated_mana(&self, owner: i32) -> i32 {
|
||||
let committed: i32 = self
|
||||
.queued
|
||||
.iter()
|
||||
.filter(|deploy| deploy.owner == owner)
|
||||
.map(|deploy| deploy.mana_cost)
|
||||
.sum();
|
||||
self.mana_of(owner).saturating_sub(committed).max(0)
|
||||
}
|
||||
fn mana_of(&self, owner: i32) -> i32 {
|
||||
self.mode
|
||||
.battle
|
||||
.objects
|
||||
.objects
|
||||
.iter()
|
||||
.find_map(|entry| match (&entry.body, entry.owner_index()) {
|
||||
(logic::battle::LogicObjectBody::Summoner(summoner), o) if o == owner => {
|
||||
Some(summoner.mana)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
pub fn spend_mana(&mut self, owner: i32, cost: i32) {
|
||||
for entry in self.mode.battle.objects.objects.iter_mut() {
|
||||
if entry.owner_index() != owner {
|
||||
|
|
@ -111,24 +172,55 @@ impl BattleSession {
|
|||
}
|
||||
}
|
||||
}
|
||||
pub fn take_bot_play(&mut self) -> Option<(LogicDataRef, LogicVector2, i32)> {
|
||||
pub fn take_bot_play(&mut self) -> Option<(LogicDataRef, LogicVector2, i32, i32)> {
|
||||
self.pending_bot_play.take()
|
||||
}
|
||||
pub fn bot_play(&mut self) -> Option<(LogicDataRef, LogicVector2, i32)> {
|
||||
pub fn bot_play(&mut self) -> Option<(LogicDataRef, LogicVector2, i32, i32)> {
|
||||
let deck = self.mode.battle.decks.get(1)?.as_ref()?;
|
||||
let filled: Vec<LogicDataRef> = deck
|
||||
let filled: Vec<(LogicDataRef, i32)> = deck
|
||||
.slots
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|spell| spell.data.clone())
|
||||
.map(|spell| (spell.data.clone(), spell.level_index))
|
||||
.collect();
|
||||
if filled.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let card = filled
|
||||
if test_script_enabled() {
|
||||
let slot = ((self.tick / TEST_SCRIPT_INTERVAL_TICKS).max(0) as usize) % filled.len();
|
||||
let (card, level) = filled[slot].clone();
|
||||
let leader = self
|
||||
.mode
|
||||
.battle
|
||||
.leader(crate::bot::BOT_OWNER_INDEX as usize)?;
|
||||
let (king_x, king_y) = leader.position();
|
||||
let (x, tower_y) = self.mode.battle.leader_towers[crate::bot::BOT_OWNER_INDEX as usize]
|
||||
.iter()
|
||||
.find_map(|tower| {
|
||||
self.mode
|
||||
.battle
|
||||
.objects
|
||||
.objects
|
||||
.iter()
|
||||
.find(|entry| entry.global_id == *tower && entry.is_alive())
|
||||
})
|
||||
.map(|entry| entry.position())
|
||||
.unwrap_or((king_x, king_y));
|
||||
let ahead = tower_y + (tower_y - king_y).signum() * BOT_DEPLOY_AHEAD;
|
||||
return Some((
|
||||
card,
|
||||
LogicVector2::new(x, ahead),
|
||||
self.next_instance(),
|
||||
level,
|
||||
));
|
||||
}
|
||||
let (card, level) = filled
|
||||
.get(self.random.next(filled.len() as i32).max(0) as usize)?
|
||||
.clone();
|
||||
let leader = self.mode.battle.leader(crate::bot::BOT_OWNER_INDEX as usize)?;
|
||||
let leader = self
|
||||
.mode
|
||||
.battle
|
||||
.leader(crate::bot::BOT_OWNER_INDEX as usize)?;
|
||||
let (king_x, king_y) = leader.position();
|
||||
let towers: Vec<(i32, i32)> = self.mode.battle.leader_towers
|
||||
[crate::bot::BOT_OWNER_INDEX as usize]
|
||||
|
|
@ -148,25 +240,39 @@ impl BattleSession {
|
|||
.copied()
|
||||
.unwrap_or((king_x, king_y));
|
||||
let ahead = tower_y + (tower_y - king_y).signum() * BOT_DEPLOY_AHEAD;
|
||||
Some((card, LogicVector2::new(x, ahead), self.next_instance()))
|
||||
Some((
|
||||
card,
|
||||
LogicVector2::new(x, ahead),
|
||||
self.next_instance(),
|
||||
level,
|
||||
))
|
||||
}
|
||||
pub fn pushes_snapshots(&self) -> bool {
|
||||
self.mode.battle.battle_type == BATTLE_TYPE_PVP
|
||||
}
|
||||
pub fn mana(&self) -> Option<i32> {
|
||||
self.mode.battle.objects.objects.iter().find_map(|entry| {
|
||||
match &entry.body {
|
||||
logic::battle::LogicObjectBody::Summoner(summoner)
|
||||
if entry.owner_index() == 0 =>
|
||||
{
|
||||
self.mode
|
||||
.battle
|
||||
.objects
|
||||
.objects
|
||||
.iter()
|
||||
.find_map(|entry| match &entry.body {
|
||||
logic::battle::LogicObjectBody::Summoner(summoner) if entry.owner_index() == 0 => {
|
||||
Some(summoner.mana)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
pub fn snapshot_message(&mut self) -> Option<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 unit = self
|
||||
.mode
|
||||
|
|
@ -245,22 +351,75 @@ impl BattleSession {
|
|||
pub fn seconds(&self) -> i32 {
|
||||
self.tick / BATTLE_TICKS_PER_SECOND
|
||||
}
|
||||
pub fn queue(&mut self, at_tick: i32, entries: Vec<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() {
|
||||
return;
|
||||
return (at_tick + DEPLOY_BIRTH_TICKS).max(self.tick + 1);
|
||||
}
|
||||
self.queued.push((at_tick.max(self.tick + 1), entries));
|
||||
if let Some(tilemap) = self.mode.battle.tilemap.as_ref() {
|
||||
let lane = logic::battle::get_lane_id(tilemap, deploy.x, deploy.y);
|
||||
for entry in entries.iter_mut() {
|
||||
entry.body.set_lane_id(lane);
|
||||
}
|
||||
}
|
||||
let due = at_tick + DEPLOY_BIRTH_TICKS;
|
||||
let born = due.max(self.tick + 1);
|
||||
let late = born.saturating_sub(due).max(0);
|
||||
if late > 0 && at_tick > 0 {
|
||||
let elapsed = late.saturating_mul(logic::battle::TICK_MILLISECONDS);
|
||||
let moves = |entry: &LogicGameObjectEntry| entry.stats().speed >= 1;
|
||||
for entry in entries.iter_mut() {
|
||||
let can_move = moves(entry);
|
||||
if let logic::battle::LogicObjectBody::Character(character) = &mut entry.body {
|
||||
if character.deploy_timer > 0 {
|
||||
character.deploy_timer = (character.deploy_timer - elapsed).max(0);
|
||||
if character.deploy_timer == 0 {
|
||||
character.state = if can_move {
|
||||
logic::battle::CHARACTER_STATE_MOVING
|
||||
} else {
|
||||
0
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.queued.push(QueuedDeploy {
|
||||
born,
|
||||
owner,
|
||||
mana_cost,
|
||||
deck_slot,
|
||||
entries,
|
||||
});
|
||||
born
|
||||
}
|
||||
pub fn advance_to(&mut self, tick: i32) {
|
||||
let tick = tick.min(self.tick + MAX_CATCH_UP_TICKS);
|
||||
while self.tick < tick {
|
||||
if self.mode.battle.is_end_condition_matched(self.tick) {
|
||||
if !self.result_sent {
|
||||
self.result_sent = true;
|
||||
self.emit_battle_result();
|
||||
}
|
||||
break;
|
||||
}
|
||||
self.tick += 1;
|
||||
self.mode.time.tick = self.tick;
|
||||
self.release_queued(self.tick);
|
||||
let deployed = self.release_queued(self.tick);
|
||||
let tick = self.tick;
|
||||
self.pending_commands
|
||||
.retain(|command| tick <= command.tick_when_given + COMMAND_DELAY_TICKS);
|
||||
self.mode.battle.tick(self.tick);
|
||||
let spawned_counter = self.mode.battle.objects.instance_counters
|
||||
[logic::battle::CHARACTER_OBJECT_TYPE as usize];
|
||||
self.next_instance = self.next_instance.max(spawned_counter);
|
||||
let this_checksum = self.mode.calculate_checksum().ok();
|
||||
if let Some(checksum) = this_checksum {
|
||||
if self.recent_checksums.len() >= CHECKSUM_HISTORY {
|
||||
|
|
@ -268,32 +427,95 @@ impl BattleSession {
|
|||
}
|
||||
self.recent_checksums.push_back((self.tick, checksum));
|
||||
}
|
||||
if heartbeats_enabled()
|
||||
&& self.pushes_snapshots()
|
||||
&& self.tick % TICKS_PER_TURN == 0
|
||||
{
|
||||
if let Some(checksum) = this_checksum {
|
||||
let turn = self.tick / TICKS_PER_TURN;
|
||||
if let Ok(message) =
|
||||
crate::wire::encode(&SectorHeartbeatMessage::new(turn, checksum))
|
||||
{
|
||||
self.outbound.push(message);
|
||||
if let Ok(dump) = std::env::var("SCROLL_TRACE_TICK") {
|
||||
if dump == "auto" || dump.parse::<i32>().ok() == Some(self.tick) {
|
||||
titan::checksum::checksum_trace_start();
|
||||
let dumped = self.mode.calculate_checksum().ok();
|
||||
let fields = titan::checksum::checksum_trace_take();
|
||||
let path = format!("/tmp/scroll-server-trace-{}.txt", self.tick);
|
||||
let mut index = 0usize;
|
||||
let mut lines = Vec::with_capacity(fields.len() + 1);
|
||||
lines.push(format!("checksum={dumped:?}"));
|
||||
for field in &fields {
|
||||
if field.starts_with('#') {
|
||||
lines.push(field.clone());
|
||||
} else {
|
||||
index += 1;
|
||||
lines.push(format!("{index}\t{field}"));
|
||||
}
|
||||
}
|
||||
let _ = std::fs::write(&path, lines.join("\n"));
|
||||
if dump != "auto" {
|
||||
tracing::warn!(tick = self.tick, fields = fields.len(), path = %path, "dumped checksum field trace");
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.pushes_snapshots() && self.tick >= self.next_snapshot {
|
||||
if heartbeats_enabled() && self.pushes_snapshots() && self.tick % TICKS_PER_TURN == 0 {
|
||||
if let Some(checksum) = this_checksum {
|
||||
let turn = self.tick / TICKS_PER_TURN;
|
||||
let mut commands = Vec::new();
|
||||
for command in self.pending_commands.iter_mut().filter(|c| !c.sent) {
|
||||
command.sent = true;
|
||||
commands.push(command.encoded.clone());
|
||||
}
|
||||
if !commands.is_empty() {
|
||||
tracing::info!(
|
||||
tick,
|
||||
turn,
|
||||
commands = commands.len(),
|
||||
"handing commands back to the client"
|
||||
);
|
||||
}
|
||||
let message = match (dump_enabled(), self.mode.checksum_stream()) {
|
||||
(true, Ok((_, stream))) => SectorHeartbeatMessage::with_tick_data(
|
||||
turn, checksum, &commands, &stream,
|
||||
),
|
||||
_ => SectorHeartbeatMessage::with_commands(turn, checksum, &commands),
|
||||
};
|
||||
if let Ok(message) = crate::wire::encode(&message) {
|
||||
self.outbound.push(message);
|
||||
}
|
||||
if !commands.is_empty() {
|
||||
if let Some(fires_at) = self.next_command_fire_tick() {
|
||||
self.next_snapshot = self.next_snapshot.max(fires_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if deployed {
|
||||
self.next_snapshot = self.tick + snapshot_interval_ticks();
|
||||
}
|
||||
if self.state_owed && self.next_command_fire_tick().is_none() {
|
||||
self.state_owed = false;
|
||||
if let Some(message) = self.snapshot_message() {
|
||||
self.pushed_snapshots += 1;
|
||||
self.outbound.push(message);
|
||||
self.next_snapshot = self.tick + snapshot_interval_ticks();
|
||||
}
|
||||
}
|
||||
if !deployed && self.pushes_snapshots() && self.tick >= self.next_snapshot {
|
||||
let carried = self.commands_to_push();
|
||||
self.next_snapshot = self.tick + snapshot_interval_ticks();
|
||||
if let Some(message) = self.snapshot_message() {
|
||||
self.pushed_snapshots += 1;
|
||||
self.outbound.push(message);
|
||||
if !carried.is_empty() {
|
||||
if let Some(fires_at) = self.next_command_fire_tick() {
|
||||
self.next_snapshot = self.next_snapshot.max(fires_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.tick >= self.next_bot_play {
|
||||
let span = crate::bot::BOT_PLAY_MAX_SECONDS - crate::bot::BOT_PLAY_MIN_SECONDS;
|
||||
let wait = crate::bot::BOT_PLAY_MIN_SECONDS + self.random.next(span.max(1));
|
||||
self.next_bot_play = self.tick + wait * BATTLE_TICKS_PER_SECOND;
|
||||
if let Some((card, position, instance)) = self.bot_play() {
|
||||
self.pending_bot_play = Some((card, position, instance));
|
||||
self.next_bot_play = if test_script_enabled() {
|
||||
self.tick + TEST_SCRIPT_INTERVAL_TICKS
|
||||
} else {
|
||||
let span = crate::bot::BOT_PLAY_MAX_SECONDS - crate::bot::BOT_PLAY_MIN_SECONDS;
|
||||
let wait = crate::bot::BOT_PLAY_MIN_SECONDS + self.random.next(span.max(1));
|
||||
self.tick + wait * BATTLE_TICKS_PER_SECOND
|
||||
};
|
||||
if let Some((card, position, instance, level)) = self.bot_play() {
|
||||
self.pending_bot_play = Some((card, position, instance, level));
|
||||
}
|
||||
}
|
||||
if self.tick >= self.next_bot_emote {
|
||||
|
|
@ -306,17 +528,58 @@ impl BattleSession {
|
|||
}
|
||||
}
|
||||
}
|
||||
fn release_queued(&mut self, tick: i32) {
|
||||
fn emit_battle_result(&mut self) {
|
||||
self.mode.battle.resolve_winner(self.tick);
|
||||
let winner = self.mode.battle.winner_index;
|
||||
let own_stars = self.mode.battle.stars(0);
|
||||
let opponent_stars = self.mode.battle.stars(1);
|
||||
let result = if winner == 0 {
|
||||
BATTLE_RESULT_WIN
|
||||
} else if winner == 1 {
|
||||
BATTLE_RESULT_LOSE
|
||||
} else {
|
||||
BATTLE_RESULT_DRAW
|
||||
};
|
||||
let message = BattleResultMessage::result(result, own_stars, opponent_stars, 0, 0, 0, None);
|
||||
if let Ok(wire) = crate::wire::encode(&message) {
|
||||
self.outbound.push(wire);
|
||||
tracing::info!(
|
||||
tick = self.tick,
|
||||
winner,
|
||||
own_stars,
|
||||
opponent_stars,
|
||||
"battle ended, sent result"
|
||||
);
|
||||
}
|
||||
}
|
||||
fn release_queued(&mut self, tick: i32) -> bool {
|
||||
let mut due = Vec::new();
|
||||
self.queued.retain(|(at, entries)| {
|
||||
if *at <= tick {
|
||||
due.extend(entries.iter().cloned());
|
||||
self.queued.retain(|deploy| {
|
||||
if deploy.born <= tick {
|
||||
due.push((
|
||||
deploy.owner,
|
||||
deploy.mana_cost,
|
||||
deploy.deck_slot,
|
||||
deploy.entries.clone(),
|
||||
));
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
for entry in due {
|
||||
let released = !due.is_empty();
|
||||
if released {
|
||||
self.last_fire_tick = Some(tick);
|
||||
}
|
||||
let mut entries = Vec::new();
|
||||
for (owner, mana_cost, deck_slot, deployed) in due {
|
||||
self.spend_mana(owner, mana_cost);
|
||||
self.play_from_hand(owner, deck_slot);
|
||||
entries.extend(deployed);
|
||||
}
|
||||
let due = entries;
|
||||
for mut entry in due {
|
||||
self.clamp_into_arena(&mut entry);
|
||||
tracing::info!(
|
||||
tick,
|
||||
global_id = ?entry.global_id.0,
|
||||
|
|
@ -328,6 +591,7 @@ impl BattleSession {
|
|||
);
|
||||
self.mode.battle.objects.push(entry);
|
||||
}
|
||||
released
|
||||
}
|
||||
pub fn is_finished(&self) -> bool {
|
||||
self.mode.battle.is_end_condition_matched(self.tick)
|
||||
|
|
@ -362,17 +626,116 @@ impl BattleSession {
|
|||
pub fn next_instance(&self) -> i32 {
|
||||
self.next_instance
|
||||
}
|
||||
pub fn character_counter(&self) -> i32 {
|
||||
self.mode.battle.objects.instance_counters[logic::battle::CHARACTER_OBJECT_TYPE as usize]
|
||||
}
|
||||
pub fn reserve_instances(&mut self, count: usize) {
|
||||
self.next_instance += count.max(1) as i32;
|
||||
}
|
||||
pub fn deck_card(&self, owner: i32, slot: i32) -> Option<LogicDataRef> {
|
||||
let deck = self.mode.battle.decks.get(owner.max(0) as usize)?.as_ref()?;
|
||||
let spell = deck.slots.get(slot.max(0) as usize)?.as_ref()?;
|
||||
Some(spell.data.clone())
|
||||
self.deck_slot(owner, slot).map(|(data, _)| data)
|
||||
}
|
||||
pub fn push(&mut self, entry: LogicGameObjectEntry) {
|
||||
pub fn deck_slot(&self, owner: i32, slot: i32) -> Option<(LogicDataRef, i32)> {
|
||||
let deck = self
|
||||
.mode
|
||||
.battle
|
||||
.decks
|
||||
.get(owner.max(0) as usize)?
|
||||
.as_ref()?;
|
||||
let spell = deck.slots.get(slot.max(0) as usize)?.as_ref()?;
|
||||
Some((spell.data.clone(), spell.level_index))
|
||||
}
|
||||
pub fn deliver_command(&mut self, tick_when_given: i32, command: &dyn logic::LogicCommand) {
|
||||
let mut writer = ByteStreamWriter::new();
|
||||
if logic::LogicCommandManager::encode_command(&mut writer, command).is_err() {
|
||||
tracing::warn!(
|
||||
command = command.name(),
|
||||
"could not encode the command to hand back"
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.pending_commands.push(PendingCommand {
|
||||
tick_when_given,
|
||||
encoded: writer.into_inner(),
|
||||
sent: false,
|
||||
});
|
||||
}
|
||||
pub fn state_on_request(&mut self) -> Option<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);
|
||||
}
|
||||
fn assign_lane_id(&self, entry: &mut LogicGameObjectEntry) {
|
||||
if let Some(tilemap) = self.mode.battle.tilemap.as_ref() {
|
||||
let pos = entry.position();
|
||||
let lane = logic::battle::spawn_lane_of(tilemap, pos.0, pos.1);
|
||||
entry.body.set_lane_id(lane);
|
||||
}
|
||||
}
|
||||
pub fn object_count(&self) -> usize {
|
||||
self.mode.battle.objects.objects.len()
|
||||
}
|
||||
|
|
@ -405,20 +768,36 @@ impl BattleRegistry {
|
|||
}
|
||||
pub async fn tick<F>(&self, account: AccountRef, summon: F) -> Vec<WireMessage>
|
||||
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 {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut session = handle.lock().await;
|
||||
session.advance_to_now();
|
||||
if let Some((card, position, instance)) = session.take_bot_play() {
|
||||
let entries = summon(&card, position, crate::bot::BOT_OWNER_INDEX, instance);
|
||||
session.reserve_instances(entries.len());
|
||||
if !entries.is_empty() {
|
||||
tracing::debug!(card = %card, count = entries.len(), "the bot played a card");
|
||||
for entry in entries {
|
||||
session.push(entry);
|
||||
if let Some((card, position, instance, level)) = session.take_bot_play() {
|
||||
let adjusted = session.mode().battle.resolve_spell_position(
|
||||
&card,
|
||||
position,
|
||||
crate::bot::BOT_OWNER_INDEX,
|
||||
);
|
||||
match adjusted {
|
||||
None => {
|
||||
tracing::debug!(card = %card, "the bot picked an illegal drop point, skipping");
|
||||
}
|
||||
Some(adjusted) => {
|
||||
let entries = summon(
|
||||
&card,
|
||||
adjusted,
|
||||
crate::bot::BOT_OWNER_INDEX,
|
||||
level,
|
||||
instance,
|
||||
);
|
||||
session.reserve_instances(entries.len());
|
||||
if !entries.is_empty() {
|
||||
tracing::debug!(card = %card, count = entries.len(), "the bot played a card");
|
||||
session.push_group(adjusted, entries);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -439,7 +818,7 @@ impl BattleRegistry {
|
|||
pub async fn resend(&self, account: AccountRef) -> Option<WireMessage> {
|
||||
let handle = self.session(account).await?;
|
||||
let mut session = handle.lock().await;
|
||||
session.snapshot_message()
|
||||
session.state_on_request()
|
||||
}
|
||||
pub async fn announce_once(&self, account: AccountRef) -> bool {
|
||||
let Some(handle) = self.session(account).await else {
|
||||
|
|
@ -471,15 +850,25 @@ impl BattleRegistry {
|
|||
pub async fn play<F>(
|
||||
&self,
|
||||
account: AccountRef,
|
||||
executor: LogicLong,
|
||||
slot: i32,
|
||||
position: logic::battle::LogicVector2,
|
||||
at_tick: i32,
|
||||
played: &logic::LogicDoSpellCommand,
|
||||
summon: F,
|
||||
) -> usize
|
||||
where
|
||||
F: Fn(&LogicDataRef, logic::battle::LogicVector2, i32, i32) -> Vec<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 {
|
||||
return 0;
|
||||
};
|
||||
|
|
@ -487,16 +876,68 @@ impl BattleRegistry {
|
|||
let Some(owner) = session.owner_of(executor) else {
|
||||
return 0;
|
||||
};
|
||||
let Some(card) = session.deck_card(owner, slot) else {
|
||||
let Some((card, level)) = session.deck_slot(owner, slot) else {
|
||||
return 0;
|
||||
};
|
||||
let cost = card.data().map(|row| row.int("ManaCost")).unwrap_or(0);
|
||||
session.spend_mana(owner, cost);
|
||||
session.play_from_hand(owner, slot);
|
||||
let entries = summon(&card, position, owner, session.next_instance());
|
||||
let Some(adjusted) = session
|
||||
.mode()
|
||||
.battle
|
||||
.resolve_spell_position(&card, position, owner)
|
||||
else {
|
||||
tracing::info!(
|
||||
owner,
|
||||
slot,
|
||||
at_tick,
|
||||
raw = ?(position.x, position.y),
|
||||
card = %card,
|
||||
"the drop point is not legal, spawning nothing"
|
||||
);
|
||||
session.deliver_command(at_tick, played);
|
||||
return 0;
|
||||
};
|
||||
let cost = card.as_spell().map(|spell| spell.mana_cost()).unwrap_or(0);
|
||||
let affordable = session.simulated_mana(owner);
|
||||
if cost > affordable {
|
||||
tracing::info!(
|
||||
owner,
|
||||
slot,
|
||||
at_tick,
|
||||
cost,
|
||||
affordable,
|
||||
card = %card,
|
||||
"not enough mana once the cards already in flight are counted"
|
||||
);
|
||||
session.deliver_command(at_tick, played);
|
||||
return 0;
|
||||
}
|
||||
let entries = summon(&card, adjusted, owner, level, session.next_instance());
|
||||
session.reserve_instances(entries.len());
|
||||
let spawned = entries.len();
|
||||
session.queue(at_tick, entries);
|
||||
tracing::info!(
|
||||
owner,
|
||||
slot,
|
||||
at_tick,
|
||||
server_tick = session.tick(),
|
||||
late = (session.tick() + 1 - at_tick).max(0),
|
||||
assigned = session.next_instance(),
|
||||
counter = session.character_counter(),
|
||||
units = spawned,
|
||||
raw = ?(position.x, position.y),
|
||||
adjusted = ?(adjusted.x, adjusted.y),
|
||||
card = %card,
|
||||
"card played"
|
||||
);
|
||||
let born = session.queue(at_tick, adjusted, owner, cost, slot, entries);
|
||||
let echo = logic::LogicDoSpellCommand {
|
||||
header: logic::LogicCommandHeader {
|
||||
execute_tick: born,
|
||||
..played.header
|
||||
},
|
||||
deck_slot: played.deck_slot,
|
||||
spell: played.spell.clone(),
|
||||
position: played.position,
|
||||
};
|
||||
session.deliver_command(at_tick, &echo);
|
||||
spawned
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,10 @@ fn predefined_deck() -> Option<LogicSpellDeck> {
|
|||
let name = row.string_at(DECK_SPELLS_COLUMN, slot);
|
||||
let data = LogicDataRef::by_name(table::SPELLS, name);
|
||||
if data.is_none() {
|
||||
tracing::warn!(card = name, "predefined deck names a card the tables do not have");
|
||||
tracing::warn!(
|
||||
card = name,
|
||||
"predefined deck names a card the tables do not have"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let level = row.int_at(DECK_LEVEL_COLUMN, slot).max(1);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use crate::config::{CardRef, DataSelector};
|
||||
use logic::{table, LogicDataRef, LogicDataTables};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use logic::{table, LogicDataRef, LogicDataTables};
|
||||
use crate::config::{CardRef, DataSelector};
|
||||
pub const GOLD_RESOURCE_FALLBACK_INSTANCE: i32 = 1;
|
||||
pub const ARENA_FALLBACK_INSTANCE: i32 = 1;
|
||||
pub struct Catalog {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use std::path::PathBuf;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use storage::DatabaseConfig;
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
use crate::catalog::GOLD_RESOURCE_FALLBACK_INSTANCE;
|
||||
use crate::store::{OwnedCard, PlayerProfile, StoredChest};
|
||||
use logic::commands::COMMODITY_ACHIEVEMENT_PROGRESS;
|
||||
use logic::data::LogicDataTables;
|
||||
use logic::data::RESOURCE_GOLD;
|
||||
use logic::model::{
|
||||
LogicChest, LogicClientAvatar, LogicClientHome, LogicCommodityStore, LogicDataSlot, LogicSpell,
|
||||
LogicSpellCollection, LogicSpellDeck, LogicTimer,
|
||||
};
|
||||
use titan::LogicLong;
|
||||
use logic::data::RESOURCE_GOLD;
|
||||
use logic::{table, LogicDataRef};
|
||||
use logic::data::LogicDataTables;
|
||||
use logic::commands::COMMODITY_ACHIEVEMENT_PROGRESS;
|
||||
use std::sync::Arc;
|
||||
use crate::catalog::GOLD_RESOURCE_FALLBACK_INSTANCE;
|
||||
use crate::store::{OwnedCard, PlayerProfile, StoredChest};
|
||||
use titan::LogicLong;
|
||||
pub const COMMODITY_RESOURCES: usize = 0;
|
||||
fn spell_of(owned: &OwnedCard) -> LogicSpell {
|
||||
LogicSpell {
|
||||
|
|
@ -156,7 +156,10 @@ pub fn build_avatar(profile: &PlayerProfile) -> LogicClientAvatar {
|
|||
COMMODITY_RESOURCES,
|
||||
vec![LogicDataSlot::new(gold_resource(profile), profile.gold)],
|
||||
);
|
||||
commodities.set(COMMODITY_ACHIEVEMENT_PROGRESS, achievement_progress(profile));
|
||||
commodities.set(
|
||||
COMMODITY_ACHIEVEMENT_PROGRESS,
|
||||
achievement_progress(profile),
|
||||
);
|
||||
LogicClientAvatar {
|
||||
avatar_id: account,
|
||||
account_id: account,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use crate::home::{build_avatar, build_home};
|
||||
use crate::rewards::RewardRoller;
|
||||
use crate::shop::{Purchase, ShopCatalog, ShopCycle, ShopEntry};
|
||||
use crate::store::{OwnedCard, PlayerProfile, StoredChest};
|
||||
use logic::data::{RESOURCE_DIAMONDS, RESOURCE_GOLD};
|
||||
use logic::{
|
||||
chest_source, AvailableServerCommandMessage, CommandOutcome, EndClientTurnMessage,
|
||||
|
|
@ -8,11 +10,9 @@ use logic::{
|
|||
};
|
||||
use logic::{table, LogicGlobals};
|
||||
use service_rpc::AccountRef;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use crate::home::{build_avatar, build_home};
|
||||
use crate::rewards::RewardRoller;
|
||||
use crate::shop::{Purchase, ShopCatalog, ShopCycle, ShopEntry};
|
||||
use crate::store::{OwnedCard, PlayerProfile, StoredChest};
|
||||
pub const MAX_FAST_FORWARD_TICKS: i32 = 20 * 60 * 60;
|
||||
pub const CHEST_FREE: &str = "Free";
|
||||
pub const CHEST_CROWN: &str = "Star";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use std::sync::Arc;
|
||||
use game_service::{GameConfig, GameService};
|
||||
use service_rpc::serve;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use service_rpc::AccountRef;
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
use service_rpc::AccountRef;
|
||||
use tokio::sync::Mutex;
|
||||
pub const MATCHMAKE_WAIT: Duration = Duration::from_secs(10);
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
|
@ -16,10 +16,7 @@ pub struct Matchmaker {
|
|||
impl Matchmaker {
|
||||
pub async fn enter(&self, account: AccountRef) -> Matched {
|
||||
let mut waiting = self.waiting.lock().await;
|
||||
let opponent = waiting
|
||||
.keys()
|
||||
.find(|other| **other != account)
|
||||
.copied();
|
||||
let opponent = waiting.keys().find(|other| **other != account).copied();
|
||||
if let Some(opponent) = opponent {
|
||||
waiting.remove(&opponent);
|
||||
waiting.remove(&account);
|
||||
|
|
@ -33,11 +30,7 @@ impl Matchmaker {
|
|||
let Some(since) = waiting.get(&account).copied() else {
|
||||
return Matched::Waiting;
|
||||
};
|
||||
if let Some(opponent) = waiting
|
||||
.keys()
|
||||
.find(|other| **other != account)
|
||||
.copied()
|
||||
{
|
||||
if let Some(opponent) = waiting.keys().find(|other| **other != account).copied() {
|
||||
waiting.remove(&opponent);
|
||||
waiting.remove(&account);
|
||||
return Matched::Player(opponent);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
use crate::catalog::Catalog;
|
||||
use logic::data::{RARITY_COMMON, RARITY_EPIC, RARITY_RARE};
|
||||
use logic::model::LogicSpell;
|
||||
use logic::{LogicDataRef, LogicReward};
|
||||
use rand::seq::SliceRandom;
|
||||
use rand::Rng;
|
||||
use crate::catalog::Catalog;
|
||||
pub struct RewardRoller {
|
||||
pool: Vec<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::{
|
||||
AvailableServerCommandMessage, EndClientTurnMessage, LogicCommandManager, LogicDataRef,
|
||||
LogicShopSeedChangedCommand, OutOfSyncMessage, OwnHomeDataMessage,
|
||||
SectorCommandMessage, SendBattleEventMessage,
|
||||
StartMissionMessage, StopHomeLogicMessage,
|
||||
LogicShopSeedChangedCommand, OutOfSyncMessage, OwnHomeDataMessage, SectorCommandMessage,
|
||||
SendBattleEventMessage, StartMissionMessage, StopHomeLogicMessage,
|
||||
};
|
||||
use service_rpc::{
|
||||
AccountRef, GameApi, GameRequest, GameResponse, HomeRequestKind, RpcError, RpcResult,
|
||||
RpcService, WireMessage,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use titan::{Message, MessageMeta, Payload};
|
||||
use crate::battle::BattleBuilder;
|
||||
use crate::battle_session::BattleRegistry;
|
||||
use crate::matchmaker::{Matched, Matchmaker};
|
||||
use crate::catalog::Catalog;
|
||||
use crate::config::GameConfig;
|
||||
use crate::bot::BotPlayer;
|
||||
use crate::home::{build_avatar, build_deck};
|
||||
use crate::home_mode::{available_server_command, HomeModeRegistry};
|
||||
use crate::rewards::RewardRoller;
|
||||
use crate::shop::{ShopCatalog, ShopCycle};
|
||||
use crate::store::{connect_failed, unavailable, PlayerProfile, ProfileStore};
|
||||
use crate::time::unix_seconds;
|
||||
pub const ARENA_PVP_LOCATION_COLUMN: &str = "PvpLocation";
|
||||
pub const NPC_LOCATION_COLUMN: &str = "Location";
|
||||
fn location_of(data: &LogicDataRef, column: &str) -> LogicDataRef {
|
||||
data.data()
|
||||
.map(|row| LogicDataRef::by_name(table::LOCATIONS, row.string(column)))
|
||||
.unwrap_or_default()
|
||||
fn location_named(name: &str) -> LogicDataRef {
|
||||
if name.is_empty() {
|
||||
return LogicDataRef::None;
|
||||
}
|
||||
LogicDataRef::by_name(table::LOCATIONS, name)
|
||||
}
|
||||
pub struct GameService {
|
||||
config: GameConfig,
|
||||
|
|
@ -114,9 +112,15 @@ impl GameService {
|
|||
.await
|
||||
.stop_home_logic();
|
||||
let location = if npc.is_none() {
|
||||
location_of(&profile.arena, ARENA_PVP_LOCATION_COLUMN)
|
||||
profile
|
||||
.arena
|
||||
.as_arena()
|
||||
.map(|arena| location_named(arena.pvp_location()))
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
location_of(&npc, NPC_LOCATION_COLUMN)
|
||||
npc.as_npc()
|
||||
.map(|npc| location_named(npc.location()))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let deck = build_deck(&profile);
|
||||
let (opponent, opponent_deck) = match opponent_account {
|
||||
|
|
@ -339,16 +343,9 @@ impl GameApi for GameService {
|
|||
};
|
||||
spawned += self
|
||||
.running_battles
|
||||
.play(
|
||||
account,
|
||||
played.header.executor_account_id,
|
||||
played.deck_slot,
|
||||
played.position,
|
||||
played.header.execute_tick,
|
||||
|card, position, owner, instance| {
|
||||
self.battles.summon(card, position, owner, 0, instance)
|
||||
},
|
||||
)
|
||||
.play(account, played, |card, position, owner, level, instance| {
|
||||
self.battles.summon(card, position, owner, level, instance)
|
||||
})
|
||||
.await;
|
||||
}
|
||||
let progress = self.running_battles.advance(account, turn.tick).await;
|
||||
|
|
@ -444,12 +441,15 @@ impl GameApi for GameService {
|
|||
}
|
||||
let messages = self
|
||||
.running_battles
|
||||
.tick(account, |card, position, owner, instance| {
|
||||
self.battles.summon(card, position, owner, 0, instance)
|
||||
.tick(account, |card, position, owner, level, instance| {
|
||||
self.battles.summon(card, position, owner, level, instance)
|
||||
})
|
||||
.await;
|
||||
if !messages.is_empty() {
|
||||
let kinds: Vec<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");
|
||||
}
|
||||
Ok(messages)
|
||||
|
|
@ -468,7 +468,12 @@ impl GameApi for GameService {
|
|||
Ok(())
|
||||
}
|
||||
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<()> {
|
||||
let Ok(sector) = SectorCommandMessage::from_bytes(&payload) else {
|
||||
|
|
@ -483,23 +488,16 @@ impl GameApi for GameService {
|
|||
.await;
|
||||
let server_checksum = progress.and_then(|state| state.checksum);
|
||||
let mut spawned = 0;
|
||||
if let Some(played) = sector
|
||||
.command
|
||||
.as_ref()
|
||||
.and_then(|command| command.as_any().downcast_ref::<logic::LogicDoSpellCommand>())
|
||||
{
|
||||
if let Some(played) = sector.command.as_ref().and_then(|command| {
|
||||
command
|
||||
.as_any()
|
||||
.downcast_ref::<logic::LogicDoSpellCommand>()
|
||||
}) {
|
||||
spawned = self
|
||||
.running_battles
|
||||
.play(
|
||||
account,
|
||||
played.header.executor_account_id,
|
||||
played.deck_slot,
|
||||
played.position,
|
||||
played.header.execute_tick,
|
||||
|card, position, owner, instance| {
|
||||
self.battles.summon(card, position, owner, 0, instance)
|
||||
},
|
||||
)
|
||||
.play(account, played, |card, position, owner, level, instance| {
|
||||
self.battles.summon(card, position, owner, level, instance)
|
||||
})
|
||||
.await;
|
||||
}
|
||||
tracing::info!(
|
||||
|
|
@ -556,9 +554,9 @@ impl RpcService for GameService {
|
|||
GameRequest::EndClientTurn { account, payload } => Ok(GameResponse::messages(
|
||||
self.end_client_turn(account, payload).await?,
|
||||
)),
|
||||
GameRequest::BattleTick { account } => Ok(GameResponse::messages(
|
||||
self.battle_tick(account).await?,
|
||||
)),
|
||||
GameRequest::BattleTick { account } => {
|
||||
Ok(GameResponse::messages(self.battle_tick(account).await?))
|
||||
}
|
||||
GameRequest::RequestSectorState { account } => Ok(GameResponse::messages(
|
||||
self.request_sector_state(account).await?,
|
||||
)),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use std::path::Path;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::shop::entry::ShopEntry;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
pub const DEFAULT_SHOP: &str = include_str!("default_shop.json");
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ShopOffer {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
use logic::{table, LogicDataRef};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
pub const SEPARATOR: char = '#';
|
||||
pub const QUALIFIER: char = ':';
|
||||
pub const LOOKUP: &[(&str, i32)] = &[
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
mod postgres;
|
||||
mod profile;
|
||||
use service_rpc::RpcError;
|
||||
use storage::StorageError;
|
||||
pub use postgres::ProfileStore;
|
||||
pub use profile::{
|
||||
OwnedCard, PlayerProfile, StoredChest, CARD_HOLDER_COLLECTION, CARD_HOLDER_DECK,
|
||||
};
|
||||
use service_rpc::RpcError;
|
||||
use storage::StorageError;
|
||||
pub fn unavailable(error: StorageError) -> RpcError {
|
||||
RpcError::Unavailable(error.to_string())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
use logic::LogicDataRef;
|
||||
use service_rpc::AccountRef;
|
||||
use storage::sqlx::{Postgres, QueryBuilder};
|
||||
use storage::{Database, DatabaseConfig, PgRow, Result, Row};
|
||||
use crate::shop::Purchase;
|
||||
use crate::store::{
|
||||
OwnedCard, PlayerProfile, StoredChest, CARD_HOLDER_COLLECTION, CARD_HOLDER_DECK,
|
||||
};
|
||||
use crate::time::unix_seconds;
|
||||
use logic::LogicDataRef;
|
||||
use service_rpc::AccountRef;
|
||||
use storage::sqlx::{Postgres, QueryBuilder};
|
||||
use storage::{Database, DatabaseConfig, PgRow, Result, Row};
|
||||
pub struct ProfileStore {
|
||||
database: Database,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use service_rpc::AccountRef;
|
||||
use logic::LogicDataRef;
|
||||
use crate::catalog::Catalog;
|
||||
use crate::config::StarterProfile;
|
||||
use logic::LogicDataRef;
|
||||
use service_rpc::AccountRef;
|
||||
pub const CARD_HOLDER_DECK: i16 = 0;
|
||||
pub const CARD_HOLDER_COLLECTION: i16 = 1;
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use game_service::home::build_avatar;
|
||||
use logic::commands::COMMODITY_ACHIEVEMENT_PROGRESS;
|
||||
use logic::{CommandOutcome, LogicClaimAchievementRewardCommand, LogicCommandHeader};
|
||||
use logic::data::{table, LogicDataRef, LogicDataTables};
|
||||
use logic::home::LogicHomeMode;
|
||||
use logic::model::LogicClientHome;
|
||||
use logic::{CommandOutcome, LogicClaimAchievementRewardCommand, LogicCommandHeader};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
fn tables() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets");
|
||||
if let Ok(t) = LogicDataTables::load_from_dir(&root) {
|
||||
|
|
@ -20,9 +20,17 @@ fn adding_exp_rolls_over_into_levels() {
|
|||
home.avatar_mut().exp_points = 0;
|
||||
home.add_exp(25);
|
||||
assert_eq!(home.avatar().exp_level, 2, "25 exp should reach level 2");
|
||||
assert_eq!(home.avatar().exp_points, 5, "5 exp should carry into level 2");
|
||||
assert_eq!(
|
||||
home.avatar().exp_points,
|
||||
5,
|
||||
"5 exp should carry into level 2"
|
||||
);
|
||||
home.add_exp(50);
|
||||
assert_eq!(home.avatar().exp_level, 3, "another 50 should reach level 3");
|
||||
assert_eq!(
|
||||
home.avatar().exp_level,
|
||||
3,
|
||||
"another 50 should reach level 3"
|
||||
);
|
||||
assert_eq!(home.avatar().exp_points, 5);
|
||||
}
|
||||
#[test]
|
||||
|
|
@ -61,7 +69,10 @@ fn a_completed_findcard_achievement_can_be_claimed() {
|
|||
.get(COMMODITY_ACHIEVEMENT_PROGRESS)
|
||||
.map(|slots| slots.iter().filter(|s| s.count >= required).count())
|
||||
.unwrap_or(0);
|
||||
assert!(progress > 0, "the served progress should complete a findcard tier");
|
||||
assert!(
|
||||
progress > 0,
|
||||
"the served progress should complete a findcard tier"
|
||||
);
|
||||
let mut home = LogicHomeMode::new(LogicClientHome::default(), avatar, 0);
|
||||
let before_level = home.avatar().exp_level;
|
||||
let before_points = home.avatar().exp_points;
|
||||
|
|
@ -70,10 +81,17 @@ fn a_completed_findcard_achievement_can_be_claimed() {
|
|||
header: LogicCommandHeader::default(),
|
||||
};
|
||||
let outcome = home.execute(&command);
|
||||
assert_eq!(outcome, CommandOutcome::Applied, "the claim should be accepted");
|
||||
assert_eq!(
|
||||
outcome,
|
||||
CommandOutcome::Applied,
|
||||
"the claim should be accepted"
|
||||
);
|
||||
assert!(
|
||||
home.avatar().exp_points != before_points || home.avatar().exp_level != before_level,
|
||||
"claiming should have granted exp"
|
||||
);
|
||||
assert!(matches!(home.execute(&command), CommandOutcome::Rejected(_)));
|
||||
assert!(matches!(
|
||||
home.execute(&command),
|
||||
CommandOutcome::Rejected(_)
|
||||
));
|
||||
}
|
||||
|
|
|
|||
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_session::MAX_CATCH_UP_TICKS;
|
||||
use logic::battle::{LogicVector2, BATTLE_TICKS_PER_SECOND};
|
||||
use logic::battle::{verify_snapshot, LogicVector2, BATTLE_TICKS_PER_SECOND};
|
||||
use logic::data::{table, LogicDataRef, LogicDataTables};
|
||||
use logic::model::LogicClientAvatar;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use titan::LogicLong;
|
||||
fn assets() -> std::path::PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets")
|
||||
|
|
@ -20,6 +20,40 @@ fn avatar(low: i32) -> LogicClientAvatar {
|
|||
}
|
||||
}
|
||||
#[test]
|
||||
fn a_ranged_troop_fires_and_the_snapshot_reads_back() {
|
||||
let root = assets();
|
||||
let tables = LogicDataTables::load_from_dir(&root).expect("tables");
|
||||
LogicDataTables::install(Arc::new(tables));
|
||||
let builder = BattleBuilder::new(&root);
|
||||
let mut mode = builder
|
||||
.build(
|
||||
LogicDataRef::by_name(table::LOCATIONS, "PvP_goblin"),
|
||||
LogicDataRef::None,
|
||||
LogicDataRef::by_name(table::ARENAS, "Arena_T"),
|
||||
vec![avatar(5), avatar(0)],
|
||||
[None, None],
|
||||
1,
|
||||
)
|
||||
.expect("battle");
|
||||
let spell = LogicDataRef::spell("Archer");
|
||||
let entries = builder.summon(&spell, LogicVector2::new(14500, 23500), 0, 0, 100);
|
||||
assert!(!entries.is_empty(), "the archer spell summons nothing");
|
||||
for entry in entries {
|
||||
mode.battle.objects.push(entry);
|
||||
}
|
||||
for tick in 0..(15 * BATTLE_TICKS_PER_SECOND) {
|
||||
mode.battle.tick(tick);
|
||||
let snapshot = mode.snapshot(&[]).expect("snapshot encodes");
|
||||
let report = verify_snapshot(&snapshot);
|
||||
assert!(
|
||||
report.is_ok(),
|
||||
"t={tick}: snapshot did not read back: {:?} trailing={}",
|
||||
report.error,
|
||||
report.trailing
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn a_troop_walks_at_the_enemy_towers_and_stops_in_range() {
|
||||
let root = assets();
|
||||
let tables = LogicDataTables::load_from_dir(&root).expect("tables");
|
||||
|
|
@ -80,7 +114,7 @@ fn a_troop_walks_at_the_enemy_towers_and_stops_in_range() {
|
|||
let reach = ((end.0 - tower.0) as f64).hypot((end.1 - tower.1) as f64);
|
||||
println!("distance to the near enemy tower: {reach:.0}");
|
||||
assert!(
|
||||
reach < 2500.0,
|
||||
reach < 3000.0,
|
||||
"the knight never reached the enemy tower, stopped {reach:.0} away at {end:?}"
|
||||
);
|
||||
}
|
||||
|
|
@ -92,7 +126,10 @@ fn the_bot_deploys_in_front_of_its_own_towers() {
|
|||
}
|
||||
let builder = BattleBuilder::new(&root);
|
||||
let mut deck = logic::model::LogicSpellDeck::default();
|
||||
for (slot, name) in ["Knight", "Cannon", "GoblinHut", "Giant"].iter().enumerate() {
|
||||
for (slot, name) in ["Knight", "Cannon", "GoblinHut", "Giant"]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
deck.slots[slot] = Some(logic::model::LogicSpell {
|
||||
data: LogicDataRef::spell(name),
|
||||
..logic::model::LogicSpell::default()
|
||||
|
|
@ -112,8 +149,8 @@ fn the_bot_deploys_in_front_of_its_own_towers() {
|
|||
let mut plays = 0;
|
||||
for tick in 0..(180 * BATTLE_TICKS_PER_SECOND) {
|
||||
if tick % 20 == 0 {
|
||||
if let Some((card, at, instance)) = session.bot_play() {
|
||||
let entries = builder.summon(&card, at, 1, 0, instance);
|
||||
if let Some((card, at, instance, level)) = session.bot_play() {
|
||||
let entries = builder.summon(&card, at, 1, level, instance);
|
||||
let entries_debug = entries.clone();
|
||||
session.reserve_instances(entries.len());
|
||||
for entry in entries {
|
||||
|
|
@ -126,14 +163,17 @@ fn the_bot_deploys_in_front_of_its_own_towers() {
|
|||
);
|
||||
plays += 1;
|
||||
if plays <= 8 {
|
||||
println!(
|
||||
"bot played {:?} at {:?}, {} entries, speeds {:?}",
|
||||
card,
|
||||
(at.x, at.y),
|
||||
entries_debug.len(),
|
||||
entries_debug.iter().map(|e| e.stats().speed).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"bot played {:?} at {:?}, {} entries, speeds {:?}",
|
||||
card,
|
||||
(at.x, at.y),
|
||||
entries_debug.len(),
|
||||
entries_debug
|
||||
.iter()
|
||||
.map(|e| e.stats().speed)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
session.advance_to(tick);
|
||||
|
|
@ -156,7 +196,7 @@ fn the_bot_deploys_in_front_of_its_own_towers() {
|
|||
}
|
||||
for entry in session.mode().battle.objects.objects.iter() {
|
||||
let (x, y) = entry.position();
|
||||
if !((500..=17500).contains(&x) && (1000..=31000).contains(&y)) {
|
||||
if !((0..=17999).contains(&x) && (0..=31999).contains(&y)) {
|
||||
println!(
|
||||
"t={tick}: object owner={} speed={} alive={} at ({x}, {y})",
|
||||
entry.owner_index(),
|
||||
|
|
@ -329,6 +369,7 @@ fn a_dormant_king_clears_its_combat_bit_and_towers_do_not() {
|
|||
assert_eq!(mask, 13, "a princess tower keeps its combat bit");
|
||||
princesses += 1;
|
||||
}
|
||||
logic::battle::LogicObjectBody::Projectile(_) => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(kings, 2);
|
||||
|
|
@ -389,7 +430,11 @@ fn a_deployed_troop_counts_down_and_holds_still_until_ready() {
|
|||
})
|
||||
};
|
||||
mode.battle.tick(1);
|
||||
assert_eq!(timer_of(&mode), Some(deploy - 50), "deploy timer counts down");
|
||||
assert_eq!(
|
||||
timer_of(&mode),
|
||||
Some(deploy - 50),
|
||||
"deploy timer counts down"
|
||||
);
|
||||
let here = mode
|
||||
.battle
|
||||
.objects
|
||||
|
|
@ -476,7 +521,10 @@ fn a_troop_in_range_damages_the_tower_with_the_client_hit_model() {
|
|||
_ => None,
|
||||
})
|
||||
.expect("tower still present");
|
||||
assert!(hp < start_hp, "the knight's attacks should reduce tower hp ({hp} !< {start_hp})");
|
||||
assert!(
|
||||
hp < start_hp,
|
||||
"the knight's attacks should reduce tower hp ({hp} !< {start_hp})"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn the_session_emits_a_heartbeat_every_turn() {
|
||||
|
|
@ -500,11 +548,13 @@ fn the_session_emits_a_heartbeat_every_turn() {
|
|||
session.advance_to(25);
|
||||
let out = session.take_outbound();
|
||||
std::env::remove_var("SCROLL_HEARTBEAT");
|
||||
let heartbeats: Vec<&service_rpc::WireMessage> = out
|
||||
.iter()
|
||||
.filter(|m| m.message_type == 21902)
|
||||
.collect();
|
||||
assert_eq!(heartbeats.len(), 2, "expected a heartbeat at ticks 10 and 20");
|
||||
let heartbeats: Vec<&service_rpc::WireMessage> =
|
||||
out.iter().filter(|m| m.message_type == 21902).collect();
|
||||
assert_eq!(
|
||||
heartbeats.len(),
|
||||
2,
|
||||
"expected a heartbeat at ticks 10 and 20"
|
||||
);
|
||||
let mut reader = titan::ByteStreamReader::new(&heartbeats[0].payload);
|
||||
let turn = reader.read_vint().unwrap();
|
||||
let checksum = reader.read_vint().unwrap();
|
||||
|
|
@ -519,7 +569,14 @@ fn playing_a_card_cycles_the_summoner_hand() {
|
|||
}
|
||||
let mut deck = logic::model::LogicSpellDeck::default();
|
||||
let names = [
|
||||
"Knight", "Archers", "Goblins", "Giant", "Fireball", "Arrows", "Minions", "Musketeer",
|
||||
"Knight",
|
||||
"Archers",
|
||||
"Goblins",
|
||||
"Giant",
|
||||
"Fireball",
|
||||
"Arrows",
|
||||
"Minions",
|
||||
"Musketeer",
|
||||
];
|
||||
for (slot, name) in names.iter().enumerate().take(deck.slots.len()) {
|
||||
deck.slots[slot] = Some(logic::model::LogicSpell {
|
||||
|
|
@ -547,9 +604,10 @@ fn playing_a_card_cycles_the_summoner_hand() {
|
|||
.objects
|
||||
.iter()
|
||||
.find_map(|e| match &e.body {
|
||||
logic::battle::LogicObjectBody::Summoner(s) if e.owner_index() == 0 => {
|
||||
s.deck.as_ref().map(|d| (d.hand, d.used_pile.clone(), d.spell_cooldown))
|
||||
}
|
||||
logic::battle::LogicObjectBody::Summoner(s) if e.owner_index() == 0 => s
|
||||
.deck
|
||||
.as_ref()
|
||||
.map(|d| (d.hand, d.used_pile.clone(), d.spell_cooldown)),
|
||||
_ => None,
|
||||
})
|
||||
.expect("player summoner with a deck")
|
||||
|
|
@ -569,7 +627,10 @@ fn playing_a_card_cycles_the_summoner_hand() {
|
|||
}
|
||||
let (hand2, _, _) = hand_of(&session);
|
||||
assert_ne!(hand2[0], -1, "the slot refilled from the draw pile");
|
||||
assert_ne!(hand2[0], played, "with a different card than the one played");
|
||||
assert_ne!(
|
||||
hand2[0], played,
|
||||
"with a different card than the one played"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn a_multi_unit_card_spreads_its_units() {
|
||||
|
|
@ -578,12 +639,200 @@ fn a_multi_unit_card_spreads_its_units() {
|
|||
LogicDataTables::install(Arc::new(t));
|
||||
}
|
||||
let builder = BattleBuilder::new(&root);
|
||||
let entries = builder.summon(&LogicDataRef::spell("Goblins"), LogicVector2::new(9000, 14500), 0, 0, 100);
|
||||
let entries = builder.summon(
|
||||
&LogicDataRef::spell("Goblins"),
|
||||
LogicVector2::new(9000, 14500),
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
);
|
||||
assert!(entries.len() >= 3, "goblins summon multiple units");
|
||||
let positions: Vec<(i32, i32)> = entries.iter().map(|e| e.position()).collect();
|
||||
for i in 0..positions.len() {
|
||||
for j in (i + 1)..positions.len() {
|
||||
assert_ne!(positions[i], positions[j], "units must not stack on one point");
|
||||
assert_ne!(
|
||||
positions[i], positions[j],
|
||||
"units must not stack on one point"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn a_snapshot_carrying_a_command_still_reads_back() {
|
||||
let root = assets();
|
||||
let tables = LogicDataTables::load_from_dir(&root).expect("tables");
|
||||
LogicDataTables::install(Arc::new(tables));
|
||||
let builder = BattleBuilder::new(&root);
|
||||
let mode = builder
|
||||
.build(
|
||||
LogicDataRef::by_name(table::LOCATIONS, "PvP_goblin"),
|
||||
LogicDataRef::None,
|
||||
LogicDataRef::by_name(table::ARENAS, "Arena_T"),
|
||||
vec![avatar(5), avatar(0)],
|
||||
[None, None],
|
||||
1,
|
||||
)
|
||||
.expect("battle");
|
||||
let played = logic::LogicDoSpellCommand {
|
||||
header: logic::LogicCommandHeader {
|
||||
tick_when_given: 125,
|
||||
execute_tick: 145,
|
||||
executor_account_id: LogicLong::new(0, 5),
|
||||
},
|
||||
deck_slot: 0,
|
||||
spell: Some(logic::model::LogicSpell {
|
||||
data: LogicDataRef::spell("Knight"),
|
||||
..Default::default()
|
||||
}),
|
||||
position: LogicVector2::new(10500, 12500),
|
||||
};
|
||||
let mut writer = titan::ByteStreamWriter::new();
|
||||
logic::LogicCommandManager::encode_command(&mut writer, &played).expect("command encodes");
|
||||
let encoded = writer.into_inner();
|
||||
for commands in [
|
||||
Vec::new(),
|
||||
vec![encoded.clone()],
|
||||
vec![encoded.clone(), encoded],
|
||||
] {
|
||||
let snapshot = mode.snapshot(&commands).expect("snapshot encodes");
|
||||
let report = verify_snapshot(&snapshot);
|
||||
assert!(
|
||||
report.is_ok(),
|
||||
"{} command(s): snapshot did not read back: {:?} trailing={}",
|
||||
commands.len(),
|
||||
report.error,
|
||||
report.trailing
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn the_search_moves_a_drop_the_way_the_client_does() {
|
||||
let root = assets();
|
||||
let tables = LogicDataTables::load_from_dir(&root).expect("tables");
|
||||
LogicDataTables::install(Arc::new(tables));
|
||||
let builder = BattleBuilder::new(&root);
|
||||
let mode = builder
|
||||
.build(
|
||||
LogicDataRef::by_name(table::LOCATIONS, "PvP_goblin"),
|
||||
LogicDataRef::None,
|
||||
LogicDataRef::by_name(table::ARENAS, "Arena_T"),
|
||||
vec![avatar(5), avatar(0)],
|
||||
[None, None],
|
||||
1,
|
||||
)
|
||||
.expect("battle");
|
||||
let tilemap = mode.battle.tilemap.as_ref().expect("tilemap");
|
||||
let knight = LogicDataRef::by_name(table::CHARACTERS_COMBINED, "Knight");
|
||||
let blockers = mode.battle.deploy_blockers(true);
|
||||
assert_eq!(blockers.len(), 6);
|
||||
assert!(blockers.contains(&logic::battle::DeployBlocker {
|
||||
x: 3500,
|
||||
y: 25500,
|
||||
size_w: 11,
|
||||
size_h: 21
|
||||
}));
|
||||
assert!(blockers.contains(&logic::battle::DeployBlocker {
|
||||
x: 3500,
|
||||
y: 6500,
|
||||
size_w: 3,
|
||||
size_h: 3
|
||||
}));
|
||||
for (raw, expected) in [
|
||||
((0, 0), (500, 1500)),
|
||||
((9000, 15900), (8500, 14500)),
|
||||
((9000, 22000), (8500, 14500)),
|
||||
((3500, 22000), (3500, 14500)),
|
||||
((3500, 10000), (3500, 10500)),
|
||||
((12500, 9500), (12500, 9500)),
|
||||
] {
|
||||
let found = logic::battle::find_position_for_spell(
|
||||
knight.as_character().as_ref(),
|
||||
LogicVector2::new(raw.0, raw.1),
|
||||
tilemap,
|
||||
&blockers,
|
||||
false,
|
||||
)
|
||||
.unwrap_or_else(|| panic!("{raw:?} should resolve to a position"));
|
||||
assert_eq!((found.x, found.y), expected, "drop at {raw:?} landed wrong");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn a_drop_the_client_refuses_spawns_nothing() {
|
||||
let root = assets();
|
||||
let tables = LogicDataTables::load_from_dir(&root).expect("tables");
|
||||
LogicDataTables::install(Arc::new(tables));
|
||||
let builder = BattleBuilder::new(&root);
|
||||
let mode = builder
|
||||
.build(
|
||||
LogicDataRef::by_name(table::LOCATIONS, "PvP_goblin"),
|
||||
LogicDataRef::None,
|
||||
LogicDataRef::by_name(table::ARENAS, "Arena_T"),
|
||||
vec![avatar(5), avatar(0)],
|
||||
[None, None],
|
||||
1,
|
||||
)
|
||||
.expect("battle");
|
||||
let tilemap = mode.battle.tilemap.as_ref().expect("tilemap");
|
||||
let knight = LogicDataRef::spell("Knight");
|
||||
for (tile, raw) in [((25, 31), (12750, 15750)), ((0, 0), (0, 0))] {
|
||||
assert!(
|
||||
!tilemap.can_place_egg(tile.0, tile.1),
|
||||
"tile {tile:?} should refuse a deploy"
|
||||
);
|
||||
let point = LogicVector2::new(raw.0, raw.1);
|
||||
assert_eq!(
|
||||
logic::battle::check_spell_position(tilemap, point.x, point.y, true),
|
||||
5
|
||||
);
|
||||
assert!(mode
|
||||
.battle
|
||||
.resolve_spell_position(&knight, point, 0)
|
||||
.is_none());
|
||||
}
|
||||
assert_eq!(
|
||||
logic::battle::check_spell_position(tilemap, 12750, 15750, false),
|
||||
0
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn mana_already_committed_cannot_be_spent_twice() {
|
||||
let root = assets();
|
||||
if let Ok(t) = LogicDataTables::load_from_dir(&root) {
|
||||
LogicDataTables::install(Arc::new(t));
|
||||
}
|
||||
let builder = BattleBuilder::new(&root);
|
||||
let mode = builder
|
||||
.build(
|
||||
LogicDataRef::by_name(table::LOCATIONS, "PvP_goblin"),
|
||||
LogicDataRef::None,
|
||||
LogicDataRef::by_name(table::ARENAS, "Arena_T"),
|
||||
vec![avatar(5), avatar(0)],
|
||||
[None, None],
|
||||
1,
|
||||
)
|
||||
.expect("battle");
|
||||
let mut session = game_service::battle_session::BattleSession::new(mode, Vec::new());
|
||||
session.advance_to(120);
|
||||
let available = session.simulated_mana(0);
|
||||
assert!(available > 0, "the player should have mana by tick 120");
|
||||
let entries = builder.summon(
|
||||
&LogicDataRef::spell("Knight"),
|
||||
LogicVector2::new(9500, 14500),
|
||||
0,
|
||||
0,
|
||||
session.next_instance(),
|
||||
);
|
||||
session.queue(
|
||||
120,
|
||||
LogicVector2::new(9500, 14500),
|
||||
0,
|
||||
available,
|
||||
0,
|
||||
entries,
|
||||
);
|
||||
assert_eq!(
|
||||
session.simulated_mana(0),
|
||||
0,
|
||||
"a card in flight has already claimed its mana"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
use std::sync::Arc;
|
||||
use service_rpc::{
|
||||
AccountRef, AuthApi, AuthRequest, AuthResponse, DeviceInfo, GameApi, GameRequest, GameResponse,
|
||||
HomeRequestKind, LoginOutcome, RpcClient, RpcError, RpcResult, WireMessage,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
pub struct Backends {
|
||||
pub auth: Arc<dyn AuthApi>,
|
||||
pub game: Arc<dyn GameApi>,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
use std::time::Duration;
|
||||
use logic::{
|
||||
message_type, AvailableServerCommandMessage, EndClientTurnMessage, KeepAliveMessage,
|
||||
LogicClaimRewardCommand, LogicCollectFreeChestCommand, LogicCommandHeader, LogicDataTables,
|
||||
LoginMessage, LoginOkMessage, OwnHomeDataMessage,
|
||||
};
|
||||
use std::time::Duration;
|
||||
use titan::crypto::SessionCipher;
|
||||
use titan::{FrameCodec, FrameHeader, MessageFrame, MessageMeta, Payload, HEADER_LEN};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ pub mod backend;
|
|||
pub mod config;
|
||||
pub mod message_manager;
|
||||
pub mod session;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
pub use backend::{Backends, RemoteAuth, RemoteGame};
|
||||
pub use config::GatewayConfig;
|
||||
pub use message_manager::MessageManager;
|
||||
pub use session::{Session, SessionError};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
pub async fn bind(config: &GatewayConfig) -> std::io::Result<TcpListener> {
|
||||
TcpListener::bind(&config.listen).await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::task::JoinHandle;
|
||||
use crate::backend::Backends;
|
||||
use crate::config::GatewayConfig;
|
||||
use logic::{
|
||||
message_type, CancelMatchmakeDoneMessage, ClientCapabilitiesMessage, GoHomeMessage,
|
||||
KeepAliveServerMessage, LoginFailedMessage, LoginMessage, LoginOkMessage, ServerErrorMessage,
|
||||
|
|
@ -9,9 +7,11 @@ use logic::{
|
|||
use service_rpc::{
|
||||
AccountRef, DeviceInfo, HomeRequestKind, LoginOutcome, Session as AuthSession, WireMessage,
|
||||
};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use titan::{Incoming, LogicLong, MessagingSender, OutboundMessage};
|
||||
use crate::backend::Backends;
|
||||
use crate::config::GatewayConfig;
|
||||
use tokio::task::JoinHandle;
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RoutingError {
|
||||
#[error("transport: {0}")]
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
use crate::backend::Backends;
|
||||
use crate::config::GatewayConfig;
|
||||
use crate::message_manager::{MessageManager, RoutingError};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use titan::crypto::SessionCipher;
|
||||
use titan::{MessageRegistry, Messaging, MessagingConfig};
|
||||
use tokio::net::TcpStream;
|
||||
use crate::backend::Backends;
|
||||
use crate::config::GatewayConfig;
|
||||
use crate::message_manager::{MessageManager, RoutingError};
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SessionError {
|
||||
#[error("io: {0}")]
|
||||
|
|
|
|||
Loading…
Reference in a new issue