From 774e7c60cfdba9a81eff214499bcfc1950f82a80 Mon Sep 17 00:00:00 2001 From: WiseDev <83840010+wisedevik@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:24:20 +0300 Subject: [PATCH] build and send the battle sector state npc missions used to get a ServerErrorMessage back. now StartMission builds a LogicGameMode snapshot off the arena tilemap and answers 21903. towers come from assets/locations/*.csv the way initDefaultSector does it: tile coordinates times 500, leader index decided by which half of the map the tower sits in. the two king towers must be there, the client dereferences them without a null check. they live in buildings.csv, not characters.csv. LogicCharacter puts the base object fields fourth, not first. the buff component writes a fixed array sized by the character_buffs row count even with no buffs. training_arena parses to 2 kings, 4 princess towers, 36x64 subtiles. snapshot is 602 bytes over 6 objects. the real client has not seen it yet. --- crates/game-service/src/battle.rs | 214 ++++++++++++++++++ crates/game-service/src/lib.rs | 2 + crates/game-service/src/service.rs | 51 ++++- crates/gateway/src/backend.rs | 11 + crates/gateway/src/bin/scroll-probe.rs | 19 +- crates/gateway/src/message_manager.rs | 35 +-- crates/logic/src/battle/logic_character.rs | 145 ++++++++++++ crates/logic/src/battle/logic_component.rs | 126 +++++++++++ crates/logic/src/battle/logic_game_mode.rs | 47 ++++ crates/logic/src/battle/logic_game_object.rs | 29 ++- .../src/battle/logic_game_object_manager.rs | 46 +--- crates/logic/src/battle/logic_tilemap.rs | 110 +++++++++ crates/logic/src/battle/mod.rs | 17 +- crates/logic/src/messages/mod.rs | 2 + crates/logic/src/messages/sector_state.rs | 12 + crates/service-rpc/src/game.rs | 10 + crates/titan-derive/src/codec.rs | 51 ++++- crates/titan/src/error.rs | 2 + crates/titan/src/io/reader.rs | 4 + crates/titan/src/io/writer.rs | 3 + 20 files changed, 874 insertions(+), 62 deletions(-) create mode 100644 crates/game-service/src/battle.rs create mode 100644 crates/logic/src/battle/logic_character.rs create mode 100644 crates/logic/src/battle/logic_component.rs create mode 100644 crates/logic/src/battle/logic_game_mode.rs create mode 100644 crates/logic/src/battle/logic_tilemap.rs create mode 100644 crates/logic/src/messages/sector_state.rs diff --git a/crates/game-service/src/battle.rs b/crates/game-service/src/battle.rs new file mode 100644 index 0000000..165d507 --- /dev/null +++ b/crates/game-service/src/battle.rs @@ -0,0 +1,214 @@ +use std::path::{Path, PathBuf}; +use logic::battle::{ + LogicBattle, LogicCharacter, LogicCharacterBuffComponent, LogicCombatComponent, LogicComponent, + LogicGameMode, LogicGameObject, LogicGameObjectEntry, LogicGameObjectManager, + LogicGameObjectRef, LogicHitpointComponent, LogicObjectBody, LogicSummoner, LogicTilemap, + LogicTime, LogicVector2, BATTLE_TYPE_NPC, CHARACTER_OBJECT_TYPE, DIRECTION_BOTTOM, + DIRECTION_TOP, SUBTILE_UNITS, +}; +use logic::model::LogicClientAvatar; +use logic::{table, LogicDataRef, LogicDataTables, LogicRandom}; +pub const LOCATION_FILE_COLUMN: &str = "FileName"; +pub const CHARACTER_KING_TOWER: &str = "KingTower"; +pub const CHARACTER_PRINCESS_TOWER: &str = "PrincessTower"; +pub const BUFF_TABLE: i32 = 9; +pub struct TowerSpec { + pub data: LogicDataRef, + pub instance: i32, + pub tile: (i32, i32), + pub owner: i32, + pub level_index: i32, + pub is_leader: bool, + pub summoner: bool, +} +pub struct BattleBuilder { + root: PathBuf, + buff_type_count: usize, +} +fn tile_to_units(tile: i32) -> i32 { + tile.saturating_mul(SUBTILE_UNITS) +} +fn character_data(name: &str) -> LogicDataRef { + let combined = LogicDataRef::by_name(table::CHARACTERS_COMBINED, name); + if !combined.is_none() { + return combined; + } + let building = LogicDataRef::by_name(table::BUILDINGS, name); + if !building.is_none() { + return building; + } + LogicDataRef::by_name(table::CHARACTERS, name) +} +impl BattleBuilder { + pub fn new(root: impl AsRef) -> Self { + let buff_type_count = LogicDataTables::instance() + .table(BUFF_TABLE) + .map(|rows| rows.count()) + .unwrap_or(0); + Self { + root: root.as_ref().to_path_buf(), + buff_type_count, + } + } + pub fn buff_type_count(&self) -> usize { + self.buff_type_count + } + pub fn tilemap_for(&self, location: &LogicDataRef) -> std::io::Result { + let file_name = location + .data() + .map(|data| data.string(LOCATION_FILE_COLUMN).to_owned()) + .unwrap_or_default(); + if file_name.is_empty() { + return Err(std::io::Error::other("the location names no tilemap")); + } + LogicTilemap::load(&self.root, &file_name) + } + fn tower(&self, spec: TowerSpec) -> LogicGameObjectEntry { + let TowerSpec { + data, + instance, + tile, + owner, + level_index, + is_leader, + summoner, + } = 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 character = LogicCharacter { + level_index, + is_leader, + base: LogicGameObject { + position: LogicVector2::new(tile_to_units(tile.0), tile_to_units(tile.1)), + owner_index: owner, + ..LogicGameObject::default() + }, + direction: LogicVector2::new( + 0, + if owner == 0 { + DIRECTION_TOP + } else { + DIRECTION_BOTTOM + }, + ), + ..LogicCharacter::default() + }; + let body = if summoner { + LogicObjectBody::Summoner(Box::new(LogicSummoner { + character, + ..LogicSummoner::default() + })) + } else { + LogicObjectBody::Character(Box::new(character)) + }; + LogicGameObjectEntry { + data, + global_id: LogicGameObjectRef::of(CHARACTER_OBJECT_TYPE + 1, instance), + body, + components: [ + Some(LogicComponent::Combat(LogicCombatComponent::default())), + None, + Some(LogicComponent::Hitpoint(LogicHitpointComponent::healthy( + hitpoints, + ))), + Some(LogicComponent::Buff(LogicCharacterBuffComponent::empty( + self.buff_type_count, + ))), + ], + } + } + pub fn build( + &self, + location: LogicDataRef, + npc: LogicDataRef, + arena: LogicDataRef, + avatars: Vec, + random_seed: i32, + ) -> std::io::Result { + let tilemap = self.tilemap_for(&location)?; + let height = tilemap.height(); + let middle = tile_to_units(height) / 2; + let king = character_data(CHARACTER_KING_TOWER); + let princess = character_data(CHARACTER_PRINCESS_TOWER); + if king.is_none() { + return Err(std::io::Error::other( + "no KingTower row in the character tables", + )); + } + let mut objects = LogicGameObjectManager::default(); + let mut leaders = [LogicGameObjectRef::NONE; 2]; + let mut towers: [Vec; 2] = [Vec::new(), Vec::new()]; + let mut instance = 0; + for tile in tilemap.king_towers() { + let owner = usize::from(tile_to_units(tile.1) >= middle); + let entry = self.tower(TowerSpec { + data: king.clone(), + instance, + tile: *tile, + owner: owner as i32, + level_index: 0, + is_leader: true, + summoner: true, + }); + leaders[owner] = entry.global_id; + objects.push(entry); + instance += 1; + } + if !princess.is_none() { + for tile in tilemap.princess_towers() { + let owner = usize::from(tile_to_units(tile.1) >= middle); + let entry = self.tower(TowerSpec { + data: princess.clone(), + instance, + tile: *tile, + owner: owner as i32, + level_index: 0, + is_leader: false, + summoner: false, + }); + towers[owner].push(entry.global_id); + objects.push(entry); + instance += 1; + } + } + if leaders.iter().any(LogicGameObjectRef::is_none) { + return Err(std::io::Error::other(format!( + "{} has {} king towers, the client needs exactly two", + location, + tilemap.king_towers().len() + ))); + } + let account_ids = [ + avatars + .first() + .map(|avatar| avatar.account_id) + .unwrap_or_default(), + avatars + .get(1) + .map(|avatar| avatar.account_id) + .unwrap_or_default(), + ]; + let battle = LogicBattle { + location, + npc, + arena, + account_ids, + battle_type: BATTLE_TYPE_NPC, + objects, + leaders, + leader_towers: towers, + ..LogicBattle::default() + }; + Ok(LogicGameMode { + random: LogicRandom::new(random_seed), + random_seed, + time: LogicTime::default(), + battle, + avatars, + ..LogicGameMode::default() + }) + } +} diff --git a/crates/game-service/src/lib.rs b/crates/game-service/src/lib.rs index fafcfc2..4f105b8 100644 --- a/crates/game-service/src/lib.rs +++ b/crates/game-service/src/lib.rs @@ -1,3 +1,4 @@ +pub mod battle; pub mod catalog; pub mod config; pub mod home; @@ -7,6 +8,7 @@ pub mod service; pub mod shop; pub mod store; pub mod time; +pub use battle::BattleBuilder; pub use catalog::{Catalog, ARENA_FALLBACK_INSTANCE, GOLD_RESOURCE_FALLBACK_INSTANCE}; pub use config::{CardRef, DataSelector, GameConfig, StarterProfile}; pub use home::{build_avatar, build_home}; diff --git a/crates/game-service/src/service.rs b/crates/game-service/src/service.rs index baad5b1..dc4bc82 100644 --- a/crates/game-service/src/service.rs +++ b/crates/game-service/src/service.rs @@ -1,15 +1,19 @@ use std::sync::Arc; +use logic::table; use logic::{ AvailableServerCommandMessage, EndClientTurnMessage, LogicCommandManager, LogicDataRef, - LogicShopSeedChangedCommand, OutOfSyncMessage, OwnHomeDataMessage, + LogicShopSeedChangedCommand, OutOfSyncMessage, OwnHomeDataMessage, SectorStateMessage, + StartMissionMessage, }; use service_rpc::{ AccountRef, GameApi, GameRequest, GameResponse, HomeRequestKind, RpcError, RpcResult, RpcService, WireMessage, }; use titan::{Message, MessageMeta, Payload}; +use crate::battle::BattleBuilder; use crate::catalog::Catalog; use crate::config::GameConfig; +use crate::home::build_avatar; use crate::home_mode::{available_server_command, HomeModeRegistry}; use crate::rewards::RewardRoller; use crate::shop::{ShopCatalog, ShopCycle}; @@ -20,6 +24,7 @@ pub struct GameService { profiles: ProfileStore, catalog: Arc, shop: Arc, + battles: Arc, sessions: HomeModeRegistry, } impl GameService { @@ -31,6 +36,9 @@ impl GameService { .map_err(connect_failed)?; let profile_count = profiles.count().await?; let shop = Arc::new(ShopCatalog::load(config.shop_path.as_deref())); + let battles = Arc::new(BattleBuilder::new( + config.csv_root.clone().unwrap_or_else(|| "assets".into()), + )); for offer in shop.describe() { tracing::debug!(offer, "shop offer"); } @@ -46,6 +54,7 @@ impl GameService { profiles, catalog, shop, + battles, sessions: HomeModeRegistry::default(), })) } @@ -162,6 +171,43 @@ impl GameApi for GameService { tracing::debug!(%account, ping_ms, "client capabilities"); Ok(()) } + async fn start_mission( + &self, + account: AccountRef, + payload: Vec, + ) -> RpcResult> { + let mission = StartMissionMessage::from_bytes(&payload) + .map_err(|error| RpcError::Rejected(error.to_string()))?; + let profile = self.profile(account).await?; + let avatar = build_avatar(&profile); + let location = mission + .npc + .data() + .map(|npc| LogicDataRef::by_name(table::LOCATIONS, npc.string("Location"))) + .unwrap_or_default(); + let battle = self + .battles + .build( + location.clone(), + mission.npc.clone(), + profile.arena.clone(), + vec![avatar.clone(), avatar], + self.config.random_seed, + ) + .map_err(|error| RpcError::Rejected(error.to_string()))?; + let snapshot = battle + .snapshot() + .map_err(|error| RpcError::Rejected(error.to_string()))?; + tracing::info!( + %account, + npc = %mission.npc, + location = %location, + objects = battle.battle.objects.objects.len(), + bytes = snapshot.len(), + "sending a battle sector state" + ); + Ok(vec![encode(&SectorStateMessage::new(snapshot))?]) + } async fn end_client_turn( &self, account: AccountRef, @@ -255,6 +301,9 @@ impl RpcService for GameService { self.client_capabilities(account, ping_ms).await?; Ok(GameResponse::Empty) } + GameRequest::StartMission { account, payload } => Ok(GameResponse::messages( + self.start_mission(account, payload).await?, + )), GameRequest::EndClientTurn { account, payload } => Ok(GameResponse::messages( self.end_client_turn(account, payload).await?, )), diff --git a/crates/gateway/src/backend.rs b/crates/gateway/src/backend.rs index 91d53a8..aecb753 100644 --- a/crates/gateway/src/backend.rs +++ b/crates/gateway/src/backend.rs @@ -89,6 +89,17 @@ impl GameApi for RemoteGame { .await?; Ok(()) } + async fn start_mission( + &self, + account: AccountRef, + payload: Vec, + ) -> RpcResult> { + Ok(self + .client + .call(&GameRequest::StartMission { account, payload }) + .await? + .into_messages()) + } async fn end_client_turn( &self, account: AccountRef, diff --git a/crates/gateway/src/bin/scroll-probe.rs b/crates/gateway/src/bin/scroll-probe.rs index e143ae5..94a884e 100644 --- a/crates/gateway/src/bin/scroll-probe.rs +++ b/crates/gateway/src/bin/scroll-probe.rs @@ -66,6 +66,9 @@ async fn main() -> Result<(), Box> { let buy_chest = std::env::args() .skip_while(|arg| arg != "--buy-chest") .nth(1); + let start_mission = std::env::args() + .skip_while(|arg| arg != "--start-mission") + .nth(1); let csv_root = std::env::var("SCROLL_CSV_ROOT").unwrap_or_else(|_| "assets".to_owned()); match LogicDataTables::load_from_dir(&csv_root) { Ok(tables) => { @@ -181,7 +184,16 @@ async fn main() -> Result<(), Box> { let checksum = home.home.chest_id_counter + ((home.home.spell_collection.spells.len() as i32) << 16) + i32::from(desync); - if let Some(name) = buy_chest.as_deref() { + if let Some(name) = start_mission.as_deref() { + let npc = logic::LogicDataRef::by_name(logic::table::NPCS, name); + if npc.is_none() { + println!("!! no npc named {name}"); + } else { + println!(" starting mission {npc}"); + probe.send(&logic::StartMissionMessage { npc }).await?; + println!("-> 14104 StartMissionMessage"); + } + } else if let Some(name) = buy_chest.as_deref() { let chest = logic::LogicDataRef::by_name(logic::table::TREASURE_CHESTS, name); match chest.as_treasure_chest() { None => println!("!! no treasure chest named {name}"), @@ -247,6 +259,11 @@ async fn main() -> Result<(), Box> { probe.send(&KeepAliveMessage::default()).await?; println!("-> 10108 KeepAliveMessage"); } + 21903 => { + println!("<- 21903 SectorStateMessage ({} bytes)", payload.len()); + println!("lobby reached, battle snapshot received"); + return Ok(()); + } message_type::KEEP_ALIVE_SERVER => { println!("<- 20108 KeepAliveServerMessage"); println!("lobby reached, probe done"); diff --git a/crates/gateway/src/message_manager.rs b/crates/gateway/src/message_manager.rs index 7b49303..0f0f377 100644 --- a/crates/gateway/src/message_manager.rs +++ b/crates/gateway/src/message_manager.rs @@ -2,7 +2,7 @@ use std::net::SocketAddr; use std::sync::Arc; use logic::{ message_type, ClientCapabilitiesMessage, GoHomeMessage, KeepAliveServerMessage, - LoginFailedMessage, LoginMessage, LoginOkMessage, ServerErrorMessage, StartMissionMessage, + LoginFailedMessage, LoginMessage, LoginOkMessage, ServerErrorMessage, }; use service_rpc::{ AccountRef, DeviceInfo, HomeRequestKind, LoginOutcome, Session as AuthSession, WireMessage, @@ -89,20 +89,25 @@ impl MessageManager { self.push_home(HomeRequestKind::GoHome).await } message_type::START_MISSION => { - let mission = incoming - .downcast::() - .expect("start mission message"); - tracing::warn!( - peer = %self.peer, - npc = %mission.npc, - "client asked for an npc mission, which this server does not implement; \ - give the avatar a non zero npc_win_count so it goes to the home screen instead" - ); - self.sender - .send_message(&ServerErrorMessage::new( - "npc missions are not implemented by this server", - )) - .await?; + let Some(account) = self.account else { + return Ok(()); + }; + match self + .backends + .game + .start_mission(account, incoming.payload) + .await + { + Ok(replies) => self.push_wire_messages(replies).await?, + Err(error) => { + tracing::warn!(peer = %self.peer, %error, "could not start the mission"); + self.sender + .send_message(&ServerErrorMessage::new( + "this server could not build the battle", + )) + .await?; + } + } Ok(()) } message_type::END_CLIENT_TURN => { diff --git a/crates/logic/src/battle/logic_character.rs b/crates/logic/src/battle/logic_character.rs new file mode 100644 index 0000000..15634d5 --- /dev/null +++ b/crates/logic/src/battle/logic_character.rs @@ -0,0 +1,145 @@ +use titan::{ByteStreamWriter, Result}; +use crate::battle::logic_game_object::{LogicGameObject, LogicVector2}; +pub const DIRECTION_TOP: i32 = 256; +pub const DIRECTION_BOTTOM: i32 = -256; +pub const DEFAULT_SIZE: i32 = 100; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LogicCharacter { + pub level_index: i32, + pub flag_160: bool, + pub is_leader: bool, + pub base: LogicGameObject, + pub direction: LogicVector2, + pub state: i32, + pub size: i32, + pub growth_timer: i32, + pub spawn_timer: i32, + pub field_120: i32, + pub remaining_spawn_count: i32, + pub destruction_wave_timer: i32, + pub lane_id: i32, + pub deploy_timer: i32, + pub hide_timer: i32, + pub field_100: i32, + pub pending_physical_damage: i32, + pub mana_generate_limit: Option, + pub reload: Option<(i32, i32)>, +} +impl Default for LogicCharacter { + fn default() -> Self { + Self { + level_index: 0, + flag_160: false, + is_leader: false, + base: LogicGameObject::default(), + direction: LogicVector2::new(0, DIRECTION_BOTTOM), + state: 0, + size: DEFAULT_SIZE, + growth_timer: 0, + spawn_timer: 0, + field_120: 0, + remaining_spawn_count: 0, + destruction_wave_timer: 0, + lane_id: 0, + deploy_timer: 0, + hide_timer: 0, + field_100: 0, + pending_physical_damage: 0, + mana_generate_limit: None, + reload: None, + } + } +} +impl LogicCharacter { + pub fn encode(&self, writer: &mut ByteStreamWriter) -> Result<()> { + writer.write_vint(self.level_index); + writer.write_boolean(self.flag_160); + writer.write_boolean(self.is_leader); + self.base.encode_base(writer)?; + writer.write_vint(self.direction.x); + writer.write_vint(self.direction.y); + writer.write_vint(self.state); + writer.write_vint(self.size); + writer.write_vint(self.growth_timer); + writer.write_vint(self.spawn_timer); + writer.write_vint(self.field_120); + writer.write_vint(self.remaining_spawn_count); + writer.write_vint(self.destruction_wave_timer); + writer.write_vint(self.lane_id); + writer.write_vint(self.deploy_timer); + writer.write_vint(self.hide_timer); + writer.write_vint(self.field_100); + writer.write_vint(self.pending_physical_damage); + if let Some(limit) = self.mana_generate_limit { + writer.write_vint(limit); + } + if let Some((timer, hits)) = self.reload { + writer.write_vint(timer); + writer.write_vint(hits); + } + Ok(()) + } +} +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct LogicSummoner { + pub character: LogicCharacter, + pub deck: Option, + pub mana_regen_timer: i32, + pub mana: i32, + pub field_244: i32, + pub field_256: i32, +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LogicSummonerDeck { + pub field_220: i32, + pub hand: [i32; 4], + pub draw_pile: Vec, + pub used_pile: Vec, + pub last_used_index: i32, + pub field_248: i32, + pub field_240: i32, +} +impl Default for LogicSummonerDeck { + fn default() -> Self { + Self { + field_220: 0, + hand: [-1; 4], + draw_pile: Vec::new(), + used_pile: Vec::new(), + last_used_index: -1, + field_248: 0, + field_240: 0, + } + } +} +impl LogicSummoner { + pub fn encode(&self, writer: &mut ByteStreamWriter) -> Result<()> { + self.character.encode(writer)?; + match &self.deck { + None => writer.write_boolean(false), + Some(deck) => { + writer.write_boolean(true); + writer.write_vint(deck.field_220); + for slot in deck.hand { + writer.write_vint(slot); + } + writer.write_vint(deck.draw_pile.len() as i32); + for value in &deck.draw_pile { + writer.write_vint(*value); + } + writer.write_vint(deck.used_pile.len() as i32); + for value in &deck.used_pile { + writer.write_vint(*value); + } + writer.write_vint(deck.last_used_index); + writer.write_vint(deck.field_248); + writer.write_vint(deck.field_240); + } + } + writer.write_vint(self.mana_regen_timer); + writer.write_vint(self.mana); + writer.write_vint(self.field_244); + writer.write_vint(self.field_256); + Ok(()) + } +} diff --git a/crates/logic/src/battle/logic_component.rs b/crates/logic/src/battle/logic_component.rs new file mode 100644 index 0000000..4fefb22 --- /dev/null +++ b/crates/logic/src/battle/logic_component.rs @@ -0,0 +1,126 @@ +use titan::{ByteStreamWriter, Payload, Result}; +use crate::battle::logic_game_object_ref::LogicGameObjectRef; +pub const COMPONENT_COMBAT: usize = 0; +pub const COMPONENT_MOVEMENT: usize = 1; +pub const COMPONENT_HITPOINT: usize = 2; +pub const COMPONENT_BUFF: usize = 3; +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct LogicCombatComponent { + pub flag_72: bool, + pub flag_73: bool, + pub field_48: i32, + pub field_52: i32, + pub field_60: i32, + pub field_64: i32, + pub field_68: i32, + pub target: LogicGameObjectRef, + pub attackers: Vec<(LogicGameObjectRef, i32)>, + pub observers: Vec, +} +impl LogicCombatComponent { + pub fn encode(&self, writer: &mut ByteStreamWriter) -> Result<()> { + writer.write_boolean(self.flag_72); + writer.write_boolean(self.flag_73); + writer.write_vint(self.field_48); + writer.write_vint(self.field_52); + writer.write_vint(self.field_60); + writer.write_vint(self.field_64); + writer.write_vint(self.field_68); + writer.write_vint(self.attackers.len() as i32); + writer.write_vint(self.observers.len() as i32); + self.target.encode(writer)?; + for (reference, value) in &self.attackers { + reference.encode(writer)?; + writer.write_vint(*value); + } + for reference in &self.observers { + reference.encode(writer)?; + } + Ok(()) + } +} +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct LogicHitpointComponent { + pub hitpoints: i32, + pub base_hitpoints: i32, + pub lifetime_damage: i32, + pub lifetime_accumulator: i32, + pub death_hit_angle: i32, +} +impl LogicHitpointComponent { + pub fn healthy(hitpoints: i32) -> Self { + Self { + hitpoints, + base_hitpoints: hitpoints, + ..Self::default() + } + } + pub fn encode(&self, writer: &mut ByteStreamWriter) -> Result<()> { + writer.write_vint(self.hitpoints); + writer.write_vint(self.base_hitpoints); + writer.write_vint(self.lifetime_damage); + if self.lifetime_damage != 0 { + writer.write_vint(self.lifetime_accumulator); + } + if self.hitpoints <= 0 { + writer.write_vint(self.death_hit_angle); + } + Ok(()) + } +} +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct LogicCharacterBuffComponent { + pub field_32: i32, + pub field_36: i32, + pub field_40: i32, + pub buff_type_count: usize, + pub field_52: i32, + pub field_56: i32, + pub field_60: i32, + pub field_64: i32, + pub field_68: i32, + pub field_72: i32, +} +impl LogicCharacterBuffComponent { + pub fn empty(buff_type_count: usize) -> Self { + Self { + buff_type_count, + ..Self::default() + } + } + pub fn encode(&self, writer: &mut ByteStreamWriter) -> Result<()> { + writer.write_vint(0); + writer.write_vint(0); + writer.write_vint(self.field_32); + writer.write_vint(self.field_36); + writer.write_vint(self.field_40); + for _ in 0..self.buff_type_count { + writer.write_vint(0); + } + for _ in 0..self.buff_type_count { + writer.write_boolean(false); + } + writer.write_vint(self.field_52); + writer.write_vint(self.field_56); + writer.write_vint(self.field_60); + writer.write_vint(self.field_64); + writer.write_vint(self.field_68); + writer.write_vint(self.field_72); + Ok(()) + } +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LogicComponent { + Combat(LogicCombatComponent), + Hitpoint(LogicHitpointComponent), + Buff(LogicCharacterBuffComponent), +} +impl LogicComponent { + pub fn encode(&self, writer: &mut ByteStreamWriter) -> Result<()> { + match self { + LogicComponent::Combat(component) => component.encode(writer), + LogicComponent::Hitpoint(component) => component.encode(writer), + LogicComponent::Buff(component) => component.encode(writer), + } + } +} diff --git a/crates/logic/src/battle/logic_game_mode.rs b/crates/logic/src/battle/logic_game_mode.rs new file mode 100644 index 0000000..aa1f5e2 --- /dev/null +++ b/crates/logic/src/battle/logic_game_mode.rs @@ -0,0 +1,47 @@ +use titan::{ByteStreamWriter, Payload, Result}; +use crate::battle::logic_battle::LogicBattle; +use crate::battle::logic_time::LogicTime; +use crate::battle::logic_tutorial_manager::LogicTutorialManager; +use crate::logic_random::LogicRandom; +use crate::model::LogicClientAvatar; +pub const SECTION_BATTLE: i32 = 11; +pub const SECTION_TUTORIAL: i32 = 12; +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct LogicGameMode { + pub server_tick: i32, + pub time: LogicTime, + pub random: LogicRandom, + pub random_seed: i32, + pub battle: LogicBattle, + pub avatars: Vec, + pub tutorial_manager: LogicTutorialManager, +} +impl LogicGameMode { + pub fn snapshot(&self) -> Result> { + let mut writer = ByteStreamWriter::new(); + self.encode(&mut writer)?; + Ok(writer.into_inner()) + } +} +impl Payload for LogicGameMode { + fn encode(&self, writer: &mut ByteStreamWriter) -> Result<()> { + writer.write_vint(self.server_tick); + writer.write_vint(0); + writer.write_vint(SECTION_BATTLE); + self.time.encode(writer)?; + self.random.encode(writer)?; + writer.write_vint(self.random_seed); + self.battle.encode(writer)?; + for avatar in &self.avatars { + avatar.encode(writer)?; + } + writer.write_vint(SECTION_TUTORIAL); + self.tutorial_manager.encode(writer)?; + writer.write_vint(0); + writer.write_vint(0); + Ok(()) + } + fn decode(_reader: &mut titan::ByteStreamReader<'_>) -> Result { + Err(titan::Error::Unsupported("LogicGameMode is encode only")) + } +} diff --git a/crates/logic/src/battle/logic_game_object.rs b/crates/logic/src/battle/logic_game_object.rs index 993c3d5..c61b4ab 100644 --- a/crates/logic/src/battle/logic_game_object.rs +++ b/crates/logic/src/battle/logic_game_object.rs @@ -19,18 +19,37 @@ impl LogicVector2 { #[derive(Debug, Default, Clone, PartialEq, Eq, Payload)] pub struct LogicGameObject { #[codec(vint)] - pub hitpoints: i32, + pub field_28: i32, #[codec(vint)] - pub owner_index: i32, + pub field_56: i32, pub position: LogicVector2, #[codec(vint)] - pub state: i32, + pub owner_index: i32, } -#[derive(Debug, Default, Clone, PartialEq, Eq)] +impl LogicGameObject { + pub fn encode_base(&self, writer: &mut titan::ByteStreamWriter) -> titan::Result<()> { + ::encode(self, writer) + } +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LogicObjectBody { + Character(Box), + Summoner(Box), +} +impl LogicObjectBody { + pub fn encode(&self, writer: &mut titan::ByteStreamWriter) -> titan::Result<()> { + match self { + LogicObjectBody::Character(character) => character.encode(writer), + LogicObjectBody::Summoner(summoner) => summoner.encode(writer), + } + } +} +#[derive(Debug, Clone, PartialEq, Eq)] pub struct LogicGameObjectEntry { pub data: LogicDataRef, pub global_id: LogicGameObjectRef, - pub object: LogicGameObject, + pub body: LogicObjectBody, + pub components: [Option; COMPONENT_PASSES], } impl LogicGameObjectEntry { pub fn object_type(&self) -> i32 { diff --git a/crates/logic/src/battle/logic_game_object_manager.rs b/crates/logic/src/battle/logic_game_object_manager.rs index 15c4914..ab84ced 100644 --- a/crates/logic/src/battle/logic_game_object_manager.rs +++ b/crates/logic/src/battle/logic_game_object_manager.rs @@ -1,9 +1,5 @@ use titan::{ByteStreamReader, ByteStreamWriter, Payload, Result}; -use crate::battle::logic_game_object::{ - LogicGameObject, LogicGameObjectEntry, COMPONENT_PASSES, OBJECT_TYPE_COUNT, -}; -use crate::battle::logic_game_object_ref::LogicGameObjectRef; -use crate::data::LogicDataRef; +use crate::battle::logic_game_object::{LogicGameObjectEntry, COMPONENT_PASSES, OBJECT_TYPE_COUNT}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct LogicGameObjectManager { pub instance_counters: [i32; OBJECT_TYPE_COUNT], @@ -44,38 +40,20 @@ impl Payload for LogicGameObjectManager { entry.global_id.encode(writer)?; } for entry in &self.objects { - entry.object.encode(writer)?; + entry.body.encode(writer)?; } - for _ in 0..COMPONENT_PASSES { - for _ in &self.objects {} + for pass in 0..COMPONENT_PASSES { + for entry in &self.objects { + if let Some(component) = &entry.components[pass] { + component.encode(writer)?; + } + } } Ok(()) } - fn decode(reader: &mut ByteStreamReader<'_>) -> Result { - let mut instance_counters = [0; OBJECT_TYPE_COUNT]; - for counter in instance_counters.iter_mut() { - *counter = reader.read_vint()?; - } - let count = reader.read_vint()?.max(0) as usize; - let mut data = Vec::with_capacity(count); - for _ in 0..count { - data.push(LogicDataRef::decode(reader)?); - } - let mut ids = Vec::with_capacity(count); - for _ in 0..count { - ids.push(LogicGameObjectRef::decode(reader)?); - } - let mut objects = Vec::with_capacity(count); - for index in 0..count { - objects.push(LogicGameObjectEntry { - data: data[index].clone(), - global_id: ids[index], - object: LogicGameObject::decode(reader)?, - }); - } - Ok(Self { - instance_counters, - objects, - }) + fn decode(_reader: &mut ByteStreamReader<'_>) -> Result { + Err(titan::Error::Unsupported( + "LogicGameObjectManager is encode only", + )) } } diff --git a/crates/logic/src/battle/logic_tilemap.rs b/crates/logic/src/battle/logic_tilemap.rs new file mode 100644 index 0000000..8a728a5 --- /dev/null +++ b/crates/logic/src/battle/logic_tilemap.rs @@ -0,0 +1,110 @@ +use std::collections::HashMap; +use std::path::Path; +pub const SUBTILE_UNITS: i32 = 500; +pub const SECTION_OBJECTS: &str = "Objects"; +pub const SECTION_MAP: &str = "Map"; +pub const OBJECT_KING_TOWER: &str = "KingTower"; +pub const OBJECT_PRINCESS_TOWER: &str = "PrincessTower"; +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LogicTilemap { + pub objects: Vec<(String, Vec<(i32, i32)>)>, + pub tiles: Vec>, +} +fn split_row(line: &str) -> Vec { + let mut cells = Vec::new(); + let mut current = String::new(); + let mut quoted = false; + for character in line.chars() { + match character { + '"' => quoted = !quoted, + ',' if !quoted => cells.push(std::mem::take(&mut current)), + '\r' => {} + _ => current.push(character), + } + } + cells.push(current); + cells +} +fn cell(row: &[String], index: usize) -> &str { + row.get(index).map(String::as_str).unwrap_or("").trim() +} +fn number(value: &str) -> Option { + value.parse::().ok() +} +impl LogicTilemap { + pub fn parse(source: &str) -> Self { + let mut tilemap = Self::default(); + let mut section = String::new(); + let mut object: Option = None; + for line in source.lines() { + let row = split_row(line); + let head = cell(&row, 0); + if !head.is_empty() { + section = head.to_owned(); + object = None; + continue; + } + match section.as_str() { + SECTION_OBJECTS => { + let name = cell(&row, 1); + if !name.is_empty() && name != "string" && name != "int" { + object = Some(name.to_owned()); + tilemap.objects.push((name.to_owned(), Vec::new())); + continue; + } + let (Some(x), Some(y)) = (number(cell(&row, 2)), number(cell(&row, 3))) else { + continue; + }; + if object.is_some() { + if let Some(entry) = tilemap.objects.last_mut() { + entry.1.push((x, y)); + } + } + } + SECTION_MAP => { + let values: Vec = row[1..] + .iter() + .map(|value| value.trim()) + .take_while(|value| !value.is_empty()) + .filter_map(number) + .collect(); + if !values.is_empty() { + tilemap.tiles.push(values); + } + } + _ => {} + } + } + tilemap + } + pub fn load(root: &Path, file_name: &str) -> std::io::Result { + let path = root.join(file_name); + let source = std::fs::read_to_string(&path)?; + Ok(Self::parse(&source)) + } + pub fn height(&self) -> i32 { + self.tiles.len() as i32 + } + pub fn width(&self) -> i32 { + self.tiles.iter().map(Vec::len).max().unwrap_or(0) as i32 + } + pub fn placements(&self, name: &str) -> &[(i32, i32)] { + self.objects + .iter() + .find(|(object, _)| object == name) + .map(|(_, positions)| positions.as_slice()) + .unwrap_or(&[]) + } + pub fn king_towers(&self) -> &[(i32, i32)] { + self.placements(OBJECT_KING_TOWER) + } + pub fn princess_towers(&self) -> &[(i32, i32)] { + self.placements(OBJECT_PRINCESS_TOWER) + } + pub fn counts(&self) -> HashMap<&str, usize> { + self.objects + .iter() + .map(|(name, positions)| (name.as_str(), positions.len())) + .collect() + } +} diff --git a/crates/logic/src/battle/mod.rs b/crates/logic/src/battle/mod.rs index d884159..08272a8 100644 --- a/crates/logic/src/battle/mod.rs +++ b/crates/logic/src/battle/mod.rs @@ -1,18 +1,31 @@ mod logic_battle; +mod logic_character; +mod logic_component; +mod logic_game_mode; mod logic_game_object; mod logic_game_object_manager; mod logic_game_object_ref; +mod logic_tilemap; mod logic_time; mod logic_tutorial_manager; pub use logic_battle::{ LogicBattle, BATTLE_INT_ARRAY, BATTLE_TRAILING_INTS, BATTLE_TYPE_NPC, BATTLE_TYPE_PVP, BATTLE_TYPE_REPLAY, }; +pub use logic_character::{ + LogicCharacter, LogicSummoner, LogicSummonerDeck, DEFAULT_SIZE, DIRECTION_BOTTOM, DIRECTION_TOP, +}; +pub use logic_component::{ + LogicCharacterBuffComponent, LogicCombatComponent, LogicComponent, LogicHitpointComponent, + COMPONENT_BUFF, COMPONENT_COMBAT, COMPONENT_HITPOINT, COMPONENT_MOVEMENT, +}; +pub use logic_game_mode::{LogicGameMode, SECTION_BATTLE, SECTION_TUTORIAL}; pub use logic_game_object::{ - LogicGameObject, LogicGameObjectEntry, LogicVector2, CHARACTER_OBJECT_TYPE, COMPONENT_PASSES, - OBJECT_TYPE_COUNT, + LogicGameObject, LogicGameObjectEntry, LogicObjectBody, LogicVector2, CHARACTER_OBJECT_TYPE, + COMPONENT_PASSES, OBJECT_TYPE_COUNT, }; pub use logic_game_object_manager::LogicGameObjectManager; pub use logic_game_object_ref::LogicGameObjectRef; +pub use logic_tilemap::{LogicTilemap, OBJECT_KING_TOWER, OBJECT_PRINCESS_TOWER, SUBTILE_UNITS}; pub use logic_time::LogicTime; pub use logic_tutorial_manager::LogicTutorialManager; diff --git a/crates/logic/src/messages/mod.rs b/crates/logic/src/messages/mod.rs index 7d3cde4..1160af0 100644 --- a/crates/logic/src/messages/mod.rs +++ b/crates/logic/src/messages/mod.rs @@ -10,6 +10,7 @@ mod login_failed; mod login_ok; mod out_of_sync; mod own_home_data; +mod sector_state; mod server_error; mod start_mission; pub use available_server_command::AvailableServerCommandMessage; @@ -29,6 +30,7 @@ pub use login_failed::{LoginFailedMessage, LoginFailureReason}; pub use login_ok::LoginOkMessage; pub use out_of_sync::OutOfSyncMessage; pub use own_home_data::OwnHomeDataMessage; +pub use sector_state::SectorStateMessage; pub use server_error::ServerErrorMessage; pub use start_mission::StartMissionMessage; pub mod message_type { diff --git a/crates/logic/src/messages/sector_state.rs b/crates/logic/src/messages/sector_state.rs new file mode 100644 index 0000000..6be4070 --- /dev/null +++ b/crates/logic/src/messages/sector_state.rs @@ -0,0 +1,12 @@ +use titan::Message; +#[derive(Debug, Default, Clone, PartialEq, Eq, Message)] +#[message(id = 21903, direction = "server", name = "SectorStateMessage")] +#[codec(raw)] +pub struct SectorStateMessage { + pub snapshot: Vec, +} +impl SectorStateMessage { + pub fn new(snapshot: Vec) -> Self { + Self { snapshot } + } +} diff --git a/crates/service-rpc/src/game.rs b/crates/service-rpc/src/game.rs index a06b1b2..953a0b8 100644 --- a/crates/service-rpc/src/game.rs +++ b/crates/service-rpc/src/game.rs @@ -43,6 +43,11 @@ pub enum GameRequest { #[serde(with = "crate::base64::serde_bytes")] payload: Vec, }, + StartMission { + account: AccountRef, + #[serde(with = "crate::base64::serde_bytes")] + payload: Vec, + }, Disconnect { account: AccountRef, }, @@ -72,6 +77,11 @@ pub trait GameApi: Send + Sync + 'static { kind: HomeRequestKind, ) -> RpcResult>; async fn client_capabilities(&self, account: AccountRef, ping_ms: i32) -> RpcResult<()>; + async fn start_mission( + &self, + account: AccountRef, + payload: Vec, + ) -> RpcResult>; async fn end_client_turn( &self, account: AccountRef, diff --git a/crates/titan-derive/src/codec.rs b/crates/titan-derive/src/codec.rs index c777470..f244d13 100644 --- a/crates/titan-derive/src/codec.rs +++ b/crates/titan-derive/src/codec.rs @@ -36,6 +36,9 @@ impl Default for FieldSpec { } pub fn expand_payload(input: &DeriveInput) -> Result { let ident = &input.ident; + if has_raw(input)? { + return expand_raw(input); + } let partial = has_partial(input)?; let fields = match &input.data { Data::Struct(data) => match &data.fields { @@ -124,21 +127,61 @@ pub fn expand_payload(input: &DeriveInput) -> Result { } }) } +fn expand_raw(input: &DeriveInput) -> Result { + let ident = &input.ident; + let Data::Struct(data) = &input.data else { + return Err(Error::new_spanned(ident, "#[codec(raw)] needs a struct")); + }; + let Fields::Named(named) = &data.fields else { + return Err(Error::new_spanned( + ident, + "#[codec(raw)] needs one named field", + )); + }; + let fields: Vec<_> = named.named.iter().collect(); + if fields.len() != 1 { + return Err(Error::new_spanned( + ident, + "#[codec(raw)] needs exactly one field", + )); + } + let name = fields[0].ident.as_ref().expect("named field"); + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + Ok(quote! { + impl #impl_generics ::titan::message::Payload for #ident #ty_generics #where_clause { + fn encode(&self, writer: &mut ::titan::io::ByteStreamWriter) -> ::titan::error::Result<()> { + writer.write_raw(&self.#name); + ::core::result::Result::Ok(()) + } + fn decode(reader: &mut ::titan::io::ByteStreamReader<'_>) -> ::titan::error::Result { + ::core::result::Result::Ok(Self { #name: reader.read_remaining() }) + } + } + }) +} fn has_partial(input: &DeriveInput) -> Result { - let mut partial = false; + container_flag(input, "partial") +} +fn has_raw(input: &DeriveInput) -> Result { + container_flag(input, "raw") +} +fn container_flag(input: &DeriveInput, wanted: &str) -> Result { + let mut found = false; for attr in &input.attrs { if !attr.path().is_ident("codec") { continue; } attr.parse_nested_meta(|meta| { - if meta.path.is_ident("partial") { - partial = true; + if meta.path.is_ident("partial") || meta.path.is_ident("raw") { + if meta.path.is_ident(wanted) { + found = true; + } return Ok(()); } Err(meta.error("unsupported container level #[codec(...)] key")) })?; } - Ok(partial) + Ok(found) } fn parse_field_spec(attrs: &[syn::Attribute]) -> Result { let mut spec = FieldSpec::default(); diff --git a/crates/titan/src/error.rs b/crates/titan/src/error.rs index ea52da3..d529c1b 100644 --- a/crates/titan/src/error.rs +++ b/crates/titan/src/error.rs @@ -26,6 +26,8 @@ pub enum Error { UnknownCommandType(i32), #[error("trailing {0} unread byte(s)")] TrailingBytes(usize), + #[error("{0}")] + Unsupported(&'static str), #[error("io: {0}")] Io(#[from] std::io::Error), } diff --git a/crates/titan/src/io/reader.rs b/crates/titan/src/io/reader.rs index f44fe67..550a8f1 100644 --- a/crates/titan/src/io/reader.rs +++ b/crates/titan/src/io/reader.rs @@ -151,6 +151,10 @@ impl<'a> ByteStreamReader<'a> { } Ok(Some(self.take(length as usize)?.to_vec())) } + pub fn read_remaining(&mut self) -> Vec { + let rest = self.remaining(); + self.take(rest).map(<[u8]>::to_vec).unwrap_or_default() + } pub fn expect_consumed(&self) -> Result<()> { match self.remaining() { 0 => Ok(()), diff --git a/crates/titan/src/io/writer.rs b/crates/titan/src/io/writer.rs index 38328be..c89234c 100644 --- a/crates/titan/src/io/writer.rs +++ b/crates/titan/src/io/writer.rs @@ -156,6 +156,9 @@ impl ByteStreamWriter { self.buffer.extend_from_slice(bytes); Ok(()) } + pub fn write_raw(&mut self, value: &[u8]) { + self.buffer.extend_from_slice(value); + } pub fn write_bytes(&mut self, value: Option<&[u8]>) { match value { None => self.write_int_raw(-1),