diff --git a/doc/bt/CZ_PARTY_PROP_CHANGE.bt b/doc/bt/CZ_PARTY_PROP_CHANGE.bt index 34283b441..ca94d07b9 100644 --- a/doc/bt/CZ_PARTY_PROP_CHANGE.bt +++ b/doc/bt/CZ_PARTY_PROP_CHANGE.bt @@ -14,5 +14,8 @@ #include "inc/common.bt" ClientHeaderFixed header; -byte b1; -getProperties(17*8); \ No newline at end of file + +PartyType type; +int propertyId; +int i1; +char propertyValue[64]; \ No newline at end of file diff --git a/doc/bt/ZC_ENTER_PC.bt b/doc/bt/ZC_ENTER_PC.bt index 93f1c865b..1b46ed55a 100644 --- a/doc/bt/ZC_ENTER_PC.bt +++ b/doc/bt/ZC_ENTER_PC.bt @@ -16,6 +16,8 @@ // - i174236: b3 was added // - i197611: b3 was removed // - i373230: i6 was added +// - i381165: b4 was added +// - changed from s2 (short) to 2 bytes //------------------------------------------------ #include "inc/common.bt" @@ -23,11 +25,8 @@ ServerHeaderFixed header; int handle; -float x; -float y; -float z; -float dcos; -float dsin; +position pos; +direction dir; short s1; int64 socialInfoId; byte pose; // ? @@ -42,7 +41,8 @@ int i6; int stamina; int maxStamina; byte b1; -short s2; +byte b0; +byte isSitting; int titleAchievementId; int i3; byte b2; diff --git a/doc/bt/ZC_PARTY_INFO.bt b/doc/bt/ZC_PARTY_INFO.bt index 448eaf0fc..19063e965 100644 --- a/doc/bt/ZC_PARTY_INFO.bt +++ b/doc/bt/ZC_PARTY_INFO.bt @@ -1,8 +1,8 @@ //------------------------------------------------ //--- 010 Editor v8.0 Binary Template // -// File: ZC_PARTY_INFO -// Authors: Tachiorz +// File: ZC_PARTY_INFO.bt +// Authors: Tachiorz, Salman T. Khan // Version: i11XXX // Purpose: // Category: @@ -15,7 +15,8 @@ ServerHeaderDynamic header; -short partyType; // 0: normal, 1: guild +PartyType partyType; // 0: normal, 1: guild +byte b1; // PARTY_INFO FILETIME creationTime; diff --git a/doc/bt/inc/Party.bt b/doc/bt/inc/Party.bt index 02deffe14..3fdc15251 100644 --- a/doc/bt/inc/Party.bt +++ b/doc/bt/inc/Party.bt @@ -28,7 +28,7 @@ typedef struct int64 l1; int i11; int i0; - int i1; + int mapId; // Might be offline if set to 0? int handle; char teamName1[64]; char characterName[64]; @@ -38,10 +38,11 @@ typedef struct short baseJob1; ushort s10; int jobLevel; - short s5; - int i5; - byte bin[18]; - int mapId; // removed s12 and merged, it was a 0 + short gender; + short hair; + int i12; + byte bin[16]; + int serverGroup; // removed s12 and merged, it was a 0 int level; byte b1; byte b2; diff --git a/sql/updates/update_2025-01-13_1.sql b/sql/updates/update_2025-01-13_1.sql new file mode 100644 index 000000000..c3a0ba233 --- /dev/null +++ b/sql/updates/update_2025-01-13_1.sql @@ -0,0 +1,16 @@ +-- Add partyId column to characters table for party persistence +ALTER TABLE `characters` ADD COLUMN `partyId` BIGINT NOT NULL DEFAULT '0' AFTER `usedStat`; + +-- Create party table for storing party information +CREATE TABLE IF NOT EXISTS `party` ( + `partyId` bigint(20) NOT NULL AUTO_INCREMENT, + `leaderId` bigint(20) NOT NULL, + `name` varchar(64) NOT NULL, + `note` varchar(64) NOT NULL, + `dateCreated` DATETIME DEFAULT CURRENT_TIMESTAMP, + `questSharing` tinyint(1) DEFAULT '1', + `expDistribution` tinyint(1) DEFAULT '1', + `itemDistribution` tinyint(1) DEFAULT '1', + PRIMARY KEY (`partyId`), + KEY `leaderId` (`leaderId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=1 ; diff --git a/src/Shared/Configuration/Files/World.cs b/src/Shared/Configuration/Files/World.cs index 9d867274c..43fe069a9 100644 --- a/src/Shared/Configuration/Files/World.cs +++ b/src/Shared/Configuration/Files/World.cs @@ -48,6 +48,18 @@ public class WorldConfFile : ConfFile // misc.conf public bool ResurrectCityOption { get; protected set; } + // party.conf + public float PartyExpMultiplier2 { get; protected set; } + public float PartyExpMultiplier3 { get; protected set; } + public float PartyExpMultiplier4 { get; protected set; } + public float PartyExpMultiplier5Plus { get; protected set; } + public int PartyLevelPenaltyThreshold1 { get; protected set; } + public float PartyLevelPenalty1 { get; protected set; } + public int PartyLevelPenaltyThreshold2 { get; protected set; } + public float PartyLevelPenalty2 { get; protected set; } + public int PartyLevelPenaltyThreshold3 { get; protected set; } + public float PartyLevelPenalty3 { get; protected set; } + // quests.conf public bool DisplayQuestObjectives { get; protected set; } @@ -140,6 +152,17 @@ public void Load(string filePath) this.ResurrectCityOption = this.GetBool("resurrect_city_option", true); + this.PartyExpMultiplier2 = this.GetFloat("party_exp_multiplier_2", 1.2f); + this.PartyExpMultiplier3 = this.GetFloat("party_exp_multiplier_3", 1.5f); + this.PartyExpMultiplier4 = this.GetFloat("party_exp_multiplier_4", 1.8f); + this.PartyExpMultiplier5Plus = this.GetFloat("party_exp_multiplier_5plus", 2.2f); + this.PartyLevelPenaltyThreshold1 = this.GetInt("party_level_penalty_threshold_1", 10); + this.PartyLevelPenalty1 = this.GetFloat("party_level_penalty_1", 0.7f); + this.PartyLevelPenaltyThreshold2 = this.GetInt("party_level_penalty_threshold_2", 15); + this.PartyLevelPenalty2 = this.GetFloat("party_level_penalty_2", 0.4f); + this.PartyLevelPenaltyThreshold3 = this.GetInt("party_level_penalty_threshold_3", 20); + this.PartyLevelPenalty3 = this.GetFloat("party_level_penalty_3", 0.0f); + this.DisplayQuestObjectives = this.GetBool("display_quest_objectives", true); this.DisableSDR = this.GetBool("disable_sdr", false); diff --git a/src/Shared/Game/Const/GroupType.cs b/src/Shared/Game/Const/GroupType.cs new file mode 100644 index 000000000..dc56b0f45 --- /dev/null +++ b/src/Shared/Game/Const/GroupType.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Melia.Shared.Game.Const +{ + public enum GroupType + { + Party = 0, + Guild = 1, + } +} diff --git a/src/Shared/Game/Const/Message.cs b/src/Shared/Game/Const/Message.cs index 383959a44..1d1571db4 100644 --- a/src/Shared/Game/Const/Message.cs +++ b/src/Shared/Game/Const/Message.cs @@ -81,7 +81,10 @@ public static class AddonMessage public const string ENABLE_PCBANG_SHOP = "ENABLE_PCBANG_SHOP"; + public const string PARTY_JOIN = "PARTY_JOIN"; public const string PARTY_UPDATE = "PARTY_UPDATE"; + public const string PARTY_INVITE_CANCEL = "PARTY_INVITE_CANCEL"; + public const string SUCCESS_UPDATE_PARTY_INFO = "SUCCESS_UPDATE_PARTY_INFO"; public const string UPDATE_GUILD_MILEAGE = "UPDATE_GUILD_MILEAGE"; public const string UPDATE_ATTENDANCE_REWARD = "UPDATE_ATTENDANCE_REWARD"; diff --git a/src/Shared/Game/Const/RelationType.cs b/src/Shared/Game/Const/RelationType.cs new file mode 100644 index 000000000..f306bd6c7 --- /dev/null +++ b/src/Shared/Game/Const/RelationType.cs @@ -0,0 +1,50 @@ +using System.Text.RegularExpressions; + +namespace Melia.Shared.Game.Const +{ + /// + /// Used to specify the type of a monster. + /// + public enum RelationType : byte + { + /// + /// Party/Guild + /// + Friendly = 0, + + /// + /// An aggressive monster. + /// + Enemy = 1, + + /// + /// An NPC or item. + /// + Neutral = 2, + + /// + /// A party member + /// + Party = 3, + + /// + /// A guild member + /// + Guild = 4, + + /// + /// Is a player + /// + Character = 5, + + /// + /// Is a monster + /// + Monster = 6, + + /// + /// All relations + /// + All = 127, + } +} diff --git a/src/Shared/Network/NormalOp.cs b/src/Shared/Network/NormalOp.cs index 597fda8d6..c110f1e00 100644 --- a/src/Shared/Network/NormalOp.cs +++ b/src/Shared/Network/NormalOp.cs @@ -69,12 +69,20 @@ public static class Zone public const int PlayTextEffect = 0xE3; public const int Unknown_E4 = 0xE7; public const int Unknown_EF = 0xF2; + public const int PartyMemberData = 0xF4; + public const int PartyLeaderChange = 0xF6; + public const int PartyNameChange = 0xF7; + public const int PartyInvite = 0xF8; + public const int PartyPropertyChange = 0xF9; + public const int PartyMemberPropertyChange = 0xFA; public const int ChannelTraffic = 0x12D; public const int SetGreetingMessage = 0x136; + public const int ShowParty = 0x13C; public const int Unk13E = 0x13E; public const int SetSessionKey = 0x14F; public const int ItemDrop = 0x152; public const int NGSCallback = 0x170; + public const int MemberMapStatusUpdate = 0x17A; public const int HeadgearVisibilityUpdate = 0x17C; public const int UpdateSkillUI = 0x189; public const int AdventureBook = 0x197; diff --git a/src/Shared/ObjectProperties/PropertyObject.cs b/src/Shared/ObjectProperties/PropertyObject.cs index cea47864c..985e9ffc6 100644 --- a/src/Shared/ObjectProperties/PropertyObject.cs +++ b/src/Shared/ObjectProperties/PropertyObject.cs @@ -24,6 +24,7 @@ public interface IPropertyObject : IPropertyHolder public static class ObjectIdRanges { + public const long Account = 0x0000000000000000; public const long Characters = 0x0100000000000000; public const long SocialUser = 0x0200000000000000; public const long Items = 0x0500000000000000; @@ -31,6 +32,10 @@ public static class ObjectIdRanges public const long Abilities = 0x0700000000000000; public const long SessionObjects = 0x0A00000000000000; public const long Quests = 0x0B00000000000000; + public const long Companions = 0x0C00000000000000; + public const long Party = 0x0D00000000000000; + public const long Guild = 0x0E00000000000000; + public const long Assisters = 0x0F00000000000000; // Old stuff for referecence: diff --git a/src/Shared/Util/DateTimeUtils.cs b/src/Shared/Util/DateTimeUtils.cs new file mode 100644 index 000000000..2ea95edaf --- /dev/null +++ b/src/Shared/Util/DateTimeUtils.cs @@ -0,0 +1,22 @@ +using System; + +namespace Melia.Shared.Util +{ + public static class DateTimeUtils + { + /// + /// Returns date time in a year(yyyy), month(MM), day of week(0-6), + /// date (dd), hour (HH), minutes (mm) and seconds (ss) + /// + /// + /// + public static string ToPropertyDateTimeString(this DateTime dateTime) + { + var yearMonth = dateTime.ToString("yyyyMM"); + var date = dateTime.ToString("dd"); + var time = dateTime.ToString("HHmmss"); + + return yearMonth + $"{(int)dateTime.DayOfWeek}" + date + time; + } + } +} diff --git a/src/SocialServer/Commands/ChatCommands.Handlers.cs b/src/SocialServer/Commands/ChatCommands.Handlers.cs index 4193301a9..c2968f86e 100644 --- a/src/SocialServer/Commands/ChatCommands.Handlers.cs +++ b/src/SocialServer/Commands/ChatCommands.Handlers.cs @@ -1,4 +1,4 @@ -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; using Melia.Social.Database; using Melia.Social.Network; using Melia.Social.World; @@ -19,6 +19,7 @@ public ChatCommands() { this.Add("w", " ", "", this.HandleWhisper); this.Add("f", " ", "", this.HandleChatRoomChat); + this.Add("p", "", "", this.HandlePartyChat); } /// @@ -99,5 +100,37 @@ private CommandResult HandleChatRoomChat(SocialUser user, string message, string return CommandResult.Okay; } + + /// + /// Request to send a party chat message. + /// + /// + /// + /// + /// + /// + private CommandResult HandlePartyChat(SocialUser user, string message, string command, Arguments args) + { + if (args.Count < 1) + { + return CommandResult.Okay; + } + + var text = message.Substring(message.IndexOf(" ")); + + if (string.IsNullOrEmpty(text)) + return CommandResult.Okay; + + + if (!SocialServer.Instance.ChatManager.TryGetChatRoom(user.Character.PartyId, out var chatRoom)) + { + chatRoom = SocialServer.Instance.ChatManager.CreateChatRoom(user, user.Character.PartyId, ChatRoomType.Friends); + } + + var chatMessage = new ChatMessage(user, text, ChatMessageType.Party); + chatRoom.AddMessage(chatMessage); + + return CommandResult.Okay; + } } } diff --git a/src/SocialServer/Database/Character.cs b/src/SocialServer/Database/Character.cs index 36ef64460..950cb3cc4 100644 --- a/src/SocialServer/Database/Character.cs +++ b/src/SocialServer/Database/Character.cs @@ -71,6 +71,11 @@ public class Character /// public int ChannelId { get; set; } + /// + /// Gets or sets the character's party id. + /// + public long PartyId { get; set; } + /// /// Resets select properties that will make the character appear offline /// when included in a friend list refresh. diff --git a/src/SocialServer/Database/ChatMessage.cs b/src/SocialServer/Database/ChatMessage.cs index 89b46ceda..c3f0954e8 100644 --- a/src/SocialServer/Database/ChatMessage.cs +++ b/src/SocialServer/Database/ChatMessage.cs @@ -1,4 +1,4 @@ -using System; +using System; using Melia.Social.World; namespace Melia.Social.Database @@ -18,11 +18,21 @@ public class ChatMessage /// public string SenderTeamName { get; } + /// + /// Returns the team name of the message's target. + /// + public string TargetTeamName { get; } = string.Empty; + /// /// Returns the message that was sent. /// public string Message { get; } + /// + /// Returns the message type. + /// + public ChatMessageType Type { get; } + /// /// Returns the time the message was sent. /// @@ -33,11 +43,36 @@ public class ChatMessage /// /// /// - public ChatMessage(SocialUser sender, string message) + /// + public ChatMessage(SocialUser sender, string message, ChatMessageType type = ChatMessageType.Group) + { + this.SenderAccountId = sender.Id; + this.SenderTeamName = sender.TeamName; + this.Message = message; + this.Type = type; + } + + /// + /// Creates new chat message. + /// + /// + /// + /// + /// + public ChatMessage(SocialUser sender, SocialUser target, string message, ChatMessageType type = ChatMessageType.Group) { this.SenderAccountId = sender.Id; this.SenderTeamName = sender.TeamName; + this.TargetTeamName = target.TeamName; this.Message = message; + this.Type = type; } } + + public enum ChatMessageType + { + Party = 0, + Guild = 1, + Group = 2, + } } diff --git a/src/SocialServer/Database/ChatRoom.cs b/src/SocialServer/Database/ChatRoom.cs index 90a609b70..30d359ddd 100644 --- a/src/SocialServer/Database/ChatRoom.cs +++ b/src/SocialServer/Database/ChatRoom.cs @@ -1,7 +1,9 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; +using Melia.Shared.Network; using Melia.Social.Network; using Melia.Social.World; +using Yggdrasil.Logging; namespace Melia.Social.Database { @@ -62,9 +64,22 @@ public int MemberCount /// /// /// - public ChatRoom(string name, ChatRoomType type) + public ChatRoom(string name, ChatRoomType type) : this(0, name, type) { - this.Id = SocialServer.Instance.ChatManager.GetNewChatId(); + } + + /// + /// Creates new chat room with specific id. + /// + /// + /// + /// + public ChatRoom(long id, string name, ChatRoomType type) + { + if (id == 0) + this.Id = SocialServer.Instance.ChatManager.GetNewChatId(); + else + this.Id = id; this.Name = name; this.Type = type; @@ -179,6 +194,22 @@ public void AddMessage(ChatMessage message) } } + /// + /// Broadcasts packet to all members of the room who are online. + /// + /// + public virtual void Broadcast(Packet packet) + { + lock (_members) + { + foreach (var member in _members) + { + if (SocialServer.Instance.UserManager.TryGet(member.AccountId, out var user)) + user.Connection?.Send(packet); + } + } + } + /// /// Marks the room a having open invites, created by the given inviter. /// diff --git a/src/SocialServer/Database/SocialDb.cs b/src/SocialServer/Database/SocialDb.cs index 68b934535..c9e83dc5f 100644 --- a/src/SocialServer/Database/SocialDb.cs +++ b/src/SocialServer/Database/SocialDb.cs @@ -329,7 +329,7 @@ public void LoadCharacterInfo(SocialUser user) using (var conn = this.GetConnection()) { var query = @" - SELECT `c`.`characterId`, `c`.`name`, `c`.`teamName`, `c`.`level`, `c`.`job`, `c`.`gender`, `c`.`hair`, `c`.`skinColor`, `c`.`equipVisibility`, `c`.`zone` + SELECT `c`.`characterId`, `c`.`name`, `c`.`teamName`, `c`.`level`, `c`.`job`, `c`.`gender`, `c`.`hair`, `c`.`skinColor`, `c`.`equipVisibility`, `c`.`zone`, `c`.`partyId` FROM `accounts` AS `a` INNER JOIN `characters` AS `c` ON `a`.`loginCharacter` = `c`.`characterId` WHERE `a`.`accountId` = @accountId @@ -361,6 +361,8 @@ public void LoadCharacterInfo(SocialUser user) character.SkinColor = reader.GetUInt32("skinColor"); character.VisibleHats = (VisibleEquip)reader.GetInt32("equipVisibility"); character.MapId = reader.GetInt32("zone"); + + character.PartyId = reader.GetInt64("partyId") | ObjectIdRanges.Party; } } } diff --git a/src/SocialServer/Network/PacketHandlerChat.cs b/src/SocialServer/Network/PacketHandlerChat.cs index aac92ce01..ae885fb4a 100644 --- a/src/SocialServer/Network/PacketHandlerChat.cs +++ b/src/SocialServer/Network/PacketHandlerChat.cs @@ -1,6 +1,8 @@ -using System; +using System; +using System.Collections.Generic; using Melia.Shared.L10N; using Melia.Shared.Network; +using Melia.Shared.Game.Const; using Melia.Social.Database; using Yggdrasil.Logging; using Yggdrasil.Security.Hashing; @@ -74,6 +76,22 @@ public void CS_LOGIN(ISocialConnection conn, Packet packet) [PacketHandler(Op.CS_NORMAL_GAME_START)] public void CS_NORMAL_GAME_START(ISocialConnection conn, Packet packet) { + var user = conn.User; + + SocialServer.Instance.Database.LoadCharacterInfo(user); + user.Friends.RefreshList(); + user.Friends.RefreshStatus(); + + if (conn.User.Character.PartyId != 0) + { + if (!SocialServer.Instance.ChatManager.TryGetChatRoom(user.Character.PartyId, out var chatRoom)) + { + chatRoom = SocialServer.Instance.ChatManager.CreateChatRoom(user, user.Character.PartyId, ChatRoomType.Friends); + } + chatRoom.AddMember(user); + Send.SC_NORMAL.MessageList(conn, chatRoom, chatRoom.GetMessages()); + } + Send.SC_FROM_INTEGRATE.Unknown_01(conn.User); } @@ -316,7 +334,7 @@ public void CS_GROUP_CHAT_INVITE(ISocialConnection conn, Packet packet) /// server to accept join requests, as client doesn't seem to wait /// for a response. The invite tag is added to the chat input either /// way. The tag takes the following form in a whisper message: - /// + /// /// /w Name {a SLC 1@@@557516819791874}{#0000FF}{img link_whisper 24 24}New Chat1{/}{/}{/} /// /// @@ -468,5 +486,35 @@ public void CS_REDIS_SKILLPOINT(ISocialConnection conn, Packet packet) // What to do with this? Perhaps it's a job update of some sort? } + + [PacketHandler(Op.CS_PARTY_CLIENT_INFO_SEND)] + public void CS_PARTY_CLIENT_INFO_SEND(ISocialConnection conn, Packet packet) + { + var partyType = (GroupType)packet.GetByte(); + var partyId = packet.GetLong(); + var b1 = packet.GetByte(); + var accountId = packet.GetLong(); + var l1 = packet.GetLong(); + var sessionObjectId = packet.GetInt(); // Main Session Object (90000) + var propertySize = packet.GetInt(); + + switch (b1) + { + // Silver Gained by Party Member? + case 1: + break; + // Session Object Update? + case 2: + for (var i = 0; i < propertySize; i++) + { + var propId = packet.GetInt(); + var propValue = packet.GetInt(); + } + break; + // Some other party related update? + case 3: + break; + } + } } } diff --git a/src/SocialServer/Network/Send.Normal.cs b/src/SocialServer/Network/Send.Normal.cs index 0a1a7ff21..5a9c52568 100644 --- a/src/SocialServer/Network/Send.Normal.cs +++ b/src/SocialServer/Network/Send.Normal.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using Melia.Shared.Network; @@ -73,20 +73,20 @@ public static void AddMessage(ISocialConnection conn, ChatRoom chatRoom, ChatMes packet.PutInt(NormalOp.Social.AddMessage); packet.PutLong(chatRoom.Id); // Chat Id - packet.PutLong(1); // msg id? + packet.PutLong((long)chatRoom.Type); // msg id? packet.PutByte(1); packet.PutDate(chatMessage.SentTime); packet.PutLpString(chatMessage.SenderTeamName); packet.PutShort(1001); // server group? packet.PutLpString(chatMessage.Message); packet.PutByte(0); // 0 or 1 - packet.PutInt(2); + packet.PutInt((int)chatMessage.Type); packet.PutShort(1); packet.PutByte(0); - packet.PutLpString(""); + packet.PutLpString(chatMessage.TargetTeamName); packet.PutLpString("GLOBAL"); - conn.Send(packet); + chatRoom.Broadcast(packet); } /// diff --git a/src/SocialServer/World/ChatManager.cs b/src/SocialServer/World/ChatManager.cs index 3252f28a3..faf6449e0 100644 --- a/src/SocialServer/World/ChatManager.cs +++ b/src/SocialServer/World/ChatManager.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -89,14 +89,18 @@ public bool TryGetChatRoom(long chatId, out ChatRoom chatRoom) /// Creates a new chat room for the given user. /// /// + /// + /// /// - public ChatRoom CreateChatRoom(SocialUser creator) + public ChatRoom CreateChatRoom(SocialUser creator, long chatId = 0, ChatRoomType type = ChatRoomType.Group) { - var room = new ChatRoom("", ChatRoomType.Group); + var room = new ChatRoom(chatId, "", type); this.AddChatRoom(room); room.AddMember(creator); - room.AddMessage(new ChatMessage(creator, "!@#$NewRoomHasBeenCreated#@!")); + + if (chatId == 0) + room.AddMessage(new ChatMessage(creator, "!@#$NewRoomHasBeenCreated#@!")); return room; } diff --git a/src/SocialServer/World/UserManager.cs b/src/SocialServer/World/UserManager.cs index d9d950c6e..062a2920e 100644 --- a/src/SocialServer/World/UserManager.cs +++ b/src/SocialServer/World/UserManager.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using Melia.Shared.Network; @@ -173,6 +173,30 @@ public bool IsOnline(long accountId) return user.TryGetConnection(out _); } + /// + /// Returns all online users with the given party id. + /// + /// + /// + public List GetOnlineUsersByPartyId(long partyId) + { + var result = new List(); + + if (partyId == 0) + return result; + + lock (_users) + { + foreach (var user in _users.Values) + { + if (user.Character != null && user.Character.PartyId == partyId && user.TryGetConnection(out _)) + result.Add(user); + } + } + + return result; + } + /// /// Broadcasts packet to all active users. /// diff --git a/src/ZoneServer/Commands/ChatCommands.Handlers.cs b/src/ZoneServer/Commands/ChatCommands.Handlers.cs index de7a5e38c..bc1da27bd 100644 --- a/src/ZoneServer/Commands/ChatCommands.Handlers.cs +++ b/src/ZoneServer/Commands/ChatCommands.Handlers.cs @@ -44,6 +44,15 @@ public ChatCommands() this.Add("intewarpByToken", "", "", this.HandleTokenWarp); this.Add("mic", "", "", this.HandleMic); + // Client Party Commands + this.Add("memberinfoForAct", "", "", this.HandleMemberInfoForAct); + this.Add("partyleader", "", "", this.HandlePartyLeader); + this.Add("partymake", "", "", this.HandlePartyMake); + this.Add("partyname", "0 0 ", "", this.HandlePartyName); + this.Add("partyDirectInvite", "", "", this.HandlePartyInvite); + this.Add("partyban", "0 ", "", this.HandlePartyBan); + this.Add("pmyp", "0 ", "", this.HandlePartyMemberProperty); + // Custom Client Commands this.Add("buyshop", "", "", this.HandleBuyShop); this.Add("updatemouse", "", "", this.HandleUpdateMouse); @@ -2333,5 +2342,231 @@ private CommandResult HandleMedals(Character sender, Character target, string me return CommandResult.Okay; } + + /// + /// Official slash command to show party info for a character. + /// + /// + /// + /// + /// + /// + /// + private CommandResult HandleMemberInfoForAct(Character sender, Character target, string message, string commandName, Arguments args) + { + if (args.Count != 1) + { + Log.Debug("HandleMemberInfoForAct: Invalid call by user '{0}': {1}", sender.Username, commandName); + return CommandResult.Okay; + } + + var character = ZoneServer.Instance.World.GetCharacterByTeamName(args.Get(0)); + if (character != null) + { + if (character.Connection.Party != null) + { + Send.ZC_NORMAL.ShowParty(sender.Connection, character); + } + } + + return CommandResult.Okay; + } + + /// + /// Official slash command to change a party name. + /// + /// + /// + /// + /// + /// + /// + private CommandResult HandlePartyName(Character sender, Character target, string message, string commandName, Arguments args) + { + if (args.Count < 4) + { + Log.Debug("HandlePartyName: Invalid call by user '{0}': {1}", sender.Username, commandName); + return CommandResult.Okay; + } + + var party = sender.Connection.Party; + + if (party == null) + { + sender.SystemMessage("HadNotMyParty"); + return CommandResult.Okay; + } + + if (party.IsLeader(sender)) + { + var partyName = message.Substring(message.IndexOf(args.Get(2)) + args.Get(2).Length + 1); + // Client has an internal limit, additional safety check + if (partyName.Length > 2 && partyName.Length < 16) + sender.Connection.Party.ChangeName(partyName); + } + + return CommandResult.Okay; + } + + /// + /// Official slash command to create a party. + /// + /// + /// + /// + /// + /// + /// + private CommandResult HandlePartyMake(Character sender, Character target, string message, string commandName, Arguments args) + { + if (args.Count != 1) + { + Log.Debug("HandlePartyMake: Invalid call by user '{0}': {1}", sender.Username, commandName); + return CommandResult.Okay; + } + + if (sender.Connection.Party == null) + { + ZoneServer.Instance.World.Parties.Create(sender); + } + + return CommandResult.Okay; + } + + /// + /// Official slash command to invite a character to a party. + /// + /// + /// + /// + /// + /// + /// + private CommandResult HandlePartyInvite(Character sender, Character target, string message, string commandName, Arguments args) + { + if (args.Count != 1) + { + Log.Debug("HandlePartyInvite: Invalid call by user '{0}': {1}", sender.Username, commandName); + return CommandResult.Okay; + } + + var character = ZoneServer.Instance.World.GetCharacterByTeamName(args.Get(0)); + + if (character == null) + { + sender.SystemMessage("TargetUserNotExist"); + return CommandResult.Okay; + } + + // Can't invite a player that already has a party + if (character.Connection.Party != null) + { + sender.SystemMessage("{PC}AlreadyBelongsToParty", new MsgParameter("PC", character.TeamName)); + return CommandResult.Okay; + } + + Send.ZC_NORMAL.PartyInvite(character, sender, GroupType.Party); + + return CommandResult.Okay; + } + + /// + /// Official slash command to expel a member from a party. + /// + /// + /// + /// + /// + /// + /// + private CommandResult HandlePartyBan(Character sender, Character target, string message, string commandName, Arguments args) + { + if (args.Count != 2) + { + Log.Debug("HandlePartyBan: Invalid call by user '{0}': {1}", sender.Username, commandName); + return CommandResult.Okay; + } + + var teamName = args.Get(1); + var party = sender.Connection.Party; + + if (party == null) + { + sender.SystemMessage("HadNotMyParty"); + return CommandResult.Okay; + } + + party?.Expel(sender, teamName); + + return CommandResult.Okay; + } + + /// + /// Official slash command to change party leader. + /// + /// + /// + /// + /// + /// + /// + private CommandResult HandlePartyLeader(Character sender, Character target, string message, string commandName, Arguments args) + { + if (args.Count != 1) + { + Log.Debug("HandlePartyLeader: Invalid call by user '{0}': {1}", sender.Username, commandName); + return CommandResult.Okay; + } + + var teamName = args.Get(0); + var party = sender.Connection.Party; + + var character = ZoneServer.Instance.World.GetCharacterByTeamName(teamName); + + if (character == null) + { + sender.SystemMessage("TargetUserNotExist"); + return CommandResult.Okay; + } + + if (party == null) + { + sender.SystemMessage("HadNotMyParty"); + return CommandResult.Okay; + } + + if (!party.IsLeader(sender)) + { + return CommandResult.Okay; + } + + party.ChangeLeader(character); + + return CommandResult.Okay; + } + + /// + /// Official slash command to update party member properties. + /// + /// + /// + /// + /// + /// + /// + private CommandResult HandlePartyMemberProperty(Character sender, Character target, string message, string commandName, Arguments args) + { + var party = sender.Connection.Party; + if (party == null) + return CommandResult.Okay; + + if (args.Count != 4) + { + Log.Debug("HandlePartyMemberProperty: Invalid call by user '{0}': {1}", sender.Username, commandName); + return CommandResult.Okay; + } + + return CommandResult.Okay; + } } } diff --git a/src/ZoneServer/Database/ZoneDb.Social.cs b/src/ZoneServer/Database/ZoneDb.Social.cs new file mode 100644 index 000000000..112aa1bba --- /dev/null +++ b/src/ZoneServer/Database/ZoneDb.Social.cs @@ -0,0 +1,227 @@ +using System; +using Melia.Shared.Database; +using Melia.Shared.Game.Const; +using Melia.Shared.World; +using Melia.Zone.World; +using Melia.Zone.World.Actors.Characters; +using Melia.Zone.World.Groups; +using MySqlConnector; +using Yggdrasil.Logging; + +namespace Melia.Zone.Database +{ + /// + /// Contains methods related to Social (Party/Guild) persistence. + /// + public partial class ZoneDb + { + #region Party Methods + public void CreateParty(Party party) + { + using (var conn = this.GetConnection()) + using (var trans = conn.BeginTransaction()) + { + try + { + using (var cmd = new InsertCommand("INSERT INTO `party` {0}", conn, trans)) + { + party.DateCreated = DateTime.Now; + cmd.Set("name", party.Name); + cmd.Set("leaderId", party.LeaderDbId); + cmd.Set("note", party.Note); + cmd.Set("questSharing", (int)party.QuestSharing); + cmd.Set("expDistribution", (int)party.ExpDistribution); + cmd.Set("itemDistribution", (int)party.ItemDistribution); + cmd.Set("dateCreated", party.DateCreated); + cmd.Execute(); + party.DbId = cmd.LastId; + } + + using (var cmdUpdateLeader = new UpdateCommand("UPDATE `characters` SET {0} WHERE `characterId` = @characterId", conn, trans)) + { + cmdUpdateLeader.AddParameter("@characterId", party.LeaderDbId); + cmdUpdateLeader.Set("partyId", party.DbId); + if (cmdUpdateLeader.Execute() == 0) + { + Log.Error($"CreateParty: Failed to update partyId for leader character {party.LeaderDbId}."); + throw new InvalidOperationException($"Failed to update leader character {party.LeaderDbId}"); + } + } + trans.Commit(); + } + catch (Exception ex) + { + Log.Error($"CreateParty: Transaction failed (Leader: {party.LeaderDbId}): {ex}"); + try { trans.Rollback(); } catch (Exception rbEx) { Log.Error($"CreateParty: Rollback failed: {rbEx}"); } + throw; + } + } + } + + public void LoadParty(Character character) + { + if (character.PartyId <= 0) + return; + var party = ZoneServer.Instance.World.GetParty(character.PartyId); + if (party == null) + { + using (var conn = this.GetConnection()) + using (var mc = new MySqlCommand("SELECT * FROM `party` WHERE `partyId` = @partyId", conn)) + { + mc.Parameters.AddWithValue("@partyId", character.PartyId); + using (var reader = mc.ExecuteReader()) + { + if (reader.Read()) + { + party = new Party(reader.GetInt64("partyId"), reader.GetInt64("leaderId"), reader.GetString("name"), reader.GetDateTime("dateCreated"), reader.GetString("note"), + (PartyItemDistribution)reader.GetByte("itemDistribution"), (PartyExpDistribution)reader.GetByte("expDistribution"), (PartyQuestSharing)reader.GetByte("questSharing")); + this.LoadPartyMembers(character, party); + ZoneServer.Instance.World.Parties.Add(party); + } + } + } + } + else + { + if (party.TryGetMember(character.ObjectId, out var member)) + member.IsOnline = true; + } + } + + private void LoadPartyMembers(Character loadCharacter, Party party) + { + using (var conn = this.GetConnection()) + { + var offlineMembers = new System.Collections.Generic.List(); + + using (var mc = new MySqlCommand("SELECT * FROM `characters` WHERE `partyId` = @partyId", conn)) + { + mc.Parameters.AddWithValue("@partyId", party.DbId); + using (var reader = mc.ExecuteReader()) + { + while (reader.Read()) + { + var character = ZoneServer.Instance.World.GetCharacter(c => c.DbId == reader.GetInt64("characterId")); + if (character == null) + { + var member = new PartyMember + { + DbId = reader.GetInt64("characterId"), + AccountId = reader.GetInt64("accountId"), + Name = reader.GetString("name"), + TeamName = reader.GetString("teamName"), + VisualJobId = (JobId)reader.GetInt16("job"), + Gender = (Gender)reader.GetByte("gender"), + Hair = reader.GetInt32("hair"), + MapId = reader.GetInt32("zone"), + Level = reader.GetInt32("level"), + }; + var x = reader.GetFloat("x"); + var y = reader.GetFloat("y"); + var z = reader.GetFloat("z"); + member.Position = new Position(x, y, z); + member.IsOnline = loadCharacter.DbId == member.DbId; + offlineMembers.Add(member); + } + else + party.AddMember(character, true); + } + } + } + + // Load jobs for offline members + foreach (var member in offlineMembers) + { + using (var mc = new MySqlCommand("SELECT `jobId` FROM `jobs` WHERE `characterId` = @characterId ORDER BY `selectionDate` ASC", conn)) + { + mc.Parameters.AddWithValue("@characterId", member.DbId); + using (var reader = mc.ExecuteReader()) + { + var i = 0; + while (reader.Read()) + { + var jobId = (JobId)reader.GetInt32("jobId"); + member.VisualJobId = jobId; + switch (i) + { + case 0: + member.FirstJobId = jobId; + break; + case 1: + member.SecondJobId = jobId; + break; + case 2: + member.ThirdJobId = jobId; + break; + case 3: + member.FourthJobId = jobId; + break; + } + i++; + } + } + } + party.AddMember(member); + } + } + } + + public void SaveParty(Character character) + { + var party = character.Connection.Party; + if (party == null || !party.IsLeader(character.ObjectId)) + return; + using (var conn = this.GetConnection()) + using (var trans = conn.BeginTransaction()) + { + using (var cmd = new UpdateCommand("UPDATE `party` SET {0} WHERE `partyId` = @partyId", conn, trans)) + { + cmd.AddParameter("@partyId", party.DbId); + cmd.Set("name", party.Name); + cmd.Set("leaderId", party.LeaderDbId); + cmd.Set("note", party.Note); + cmd.Set("questSharing", (int)party.QuestSharing); + cmd.Set("expDistribution", (int)party.ExpDistribution); + cmd.Set("itemDistribution", (int)party.ItemDistribution); + cmd.Execute(); + } + trans.Commit(); + } + } + + public void DeleteParty(Party party) + { + using (var conn = this.GetConnection()) + using (var trans = conn.BeginTransaction()) + { + using (var cmd = new MySqlCommand("DELETE FROM `party` WHERE `partyId` = @partyId", conn, trans)) + { + cmd.Parameters.AddWithValue("@partyId", party.DbId); + cmd.ExecuteNonQuery(); + } + foreach (var member in party.GetMembers()) + { + using (var cmd = new UpdateCommand("UPDATE `characters` SET {0} WHERE `characterId` = @characterId", conn, trans)) + { + cmd.AddParameter("@characterId", member.DbId); + cmd.Set("partyId", 0); + cmd.Execute(); + } + } + trans.Commit(); + } + } + + public void UpdatePartyId(long dbId, long partyId = 0) + { + using (var conn = this.GetConnection()) + using (var cmd = new UpdateCommand("UPDATE `characters` SET {0} WHERE `characterId` = @characterId", conn)) + { + cmd.AddParameter("@characterId", dbId); + cmd.Set("partyId", partyId); + cmd.Execute(); + } + } + #endregion + } +} diff --git a/src/ZoneServer/Database/ZoneDb.cs b/src/ZoneServer/Database/ZoneDb.cs index b08d0afee..f73b0edc8 100644 --- a/src/ZoneServer/Database/ZoneDb.cs +++ b/src/ZoneServer/Database/ZoneDb.cs @@ -25,7 +25,7 @@ namespace Melia.Zone.Database { - public class ZoneDb : MeliaDb + public partial class ZoneDb : MeliaDb { /// /// Saves account. @@ -158,6 +158,7 @@ public Character GetCharacter(long accountId, long characterId) var z = reader.GetFloat("z"); character.Position = new Position(x, y, z); character.Direction = new Direction(0); + character.PartyId = reader.GetInt64("partyId"); } } @@ -173,6 +174,7 @@ public Character GetCharacter(long accountId, long characterId) this.LoadProperties("character_properties", "characterId", character.DbId, character.Properties); this.LoadProperties("character_etc_properties", "characterId", character.DbId, character.Etc.Properties); this.LoadCollections(character); + this.LoadParty(character); // Initialize the properties to trigger calculated properties // and to set some properties in case the character is new and @@ -418,6 +420,7 @@ public void SaveCharacter(Character character) cmd.Set("equipVisibility", character.VisibleEquip); cmd.Set("stamina", character.Properties.Stamina); cmd.Set("silver", character.Inventory.CountItem(ItemId.Silver)); + cmd.Set("partyId", character.Connection.Party?.DbId ?? 0); cmd.Execute(); } @@ -436,6 +439,7 @@ public void SaveCharacter(Character character) this.SaveBuffs(character); this.SaveCooldowns(character); this.SaveQuests(character); + this.SaveParty(character); } /// diff --git a/src/ZoneServer/Events/ServerEvents.cs b/src/ZoneServer/Events/ServerEvents.cs index bae09503a..c97485436 100644 --- a/src/ZoneServer/Events/ServerEvents.cs +++ b/src/ZoneServer/Events/ServerEvents.cs @@ -130,6 +130,11 @@ public class ServerEvents /// public readonly Event PlayerDialog = new(); + /// + /// Raised when a player leaves a party. + /// + public readonly Event PlayerLeftParty = new(); + // Combat Events //------------------------------------------------------------------- diff --git a/src/ZoneServer/Network/Helpers/PartyHelper.cs b/src/ZoneServer/Network/Helpers/PartyHelper.cs new file mode 100644 index 000000000..f4ee00b49 --- /dev/null +++ b/src/ZoneServer/Network/Helpers/PartyHelper.cs @@ -0,0 +1,67 @@ +using Melia.Shared.Network; +using Melia.Shared.Network.Helpers; +using Melia.Zone.World.Actors.Characters; +using Melia.Zone.World.Groups; + +namespace Melia.Zone.Network.Helpers +{ + public static class PartyHelper + { + public static void AddMember(this Packet packet, IMember member) + { + packet.PutLong(member.AccountObjectId); + packet.PutString(member.TeamName, 64); + packet.PutLong(0); + packet.PutInt(0); + packet.PutInt(0); + packet.PutInt(member.IsOnline ? member.MapId : 0); + packet.PutInt(member.Handle); + packet.PutString(member.TeamName, 64); + packet.PutString(member.Name, 64); + packet.PutByte(0); + packet.PutByte(0x40); + packet.PutShort((short)member.VisualJobId); + packet.PutShort((short)member.FirstJobId); + packet.PutShort(0); + packet.PutInt(member.JobLevel); + packet.PutShort((short)member.Gender); + packet.PutShort((short)member.Hair); + packet.PutInt(0); + packet.PutEmptyBin(16); + packet.PutInt(member.ServerGroup); + packet.PutInt(member.Level); + packet.PutByte(0x80); + packet.PutByte(0x80); + packet.PutByte(0x80); + packet.PutByte(0xFF); + packet.PutShort((short)member.FirstJobId); + packet.PutShort((short)member.SecondJobId); + packet.PutShort((short)member.ThirdJobId); + packet.PutShort((short)member.FourthJobId); + packet.PutEmptyBin(4); + packet.PutShort(0); + packet.PutShort(1); + packet.PutInt(member.Level); + packet.PutPosition(member.Position); + packet.PutInt(member.Sp); + packet.PutInt(member.Hp); + packet.PutInt(member.MaxSp); + packet.PutInt(member.MaxHp); + packet.PutInt(1); + packet.PutInt(member.Level); + packet.PutShort(0); + } + + public static void AddPartyInstantMemberInfo(this Packet packet, IMember member) + { + packet.PutLong(member.AccountObjectId); + packet.PutPosition(member.Position); + packet.PutInt(member.Sp); + packet.PutInt(member.Hp); + packet.PutInt(member.MaxSp); + packet.PutInt(member.MaxHp); + packet.PutInt(0); + packet.PutInt(member.Level); + } + } +} diff --git a/src/ZoneServer/Network/PacketHandler.cs b/src/ZoneServer/Network/PacketHandler.cs index 652efadf3..d514461b6 100644 --- a/src/ZoneServer/Network/PacketHandler.cs +++ b/src/ZoneServer/Network/PacketHandler.cs @@ -110,6 +110,15 @@ public void CZ_CONNECT(IZoneConnection conn, Packet packet) character.Connection = conn; conn.SelectedCharacter = character; + // Get party + var existingParty = ZoneServer.Instance.World.Parties.FindPartyByAccountId(conn.Account.Id, out var oldMember); + if (existingParty != null && oldMember != null && oldMember.DbId != character.DbId) + { + // The account is already in a party with a different character + existingParty.ReplaceCharacter(oldMember, character); + } + conn.Party = existingParty; + ZoneServer.Instance.ServerEvents.PlayerLoggedIn.Raise(new PlayerEventArgs(character)); map.AddCharacter(character); @@ -229,6 +238,30 @@ public void CZ_GAME_READY(IZoneConnection conn, Packet packet) // will display the restored cooldowns Send.ZC_COOLDOWN_LIST(character, character.Components.Get().GetAll()); + // Handle party reconnection if the character has one + if (conn.Party != null) + { + // Deletes the party if the leader is null for some reason (this may happens on instance dungeons parties) + if (conn.Party.Owner == null) + { + var partyManager = ZoneServer.Instance.World.Parties; + + foreach (var partyMemberCharacter in conn.Party.GetPartyMembers()) + { + conn.Party.RemoveMember(partyMemberCharacter); + } + + if (conn.Party.MemberCount == 0) + partyManager.Delete(conn.Party); + } + else + { + Send.ZC_PARTY_INFO(character, conn.Party); + Send.ZC_PARTY_LIST(conn.Party); + conn.Party.UpdateMember(character, true); + } + } + character.OpenEyes(); ZoneServer.Instance.ServerEvents.PlayerReady.Raise(new PlayerEventArgs(character)); @@ -3087,5 +3120,89 @@ public void CZ_BUFF_REMOVE(IZoneConnection conn, Packet packet) character.StopBuff(buffId); } + + /// + /// Sent when accepting a party invite. + /// + /// + /// + [PacketHandler(Op.CZ_PARTY_INVITE_ACCEPT)] + public void CZ_PARTY_INVITE_ACCEPT(IZoneConnection conn, Packet packet) + { + var b1 = packet.GetByte(); + var teamName = packet.GetString(); + var character = conn.SelectedCharacter; + var sender = ZoneServer.Instance.World.GetCharacterByTeamName(teamName); + + if (sender != null) + { + var party = sender.Connection.Party; + party ??= ZoneServer.Instance.World.Parties.Create(sender); + party.AddMember(character); + } + } + + /// + /// Sent when canceling a party invite. + /// + /// + /// + [PacketHandler(Op.CZ_PARTY_INVITE_CANCEL)] + public void CZ_PARTY_INVITE_CANCEL(IZoneConnection conn, Packet packet) + { + var b1 = packet.GetByte(); + var teamName = packet.GetString(); + + var character = conn.SelectedCharacter; + var partyInviter = ZoneServer.Instance.World.GetCharacterByTeamName(teamName); + + if (partyInviter != null) + { + Send.ZC_ADDON_MSG(partyInviter, AddonMessage.PARTY_INVITE_CANCEL, 0, character.TeamName); + } + } + + /// + /// Sent when leaving a party. + /// + /// + /// + [PacketHandler(Op.CZ_PARTY_OUT)] + public void CZ_PARTY_OUT(IZoneConnection conn, Packet packet) + { + var character = conn.SelectedCharacter; + var party = character.Connection.Party; + + if (party != null) + { + party.RemoveMember(character); + if (party.MemberCount == 0) + ZoneServer.Instance.World.Parties.Delete(party); + } + } + + /// + /// Sent when changing party properties/settings. + /// + /// + /// + [PacketHandler(Op.CZ_PARTY_PROP_CHANGE)] + public void CZ_PARTY_PROP_CHANGE(IZoneConnection conn, Packet packet) + { + var b1 = packet.GetByte(); + var type = packet.GetInt(); + var b2 = packet.GetByte(); + var b3 = packet.GetByte(); + var s1 = packet.GetShort(); + var value = packet.GetString(); + + var character = conn.SelectedCharacter; + var party = character.Connection.Party; + + if (party != null && party.LeaderDbId == character.DbId) + { + party.UpdateSetting(type, value); + } + } } } diff --git a/src/ZoneServer/Network/Send.Normal.cs b/src/ZoneServer/Network/Send.Normal.cs index ebb09e5e9..29a6021e0 100644 --- a/src/ZoneServer/Network/Send.Normal.cs +++ b/src/ZoneServer/Network/Send.Normal.cs @@ -2,6 +2,7 @@ using Melia.Shared.Game.Const; using Melia.Shared.Network; using Melia.Shared.Network.Helpers; +using Melia.Shared.ObjectProperties; using Melia.Shared.World; using Melia.Zone.Network.Helpers; using Melia.Zone.World.Actors; @@ -9,6 +10,7 @@ using Melia.Zone.World.Actors.Characters.Components; using Melia.Zone.World.Actors.Monsters; using Melia.Zone.World.Actors.Pads; +using Melia.Zone.World.Groups; namespace Melia.Zone.Network { @@ -1357,6 +1359,202 @@ public static void OpenBook(Character character, string bookName) character.Connection.Send(packet); } + + /// + /// Sends party member data to all party members. + /// + /// + /// + public static void PartyMemberData(IMember member, IGroup group) + { + var packet = new Packet(Op.ZC_NORMAL); + + packet.PutInt(NormalOp.Zone.PartyMemberData); + packet.PutByte(member.IsOnline); + packet.PutByte((byte)group.Type); + packet.PutLong(group.ObjectId); + packet.PutLong(member.AccountObjectId); + packet.AddMember(member); + + group.Broadcast(packet); + } + + /// + /// Notifies all party members of a leader change. + /// + /// + /// + public static void PartyLeaderChange(IGroup group, long leaderId) + { + var packet = new Packet(Op.ZC_NORMAL); + + packet.PutInt(NormalOp.Zone.PartyLeaderChange); + packet.PutByte((byte)group.Type); + packet.PutLong(group.ObjectId); + packet.PutLong(leaderId); + + group.Broadcast(packet); + } + + /// + /// Server response on Party Name Change + /// + /// + public static void PartyNameChange(IGroup group) + { + var packet = new Packet(Op.ZC_NORMAL); + packet.PutInt(NormalOp.Zone.PartyNameChange); + packet.PutByte((byte)group.Type); + packet.PutLong(group.ObjectId); + packet.PutInt(0); + packet.PutLong(group.Owner.ObjectId); + packet.PutLpString(group.Name); + packet.PutInt(1); + packet.PutByte(1); + + group.Broadcast(packet); + } + + /// + /// Server response on Party Property Change + /// + /// + /// + /// + public static void PartyPropertyUpdate(IGroup group, int propertyId, string propertyValue) + { + var packet = new Packet(Op.ZC_NORMAL); + packet.PutInt(NormalOp.Zone.PartyPropertyChange); + packet.PutByte((byte)group.Type); + packet.PutLong(group.ObjectId); + packet.PutInt(propertyId); + packet.PutLpString(propertyValue); + + group.Broadcast(packet); + } + + /// + /// Server response on Party Property Change + /// + /// + /// + public static void PartyPropertyUpdate(IGroup group, PropertyList properties) + { + var packet = new Packet(Op.ZC_NORMAL); + packet.PutInt(NormalOp.Zone.PartyPropertyChange); + packet.PutByte((byte)group.Type); + packet.PutLong(group.ObjectId); + packet.AddProperties(properties); + + group.Broadcast(packet); + } + + /// + /// Sends party invite UI to player. + /// + /// + /// + /// + public static void PartyInvite(Character character, Character sender, GroupType partyType) + { + var packet = new Packet(Op.ZC_NORMAL); + + packet.PutInt(NormalOp.Zone.PartyInvite); + packet.PutByte((byte)partyType); + packet.PutLong(sender.AccountObjectId); + packet.PutLpString(sender.TeamName); + + character.Connection.Send(packet); + } + + /// + /// Shows party name above character's head. + /// + /// + public static void ShowParty(Character character) + { + var party = character.Connection.Party; + + var packet = new Packet(Op.ZC_NORMAL); + packet.PutInt(NormalOp.Zone.ShowParty); + + packet.PutInt(character.Handle); + if (party != null) + { + packet.PutByte(1); + packet.PutLpString(party.Name); + packet.PutByte(3); + } + else + { + packet.PutByte(3); + } + + character.Map.Broadcast(packet, character); + } + + /// + /// Shows party name for a character on a specific connection. + /// + /// + /// + public static void ShowParty(IZoneConnection conn, Character character) + { + var party = character.Connection.Party; + var packet = new Packet(Op.ZC_NORMAL); + packet.PutInt(NormalOp.Zone.ShowParty); + + packet.PutInt(character.Handle); + if (party != null) + { + packet.PutByte(1); + packet.PutLpString(party.Name); + packet.PutByte(3); + } + else + { + packet.PutByte(3); + } + + conn.Send(packet); + } + + /// + /// Server response on Party Member Property Change + /// + /// + /// + /// + public static void PartyMemberPropertyUpdate(IGroup group, Character character, PropertyList properties) + { + var packet = new Packet(Op.ZC_NORMAL); + + packet.PutInt(NormalOp.Zone.PartyMemberPropertyChange); + packet.PutByte((byte)group.Type); + packet.PutLong(group.ObjectId); + packet.PutLong(character.ObjectId); + packet.AddProperties(properties); + + group.Broadcast(packet); + } + + /// + /// Updates member map status for all party members. + /// + /// + /// + public static void MemberMapStatusUpdate(IGroup group, IMember member) + { + var packet = new Packet(Op.ZC_NORMAL); + packet.PutInt(NormalOp.Zone.MemberMapStatusUpdate); + + packet.PutByte((byte)group.Type); + packet.PutLong(member.AccountObjectId); + packet.PutShort(member.IsOnline ? member.MapId : 0); + packet.PutShort(member.IsOnline ? member.Channel : 0); + + group.Broadcast(packet); + } } } } diff --git a/src/ZoneServer/Network/Send.cs b/src/ZoneServer/Network/Send.cs index 8adb3d947..84208bd84 100644 --- a/src/ZoneServer/Network/Send.cs +++ b/src/ZoneServer/Network/Send.cs @@ -21,6 +21,7 @@ using Melia.Zone.World.Actors.Characters.Components; using Melia.Zone.World.Actors.CombatEntities.Components; using Melia.Zone.World.Actors.Monsters; +using Melia.Zone.World.Groups; using Melia.Zone.World.Items; using Melia.Zone.World.Maps; using Yggdrasil.Extensions; @@ -135,6 +136,14 @@ public static void ZC_MYPC_ENTER(Character character) /// public static void ZC_ENTER_PC(IZoneConnection conn, Character character) { + var relation = conn.SelectedCharacter.GetRelation(character); + + // Map Party to Friendly for display purposes + if (relation == RelationType.Party) + relation = RelationType.Friendly; + + var relationship = (byte)Math.Clamp((int)relation, 0, 2); + var packet = new Packet(Op.ZC_ENTER_PC); packet.PutInt(character.Handle); @@ -143,7 +152,8 @@ public static void ZC_ENTER_PC(IZoneConnection conn, Character character) packet.PutFloat(character.Position.Z); packet.PutFloat(character.Direction.Cos); packet.PutFloat(character.Direction.Sin); - packet.PutShort(0); + packet.PutByte(relationship); // Changes name color (0 = Green/Friendly, 1 = Enemy, 2 = Neutral) + packet.PutByte(0); packet.PutLong(character.SocialUserId); packet.PutByte(0); // Pose packet.PutFloat(character.Properties.GetFloat(PropertyName.MSPD)); @@ -2500,6 +2510,22 @@ public static void ZC_HEAL_INFO(ICombatEntity entity, float amount, float maxHp, entity.Map.Broadcast(packet, entity); } + /// + /// Updates the relation type between the client and an entity. + /// + /// + /// + /// + public static void ZC_CHANGE_RELATION(IZoneConnection conn, int handle, RelationType relation) + { + var packet = new Packet(Op.ZC_CHANGE_RELATION); + + packet.PutInt(handle); + packet.PutByte((byte)relation); + + conn.Send(packet); + } + /// /// Updates the stance of a character. /// @@ -4550,5 +4576,124 @@ public static void ZC_ACTION_PKS(IActor toActor, IActor fromActor, byte type, in toActor.Map.Broadcast(packet); } + + /// + /// Sends party information to character. + /// + /// + /// + public static void ZC_PARTY_INFO(Character character, IGroup group) + { + var propertyList = group.Properties.GetAll(); + var propertiesSize = propertyList.GetByteCount(); + + var packet = new Packet(Op.ZC_PARTY_INFO); + packet.PutByte((byte)group.Type); + packet.PutByte(0); + packet.PutDate(group.DateCreated); + packet.PutLong(group.ObjectId); + packet.PutLpString(group.Name); + packet.PutLong(group.Owner?.AccountObjectId ?? 0); + packet.PutLpString(group.Owner?.TeamName ?? ""); + packet.PutInt(0); + packet.PutInt(1); + packet.PutShort((short)propertiesSize); + packet.AddProperties(propertyList); + + character.Connection.Send(packet); + } + + /// + /// Broadcasts party member list to all party members. + /// + /// + public static void ZC_PARTY_LIST(IGroup group) + { + var members = group.GetMembers(); + + var packet = new Packet(Op.ZC_PARTY_LIST); + packet.PutLong(0); + packet.PutByte((byte)group.Type); + packet.PutLong(group.ObjectId); + packet.PutByte((byte)members.Count); + foreach (var member in members) + packet.AddMember(member); + + group.Broadcast(packet); + } + + /// + /// Broadcasts party enter notification to all party members. + /// + /// + /// + public static void ZC_PARTY_ENTER(Character character, IGroup group) + { + var packet = new Packet(Op.ZC_PARTY_ENTER); + + packet.PutByte((byte)group.Type); + packet.PutLong(group.ObjectId); + packet.AddMember(group.ToMember(character)); + packet.PutShort(0); + + group.Broadcast(packet); + } + + /// + /// Sends party leave notification to character. + /// + /// + /// + public static void ZC_PARTY_OUT(Character character, IGroup group) + { + var packet = new Packet(Op.ZC_PARTY_OUT); + + packet.PutByte((byte)group.Type); + packet.PutLong(group.ObjectId); + packet.PutLong(character.AccountId | ObjectIdRanges.Account); + packet.PutByte(0); + + character.Connection.Send(packet); + } + + /// + /// Broadcasts party leave notification to all party members. + /// + /// + /// + public static void ZC_PARTY_OUT(IGroup group, IMember member) + { + var packet = new Packet(Op.ZC_PARTY_OUT); + + packet.PutByte((byte)group.Type); + packet.PutLong(group.ObjectId); + packet.PutLong(member.AccountObjectId); + packet.PutByte(0); + + group.Broadcast(packet); + } + + /// + /// Broadcasts instant party information to all party members. + /// + /// + public static void ZC_PARTY_INST_INFO(IGroup group) + { + var members = group.GetMembers(); + + var packet = new Packet(Op.ZC_PARTY_INST_INFO); + + packet.PutByte((byte)group.Type); + packet.PutInt(members.Count); + foreach (var member in members) + packet.AddPartyInstantMemberInfo(member); + packet.PutInt(0); + packet.PutInt(0); + packet.PutInt(0); + packet.PutInt(0); + packet.PutByte(0); + + group.Broadcast(packet); + } } } diff --git a/src/ZoneServer/Network/ZoneConnection.cs b/src/ZoneServer/Network/ZoneConnection.cs index 5c3f4ea1c..fe5635548 100644 --- a/src/ZoneServer/Network/ZoneConnection.cs +++ b/src/ZoneServer/Network/ZoneConnection.cs @@ -2,6 +2,7 @@ using Melia.Shared.Network; using Melia.Zone.Database; using Melia.Zone.Scripting.Dialogues; +using Melia.Zone.World; using Melia.Zone.World.Actors.Characters; using Melia.Zone.World.Actors.CombatEntities.Components; using Yggdrasil.Logging; @@ -32,6 +33,11 @@ public interface IZoneConnection : IConnection /// /// Saves the account and character associated with this connection. /// + /// + /// Gets or sets the current party. + /// + Party Party { get; set; } + void SaveAccountAndCharacter(); } @@ -55,6 +61,11 @@ public class ZoneConnection : Connection, IZoneConnection /// public Dialog CurrentDialog { get; set; } + /// + /// Gets or sets the current party. + /// + public Party Party { get; set; } + /// /// Handles the given packet for this connection. /// @@ -75,6 +86,10 @@ protected override void OnClosed(ConnectionCloseType type) var character = this.SelectedCharacter; var justSaved = character?.SavedForWarp ?? false; + // Notify party members that this character has gone offline + if (character != null) + this.Party?.UpdateMember(character, false); + // We have two situations in which we want to save: On logout, // and when the connection is closed somewhat unexpectedly. // This save is for the latter case, but we only want to do diff --git a/src/ZoneServer/World/Actors/Characters/Character.Social.cs b/src/ZoneServer/World/Actors/Characters/Character.Social.cs new file mode 100644 index 000000000..828557e75 --- /dev/null +++ b/src/ZoneServer/World/Actors/Characters/Character.Social.cs @@ -0,0 +1,27 @@ +// =================================================================== +// CharacterSocial.cs - Communication, party, and social features +// =================================================================== +using System.Collections.Generic; +using Melia.Zone.Network; + +namespace Melia.Zone.World.Actors.Characters +{ + public partial class Character + { + /// + /// Gets all party members within a specified range. + /// + public IEnumerable GetPartyMembersInRange(float range = 0, bool areAlive = true) + { + return this.Map.GetPartyMembersInRange(this, this.Position, range, areAlive); + } + + /// + /// Sends an addon message to the client. + /// + public void AddonMessage(string function, string stringParameter = null, int intParameter = 0) + { + Send.ZC_ADDON_MSG(this, function, intParameter, stringParameter); + } + } +} diff --git a/src/ZoneServer/World/Actors/Characters/Character.cs b/src/ZoneServer/World/Actors/Characters/Character.cs index 0d2d659c9..a5d43b342 100644 --- a/src/ZoneServer/World/Actors/Characters/Character.cs +++ b/src/ZoneServer/World/Actors/Characters/Character.cs @@ -27,7 +27,7 @@ namespace Melia.Zone.World.Actors.Characters /// /// Represents a player character. /// - public class Character : Actor, IActor, ICombatEntity, ICommander, IPropertyObject, IUpdateable + public partial class Character : Actor, IActor, ICombatEntity, ICommander, IPropertyObject, IUpdateable { private bool _warping; private int _destinationChannelId; @@ -87,6 +87,22 @@ public class Character : Actor, IActor, ICombatEntity, ICommander, IPropertyObje /// public long AccountId { get; set; } + /// + /// Returns the character's account object id. + /// + public long AccountObjectId => ObjectIdRanges.Account + this.AccountId; + + /// + /// Returns the character's party id. + /// + public long PartyId { get; set; } + + /// + /// Returns true if the character has a party. + /// + public bool HasParty + => this.Connection?.Party != null; + /// /// Returns the character's faction. /// @@ -690,6 +706,7 @@ public void LevelUp(int amount = 1) Send.ZC_OBJECT_PROPERTY(this); Send.ZC_ADDON_MSG(this, "NOTICE_Dm_levelup_base", 3, "!@#$Auto_KaeLigTeo_LeBeli_SangSeungHayeossSeupNiDa#@!"); Send.ZC_NORMAL.PlayEffect(this, "F_pc_level_up", 3); + this.Connection.Party?.UpdateMemberInfo(this); } /// @@ -806,6 +823,7 @@ public void ModifyHpSafe(float amount, out float newHp, out int priority) newHp = (int)this.Properties.Modify(PropertyName.HP, amount); priority = (this.HpChangeCounter += 1); } + this.Connection.Party?.UpdateMemberInfo(this); } /// @@ -817,6 +835,7 @@ public void ModifyHp(float amount) { this.ModifyHpSafe(amount, out var hp, out var priority); Send.ZC_ADD_HP(this, amount, hp, priority); + this.Connection.Party?.UpdateMemberInfo(this); } /// @@ -828,6 +847,7 @@ public void ModifySp(float amount) { var sp = this.Properties.Modify(PropertyName.SP, amount); Send.ZC_UPDATE_SP(this, sp, true); + this.Connection.Party?.UpdateMemberInfo(this); } /// diff --git a/src/ZoneServer/World/Actors/CombatEntities/Components/MovementComponent.cs b/src/ZoneServer/World/Actors/CombatEntities/Components/MovementComponent.cs index 02bc99b6b..b4f3f0910 100644 --- a/src/ZoneServer/World/Actors/CombatEntities/Components/MovementComponent.cs +++ b/src/ZoneServer/World/Actors/CombatEntities/Components/MovementComponent.cs @@ -315,6 +315,9 @@ internal void NotifyMove(Position pos, Direction dir, float unkFloat) this.MoveTarget = MoveTargetType.Direction; Send.ZC_MOVE_DIR(this.Entity, pos, dir, unkFloat); + + if (this.Entity is Character character) + character.Connection.Party?.UpdateMemberInfo(character); } /// diff --git a/src/ZoneServer/World/Actors/Entity.cs b/src/ZoneServer/World/Actors/Entity.cs index 96846d6e3..8b41ac51f 100644 --- a/src/ZoneServer/World/Actors/Entity.cs +++ b/src/ZoneServer/World/Actors/Entity.cs @@ -164,6 +164,45 @@ public static bool IsHostileFaction(this ICombatEntity entity, ICombatEntity oth return isHostileFaction; } + /// + /// Returns the relation between two entities. + /// + /// + /// + /// + public static RelationType GetRelation(this ICombatEntity entity, ICombatEntity target) + { + // Entity is always friendly to itself + if (entity == target) + return RelationType.Friendly; + + // Monsters are friendly to each other if same faction + if (target is IMonster monster) + { + if (entity.Faction == target.Faction) + return RelationType.Friendly; + return (RelationType)monster.MonsterType; + } + + // Check relations between entities of the same faction + if (entity.Faction == target.Faction) + { + if (entity is Character character && target is Character targetCharacter) + { + // Check if both characters are in the same party + if (character.HasParty && targetCharacter.HasParty + && character.Connection.Party == targetCharacter.Connection.Party) + return RelationType.Party; + } + + return RelationType.Neutral; + } + + var isHostile = entity.IsHostileFaction(target); + + return isHostile ? RelationType.Enemy : RelationType.Neutral; + } + /// /// Makes the entity turn towards the actor if it's not null. /// diff --git a/src/ZoneServer/World/Actors/Monsters/Mob.cs b/src/ZoneServer/World/Actors/Monsters/Mob.cs index 32593cf82..d2b7e9531 100644 --- a/src/ZoneServer/World/Actors/Monsters/Mob.cs +++ b/src/ZoneServer/World/Actors/Monsters/Mob.cs @@ -307,7 +307,10 @@ public void Kill(ICombatEntity killer) this.GetExpToGive(out var exp, out var jobExp); this.DropItems(beneficiary); - beneficiary?.GiveExp(exp, jobExp, this); + if (beneficiary.Connection.Party != null) + beneficiary.Connection.Party.GiveExp(beneficiary, exp, jobExp, this); + else + beneficiary?.GiveExp(exp, jobExp, this); } this.Died?.Invoke(this, killer); @@ -606,8 +609,21 @@ private void DropStacks(Character killer, List dropStacks) var dropRadius = ZoneServer.Instance.Conf.World.DropRadius; var distance = rnd.Next(dropRadius / 2, dropRadius + 1); - dropItem.SetLootProtection(killer, TimeSpan.FromSeconds(ZoneServer.Instance.Conf.World.LootPrectionSeconds)); - dropItem.Drop(this.Map, this.Position, direction, distance); + // Check if killer has party + var killersParty = killer.Connection?.Party; + if (killersParty != null) + { + if (killersParty.TryGetItemRecipient(killer, out var recipient)) + { + dropItem.SetLootProtection(recipient, TimeSpan.FromSeconds(ZoneServer.Instance.Conf.World.LootPrectionSeconds)); + dropItem.Drop(this.Map, this.Position, direction, distance); + } + } + else + { + dropItem.SetLootProtection(killer, TimeSpan.FromSeconds(ZoneServer.Instance.Conf.World.LootPrectionSeconds)); + dropItem.Drop(this.Map, this.Position, direction, distance); + } } } diff --git a/src/ZoneServer/World/Groups/Group.cs b/src/ZoneServer/World/Groups/Group.cs new file mode 100644 index 000000000..1cb791711 --- /dev/null +++ b/src/ZoneServer/World/Groups/Group.cs @@ -0,0 +1,366 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Melia.Shared.Game.Const; +using Melia.Shared.Network; +using Melia.Shared.ObjectProperties; +using Melia.Zone.Network; +using Melia.Zone.World.Actors.Characters; +using Yggdrasil.Scheduling; +using Yggdrasil.Util; + +namespace Melia.Zone.World.Groups +{ + + public abstract class Group : IGroup + { + /// + /// List of party members + /// + protected readonly Dictionary _members = new(); + + /// + /// The group's type. + /// + public abstract GroupType Type { get; } + + /// + /// The group's maximum size. + /// + public abstract int MaxMemberCount { get; } + + /// + /// The group's database unique id. + /// + public long DbId { get; set; } + + /// + /// The group's unique id. + /// + public abstract long ObjectId { get; } + + /// + /// The group's leader database unique id. + /// + public long LeaderDbId { get; set; } + + /// + /// The group's leader globally unique id. + /// + public abstract long LeaderObjectId { get; } + + public DateTime DateCreated { get; set; } + + public string Name { get; set; } + + public string Note { get; set; } + + public abstract Properties Properties { get; } + + /// + /// Returns the group's variables. + /// + /// + /// Not saved to the database. + /// + public Variables Vars { get; } = new Variables(); + + public IMember Owner => this.GetMember(this.LeaderObjectId); + public bool IsLeader(long objectId) => this.LeaderObjectId == objectId; + + /// + /// Checks if the character is party leader otherwise sends a system message. + /// + /// + /// + public bool IsLeader(Character character) + { + var isLeader = this.IsLeader(character.ObjectId); + if (!isLeader) + character.SystemMessage("PartyLeaderOnly"); + return isLeader; + } + + /// + /// Returns the group's member count. + /// + public int MemberCount + { + get + { + lock (_members) + return this._members.Count; + } + } + + /// + /// Get's the group leader. + /// + /// + public Character GetLeader() + { + return ZoneServer.Instance.World.GetCharacter(c => c.ObjectId == this.LeaderObjectId); + } + + /// + /// Return's true if the group has a leader is on the same Zone Server. + /// + /// + public bool TryGetLeader(out Character leader) + { + leader = ZoneServer.Instance.World.GetCharacter(c => c.ObjectId == this.LeaderObjectId); + + return leader != null; + } + + /// + /// Add a member + /// + /// + /// + public bool AddMember(IMember member) + { + lock (_members) + { + // Do not add another character from the same team. + if (_members.Values.Any(m => m.AccountId == member.AccountId)) + return false; + if (_members.Count < this.MaxMemberCount) + return _members.TryAdd(member.ObjectId, member); + + return false; + } + } + + public void Broadcast(Packet packet, Character memberToExclude = null) + { + lock (_members) + { + foreach (var member in _members.Values) + { + var character = ZoneServer.Instance.World.GetCharacter(c => c.ObjectId == member.ObjectId); + if (character != null) + character?.Connection.Send(packet); + } + } + } + + private IMember GetMember(long leaderObjectId) + { + IMember member = default; + lock (_members) + _members.TryGetValue(leaderObjectId, out member); + return member; + } + + public List GetMembers() + { + lock (_members) + return _members.Values.ToList(); + } + + /// + /// Gets all alive members in the group. + /// + /// A list of alive members. + public List GetAliveMembers() + { + var aliveMembers = new List(); + lock (_members) + { + foreach (var member in _members.Values) + { + // An alive member is online and has more than 0 HP. + if (member.IsOnline && member.Hp > 0) + { + aliveMembers.Add(member); + } + } + } + return aliveMembers; + } + + /// + /// Gets the count of alive members in the group. + /// + /// The number of alive members. + public int GetAliveMemberCount() + { + return this.GetAliveMembers().Count; + } + + /// + /// Remove a group member + /// + /// + protected void RemoveMember(IMember member) + { + lock (_members) + { + _members.Remove(member.ObjectId); + } + } + + /// + /// Returns true if a member if found, otherwise false. + /// + /// + /// + public bool TryGetMember(long characterObjectId, out IMember member) + { + lock (_members) + return _members.TryGetValue(characterObjectId, out member); + } + + /// + /// Broadcast member info update, used for Instant Updates (HP? Position?). + /// + /// + public void UpdateMemberInfo(Character character, bool isOnline = true) + { + if (this.TryGetMember(character.ObjectId, out var member)) + { + member.Handle = character.Handle; + member.Hp = character.Hp; + member.MaxHp = character.MaxHp; + member.Sp = character.Sp; + member.MaxSp = character.MaxSp; + member.Position = character.Position; + member.MapId = character.MapId; + member.Level = character.Level; + member.JobLevel = character.JobLevel; + member.IsOnline = isOnline; + + // Spams pretty hard, probably should move this to the Update(TimeSpan elapsed) + // TODO: Optimize this, maybe only send if more than 1 member is online too. + Send.ZC_PARTY_INST_INFO(this); + } + } + + public void UpdateMember(Character character, bool isOnline = true) + { + this.UpdateMemberData(character, isOnline); + this.UpdateMemberInfo(character, isOnline); + if (isOnline) + Send.ZC_PARTY_INST_INFO(this); + this.UpdateMemberMapChannel(character); + } + + /// + /// Broadcast member data update, used for HP/SP/Map/Level Updates. + /// + /// + public void UpdateMemberData(Character character, bool isOnline = true) + { + if (this.TryGetMember(character.ObjectId, out var member)) + { + member.Handle = character.Handle; + member.Hp = character.Hp; + member.MaxHp = character.MaxHp; + member.Sp = character.Sp; + member.MaxSp = character.MaxSp; + member.Position = character.Position; + member.MapId = character.MapId; + member.Level = character.Level; + member.JobLevel = character.JobLevel; + member.IsOnline = isOnline; + + Send.ZC_NORMAL.PartyMemberData(member, this); + } + } + + public void UpdateMemberMapChannel(Character character) + { + if (this.TryGetMember(character.ObjectId, out var member)) + Send.ZC_NORMAL.MemberMapStatusUpdate(this, member); + } + + public virtual void Update(TimeSpan elapsed) + { + } + + public IMember ToMember(Character character) + { + switch (this.Type) + { + case GroupType.Party: + return GroupMember.ToPartyMember(character); + } + return null; + } + } + + public interface IGroup : IPropertyObject, IUpdateable + { + /// + /// The group's type. + /// + public GroupType Type { get; } + + /// + /// The group's unique id. + /// + public long DbId { get; set; } + + /// + /// The group leader's unique id. + /// + public long LeaderDbId { get; set; } + + /// + /// The group leader's globally unique id. + /// + public long LeaderObjectId => this.LeaderDbId | ObjectIdRanges.Characters; + + /// + /// The group's owner. + /// + public IMember Owner { get; } + + /// + /// The group's creation date. + /// + DateTime DateCreated { get; set; } + + /// + /// The group's name. + /// + string Name { get; set; } + + /// + /// The group's note. + /// + string Note { get; } + + /// + /// Get a list of members of the group. + /// + /// + List GetMembers(); + + /// + /// Gets all alive members in the group. + /// + /// A list of alive members. + List GetAliveMembers(); + + /// + /// Gets the count of alive members in the group. + /// + /// The number of alive members. + int GetAliveMemberCount(); + + /// + /// Converts character to member. + /// + /// + /// + public IMember ToMember(Character character); + + /// + /// Broadcast a packet to the group. + /// + /// + public void Broadcast(Packet packet, Character memberToExclude = null); + } +} diff --git a/src/ZoneServer/World/Groups/GroupMember.cs b/src/ZoneServer/World/Groups/GroupMember.cs new file mode 100644 index 000000000..4b3c89c60 --- /dev/null +++ b/src/ZoneServer/World/Groups/GroupMember.cs @@ -0,0 +1,120 @@ +using Melia.Shared.Game.Const; +using Melia.Shared.ObjectProperties; +using Melia.Shared.World; +using Melia.Zone.World.Actors.Characters; + +namespace Melia.Zone.World.Groups +{ + public abstract class GroupMember : IMember + { + public long DbId { get; set; } + public long ObjectId => this.DbId | ObjectIdRanges.Characters; + public long AccountId { get; set; } + public long AccountObjectId => this.AccountId | ObjectIdRanges.Account; + public string TeamName { get; set; } + public string Name { get; set; } + public bool IsOnline { get; set; } = false; + public int Handle { get; set; } + public int MapId { get; set; } + public int Stance { get; set; } + public Gender Gender { get; set; } + public int Level { get; set; } = 1; + public int Hair { get; set; } + public Position Position { get; set; } + public int Hp { get; set; } + public int MaxHp { get; set; } + public int Sp { get; set; } + public int MaxSp { get; set; } + public JobId FirstJobId { get; set; } + public JobId SecondJobId { get; set; } + public JobId ThirdJobId { get; set; } + public JobId FourthJobId { get; set; } + public JobId VisualJobId { get; set; } + public int JobLevel { get; set; } + public int ServerGroup { get; set; } = 1001; + + public Properties Properties { get; } + public short Channel { get; set; } + + public static PartyMember ToPartyMember(Character character) + { + var member = new PartyMember() + { + DbId = character.DbId, + AccountId = character.AccountId, + Gender = character.Gender, + Hair = character.Hair, + Handle = character.Handle, + Hp = character.Hp, + JobLevel = character.Job?.Level ?? 1001, + Sp = character.Sp, + Level = character.Level, + MapId = character.MapId, + TeamName = character.TeamName, + MaxHp = character.MaxHp, + MaxSp = character.MaxSp, + Name = character.Name, + Position = character.Position, + Stance = character.Stance, + IsOnline = character.Connection?.LoggedIn ?? false, + }; + var i = 0; + foreach (var job in character.Jobs.GetList()) + { + member.VisualJobId = job.Id; + switch (i) + { + case 0: + member.FirstJobId = job.Id; + break; + case 1: + member.SecondJobId = job.Id; + break; + case 2: + member.ThirdJobId = job.Id; + break; + case 3: + member.FourthJobId = job.Id; + break; + } + i++; + } + return member; + } + } + + public class PartyMember : GroupMember + { + public new Properties Properties { get; set; } = new Properties("PartyMember"); + } + + public interface IMember : IPropertyHolder + { + public long DbId { get; set; } + public long ObjectId { get; } + public long AccountId { get; set; } + public long AccountObjectId { get; } + public string TeamName { get; set; } + public string Name { get; set; } + public bool IsOnline { get; set; } + public int Handle { get; set; } + public int MapId { get; set; } + public int Stance { get; set; } + public Gender Gender { get; set; } + public int Level { get; set; } + public int Hair { get; set; } + public Position Position { get; set; } + public int Hp { get; set; } + public int MaxHp { get; set; } + public int Sp { get; set; } + public int MaxSp { get; set; } + public JobId FirstJobId { get; set; } + public JobId SecondJobId { get; set; } + public JobId ThirdJobId { get; set; } + public JobId FourthJobId { get; set; } + public JobId VisualJobId { get; set; } + public int JobLevel { get; set; } + public int ServerGroup { get; set; } + public short Channel { get; set; } + } +} diff --git a/src/ZoneServer/World/Maps/Map.cs b/src/ZoneServer/World/Maps/Map.cs index d65b07dd7..9fc47068c 100644 --- a/src/ZoneServer/World/Maps/Map.cs +++ b/src/ZoneServer/World/Maps/Map.cs @@ -303,6 +303,17 @@ public Character[] GetCharacters() return _characters.Values.ToArray(); } + /// + /// Returns the first character on this map that matches the given predicate. + /// + /// + /// + public Character GetCharacter(Func predicate) + { + lock (_characters) + return _characters.Values.FirstOrDefault(predicate); + } + /// /// Returns all characters on this map that match the given predicate. /// @@ -322,6 +333,51 @@ public Character[] GetCharacters(Func predicate) public Character[] GetVisibleCharacters(Character character) => this.GetCharacters(a => a != character && character.Position.InRange2D(a.Position, VisibleRange)); + /// + /// Returns all party members of the character on this map. + /// + /// + /// + public List GetPartyMembers(Character character) + { + if (character.Connection.Party == null) return new List(); + + var party = character.Connection.Party; + return _characters.Values + .Where(a => a.Connection.Party?.ObjectId == party.ObjectId) + .ToList(); + } + + /// + /// Returns all party members in range of the character. + /// + /// + /// + /// + /// + public List GetPartyMembersInRange(Character character, float radius, bool areAlive = true) => + GetPartyMembersInRange(character, character.Position, radius, areAlive); + + /// + /// Returns all party members in range of the specified position. + /// + /// + /// + /// + /// + /// + public List GetPartyMembersInRange(Character character, Position position, float radius, bool areAlive = true) + { + if (character.Connection.Party == null) return new List(); + + var party = character.Connection.Party; + return _characters.Values + .Where(a => (radius == 0 || a.Position.InRange2D(position, radius)) && + a.Connection.Party?.ObjectId == party.ObjectId && + a.IsDead == !areAlive) + .ToList(); + } + /// /// Adds monster to map. /// diff --git a/src/ZoneServer/World/Party.cs b/src/ZoneServer/World/Party.cs new file mode 100644 index 000000000..7d011bbd3 --- /dev/null +++ b/src/ZoneServer/World/Party.cs @@ -0,0 +1,649 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Melia.Shared.Network; +using Melia.Shared.ObjectProperties; +using Melia.Shared.Game.Const; +using Melia.Shared.Game.Properties; +using Melia.Shared.Util; +using Melia.Zone.Events.Arguments; +using Melia.Zone.Network; +using Melia.Zone.World.Actors.Characters; +using Melia.Zone.World.Actors.Monsters; +using Melia.Zone.World.Groups; +using Yggdrasil.Extensions; +using Yggdrasil.Scheduling; + +namespace Melia.Zone.World +{ + public class Party : Group + { + /// + /// The party's globally unique id. + /// + public override long ObjectId => this.DbId | ObjectIdRanges.Party; + + /// + /// The party's leader unique id. + /// + public override long LeaderObjectId => this.LeaderDbId | ObjectIdRanges.Characters; + + /// + /// The party's maximum size. + /// + public override int MaxMemberCount => GetDefaultMaxMemberCount(); + + /// + /// Party Item Distribution Setting + /// + public PartyItemDistribution ItemDistribution { get; set; } + + /// + /// Party Exp Distribution Setting + /// + public PartyExpDistribution ExpDistribution { get; set; } + + /// + /// Party Quest Sharing Setting + /// + public PartyQuestSharing QuestSharing { get; set; } + + /// + /// Returns the party's property collection. + /// + public override Properties Properties { get; } = new Properties("Party"); + + /// + /// Returns the type of party + /// + public override GroupType Type => GroupType.Party; + + /// + /// Index of the player who last received the item. + /// Used in round robin item distribution. + /// + public int LastItemRecipientIndex { get; set; } = 0; + + /// + /// Returns the party's member count. + /// + public int OnlineMemberCount + => ZoneServer.Instance.World.GetCharacters(c => c.PartyId == this.DbId).Length; + + /// + /// Creates a new instance of Party. + /// + /// + public Party() + { + this.Name = "Party#" + Convert.ToBase64String(Guid.NewGuid().ToByteArray()).Substring(0, 6).ToUpper(); + this.ItemDistribution = PartyItemDistribution.RoundRobin; + this.ExpDistribution = PartyExpDistribution.IndividualExp; + this.QuestSharing = PartyQuestSharing.Enabled; + this.Note = "Let's Party!"; + + this.Load(); + } + + /// + /// Create's a new instance of a Party. + /// + /// + /// + /// + /// + /// + public Party(long id, long leaderId, string name, DateTime dateCreated, string note, PartyItemDistribution item, PartyExpDistribution exp, PartyQuestSharing quest) + { + this.DbId = id; + this.LeaderDbId = leaderId; + this.Name = name; + this.DateCreated = dateCreated; + this.ItemDistribution = item; + this.ExpDistribution = exp; + this.QuestSharing = quest; + this.Note = note; + + this.Load(); + } + + /// + /// Initialize Properties + /// + private void Load() + { + // These properties aren't used anywhere yet, but might be? + this.Properties.Create(new RFloatProperty(PropertyName.ItemRouting, () => (int)this.ItemDistribution)); + this.Properties.Create(new RStringProperty(PropertyName.Note, () => this.Note)); + this.Properties.Create(new RFloatProperty(PropertyName.ExpGainType, () => (int)this.ExpDistribution)); + this.Properties.Create(new RFloatProperty(PropertyName.IsQuestShare, () => (int)this.QuestSharing)); + this.Properties.Create(new RStringProperty(PropertyName.CreateTime, () => this.DateCreated.ToPropertyDateTimeString())); + } + + /// + /// How many party members we allow in parties by default + /// + /// + public static int GetDefaultMaxMemberCount() + { + return 5; + } + + /// + /// Add Member and Send to Party Packets Client + /// ZC_PARTY_INFO, ZC_PARTY_LIST, ZC_PARTY_ENTER, + /// ZC_ADDON_MSG, ZC_UPDATE_ALL_STATUS + /// + /// + /// + public virtual void AddMember(Character character, bool silently = false) + { + var member = GroupMember.ToPartyMember(character); + if (!this.AddMember(member)) + return; + character.Connection.Party = this; + character.PartyId = this.DbId; + if (!silently) + { + Send.ZC_PARTY_INFO(character, this); + Send.ZC_PARTY_LIST(this); + Send.ZC_PARTY_ENTER(character, this); + Send.ZC_ADDON_MSG(character, AddonMessage.PARTY_JOIN, 0, "None"); + Send.ZC_UPDATE_ALL_STATUS(character, 0); + var members = character.Map.GetPartyMembers(character); + foreach (var otherMember in members) + { + Send.ZC_CHANGE_RELATION(character.Connection, otherMember.Handle, RelationType.Friendly); + Send.ZC_CHANGE_RELATION(otherMember.Connection, character.Handle, RelationType.Friendly); + } + } + } + + /// + /// Replaces an existing party member with a new character from the same account. + /// Used when a player switches characters while in a party. + /// + /// + /// + public void ReplaceCharacter(Groups.IMember oldMember, Character newCharacter) + { + // Remove the old character from the party (in-memory only) + this.RemoveMember(oldMember); + + // Update the old character's partyId in database to 0 + ZoneServer.Instance.Database.UpdatePartyId(oldMember.DbId, 0); + + // Transfer leadership if the old character was the leader + var wasLeader = this.IsLeader(oldMember.ObjectId); + + // Add the new character to the party + this.AddMember(newCharacter, true); + + // Update the new character's partyId in database + newCharacter.PartyId = this.DbId; + ZoneServer.Instance.Database.UpdatePartyId(newCharacter.DbId, this.DbId); + + // Transfer leadership if needed + if (wasLeader) + { + this.LeaderDbId = newCharacter.DbId; + Send.ZC_NORMAL.PartyLeaderChange(this, newCharacter.AccountObjectId); + } + + // Notify all party members about the change + Send.ZC_PARTY_LIST(this); + } + + /// + /// Remove a party member and send ZC_PARTY_OUT to party members + /// + /// + public void RemoveMember(Character character) + { + if (this.TryGetMember(character.ObjectId, out var member)) + { + if (this.IsLeader(character.ObjectId) && this.MemberCount >= 2) + { + var nextLeader = this.GetMembers().Find(m => m.ObjectId != character.ObjectId); + if (nextLeader != null) + this.ChangeLeader(nextLeader); + } + Send.ZC_PARTY_OUT(this, member); + character.AddonMessage(AddonMessage.SUCCESS_UPDATE_PARTY_INFO, "None", 0); + character.Connection.Party = null; + character.PartyId = 0; + this.RemovePartyMember(member); + } + } + + /// + /// Remove a party member and send ZC_PARTY_OUT to party members + /// + /// + private void RemovePartyMember(IMember member) + { + this.RemoveMember(member); + Send.ZC_PARTY_OUT(this, member); + foreach (var otherMemberId in _members.Keys) + { + var character = ZoneServer.Instance.World.GetCharacter(c => c.ObjectId == otherMemberId); + if (character == null) + continue; + if (character.MapId == member.MapId) + Send.ZC_CHANGE_RELATION(character.Connection, member.Handle, RelationType.Neutral); + } + + var leavingCharacter = ZoneServer.Instance.World.GetCharacter(c => c.ObjectId == member.ObjectId); + ZoneServer.Instance.ServerEvents.PlayerLeftParty.Raise(new PlayerEventArgs(leavingCharacter)); + if (leavingCharacter != null) + { + leavingCharacter.PartyId = 0; + leavingCharacter.Connection.Party = null; + foreach (var otherMemberId in _members.Keys) + { + var character = ZoneServer.Instance.World.GetCharacter(c => c.ObjectId == otherMemberId); + if (character == null) + continue; + if (character.MapId == member.MapId) + Send.ZC_CHANGE_RELATION(leavingCharacter.Connection, character.Handle, RelationType.Neutral); + } + Send.ZC_PARTY_OUT(leavingCharacter, this); + leavingCharacter.AddonMessage(AddonMessage.SUCCESS_UPDATE_PARTY_INFO, "None"); + } + else + { + ZoneServer.Instance.Database.UpdatePartyId(member.DbId); + } + } + + /// + /// Update Party Settings + /// + /// + /// + public void UpdateSetting(int id, string value) + { + if (PropertyTable.TryGetName("Party", id, out var propertyId)) + { + switch (propertyId) + { + case PropertyName.ItemRouting: + this.ItemDistribution = (PartyItemDistribution)int.Parse(value); + break; + case PropertyName.Note: + this.Note = value; + break; + case PropertyName.ExpGainType: + this.ExpDistribution = (PartyExpDistribution)int.Parse(value); + break; + case PropertyName.IsQuestShare: + this.QuestSharing = (PartyQuestSharing)int.Parse(value); + break; + } + Send.ZC_NORMAL.PartyPropertyUpdate(this, id, value); + } + } + + public void ChangeName(string name) + { + this.Name = name; + Send.ZC_NORMAL.PartyNameChange(this); + } + + public void ChangeLeader(Character character) + { + var nextLeader = this.GetMembers().Find(m => m.ObjectId == character.ObjectId); + if (nextLeader != null) + this.ChangeLeader(nextLeader); + } + + private void ChangeLeader(IMember nextLeader) + { + this.LeaderDbId = nextLeader.DbId; + Send.ZC_NORMAL.PartyLeaderChange(this, nextLeader.AccountObjectId); + } + + /// + /// Gets all party members in this party as list of characters. + /// + /// + public List GetPartyMembers() + { + var list = new List(); + foreach (var member in _members.Keys) + { + // TODO: Improve this + list.Add(ZoneServer.Instance.World.GetCharacter(c => c.ObjectId == member)); + } + + return list; + } + + public override void Update(TimeSpan elapsed) + { + lock (_members) + { + if (_members.Count == 1) + return; + + foreach (var member in _members.Values) + { + Send.ZC_NORMAL.PartyMemberData(member, this); + } + Send.ZC_PARTY_INST_INFO(this); + } + } + + /// + /// Give Exp with Party Settings + /// + /// + /// + /// + /// + public void GiveExp(Character killer, long exp, long classExp, Mob monster) + { + // If individual exp distribution, no party bonuses or penalties apply + if (this.ExpDistribution == PartyExpDistribution.IndividualExp) + { + killer.GiveExp(exp, classExp, monster); + return; + } + + // Get all valid party members in the map + var partyCharacters = killer.Map.GetCharacters(a => + a.Connection.Party != null && + a.Connection.Party.ObjectId == this.ObjectId && + !a.IsDead + ); + + // If no valid party members found + if (partyCharacters.Length == 0) + return; + + // Calculate party bonus exp + (exp, classExp) = this.GetPartyBonusExp(exp, classExp); + + // Find highest level in party for penalty calculations + var highestLevel = partyCharacters.Max(c => c.Level); + + switch (this.ExpDistribution) + { + case PartyExpDistribution.EqualExp: + { + if (exp > 0) + exp = Math.Max(0, exp / partyCharacters.Length); + if (classExp > 0) + classExp = Math.Max(0, classExp / partyCharacters.Length); + + foreach (var partyCharacter in partyCharacters) + { + var (penalizedExp, penalizedClassExp) = this.ApplyLevelPenalty( + exp, + classExp, + partyCharacter.Level, + highestLevel + ); + + partyCharacter?.GiveExp( + penalizedExp, + penalizedClassExp, + (partyCharacter.ObjectId == killer.ObjectId) ? monster : null + ); + } + break; + } + case PartyExpDistribution.ByLevel: + { + var averageLevel = partyCharacters.Average(a => a.Level); + + foreach (var partyCharacter in partyCharacters) + { + var levelModifiedExp = exp; + var levelModifiedClassExp = classExp; + + if (exp > 0) + levelModifiedExp = (long)Math.Max(0, exp * (averageLevel / partyCharacter.Level)); + if (classExp > 0) + levelModifiedClassExp = (long)Math.Max(0, classExp * (averageLevel / partyCharacter.Level)); + + var (penalizedExp, penalizedClassExp) = this.ApplyLevelPenalty( + levelModifiedExp, + levelModifiedClassExp, + partyCharacter.Level, + highestLevel + ); + + partyCharacter?.GiveExp( + penalizedExp, + penalizedClassExp, + (partyCharacter.ObjectId == killer.ObjectId) ? monster : null + ); + } + break; + } + } + } + + /// + /// Applies level-based experience penalties based on the difference + /// between a character's level and the highest level in the party. + /// + /// + /// + /// + /// + /// + private (long exp, long classExp) ApplyLevelPenalty(long baseExp, long baseClassExp, int characterLevel, int highestLevel) + { + var levelDifference = highestLevel - characterLevel; + var penaltyMultiplier = 1.0f; + var conf = ZoneServer.Instance.Conf.World; + + // Check thresholds from highest to lowest + if (levelDifference >= conf.PartyLevelPenaltyThreshold3) + { + penaltyMultiplier = conf.PartyLevelPenalty3; + } + else if (levelDifference >= conf.PartyLevelPenaltyThreshold2) + { + penaltyMultiplier = conf.PartyLevelPenalty2; + } + else if (levelDifference >= conf.PartyLevelPenaltyThreshold1) + { + penaltyMultiplier = conf.PartyLevelPenalty1; + } + + return ( + (long)(baseExp * penaltyMultiplier), + (long)(baseClassExp * penaltyMultiplier) + ); + } + + + /// + /// Receives the default monster exp and returns the + /// modified exp based on party configuration. + /// + /// + /// + private (long, long) GetPartyBonusExp(long baseExp, long baseJobExp) + { + var bonusExpRatio = this.GetPartyExpModifier(); + baseExp = (long)Math.Floor(baseExp * bonusExpRatio); + baseJobExp = (long)Math.Floor(baseJobExp * bonusExpRatio); + + return (baseExp, baseJobExp); + } + + /// + /// Returns the exp multiplier based on party member count. + /// + /// Does not take into consideration party configuration. + /// + /// + /// + private float GetPartyExpModifier() + { + var onlineMemberCount = this.OnlineMemberCount; + if (onlineMemberCount <= 1) + { + return 1f; + } + + var conf = ZoneServer.Instance.Conf.World; + + switch (onlineMemberCount) + { + case 2: + return conf.PartyExpMultiplier2; + case 3: + return conf.PartyExpMultiplier3; + case 4: + return conf.PartyExpMultiplier4; + default: // 5 or more + return conf.PartyExpMultiplier5Plus; + } + } + + /// + /// Set's a property and updates the client. + /// + /// + /// + public void SetProperty(string propertyName, float propertyValue) + { + this.Properties.SetFloat(propertyName, propertyValue); + Send.ZC_NORMAL.PartyPropertyUpdate(this, this.Properties.GetSelect(propertyName)); + } + + /// + /// Set's a property and updates the client. + /// + /// + /// + public void SetProperty(string propertyName, string propertyValue) + { + this.Properties.SetString(propertyName, propertyValue); + Send.ZC_NORMAL.PartyPropertyUpdate(this, this.Properties.GetSelect(propertyName)); + } + + public void Expel(Character sender, string kickedPlayerTeamName) + { + if (sender.TeamName != kickedPlayerTeamName) + { + if (!this.IsLeader(sender)) + return; + } + lock (_members) + { + var member = _members.Values.FirstOrDefault(m => m.TeamName == kickedPlayerTeamName); + if (member != null) + { + this.RemovePartyMember(member); + } + } + } + + /// + /// Returns the character in the party who should get an item given + /// party item distribution settings. + /// + /// + /// The character who should receive the item + public bool TryGetItemRecipient(Character killer, out Character recipient) + { + recipient = killer; + + if (killer == null) + return false; + + switch (this.ItemDistribution) + { + case PartyItemDistribution.Individual: + { + return true; + } + case PartyItemDistribution.RoundRobin: + { + var partyCharacters = killer.Map.GetCharacters(a => + a.Connection.Party != null && + a.Connection.Party.ObjectId == this.ObjectId && + !a.IsDead); + + if (partyCharacters != null && partyCharacters.Length > 0) + { + this.LastItemRecipientIndex %= partyCharacters.Length; + recipient = partyCharacters[this.LastItemRecipientIndex]; + this.LastItemRecipientIndex++; + return true; + } + else + { + return false; + } + } + case PartyItemDistribution.Random: + { + // Get party members eligible to receive items. + var partyCharacters = killer.Map.GetCharacters(a => + a.Connection.Party != null && + a.Connection.Party.ObjectId == this.ObjectId && + !a.IsDead); + + if (partyCharacters != null && partyCharacters.Length > 0) + { + recipient = partyCharacters.Random(); + return true; + } + else + { + return false; + } + } + default: + return false; + } + } + } + + /// + /// Party Exp Distribution Values + /// + public enum PartySetting + { + ItemDistribution = 2, + Note = 3, + ExpDistribution = 4, + QuestSharing = 7, + } + + /// + /// Party Item Distribution Values + /// + public enum PartyItemDistribution + { + Individual = 0, + RoundRobin = 1, + Random = 2, + } + + /// + /// Party Exp Distribution Values + /// + public enum PartyExpDistribution + { + IndividualExp = 0, + EqualExp = 1, + ByLevel = 2, + } + + /// + /// Party Quest Sharing Values + /// + public enum PartyQuestSharing + { + Disabled = 0, + Enabled = 1, + } +} diff --git a/src/ZoneServer/World/PartyManager.cs b/src/ZoneServer/World/PartyManager.cs new file mode 100644 index 000000000..d2483ce5b --- /dev/null +++ b/src/ZoneServer/World/PartyManager.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Melia.Shared.ObjectProperties; +using Melia.Shared.Game.Const; +using Melia.Zone.Network; +using Melia.Zone.World.Actors.Characters; + +namespace Melia.Zone.World +{ + public class PartyManager + { + /// + /// Parties indexed by their id. + /// + private readonly Dictionary _parties = new(); + + /// + /// Create a party and persist it to the database. + /// + /// + /// + public Party Create(Character character) + { + var party = new Party() + { + LeaderDbId = character.DbId, + }; + party.AddMember(character, true); + + ZoneServer.Instance.Database.CreateParty(party); + Send.ZC_PARTY_INFO(character, party); + Send.ZC_ADDON_MSG(character, AddonMessage.PARTY_JOIN, 0, "None"); + Send.ZC_PARTY_LIST(party); + Send.ZC_NORMAL.ShowParty(character); + this.Add(party); + return party; + } + + /// + /// Removes a party and deletes it from the database. + /// + /// + public void Delete(Party party) + { + if (this.Remove(party.ObjectId)) + ZoneServer.Instance.Database.DeleteParty(party); + } + + /// + /// Adds party to the manager. + /// + /// + /// + /// Thrown if a party with the same id as the given one + /// already exists. + /// + public void Add(Party party) + { + lock (_parties) + { + _parties.TryAdd(party.ObjectId, party); + } + } + + /// + /// Removes the party with given id from the manager. + /// + /// + /// + /// Thrown if no party with the given id exists. + /// + public bool Remove(long partyId) + { + if (partyId < ObjectIdRanges.Party) + partyId += ObjectIdRanges.Party; + + lock (_parties) + { + if (!_parties.ContainsKey(partyId)) + throw new ArgumentException($"Party {partyId} doesn't exist."); + + return _parties.Remove(partyId); + } + } + + /// + /// Checks if a party exists with given id in the manager. + /// + /// + /// True if the party exists + public bool Exists(long partyId) + { + if (partyId < ObjectIdRanges.Party) + partyId += ObjectIdRanges.Party; + + lock (_parties) + { + return _parties.ContainsKey(partyId); + } + } + + /// + /// Find party by id or null if it doesn't exist + /// + /// + /// + public Party GetParty(long partyId) + { + if (partyId < ObjectIdRanges.Party) + partyId += ObjectIdRanges.Party; + + lock (_parties) + { + if (_parties.TryGetValue(partyId, out var value)) + return value; + } + return null; + } + + /// + /// Finds a party that has a member with the given account ID. + /// Returns the party and the member if found. + /// + /// + /// + /// + public Party FindPartyByAccountId(long accountId, out Groups.IMember member) + { + member = null; + lock (_parties) + { + foreach (var party in _parties.Values) + { + var members = party.GetMembers(); + foreach (var m in members) + { + if (m.AccountId == accountId) + { + member = m; + return party; + } + } + } + } + return null; + } + } +} diff --git a/src/ZoneServer/World/WorldManager.cs b/src/ZoneServer/World/WorldManager.cs index d4b808465..bf87ca635 100644 --- a/src/ZoneServer/World/WorldManager.cs +++ b/src/ZoneServer/World/WorldManager.cs @@ -56,6 +56,12 @@ public class WorldManager /// public GlobalVariables GlobalVariables { get; } = new(); + /// + /// Returns the world's parties, a manager for + /// all the parties in the world. + /// + public PartyManager Parties { get; } = new PartyManager(); + /// /// Returns a new handle to be used for a character or monster. /// @@ -334,6 +340,34 @@ public bool TryGetCharacterByTeamName(string teamName, out Character character) return character != null; } + /// + /// Returns a party if found by id or null + /// + /// + /// + public Party GetParty(long partyId) + => this.Parties.GetParty(partyId); + + /// + /// Returns the first character found that matches the given predicate. + /// + /// + /// + public Character GetCharacter(Func predicate) + { + lock (_mapsLock) + { + foreach (var map in _mapsId.Values) + { + var character = map.GetCharacter(predicate); + if (character != null) + return character; + } + } + + return null; + } + /// /// Returns all characters that are currently online. /// diff --git a/system/conf/world.conf b/system/conf/world.conf index 83dd368d4..d3af8d95e 100644 --- a/system/conf/world.conf +++ b/system/conf/world.conf @@ -9,6 +9,7 @@ require "world/game_time.conf" require "world/items.conf" require "world/jobs.conf" require "world/misc.conf" +require "world/party.conf" require "world/quests.conf" require "world/rare_monsters.conf" require "world/skills.conf" diff --git a/system/conf/world/party.conf b/system/conf/world/party.conf new file mode 100644 index 000000000..97e5234e4 --- /dev/null +++ b/system/conf/world/party.conf @@ -0,0 +1,86 @@ +// Melia +// Configuration file +//---------------------------------------------------------------------------- + +//---------------------------------------------------------------------------- +// Party Experience Multipliers +//---------------------------------------------------------------------------- +// Experience multipliers based on the number of party members. +// These values apply when party experience distribution is set to +// "Equal Exp" or "By Level" mode (not Individual Exp). +// +// The base experience from a monster is multiplied by these values +// before being distributed to party members. +// +// Format: party_exp_multiplier_ : + +// 2 party members +party_exp_multiplier_2: 1.2 + +// 3 party members +party_exp_multiplier_3: 1.5 + +// 4 party members +party_exp_multiplier_4: 1.8 + +// 5 or more party members +party_exp_multiplier_5plus: 2.2 + +//---------------------------------------------------------------------------- +// Party Level Difference Penalties +//---------------------------------------------------------------------------- +// Experience penalties applied when party members have large level differences. +// The penalty is based on the difference between a character's level and +// the highest level character in the party. +// +// Thresholds define the level differences where penalties apply (checked from highest to lowest) +// Multipliers define the exp reduction at each threshold +// A multiplier of 1.0 = 100% exp (no penalty) +// A multiplier of 0.4 = 40% exp (60% penalty) +// A multiplier of 0.0 = 0% exp (100% penalty) +// +// HOW IT WORKS: +// The system checks thresholds from highest to lowest (3 -> 2 -> 1). +// If level difference >= threshold, that penalty multiplier is applied. +// +// EXAMPLE CONFIGURATIONS: +// +// Default (gradual penalties): +// If level difference is 10: uses penalty_1 (0.7 = 70% exp) +// If level difference is 15: uses penalty_2 (0.4 = 40% exp) +// If level difference is 20: uses penalty_3 (0.0 = 0% exp) +// +// Static party share range (100% exp up to 10 levels, then 0%): +// party_level_penalty_threshold_1: 10 +// party_level_penalty_1: 0.0 +// party_level_penalty_threshold_2: 9999 +// party_level_penalty_2: 0.0 +// party_level_penalty_threshold_3: 9999 +// party_level_penalty_3: 0.0 +// Result: Below 10 levels = 100% exp, 10+ levels = 0% exp +// +// Extended share range with no penalties (100% exp up to 50 levels): +// party_level_penalty_threshold_1: 50 +// party_level_penalty_1: 0.0 +// party_level_penalty_threshold_2: 9999 +// party_level_penalty_2: 0.0 +// party_level_penalty_threshold_3: 9999 +// party_level_penalty_3: 0.0 +// Result: Below 50 levels = 100% exp, 50+ levels = 0% exp + +// First penalty threshold (level difference) +party_level_penalty_threshold_1: 10 +// First penalty multiplier +party_level_penalty_1: 0.7 + +// Second penalty threshold (level difference) +party_level_penalty_threshold_2: 15 +// Second penalty multiplier +party_level_penalty_2: 0.4 + +// Third penalty threshold (level difference) +party_level_penalty_threshold_3: 20 +// Third penalty multiplier +party_level_penalty_3: 0.0 + +include "/user/conf/world/party.conf"