spawn the played card on the server

a LogicDoSpellCommand names a deck slot, so the card comes from the deck
we sent for that player, and the owner comes from matching the command's
executor account against the two the battle carries. the spell row's
SummonCharacter and SummonNumber say what and how many to place, at the
position the command carries.

the object factory behind the towers is now shared: hitpoints and the
combat/hitpoint/buff components are built the same way for a summoned
character as for a tower.

these objects are not encodable yet. a character with Speed gets a
LogicMovementComponent on the client, and we have no encoder for that
component, so the server copy must not be turned back into a snapshot
until it exists. nothing re-encodes it today - it feeds the clock, the
crowns and the end condition only.
This commit is contained in:
WiseDev 2026-08-23 12:09:30 +03:00
parent 7a0ba1b652
commit 83a1dd2bd3
4 changed files with 188 additions and 18 deletions

View file

@ -4,7 +4,8 @@ use logic::battle::{
LogicGameMode, LogicGameObject, LogicGameObjectEntry, LogicGameObjectManager,
LogicGameObjectRef, LogicHitpointComponent, LogicObjectBody, LogicSummoner, LogicSummonerDeck,
LogicTilemap,
LogicTime, LogicVector2, BATTLE_TYPE_NPC, CHARACTER_OBJECT_TYPE, DIRECTION_BOTTOM,
LogicTime, LogicVector2, BATTLE_TYPE_NPC, CHARACTER_OBJECT_TYPE, COMPONENT_PASSES,
DIRECTION_BOTTOM,
DIRECTION_TOP, SUBTILE_UNITS,
};
use logic::model::{LogicClientAvatar, LogicSpellDeck};
@ -14,6 +15,17 @@ 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,
pub position: LogicVector2,
pub owner: i32,
pub level_index: i32,
}
pub struct TowerSpec {
pub data: LogicDataRef,
pub instance: i32,
@ -74,6 +86,91 @@ 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))
.filter(|value| *value > 0)
.unwrap_or(1)
}
fn components_for(&self, hitpoints: i32) -> [Option<LogicComponent>; COMPONENT_PASSES] {
[
Some(LogicComponent::Combat(LogicCombatComponent::default())),
None,
Some(LogicComponent::Hitpoint(LogicHitpointComponent::healthy(
hitpoints,
))),
Some(LogicComponent::Buff(LogicCharacterBuffComponent::empty(
self.buff_type_count,
))),
]
}
pub fn character(&self, spec: CharacterSpec) -> LogicGameObjectEntry {
let CharacterSpec {
data,
instance,
position,
owner,
level_index,
} = spec;
let hitpoints = Self::hitpoints_of(&data, level_index);
let character = LogicCharacter {
level_index,
base: LogicGameObject {
position,
owner_index: owner,
..LogicGameObject::default()
},
direction: LogicVector2::new(
0,
if owner == 0 {
DIRECTION_TOP
} else {
DIRECTION_BOTTOM
},
),
..LogicCharacter::default()
};
let components = self.components_for(hitpoints);
LogicGameObjectEntry::new(
data,
LogicGameObjectRef::of(CHARACTER_OBJECT_TYPE + 1, instance),
LogicObjectBody::Character(Box::new(character)),
components,
)
}
pub fn summon(
&self,
spell: &LogicDataRef,
position: LogicVector2,
owner: i32,
level_index: i32,
first_instance: i32,
) -> Vec<LogicGameObjectEntry> {
let Some(row) = spell.data() else {
return Vec::new();
};
let name = row.string(SPELL_SUMMON_CHARACTER_COLUMN).to_owned();
if name.is_empty() {
return Vec::new();
}
let data = character_data(&name);
if data.is_none() {
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);
(0..count)
.map(|index| {
self.character(CharacterSpec {
data: data.clone(),
instance: first_instance + index,
position,
owner,
level_index,
})
})
.collect()
}
fn tower(&self, spec: TowerSpec) -> LogicGameObjectEntry {
let TowerSpec {
data,
@ -85,11 +182,7 @@ impl BattleBuilder {
summoner,
deck_slots,
} = spec;
let hitpoints = data
.data()
.map(|row| row.int_at("Hitpoints", level_index.max(0) as usize))
.filter(|value| *value > 0)
.unwrap_or(1);
let hitpoints = Self::hitpoints_of(&data, level_index);
let character = LogicCharacter {
level_index,
is_leader,
@ -122,16 +215,7 @@ impl BattleBuilder {
data,
LogicGameObjectRef::of(CHARACTER_OBJECT_TYPE + 1, instance),
body,
[
Some(LogicComponent::Combat(LogicCombatComponent::default())),
None,
Some(LogicComponent::Hitpoint(LogicHitpointComponent::healthy(
hitpoints,
))),
Some(LogicComponent::Buff(LogicCharacterBuffComponent::empty(
self.buff_type_count,
))),
],
self.components_for(hitpoints),
)
}
pub fn build(

View file

@ -1,5 +1,7 @@
use std::collections::HashMap;
use logic::battle::{LogicGameMode, BATTLE_TICKS_PER_SECOND};
use logic::battle::{LogicGameMode, LogicGameObjectEntry, BATTLE_TICKS_PER_SECOND};
use logic::LogicDataRef;
use titan::LogicLong;
use service_rpc::AccountRef;
use tokio::sync::Mutex;
pub struct BattleSession {
@ -31,6 +33,36 @@ impl BattleSession {
pub fn stars(&self) -> (i32, i32) {
(self.mode.battle.stars(0), self.mode.battle.stars(1))
}
pub fn owner_of(&self, account: LogicLong) -> Option<i32> {
self.mode
.battle
.account_ids
.iter()
.position(|id| *id == account)
.map(|index| index as i32)
}
pub fn next_instance(&self) -> i32 {
self.mode
.battle
.objects
.objects
.iter()
.filter_map(|entry| entry.global_id.0.map(|id| id.instance_id))
.max()
.map(|last| last + 1)
.unwrap_or(0)
}
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())
}
pub fn push(&mut self, entry: LogicGameObjectEntry) {
self.mode.battle.objects.push(entry);
}
pub fn object_count(&self) -> usize {
self.mode.battle.objects.objects.len()
}
}
#[derive(Default)]
pub struct BattleRegistry {
@ -54,12 +86,42 @@ impl BattleRegistry {
seconds: session.seconds(),
finished: session.is_finished(),
stars: session.stars(),
objects: session.object_count(),
})
}
pub async fn play<F>(
&self,
account: AccountRef,
executor: LogicLong,
slot: i32,
position: logic::battle::LogicVector2,
summon: F,
) -> usize
where
F: Fn(&LogicDataRef, logic::battle::LogicVector2, i32, i32) -> Vec<LogicGameObjectEntry>,
{
let mut sessions = self.sessions.lock().await;
let Some(session) = sessions.get_mut(&account) else {
return 0;
};
let Some(owner) = session.owner_of(executor) else {
return 0;
};
let Some(card) = session.deck_card(owner, slot) else {
return 0;
};
let entries = summon(&card, position, owner, session.next_instance());
let spawned = entries.len();
for entry in entries {
session.push(entry);
}
spawned
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BattleProgress {
pub seconds: i32,
pub finished: bool,
pub stars: (i32, i32),
pub objects: usize,
}

View file

@ -306,6 +306,27 @@ impl GameApi for GameService {
(home.end_client_turn(&turn, &roller, &self.shop), in_battle)
};
if in_battle {
let mut spawned = 0;
for command in &turn.commands {
let Some(played) = command
.as_any()
.downcast_ref::<logic::LogicDoSpellCommand>()
else {
continue;
};
spawned += self
.running_battles
.play(
account,
played.header.executor_account_id,
played.deck_slot,
played.position,
|card, position, owner, instance| {
self.battles.summon(card, position, owner, 0, instance)
},
)
.await;
}
let progress = self.running_battles.advance(account, turn.tick).await;
tracing::info!(
%account,
@ -313,6 +334,8 @@ impl GameApi for GameService {
seconds = progress.map(|state| state.seconds),
finished = progress.map(|state| state.finished),
stars = ?progress.map(|state| state.stars),
objects = progress.map(|state| state.objects),
spawned,
commands = ?turn
.commands
.iter()

View file

@ -11,7 +11,8 @@ pub use commands::{
chest_source, command_type, CommandMeta, CommandOutcome, Execute, LogicBuyCardCommand,
LogicBuyChestCommand, LogicBuyResourcePackCommand, LogicClaimAchievementRewardCommand,
LogicClaimRewardCommand, LogicCollectFreeChestCommand, LogicCollectMultiWinChestCommand,
LogicCommand, LogicCommandHeader, LogicCommandManager, LogicFuseSpellsCommand,
LogicCommand, LogicCommandHeader, LogicCommandManager, LogicCompleteTutorialBattleCommand,
LogicDoSpellCommand, LogicFuseSpellsCommand,
LogicHelpOpenedCommand, LogicMoveSpellCommand, LogicPageOpenedCommand,
LogicRefreshAchievementsCommand, LogicReward, LogicShopOpenedCommand,
LogicShopSeedChangedCommand, LogicSortCollectionCommand, LogicStartMatchmakeCommand,