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