diff --git a/core/src/ipc/zone/client/mod.rs b/core/src/ipc/zone/client/mod.rs index 7f12f3f9..a49760ef 100644 --- a/core/src/ipc/zone/client/mod.rs +++ b/core/src/ipc/zone/client/mod.rs @@ -433,12 +433,12 @@ pub enum ClientZoneIpcData { unk2: u16, #[brw(pad_after = 1)] // Seems to be empty/zeroes unk3: u16, - #[brw(pad_size_to = 20)] - #[br(count = 20)] + #[brw(pad_size_to = CHAR_NAME_MAX_LENGTH)] + #[br(count = CHAR_NAME_MAX_LENGTH)] #[br(map = read_string)] #[bw(map = write_string)] name: String, - unk4: [u8; 13], // Unknown data, likely garbage since several other client opcodes have been discovered to leave garbage behind around name strings + unk4: [u8; 1], }, CrossworldLinkshellMemberListRequest { linkshell_id: u64, @@ -464,6 +464,23 @@ pub enum ClientZoneIpcData { #[brw(pad_after = 8)] // empty listing_id: u64, }, + CheckCWLinkshellNameAvailability { + unk1: u8, // TODO: What is this? Seems to be always 1? + #[bw(pad_size_to = CHAR_NAME_MAX_LENGTH)] + #[br(count = CHAR_NAME_MAX_LENGTH)] + #[br(map = read_string)] + #[bw(map = write_string)] + name: String, + unk2: [u8; 7], // Unknown data, likely garbage since several other client opcodes have been discovered to leave garbage behind around name strings*/ + }, + CreateNewCrossworldLinkshell { + /// The name of the cross-world linkshell. + #[brw(pad_size_to = CHAR_NAME_MAX_LENGTH)] + #[br(count = CHAR_NAME_MAX_LENGTH)] + #[br(map = read_string)] + #[bw(map = write_string)] + name: String, + }, } #[cfg(test)] diff --git a/core/src/ipc/zone/server/linkshell.rs b/core/src/ipc/zone/server/linkshell.rs index dd4c7588..3d3fdefc 100644 --- a/core/src/ipc/zone/server/linkshell.rs +++ b/core/src/ipc/zone/server/linkshell.rs @@ -25,7 +25,7 @@ impl LinkshellEntry { /// Represents one member entry in the CWLSMemberList. #[binrw] -#[derive(Debug, Default, Clone)] +#[derive(Clone, Debug, Default)] pub struct CWLSMemberListEntry { pub content_id: u64, pub unk_timestamp: u32, // Possibly when this member joined, or last had their rank changed? @@ -70,7 +70,7 @@ pub enum CWLSPermissionRank { /// Represents the CWLS's id number and ChatChannel. This was added to help reduce copy paste in CrossworldLinkshell & CrossworldLinkshellEx. #[binrw] -#[derive(Debug, Default, Clone)] +#[derive(Clone, Debug, Default)] pub struct CWLSCommonIdentifiers { pub linkshell_id: u64, pub linkshell_chat_id: ChatChannel, @@ -78,7 +78,7 @@ pub struct CWLSCommonIdentifiers { /// Represents the CWLS's name & permission rank info. This was added to help reduce copy paste in CrossworldLinkshell & CrossworldLinkshellEx. #[binrw] -#[derive(Debug, Default, Clone)] +#[derive(Clone, Debug, Default)] pub struct CWLSCommon { /// The client's rank in the CWLS. pub rank: CWLSPermissionRank, @@ -93,7 +93,7 @@ pub struct CWLSCommon { /// Represents data of a single CWLS. This version is used on login. #[binrw] -#[derive(Debug, Default, Clone)] +#[derive(Clone, Debug, Default)] pub struct CrossworldLinkshell { pub ids: CWLSCommonIdentifiers, /// The client's name and rank in the CWLS. @@ -107,7 +107,7 @@ impl CrossworldLinkshell { /// Represents data of a single CWLS. This extended version is used when the CWLS menu is opened. #[binrw] -#[derive(Debug, Default, Clone)] +#[derive(Clone, Debug, Default)] pub struct CrossworldLinkshellEx { pub ids: CWLSCommonIdentifiers, /// A 32-bit Unix timestmap indicating when this CWLS was created. @@ -121,3 +121,13 @@ impl CrossworldLinkshellEx { pub const SIZE: usize = 64; pub const COUNT: usize = 8; } + +/// The result sent back to the client when they ask if a CWLS's name is available for use. +#[binrw] +#[brw(repr = u8)] +#[derive(Clone, Debug, Default, PartialEq)] +pub enum CWLSNameAvailability { + #[default] + Available = 0, + NotAvailable = 1, +} diff --git a/core/src/ipc/zone/server/mod.rs b/core/src/ipc/zone/server/mod.rs index e671a90d..7e1cc593 100644 --- a/core/src/ipc/zone/server/mod.rs +++ b/core/src/ipc/zone/server/mod.rs @@ -136,8 +136,8 @@ pub use marketboard::MarketBoardItem; mod linkshell; pub use linkshell::{ - CWLSMemberListEntry, CWLSPermissionRank, CrossworldLinkshell, CrossworldLinkshellEx, - LinkshellEntry, + CWLSCommon, CWLSCommonIdentifiers, CWLSMemberListEntry, CWLSNameAvailability, + CWLSPermissionRank, CrossworldLinkshell, CrossworldLinkshellEx, LinkshellEntry, }; mod spawn_treasure; @@ -1056,6 +1056,26 @@ pub enum ServerZoneIpcData { listing_id: u64, unk: [u8; 456], }, + CWLinkshellNameAvailability { + unk1: u8, // TODO: What is this? Seems to be always 1. + /// If the desired name was available or not. + result: CWLSNameAvailability, + /// The desired name. + #[brw(pad_size_to = CHAR_NAME_MAX_LENGTH)] + #[br(count = CHAR_NAME_MAX_LENGTH)] + #[br(map = read_string)] + #[bw(map = write_string)] + #[brw(pad_after = 6)] + name: String, + }, + NewCrossworldLinkshell { + /// The CWLS's id number and ChatChannel information. + ids: CWLSCommonIdentifiers, + unk_timestamp1: u32, // Unknown 32-bit Unix timestamp, likely the cwls's creation time. + unk_timestamp2: u32, // Seems to be the same timestamp repeated? Might be the member's join time? + /// The member's rank in the cross-world linkshell, and the linkshell's name. + common: CWLSCommon, + }, } #[cfg(test)] diff --git a/resources/data/opcodes.yml b/resources/data/opcodes.yml index 585731e0..ab93581f 100644 --- a/resources/data/opcodes.yml +++ b/resources/data/opcodes.yml @@ -619,6 +619,14 @@ ServerZoneIpcType: comment: Sent in response to ViewCrossRealmListing opcode: 735 size: 464 + - name: CWLinkshellNameAvailability + comment: The server responds to CheckCWLinkshellNameAvailability, informing the client if the desired name was available or not. + opcode: 906 + size: 40 + - name: NewCrossworldLinkshell + comment: The server responds to NewCrossworldLinkshellInfoRequest, providing some information on the newly created/joined cwls. + opcode: 855 + size: 64 ClientZoneIpcType: - name: InitRequest comment: Sent by the client when they successfully initialize with the server, and they need several bits of information (e.g. what zone to load.) @@ -912,6 +920,14 @@ ClientZoneIpcType: comment: Seen while viewing information for a specific Party Finder listing. opcode: 810 size: 16 + - name: CheckCWLinkshellNameAvailability + comment: The client requests if this name is available for use with a new cross-world linkshell. + opcode: 591 + size: 40 + - name: CreateNewCrossworldLinkshell + comment: The client requests for the server to commence with creating the new cross-world linkshell after confirming its name's availability. + opcode: 587 + size: 32 ServerLobbyIpcType: - name: NackReply comment: Sent by the server to indicate an lobby error occured. diff --git a/servers/world/src/common.rs b/servers/world/src/common.rs index e5b6eda5..f61ea021 100644 --- a/servers/world/src/common.rs +++ b/servers/world/src/common.rs @@ -206,7 +206,7 @@ pub enum FromServer { /// 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), + SetLinkshellChatChannels(Vec, Vec, bool), } #[derive(Debug, Clone)] @@ -389,6 +389,7 @@ pub enum ToServer { ObjectId, Option>, Option>, + bool, ), /// The client sent a message to a cross-world linkshell. CWLSMessageSent(ObjectId, SendCWLinkshellMessage), diff --git a/servers/world/src/database/mod.rs b/servers/world/src/database/mod.rs index b84cb3b3..30a2a060 100644 --- a/servers/world/src/database/mod.rs +++ b/servers/world/src/database/mod.rs @@ -6,8 +6,8 @@ use kawari::ipc::zone::{ GameMasterRank, OnlineStatus, ServerZoneIpcData, SocialListUIFlags, SocialListUILanguages, }; pub use models::{ - AetherCurrent, Aetheryte, Character, ClassJob, Companion, Content, Friends, Mentor, Quest, - SearchInfo, Unlock, Volatile, + AetherCurrent, Aetheryte, Character, ClassJob, Companion, Content, Friends, LinkshellMembers, + Mentor, Quest, SearchInfo, Unlock, Volatile, }; mod schema; @@ -27,8 +27,8 @@ use kawari::{ constants::AVAILABLE_CLASSJOBS, ipc::lobby::{CharacterDetails, CharacterFlag}, ipc::zone::{ - CWLSMemberListEntry, CWLSPermissionRank, CrossworldLinkshellEx, OnlineStatusMask, - PlayerEntry, + CWLSCommon, CWLSCommonIdentifiers, CWLSMemberListEntry, CWLSNameAvailability, + CWLSPermissionRank, CrossworldLinkshellEx, OnlineStatusMask, PlayerEntry, }, }; @@ -1251,6 +1251,151 @@ impl WorldDatabase { Some(members) } + pub fn add_member_to_linkshell( + &mut self, + for_linkshell_id: i64, + for_content_id: i64, + their_rank: CWLSPermissionRank, + their_invite_time: i64, + ) -> bool { + use schema::linkshell_members::dsl::*; + + let already_member = linkshell_members + .select(content_id) + .filter(content_id.eq(for_content_id)) + .filter(linkshell_id.eq(for_linkshell_id)) + .first::(&mut self.connection); + + // If they're not in this linkshell, add them. + if already_member.is_err() { + let next_id = if let Ok(highest) = linkshell_members + .select(id) + .order(id.desc()) + .first::(&mut self.connection) + { + highest + 1 + } else { + 1 // Start from a safe default if there are no members. + }; + let new_member = LinkshellMembers { + id: next_id, + content_id: for_content_id, + linkshell_id: for_linkshell_id, + invite_time: their_invite_time, + rank: their_rank as i32, + }; + + let result = diesel::insert_into(linkshell_members) + .values(new_member) + .execute(&mut self.connection); + + match result { + Ok(_) => { + return true; + } + + Err(err) => { + tracing::warn!( + "Failed to add member to linkshell due to the following error: {err:#?}" + ); + return false; + } + } + } else { + tracing::warn!( + "This character {for_content_id} is already in this linkshell {for_content_id}!" + ); + } + + false + } + + pub fn linkshell_name_available(&mut self, desired_name: String) -> CWLSNameAvailability { + // Linkshell names must be: between 3 and 20 characters in length, may contain punctuation, not contain double spaces/underscores, not contain a space at the start or end of the name, and the name may not consist solely of punctuation. + // TODO: Should we bother enforcing the other rules if a player somehow bypassed the client-side limitations? + use schema::linkshells::dsl::*; + + if desired_name.len() >= 3 && desired_name.len() <= 20 { + let already_exists = linkshells + .select(name) + .filter(name.eq(desired_name.clone())) + .first::(&mut self.connection); + + if already_exists.is_err() { + return CWLSNameAvailability::Available; + } + } + CWLSNameAvailability::NotAvailable + } + + pub fn create_linkshell( + &mut self, + from_content_id: i64, + ls_name: String, + is_crossworld_ls: bool, + ) -> Option { + use schema::linkshells::dsl::*; + + // Only allow creation if this LS doesn't exist already. Probably a bit redundant with how the order of events goes, but never hurts. + if self.linkshell_name_available(ls_name.clone()) == CWLSNameAvailability::Available { + let ls_creation_time = diesel::select(unixepoch()) + .get_result::(&mut self.connection) + .unwrap_or_default(); + + let next_id = if let Ok(highest) = linkshells + .select(id) + .order(id.desc()) + .first::(&mut self.connection) + { + highest + 1 + } else { + 1 // Start from a safe default if there are no linkshells at all on the server. + }; + + let linkshell = models::Linkshells { + id: next_id, + name: ls_name.clone(), + creation_time: ls_creation_time, + is_crossworld: is_crossworld_ls, + }; + + let result = diesel::insert_into(linkshells) + .values(linkshell) + .execute(&mut self.connection); + + match result { + Ok(_) => { + let rank = CWLSPermissionRank::Master; + if self.add_member_to_linkshell( + next_id, + from_content_id, + rank, + ls_creation_time, + ) { + return Some(CrossworldLinkshellEx { + ids: CWLSCommonIdentifiers { + linkshell_id: next_id as u64, + ..Default::default() + }, + creation_time: ls_creation_time as u32, + common: CWLSCommon { + rank, + name: ls_name.clone(), + }, + }); + } + } + Err(err) => tracing::error!( + "Failed to create the linkshell because of the following error: {err:#?}" + ), + } + } else { + tracing::warn!("The linkshell name {ls_name} already exists!"); + } + + None + } + pub fn do_cleanup_tasks(&mut self) { use schema::volatile::dsl::*; diff --git a/servers/world/src/main.rs b/servers/world/src/main.rs index 2737446e..1b372996 100644 --- a/servers/world/src/main.rs +++ b/servers/world/src/main.rs @@ -396,7 +396,7 @@ 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::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), }, @@ -2751,6 +2751,14 @@ async fn process_packet( ); connection.send_ipc_self(ipc).await; } + ClientZoneIpcData::CheckCWLinkshellNameAvailability { name, .. } => { + connection + .check_cwlinkshell_name_availability(name.clone()) + .await; + } + ClientZoneIpcData::CreateNewCrossworldLinkshell { name } => { + connection.create_crossworld_linkshell(name.clone()).await; + } ClientZoneIpcData::Unknown { unk } => { tracing::warn!( "Unknown Zone packet {:?} recieved ({} bytes), this should be handled!", @@ -3098,10 +3106,12 @@ async fn process_server_msg( database.commit_parties(parties); } FromServer::TreasureSpawn(treasure) => connection.spawn_treasure(treasure).await, - FromServer::SetLinkshellChatChannels(cwlses, _locals) => { + FromServer::SetLinkshellChatChannels(cwlses, _locals, need_to_send_linkshells) => { // 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; + if need_to_send_linkshells { + connection.send_crossworld_linkshells(false).await; + } } _ => { tracing::error!( diff --git a/servers/world/src/server/social.rs b/servers/world/src/server/social.rs index 0ee5172c..00c20dd1 100644 --- a/servers/world/src/server/social.rs +++ b/servers/world/src/server/social.rs @@ -383,7 +383,7 @@ pub fn update_party_position( } /// A minimal struct representing a linkshell member. -#[derive(Debug, Default, Clone)] +#[derive(Debug, Default, Clone, PartialEq)] pub struct LinkshellMember { /// The member's actor id. pub actor_id: ObjectId, @@ -1596,7 +1596,12 @@ pub fn handle_social_messages( true } - ToServer::SetLinkshells(from_actor_id, crossworld_shells, _local_shells) => { + ToServer::SetLinkshells( + from_actor_id, + crossworld_shells, + _local_shells, + need_to_send_linkshells, + ) => { let mut network = network.lock(); let mut cwlses = vec![0; CrossworldLinkshellEx::COUNT]; let locals = vec![0; CrossworldLinkshellEx::COUNT]; @@ -1608,10 +1613,17 @@ pub fn handle_social_messages( { linkshell = network.linkshells.entry(*shell).or_default().clone(); } - linkshell.members.push(LinkshellMember { + let member = LinkshellMember { actor_id: *from_actor_id, rank: *rank, - }); + }; + + if !linkshell.members.contains(&member) { + linkshell.members.push(LinkshellMember { + actor_id: *from_actor_id, + rank: *rank, + }); + } if linkshell.channel_number == 0 { linkshell.channel_number = network.next_ls_channel_number(); } @@ -1624,7 +1636,8 @@ pub fn handle_social_messages( // TODO: Local LSes not supported yet // Inform the chat connection their zone connection belongs to these LSes. - let msg = FromServer::SetLinkshellChatChannels(cwlses, locals); + let msg = + FromServer::SetLinkshellChatChannels(cwlses, locals, *need_to_send_linkshells); // 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( diff --git a/servers/world/src/zone_connection/social.rs b/servers/world/src/zone_connection/social.rs index c134507b..9551b5f4 100644 --- a/servers/world/src/zone_connection/social.rs +++ b/servers/world/src/zone_connection/social.rs @@ -6,12 +6,12 @@ use kawari::{ ipc::{ chat::{ChatChannel, ChatChannelType}, zone::{ - ActorControlCategory, CWLSMemberListEntry, CrossworldLinkshell, CrossworldLinkshellEx, - InviteReply, InviteType, InviteUpdateType, OnlineStatus, OnlineStatusMask, - PartyMemberEntry, PartyUpdateStatus, PlayerEntry, SearchUIClassJobMask, - SearchUIGrandCompanies, ServerZoneIpcData, ServerZoneIpcSegment, SocialList, - SocialListRequestType, SocialListUILanguages, StrategyBoard, StrategyBoardUpdate, - WaymarkPlacementMode, WaymarkPosition, WaymarkPreset, + ActorControlCategory, CWLSMemberListEntry, CWLSPermissionRank, CrossworldLinkshell, + CrossworldLinkshellEx, InviteReply, InviteType, InviteUpdateType, OnlineStatus, + OnlineStatusMask, PartyMemberEntry, PartyUpdateStatus, PlayerEntry, + SearchUIClassJobMask, SearchUIGrandCompanies, ServerZoneIpcData, ServerZoneIpcSegment, + SocialList, SocialListRequestType, SocialListUILanguages, StrategyBoard, + StrategyBoardUpdate, WaymarkPlacementMode, WaymarkPosition, WaymarkPreset, }, }, }; @@ -745,6 +745,7 @@ impl ZoneConnection { .collect(), ), None, + true, )) .await; } @@ -833,4 +834,75 @@ impl ZoneConnection { self.send_ipc_self(ipc).await; } + + pub async fn check_cwlinkshell_name_availability(&mut self, name: String) { + let result; + { + let mut db = self.database.lock(); + result = db.linkshell_name_available(name.clone()); + } + + let ipc = ServerZoneIpcSegment::new(ServerZoneIpcData::CWLinkshellNameAvailability { + result, + name, + unk1: 1, + }); + + self.send_ipc_self(ipc).await; + } + + /// Creates a new cross-world linkshell and then informs both the global server & the client about it. + pub async fn create_crossworld_linkshell(&mut self, name: String) { + let mut cwlses = vec![(0, CWLSPermissionRank::Invitee); CrossworldLinkshellEx::COUNT]; + { + let info; + { + let mut db = self.database.lock(); + info = + db.create_linkshell(self.player_data.character.content_id, name.clone(), true); + } + + // If LS creation is successful, prepare some info for both the client and the global server state. + if let Some(info) = info { + if let Some(cwls_memberships) = &mut self.cwls_memberships { + let mut found_empty_slot = false; + for (index, linkshell) in cwls_memberships.iter_mut().enumerate() { + // Fill the first empty slot on our side with the new linkshell's info. + if !found_empty_slot && linkshell.ids.linkshell_id == 0 { + *linkshell = info.clone(); + found_empty_slot = true; + } + + // Fill in the global server's copy of the info. + cwlses[index] = (linkshell.ids.linkshell_id, linkshell.common.rank); + } + } else { + // Otherwise, even if we didn't have any linkshells before, we do now. + let mut new_memberships = + vec![CrossworldLinkshellEx::default(); CrossworldLinkshellEx::COUNT]; + new_memberships[0] = info.clone(); + cwlses[0] = (info.ids.linkshell_id, info.common.rank); + self.cwls_memberships = Some(new_memberships); + } + + self.handle + .send(ToServer::SetLinkshells( + self.player_data.character.actor_id, + Some(cwlses), + None, // TODO: local linkshells + false, + )) + .await; + + let ipc = ServerZoneIpcSegment::new(ServerZoneIpcData::NewCrossworldLinkshell { + ids: info.ids.clone(), + unk_timestamp1: info.creation_time, + unk_timestamp2: info.creation_time, + common: info.common.clone(), + }); + + self.send_ipc_self(ipc).await; + } + } + } }