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:
WiseDev 2026-08-28 16:27:16 +03:00
parent 07f0ee5427
commit a585b0c18d
27 changed files with 1434 additions and 254 deletions

View file

@ -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];

View file

@ -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();
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 heartbeats_enabled() && self.pushes_snapshots() && self.tick % TICKS_PER_TURN == 0 {
if let Some(checksum) = this_checksum { if let Some(checksum) = this_checksum {
let turn = self.tick / TICKS_PER_TURN; let turn = self.tick / TICKS_PER_TURN;
if let Ok(message) = let mut commands = Vec::new();
crate::wire::encode(&SectorHeartbeatMessage::new(turn, checksum)) 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); 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 self.pushes_snapshots() && self.tick >= self.next_snapshot { }
}
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 {
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 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)); 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; self.tick + wait * BATTLE_TICKS_PER_SECOND
if let Some((card, position, instance)) = self.bot_play() { };
self.pending_bot_play = Some((card, position, instance)); 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 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);
} }
pub fn push(&mut self, entry: LogicGameObjectEntry) {
self.mode.battle.objects.push(entry); 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 { 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(
&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()); session.reserve_instances(entries.len());
if !entries.is_empty() { if !entries.is_empty() {
tracing::debug!(card = %card, count = entries.len(), "the bot played a card"); tracing::debug!(card = %card, count = entries.len(), "the bot played a card");
for entry in entries { session.push_group(adjusted, entries);
session.push(entry); }
} }
} }
} }
@ -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
} }
} }

View file

@ -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);

View file

@ -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 {

View file

@ -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)]

View file

@ -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,

View file

@ -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";

View file

@ -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>> {

View file

@ -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);

View file

@ -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>,
} }

View file

@ -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?,
)), )),

View file

@ -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 {

View file

@ -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)] = &[

View file

@ -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())
} }

View file

@ -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,
} }

View file

@ -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)]

View file

@ -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(_)
));
} }

View 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"
);
}

View 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");
}

View 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);
}

View 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"
);
}

View file

@ -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 {
@ -131,7 +168,10 @@ fn the_bot_deploys_in_front_of_its_own_towers() {
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<_>>()
); );
} }
} }
@ -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"
);
}

View file

@ -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>,

View file

@ -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};

View file

@ -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
} }

View file

@ -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}")]

View file

@ -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}")]