From 954edc91b89964e8544038a29320032f1c1a153c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Feb 2026 17:14:13 -0800 Subject: [PATCH] Harden quest accept state and resync quest log on login - add idempotent pending-accept tracking with timeout and per-quest cleanup hooks - stop optimistic local quest insertion on accept; rely on server-authoritative updates - handle QUEST_INVALID already-on/completed as reconciliation paths for pending accepts - trigger quest metadata + status resync when accept state drifts - add login-time quest log rebuild from PLAYER_QUEST_LOG server slots - query quest metadata for server-slot quests to hydrate titles/objectives - clear stale pending quest accept/query state on fresh world entry - improve gossip quest selection fallback by trusting server quest slots when local cache is missing This reduces duplicate accept/state mismatch loops (notably reason=13) and stabilizes WotLK quest behavior after relogs. --- include/game/game_handler.hpp | 9 ++ src/game/game_handler.cpp | 195 +++++++++++++++++++++++++++++++--- 2 files changed, 189 insertions(+), 15 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index b6f8dc7e..8c2bbb90 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1127,6 +1127,11 @@ private: void handleQuestDetails(network::Packet& packet); void handleQuestRequestItems(network::Packet& packet); void handleQuestOfferReward(network::Packet& packet); + void clearPendingQuestAccept(uint32_t questId); + void triggerQuestAcceptResync(uint32_t questId, uint64_t npcGuid, const char* reason); + bool hasQuestInLog(uint32_t questId) const; + void addQuestToLocalLogIfMissing(uint32_t questId, const std::string& title, const std::string& objectives); + bool resyncQuestLogFromServerSlots(bool forceQueryMetadata); void handleListInventory(network::Packet& packet); void addMoneyCopper(uint32_t amount); @@ -1487,12 +1492,16 @@ private: uint32_t pendingTurnInQuestId_ = 0; uint64_t pendingTurnInNpcGuid_ = 0; bool pendingTurnInRewardRequest_ = false; + std::unordered_map pendingQuestAcceptTimeouts_; + std::unordered_map pendingQuestAcceptNpcGuids_; bool questOfferRewardOpen_ = false; QuestOfferRewardData currentQuestOfferReward_; // Quest log std::vector questLog_; std::unordered_set pendingQuestQueryIds_; + bool pendingLoginQuestResync_ = false; + float pendingLoginQuestResyncTimeout_ = 0.0f; // Quest giver status per NPC std::unordered_map npcQuestStatus_; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 34f7ab2c..e229d687 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -575,6 +575,20 @@ void GameHandler::update(float deltaTime) { if (auctionSearchDelayTimer_ < 0.0f) auctionSearchDelayTimer_ = 0.0f; } + for (auto it = pendingQuestAcceptTimeouts_.begin(); it != pendingQuestAcceptTimeouts_.end();) { + it->second -= deltaTime; + if (it->second <= 0.0f) { + const uint32_t questId = it->first; + const uint64_t npcGuid = pendingQuestAcceptNpcGuids_.count(questId) != 0 + ? pendingQuestAcceptNpcGuids_[questId] : 0; + triggerQuestAcceptResync(questId, npcGuid, "timeout"); + it = pendingQuestAcceptTimeouts_.erase(it); + pendingQuestAcceptNpcGuids_.erase(questId); + } else { + ++it; + } + } + if (pendingMoneyDeltaTimer_ > 0.0f) { pendingMoneyDeltaTimer_ -= deltaTime; if (pendingMoneyDeltaTimer_ <= 0.0f) { @@ -583,6 +597,18 @@ void GameHandler::update(float deltaTime) { } } + if (pendingLoginQuestResync_) { + pendingLoginQuestResyncTimeout_ -= deltaTime; + if (resyncQuestLogFromServerSlots(true)) { + pendingLoginQuestResync_ = false; + pendingLoginQuestResyncTimeout_ = 0.0f; + } else if (pendingLoginQuestResyncTimeout_ <= 0.0f) { + pendingLoginQuestResync_ = false; + pendingLoginQuestResyncTimeout_ = 0.0f; + LOG_WARNING("Quest login resync timed out waiting for player quest slot fields"); + } + } + for (auto it = pendingGameObjectLootRetries_.begin(); it != pendingGameObjectLootRetries_.end();) { it->timer -= deltaTime; if (it->timer <= 0.0f) { @@ -2223,6 +2249,30 @@ void GameHandler::handlePacket(network::Packet& packet) { case 19: reasonStr = "Can't take any more quests"; break; } LOG_WARNING("Quest invalid: reason=", failReason, " (", reasonStr, ")"); + if (!pendingQuestAcceptTimeouts_.empty()) { + std::vector pendingQuestIds; + pendingQuestIds.reserve(pendingQuestAcceptTimeouts_.size()); + for (const auto& pending : pendingQuestAcceptTimeouts_) { + pendingQuestIds.push_back(pending.first); + } + for (uint32_t questId : pendingQuestIds) { + const uint64_t npcGuid = pendingQuestAcceptNpcGuids_.count(questId) != 0 + ? pendingQuestAcceptNpcGuids_[questId] : 0; + if (failReason == 13) { + std::string fallbackTitle = "Quest #" + std::to_string(questId); + std::string fallbackObjectives; + if (currentQuestDetails.questId == questId) { + if (!currentQuestDetails.title.empty()) fallbackTitle = currentQuestDetails.title; + fallbackObjectives = currentQuestDetails.objectives; + } + addQuestToLocalLogIfMissing(questId, fallbackTitle, fallbackObjectives); + triggerQuestAcceptResync(questId, npcGuid, "already-on-quest"); + } else if (failReason == 18) { + triggerQuestAcceptResync(questId, npcGuid, "already-completed"); + } + clearPendingQuestAccept(questId); + } + } // Only show error to user for real errors (not informational messages) if (failReason != 13 && failReason != 18) { // Don't spam "already on/completed" addSystemChatMessage(std::string("Quest unavailable: ") + reasonStr); @@ -2268,6 +2318,7 @@ void GameHandler::handlePacket(network::Packet& packet) { size_t rem = packet.getSize() - packet.getReadPos(); if (rem >= 12) { uint32_t questId = packet.readUInt32(); + clearPendingQuestAccept(questId); uint32_t entry = packet.readUInt32(); // Creature entry uint32_t count = packet.readUInt32(); // Current kills uint32_t reqCount = 0; @@ -2302,6 +2353,7 @@ void GameHandler::handlePacket(network::Packet& packet) { } else if (rem >= 4) { // Swapped mapping fallback: treat as QUESTUPDATE_COMPLETE packet. uint32_t questId = packet.readUInt32(); + clearPendingQuestAccept(questId); LOG_INFO("Quest objectives completed (compat via ADD_KILL): questId=", questId); for (auto& quest : questLog_) { if (quest.questId == questId) { @@ -2347,6 +2399,7 @@ void GameHandler::handlePacket(network::Packet& packet) { size_t rem = packet.getSize() - packet.getReadPos(); if (rem >= 12) { uint32_t questId = packet.readUInt32(); + clearPendingQuestAccept(questId); uint32_t entry = packet.readUInt32(); uint32_t count = packet.readUInt32(); uint32_t reqCount = 0; @@ -2364,6 +2417,7 @@ void GameHandler::handlePacket(network::Packet& packet) { } } else if (rem >= 4) { uint32_t questId = packet.readUInt32(); + clearPendingQuestAccept(questId); LOG_INFO("Quest objectives completed: questId=", questId); for (auto& quest : questLog_) { @@ -2384,6 +2438,7 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } uint32_t questId = packet.readUInt32(); + clearPendingQuestAccept(questId); pendingQuestQueryIds_.erase(questId); if (questId == 0) { // Some servers emit a zero-id variant during world bootstrap. @@ -3092,6 +3147,10 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { playerSkills_.clear(); questLog_.clear(); pendingQuestQueryIds_.clear(); + pendingLoginQuestResync_ = false; + pendingLoginQuestResyncTimeout_ = 0.0f; + pendingQuestAcceptTimeouts_.clear(); + pendingQuestAcceptNpcGuids_.clear(); npcQuestStatus_.clear(); hostileAttackers_.clear(); combatText.clear(); @@ -3144,6 +3203,7 @@ void GameHandler::handleLoginSetTimeSpeed(network::Packet& packet) { void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { LOG_INFO("Handling SMSG_LOGIN_VERIFY_WORLD"); + const bool initialWorldEntry = (state == WorldState::ENTERING_WORLD); LoginVerifyWorldData data; if (!LoginVerifyWorldParser::parse(packet, data)) { @@ -3232,6 +3292,15 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { taxiRecoverPending_ = false; } } + + if (initialWorldEntry) { + pendingQuestAcceptTimeouts_.clear(); + pendingQuestAcceptNpcGuids_.clear(); + pendingQuestQueryIds_.clear(); + pendingLoginQuestResync_ = true; + pendingLoginQuestResyncTimeout_ = 10.0f; + LOG_INFO("Queued quest log resync for login (from server quest slots)"); + } } void GameHandler::handleClientCacheVersion(network::Packet& packet) { @@ -9675,7 +9744,18 @@ void GameHandler::selectGossipQuest(uint32_t questId) { } return false; }; - const bool activeQuestConfirmedByServer = activeQuest && questInServerLogSlots(questId); + const bool questInServerLog = questInServerLogSlots(questId); + if (questInServerLog && !activeQuest) { + addQuestToLocalLogIfMissing(questId, "Quest #" + std::to_string(questId), ""); + requestQuestQuery(questId, false); + for (const auto& q : questLog_) { + if (q.questId == questId) { + activeQuest = &q; + break; + } + } + } + const bool activeQuestConfirmedByServer = questInServerLog; // Only trust server quest-log slots for deciding "already accepted" flow. // Gossip icon values can differ across cores/expansions and misclassify // available quests as active, which blocks acceptance. @@ -9733,31 +9813,113 @@ void GameHandler::handleQuestDetails(network::Packet& packet) { gossipWindowOpen = false; } +bool GameHandler::hasQuestInLog(uint32_t questId) const { + for (const auto& q : questLog_) { + if (q.questId == questId) return true; + } + return false; +} + +void GameHandler::addQuestToLocalLogIfMissing(uint32_t questId, const std::string& title, const std::string& objectives) { + if (questId == 0 || hasQuestInLog(questId)) return; + QuestLogEntry entry; + entry.questId = questId; + entry.title = title.empty() ? ("Quest #" + std::to_string(questId)) : title; + entry.objectives = objectives; + questLog_.push_back(std::move(entry)); +} + +bool GameHandler::resyncQuestLogFromServerSlots(bool forceQueryMetadata) { + if (lastPlayerFields_.empty()) return false; + + const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START); + const uint8_t qStride = packetParsers_ ? packetParsers_->questLogStride() : 5; + std::unordered_set serverQuestIds; + serverQuestIds.reserve(25); + for (uint16_t slot = 0; slot < 25; ++slot) { + const uint16_t idField = ufQuestStart + slot * qStride; + auto it = lastPlayerFields_.find(idField); + if (it == lastPlayerFields_.end()) continue; + if (it->second != 0) serverQuestIds.insert(it->second); + } + + const size_t localBefore = questLog_.size(); + std::erase_if(questLog_, [&](const QuestLogEntry& q) { + return q.questId == 0 || serverQuestIds.count(q.questId) == 0; + }); + const size_t removed = localBefore - questLog_.size(); + + size_t added = 0; + for (uint32_t questId : serverQuestIds) { + if (hasQuestInLog(questId)) continue; + addQuestToLocalLogIfMissing(questId, "Quest #" + std::to_string(questId), ""); + ++added; + } + + if (forceQueryMetadata) { + for (uint32_t questId : serverQuestIds) { + requestQuestQuery(questId, false); + } + } + + LOG_INFO("Quest log resync from server slots: server=", serverQuestIds.size(), + " localBefore=", localBefore, " removed=", removed, " added=", added); + return true; +} + +void GameHandler::clearPendingQuestAccept(uint32_t questId) { + pendingQuestAcceptTimeouts_.erase(questId); + pendingQuestAcceptNpcGuids_.erase(questId); +} + +void GameHandler::triggerQuestAcceptResync(uint32_t questId, uint64_t npcGuid, const char* reason) { + if (questId == 0 || !socket || state != WorldState::IN_WORLD) return; + + LOG_INFO("Quest accept resync: questId=", questId, " reason=", reason ? reason : "unknown"); + requestQuestQuery(questId, true); + + if (npcGuid != 0) { + network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY)); + qsPkt.writeUInt64(npcGuid); + socket->send(qsPkt); + + auto queryPkt = packetParsers_ + ? packetParsers_->buildQueryQuestPacket(npcGuid, questId) + : QuestgiverQueryQuestPacket::build(npcGuid, questId); + socket->send(queryPkt); + } +} + void GameHandler::acceptQuest() { if (!questDetailsOpen || state != WorldState::IN_WORLD || !socket) return; + const uint32_t questId = currentQuestDetails.questId; + if (questId == 0) return; uint64_t npcGuid = currentQuestDetails.npcGuid; + if (pendingQuestAcceptTimeouts_.count(questId) != 0) { + LOG_DEBUG("Ignoring duplicate quest accept while pending: questId=", questId); + triggerQuestAcceptResync(questId, npcGuid, "duplicate-accept"); + questDetailsOpen = false; + currentQuestDetails = QuestDetailsData{}; + return; + } + if (hasQuestInLog(questId)) { + LOG_INFO("Ignoring duplicate quest accept already in local log: questId=", questId); + questDetailsOpen = false; + currentQuestDetails = QuestDetailsData{}; + return; + } + // WotLK/TBC expect an additional trailing flag on CMSG_QUESTGIVER_ACCEPT_QUEST. // Classic/Turtle use the short form (guid + questId only). network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_ACCEPT_QUEST)); packet.writeUInt64(npcGuid); - packet.writeUInt32(currentQuestDetails.questId); + packet.writeUInt32(questId); if (!isActiveExpansion("classic") && !isActiveExpansion("turtle")) { packet.writeUInt8(1); // from-gossip / auto-accept continuation flag } socket->send(packet); - - // Add to quest log - bool alreadyInLog = false; - for (const auto& q : questLog_) { - if (q.questId == currentQuestDetails.questId) { alreadyInLog = true; break; } - } - if (!alreadyInLog) { - QuestLogEntry entry; - entry.questId = currentQuestDetails.questId; - entry.title = currentQuestDetails.title; - entry.objectives = currentQuestDetails.objectives; - questLog_.push_back(entry); - } + pendingQuestAcceptTimeouts_[questId] = 5.0f; + pendingQuestAcceptNpcGuids_[questId] = npcGuid; questDetailsOpen = false; currentQuestDetails = QuestDetailsData{}; @@ -9776,6 +9938,7 @@ void GameHandler::declineQuest() { } void GameHandler::abandonQuest(uint32_t questId) { + clearPendingQuestAccept(questId); // Find the quest's index in our local log for (size_t i = 0; i < questLog_.size(); i++) { if (questLog_[i].questId == questId) { @@ -9798,6 +9961,7 @@ void GameHandler::handleQuestRequestItems(network::Packet& packet) { LOG_WARNING("Failed to parse SMSG_QUESTGIVER_REQUEST_ITEMS"); return; } + clearPendingQuestAccept(data.questId); // Expansion-safe fallback: COMPLETE_QUEST is the default flow. // If a server echoes REQUEST_ITEMS again while still completable, @@ -9860,6 +10024,7 @@ void GameHandler::handleQuestOfferReward(network::Packet& packet) { LOG_WARNING("Failed to parse SMSG_QUESTGIVER_OFFER_REWARD"); return; } + clearPendingQuestAccept(data.questId); LOG_INFO("Quest offer reward: questId=", data.questId, " title=\"", data.title, "\""); if (pendingTurnInQuestId_ == data.questId) { pendingTurnInQuestId_ = 0;