diff --git a/src/ZoneServer/Network/PacketHandler.cs b/src/ZoneServer/Network/PacketHandler.cs
index 652efadf3..641358112 100644
--- a/src/ZoneServer/Network/PacketHandler.cs
+++ b/src/ZoneServer/Network/PacketHandler.cs
@@ -1905,6 +1905,114 @@ public void CZ_MAP_SEARCH_INFO(IZoneConnection conn, Packet packet)
public void CZ_EXCHANGE_REQUEST(IZoneConnection conn, Packet packet)
{
var targetHandle = packet.GetInt();
+
+ var character = conn.SelectedCharacter;
+
+ if (character == null)
+ return;
+
+ var targetCharacter = character.Map.GetCharacter(targetHandle);
+ if (targetCharacter == null)
+ {
+ Log.Warning("CZ_EXCHANGE_REQUEST: User '{0}' trade partner not found.", conn.Account.Name);
+ return;
+ }
+
+ ZoneServer.Instance.World.Trades.RequestTrade(character, targetCharacter);
+ }
+
+ ///
+ /// Indicates an accepted request from the client to trade with another character.
+ ///
+ ///
+ ///
+ [PacketHandler(Op.CZ_EXCHANGE_ACCEPT)]
+ public void CZ_EXCHANGE_ACCEPT(IZoneConnection conn, Packet packet)
+ {
+ var character = conn.SelectedCharacter;
+
+ if (!character.IsTrading)
+ {
+ Log.Warning("CZ_EXCHANGE_ACCEPT: User '{0}' tried to accept a non-existent trade.", conn.Account.Name);
+ return;
+ }
+
+ ZoneServer.Instance.World.Trades.StartTrade(character);
+ }
+
+ ///
+ /// Request to offer an item for trade
+ ///
+ ///
+ ///
+ [PacketHandler(Op.CZ_EXCHANGE_OFFER)]
+ public void CZ_EXCHANGE_OFFER(IZoneConnection conn, Packet packet)
+ {
+ var i1 = packet.GetInt();
+ var worldId = packet.GetLong();
+ var amount = packet.GetInt();
+ var i3 = packet.GetInt();
+
+ var character = conn.SelectedCharacter;
+
+ if (character == null)
+ return;
+
+ if (!character.IsTrading)
+ {
+ Log.Warning("CZ_EXCHANGE_OFFER: User '{0}' tried to trade without actually trading.", conn.Account.Name);
+ return;
+ }
+
+ ZoneServer.Instance.World.Trades.OfferTradeItem(character, worldId, amount);
+ }
+
+ ///
+ /// Initial trade agreement request
+ ///
+ ///
+ ///
+ [PacketHandler(Op.CZ_EXCHANGE_AGREE)]
+ public void CZ_EXCHANGE_AGREE(IZoneConnection conn, Packet packet)
+ {
+ var character = conn.SelectedCharacter;
+
+ if (character == null)
+ return;
+
+ ZoneServer.Instance.World.Trades.ConfirmTrade(character);
+ }
+
+ ///
+ /// Final trade agreement request
+ ///
+ ///
+ ///
+ [PacketHandler(Op.CZ_EXCHANGE_FINALAGREE)]
+ public void CZ_EXCHANGE_FINALAGREE(IZoneConnection conn, Packet packet)
+ {
+ var character = conn.SelectedCharacter;
+
+ if (character == null)
+ return;
+
+ ZoneServer.Instance.World.Trades.FinalConfirmTrade(character);
+ }
+
+ ///
+ /// Cancel trade request
+ ///
+ ///
+ ///
+ [PacketHandler(Op.CZ_EXCHANGE_CANCEL)]
+ public void CZ_EXCHANGE_CANCEL(IZoneConnection conn, Packet packet)
+ {
+ var character = conn.SelectedCharacter;
+
+ if (character == null)
+ return;
+
+ ZoneServer.Instance.World.Trades.CancelTrade(character);
}
///
diff --git a/src/ZoneServer/Network/Send.cs b/src/ZoneServer/Network/Send.cs
index 8adb3d947..f04e42489 100644
--- a/src/ZoneServer/Network/Send.cs
+++ b/src/ZoneServer/Network/Send.cs
@@ -1552,6 +1552,139 @@ public static void ZC_OBJECT_PROPERTY(IZoneConnection conn, long objectId, Prope
conn.Send(packet);
}
+ ///
+ /// Send a trade request to another player acknowledgement
+ ///
+ ///
+ public static void ZC_EXCHANGE_REQUEST_ACK(Character character)
+ {
+ var packet = new Packet(Op.ZC_EXCHANGE_REQUEST_ACK);
+
+ packet.PutString(character.Name, 65);
+ packet.PutByte(0);
+
+ character.Connection.Send(packet);
+ }
+
+ ///
+ /// Send a trade request to another player
+ ///
+ ///
+ public static void ZC_EXCHANGE_REQUEST_RECEIVED(Character character, string requesterName)
+ {
+ var packet = new Packet(Op.ZC_EXCHANGE_REQUEST_RECEIVED);
+
+ packet.PutString(requesterName, 65);
+
+ character.Connection.Send(packet);
+ }
+
+ ///
+ /// Send start trade to client
+ ///
+ ///
+ public static void ZC_EXCHANGE_START(Character character, string tradePartnerTeamName)
+ {
+ var packet = new Packet(Op.ZC_EXCHANGE_START);
+
+ packet.PutString(tradePartnerTeamName, 65);
+ packet.PutByte(0);
+
+ character.Connection.Send(packet);
+ }
+
+ ///
+ /// Send item offer to client
+ ///
+ ///
+ public static void ZC_EXCHANGE_OFFER_ACK(Character character, bool sameAsSender, Item item, int amount)
+ {
+ var packet = new Packet(Op.ZC_EXCHANGE_OFFER_ACK);
+
+ var propertyList = item.Properties.GetAll();
+ var propertiesSize = propertyList.GetByteCount();
+
+ packet.PutByte((byte)(sameAsSender ? 0 : 1));
+ packet.PutInt(0);
+ packet.PutInt(-1);
+ packet.PutLong(item.ObjectId);
+ packet.PutInt(item.Id);
+ packet.PutInt(amount);
+ packet.PutShort(propertiesSize);
+ packet.AddProperties(propertyList);
+ packet.PutShort(0);
+ packet.PutLong(item.ObjectId);
+
+ // TODO:
+ // Check for item sockets when trading
+ // -----------------------------------
+ // if (!item.HasSockets)
+ // packet.PutShort(0);
+ // else
+ // {
+ // var gems = item.GetGemSockets();
+ // packet.PutShort(gems.Count);
+ //
+ // short gemIndex = 0;
+ // foreach (var gem in gems)
+ // {
+ // packet.AddSocket(gemIndex, gem);
+ // gemIndex++;
+ // }
+ // }
+
+ character.Connection.Send(packet);
+ }
+
+ ///
+ /// Send exchange initial agree acknowledgement to client
+ ///
+ ///
+ ///
+ public static void ZC_EXCHANGE_AGREE_ACK(Character character, bool isSameAsSender)
+ {
+ var packet = new Packet(Op.ZC_EXCHANGE_AGREE_ACK);
+ packet.PutByte((byte)(isSameAsSender ? 0 : 1));
+
+ character.Connection.Send(packet);
+ }
+
+ ///
+ /// Send exchange final agree acknowledgement to client
+ ///
+ ///
+ ///
+ public static void ZC_EXCHANGE_FINALAGREE_ACK(Character character, bool isSameAsSender)
+ {
+ var packet = new Packet(Op.ZC_EXCHANGE_FINALAGREE_ACK);
+
+ packet.PutByte((byte)(isSameAsSender ? 0 : 1));
+
+ character.Connection.Send(packet);
+ }
+
+ ///
+ /// Send trade successfully completed
+ ///
+ ///
+ public static void ZC_EXCHANGE_SUCCESS(Character character)
+ {
+ var packet = new Packet(Op.ZC_EXCHANGE_SUCCESS);
+
+ character.Connection.Send(packet);
+ }
+
+ ///
+ /// Send trade canceled
+ ///
+ ///
+ public static void ZC_EXCHANGE_CANCEL_ACK(Character character)
+ {
+ var packet = new Packet(Op.ZC_EXCHANGE_CANCEL_ACK);
+
+ character.Connection.Send(packet);
+ }
+
///
/// Updates actor's rotation for characters in range of it.
///
diff --git a/src/ZoneServer/World/Actors/Characters/Character.cs b/src/ZoneServer/World/Actors/Characters/Character.cs
index 0d2d659c9..c0679b52f 100644
--- a/src/ZoneServer/World/Actors/Characters/Character.cs
+++ b/src/ZoneServer/World/Actors/Characters/Character.cs
@@ -181,6 +181,11 @@ public class Character : Actor, IActor, ICombatEntity, ICommander, IPropertyObje
///
public bool IsSitting { get; set; }
+ ///
+ /// Gets whether the character is currently in a trade.
+ ///
+ public bool IsTrading => ZoneServer.Instance.World.Trades.IsTrading(this.ObjectId);
+
///
/// Returns the character's personal storage.
///
diff --git a/src/ZoneServer/World/Items/Item.cs b/src/ZoneServer/World/Items/Item.cs
index 02b414e2a..42520b2ad 100644
--- a/src/ZoneServer/World/Items/Item.cs
+++ b/src/ZoneServer/World/Items/Item.cs
@@ -116,7 +116,7 @@ public Item(int itemId, int amount = 1)
/// Creates a copy of the given item.
///
///
- public Item(Item other)
+ public Item(Item other, int amount = -1)
{
this.Id = other.Id;
this.LoadData();
@@ -129,8 +129,15 @@ public Item(Item other)
this.LootProtectionEnd = other.LootProtectionEnd;
other.Properties.CopyFrom(this.Properties);
+ // TODO:
+ // Copy sockets
+ // -----------------------------------
+ // this.CopyGemSockets(other);
- this.Amount = other.Amount;
+ if (amount == -1)
+ this.Amount = other.Amount;
+ else
+ this.Amount = amount;
}
///
diff --git a/src/ZoneServer/World/TradeManager.cs b/src/ZoneServer/World/TradeManager.cs
new file mode 100644
index 000000000..67dac9c94
--- /dev/null
+++ b/src/ZoneServer/World/TradeManager.cs
@@ -0,0 +1,165 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Melia.Zone.Network;
+using Melia.Zone.World.Actors.Characters;
+using Melia.Zone.World.Trades;
+using Yggdrasil.Logging;
+
+namespace Melia.Zone.World
+{
+ ///
+ /// Manages active player-to-player trades.
+ ///
+ public class TradeManager
+ {
+ ///
+ /// Trades indexed by character ObjectId.
+ ///
+ private readonly Dictionary _trades = new Dictionary();
+
+ ///
+ /// Returns true if the character is currently in a trade.
+ ///
+ ///
+ ///
+ public bool IsTrading(long characterObjectId) => _trades.ContainsKey(characterObjectId);
+
+ ///
+ /// Sends a trade request between two characters, the sender and recipient.
+ ///
+ ///
+ ///
+ public void RequestTrade(Character sender, Character recipient)
+ {
+ if (sender.IsTrading || recipient.IsTrading)
+ return;
+ var trade = new Trade(sender, recipient);
+
+ lock (_trades)
+ {
+ if (!_trades.TryAdd(sender.ObjectId, trade))
+ return;
+ if (!_trades.TryAdd(recipient.ObjectId, trade))
+ {
+ _trades.Remove(sender.ObjectId);
+ return;
+ }
+ }
+
+ Send.ZC_EXCHANGE_REQUEST_ACK(sender);
+ Send.ZC_EXCHANGE_REQUEST_RECEIVED(recipient, sender.Name);
+ }
+
+ ///
+ /// Starts the trade session, opening the trade window for both traders.
+ ///
+ ///
+ public void StartTrade(Character character)
+ {
+ if (!_trades.TryGetValue(character.ObjectId, out var trade))
+ {
+ Log.Warning("TradeManager: User {0} tried to start a non-existent trade.", character.Connection.Account.Name);
+ return;
+ }
+
+ trade.Start();
+ }
+
+ ///
+ /// Cancels the trade and removes it from active trades.
+ ///
+ ///
+ public void CancelTrade(Character character)
+ {
+ if (!character.IsTrading)
+ return;
+ if (!_trades.TryGetValue(character.ObjectId, out var trade))
+ {
+ Log.Warning("TradeManager: User {0} tried to cancel a non-existent trade.", character.Connection.Account.Name);
+ return;
+ }
+
+ trade.Cancel();
+
+ lock (_trades)
+ {
+ _trades.Remove(trade.Trader1ObjectId);
+ _trades.Remove(trade.Trader2ObjectId);
+ }
+ }
+
+ ///
+ /// Offers an item to the trade partner.
+ ///
+ ///
+ ///
+ ///
+ public void OfferTradeItem(Character character, long worldId, int amount)
+ {
+ if (!_trades.TryGetValue(character.ObjectId, out var trade))
+ {
+ Log.Warning("TradeManager: User {0} tried to offer a non-existent trade.", character.Connection.Account.Name);
+ return;
+ }
+
+ trade.Offer(character, worldId, amount);
+ }
+
+ ///
+ /// Confirms the current trade offer (first confirmation).
+ ///
+ ///
+ public void ConfirmTrade(Character character)
+ {
+ if (!_trades.TryGetValue(character.ObjectId, out var trade))
+ {
+ Log.Warning("TradeManager: User {0} tried to confirm a non-existent trade.", character.Connection.Account.Name);
+ return;
+ }
+
+ trade.Confirm(character);
+ }
+
+ ///
+ /// Final confirmation to complete the trade.
+ ///
+ ///
+ public void FinalConfirmTrade(Character character)
+ {
+ if (!_trades.TryGetValue(character.ObjectId, out var trade))
+ {
+ Log.Warning("TradeManager: User {0} tried to final confirm a non-existent trade.", character.Connection.Account.Name);
+ return;
+ }
+
+ if (trade.FinalConfirm(character))
+ this.FinishTrade(character);
+ }
+
+ ///
+ /// Removes the completed trade from active trades.
+ ///
+ ///
+ public void FinishTrade(Character character)
+ {
+ if (!_trades.TryGetValue(character.ObjectId, out var trade))
+ {
+ Log.Warning("TradeManager: User {0} tried to finish trade a non-existent trade.", character.Connection.Account.Name);
+ return;
+ }
+
+ if (trade.IsFinished)
+ {
+ lock (_trades)
+ {
+ _trades.Remove(trade.Trader1ObjectId);
+ _trades.Remove(trade.Trader2ObjectId);
+ }
+ }
+ }
+ }
+}
diff --git a/src/ZoneServer/World/Trades/Trade.cs b/src/ZoneServer/World/Trades/Trade.cs
new file mode 100644
index 000000000..145630c77
--- /dev/null
+++ b/src/ZoneServer/World/Trades/Trade.cs
@@ -0,0 +1,271 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Melia.Shared.Game.Const;
+using Melia.Zone.Network;
+using Melia.Zone.World.Actors.Characters;
+using Melia.Zone.World.Actors.Characters.Components;
+using Melia.Zone.World.Items;
+using Yggdrasil.Logging;
+
+namespace Melia.Zone.World.Trades
+{
+ public class Trade
+ {
+ private readonly Dictionary _trader1Items = new();
+ private readonly Dictionary _trader2Items = new();
+
+ private TradeState _trader1State = TradeState.NotStarted;
+ private TradeState _trader2State = TradeState.NotStarted;
+
+ enum TradeState
+ {
+ NotStarted,
+ Started,
+ Confirmed,
+ FinalConfirmed,
+ }
+
+ public long Trader1ObjectId { get; }
+ public long Trader2ObjectId { get; }
+
+ public bool IsFinished => _trader1State == TradeState.FinalConfirmed && _trader2State == TradeState.FinalConfirmed;
+
+ public Trade(Character trader1, Character trader2)
+ {
+ this.Trader1ObjectId = trader1.ObjectId;
+ this.Trader2ObjectId = trader2.ObjectId;
+ }
+
+ ///
+ /// Offer an item to trade.
+ ///
+ ///
+ ///
+ ///
+ public void Offer(Character character, long worldId, int amount)
+ {
+ if (!this.ValidateTrader(character, worldId, amount, out var item, out var traderItems, out var trader))
+ return;
+
+ if (!traderItems.TryAdd(worldId, amount))
+ traderItems[worldId] = amount;
+
+ var otherTrader = this.GetOtherTrader(character);
+ Send.ZC_EXCHANGE_OFFER_ACK(trader, true, item, amount);
+ Send.ZC_EXCHANGE_OFFER_ACK(otherTrader, false, item, amount);
+ }
+
+ private Character GetOtherTrader(Character character)
+ {
+ var otherTraderId = character.ObjectId == this.Trader1ObjectId ? this.Trader2ObjectId : this.Trader1ObjectId;
+
+ return ZoneServer.Instance.World.GetCharacter(c => c.ObjectId == otherTraderId);
+ }
+
+ ///
+ /// Validates trader and item offered.
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ private bool ValidateTrader(Character character, long worldId, int amount, out Item item, out Dictionary traderItems, out Character trader)
+ {
+ item = null;
+ traderItems = null;
+ trader = null;
+
+ if (character.ObjectId == this.Trader1ObjectId)
+ {
+ trader = character;
+ traderItems = this._trader1Items;
+ }
+ else if (character.ObjectId == this.Trader2ObjectId)
+ {
+ trader = character;
+ traderItems = this._trader2Items;
+ }
+ else
+ {
+ return false;
+ }
+
+ item = trader.Inventory.GetItem(worldId);
+ if (item == null)
+ {
+ Log.Warning("Trade Offer: User '{0}' tried to trade a non-existent item.", trader.Connection.Account.Name);
+ return false;
+ }
+
+ if (item.Amount < amount)
+ {
+ Log.Warning("Trade Offer: User '{0}' tried to trade more of an item than they own.", trader.Connection.Account.Name);
+ return false;
+ }
+
+ if (item.IsLocked)
+ {
+ Log.Warning("Trade Offer: User '{0}' tried to trade a locked item.", trader.Connection.Account.Name);
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Check if offered items exist and have enough quantity to be traded
+ /// before swapping items from inventory, returns true if trade is completed.
+ ///
+ private bool Finalized()
+ {
+ var trader1 = ZoneServer.Instance.World.GetCharacter(c => c.ObjectId == this.Trader1ObjectId);
+ var trader2 = ZoneServer.Instance.World.GetCharacter(c => c.ObjectId == this.Trader2ObjectId);
+
+ if (trader1 == null || trader2 == null)
+ {
+ this.Cancel();
+ return false;
+ }
+
+ // --- Pre-validation step to ensure trade is still valid ---
+ foreach (var offer in _trader1Items)
+ {
+ var item = trader1.Inventory.GetItem(offer.Key);
+ if (item == null || item.Amount < offer.Value || item.IsLocked)
+ {
+ trader1.SystemMessage("An item in your trade offer is no longer valid. The trade has been cancelled.");
+ trader2.SystemMessage("An item in the other player's trade offer is no longer valid. The trade has been cancelled.");
+ this.Cancel();
+ return false;
+ }
+ }
+ foreach (var offer in _trader2Items)
+ {
+ var item = trader2.Inventory.GetItem(offer.Key);
+ if (item == null || item.Amount < offer.Value || item.IsLocked)
+ {
+ trader2.SystemMessage("An item in your trade offer is no longer valid. The trade has been cancelled.");
+ trader1.SystemMessage("An item in the other player's trade offer is no longer valid. The trade has been cancelled.");
+ this.Cancel();
+ return false;
+ }
+ }
+
+ // --- Perform Transfers ---
+
+ // Transfer items from Trader 1 to Trader 2
+ foreach (var offer in this._trader1Items)
+ {
+ var tradedItem = trader1.Inventory.GetItem(offer.Key);
+ if (tradedItem != null)
+ {
+ var amountToTransfer = offer.Value;
+
+ trader1.Inventory.Remove(tradedItem, offer.Value, InventoryItemRemoveMsg.Given);
+
+ if (amountToTransfer > 0)
+ {
+ var newItem = new Item(tradedItem, amountToTransfer);
+ trader2.Inventory.Add(newItem, InventoryAddType.New);
+ }
+ }
+ }
+
+ // Transfer items from Trader 2 to Trader 1
+ foreach (var offer in this._trader2Items)
+ {
+ var tradedItem = trader2.Inventory.GetItem(offer.Key);
+ if (tradedItem != null)
+ {
+ var amountToTransfer = offer.Value;
+
+ trader2.Inventory.Remove(tradedItem, offer.Value, InventoryItemRemoveMsg.Given);
+
+ if (amountToTransfer > 0)
+ {
+ var newItem = new Item(tradedItem, amountToTransfer);
+ trader1.Inventory.Add(newItem, InventoryAddType.New);
+ }
+ }
+ }
+
+ Send.ZC_EXCHANGE_SUCCESS(trader1);
+ Send.ZC_EXCHANGE_SUCCESS(trader2);
+
+ return true;
+ }
+
+ ///
+ /// Start Trade
+ ///
+ public void Start()
+ {
+ this._trader1State = TradeState.Started;
+ this._trader2State = TradeState.Started;
+ var trader1 = ZoneServer.Instance.World.GetCharacter(c => c.ObjectId == this.Trader1ObjectId);
+ var trader2 = ZoneServer.Instance.World.GetCharacter(c => c.ObjectId == this.Trader2ObjectId);
+
+ Send.ZC_EXCHANGE_START(trader1, trader2.TeamName);
+ Send.ZC_EXCHANGE_START(trader2, trader1.TeamName);
+ }
+
+ ///
+ /// Cancel Trade
+ ///
+ public void Cancel()
+ {
+ var trader1 = ZoneServer.Instance.World.GetCharacter(c => c.ObjectId == this.Trader1ObjectId);
+ var trader2 = ZoneServer.Instance.World.GetCharacter(c => c.ObjectId == this.Trader2ObjectId);
+
+ if (trader1 != null)
+ Send.ZC_EXCHANGE_CANCEL_ACK(trader1);
+ if (trader2 != null)
+ Send.ZC_EXCHANGE_CANCEL_ACK(trader2);
+ }
+
+ ///
+ /// Trade offer confirmation, before final trade confirmation
+ ///
+ ///
+ public void Confirm(Character trader)
+ {
+ var otherTrader = this.GetOtherTrader(trader);
+
+ if (trader.ObjectId == this.Trader1ObjectId)
+ this._trader1State = TradeState.Confirmed;
+ else if (trader.ObjectId == this.Trader2ObjectId)
+ this._trader2State = TradeState.Confirmed;
+
+ Send.ZC_EXCHANGE_AGREE_ACK(trader, true);
+ Send.ZC_EXCHANGE_AGREE_ACK(otherTrader, false);
+ }
+
+ ///
+ /// Trade Final Agreement, if both players agree finalized is called.
+ ///
+ ///
+ public bool FinalConfirm(Character trader)
+ {
+ var otherTrader = this.GetOtherTrader(trader);
+
+ if (trader.ObjectId == this.Trader1ObjectId)
+ this._trader1State = TradeState.FinalConfirmed;
+ else if (trader.ObjectId == this.Trader2ObjectId)
+ this._trader2State = TradeState.FinalConfirmed;
+
+ Send.ZC_EXCHANGE_FINALAGREE_ACK(trader, true);
+ Send.ZC_EXCHANGE_FINALAGREE_ACK(otherTrader, false);
+
+ if (this._trader1State == TradeState.FinalConfirmed && this._trader2State == TradeState.FinalConfirmed)
+ return this.Finalized();
+
+ return false;
+ }
+ }
+}
diff --git a/src/ZoneServer/World/WorldManager.cs b/src/ZoneServer/World/WorldManager.cs
index d4b808465..480f82f44 100644
--- a/src/ZoneServer/World/WorldManager.cs
+++ b/src/ZoneServer/World/WorldManager.cs
@@ -56,6 +56,11 @@ public class WorldManager
///
public GlobalVariables GlobalVariables { get; } = new();
+ ///
+ /// Returns the world's trade manager.
+ ///
+ public TradeManager Trades { get; } = new TradeManager();
+
///
/// Returns a new handle to be used for a character or monster.
///
@@ -334,6 +339,27 @@ public bool TryGetCharacterByTeamName(string teamName, out Character character)
return character != null;
}
+ ///
+ /// Returns the first character that matches the given predicate,
+ /// or null if none were found.
+ ///
+ ///
+ ///
+ public Character GetCharacter(Func predicate)
+ {
+ lock (_mapsLock)
+ {
+ foreach (var map in _mapsId.Values)
+ {
+ var characters = map.GetCharacters(predicate);
+ if (characters.Length > 0)
+ return characters[0];
+ }
+ }
+
+ return null;
+ }
+
///
/// Returns all characters that are currently online.
///