From 5cc3d9645ca457279681b84f177a7546ecd27650 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 6 Feb 2026 21:50:15 -0800 Subject: [PATCH] Fix vendor buying and add quest turn-in flow CMSG_BUY_ITEM was missing the trailing uint8 bag field, causing the server to silently drop undersized packets. Add handlers for SMSG_QUESTGIVER_REQUEST_ITEMS and SMSG_QUESTGIVER_OFFER_REWARD with UI windows for quest completion and reward selection. --- include/game/game_handler.hpp | 19 ++++ include/game/world_packets.hpp | 56 +++++++++++ include/ui/game_screen.hpp | 2 + src/game/game_handler.cpp | 76 ++++++++++++++ src/game/world_packets.cpp | 119 ++++++++++++++++++++++ src/ui/game_screen.cpp | 175 +++++++++++++++++++++++++++++++++ 6 files changed, 447 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index d42a47d9..9731ea9b 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -363,6 +363,17 @@ public: bool isQuestDetailsOpen() const { return questDetailsOpen; } const QuestDetailsData& getQuestDetails() const { return currentQuestDetails; } + // Quest turn-in + bool isQuestRequestItemsOpen() const { return questRequestItemsOpen_; } + const QuestRequestItemsData& getQuestRequestItems() const { return currentQuestRequestItems_; } + void completeQuest(); // Send CMSG_QUESTGIVER_COMPLETE_QUEST + void closeQuestRequestItems(); + + bool isQuestOfferRewardOpen() const { return questOfferRewardOpen_; } + const QuestOfferRewardData& getQuestOfferReward() const { return currentQuestOfferReward_; } + void chooseQuestReward(uint32_t rewardIndex); // Send CMSG_QUESTGIVER_CHOOSE_REWARD + void closeQuestOfferReward(); + // Quest log struct QuestLogEntry { uint32_t questId = 0; @@ -538,6 +549,8 @@ private: void handleGossipMessage(network::Packet& packet); void handleGossipComplete(network::Packet& packet); void handleQuestDetails(network::Packet& packet); + void handleQuestRequestItems(network::Packet& packet); + void handleQuestOfferReward(network::Packet& packet); void handleListInventory(network::Packet& packet); LootResponseData generateLocalLoot(uint64_t guid); void simulateLootResponse(const LootResponseData& data); @@ -689,6 +702,12 @@ private: bool questDetailsOpen = false; QuestDetailsData currentQuestDetails; + // Quest turn-in + bool questRequestItemsOpen_ = false; + QuestRequestItemsData currentQuestRequestItems_; + bool questOfferRewardOpen_ = false; + QuestOfferRewardData currentQuestOfferReward_; + // Quest log std::vector questLog_; diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index fcb7b2c0..509cae37 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1238,6 +1238,62 @@ public: static bool parse(network::Packet& packet, QuestDetailsData& data); }; +/** Reward item entry (shared by quest detail/offer windows) */ +struct QuestRewardItem { + uint32_t itemId = 0; + uint32_t count = 0; + uint32_t displayInfoId = 0; +}; + +/** SMSG_QUESTGIVER_REQUEST_ITEMS data (turn-in progress check) */ +struct QuestRequestItemsData { + uint64_t npcGuid = 0; + uint32_t questId = 0; + std::string title; + std::string completionText; + uint32_t requiredMoney = 0; + uint32_t completableFlags = 0; // 0x03 = completable + std::vector requiredItems; + + bool isCompletable() const { return (completableFlags & 0x03) != 0; } +}; + +/** SMSG_QUESTGIVER_REQUEST_ITEMS parser */ +class QuestRequestItemsParser { +public: + static bool parse(network::Packet& packet, QuestRequestItemsData& data); +}; + +/** SMSG_QUESTGIVER_OFFER_REWARD data (choose reward) */ +struct QuestOfferRewardData { + uint64_t npcGuid = 0; + uint32_t questId = 0; + std::string title; + std::string rewardText; + uint32_t rewardMoney = 0; + uint32_t rewardXp = 0; + std::vector choiceRewards; // Pick one + std::vector fixedRewards; // Always given +}; + +/** SMSG_QUESTGIVER_OFFER_REWARD parser */ +class QuestOfferRewardParser { +public: + static bool parse(network::Packet& packet, QuestOfferRewardData& data); +}; + +/** CMSG_QUESTGIVER_COMPLETE_QUEST packet builder */ +class QuestgiverCompleteQuestPacket { +public: + static network::Packet build(uint64_t npcGuid, uint32_t questId); +}; + +/** CMSG_QUESTGIVER_CHOOSE_REWARD packet builder */ +class QuestgiverChooseRewardPacket { +public: + static network::Packet build(uint64_t npcGuid, uint32_t questId, uint32_t rewardIndex); +}; + // ============================================================ // Phase 5: Vendor // ============================================================ diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index ce1c4bab..1dc10ec8 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -146,6 +146,8 @@ private: void renderLootWindow(game::GameHandler& gameHandler); void renderGossipWindow(game::GameHandler& gameHandler); void renderQuestDetailsWindow(game::GameHandler& gameHandler); + void renderQuestRequestItemsWindow(game::GameHandler& gameHandler); + void renderQuestOfferRewardWindow(game::GameHandler& gameHandler); void renderVendorWindow(game::GameHandler& gameHandler); void renderTeleporterPanel(); void renderDeathScreen(game::GameHandler& gameHandler); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 9d4707c9..a1a257e1 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1285,7 +1285,11 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_QUESTGIVER_REQUEST_ITEMS: + handleQuestRequestItems(packet); + break; case Opcode::SMSG_QUESTGIVER_OFFER_REWARD: + handleQuestOfferReward(packet); + break; case Opcode::SMSG_GROUP_SET_LEADER: LOG_DEBUG("Ignoring known opcode: 0x", std::hex, opcode, std::dec); break; @@ -4144,6 +4148,78 @@ void GameHandler::abandonQuest(uint32_t questId) { } } +void GameHandler::handleQuestRequestItems(network::Packet& packet) { + QuestRequestItemsData data; + if (!QuestRequestItemsParser::parse(packet, data)) { + LOG_WARNING("Failed to parse SMSG_QUESTGIVER_REQUEST_ITEMS"); + return; + } + currentQuestRequestItems_ = data; + questRequestItemsOpen_ = true; + gossipWindowOpen = false; + questDetailsOpen = false; + + // Query item names for required items + for (const auto& item : data.requiredItems) { + queryItemInfo(item.itemId, 0); + } +} + +void GameHandler::handleQuestOfferReward(network::Packet& packet) { + QuestOfferRewardData data; + if (!QuestOfferRewardParser::parse(packet, data)) { + LOG_WARNING("Failed to parse SMSG_QUESTGIVER_OFFER_REWARD"); + return; + } + currentQuestOfferReward_ = data; + questOfferRewardOpen_ = true; + questRequestItemsOpen_ = false; + gossipWindowOpen = false; + questDetailsOpen = false; + + // Query item names for reward items + for (const auto& item : data.choiceRewards) + queryItemInfo(item.itemId, 0); + for (const auto& item : data.fixedRewards) + queryItemInfo(item.itemId, 0); +} + +void GameHandler::completeQuest() { + if (!questRequestItemsOpen_ || state != WorldState::IN_WORLD || !socket) return; + auto packet = QuestgiverCompleteQuestPacket::build( + currentQuestRequestItems_.npcGuid, currentQuestRequestItems_.questId); + socket->send(packet); + questRequestItemsOpen_ = false; + currentQuestRequestItems_ = QuestRequestItemsData{}; +} + +void GameHandler::closeQuestRequestItems() { + questRequestItemsOpen_ = false; + currentQuestRequestItems_ = QuestRequestItemsData{}; +} + +void GameHandler::chooseQuestReward(uint32_t rewardIndex) { + if (!questOfferRewardOpen_ || state != WorldState::IN_WORLD || !socket) return; + uint64_t npcGuid = currentQuestOfferReward_.npcGuid; + auto packet = QuestgiverChooseRewardPacket::build( + npcGuid, currentQuestOfferReward_.questId, rewardIndex); + socket->send(packet); + questOfferRewardOpen_ = false; + currentQuestOfferReward_ = QuestOfferRewardData{}; + + // Re-query quest giver status so markers update + if (npcGuid) { + network::Packet qsPkt(static_cast(Opcode::CMSG_QUESTGIVER_STATUS_QUERY)); + qsPkt.writeUInt64(npcGuid); + socket->send(qsPkt); + } +} + +void GameHandler::closeQuestOfferReward() { + questOfferRewardOpen_ = false; + currentQuestOfferReward_ = QuestOfferRewardData{}; +} + void GameHandler::closeGossip() { gossipWindowOpen = false; currentGossip = GossipMessageData{}; diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index e870c12c..f6af7778 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -2087,6 +2087,124 @@ bool GossipMessageParser::parse(network::Packet& packet, GossipMessageData& data return true; } +bool QuestRequestItemsParser::parse(network::Packet& packet, QuestRequestItemsData& data) { + if (packet.getSize() - packet.getReadPos() < 20) return false; + data.npcGuid = packet.readUInt64(); + data.questId = packet.readUInt32(); + data.title = packet.readString(); + data.completionText = packet.readString(); + + if (packet.getReadPos() + 20 > packet.getSize()) { + LOG_INFO("Quest request items (short): id=", data.questId, " title='", data.title, "'"); + return true; + } + + /*emoteDelay*/ packet.readUInt32(); + /*emote*/ packet.readUInt32(); + /*autoCloseOnCancel*/ packet.readUInt32(); + /*flags*/ packet.readUInt32(); + /*suggestedPlayers*/ packet.readUInt32(); + + if (packet.getReadPos() + 4 > packet.getSize()) return true; + data.requiredMoney = packet.readUInt32(); + + if (packet.getReadPos() + 4 > packet.getSize()) return true; + uint32_t requiredItemCount = packet.readUInt32(); + for (uint32_t i = 0; i < requiredItemCount; ++i) { + if (packet.getReadPos() + 12 > packet.getSize()) break; + QuestRewardItem item; + item.itemId = packet.readUInt32(); + item.count = packet.readUInt32(); + item.displayInfoId = packet.readUInt32(); + if (item.itemId > 0) + data.requiredItems.push_back(item); + } + + if (packet.getReadPos() + 4 > packet.getSize()) return true; + data.completableFlags = packet.readUInt32(); + + LOG_INFO("Quest request items: id=", data.questId, " title='", data.title, + "' items=", data.requiredItems.size(), " completable=", data.isCompletable()); + return true; +} + +bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData& data) { + if (packet.getSize() - packet.getReadPos() < 20) return false; + data.npcGuid = packet.readUInt64(); + data.questId = packet.readUInt32(); + data.title = packet.readString(); + data.rewardText = packet.readString(); + + if (packet.getReadPos() + 10 > packet.getSize()) { + LOG_INFO("Quest offer reward (short): id=", data.questId, " title='", data.title, "'"); + return true; + } + + /*autoFinish*/ packet.readUInt8(); + /*flags*/ packet.readUInt32(); + /*suggestedPlayers*/ packet.readUInt32(); + + // Emotes + if (packet.getReadPos() + 4 > packet.getSize()) return true; + uint32_t emoteCount = packet.readUInt32(); + for (uint32_t i = 0; i < emoteCount; ++i) { + if (packet.getReadPos() + 8 > packet.getSize()) break; + packet.readUInt32(); // delay + packet.readUInt32(); // emote + } + + // Choice reward items (pick one): count + 6 * (id, count, displayInfo) + if (packet.getReadPos() + 4 > packet.getSize()) return true; + /*choiceCount*/ packet.readUInt32(); + for (uint32_t i = 0; i < 6; ++i) { + if (packet.getReadPos() + 12 > packet.getSize()) break; + QuestRewardItem item; + item.itemId = packet.readUInt32(); + item.count = packet.readUInt32(); + item.displayInfoId = packet.readUInt32(); + if (item.itemId > 0) + data.choiceRewards.push_back(item); + } + + // Fixed reward items: count + 4 * (id, count, displayInfo) + if (packet.getReadPos() + 4 > packet.getSize()) return true; + /*rewardCount*/ packet.readUInt32(); + for (uint32_t i = 0; i < 4; ++i) { + if (packet.getReadPos() + 12 > packet.getSize()) break; + QuestRewardItem item; + item.itemId = packet.readUInt32(); + item.count = packet.readUInt32(); + item.displayInfoId = packet.readUInt32(); + if (item.itemId > 0) + data.fixedRewards.push_back(item); + } + + // Money and XP + if (packet.getReadPos() + 4 <= packet.getSize()) + data.rewardMoney = packet.readUInt32(); + if (packet.getReadPos() + 4 <= packet.getSize()) + data.rewardXp = packet.readUInt32(); + + LOG_INFO("Quest offer reward: id=", data.questId, " title='", data.title, + "' choices=", data.choiceRewards.size(), " fixed=", data.fixedRewards.size()); + return true; +} + +network::Packet QuestgiverCompleteQuestPacket::build(uint64_t npcGuid, uint32_t questId) { + network::Packet packet(static_cast(Opcode::CMSG_QUESTGIVER_COMPLETE_QUEST)); + packet.writeUInt64(npcGuid); + packet.writeUInt32(questId); + return packet; +} + +network::Packet QuestgiverChooseRewardPacket::build(uint64_t npcGuid, uint32_t questId, uint32_t rewardIndex) { + network::Packet packet(static_cast(Opcode::CMSG_QUESTGIVER_CHOOSE_REWARD)); + packet.writeUInt64(npcGuid); + packet.writeUInt32(questId); + packet.writeUInt32(rewardIndex); + return packet; +} + // ============================================================ // Phase 5: Vendor // ============================================================ @@ -2103,6 +2221,7 @@ network::Packet BuyItemPacket::build(uint64_t vendorGuid, uint32_t itemId, uint3 packet.writeUInt32(itemId); packet.writeUInt32(slot); packet.writeUInt8(count); + packet.writeUInt8(0); // bag slot (0 = find any available bag slot) return packet; } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index bbb1e1ff..1b7eacc6 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -93,6 +93,8 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderLootWindow(gameHandler); renderGossipWindow(gameHandler); renderQuestDetailsWindow(gameHandler); + renderQuestRequestItemsWindow(gameHandler); + renderQuestOfferRewardWindow(gameHandler); renderVendorWindow(gameHandler); renderQuestMarkers(gameHandler); renderMinimapMarkers(gameHandler); @@ -2273,6 +2275,179 @@ void GameScreen::renderQuestDetailsWindow(game::GameHandler& gameHandler) { } } +// ============================================================ +// Quest Request Items Window (turn-in progress check) +// ============================================================ + +void GameScreen::renderQuestRequestItemsWindow(game::GameHandler& gameHandler) { + if (!gameHandler.isQuestRequestItemsOpen()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 225, screenH / 2 - 200), ImGuiCond_Appearing); + ImGui::SetNextWindowSize(ImVec2(450, 350), ImGuiCond_Appearing); + + bool open = true; + const auto& quest = gameHandler.getQuestRequestItems(); + if (ImGui::Begin(quest.title.c_str(), &open, ImGuiWindowFlags_NoCollapse)) { + if (!quest.completionText.empty()) { + ImGui::TextWrapped("%s", quest.completionText.c_str()); + } + + // Required items + if (!quest.requiredItems.empty()) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Required Items:"); + for (const auto& item : quest.requiredItems) { + auto* info = gameHandler.getItemInfo(item.itemId); + if (info && info->valid) + ImGui::Text(" %s x%u", info->name.c_str(), item.count); + else + ImGui::Text(" Item %u x%u", item.itemId, item.count); + } + } + + if (quest.requiredMoney > 0) { + ImGui::Spacing(); + uint32_t g = quest.requiredMoney / 10000; + uint32_t s = (quest.requiredMoney % 10000) / 100; + uint32_t c = quest.requiredMoney % 100; + ImGui::Text("Required money: %ug %us %uc", g, s, c); + } + + // Complete / Cancel buttons + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + float buttonW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f; + if (quest.isCompletable()) { + if (ImGui::Button("Complete Quest", ImVec2(buttonW, 0))) { + gameHandler.completeQuest(); + } + } else { + ImGui::BeginDisabled(); + ImGui::Button("Incomplete", ImVec2(buttonW, 0)); + ImGui::EndDisabled(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(buttonW, 0))) { + gameHandler.closeQuestRequestItems(); + } + } + ImGui::End(); + + if (!open) { + gameHandler.closeQuestRequestItems(); + } +} + +// ============================================================ +// Quest Offer Reward Window (choose reward) +// ============================================================ + +void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) { + if (!gameHandler.isQuestOfferRewardOpen()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 225, screenH / 2 - 200), ImGuiCond_Appearing); + ImGui::SetNextWindowSize(ImVec2(450, 400), ImGuiCond_Appearing); + + bool open = true; + const auto& quest = gameHandler.getQuestOfferReward(); + static int selectedChoice = -1; + + if (ImGui::Begin(quest.title.c_str(), &open, ImGuiWindowFlags_NoCollapse)) { + if (!quest.rewardText.empty()) { + ImGui::TextWrapped("%s", quest.rewardText.c_str()); + } + + // Choice rewards (pick one) + if (!quest.choiceRewards.empty()) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Choose a reward:"); + for (size_t i = 0; i < quest.choiceRewards.size(); ++i) { + const auto& item = quest.choiceRewards[i]; + auto* info = gameHandler.getItemInfo(item.itemId); + char label[256]; + if (info && info->valid) + snprintf(label, sizeof(label), "%s x%u", info->name.c_str(), item.count); + else + snprintf(label, sizeof(label), "Item %u x%u", item.itemId, item.count); + + bool selected = (selectedChoice == static_cast(i)); + if (ImGui::Selectable(label, selected)) { + selectedChoice = static_cast(i); + } + } + } + + // Fixed rewards (always given) + if (!quest.fixedRewards.empty()) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "You will also receive:"); + for (const auto& item : quest.fixedRewards) { + auto* info = gameHandler.getItemInfo(item.itemId); + if (info && info->valid) + ImGui::Text(" %s x%u", info->name.c_str(), item.count); + else + ImGui::Text(" Item %u x%u", item.itemId, item.count); + } + } + + // Money / XP rewards + if (quest.rewardXp > 0 || quest.rewardMoney > 0) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Rewards:"); + if (quest.rewardXp > 0) + ImGui::Text(" %u experience", quest.rewardXp); + if (quest.rewardMoney > 0) { + uint32_t g = quest.rewardMoney / 10000; + uint32_t s = (quest.rewardMoney % 10000) / 100; + uint32_t c = quest.rewardMoney % 100; + if (g > 0) ImGui::Text(" %ug %us %uc", g, s, c); + else if (s > 0) ImGui::Text(" %us %uc", s, c); + else ImGui::Text(" %uc", c); + } + } + + // Complete button + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + float buttonW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f; + + bool canComplete = quest.choiceRewards.empty() || selectedChoice >= 0; + if (!canComplete) ImGui::BeginDisabled(); + if (ImGui::Button("Complete Quest", ImVec2(buttonW, 0))) { + uint32_t rewardIdx = quest.choiceRewards.empty() ? 0 : static_cast(selectedChoice); + gameHandler.chooseQuestReward(rewardIdx); + selectedChoice = -1; + } + if (!canComplete) ImGui::EndDisabled(); + + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(buttonW, 0))) { + gameHandler.closeQuestOfferReward(); + selectedChoice = -1; + } + } + ImGui::End(); + + if (!open) { + gameHandler.closeQuestOfferReward(); + selectedChoice = -1; + } +} + // ============================================================ // Vendor Window (Phase 5) // ============================================================