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.
This commit is contained in:
WiseDev 2026-08-23 10:24:20 +03:00
parent d439027735
commit 774e7c60cf
20 changed files with 874 additions and 62 deletions

View file

@ -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<Path>) -> 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<LogicTilemap> {
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<LogicClientAvatar>,
random_seed: i32,
) -> std::io::Result<LogicGameMode> {
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<LogicGameObjectRef>; 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()
})
}
}

View file

@ -1,3 +1,4 @@
pub mod battle;
pub mod catalog; pub mod catalog;
pub mod config; pub mod config;
pub mod home; pub mod home;
@ -7,6 +8,7 @@ pub mod service;
pub mod shop; pub mod shop;
pub mod store; pub mod store;
pub mod time; pub mod time;
pub use battle::BattleBuilder;
pub use catalog::{Catalog, ARENA_FALLBACK_INSTANCE, GOLD_RESOURCE_FALLBACK_INSTANCE}; pub use catalog::{Catalog, ARENA_FALLBACK_INSTANCE, GOLD_RESOURCE_FALLBACK_INSTANCE};
pub use config::{CardRef, DataSelector, GameConfig, StarterProfile}; pub use config::{CardRef, DataSelector, GameConfig, StarterProfile};
pub use home::{build_avatar, build_home}; pub use home::{build_avatar, build_home};

View file

@ -1,15 +1,19 @@
use std::sync::Arc; use std::sync::Arc;
use logic::table;
use logic::{ use logic::{
AvailableServerCommandMessage, EndClientTurnMessage, LogicCommandManager, LogicDataRef, AvailableServerCommandMessage, EndClientTurnMessage, LogicCommandManager, LogicDataRef,
LogicShopSeedChangedCommand, OutOfSyncMessage, OwnHomeDataMessage, LogicShopSeedChangedCommand, OutOfSyncMessage, OwnHomeDataMessage, SectorStateMessage,
StartMissionMessage,
}; };
use service_rpc::{ use service_rpc::{
AccountRef, GameApi, GameRequest, GameResponse, HomeRequestKind, RpcError, RpcResult, AccountRef, GameApi, GameRequest, GameResponse, HomeRequestKind, RpcError, RpcResult,
RpcService, WireMessage, RpcService, WireMessage,
}; };
use titan::{Message, MessageMeta, Payload}; use titan::{Message, MessageMeta, Payload};
use crate::battle::BattleBuilder;
use crate::catalog::Catalog; use crate::catalog::Catalog;
use crate::config::GameConfig; use crate::config::GameConfig;
use crate::home::build_avatar;
use crate::home_mode::{available_server_command, HomeModeRegistry}; use crate::home_mode::{available_server_command, HomeModeRegistry};
use crate::rewards::RewardRoller; use crate::rewards::RewardRoller;
use crate::shop::{ShopCatalog, ShopCycle}; use crate::shop::{ShopCatalog, ShopCycle};
@ -20,6 +24,7 @@ pub struct GameService {
profiles: ProfileStore, profiles: ProfileStore,
catalog: Arc<Catalog>, catalog: Arc<Catalog>,
shop: Arc<ShopCatalog>, shop: Arc<ShopCatalog>,
battles: Arc<BattleBuilder>,
sessions: HomeModeRegistry, sessions: HomeModeRegistry,
} }
impl GameService { impl GameService {
@ -31,6 +36,9 @@ impl GameService {
.map_err(connect_failed)?; .map_err(connect_failed)?;
let profile_count = profiles.count().await?; let profile_count = profiles.count().await?;
let shop = Arc::new(ShopCatalog::load(config.shop_path.as_deref())); 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() { for offer in shop.describe() {
tracing::debug!(offer, "shop offer"); tracing::debug!(offer, "shop offer");
} }
@ -46,6 +54,7 @@ impl GameService {
profiles, profiles,
catalog, catalog,
shop, shop,
battles,
sessions: HomeModeRegistry::default(), sessions: HomeModeRegistry::default(),
})) }))
} }
@ -162,6 +171,43 @@ impl GameApi for GameService {
tracing::debug!(%account, ping_ms, "client capabilities"); tracing::debug!(%account, ping_ms, "client capabilities");
Ok(()) Ok(())
} }
async fn start_mission(
&self,
account: AccountRef,
payload: Vec<u8>,
) -> RpcResult<Vec<WireMessage>> {
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( async fn end_client_turn(
&self, &self,
account: AccountRef, account: AccountRef,
@ -255,6 +301,9 @@ impl RpcService for GameService {
self.client_capabilities(account, ping_ms).await?; self.client_capabilities(account, ping_ms).await?;
Ok(GameResponse::Empty) Ok(GameResponse::Empty)
} }
GameRequest::StartMission { account, payload } => Ok(GameResponse::messages(
self.start_mission(account, payload).await?,
)),
GameRequest::EndClientTurn { account, payload } => Ok(GameResponse::messages( GameRequest::EndClientTurn { account, payload } => Ok(GameResponse::messages(
self.end_client_turn(account, payload).await?, self.end_client_turn(account, payload).await?,
)), )),

View file

@ -89,6 +89,17 @@ impl GameApi for RemoteGame {
.await?; .await?;
Ok(()) Ok(())
} }
async fn start_mission(
&self,
account: AccountRef,
payload: Vec<u8>,
) -> RpcResult<Vec<WireMessage>> {
Ok(self
.client
.call(&GameRequest::StartMission { account, payload })
.await?
.into_messages())
}
async fn end_client_turn( async fn end_client_turn(
&self, &self,
account: AccountRef, account: AccountRef,

View file

@ -66,6 +66,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let buy_chest = std::env::args() let buy_chest = std::env::args()
.skip_while(|arg| arg != "--buy-chest") .skip_while(|arg| arg != "--buy-chest")
.nth(1); .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()); let csv_root = std::env::var("SCROLL_CSV_ROOT").unwrap_or_else(|_| "assets".to_owned());
match LogicDataTables::load_from_dir(&csv_root) { match LogicDataTables::load_from_dir(&csv_root) {
Ok(tables) => { Ok(tables) => {
@ -181,7 +184,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let checksum = home.home.chest_id_counter let checksum = home.home.chest_id_counter
+ ((home.home.spell_collection.spells.len() as i32) << 16) + ((home.home.spell_collection.spells.len() as i32) << 16)
+ i32::from(desync); + 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); let chest = logic::LogicDataRef::by_name(logic::table::TREASURE_CHESTS, name);
match chest.as_treasure_chest() { match chest.as_treasure_chest() {
None => println!("!! no treasure chest named {name}"), None => println!("!! no treasure chest named {name}"),
@ -247,6 +259,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
probe.send(&KeepAliveMessage::default()).await?; probe.send(&KeepAliveMessage::default()).await?;
println!("-> 10108 KeepAliveMessage"); println!("-> 10108 KeepAliveMessage");
} }
21903 => {
println!("<- 21903 SectorStateMessage ({} bytes)", payload.len());
println!("lobby reached, battle snapshot received");
return Ok(());
}
message_type::KEEP_ALIVE_SERVER => { message_type::KEEP_ALIVE_SERVER => {
println!("<- 20108 KeepAliveServerMessage"); println!("<- 20108 KeepAliveServerMessage");
println!("lobby reached, probe done"); println!("lobby reached, probe done");

View file

@ -2,7 +2,7 @@ use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
use logic::{ use logic::{
message_type, ClientCapabilitiesMessage, GoHomeMessage, KeepAliveServerMessage, message_type, ClientCapabilitiesMessage, GoHomeMessage, KeepAliveServerMessage,
LoginFailedMessage, LoginMessage, LoginOkMessage, ServerErrorMessage, StartMissionMessage, LoginFailedMessage, LoginMessage, LoginOkMessage, ServerErrorMessage,
}; };
use service_rpc::{ use service_rpc::{
AccountRef, DeviceInfo, HomeRequestKind, LoginOutcome, Session as AuthSession, WireMessage, AccountRef, DeviceInfo, HomeRequestKind, LoginOutcome, Session as AuthSession, WireMessage,
@ -89,20 +89,25 @@ impl MessageManager {
self.push_home(HomeRequestKind::GoHome).await self.push_home(HomeRequestKind::GoHome).await
} }
message_type::START_MISSION => { message_type::START_MISSION => {
let mission = incoming let Some(account) = self.account else {
.downcast::<StartMissionMessage>() return Ok(());
.expect("start mission message"); };
tracing::warn!( match self
peer = %self.peer, .backends
npc = %mission.npc, .game
"client asked for an npc mission, which this server does not implement; \ .start_mission(account, incoming.payload)
give the avatar a non zero npc_win_count so it goes to the home screen instead" .await
); {
self.sender Ok(replies) => self.push_wire_messages(replies).await?,
.send_message(&ServerErrorMessage::new( Err(error) => {
"npc missions are not implemented by this server", tracing::warn!(peer = %self.peer, %error, "could not start the mission");
)) self.sender
.await?; .send_message(&ServerErrorMessage::new(
"this server could not build the battle",
))
.await?;
}
}
Ok(()) Ok(())
} }
message_type::END_CLIENT_TURN => { message_type::END_CLIENT_TURN => {

View file

@ -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<i32>,
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<LogicSummonerDeck>,
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<i32>,
pub used_pile: Vec<i32>,
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(())
}
}

View file

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

View file

@ -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<LogicClientAvatar>,
pub tutorial_manager: LogicTutorialManager,
}
impl LogicGameMode {
pub fn snapshot(&self) -> Result<Vec<u8>> {
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<Self> {
Err(titan::Error::Unsupported("LogicGameMode is encode only"))
}
}

View file

@ -19,18 +19,37 @@ impl LogicVector2 {
#[derive(Debug, Default, Clone, PartialEq, Eq, Payload)] #[derive(Debug, Default, Clone, PartialEq, Eq, Payload)]
pub struct LogicGameObject { pub struct LogicGameObject {
#[codec(vint)] #[codec(vint)]
pub hitpoints: i32, pub field_28: i32,
#[codec(vint)] #[codec(vint)]
pub owner_index: i32, pub field_56: i32,
pub position: LogicVector2, pub position: LogicVector2,
#[codec(vint)] #[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<()> {
<Self as Payload>::encode(self, writer)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LogicObjectBody {
Character(Box<crate::battle::logic_character::LogicCharacter>),
Summoner(Box<crate::battle::logic_character::LogicSummoner>),
}
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 struct LogicGameObjectEntry {
pub data: LogicDataRef, pub data: LogicDataRef,
pub global_id: LogicGameObjectRef, pub global_id: LogicGameObjectRef,
pub object: LogicGameObject, pub body: LogicObjectBody,
pub components: [Option<crate::battle::logic_component::LogicComponent>; COMPONENT_PASSES],
} }
impl LogicGameObjectEntry { impl LogicGameObjectEntry {
pub fn object_type(&self) -> i32 { pub fn object_type(&self) -> i32 {

View file

@ -1,9 +1,5 @@
use titan::{ByteStreamReader, ByteStreamWriter, Payload, Result}; use titan::{ByteStreamReader, ByteStreamWriter, Payload, Result};
use crate::battle::logic_game_object::{ use crate::battle::logic_game_object::{LogicGameObjectEntry, COMPONENT_PASSES, OBJECT_TYPE_COUNT};
LogicGameObject, LogicGameObjectEntry, COMPONENT_PASSES, OBJECT_TYPE_COUNT,
};
use crate::battle::logic_game_object_ref::LogicGameObjectRef;
use crate::data::LogicDataRef;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogicGameObjectManager { pub struct LogicGameObjectManager {
pub instance_counters: [i32; OBJECT_TYPE_COUNT], pub instance_counters: [i32; OBJECT_TYPE_COUNT],
@ -44,38 +40,20 @@ impl Payload for LogicGameObjectManager {
entry.global_id.encode(writer)?; entry.global_id.encode(writer)?;
} }
for entry in &self.objects { for entry in &self.objects {
entry.object.encode(writer)?; entry.body.encode(writer)?;
} }
for _ in 0..COMPONENT_PASSES { for pass in 0..COMPONENT_PASSES {
for _ in &self.objects {} for entry in &self.objects {
if let Some(component) = &entry.components[pass] {
component.encode(writer)?;
}
}
} }
Ok(()) Ok(())
} }
fn decode(reader: &mut ByteStreamReader<'_>) -> Result<Self> { fn decode(_reader: &mut ByteStreamReader<'_>) -> Result<Self> {
let mut instance_counters = [0; OBJECT_TYPE_COUNT]; Err(titan::Error::Unsupported(
for counter in instance_counters.iter_mut() { "LogicGameObjectManager is encode only",
*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,
})
} }
} }

View file

@ -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<Vec<i32>>,
}
fn split_row(line: &str) -> Vec<String> {
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<i32> {
value.parse::<i32>().ok()
}
impl LogicTilemap {
pub fn parse(source: &str) -> Self {
let mut tilemap = Self::default();
let mut section = String::new();
let mut object: Option<String> = 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<i32> = 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<Self> {
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()
}
}

View file

@ -1,18 +1,31 @@
mod logic_battle; mod logic_battle;
mod logic_character;
mod logic_component;
mod logic_game_mode;
mod logic_game_object; mod logic_game_object;
mod logic_game_object_manager; mod logic_game_object_manager;
mod logic_game_object_ref; mod logic_game_object_ref;
mod logic_tilemap;
mod logic_time; mod logic_time;
mod logic_tutorial_manager; mod logic_tutorial_manager;
pub use logic_battle::{ pub use logic_battle::{
LogicBattle, BATTLE_INT_ARRAY, BATTLE_TRAILING_INTS, BATTLE_TYPE_NPC, BATTLE_TYPE_PVP, LogicBattle, BATTLE_INT_ARRAY, BATTLE_TRAILING_INTS, BATTLE_TYPE_NPC, BATTLE_TYPE_PVP,
BATTLE_TYPE_REPLAY, 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::{ pub use logic_game_object::{
LogicGameObject, LogicGameObjectEntry, LogicVector2, CHARACTER_OBJECT_TYPE, COMPONENT_PASSES, LogicGameObject, LogicGameObjectEntry, LogicObjectBody, LogicVector2, CHARACTER_OBJECT_TYPE,
OBJECT_TYPE_COUNT, COMPONENT_PASSES, OBJECT_TYPE_COUNT,
}; };
pub use logic_game_object_manager::LogicGameObjectManager; pub use logic_game_object_manager::LogicGameObjectManager;
pub use logic_game_object_ref::LogicGameObjectRef; 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_time::LogicTime;
pub use logic_tutorial_manager::LogicTutorialManager; pub use logic_tutorial_manager::LogicTutorialManager;

View file

@ -10,6 +10,7 @@ mod login_failed;
mod login_ok; mod login_ok;
mod out_of_sync; mod out_of_sync;
mod own_home_data; mod own_home_data;
mod sector_state;
mod server_error; mod server_error;
mod start_mission; mod start_mission;
pub use available_server_command::AvailableServerCommandMessage; pub use available_server_command::AvailableServerCommandMessage;
@ -29,6 +30,7 @@ pub use login_failed::{LoginFailedMessage, LoginFailureReason};
pub use login_ok::LoginOkMessage; pub use login_ok::LoginOkMessage;
pub use out_of_sync::OutOfSyncMessage; pub use out_of_sync::OutOfSyncMessage;
pub use own_home_data::OwnHomeDataMessage; pub use own_home_data::OwnHomeDataMessage;
pub use sector_state::SectorStateMessage;
pub use server_error::ServerErrorMessage; pub use server_error::ServerErrorMessage;
pub use start_mission::StartMissionMessage; pub use start_mission::StartMissionMessage;
pub mod message_type { pub mod message_type {

View file

@ -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<u8>,
}
impl SectorStateMessage {
pub fn new(snapshot: Vec<u8>) -> Self {
Self { snapshot }
}
}

View file

@ -43,6 +43,11 @@ pub enum GameRequest {
#[serde(with = "crate::base64::serde_bytes")] #[serde(with = "crate::base64::serde_bytes")]
payload: Vec<u8>, payload: Vec<u8>,
}, },
StartMission {
account: AccountRef,
#[serde(with = "crate::base64::serde_bytes")]
payload: Vec<u8>,
},
Disconnect { Disconnect {
account: AccountRef, account: AccountRef,
}, },
@ -72,6 +77,11 @@ pub trait GameApi: Send + Sync + 'static {
kind: HomeRequestKind, kind: HomeRequestKind,
) -> RpcResult<Vec<WireMessage>>; ) -> RpcResult<Vec<WireMessage>>;
async fn client_capabilities(&self, account: AccountRef, ping_ms: i32) -> RpcResult<()>; async fn client_capabilities(&self, account: AccountRef, ping_ms: i32) -> RpcResult<()>;
async fn start_mission(
&self,
account: AccountRef,
payload: Vec<u8>,
) -> RpcResult<Vec<WireMessage>>;
async fn end_client_turn( async fn end_client_turn(
&self, &self,
account: AccountRef, account: AccountRef,

View file

@ -36,6 +36,9 @@ impl Default for FieldSpec {
} }
pub fn expand_payload(input: &DeriveInput) -> Result<TokenStream> { pub fn expand_payload(input: &DeriveInput) -> Result<TokenStream> {
let ident = &input.ident; let ident = &input.ident;
if has_raw(input)? {
return expand_raw(input);
}
let partial = has_partial(input)?; let partial = has_partial(input)?;
let fields = match &input.data { let fields = match &input.data {
Data::Struct(data) => match &data.fields { Data::Struct(data) => match &data.fields {
@ -124,21 +127,61 @@ pub fn expand_payload(input: &DeriveInput) -> Result<TokenStream> {
} }
}) })
} }
fn expand_raw(input: &DeriveInput) -> Result<TokenStream> {
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<Self> {
::core::result::Result::Ok(Self { #name: reader.read_remaining() })
}
}
})
}
fn has_partial(input: &DeriveInput) -> Result<bool> { fn has_partial(input: &DeriveInput) -> Result<bool> {
let mut partial = false; container_flag(input, "partial")
}
fn has_raw(input: &DeriveInput) -> Result<bool> {
container_flag(input, "raw")
}
fn container_flag(input: &DeriveInput, wanted: &str) -> Result<bool> {
let mut found = false;
for attr in &input.attrs { for attr in &input.attrs {
if !attr.path().is_ident("codec") { if !attr.path().is_ident("codec") {
continue; continue;
} }
attr.parse_nested_meta(|meta| { attr.parse_nested_meta(|meta| {
if meta.path.is_ident("partial") { if meta.path.is_ident("partial") || meta.path.is_ident("raw") {
partial = true; if meta.path.is_ident(wanted) {
found = true;
}
return Ok(()); return Ok(());
} }
Err(meta.error("unsupported container level #[codec(...)] key")) Err(meta.error("unsupported container level #[codec(...)] key"))
})?; })?;
} }
Ok(partial) Ok(found)
} }
fn parse_field_spec(attrs: &[syn::Attribute]) -> Result<FieldSpec> { fn parse_field_spec(attrs: &[syn::Attribute]) -> Result<FieldSpec> {
let mut spec = FieldSpec::default(); let mut spec = FieldSpec::default();

View file

@ -26,6 +26,8 @@ pub enum Error {
UnknownCommandType(i32), UnknownCommandType(i32),
#[error("trailing {0} unread byte(s)")] #[error("trailing {0} unread byte(s)")]
TrailingBytes(usize), TrailingBytes(usize),
#[error("{0}")]
Unsupported(&'static str),
#[error("io: {0}")] #[error("io: {0}")]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
} }

View file

@ -151,6 +151,10 @@ impl<'a> ByteStreamReader<'a> {
} }
Ok(Some(self.take(length as usize)?.to_vec())) Ok(Some(self.take(length as usize)?.to_vec()))
} }
pub fn read_remaining(&mut self) -> Vec<u8> {
let rest = self.remaining();
self.take(rest).map(<[u8]>::to_vec).unwrap_or_default()
}
pub fn expect_consumed(&self) -> Result<()> { pub fn expect_consumed(&self) -> Result<()> {
match self.remaining() { match self.remaining() {
0 => Ok(()), 0 => Ok(()),

View file

@ -156,6 +156,9 @@ impl ByteStreamWriter {
self.buffer.extend_from_slice(bytes); self.buffer.extend_from_slice(bytes);
Ok(()) Ok(())
} }
pub fn write_raw(&mut self, value: &[u8]) {
self.buffer.extend_from_slice(value);
}
pub fn write_bytes(&mut self, value: Option<&[u8]>) { pub fn write_bytes(&mut self, value: Option<&[u8]>) {
match value { match value {
None => self.write_int_raw(-1), None => self.write_int_raw(-1),