From 87c2d9003d03ddc0816490bc8cc1f0cd2cadb565 Mon Sep 17 00:00:00 2001 From: MrShadow Date: Mon, 13 Oct 2025 15:26:44 -0300 Subject: [PATCH 01/13] Initial party system implementation --- src/Shared/Game/Const/GroupType.cs | 14 + src/Shared/Game/Const/Message.cs | 3 + src/Shared/Network/NormalOp.cs | 6 + src/Shared/ObjectProperties/PropertyObject.cs | 5 + src/Shared/Util/DateTimeUtils.cs | 22 + src/ZoneServer/Database/ZoneDb.Social.cs | 185 ++++++ src/ZoneServer/Database/ZoneDb.cs | 3 +- src/ZoneServer/Network/Helpers/PartyHelper.cs | 66 ++ src/ZoneServer/Network/PacketHandler.cs | 84 +++ src/ZoneServer/Network/Send.Normal.cs | 135 ++++ src/ZoneServer/Network/Send.cs | 120 ++++ src/ZoneServer/Network/ZoneConnection.cs | 13 +- .../Actors/Characters/Character.Social.cs | 27 + .../World/Actors/Characters/Character.cs | 7 +- src/ZoneServer/World/Actors/Monsters/Mob.cs | 22 +- src/ZoneServer/World/Groups/Group.cs | 368 +++++++++++ src/ZoneServer/World/Groups/GroupMember.cs | 183 ++++++ src/ZoneServer/World/Maps/Map.cs | 56 ++ src/ZoneServer/World/Party.cs | 596 ++++++++++++++++++ src/ZoneServer/World/PartyManager.cs | 123 ++++ src/ZoneServer/World/WorldManager.cs | 34 + 21 files changed, 2066 insertions(+), 6 deletions(-) create mode 100644 src/Shared/Game/Const/GroupType.cs create mode 100644 src/Shared/Util/DateTimeUtils.cs create mode 100644 src/ZoneServer/Database/ZoneDb.Social.cs create mode 100644 src/ZoneServer/Network/Helpers/PartyHelper.cs create mode 100644 src/ZoneServer/World/Actors/Characters/Character.Social.cs create mode 100644 src/ZoneServer/World/Groups/Group.cs create mode 100644 src/ZoneServer/World/Groups/GroupMember.cs create mode 100644 src/ZoneServer/World/Party.cs create mode 100644 src/ZoneServer/World/PartyManager.cs 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/Network/NormalOp.cs b/src/Shared/Network/NormalOp.cs index 597fda8d6..aca749ad2 100644 --- a/src/Shared/Network/NormalOp.cs +++ b/src/Shared/Network/NormalOp.cs @@ -69,12 +69,18 @@ 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 PartyPropertyChange = 0xF9; 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/ZoneServer/Database/ZoneDb.Social.cs b/src/ZoneServer/Database/ZoneDb.Social.cs new file mode 100644 index 000000000..a18da206c --- /dev/null +++ b/src/ZoneServer/Database/ZoneDb.Social.cs @@ -0,0 +1,185 @@ +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()) + 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"), + Position = new Position(reader.GetFloat("x"), reader.GetFloat("y"), reader.GetFloat("z")), + IsOnline = loadCharacter.DbId == reader.GetInt64("characterId") + }; + party.AddMember(member); + } + else + party.AddMember(character, true); + } + } + } + } + + 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..f33034db4 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. @@ -173,6 +173,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 diff --git a/src/ZoneServer/Network/Helpers/PartyHelper.cs b/src/ZoneServer/Network/Helpers/PartyHelper.cs new file mode 100644 index 000000000..01e17fa08 --- /dev/null +++ b/src/ZoneServer/Network/Helpers/PartyHelper.cs @@ -0,0 +1,66 @@ +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(1); + packet.PutInt(46); + packet.PutEmptyBin(18); + 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..1ce8ed1c1 100644 --- a/src/ZoneServer/Network/PacketHandler.cs +++ b/src/ZoneServer/Network/PacketHandler.cs @@ -3087,5 +3087,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..2d911df8b 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,139 @@ 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); + } + + /// + /// 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); + } + + /// + /// 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..32216b5f7 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; @@ -4550,5 +4551,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..a12fc6782 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,7 +33,12 @@ public interface IZoneConnection : IConnection /// /// Saves the account and character associated with this connection. /// - void SaveAccountAndCharacter(); + /// + /// 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. /// 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..9a6a9c869 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,11 @@ public class Character : Actor, IActor, ICombatEntity, ICommander, IPropertyObje /// public long AccountId { get; set; } + /// + /// Returns the character's party id. + /// + public long PartyId { get; set; } + /// /// Returns the character's faction. /// 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..22a858981 --- /dev/null +++ b/src/ZoneServer/World/Groups/Group.cs @@ -0,0 +1,368 @@ +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); + case GroupType.Guild: + return GroupMember.ToGuildMember(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..a5699a050 --- /dev/null +++ b/src/ZoneServer/World/Groups/GroupMember.cs @@ -0,0 +1,183 @@ +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 static GuildMember ToGuildMember(Character character) + { + var member = new GuildMember() + { + 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 GuildMember : GroupMember + { + public new Properties Properties { get; set; } = new Properties("GuildMember"); + + public int Contribution + { + get + { + return (int)this.Properties.GetFloat(PropertyName.Contribution); + } + set + { + this.Properties.SetFloat(PropertyName.Contribution, value); + } + } + } + + 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..5c52fd1fe --- /dev/null +++ b/src/ZoneServer/World/Party.cs @@ -0,0 +1,596 @@ +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.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); + } + } + + /// + /// 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) && 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); + + var leavingCharacter = ZoneServer.Instance.World.GetCharacter(c => c.ObjectId == member.ObjectId); + if (leavingCharacter != null) + { + leavingCharacter.PartyId = 0; + leavingCharacter.Connection.Party = null; + 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; + + if (levelDifference >= 20) + { + penaltyMultiplier = 0.0f; // -100% exp + } + else if (levelDifference >= 15) + { + penaltyMultiplier = 0.4f; // -60% exp + } + else if (levelDifference >= 10) + { + penaltyMultiplier = 0.7f; // -30% exp + } + + 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; + } + + switch (onlineMemberCount) + { + case 2: + return 1.2f; + case 3: + return 1.5f; + case 4: + return 1.8f; + case 5: + return 2.2f; + case 6: + return 2.5f; + case 7: + return 2.8f; + case 8: + return 3.0f; + case 9: + return 3.5f; + default: + return (onlineMemberCount - 9) * .5f + 3.5f; + } + } + + /// + /// 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..156bab50a --- /dev/null +++ b/src/ZoneServer/World/PartyManager.cs @@ -0,0 +1,123 @@ +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; + } + } +} 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. /// From 08e65f21ca4e460b204b3c7700354454823763ea Mon Sep 17 00:00:00 2001 From: MrShadow Date: Tue, 14 Oct 2025 00:38:11 -0300 Subject: [PATCH 02/13] Party Implementation Part2 --- sql/updates/update_2025-01-13_1.sql | 16 ++ src/Shared/Game/Const/RelationType.cs | 50 ++++ src/Shared/Network/NormalOp.cs | 2 + .../Commands/ChatCommands.Handlers.cs | 235 ++++++++++++++++++ src/ZoneServer/Database/ZoneDb.Social.cs | 47 +++- src/ZoneServer/Database/ZoneDb.cs | 3 + src/ZoneServer/Events/ServerEvents.cs | 5 + src/ZoneServer/Network/PacketHandler.cs | 11 + src/ZoneServer/Network/Send.Normal.cs | 63 +++++ src/ZoneServer/Network/Send.cs | 16 ++ .../World/Actors/Characters/Character.cs | 8 + .../Components/MovementComponent.cs | 3 + src/ZoneServer/World/Party.cs | 24 ++ 13 files changed, 473 insertions(+), 10 deletions(-) create mode 100644 sql/updates/update_2025-01-13_1.sql create mode 100644 src/Shared/Game/Const/RelationType.cs 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/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 aca749ad2..c110f1e00 100644 --- a/src/Shared/Network/NormalOp.cs +++ b/src/Shared/Network/NormalOp.cs @@ -72,7 +72,9 @@ public static class Zone 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; 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 index a18da206c..a35856d0e 100644 --- a/src/ZoneServer/Database/ZoneDb.Social.cs +++ b/src/ZoneServer/Database/ZoneDb.Social.cs @@ -81,11 +81,6 @@ public void LoadParty(Character character) } } } - else - { - if (party.TryGetMember(character.ObjectId, out var member)) - member.IsOnline = true; - } } private void LoadPartyMembers(Character loadCharacter, Party party) @@ -98,12 +93,17 @@ private void LoadPartyMembers(Character loadCharacter, Party party) { while (reader.Read()) { - var character = ZoneServer.Instance.World.GetCharacter(c => c.DbId == reader.GetInt64("characterId")); - if (character == null) + var characterDbId = reader.GetInt64("characterId"); + + // Check if this is the character that's logging in + if (characterDbId == loadCharacter.DbId) { + // Use the actual character object that's logging in + // Can't use AddMember(Character) because Connection is null + // Create a PartyMember instead var member = new PartyMember { - DbId = reader.GetInt64("characterId"), + DbId = characterDbId, AccountId = reader.GetInt64("accountId"), Name = reader.GetString("name"), TeamName = reader.GetString("teamName"), @@ -113,12 +113,39 @@ private void LoadPartyMembers(Character loadCharacter, Party party) MapId = reader.GetInt32("zone"), Level = reader.GetInt32("level"), Position = new Position(reader.GetFloat("x"), reader.GetFloat("y"), reader.GetFloat("z")), - IsOnline = loadCharacter.DbId == reader.GetInt64("characterId") + IsOnline = loadCharacter.DbId == characterDbId }; party.AddMember(member); } else - party.AddMember(character, true); + { + // Try to find the character in the world (for other online members) + var character = ZoneServer.Instance.World.GetCharacter(c => c.DbId == characterDbId); + if (character == null) + { + // Character is offline, create a PartyMember placeholder + var member = new PartyMember + { + DbId = characterDbId, + 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"), + Position = new Position(reader.GetFloat("x"), reader.GetFloat("y"), reader.GetFloat("z")), + IsOnline = loadCharacter.DbId == reader.GetInt64("characterId") + }; + party.AddMember(member); + } + else + { + // Character is online, use the actual character object + party.AddMember(character, true); + } + } } } } diff --git a/src/ZoneServer/Database/ZoneDb.cs b/src/ZoneServer/Database/ZoneDb.cs index f33034db4..f73b0edc8 100644 --- a/src/ZoneServer/Database/ZoneDb.cs +++ b/src/ZoneServer/Database/ZoneDb.cs @@ -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"); } } @@ -419,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(); } @@ -437,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/PacketHandler.cs b/src/ZoneServer/Network/PacketHandler.cs index 1ce8ed1c1..af1f77192 100644 --- a/src/ZoneServer/Network/PacketHandler.cs +++ b/src/ZoneServer/Network/PacketHandler.cs @@ -110,6 +110,9 @@ public void CZ_CONNECT(IZoneConnection conn, Packet packet) character.Connection = conn; conn.SelectedCharacter = character; + // Reconnect to party if the character has one + conn.Party = ZoneServer.Instance.World.GetParty(character.PartyId); + ZoneServer.Instance.ServerEvents.PlayerLoggedIn.Raise(new PlayerEventArgs(character)); map.AddCharacter(character); @@ -229,6 +232,14 @@ 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) + { + 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)); diff --git a/src/ZoneServer/Network/Send.Normal.cs b/src/ZoneServer/Network/Send.Normal.cs index 2d911df8b..29a6021e0 100644 --- a/src/ZoneServer/Network/Send.Normal.cs +++ b/src/ZoneServer/Network/Send.Normal.cs @@ -1449,6 +1449,24 @@ public static void PartyPropertyUpdate(IGroup group, PropertyList 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. /// @@ -1475,6 +1493,51 @@ public static void ShowParty(Character character) 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. /// diff --git a/src/ZoneServer/Network/Send.cs b/src/ZoneServer/Network/Send.cs index 32216b5f7..eb2b69510 100644 --- a/src/ZoneServer/Network/Send.cs +++ b/src/ZoneServer/Network/Send.cs @@ -2501,6 +2501,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. /// diff --git a/src/ZoneServer/World/Actors/Characters/Character.cs b/src/ZoneServer/World/Actors/Characters/Character.cs index 9a6a9c869..3fed49922 100644 --- a/src/ZoneServer/World/Actors/Characters/Character.cs +++ b/src/ZoneServer/World/Actors/Characters/Character.cs @@ -87,6 +87,11 @@ public partial class Character : Actor, IActor, ICombatEntity, ICommander, IProp /// 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. /// @@ -695,6 +700,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); } /// @@ -822,6 +828,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); } /// @@ -833,6 +840,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/Party.cs b/src/ZoneServer/World/Party.cs index 5c52fd1fe..f6f4116bb 100644 --- a/src/ZoneServer/World/Party.cs +++ b/src/ZoneServer/World/Party.cs @@ -6,6 +6,7 @@ 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; @@ -149,6 +150,12 @@ public virtual void AddMember(Character character, bool silently = false) 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); + } } } @@ -182,12 +189,29 @@ 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"); } From a69a32aad080522b1c60187b225681e53d2bfd27 Mon Sep 17 00:00:00 2001 From: MrShadow Date: Tue, 14 Oct 2025 10:52:38 -0300 Subject: [PATCH 03/13] Fixes to party member updates --- src/ZoneServer/Database/ZoneDb.Social.cs | 39 ++++--------------- src/ZoneServer/Network/ZoneConnection.cs | 12 ++++-- .../World/Actors/Characters/Character.cs | 1 + 3 files changed, 16 insertions(+), 36 deletions(-) diff --git a/src/ZoneServer/Database/ZoneDb.Social.cs b/src/ZoneServer/Database/ZoneDb.Social.cs index a35856d0e..3f32f82d9 100644 --- a/src/ZoneServer/Database/ZoneDb.Social.cs +++ b/src/ZoneServer/Database/ZoneDb.Social.cs @@ -95,12 +95,11 @@ private void LoadPartyMembers(Character loadCharacter, Party party) { var characterDbId = reader.GetInt64("characterId"); - // Check if this is the character that's logging in - if (characterDbId == loadCharacter.DbId) + // Try to find the character in the world (they're online if found) + var character = ZoneServer.Instance.World.GetCharacter(c => c.DbId == characterDbId); + if (character == null) { - // Use the actual character object that's logging in - // Can't use AddMember(Character) because Connection is null - // Create a PartyMember instead + // Character not in world yet or offline - create PartyMember placeholder var member = new PartyMember { DbId = characterDbId, @@ -113,38 +112,14 @@ private void LoadPartyMembers(Character loadCharacter, Party party) MapId = reader.GetInt32("zone"), Level = reader.GetInt32("level"), Position = new Position(reader.GetFloat("x"), reader.GetFloat("y"), reader.GetFloat("z")), - IsOnline = loadCharacter.DbId == characterDbId + IsOnline = characterDbId == loadCharacter.DbId }; party.AddMember(member); } else { - // Try to find the character in the world (for other online members) - var character = ZoneServer.Instance.World.GetCharacter(c => c.DbId == characterDbId); - if (character == null) - { - // Character is offline, create a PartyMember placeholder - var member = new PartyMember - { - DbId = characterDbId, - 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"), - Position = new Position(reader.GetFloat("x"), reader.GetFloat("y"), reader.GetFloat("z")), - IsOnline = loadCharacter.DbId == reader.GetInt64("characterId") - }; - party.AddMember(member); - } - else - { - // Character is online, use the actual character object - party.AddMember(character, true); - } + // Character is already in the world and online + party.AddMember(character, true); } } } diff --git a/src/ZoneServer/Network/ZoneConnection.cs b/src/ZoneServer/Network/ZoneConnection.cs index a12fc6782..fe5635548 100644 --- a/src/ZoneServer/Network/ZoneConnection.cs +++ b/src/ZoneServer/Network/ZoneConnection.cs @@ -34,11 +34,11 @@ public interface IZoneConnection : IConnection /// Saves the account and character associated with this connection. /// /// - /// Gets or sets the current party. - /// - Party Party { get; set; } + /// Gets or sets the current party. + /// + Party Party { get; set; } - void SaveAccountAndCharacter(); + void SaveAccountAndCharacter(); } /// @@ -86,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.cs b/src/ZoneServer/World/Actors/Characters/Character.cs index 3fed49922..25a727700 100644 --- a/src/ZoneServer/World/Actors/Characters/Character.cs +++ b/src/ZoneServer/World/Actors/Characters/Character.cs @@ -817,6 +817,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); } /// From c43b11afdf2464f9bc12ccf4d8566869f1f132f9 Mon Sep 17 00:00:00 2001 From: MrShadow Date: Tue, 14 Oct 2025 12:23:10 -0300 Subject: [PATCH 04/13] Add party chat feature --- .../Commands/ChatCommands.Handlers.cs | 35 +++++++++++- src/SocialServer/Database/Character.cs | 5 ++ src/SocialServer/Database/ChatMessage.cs | 39 ++++++++++++- src/SocialServer/Database/ChatRoom.cs | 20 ++++++- src/SocialServer/Database/SocialDb.cs | 4 +- src/SocialServer/Network/PacketHandlerChat.cs | 55 ++++++++++++++++++- src/SocialServer/Network/Send.Normal.cs | 8 +-- src/SocialServer/World/ChatManager.cs | 23 ++++++-- src/SocialServer/World/UserManager.cs | 26 ++++++++- 9 files changed, 197 insertions(+), 18 deletions(-) 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..bcd65bc2e 100644 --- a/src/SocialServer/Database/ChatRoom.cs +++ b/src/SocialServer/Database/ChatRoom.cs @@ -1,7 +1,8 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using Melia.Social.Network; using Melia.Social.World; +using Yggdrasil.Logging; namespace Melia.Social.Database { @@ -62,9 +63,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; 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..750cba2c2 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,25 @@ 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); + } + else + { + } + chatRoom.AddMember(user); + Send.SC_NORMAL.MessageList(conn, chatRoom, chatRoom.GetMessages()); + } + Send.SC_FROM_INTEGRATE.Unknown_01(conn.User); } @@ -316,7 +337,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 +489,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..c21dd4389 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,17 +73,17 @@ 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); diff --git a/src/SocialServer/World/ChatManager.cs b/src/SocialServer/World/ChatManager.cs index 3252f28a3..25b5491c8 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,29 @@ 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 this is a party chat room (chatId != 0, type Friends), add all online party members + if (chatId != 0 && type == ChatRoomType.Friends) + { + var partyMembers = SocialServer.Instance.UserManager.GetOnlineUsersByPartyId(chatId); + foreach (var member in partyMembers) + { + if (member.Id != creator.Id) // Don't add the creator again + room.AddMember(member); + } + } + + 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. /// From 3a6b33199f6e3d2b2b29311b8d57cf927c075592 Mon Sep 17 00:00:00 2001 From: MrShadow Date: Tue, 14 Oct 2025 13:09:36 -0300 Subject: [PATCH 05/13] Party Relations fixes --- src/ZoneServer/Database/ZoneDb.Social.cs | 61 ++++++++++--------- src/ZoneServer/Network/PacketHandler.cs | 22 ++++++- src/ZoneServer/Network/Send.cs | 5 +- .../World/Actors/Characters/Character.cs | 6 ++ src/ZoneServer/World/Actors/Entity.cs | 39 ++++++++++++ 5 files changed, 100 insertions(+), 33 deletions(-) diff --git a/src/ZoneServer/Database/ZoneDb.Social.cs b/src/ZoneServer/Database/ZoneDb.Social.cs index 3f32f82d9..d74235367 100644 --- a/src/ZoneServer/Database/ZoneDb.Social.cs +++ b/src/ZoneServer/Database/ZoneDb.Social.cs @@ -81,45 +81,48 @@ public void LoadParty(Character character) } } } + else + { + if (party.TryGetMember(character.ObjectId, out var member)) + member.IsOnline = true; + } } private void LoadPartyMembers(Character loadCharacter, Party party) { using (var conn = this.GetConnection()) - using (var mc = new MySqlCommand("SELECT * FROM `characters` WHERE `partyId` = @partyId", conn)) { - mc.Parameters.AddWithValue("@partyId", party.DbId); - using (var reader = mc.ExecuteReader()) + using (var mc = new MySqlCommand("SELECT * FROM `characters` WHERE `partyId` = @partyId", conn)) { - while (reader.Read()) + mc.Parameters.AddWithValue("@partyId", party.DbId); + using (var reader = mc.ExecuteReader()) { - var characterDbId = reader.GetInt64("characterId"); - - // Try to find the character in the world (they're online if found) - var character = ZoneServer.Instance.World.GetCharacter(c => c.DbId == characterDbId); - if (character == null) + while (reader.Read()) { - // Character not in world yet or offline - create PartyMember placeholder - var member = new PartyMember + var character = ZoneServer.Instance.World.GetCharacter(c => c.DbId == reader.GetInt64("characterId")); + if (character == null) { - DbId = characterDbId, - 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"), - Position = new Position(reader.GetFloat("x"), reader.GetFloat("y"), reader.GetFloat("z")), - IsOnline = characterDbId == loadCharacter.DbId - }; - party.AddMember(member); - } - else - { - // Character is already in the world and online - party.AddMember(character, true); + 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; + party.AddMember(member); + } + else + party.AddMember(character, true); } } } diff --git a/src/ZoneServer/Network/PacketHandler.cs b/src/ZoneServer/Network/PacketHandler.cs index af1f77192..dd1fe3ec5 100644 --- a/src/ZoneServer/Network/PacketHandler.cs +++ b/src/ZoneServer/Network/PacketHandler.cs @@ -235,9 +235,25 @@ public void CZ_GAME_READY(IZoneConnection conn, Packet packet) // Handle party reconnection if the character has one if (conn.Party != null) { - Send.ZC_PARTY_INFO(character, conn.Party); - Send.ZC_PARTY_LIST(conn.Party); - conn.Party.UpdateMember(character, true); + // 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(); diff --git a/src/ZoneServer/Network/Send.cs b/src/ZoneServer/Network/Send.cs index eb2b69510..22af6812e 100644 --- a/src/ZoneServer/Network/Send.cs +++ b/src/ZoneServer/Network/Send.cs @@ -136,6 +136,8 @@ public static void ZC_MYPC_ENTER(Character character) /// public static void ZC_ENTER_PC(IZoneConnection conn, Character character) { + var relationship = (byte)Math.Clamp((int)conn.SelectedCharacter.GetRelation(character), 0, 2); + var packet = new Packet(Op.ZC_ENTER_PC); packet.PutInt(character.Handle); @@ -144,7 +146,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)); diff --git a/src/ZoneServer/World/Actors/Characters/Character.cs b/src/ZoneServer/World/Actors/Characters/Character.cs index 25a727700..a5d43b342 100644 --- a/src/ZoneServer/World/Actors/Characters/Character.cs +++ b/src/ZoneServer/World/Actors/Characters/Character.cs @@ -97,6 +97,12 @@ public partial class Character : Actor, IActor, ICombatEntity, ICommander, IProp /// 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. /// 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. /// From 8d8809c0ca4c01e5c45f35e81fbfd786fec4644e Mon Sep 17 00:00:00 2001 From: MrShadow Date: Tue, 14 Oct 2025 13:53:37 -0300 Subject: [PATCH 06/13] Relation login fix --- src/SocialServer/Network/PacketHandlerChat.cs | 3 --- src/ZoneServer/Network/Send.cs | 8 +++++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/SocialServer/Network/PacketHandlerChat.cs b/src/SocialServer/Network/PacketHandlerChat.cs index 750cba2c2..ae885fb4a 100644 --- a/src/SocialServer/Network/PacketHandlerChat.cs +++ b/src/SocialServer/Network/PacketHandlerChat.cs @@ -88,9 +88,6 @@ public void CS_NORMAL_GAME_START(ISocialConnection conn, Packet packet) { chatRoom = SocialServer.Instance.ChatManager.CreateChatRoom(user, user.Character.PartyId, ChatRoomType.Friends); } - else - { - } chatRoom.AddMember(user); Send.SC_NORMAL.MessageList(conn, chatRoom, chatRoom.GetMessages()); } diff --git a/src/ZoneServer/Network/Send.cs b/src/ZoneServer/Network/Send.cs index 22af6812e..84208bd84 100644 --- a/src/ZoneServer/Network/Send.cs +++ b/src/ZoneServer/Network/Send.cs @@ -136,7 +136,13 @@ public static void ZC_MYPC_ENTER(Character character) /// public static void ZC_ENTER_PC(IZoneConnection conn, Character character) { - var relationship = (byte)Math.Clamp((int)conn.SelectedCharacter.GetRelation(character), 0, 2); + 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); From ca566b64a4260324ade32780ea45d0f3269e1c37 Mon Sep 17 00:00:00 2001 From: MrShadow Date: Tue, 14 Oct 2025 14:32:07 -0300 Subject: [PATCH 07/13] More party chat fixes --- src/SocialServer/Database/ChatRoom.cs | 17 +++++++++++++++++ src/SocialServer/Network/Send.Normal.cs | 2 +- src/SocialServer/World/ChatManager.cs | 11 ----------- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/SocialServer/Database/ChatRoom.cs b/src/SocialServer/Database/ChatRoom.cs index bcd65bc2e..30d359ddd 100644 --- a/src/SocialServer/Database/ChatRoom.cs +++ b/src/SocialServer/Database/ChatRoom.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using Melia.Shared.Network; using Melia.Social.Network; using Melia.Social.World; using Yggdrasil.Logging; @@ -193,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/Network/Send.Normal.cs b/src/SocialServer/Network/Send.Normal.cs index c21dd4389..5a9c52568 100644 --- a/src/SocialServer/Network/Send.Normal.cs +++ b/src/SocialServer/Network/Send.Normal.cs @@ -86,7 +86,7 @@ public static void AddMessage(ISocialConnection conn, ChatRoom chatRoom, ChatMes 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 25b5491c8..faf6449e0 100644 --- a/src/SocialServer/World/ChatManager.cs +++ b/src/SocialServer/World/ChatManager.cs @@ -99,17 +99,6 @@ public ChatRoom CreateChatRoom(SocialUser creator, long chatId = 0, ChatRoomType room.AddMember(creator); - // If this is a party chat room (chatId != 0, type Friends), add all online party members - if (chatId != 0 && type == ChatRoomType.Friends) - { - var partyMembers = SocialServer.Instance.UserManager.GetOnlineUsersByPartyId(chatId); - foreach (var member in partyMembers) - { - if (member.Id != creator.Id) // Don't add the creator again - room.AddMember(member); - } - } - if (chatId == 0) room.AddMessage(new ChatMessage(creator, "!@#$NewRoomHasBeenCreated#@!")); From 2d9a88d3463faec924e9e5a69717bbabfb47fc8e Mon Sep 17 00:00:00 2001 From: MrShadow Date: Tue, 14 Oct 2025 14:53:07 -0300 Subject: [PATCH 08/13] Minor fix to user leaving party receiving wrong message --- src/ZoneServer/World/Party.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ZoneServer/World/Party.cs b/src/ZoneServer/World/Party.cs index f6f4116bb..22c3db71d 100644 --- a/src/ZoneServer/World/Party.cs +++ b/src/ZoneServer/World/Party.cs @@ -167,7 +167,7 @@ public void RemoveMember(Character character) { if (this.TryGetMember(character.ObjectId, out var member)) { - if (this.IsLeader(character) && this.MemberCount >= 2) + if (this.IsLeader(character.ObjectId) && this.MemberCount >= 2) { var nextLeader = this.GetMembers().Find(m => m.ObjectId != character.ObjectId); if (nextLeader != null) From 00d8af59bd9ba15c9123d48ea2873b92767cef1d Mon Sep 17 00:00:00 2001 From: MrShadow Date: Tue, 14 Oct 2025 15:07:36 -0300 Subject: [PATCH 09/13] Remove some unecessary code --- src/ZoneServer/World/Groups/Group.cs | 2 - src/ZoneServer/World/Groups/GroupMember.cs | 63 ---------------------- 2 files changed, 65 deletions(-) diff --git a/src/ZoneServer/World/Groups/Group.cs b/src/ZoneServer/World/Groups/Group.cs index 22a858981..1cb791711 100644 --- a/src/ZoneServer/World/Groups/Group.cs +++ b/src/ZoneServer/World/Groups/Group.cs @@ -285,8 +285,6 @@ public IMember ToMember(Character character) { case GroupType.Party: return GroupMember.ToPartyMember(character); - case GroupType.Guild: - return GroupMember.ToGuildMember(character); } return null; } diff --git a/src/ZoneServer/World/Groups/GroupMember.cs b/src/ZoneServer/World/Groups/GroupMember.cs index a5699a050..4b3c89c60 100644 --- a/src/ZoneServer/World/Groups/GroupMember.cs +++ b/src/ZoneServer/World/Groups/GroupMember.cs @@ -81,69 +81,6 @@ public static PartyMember ToPartyMember(Character character) } return member; } - - public static GuildMember ToGuildMember(Character character) - { - var member = new GuildMember() - { - 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 GuildMember : GroupMember - { - public new Properties Properties { get; set; } = new Properties("GuildMember"); - - public int Contribution - { - get - { - return (int)this.Properties.GetFloat(PropertyName.Contribution); - } - set - { - this.Properties.SetFloat(PropertyName.Contribution, value); - } - } } public class PartyMember : GroupMember From e871e808a9c95fe76d5a2e0225e36954dfa887db Mon Sep 17 00:00:00 2001 From: MrShadow Date: Tue, 14 Oct 2025 16:03:11 -0300 Subject: [PATCH 10/13] Fix to party member character change --- src/ZoneServer/Network/PacketHandler.cs | 10 +++++-- src/ZoneServer/World/Party.cs | 35 +++++++++++++++++++++++++ src/ZoneServer/World/PartyManager.cs | 28 ++++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/ZoneServer/Network/PacketHandler.cs b/src/ZoneServer/Network/PacketHandler.cs index dd1fe3ec5..d514461b6 100644 --- a/src/ZoneServer/Network/PacketHandler.cs +++ b/src/ZoneServer/Network/PacketHandler.cs @@ -110,8 +110,14 @@ public void CZ_CONNECT(IZoneConnection conn, Packet packet) character.Connection = conn; conn.SelectedCharacter = character; - // Reconnect to party if the character has one - conn.Party = ZoneServer.Instance.World.GetParty(character.PartyId); + // 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)); diff --git a/src/ZoneServer/World/Party.cs b/src/ZoneServer/World/Party.cs index 22c3db71d..a91c97a21 100644 --- a/src/ZoneServer/World/Party.cs +++ b/src/ZoneServer/World/Party.cs @@ -159,6 +159,41 @@ public virtual void AddMember(Character character, bool silently = false) } } + /// + /// 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 /// diff --git a/src/ZoneServer/World/PartyManager.cs b/src/ZoneServer/World/PartyManager.cs index 156bab50a..d2483ce5b 100644 --- a/src/ZoneServer/World/PartyManager.cs +++ b/src/ZoneServer/World/PartyManager.cs @@ -119,5 +119,33 @@ public Party GetParty(long partyId) } 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; + } } } From 86249890a4aad59d716a52517e3bb30d20f88d1a Mon Sep 17 00:00:00 2001 From: MrShadow Date: Tue, 14 Oct 2025 16:33:37 -0300 Subject: [PATCH 11/13] Fix for offline party members --- src/ZoneServer/Database/ZoneDb.Social.cs | 39 +++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/ZoneServer/Database/ZoneDb.Social.cs b/src/ZoneServer/Database/ZoneDb.Social.cs index d74235367..112aa1bba 100644 --- a/src/ZoneServer/Database/ZoneDb.Social.cs +++ b/src/ZoneServer/Database/ZoneDb.Social.cs @@ -92,6 +92,8 @@ 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); @@ -119,13 +121,48 @@ private void LoadPartyMembers(Character loadCharacter, Party party) var z = reader.GetFloat("z"); member.Position = new Position(x, y, z); member.IsOnline = loadCharacter.DbId == member.DbId; - party.AddMember(member); + 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); + } } } From cc4ed30f73d5aa7220c7eb2c8079c01152fcd7ff Mon Sep 17 00:00:00 2001 From: MrShadow Date: Tue, 14 Oct 2025 17:05:01 -0300 Subject: [PATCH 12/13] Minor changes --- doc/bt/CZ_PARTY_PROP_CHANGE.bt | 7 +++++-- doc/bt/ZC_ENTER_PC.bt | 12 ++++++------ doc/bt/ZC_PARTY_INFO.bt | 7 ++++--- doc/bt/inc/Party.bt | 11 ++++++----- src/ZoneServer/Network/Helpers/PartyHelper.cs | 7 ++++--- 5 files changed, 25 insertions(+), 19 deletions(-) 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/src/ZoneServer/Network/Helpers/PartyHelper.cs b/src/ZoneServer/Network/Helpers/PartyHelper.cs index 01e17fa08..f4ee00b49 100644 --- a/src/ZoneServer/Network/Helpers/PartyHelper.cs +++ b/src/ZoneServer/Network/Helpers/PartyHelper.cs @@ -24,9 +24,10 @@ public static void AddMember(this Packet packet, IMember member) packet.PutShort((short)member.FirstJobId); packet.PutShort(0); packet.PutInt(member.JobLevel); - packet.PutShort(1); - packet.PutInt(46); - packet.PutEmptyBin(18); + 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); From d0a879fe008ff508e260a3c30e9e2c44d4f0da5a Mon Sep 17 00:00:00 2001 From: MrShadow Date: Tue, 14 Oct 2025 17:38:52 -0300 Subject: [PATCH 13/13] Added party.conf, for better customization --- src/Shared/Configuration/Files/World.cs | 23 +++++++ src/ZoneServer/World/Party.cs | 40 +++++------- system/conf/world.conf | 1 + system/conf/world/party.conf | 86 +++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 23 deletions(-) create mode 100644 system/conf/world/party.conf 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/ZoneServer/World/Party.cs b/src/ZoneServer/World/Party.cs index a91c97a21..7d011bbd3 100644 --- a/src/ZoneServer/World/Party.cs +++ b/src/ZoneServer/World/Party.cs @@ -438,23 +438,25 @@ public void GiveExp(Character killer, long exp, long classExp, Mob monster) { var levelDifference = highestLevel - characterLevel; var penaltyMultiplier = 1.0f; + var conf = ZoneServer.Instance.Conf.World; - if (levelDifference >= 20) + // Check thresholds from highest to lowest + if (levelDifference >= conf.PartyLevelPenaltyThreshold3) { - penaltyMultiplier = 0.0f; // -100% exp + penaltyMultiplier = conf.PartyLevelPenalty3; } - else if (levelDifference >= 15) + else if (levelDifference >= conf.PartyLevelPenaltyThreshold2) { - penaltyMultiplier = 0.4f; // -60% exp + penaltyMultiplier = conf.PartyLevelPenalty2; } - else if (levelDifference >= 10) + else if (levelDifference >= conf.PartyLevelPenaltyThreshold1) { - penaltyMultiplier = 0.7f; // -30% exp + penaltyMultiplier = conf.PartyLevelPenalty1; } return ( - (long)(baseExp * penaltyMultiplier), - (long)(baseClassExp * penaltyMultiplier) + (long)(baseExp * penaltyMultiplier), + (long)(baseClassExp * penaltyMultiplier) ); } @@ -489,26 +491,18 @@ private float GetPartyExpModifier() return 1f; } + var conf = ZoneServer.Instance.Conf.World; + switch (onlineMemberCount) { case 2: - return 1.2f; + return conf.PartyExpMultiplier2; case 3: - return 1.5f; + return conf.PartyExpMultiplier3; case 4: - return 1.8f; - case 5: - return 2.2f; - case 6: - return 2.5f; - case 7: - return 2.8f; - case 8: - return 3.0f; - case 9: - return 3.5f; - default: - return (onlineMemberCount - 9) * .5f + 3.5f; + return conf.PartyExpMultiplier4; + default: // 5 or more + return conf.PartyExpMultiplier5Plus; } } 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"