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::{
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];

View file

@ -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 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 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))
{
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 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();
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 {
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.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.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 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);
}
}
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);
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");
for entry in entries {
session.push(entry);
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
}
}

View file

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

View file

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

View file

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

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::{
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,

View file

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

View file

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

View file

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

View file

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

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::{
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?,
)),

View file

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

View file

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

View file

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

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

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