feat: implement trade window UI with item slots and gold offering

Previously trade only showed an accept/decline popup with no way to
actually offer items or gold. This commit adds the complete trade flow:

Packets:
- CMSG_SET_TRADE_ITEM (tradeSlot, bag, bagSlot) — add item to slot
- CMSG_CLEAR_TRADE_ITEM (tradeSlot) — remove item from slot
- CMSG_SET_TRADE_GOLD (uint64 copper) — set gold offered
- CMSG_UNACCEPT_TRADE — unaccept without cancelling
- SMSG_TRADE_STATUS_EXTENDED parser — updates trade slot/gold state

State:
- TradeSlot struct: itemId, displayId, stackCount, bag, bagSlot
- myTradeSlots_/peerTradeSlots_ arrays (6 slots each)
- myTradeGold_/peerTradeGold_ (copper)
- resetTradeState() helper clears all state on cancel/complete/close

UI (renderTradeWindow):
- Two-column layout: my offer | peer offer
- Each column shows 6 item slots with item names
- Double-click own slot to remove; right-click empty slot to open
  backpack picker popup
- Gold input field (copper, Enter to set)
- Accept Trade / Cancel buttons
- Window close button triggers cancel trade
This commit is contained in:
Kelsi 2026-03-11 00:44:07 -07:00
parent 7c5d688c00
commit 06facc0060
6 changed files with 337 additions and 5 deletions

View file

@ -933,13 +933,38 @@ public:
enum class TradeStatus : uint8_t {
None = 0, PendingIncoming, Open, Accepted, Complete
};
static constexpr int TRADE_SLOT_COUNT = 6; // WoW has 6 normal trade slots + slot 6 for non-trade item
struct TradeSlot {
uint32_t itemId = 0;
uint32_t displayId = 0;
uint32_t stackCount = 0;
uint64_t itemGuid = 0;
uint8_t bag = 0xFF; // 0xFF = not set
uint8_t bagSlot = 0xFF;
bool occupied = false;
};
TradeStatus getTradeStatus() const { return tradeStatus_; }
bool hasPendingTradeRequest() const { return tradeStatus_ == TradeStatus::PendingIncoming; }
bool isTradeOpen() const { return tradeStatus_ == TradeStatus::Open || tradeStatus_ == TradeStatus::Accepted; }
const std::string& getTradePeerName() const { return tradePeerName_; }
// My trade slots (what I'm offering)
const std::array<TradeSlot, TRADE_SLOT_COUNT>& getMyTradeSlots() const { return myTradeSlots_; }
// Peer's trade slots (what they're offering)
const std::array<TradeSlot, TRADE_SLOT_COUNT>& getPeerTradeSlots() const { return peerTradeSlots_; }
uint64_t getMyTradeGold() const { return myTradeGold_; }
uint64_t getPeerTradeGold() const { return peerTradeGold_; }
void acceptTradeRequest(); // respond to incoming SMSG_TRADE_STATUS(1) with CMSG_BEGIN_TRADE
void declineTradeRequest(); // respond with CMSG_CANCEL_TRADE
void acceptTrade(); // lock in offer: CMSG_ACCEPT_TRADE
void cancelTrade(); // CMSG_CANCEL_TRADE
void setTradeItem(uint8_t tradeSlot, uint8_t bag, uint8_t bagSlot);
void clearTradeItem(uint8_t tradeSlot);
void setTradeGold(uint64_t copper);
// ---- Duel ----
bool hasPendingDuelRequest() const { return pendingDuelRequest_; }
@ -1653,6 +1678,8 @@ private:
void handleQuestConfirmAccept(network::Packet& packet);
void handleSummonRequest(network::Packet& packet);
void handleTradeStatus(network::Packet& packet);
void handleTradeStatusExtended(network::Packet& packet);
void resetTradeState();
void handleDuelRequested(network::Packet& packet);
void handleDuelComplete(network::Packet& packet);
void handleDuelWinner(network::Packet& packet);
@ -2077,6 +2104,10 @@ private:
TradeStatus tradeStatus_ = TradeStatus::None;
uint64_t tradePeerGuid_= 0;
std::string tradePeerName_;
std::array<TradeSlot, TRADE_SLOT_COUNT> myTradeSlots_{};
std::array<TradeSlot, TRADE_SLOT_COUNT> peerTradeSlots_{};
uint64_t myTradeGold_ = 0;
uint64_t peerTradeGold_ = 0;
// Duel state
bool pendingDuelRequest_ = false;

View file

@ -1356,6 +1356,33 @@ public:
static network::Packet build();
};
/** CMSG_SET_TRADE_ITEM packet builder (tradeSlot, bag, bagSlot) */
class SetTradeItemPacket {
public:
// tradeSlot: 0-5 (normal) or 6 (backpack money-only slot)
// bag: 255 = main backpack, 19-22 = bag slots
// bagSlot: slot within bag
static network::Packet build(uint8_t tradeSlot, uint8_t bag, uint8_t bagSlot);
};
/** CMSG_CLEAR_TRADE_ITEM packet builder (remove item from trade slot) */
class ClearTradeItemPacket {
public:
static network::Packet build(uint8_t tradeSlot);
};
/** CMSG_SET_TRADE_GOLD packet builder (gold offered, in copper) */
class SetTradeGoldPacket {
public:
static network::Packet build(uint64_t copper);
};
/** CMSG_UNACCEPT_TRADE packet builder (unaccept without cancelling) */
class UnacceptTradePacket {
public:
static network::Packet build();
};
/** CMSG_ATTACKSWING packet builder */
class AttackSwingPacket {
public:

View file

@ -224,6 +224,7 @@ private:
void renderDuelRequestPopup(game::GameHandler& gameHandler);
void renderLootRollPopup(game::GameHandler& gameHandler);
void renderTradeRequestPopup(game::GameHandler& gameHandler);
void renderTradeWindow(game::GameHandler& gameHandler);
void renderSummonRequestPopup(game::GameHandler& gameHandler);
void renderSharedQuestPopup(game::GameHandler& gameHandler);
void renderItemTextWindow(game::GameHandler& gameHandler);

View file

@ -3102,9 +3102,11 @@ void GameHandler::handlePacket(network::Packet& packet) {
addSystemChatMessage("Summon cancelled.");
break;
case Opcode::SMSG_TRADE_STATUS:
case Opcode::SMSG_TRADE_STATUS_EXTENDED:
handleTradeStatus(packet);
break;
case Opcode::SMSG_TRADE_STATUS_EXTENDED:
handleTradeStatusExtended(packet);
break;
case Opcode::SMSG_LOOT_ROLL:
handleLootRoll(packet);
break;
@ -19047,13 +19049,17 @@ void GameHandler::handleTradeStatus(network::Packet& packet) {
break;
}
case 2: // OPEN_WINDOW
myTradeSlots_.fill(TradeSlot{});
peerTradeSlots_.fill(TradeSlot{});
myTradeGold_ = 0;
peerTradeGold_ = 0;
tradeStatus_ = TradeStatus::Open;
addSystemChatMessage("Trade window opened.");
break;
case 3: // CANCELLED
case 9: // REJECTED
case 12: // CLOSE_WINDOW
tradeStatus_ = TradeStatus::None;
resetTradeState();
addSystemChatMessage("Trade cancelled.");
break;
case 4: // ACCEPTED (partner accepted)
@ -19061,9 +19067,8 @@ void GameHandler::handleTradeStatus(network::Packet& packet) {
addSystemChatMessage("Trade accepted. Awaiting other player...");
break;
case 8: // COMPLETE
tradeStatus_ = TradeStatus::Complete;
addSystemChatMessage("Trade complete!");
tradeStatus_ = TradeStatus::None; // reset after notification
resetTradeState();
break;
case 7: // BACK_TO_TRADE (unaccepted after a change)
tradeStatus_ = TradeStatus::Open;
@ -19102,10 +19107,104 @@ void GameHandler::acceptTrade() {
void GameHandler::cancelTrade() {
if (!socket) return;
tradeStatus_ = TradeStatus::None;
resetTradeState();
socket->send(CancelTradePacket::build());
}
void GameHandler::setTradeItem(uint8_t tradeSlot, uint8_t bag, uint8_t bagSlot) {
if (!isTradeOpen() || !socket || tradeSlot >= TRADE_SLOT_COUNT) return;
socket->send(SetTradeItemPacket::build(tradeSlot, bag, bagSlot));
}
void GameHandler::clearTradeItem(uint8_t tradeSlot) {
if (!isTradeOpen() || !socket || tradeSlot >= TRADE_SLOT_COUNT) return;
myTradeSlots_[tradeSlot] = TradeSlot{};
socket->send(ClearTradeItemPacket::build(tradeSlot));
}
void GameHandler::setTradeGold(uint64_t copper) {
if (!isTradeOpen() || !socket) return;
myTradeGold_ = copper;
socket->send(SetTradeGoldPacket::build(copper));
}
void GameHandler::resetTradeState() {
tradeStatus_ = TradeStatus::None;
myTradeGold_ = 0;
peerTradeGold_ = 0;
myTradeSlots_.fill(TradeSlot{});
peerTradeSlots_.fill(TradeSlot{});
}
void GameHandler::handleTradeStatusExtended(network::Packet& packet) {
// WotLK 3.3.5a SMSG_TRADE_STATUS_EXTENDED format:
// uint8 isSelfState (1 = my trade window, 0 = peer's)
// uint32 tradeId
// uint32 slotCount (7: 6 normal + 1 extra for enchanting)
// Per slot (up to slotCount):
// uint8 slotIndex
// uint32 itemId
// uint32 displayId
// uint32 stackCount
// uint8 isWrapped
// uint64 giftCreatorGuid
// uint32 enchantId (and several more enchant/stat fields)
// ... (complex; we parse only the essential fields)
// uint64 coins (gold offered by the sender of this message)
size_t rem = packet.getSize() - packet.getReadPos();
if (rem < 9) return;
uint8_t isSelf = packet.readUInt8();
uint32_t tradeId = packet.readUInt32(); (void)tradeId;
uint32_t slotCount= packet.readUInt32();
auto& slots = isSelf ? myTradeSlots_ : peerTradeSlots_;
for (uint32_t i = 0; i < slotCount && (packet.getSize() - packet.getReadPos()) >= 14; ++i) {
uint8_t slotIdx = packet.readUInt8();
uint32_t itemId = packet.readUInt32();
uint32_t displayId = packet.readUInt32();
uint32_t stackCount = packet.readUInt32();
// isWrapped + giftCreatorGuid + several enchant fields — skip them all
// We need at least 1+8+4*5 = 29 bytes for the rest of this slot entry
bool isWrapped = false;
if (packet.getSize() - packet.getReadPos() >= 1) {
isWrapped = (packet.readUInt8() != 0);
}
// Skip giftCreatorGuid (8) + enchantId*5 (20) + suffixFactor (4) + randPropId (4) + lockId (4)
// + maxDurability (4) + durability (4) = 49 bytes
// Plus if wrapped: giftCreatorGuid already consumed; additional guid = 0
constexpr size_t SLOT_TRAIL = 49;
if (packet.getSize() - packet.getReadPos() >= SLOT_TRAIL) {
packet.setReadPos(packet.getReadPos() + SLOT_TRAIL);
} else {
packet.setReadPos(packet.getSize());
return;
}
(void)isWrapped;
if (slotIdx < TRADE_SLOT_COUNT) {
TradeSlot& s = slots[slotIdx];
s.itemId = itemId;
s.displayId = displayId;
s.stackCount = stackCount;
s.occupied = (itemId != 0);
}
}
// Gold offered (uint64 copper)
if (packet.getSize() - packet.getReadPos() >= 8) {
uint64_t coins = packet.readUInt64();
if (isSelf) myTradeGold_ = coins;
else peerTradeGold_ = coins;
}
LOG_DEBUG("SMSG_TRADE_STATUS_EXTENDED: isSelf=", (int)isSelf,
" myGold=", myTradeGold_, " peerGold=", peerTradeGold_);
}
// ---------------------------------------------------------------------------
// Group loot roll (SMSG_LOOT_ROLL / SMSG_LOOT_ROLL_WON / CMSG_LOOT_ROLL)
// ---------------------------------------------------------------------------

View file

@ -2177,6 +2177,35 @@ network::Packet AcceptTradePacket::build() {
return packet;
}
network::Packet SetTradeItemPacket::build(uint8_t tradeSlot, uint8_t bag, uint8_t bagSlot) {
network::Packet packet(wireOpcode(Opcode::CMSG_SET_TRADE_ITEM));
packet.writeUInt8(tradeSlot);
packet.writeUInt8(bag);
packet.writeUInt8(bagSlot);
LOG_DEBUG("Built CMSG_SET_TRADE_ITEM slot=", (int)tradeSlot, " bag=", (int)bag, " bagSlot=", (int)bagSlot);
return packet;
}
network::Packet ClearTradeItemPacket::build(uint8_t tradeSlot) {
network::Packet packet(wireOpcode(Opcode::CMSG_CLEAR_TRADE_ITEM));
packet.writeUInt8(tradeSlot);
LOG_DEBUG("Built CMSG_CLEAR_TRADE_ITEM slot=", (int)tradeSlot);
return packet;
}
network::Packet SetTradeGoldPacket::build(uint64_t copper) {
network::Packet packet(wireOpcode(Opcode::CMSG_SET_TRADE_GOLD));
packet.writeUInt64(copper);
LOG_DEBUG("Built CMSG_SET_TRADE_GOLD copper=", copper);
return packet;
}
network::Packet UnacceptTradePacket::build() {
network::Packet packet(wireOpcode(Opcode::CMSG_UNACCEPT_TRADE));
LOG_DEBUG("Built CMSG_UNACCEPT_TRADE");
return packet;
}
network::Packet InitiateTradePacket::build(uint64_t targetGuid) {
network::Packet packet(wireOpcode(Opcode::CMSG_INITIATE_TRADE));
packet.writeUInt64(targetGuid);

View file

@ -414,6 +414,7 @@ void GameScreen::render(game::GameHandler& gameHandler) {
renderDuelRequestPopup(gameHandler);
renderLootRollPopup(gameHandler);
renderTradeRequestPopup(gameHandler);
renderTradeWindow(gameHandler);
renderSummonRequestPopup(gameHandler);
renderSharedQuestPopup(gameHandler);
renderItemTextWindow(gameHandler);
@ -5980,6 +5981,150 @@ void GameScreen::renderTradeRequestPopup(game::GameHandler& gameHandler) {
ImGui::End();
}
void GameScreen::renderTradeWindow(game::GameHandler& gameHandler) {
if (!gameHandler.isTradeOpen()) return;
const auto& mySlots = gameHandler.getMyTradeSlots();
const auto& peerSlots = gameHandler.getPeerTradeSlots();
const uint64_t myGold = gameHandler.getMyTradeGold();
const uint64_t peerGold = gameHandler.getPeerTradeGold();
const auto& peerName = gameHandler.getTradePeerName();
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2.0f - 240.0f, screenH / 2.0f - 180.0f), ImGuiCond_Once);
ImGui::SetNextWindowSize(ImVec2(480.0f, 360.0f), ImGuiCond_Once);
bool open = true;
if (ImGui::Begin(("Trade with " + peerName).c_str(), &open,
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize)) {
auto formatGold = [](uint64_t copper, char* buf, size_t bufsz) {
uint64_t g = copper / 10000;
uint64_t s = (copper % 10000) / 100;
uint64_t c = copper % 100;
if (g > 0) std::snprintf(buf, bufsz, "%llug %llus %lluc",
(unsigned long long)g, (unsigned long long)s, (unsigned long long)c);
else if (s > 0) std::snprintf(buf, bufsz, "%llus %lluc",
(unsigned long long)s, (unsigned long long)c);
else std::snprintf(buf, bufsz, "%lluc", (unsigned long long)c);
};
auto renderSlotColumn = [&](const char* label,
const std::array<game::GameHandler::TradeSlot,
game::GameHandler::TRADE_SLOT_COUNT>& slots,
uint64_t gold, bool isMine) {
ImGui::Text("%s", label);
ImGui::Separator();
for (int i = 0; i < game::GameHandler::TRADE_SLOT_COUNT; ++i) {
const auto& slot = slots[i];
ImGui::PushID(i * (isMine ? 1 : -1) - (isMine ? 0 : 100));
if (slot.occupied && slot.itemId != 0) {
const auto* info = gameHandler.getItemInfo(slot.itemId);
std::string name = (info && info->valid && !info->name.empty())
? info->name
: ("Item " + std::to_string(slot.itemId));
if (slot.stackCount > 1)
name += " x" + std::to_string(slot.stackCount);
ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.5f, 1.0f), " %d. %s", i + 1, name.c_str());
if (isMine && ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) {
gameHandler.clearTradeItem(static_cast<uint8_t>(i));
}
if (isMine && ImGui::IsItemHovered()) {
ImGui::SetTooltip("Double-click to remove");
}
} else {
ImGui::TextDisabled(" %d. (empty)", i + 1);
// Allow dragging inventory items into trade slots via right-click context menu
if (isMine && ImGui::IsItemClicked(ImGuiMouseButton_Right)) {
ImGui::OpenPopup(("##additem" + std::to_string(i)).c_str());
}
}
if (isMine) {
// Drag-from-inventory: show small popup listing bag items
if (ImGui::BeginPopup(("##additem" + std::to_string(i)).c_str())) {
ImGui::TextDisabled("Add from inventory:");
const auto& inv = gameHandler.getInventory();
// Backpack slots 0-15 (bag=255)
for (int si = 0; si < game::Inventory::BACKPACK_SLOTS; ++si) {
const auto& slot = inv.getBackpackSlot(si);
if (slot.empty()) continue;
const auto* ii = gameHandler.getItemInfo(slot.item.itemId);
std::string iname = (ii && ii->valid && !ii->name.empty())
? ii->name
: (!slot.item.name.empty() ? slot.item.name
: ("Item " + std::to_string(slot.item.itemId)));
if (ImGui::Selectable(iname.c_str())) {
// bag=255 = main backpack
gameHandler.setTradeItem(static_cast<uint8_t>(i), 255u,
static_cast<uint8_t>(si));
ImGui::CloseCurrentPopup();
}
}
ImGui::EndPopup();
}
}
ImGui::PopID();
}
// Gold row
char gbuf[48];
formatGold(gold, gbuf, sizeof(gbuf));
ImGui::Spacing();
if (isMine) {
ImGui::Text("Gold offered: %s", gbuf);
static char goldInput[32] = "0";
ImGui::SetNextItemWidth(120.0f);
if (ImGui::InputText("##goldset", goldInput, sizeof(goldInput),
ImGuiInputTextFlags_CharsDecimal | ImGuiInputTextFlags_EnterReturnsTrue)) {
uint64_t copper = std::strtoull(goldInput, nullptr, 10);
gameHandler.setTradeGold(copper);
}
ImGui::SameLine();
ImGui::TextDisabled("(copper, Enter to set)");
} else {
ImGui::Text("Gold offered: %s", gbuf);
}
};
// Two-column layout: my offer | peer offer
float colW = ImGui::GetContentRegionAvail().x * 0.5f - 4.0f;
ImGui::BeginChild("##myoffer", ImVec2(colW, 240.0f), true);
renderSlotColumn("Your offer", mySlots, myGold, true);
ImGui::EndChild();
ImGui::SameLine();
ImGui::BeginChild("##peroffer", ImVec2(colW, 240.0f), true);
renderSlotColumn((peerName + "'s offer").c_str(), peerSlots, peerGold, false);
ImGui::EndChild();
// Buttons
ImGui::Spacing();
ImGui::Separator();
float bw = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
if (ImGui::Button("Accept Trade", ImVec2(bw, 0))) {
gameHandler.acceptTrade();
}
ImGui::SameLine();
if (ImGui::Button("Cancel", ImVec2(bw, 0))) {
gameHandler.cancelTrade();
}
}
ImGui::End();
if (!open) {
gameHandler.cancelTrade();
}
}
void GameScreen::renderLootRollPopup(game::GameHandler& gameHandler) {
if (!gameHandler.hasPendingLootRoll()) return;