From 1730081ca1266c7c10bef772f7d679982f48eb92 Mon Sep 17 00:00:00 2001 From: The Dax Date: Mon, 23 Mar 2026 06:12:26 -0400 Subject: [PATCH] Initial support for cross-world linkshells, without support for management features for now Creating, disbanding, inviting and kicking will be added in future commits. --- core/src/ipc/chat/common.rs | 4 +- core/src/ipc/zone/server/linkshell.rs | 24 ++- core/src/ipc/zone/server/mod.rs | 9 +- servers/world/migrations/current_world/up.sql | 119 +++++++------ servers/world/src/chat_connection.rs | 70 +++++++- servers/world/src/common.rs | 19 +- servers/world/src/database/mod.rs | 133 +++++++++++++- servers/world/src/database/models.rs | 24 +++ servers/world/src/database/schema.rs | 19 ++ servers/world/src/main.rs | 50 ++++-- servers/world/src/server/chat.rs | 54 +++++- servers/world/src/server/mod.rs | 9 +- servers/world/src/server/network.rs | 45 ++++- servers/world/src/server/social.rs | 70 +++++++- servers/world/src/zone_connection/mod.rs | 11 +- servers/world/src/zone_connection/social.rs | 163 ++++++++++++++++-- 16 files changed, 721 insertions(+), 102 deletions(-) diff --git a/core/src/ipc/chat/common.rs b/core/src/ipc/chat/common.rs index 5da9d54c..19fcd941 100644 --- a/core/src/ipc/chat/common.rs +++ b/core/src/ipc/chat/common.rs @@ -2,7 +2,7 @@ use binrw::binrw; #[binrw] #[brw(repr = u16)] -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Copy, Debug, Default, PartialEq)] pub enum ChatChannelType { #[default] None = 0, @@ -20,7 +20,7 @@ pub enum ChatChannelType { } #[binrw] -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct ChatChannel { pub channel_number: u32, pub channel_type: ChatChannelType, diff --git a/core/src/ipc/zone/server/linkshell.rs b/core/src/ipc/zone/server/linkshell.rs index ae477898..dd4c7588 100644 --- a/core/src/ipc/zone/server/linkshell.rs +++ b/core/src/ipc/zone/server/linkshell.rs @@ -1,13 +1,14 @@ use binrw::binrw; +use strum_macros::FromRepr; +use crate::common::{read_bool_from, write_bool_as}; use crate::ipc::zone::server::{CHAR_NAME_MAX_LENGTH, ChatChannel, read_string, write_string}; /// Represents one entry in the Linkshells opcode. #[binrw] #[derive(Clone, Debug, Default)] pub struct LinkshellEntry { - pub linkshell_id: u64, - pub chatchannel_id: ChatChannel, + pub common_ids: CWLSCommonIdentifiers, pub unk1: u32, #[brw(pad_size_to = CHAR_NAME_MAX_LENGTH)] #[br(count = CHAR_NAME_MAX_LENGTH)] @@ -26,14 +27,23 @@ impl LinkshellEntry { #[binrw] #[derive(Debug, Default, Clone)] pub struct CWLSMemberListEntry { - content_id: u64, - unk1: [u8; 14], + pub content_id: u64, + pub unk_timestamp: u32, // Possibly when this member joined, or last had their rank changed? + pub home_world_id: u16, + pub current_world_id: u16, + pub zone_id: u16, + pub rank: CWLSPermissionRank, + pub unk1: u8, + #[br(map = read_bool_from::)] + #[bw(map = write_bool_as::)] + pub is_online: bool, + pub unk2: u8, // TODO: What is this? It seems to always be 1, but changing it makes no apparent difference. #[brw(pad_after = 2)] // Seems to be empty/zeroes #[brw(pad_size_to = CHAR_NAME_MAX_LENGTH)] #[br(count = CHAR_NAME_MAX_LENGTH)] #[br(map = read_string)] #[bw(map = write_string)] - name: String, + pub name: String, } impl CWLSMemberListEntry { @@ -45,7 +55,7 @@ impl CWLSMemberListEntry { #[binrw] #[brw(repr = u8)] #[repr(u8)] -#[derive(Debug, Default, Clone)] +#[derive(Clone, Copy, Debug, Default, FromRepr, PartialEq)] pub enum CWLSPermissionRank { #[default] /// The player has been invited but has yet to answer the invitation. @@ -66,7 +76,7 @@ pub struct CWLSCommonIdentifiers { pub linkshell_chat_id: ChatChannel, } -/// Represents the CWLS's name permission rank info. This was added to help reduce copy paste in CrossworldLinkshell & CrossworldLinkshellEx. +/// Represents the CWLS's name & permission rank info. This was added to help reduce copy paste in CrossworldLinkshell & CrossworldLinkshellEx. #[binrw] #[derive(Debug, Default, Clone)] pub struct CWLSCommon { diff --git a/core/src/ipc/zone/server/mod.rs b/core/src/ipc/zone/server/mod.rs index 4732acb3..5adbbfc3 100644 --- a/core/src/ipc/zone/server/mod.rs +++ b/core/src/ipc/zone/server/mod.rs @@ -136,7 +136,8 @@ pub use marketboard::MarketBoardItem; mod linkshell; pub use linkshell::{ - CWLSMemberListEntry, CrossworldLinkshell, CrossworldLinkshellEx, LinkshellEntry, + CWLSMemberListEntry, CWLSPermissionRank, CrossworldLinkshell, CrossworldLinkshellEx, + LinkshellEntry, }; mod spawn_treasure; @@ -1013,11 +1014,13 @@ pub enum ServerZoneIpcData { }, CrossworldLinkshellMemberList { linkshell_id: u64, - #[brw(pad_after = 6)] // Seems to be empty/zeroes + #[brw(pad_after = 2)] // Seems to be empty/zeroes sequence: u16, + next_index: u16, + current_index: u16, #[br(count = CWLSMemberListEntry::COUNT)] #[brw(pad_size_to = CWLSMemberListEntry::COUNT * CWLSMemberListEntry::SIZE)] - linkshells: Vec, + members: Vec, }, SpawnTreasure(SpawnTreasure), OpenedTreasure { diff --git a/servers/world/migrations/current_world/up.sql b/servers/world/migrations/current_world/up.sql index 6a88837f..15ad8bdc 100644 --- a/servers/world/migrations/current_world/up.sql +++ b/servers/world/migrations/current_world/up.sql @@ -8,32 +8,18 @@ CREATE TABLE `friends`( `is_pending` BOOL NOT NULL ); -CREATE TABLE `unlock`( - `content_id` BIGINT NOT NULL PRIMARY KEY, - `unlocks` TEXT NOT NULL, - `seen_active_help` TEXT NOT NULL, - `minions` TEXT NOT NULL, - `mounts` TEXT NOT NULL, - `orchestrion_rolls` TEXT NOT NULL, - `cutscene_seen` TEXT NOT NULL, - `ornaments` TEXT NOT NULL, - `caught_fish` TEXT NOT NULL, - `caught_spearfish` TEXT NOT NULL, - `adventures` TEXT NOT NULL, - `triple_triad_cards` TEXT NOT NULL, - `glasses_styles` TEXT NOT NULL, - `chocobo_taxi_stands` TEXT NOT NULL, - `titles` TEXT NOT NULL, - FOREIGN KEY (`content_id`) REFERENCES `character`(`content_id`) +CREATE TABLE `linkshell_members`( + `id` BIGINT NOT NULL PRIMARY KEY, + `content_id` BIGINT NOT NULL, + `linkshell_id` BIGINT NOT NULL, + `invite_time` BIGINT NOT NULL, + `rank` INTEGER NOT NULL ); -CREATE TABLE `classjob`( +CREATE TABLE `quest`( `content_id` BIGINT NOT NULL PRIMARY KEY, - `current_class` INTEGER NOT NULL, - `levels` TEXT NOT NULL, - `exp` TEXT NOT NULL, - `first_class` INTEGER NOT NULL, - `rested_exp` INTEGER NOT NULL, + `completed` TEXT NOT NULL, + `active` TEXT NOT NULL, FOREIGN KEY (`content_id`) REFERENCES `character`(`content_id`) ); @@ -43,27 +29,36 @@ CREATE TABLE `companion`( FOREIGN KEY (`content_id`) REFERENCES `character`(`content_id`) ); -CREATE TABLE `quest`( +CREATE TABLE `linkshells`( + `id` BIGINT NOT NULL PRIMARY KEY, + `name` TEXT NOT NULL, + `creation_time` BIGINT NOT NULL, + `is_crossworld` BOOL NOT NULL +); + +CREATE TABLE `inventory`( `content_id` BIGINT NOT NULL PRIMARY KEY, - `completed` TEXT NOT NULL, - `active` TEXT NOT NULL, + `contents` TEXT NOT NULL, FOREIGN KEY (`content_id`) REFERENCES `character`(`content_id`) ); -CREATE TABLE `search_info`( +CREATE TABLE `classjob`( `content_id` BIGINT NOT NULL PRIMARY KEY, - `online_status` INTEGER NOT NULL, - `comment` TEXT NOT NULL, - `selected_languages` INTEGER NOT NULL, + `current_class` INTEGER NOT NULL, + `levels` TEXT NOT NULL, + `exp` TEXT NOT NULL, + `first_class` INTEGER NOT NULL, + `rested_exp` INTEGER NOT NULL, FOREIGN KEY (`content_id`) REFERENCES `character`(`content_id`) ); -CREATE TABLE `customize`( +CREATE TABLE `character`( `content_id` BIGINT NOT NULL PRIMARY KEY, - `chara_make` TEXT NOT NULL, - `city_state` INTEGER NOT NULL, - `remake_mode` INTEGER NOT NULL, - FOREIGN KEY (`content_id`) REFERENCES `character`(`content_id`) + `service_account_id` BIGINT NOT NULL, + `actor_id` BIGINT NOT NULL, + `gm_rank` INTEGER NOT NULL, + `name` TEXT NOT NULL, + `time_played_minutes` BIGINT NOT NULL ); CREATE TABLE `content`( @@ -87,6 +82,25 @@ CREATE TABLE `content`( FOREIGN KEY (`content_id`) REFERENCES `character`(`content_id`) ); +CREATE TABLE `unlock`( + `content_id` BIGINT NOT NULL PRIMARY KEY, + `unlocks` TEXT NOT NULL, + `seen_active_help` TEXT NOT NULL, + `minions` TEXT NOT NULL, + `mounts` TEXT NOT NULL, + `orchestrion_rolls` TEXT NOT NULL, + `cutscene_seen` TEXT NOT NULL, + `ornaments` TEXT NOT NULL, + `caught_fish` TEXT NOT NULL, + `caught_spearfish` TEXT NOT NULL, + `adventures` TEXT NOT NULL, + `triple_triad_cards` TEXT NOT NULL, + `glasses_styles` TEXT NOT NULL, + `chocobo_taxi_stands` TEXT NOT NULL, + `titles` TEXT NOT NULL, + FOREIGN KEY (`content_id`) REFERENCES `character`(`content_id`) +); + CREATE TABLE `mentor`( `content_id` BIGINT NOT NULL PRIMARY KEY, `version` INTEGER NOT NULL, @@ -106,11 +120,10 @@ CREATE TABLE `aetheryte`( FOREIGN KEY (`content_id`) REFERENCES `character`(`content_id`) ); -CREATE TABLE `aether_current`( - `content_id` BIGINT NOT NULL PRIMARY KEY, - `comp_flg_set` TEXT NOT NULL, - `unlocked` TEXT NOT NULL, - FOREIGN KEY (`content_id`) REFERENCES `character`(`content_id`) +CREATE TABLE `party`( + `id` BIGINT NOT NULL PRIMARY KEY, + `leader_content_id` BIGINT NOT NULL, + `members` TEXT NOT NULL ); CREATE TABLE `volatile`( @@ -125,24 +138,26 @@ CREATE TABLE `volatile`( FOREIGN KEY (`content_id`) REFERENCES `character`(`content_id`) ); -CREATE TABLE `party`( - `id` BIGINT NOT NULL PRIMARY KEY, - `leader_content_id` BIGINT NOT NULL, - `members` TEXT NOT NULL +CREATE TABLE `search_info`( + `content_id` BIGINT NOT NULL PRIMARY KEY, + `online_status` INTEGER NOT NULL, + `comment` TEXT NOT NULL, + `selected_languages` INTEGER NOT NULL, + FOREIGN KEY (`content_id`) REFERENCES `character`(`content_id`) ); -CREATE TABLE `inventory`( +CREATE TABLE `customize`( `content_id` BIGINT NOT NULL PRIMARY KEY, - `contents` TEXT NOT NULL, + `chara_make` TEXT NOT NULL, + `city_state` INTEGER NOT NULL, + `remake_mode` INTEGER NOT NULL, FOREIGN KEY (`content_id`) REFERENCES `character`(`content_id`) ); -CREATE TABLE `character`( +CREATE TABLE `aether_current`( `content_id` BIGINT NOT NULL PRIMARY KEY, - `service_account_id` BIGINT NOT NULL, - `actor_id` BIGINT NOT NULL, - `gm_rank` INTEGER NOT NULL, - `name` TEXT NOT NULL, - `time_played_minutes` BIGINT NOT NULL + `comp_flg_set` TEXT NOT NULL, + `unlocked` TEXT NOT NULL, + FOREIGN KEY (`content_id`) REFERENCES `character`(`content_id`) ); diff --git a/servers/world/src/chat_connection.rs b/servers/world/src/chat_connection.rs index 3ce7bcf5..66af780e 100644 --- a/servers/world/src/chat_connection.rs +++ b/servers/world/src/chat_connection.rs @@ -2,10 +2,14 @@ use super::common::ClientId; use crate::{MessageInfo, ServerHandle}; use kawari::common::{ObjectId, timestamp_secs}; use kawari::config::WorldConfig; -use kawari::ipc::chat::{ - ChatChannel, ChatChannelType, ClientChatIpcSegment, PartyMessage, ServerChatIpcData, - ServerChatIpcSegment, TellMessage, TellNotFoundError, +use kawari::ipc::{ + chat::{ + CWLinkshellMessage, ChatChannel, ChatChannelType, ClientChatIpcSegment, PartyMessage, + ServerChatIpcData, ServerChatIpcSegment, TellMessage, TellNotFoundError, + }, + zone::CrossworldLinkshellEx, }; + use kawari::opcodes::ServerChatIpcType; use kawari::packet::IpcSegmentHeader; use kawari::packet::{ @@ -25,6 +29,8 @@ pub struct ChatConnection { pub last_keep_alive: Instant, pub handle: ServerHandle, pub party_chatchannel: ChatChannel, + pub cwls_chatchannels: [ChatChannel; CrossworldLinkshellEx::COUNT], + pub local_ls_chatchannels: [ChatChannel; CrossworldLinkshellEx::COUNT], } impl ChatConnection { @@ -140,8 +146,19 @@ impl ChatConnection { .await; } + // Do some initial setup to prepare all of our chatchannels. Our chat connection mainly acts as a filter between the client's chat connection and our global server state. The global state will eventually fill in our channel numbers as needed. self.party_chatchannel.world_id = self.config.world_id; self.party_chatchannel.channel_type = ChatChannelType::Party; + + for linkshell in self.cwls_chatchannels.iter_mut() { + linkshell.world_id = 10008; // This seems to always be used for CWLSes. + linkshell.channel_type = ChatChannelType::CWLinkshell; + } + + for linkshell in self.local_ls_chatchannels.iter_mut() { + linkshell.world_id = self.config.world_id; + linkshell.channel_type = ChatChannelType::Linkshell + } } pub async fn tell_message_received(&mut self, message_info: MessageInfo) { @@ -163,12 +180,59 @@ impl ChatConnection { } pub async fn party_message_received(&mut self, message_info: PartyMessage) { + if message_info.party_chatchannel != self.party_chatchannel { + tracing::error!( + "party_message_received: We received a message not destined for our party! What happened? Discarding message. The destination chatchannel was {:#?}", + message_info.party_chatchannel + ); + return; + } + let sender_actor_id = message_info.sender_actor_id; let ipc = ServerChatIpcSegment::new(ServerChatIpcData::PartyMessage(message_info)); self.send_ipc(ipc, sender_actor_id).await; } + pub async fn cwls_message_received(&mut self, message_info: CWLinkshellMessage) { + if !self + .cwls_chatchannels + .contains(&message_info.cwls_chatchannel) + { + tracing::error!( + "cwls_message_received: We received a message not destined for one of our linkshells, what happened? Discarding message. The destination linkshell was {:#?}", + message_info.cwls_chatchannel + ); + return; + } + + let sender_actor_id = message_info.sender_actor_id; + let ipc = ServerChatIpcSegment::new(ServerChatIpcData::CWLinkshellMessage(message_info)); + + self.send_ipc(ipc, sender_actor_id).await; + } + + pub async fn set_linkshell_chatchannels(&mut self, cwlses: Vec, locals: Vec) { + if (cwlses.len() > self.cwls_chatchannels.len()) + || (locals.len() > self.local_ls_chatchannels.len()) + { + tracing::error!( + "set_linkshell_chatchannels: cwlses ({}) or locals ({})vecs had too many entries! What happened?", + cwlses.len(), + locals.len() + ); + return; + } + + for (index, ls) in cwlses.iter().enumerate() { + self.cwls_chatchannels[index].channel_number = *ls; + } + + for (index, ls) in locals.iter().enumerate() { + self.local_ls_chatchannels[index].channel_number = *ls; + } + } + pub async fn set_party_chatchannel(&mut self, party_channel_number: u32) { self.party_chatchannel.channel_number = party_channel_number; } diff --git a/servers/world/src/common.rs b/servers/world/src/common.rs index 1244fed1..6861bcf7 100644 --- a/servers/world/src/common.rs +++ b/servers/world/src/common.rs @@ -16,11 +16,12 @@ use kawari::{ }, ipc::{ chat::{ - ChatChannelType, PartyMessage, SendPartyMessage, SendTellMessage, TellNotFoundError, + CWLinkshellMessage, ChatChannelType, PartyMessage, SendCWLinkshellMessage, + SendPartyMessage, SendTellMessage, TellNotFoundError, }, zone::{ - ActionRequest, ActorControlCategory, ClientTrigger, Conditions, Config, InviteReply, - InviteType, NpcSpawn, ObjectSpawn, OnlineStatus, PartyMemberEntry, + ActionRequest, ActorControlCategory, CWLSPermissionRank, ClientTrigger, Conditions, + Config, InviteReply, InviteType, NpcSpawn, ObjectSpawn, OnlineStatus, PartyMemberEntry, PartyMemberPositions, PartyUpdateStatus, PlayerSpawn, ReadyCheckReply, ServerZoneIpcSegment, SpawnTreasure, StrategyBoard, StrategyBoardUpdate, WaymarkPlacementMode, WaymarkPosition, WaymarkPreset, @@ -203,6 +204,10 @@ pub enum FromServer { CommitParties(HashMap), /// Treasure was spawned. TreasureSpawn(SpawnTreasure), + /// A chat message from one of the client's cwlses has been received. + CWLSMessageSent(CWLinkshellMessage), + /// Inform the zone and chat connections about their linkshell channels. + SetLinkshellChatChannels(Vec, Vec), } #[derive(Debug, Clone)] @@ -380,6 +385,14 @@ pub enum ToServer { ReadyCheckResponse(Option, ObjectId, u64, u64, String, ReadyCheckReply), /// Removes action cooldowns for this player. RemoveCooldowns(ObjectId), + /// The client's zone connection wishes to inform its chat connection about any linkshells the player belongs to. + SetLinkshells( + ObjectId, + Option>, + Option>, + ), + /// The client sent a message to a cross-world linkshell. + CWLSMessageSent(ObjectId, SendCWLinkshellMessage), } #[derive(Clone, Debug)] diff --git a/servers/world/src/database/mod.rs b/servers/world/src/database/mod.rs index 52802bbe..b84cb3b3 100644 --- a/servers/world/src/database/mod.rs +++ b/servers/world/src/database/mod.rs @@ -26,7 +26,10 @@ use kawari::{ common::ObjectId, constants::AVAILABLE_CLASSJOBS, ipc::lobby::{CharacterDetails, CharacterFlag}, - ipc::zone::{OnlineStatusMask, PlayerEntry}, + ipc::zone::{ + CWLSMemberListEntry, CWLSPermissionRank, CrossworldLinkshellEx, OnlineStatusMask, + PlayerEntry, + }, }; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); @@ -1125,6 +1128,129 @@ impl WorldDatabase { .unwrap(); } + /// Returns a HashMap of linkshells for the global server state. NOTE: It does not fill in the members or chatchannel ids, and this is intentional! The global server waits for members to log in and inform it that they belong to a given set linkshells, and the chatchannel ids are decided by the global server itself. + pub fn find_all_linkshells(&mut self) -> HashMap { + use schema::linkshells::dsl::*; + + let mut found_linkshells = HashMap::new(); + + if let Ok(flat_linkshells) = linkshells + .select(models::Linkshells::as_select()) + .load(&mut self.connection) + { + for linkshell in flat_linkshells { + found_linkshells.insert(linkshell.id as u64, crate::server::Linkshell::default()); + } + } + + found_linkshells + } + + /// Returns a list of linkshells that the given content id is a member of. + pub fn find_linkshells(&mut self, for_content_id: i64) -> Option> { + let memberships: Vec<_>; + { + use schema::linkshell_members::dsl::*; + + memberships = linkshell_members + .filter(content_id.eq(for_content_id)) + .load::(&mut self.connection) + .unwrap_or_default(); + } + + let mut shell_info = Vec::new(); + { + use schema::linkshells::dsl::*; + + for membership in &memberships { + if let Ok(info) = linkshells + .filter(id.eq(membership.linkshell_id)) + .select(models::Linkshells::as_select()) + .first(&mut self.connection) + { + shell_info.push(info); + } + } + } + + assert!(memberships.len() == shell_info.len()); + + if !memberships.is_empty() && !shell_info.is_empty() { + let mut ret = vec![CrossworldLinkshellEx::default(); CrossworldLinkshellEx::COUNT]; + + for (index, shell) in ret.iter_mut().enumerate() { + if index >= memberships.len() { + break; + } + + shell.common.name = shell_info[index].name.clone(); + let rank = CWLSPermissionRank::from_repr(memberships[index].rank as u8); + shell.common.rank = if let Some(rank) = rank { + rank + } else { + CWLSPermissionRank::Invitee + }; + shell.ids.linkshell_id = shell_info[index].id as u64; + shell.creation_time = shell_info[index].creation_time as u32; + } + + return Some(ret); + } + + None + } + + /// Returns a list of all members in the given linkshell. + // TODO: We can likely just reuse this for local LSes too and "downscale" info they don't need in the zone connection + pub fn find_linkshell_members( + &mut self, + for_linkshell_id: u64, + game_data: &mut GameData, + ) -> Option> { + use schema::linkshell_members::dsl::*; + + let mut members = Vec::new(); + let config = get_config(); + + if let Ok(lsmembers) = linkshell_members + .select(models::LinkshellMembers::as_select()) + .load(&mut self.connection) + { + let for_linkshell_id = for_linkshell_id as i64; + for member in lsmembers { + if member.linkshell_id == for_linkshell_id { + let player_info = self.get_player_entry(game_data, member.content_id); + let is_online = player_info.online_status_mask != OnlineStatusMask::default(); + // If something goes wrong converting their rank, set it to least privileges as a precaution. + let member_rank = + if let Some(db_rank) = CWLSPermissionRank::from_repr(member.rank as u8) { + db_rank + } else { + CWLSPermissionRank::Invitee + }; + members.push(CWLSMemberListEntry { + content_id: member.content_id as u64, + unk_timestamp: member.invite_time as u32, + home_world_id: config.world.world_id, + current_world_id: config.world.world_id, + name: player_info.name.clone(), + is_online, + zone_id: if is_online { player_info.zone_id } else { 0 }, + rank: member_rank, + unk2: 1, + ..Default::default() + }); + } + } + } + + if members.is_empty() { + return None; + } + + Some(members) + } + pub fn do_cleanup_tasks(&mut self) { use schema::volatile::dsl::*; @@ -1141,3 +1267,8 @@ impl WorldDatabase { extern "SQL" { fn datetime() -> diesel::sql_types::Text; } + +#[declare_sql_function] +extern "SQL" { + fn unixepoch() -> diesel::sql_types::BigInt; +} diff --git a/servers/world/src/database/models.rs b/servers/world/src/database/models.rs index 84b5edad..e67d1ead 100644 --- a/servers/world/src/database/models.rs +++ b/servers/world/src/database/models.rs @@ -348,3 +348,27 @@ pub struct Party { pub leader_content_id: i64, pub members: PartyMembers, } + +#[derive(Insertable, Queryable, Selectable, AsChangeset, Debug, Default, Clone)] +#[diesel(table_name = super::schema::linkshells)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +#[diesel(primary_key(id))] +pub struct Linkshells { + pub id: i64, + pub name: String, + pub creation_time: i64, + pub is_crossworld: bool, +} + +#[derive(Insertable, Identifiable, Queryable, Selectable, AsChangeset, Debug, Default, Clone)] +#[diesel(table_name = super::schema::linkshell_members)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +#[diesel(primary_key(id))] +pub struct LinkshellMembers { + // Fake ID because diesel doesn't support tables without primary IDs + pub id: i64, + pub content_id: i64, + pub linkshell_id: i64, + pub invite_time: i64, + pub rank: i32, +} diff --git a/servers/world/src/database/schema.rs b/servers/world/src/database/schema.rs index 3c1c8a2e..a7972118 100644 --- a/servers/world/src/database/schema.rs +++ b/servers/world/src/database/schema.rs @@ -187,6 +187,25 @@ diesel::table! { } } +diesel::table! { + linkshells (id) { + id -> BigInt, + name -> Text, + creation_time -> BigInt, + is_crossworld -> Bool + } +} + +diesel::table! { + linkshell_members (id) { + id -> BigInt, + content_id -> BigInt, + linkshell_id -> BigInt, + invite_time -> BigInt, + rank -> Integer, + } +} + diesel::allow_tables_to_appear_in_same_query!( character, classjob, diff --git a/servers/world/src/main.rs b/servers/world/src/main.rs index 4fdf5151..bcd01ede 100644 --- a/servers/world/src/main.rs +++ b/servers/world/src/main.rs @@ -14,9 +14,9 @@ use kawari_world::inventory::{EquipSlot, Item, Storage, get_next_free_slot}; use kawari::ipc::chat::{ChatChannel, ClientChatIpcData}; use kawari::ipc::zone::{ - ActorControlCategory, Conditions, ContentFinderUserAction, EventType, InviteType, MapEffects, - MarketBoardItem, OnlineStatus, OnlineStatusMask, PlayerSetup, SceneFlags, SearchInfo, - SocialListRequestType, TrustContent, TrustInformation, + ActorControlCategory, Conditions, ContentFinderUserAction, CrossworldLinkshellEx, EventType, + InviteType, MapEffects, MarketBoardItem, OnlineStatus, OnlineStatusMask, PlayerSetup, + SceneFlags, SearchInfo, SocialListRequestType, TrustContent, TrustInformation, }; use kawari::ipc::zone::{ @@ -68,11 +68,13 @@ fn spawn_main_loop( game_data_new = game_data_mutex.clone(); } let parties; + let linkshells; { let mut database = database.lock(); parties = database.get_parties(); + linkshells = database.find_all_linkshells(); } - let res = server_main_loop(game_data_new, parties, recv).await; + let res = server_main_loop(game_data_new, parties, linkshells, recv).await; match res { Ok(()) => {} Err(err) => { @@ -171,6 +173,9 @@ async fn initial_setup( search_index: 0, friend_results: Vec::new(), friend_index: 0, + cwls_results: Vec::new(), + cwls_index: 0, + cwls_memberships: None, }; // Handle setup before passing off control to the zone connection. @@ -218,6 +223,8 @@ async fn initial_setup( socket, handle, party_chatchannel: ChatChannel::default(), + cwls_chatchannels: [ChatChannel::default(); CrossworldLinkshellEx::COUNT], + local_ls_chatchannels: [ChatChannel::default(); CrossworldLinkshellEx::COUNT], }; // Handle setup before passing off control to the chat connection. @@ -246,7 +253,7 @@ async fn initial_setup( } } -// TODO: Is there a sensible we can reuse the other ClientData type so we don't need 2? +// TODO: Is there a sensible way we can reuse the other ClientData type so we don't need 2? struct ClientChatData { /// Socket for data recieved from the global server recv: Receiver, @@ -340,13 +347,21 @@ async fn client_chat_loop( connection.handle.send(ToServer::TellMessageSent(connection.id, connection.actor_id, data.clone())).await; } ClientChatIpcData::SendPartyMessage(data) => { - connection.handle.send(ToServer::PartyMessageSent(connection.actor_id, data.clone())).await; + if data.chatchannel == connection.party_chatchannel { + connection.handle.send(ToServer::PartyMessageSent(connection.actor_id, data.clone())).await; + } else { + tracing::error!("The client tried to send a party message to an invalid ChatChannel: {:#?}, while ours is {:#?}", data.chatchannel, connection.party_chatchannel); + } } ClientChatIpcData::GetChannelList { unk } => { tracing::info!("GetChannelList: {:#?} from {}", unk, connection.actor_id); } - ClientChatIpcData::SendCWLinkshellMessage(_data) => { - tracing::info!("Chatting in CWLSes is unimplemented"); + ClientChatIpcData::SendCWLinkshellMessage(data) => { + if connection.cwls_chatchannels.contains(&data.chatchannel) { + connection.handle.send(ToServer::CWLSMessageSent(connection.actor_id, data.clone())).await; + } else { + tracing::error!("The client tried to send a party message to an invalid ChatChannel: {:#?}, while ours are {:#?}", data.chatchannel, connection.cwls_chatchannels); + } } ClientChatIpcData::SendAllianceMessage(_data) => { tracing::info!("Chatting in alliances is unimplemented"); @@ -382,6 +397,8 @@ async fn client_chat_loop( } FromServer::SetPartyChatChannel(channel_id) => connection.set_party_chatchannel(channel_id).await, FromServer::PartyMessageSent(message_info) => connection.party_message_received(message_info).await, + FromServer::SetLinkshellChatChannels(cwls, local) => connection.set_linkshell_chatchannels(cwls, local).await, + FromServer::CWLSMessageSent(message_info) => connection.cwls_message_received(message_info).await, _ => tracing::error!("ChatConnection {:#?} received a FromServer message we don't care about: {:#?}, ensure you're using the right client network or that you've implemented a handler for it if we actually care about it!", client_handle.id, msg), }, None => break, @@ -848,6 +865,7 @@ async fn process_packet( .await; //connection.remind_pending_invites().await; + connection.init_linkshells().await; // Send login message connection.send_notice(&config.world.login_message).await; @@ -2144,7 +2162,7 @@ async fn process_packet( tracing::info!("Fellowships is unimplemented"); } ClientZoneIpcData::RequestCrossworldLinkshells { .. } => { - tracing::info!("Linkshells is unimplemented"); + connection.send_crossworld_linkshells(true).await; } ClientZoneIpcData::SearchFellowships { .. } => { tracing::info!("Fellowships is unimplemented"); @@ -2668,8 +2686,13 @@ async fn process_packet( ClientZoneIpcData::CreateLocalLinkshellRequest { .. } => { tracing::warn!("Creating local linkshells is unimplemented"); } - ClientZoneIpcData::CrossworldLinkshellMemberListRequest { .. } => { - tracing::warn!("Requesting member lists for CWLSes is unimplemented"); + ClientZoneIpcData::CrossworldLinkshellMemberListRequest { + linkshell_id, + sequence, + } => { + connection + .send_cwlinkshell_members(*linkshell_id, *sequence) + .await; } ClientZoneIpcData::OpenTreasure { .. } => { tracing::warn!("Opening treasure chests is unimplemented"); @@ -2827,6 +2850,11 @@ async fn process_server_msg( database.commit_parties(parties); } FromServer::TreasureSpawn(treasure) => connection.spawn_treasure(treasure).await, + FromServer::SetLinkshellChatChannels(cwlses, _locals) => { + // TODO: There might be a better way to do this. We need the chatchannels to be set *before* sending the "overview" or chat will break. + connection.set_linkshell_chatchannels(cwlses).await; + connection.send_crossworld_linkshells(false).await; + } _ => { tracing::error!("Zone connection {:#?} received a FromServer message we don't care about: {:#?}, ensure you're using the right client network or that you've implemented a handler for it if we actually care about it!", client_handle.id, msg); } } } diff --git a/servers/world/src/server/chat.rs b/servers/world/src/server/chat.rs index 85b2d321..330be538 100644 --- a/servers/world/src/server/chat.rs +++ b/servers/world/src/server/chat.rs @@ -12,7 +12,7 @@ use crate::{ }; use kawari::{ common::ObjectId, - ipc::chat::{PartyMessage, TellNotFoundError}, + ipc::chat::{CWLinkshellMessage, PartyMessage, TellNotFoundError}, }; /// Process chat-related messages. @@ -164,6 +164,58 @@ pub fn handle_chat_messages( true } + ToServer::CWLSMessageSent(from_actor_id, message_info) => { + let mut network = network.lock(); + let data = data.lock(); + + let Some(instance) = data.find_actor_instance(*from_actor_id) else { + return true; + }; + + let Some(sender_actor) = instance.find_actor(*from_actor_id) else { + return true; + }; + + let Some(sender) = sender_actor.get_player_spawn() else { + return true; + }; + + let cwls_message = CWLinkshellMessage { + cwls_chatchannel: message_info.chatchannel, + sender_account_id: sender.account_id, + sender_content_id: sender.content_id, + sender_home_world_id: sender.home_world_id, + sender_current_world_id: sender.current_world_id, + sender_actor_id: *from_actor_id, + sender_name: sender.common.name.clone(), + message: message_info.message.clone(), + }; + + let mut linkshell_id = None; + + // We need some info about the destination LS since the chat connection doesn't provide it. + for (id, linkshell) in &network.linkshells { + if linkshell.channel_number == message_info.chatchannel.channel_number { + linkshell_id = Some(*id); + break; + } + } + + let Some(linkshell_id) = linkshell_id else { + return true; + }; + + let msg = FromServer::CWLSMessageSent(cwls_message); + + network.send_to_linkshell( + linkshell_id, + Some(*from_actor_id), + msg, + DestinationNetwork::ChatClients, + ); + + true + } _ => false, } } diff --git a/servers/world/src/server/mod.rs b/servers/world/src/server/mod.rs index 908922d4..9425210a 100644 --- a/servers/world/src/server/mod.rs +++ b/servers/world/src/server/mod.rs @@ -55,7 +55,7 @@ mod effect; mod instance; mod network; mod social; -pub use social::{Party, PartyMember}; +pub use social::{Linkshell, Party, PartyMember}; mod zone; #[derive(Default, Debug, Clone)] @@ -1005,11 +1005,13 @@ fn server_logic_tick( pub async fn server_main_loop( game_data: GameData, parties: HashMap, + linkshells: HashMap, mut recv: Receiver, ) -> Result<(), std::io::Error> { let data = Arc::new(Mutex::new(WorldServer::default())); let network = Arc::new(Mutex::new(NetworkState { parties, + linkshells, ..Default::default() })); let game_data = Arc::new(Mutex::new(game_data)); @@ -2132,6 +2134,11 @@ pub async fn server_main_loop( FromServer::ChatDisconnected(), DestinationNetwork::ChatClients, ); + + // Remove them from any relevant linkshells. + network.linkshells.iter_mut().for_each(|(_, linkshell)| { + linkshell.remove_member_by_actor_id(from_actor_id) + }); } ToServer::ActorSummonsMinion(from_actor_id, minion_id) => { let mut network = network.lock(); diff --git a/servers/world/src/server/network.rs b/servers/world/src/server/network.rs index 1468b538..61e94e32 100644 --- a/servers/world/src/server/network.rs +++ b/servers/world/src/server/network.rs @@ -7,10 +7,13 @@ use crate::{ ClientState, WorldServer, actor::NetworkedActor, instance::Instance, - social::{Party, get_party_id_from_actor_id}, + social::{Linkshell, Party, get_party_id_from_actor_id}, }, }; -use kawari::{common::ObjectId, ipc::zone::ActorControlCategory}; +use kawari::{ + common::ObjectId, + ipc::zone::{ActorControlCategory, CWLSPermissionRank}, +}; #[derive(Default, Debug)] pub struct NetworkState { @@ -19,16 +22,22 @@ pub struct NetworkState { pub clients: HashMap, pub chat_clients: HashMap, pub parties: HashMap, + pub linkshells: HashMap, pub commit_parties: bool, + pub next_ls_channel_number: u32, // TODO: find a more sensible place for this, and make it atomic? Does it need to be atomic at all? Only the network should be editing this } -#[derive(Debug, PartialEq)] +#[derive(Copy, Clone, Debug, PartialEq)] pub enum DestinationNetwork { ZoneClients, ChatClients, } impl NetworkState { + pub fn next_ls_channel_number(&mut self) -> u32 { + self.next_ls_channel_number += 1; + self.next_ls_channel_number + } /// Creates a `FromServer` message that will spawn `actor`. pub fn spawn_existing_actor_message( client_state: &mut ClientState, @@ -382,4 +391,34 @@ impl NetworkState { .last() .map(|x| x.1) } + + pub fn send_to_linkshell( + &mut self, + linkshell_id: u64, + from: Option, + message: FromServer, + destination: DestinationNetwork, + ) { + let Some(linkshell) = self.linkshells.get(&linkshell_id) else { + return; + }; + + for member in linkshell.members.clone() { + // Optionally skip the sender + if let Some(from) = from + && from == member.actor_id + { + continue; + } + + // If they're still pending, don't let them see chat messages. + if let FromServer::CWLSMessageSent(_) = message + && member.rank == CWLSPermissionRank::Invitee + { + continue; + } + + self.send_to_by_actor_id(member.actor_id, message.clone(), destination); + } + } } diff --git a/servers/world/src/server/social.rs b/servers/world/src/server/social.rs index b8053bfd..6a234609 100644 --- a/servers/world/src/server/social.rs +++ b/servers/world/src/server/social.rs @@ -14,9 +14,10 @@ use crate::{ use kawari::{ common::{CharacterMode, ObjectId, ObjectTypeId, Position}, ipc::zone::{ - ActorControlCategory, PartyMemberEntry, PartyMemberPositions, PartyUpdateStatus, - ReadyCheckReply, ServerZoneIpcData, ServerZoneIpcSegment, WaymarkPlacementMode, - WaymarkPosition, WaymarkPositions, WaymarkPreset, + ActorControlCategory, CWLSPermissionRank, CrossworldLinkshellEx, PartyMemberEntry, + PartyMemberPositions, PartyUpdateStatus, ReadyCheckReply, ServerZoneIpcData, + ServerZoneIpcSegment, WaymarkPlacementMode, WaymarkPosition, WaymarkPositions, + WaymarkPreset, }, }; @@ -381,6 +382,28 @@ pub fn update_party_position( } } +/// A minimal struct representing a linkshell member. +#[derive(Debug, Default, Clone)] +pub struct LinkshellMember { + /// The member's actor id. + pub actor_id: ObjectId, + /// The member's rank in the LS. + pub rank: CWLSPermissionRank, +} + +/// A minimal struct representing a linkshell. As far as the global server cares, we only need to keep track of its chatchannel_id, online members, and their permissions. The HashMap it's stored in keeps track of its 64-bit id. +#[derive(Debug, Default, Clone)] +pub struct Linkshell { + pub members: Vec, + pub channel_number: u32, +} + +impl Linkshell { + pub fn remove_member_by_actor_id(&mut self, actor_id: ObjectId) { + self.members.retain(|m| m.actor_id != actor_id); + } +} + /// Process social list and party-related messages. pub fn handle_social_messages( data: Arc>, @@ -1544,6 +1567,47 @@ pub fn handle_social_messages( network.send_to_party(*party_id, None, msg, DestinationNetwork::ZoneClients); true } + + ToServer::SetLinkshells(from_actor_id, crossworld_shells, _local_shells) => { + let mut network = network.lock(); + let mut cwlses = vec![0; CrossworldLinkshellEx::COUNT]; + let locals = vec![0; CrossworldLinkshellEx::COUNT]; + + if let Some(crossworld_shells) = crossworld_shells { + for (index, (shell, rank)) in crossworld_shells.iter().enumerate() { + if *shell != 0 { + let mut linkshell; + { + linkshell = network.linkshells.entry(*shell).or_default().clone(); + } + linkshell.members.push(LinkshellMember { + actor_id: *from_actor_id, + rank: *rank, + }); + if linkshell.channel_number == 0 { + linkshell.channel_number = network.next_ls_channel_number(); + } + cwlses[index] = linkshell.channel_number; + *network.linkshells.get_mut(shell).unwrap() = linkshell; + } + } + } + + // TODO: Local LSes not supported yet + + // Inform the chat connection their zone connection belongs to these LSes. + let msg = FromServer::SetLinkshellChatChannels(cwlses, locals); + + // We send this to *both* of its connections because the client may have requested a linkshell list at some point, and that all happens on the zone connection. The chat connection uses the ids for filtering bad requests. + network.send_to_by_actor_id( + *from_actor_id, + msg.clone(), + DestinationNetwork::ZoneClients, + ); + network.send_to_by_actor_id(*from_actor_id, msg, DestinationNetwork::ChatClients); + + true + } _ => false, } } diff --git a/servers/world/src/zone_connection/mod.rs b/servers/world/src/zone_connection/mod.rs index 49de0aad..884567dc 100644 --- a/servers/world/src/zone_connection/mod.rs +++ b/servers/world/src/zone_connection/mod.rs @@ -19,8 +19,9 @@ use kawari::{ common::{HandlerId, ObjectId, Position, timestamp_secs}, config::WorldConfig, ipc::zone::{ - ClientTriggerCommand, ClientZoneIpcSegment, Condition, Conditions, - ContentRegistrationFlags, PlayerEntry, ServerZoneIpcData, ServerZoneIpcSegment, + CWLSMemberListEntry, ClientTriggerCommand, ClientZoneIpcSegment, Condition, Conditions, + ContentRegistrationFlags, CrossworldLinkshellEx, PlayerEntry, ServerZoneIpcData, + ServerZoneIpcSegment, }, opcodes::ServerZoneIpcType, packet::{ @@ -173,6 +174,12 @@ pub struct ZoneConnection { pub friend_results: Vec, /// The friend list's current sequence value. Increases by 10 every time the client requests more results. pub friend_index: usize, + /// CWLS member results sent when the player opens the CWLS menu or picks a different linkshell in the same menu. + pub cwls_results: Vec, + /// CWLS member index. Increases by 8 every time the client requests more results. + pub cwls_index: usize, + // A cache of our client's cwlses. TODO: is there a better way to do this..? + pub cwls_memberships: Option>, } impl ZoneConnection { diff --git a/servers/world/src/zone_connection/social.rs b/servers/world/src/zone_connection/social.rs index 515e8794..2cee012a 100644 --- a/servers/world/src/zone_connection/social.rs +++ b/servers/world/src/zone_connection/social.rs @@ -6,15 +6,44 @@ use kawari::{ ipc::{ chat::{ChatChannel, ChatChannelType}, zone::{ - ActorControlCategory, InviteReply, InviteType, InviteUpdateType, OnlineStatus, - OnlineStatusMask, PartyMemberEntry, PartyUpdateStatus, PlayerEntry, - SearchUIClassJobMask, SearchUIGrandCompanies, ServerZoneIpcData, ServerZoneIpcSegment, - SocialList, SocialListRequestType, SocialListUILanguages, StrategyBoard, - StrategyBoardUpdate, WaymarkPlacementMode, WaymarkPosition, WaymarkPreset, + ActorControlCategory, CWLSMemberListEntry, CrossworldLinkshell, CrossworldLinkshellEx, + InviteReply, InviteType, InviteUpdateType, OnlineStatus, OnlineStatusMask, + PartyMemberEntry, PartyUpdateStatus, PlayerEntry, SearchUIClassJobMask, + SearchUIGrandCompanies, ServerZoneIpcData, ServerZoneIpcSegment, SocialList, + SocialListRequestType, SocialListUILanguages, StrategyBoard, StrategyBoardUpdate, + WaymarkPlacementMode, WaymarkPosition, WaymarkPreset, }, }, }; +fn fetch_entries( + next_index: &mut u16, + data: &mut Vec, + increment_by: usize, + state: &mut usize, +) -> Vec +where + T: Clone + std::default::Default, +{ + let mut ret: Vec; + if data.len() > increment_by { + *next_index += increment_by as u16; + ret = data.drain(0..increment_by).collect(); + } else { + *next_index = 0; + ret = std::mem::take(data); + ret.resize(increment_by, T::default()); + } + + if !data.is_empty() { + *state += increment_by; + } else { + *state = 0; + } + + ret +} + impl ZoneConnection { pub async fn received_party_invite( &mut self, @@ -278,6 +307,7 @@ impl ZoneConnection { } } + // TODO: Use the new generic version of fetch_entries above after testing these for regressions. Works fine with cwls lists though. match request_type { SocialListRequestType::Friends => { current_index = self.friend_index as u16; @@ -653,11 +683,15 @@ impl ZoneConnection { } pub async fn refresh_friend_list(&mut self) { - let mut db = self.database.lock(); - let mut game_data = self.gamedata.lock(); - self.friend_results = - db.find_friend_list(&mut game_data, self.player_data.character.content_id); - self.friend_index = 0; + // Only refresh if we ran out of results from a prior run. + if self.friend_results.is_empty() { + let mut db = self.database.lock(); + let mut game_data = self.gamedata.lock(); + self.friend_results = + db.find_friend_list(&mut game_data, self.player_data.character.content_id); + + self.friend_index = 0; + } } pub fn add_to_friend_list(&mut self, friend_content_id: u64) { @@ -688,4 +722,113 @@ impl ZoneConnection { self.add_to_friend_list(sender_content_id); } + + /// Update or refresh our ls/cwls info. + pub async fn init_linkshells(&mut self) { + { + let mut db = self.database.lock(); + self.cwls_memberships = db.find_linkshells(self.player_data.character.content_id); + } + // TODO: local shells + + // Don't bother the server if we're not in any linkshells. + if let Some(cwls_memberships) = &self.cwls_memberships { + self.handle + .send(ToServer::SetLinkshells( + self.player_data.character.actor_id, + Some( + cwls_memberships + .iter() + .map(|m| (m.ids.linkshell_id, m.common.rank)) + .collect(), + ), + None, + )) + .await; + } + } + + // TODO: Extend to support locals too + pub async fn set_linkshell_chatchannels(&mut self, cwls_channels: Vec) { + if let Some(cwls_memberships) = &mut self.cwls_memberships { + for channel_info in cwls_memberships.iter_mut().zip(cwls_channels) { + channel_info.0.ids.linkshell_chat_id.channel_number = channel_info.1; + + // Unfortunately, we can't let the chat connection decide these without pointlessly bothering the global server state + channel_info.0.ids.linkshell_chat_id.world_id = 10008; + channel_info.0.ids.linkshell_chat_id.channel_type = ChatChannelType::CWLinkshell; + } + } + } + + // TODO: Where else is this sent, if anywhere? + pub async fn send_crossworld_linkshells(&mut self, detailed: bool) { + if detailed { + // Send a more detailed report about all of the client's cross-world linkshells. Sent when the client opens the CWLS menu. It contains extra information about when the CWLS was founded. + let ipc = ServerZoneIpcSegment::new(ServerZoneIpcData::CrossworldLinkshellsEx { + linkshells: if let Some(cwls_memberships) = &self.cwls_memberships { + cwls_memberships.clone() + } else { + vec![CrossworldLinkshellEx::default(); CrossworldLinkshellEx::COUNT] + }, + }); + + self.send_ipc_self(ipc).await; + } else { + // Send a (very slightly) less detailed "overview" of cross-world linkshells on login and possibly elsewhere. Probably used so the client can chat without having to open the actual cwls menu. + let mut cwlses = vec![CrossworldLinkshell::default(); CrossworldLinkshell::COUNT]; + + if let Some(cwls_memberships) = &self.cwls_memberships { + // Our cache stores the extended version, so we need to translate it back. + for cwls in cwlses.iter_mut().zip(cwls_memberships.iter()) { + cwls.0.common.name = cwls.1.common.name.clone(); + cwls.0.ids.linkshell_id = cwls.1.ids.linkshell_id; + cwls.0.common.rank = cwls.1.common.rank; + cwls.0.ids.linkshell_chat_id = cwls.1.ids.linkshell_chat_id; + } + } + + tracing::error!("sending these cwlses to client {:#?}", cwlses); + let ipc = ServerZoneIpcSegment::new(ServerZoneIpcData::CrossworldLinkshells { + linkshells: cwlses, + }); + self.send_ipc_self(ipc).await; + } + } + + // TODO: Likely extend this for local LSes too + pub async fn send_cwlinkshell_members(&mut self, linkshell_id: u64, sequence: u16) { + // Only refresh and reset state if our list is empty. + if self.cwls_results.is_empty() { + let mut db = self.database.lock(); + let mut gamedata = self.gamedata.lock(); + if let Some(cwls_results) = db.find_linkshell_members(linkshell_id, &mut gamedata) { + self.cwls_results = cwls_results; + } else { + // If we somehow are told about an empty linkshell, ensure we can at least provide a blank member list so the client doesn't experience oddities beyond that. + self.cwls_results = vec![CWLSMemberListEntry::default(); 8]; + } + self.cwls_index = 0; + } + + let current_index = self.cwls_index as u16; + let mut next_index = self.cwls_index as u16; + + let members = fetch_entries( + &mut next_index, + &mut self.cwls_results, + CWLSMemberListEntry::COUNT, + &mut self.cwls_index, + ); + + let ipc = ServerZoneIpcSegment::new(ServerZoneIpcData::CrossworldLinkshellMemberList { + next_index, + current_index, + linkshell_id, + sequence, + members, + }); + + self.send_ipc_self(ipc).await; + } }