From adef505bf8ce91de92b44c2c011c6120b0915925 Mon Sep 17 00:00:00 2001 From: MrShadow Date: Thu, 13 Nov 2025 18:17:04 -0300 Subject: [PATCH] Player trading implementation --- src/ZoneServer/Network/PacketHandler.cs | 108 +++++++ src/ZoneServer/Network/Send.cs | 133 +++++++++ .../World/Actors/Characters/Character.cs | 5 + src/ZoneServer/World/Items/Item.cs | 11 +- src/ZoneServer/World/TradeManager.cs | 165 +++++++++++ src/ZoneServer/World/Trades/Trade.cs | 271 ++++++++++++++++++ src/ZoneServer/World/WorldManager.cs | 26 ++ 7 files changed, 717 insertions(+), 2 deletions(-) create mode 100644 src/ZoneServer/World/TradeManager.cs create mode 100644 src/ZoneServer/World/Trades/Trade.cs 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. ///