Stabilize quest log details loading and turn-in item sync

This commit is contained in:
Kelsi 2026-02-19 00:56:24 -08:00
parent 334d4d3df6
commit 4fcf869e34
5 changed files with 135 additions and 41 deletions

View file

@ -704,10 +704,16 @@ public:
std::unordered_map<uint32_t, std::pair<uint32_t, uint32_t>> killCounts;
// Quest item progress: itemId -> current count
std::unordered_map<uint32_t, uint32_t> itemCounts;
// Server-authoritative quest item requirements from REQUEST_ITEMS
std::unordered_map<uint32_t, uint32_t> requiredItemCounts;
};
const std::vector<QuestLogEntry>& getQuestLog() const { return questLog_; }
void abandonQuest(uint32_t questId);
bool requestQuestQuery(uint32_t questId, bool force = false);
bool isQuestQueryPending(uint32_t questId) const {
return pendingQuestQueryIds_.count(questId) > 0;
}
void clearQuestQueryPending(uint32_t questId) { pendingQuestQueryIds_.erase(questId); }
const std::unordered_map<uint32_t, uint32_t>& getWorldStates() const { return worldStates_; }
std::optional<uint32_t> getWorldState(uint32_t key) const {
auto it = worldStates_.find(key);
@ -1437,6 +1443,8 @@ private:
// Quest log
std::vector<QuestLogEntry> questLog_;
std::unordered_set<uint32_t> pendingQuestQueryIds_;
int questQueryTracePacketsLeft_ = 0;
uint32_t questQueryTraceQuestId_ = 0;
// Quest giver status per NPC
std::unordered_map<uint64_t, QuestGiverStatus> npcQuestStatus_;

View file

@ -3,6 +3,7 @@
#include "game/game_handler.hpp"
#include <imgui.h>
#include <cstdint>
#include <unordered_set>
namespace wowee { namespace ui {
@ -18,6 +19,8 @@ private:
bool lKeyWasDown = false;
int selectedIndex = -1;
uint32_t lastDetailRequestQuestId_ = 0;
double lastDetailRequestAt_ = 0.0;
std::unordered_set<uint32_t> questDetailQueryNoResponse_;
};
}} // namespace wowee::ui

View file

@ -932,6 +932,25 @@ void GameHandler::handlePacket(network::Packet& packet) {
// Translate wire opcode to logical opcode via expansion table
auto logicalOp = opcodeTable_.fromWire(opcode);
if (questQueryTracePacketsLeft_ > 0) {
int logicalId = logicalOp ? static_cast<int>(*logicalOp) : -1;
size_t limit = std::min<size_t>(24, packet.getSize());
std::string hex;
const auto& raw = packet.getData();
for (size_t i = 0; i < limit && i < raw.size(); ++i) {
char buf[4];
snprintf(buf, sizeof(buf), "%02x ", raw[i]);
hex += buf;
}
LOG_INFO("QTRACE RX: wire=0x", std::hex, opcode, std::dec,
" logical=", logicalId,
" size=", packet.getSize(),
" qid=", questQueryTraceQuestId_,
" head=[", hex, "]");
--questQueryTracePacketsLeft_;
}
if (!logicalOp) {
static std::unordered_set<uint16_t> loggedUnknownWireOpcodes;
if (loggedUnknownWireOpcodes.insert(opcode).second) {
@ -1861,6 +1880,10 @@ void GameHandler::handlePacket(network::Packet& packet) {
bool updatedAny = false;
for (auto& quest : questLog_) {
if (quest.complete) continue;
const bool tracksItem =
quest.requiredItemCounts.count(itemId) > 0 ||
quest.itemCounts.count(itemId) > 0;
if (!tracksItem) continue;
quest.itemCounts[itemId] = count;
updatedAny = true;
}
@ -1940,6 +1963,8 @@ void GameHandler::handlePacket(network::Packet& packet) {
}
uint32_t questId = packet.readUInt32();
LOG_INFO("Quest query RX: wire=0x", std::hex, opcode, std::dec,
" questId=", questId, " payloadSize=", packet.getSize());
packet.readUInt32(); // questMethod
const bool isClassicLayout = packetParsers_ && packetParsers_->questLogStride() == 3;
@ -1964,7 +1989,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
q.title = parsed.title;
}
if (!parsed.objectives.empty() &&
(q.objectives.empty() || parsed.objectives.size() > q.objectives.size())) {
(q.objectives.empty() || q.objectives.size() < 16)) {
q.objectives = parsed.objectives;
}
break;
@ -8657,28 +8682,12 @@ void GameHandler::selectGossipOption(uint32_t optionId) {
void GameHandler::selectGossipQuest(uint32_t questId) {
if (state != WorldState::IN_WORLD || !socket || !gossipWindowOpen) return;
// Check if quest is in our quest log and completable
bool isInLog = false;
bool isCompletable = false;
for (const auto& quest : questLog_) {
if (quest.questId == questId) {
isInLog = true;
isCompletable = quest.complete;
break;
}
}
if (isInLog && isCompletable) {
// Quest is ready to turn in - request reward
network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_REQUEST_REWARD));
packet.writeUInt64(currentGossip.npcGuid);
packet.writeUInt32(questId);
socket->send(packet);
} else {
// New quest or not completable - query details
auto packet = QuestgiverQueryQuestPacket::build(currentGossip.npcGuid, questId);
socket->send(packet);
}
// Always query quest from gossip and let the server drive next step:
// - details (new quest), or
// - request items (turn-in check), or
// - offer reward.
auto packet = QuestgiverQueryQuestPacket::build(currentGossip.npcGuid, questId);
socket->send(packet);
gossipWindowOpen = false;
}
@ -8689,6 +8698,11 @@ bool GameHandler::requestQuestQuery(uint32_t questId, bool force) {
network::Packet pkt(wireOpcode(Opcode::CMSG_QUEST_QUERY));
pkt.writeUInt32(questId);
questQueryTraceQuestId_ = questId;
questQueryTracePacketsLeft_ = 60;
LOG_INFO("Quest query TX: questId=", questId, " wireOpcode=0x",
std::hex, wireOpcode(Opcode::CMSG_QUEST_QUERY), std::dec,
" force=", force ? 1 : 0);
socket->send(pkt);
pendingQuestQueryIds_.insert(questId);
return true;
@ -8785,6 +8799,37 @@ void GameHandler::handleQuestRequestItems(network::Packet& packet) {
for (const auto& item : data.requiredItems) {
queryItemInfo(item.itemId, 0);
}
// Server-authoritative turn-in requirements: sync quest-log summary so
// UI doesn't show stale/inferred objective numbers.
for (auto& q : questLog_) {
if (q.questId != data.questId) continue;
q.complete = data.isCompletable();
q.requiredItemCounts.clear();
std::ostringstream oss;
if (!data.completionText.empty()) {
oss << data.completionText;
if (!data.requiredItems.empty() || data.requiredMoney > 0) oss << "\n\n";
}
if (!data.requiredItems.empty()) {
oss << "Required items:";
for (const auto& item : data.requiredItems) {
std::string itemLabel = "Item " + std::to_string(item.itemId);
if (const auto* info = getItemInfo(item.itemId)) {
if (!info->name.empty()) itemLabel = info->name;
}
q.requiredItemCounts[item.itemId] = item.count;
oss << "\n- " << itemLabel << " x" << item.count;
}
}
if (data.requiredMoney > 0) {
if (!data.requiredItems.empty()) oss << "\n";
oss << "\nRequired money: " << formatCopperAmount(data.requiredMoney);
}
q.objectives = oss.str();
break;
}
}
void GameHandler::handleQuestOfferReward(network::Packet& packet) {

View file

@ -4532,6 +4532,23 @@ void GameScreen::renderQuestRequestItemsWindow(game::GameHandler& gameHandler) {
bool open = true;
const auto& quest = gameHandler.getQuestRequestItems();
auto countItemInInventory = [&](uint32_t itemId) -> uint32_t {
const auto& inv = gameHandler.getInventory();
uint32_t total = 0;
for (int i = 0; i < inv.getBackpackSize(); ++i) {
const auto& slot = inv.getBackpackSlot(i);
if (!slot.empty() && slot.item.itemId == itemId) total += slot.item.stackCount;
}
for (int bag = 0; bag < game::Inventory::NUM_BAG_SLOTS; ++bag) {
int bagSize = inv.getBagSize(bag);
for (int s = 0; s < bagSize; ++s) {
const auto& slot = inv.getBagSlot(bag, s);
if (!slot.empty() && slot.item.itemId == itemId) total += slot.item.stackCount;
}
}
return total;
};
std::string processedTitle = replaceGenderPlaceholders(quest.title, gameHandler);
if (ImGui::Begin(processedTitle.c_str(), &open, ImGuiWindowFlags_NoCollapse)) {
if (!quest.completionText.empty()) {
@ -4545,11 +4562,17 @@ void GameScreen::renderQuestRequestItemsWindow(game::GameHandler& gameHandler) {
ImGui::Separator();
ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Required Items:");
for (const auto& item : quest.requiredItems) {
uint32_t have = countItemInInventory(item.itemId);
bool enough = have >= item.count;
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);
const char* name = (info && info->valid) ? info->name.c_str() : nullptr;
if (name && *name) {
ImGui::TextColored(enough ? ImVec4(0.6f, 1.0f, 0.6f, 1.0f) : ImVec4(1.0f, 0.6f, 0.6f, 1.0f),
" %s %u/%u", name, have, item.count);
} else {
ImGui::TextColored(enough ? ImVec4(0.6f, 1.0f, 0.6f, 1.0f) : ImVec4(1.0f, 0.6f, 0.6f, 1.0f),
" Item %u %u/%u", item.itemId, have, item.count);
}
}
}
@ -4566,19 +4589,17 @@ void GameScreen::renderQuestRequestItemsWindow(game::GameHandler& gameHandler) {
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();
if (ImGui::Button("Complete Quest", ImVec2(buttonW, 0))) {
gameHandler.completeQuest();
}
ImGui::SameLine();
if (ImGui::Button("Cancel", ImVec2(buttonW, 0))) {
gameHandler.closeQuestRequestItems();
}
if (!quest.isCompletable()) {
ImGui::TextDisabled("Server flagged this quest as incomplete; completion will be server-validated.");
}
}
ImGui::End();

View file

@ -284,11 +284,14 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) {
if (clicked) {
selectedIndex = static_cast<int>(i);
if (q.objectives.empty()) {
if (gameHandler.requestQuestQuery(q.questId)) {
if (!questDetailQueryNoResponse_.count(q.questId) &&
gameHandler.requestQuestQuery(q.questId)) {
lastDetailRequestQuestId_ = q.questId;
lastDetailRequestAt_ = ImGui::GetTime();
}
} else if (lastDetailRequestQuestId_ == q.questId) {
lastDetailRequestQuestId_ = 0;
questDetailQueryNoResponse_.erase(q.questId);
}
}
ImGui::PopID();
@ -310,23 +313,37 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) {
ImGui::Separator();
if (sel.objectives.empty()) {
if (lastDetailRequestQuestId_ != sel.questId) {
if (gameHandler.requestQuestQuery(sel.questId)) {
lastDetailRequestQuestId_ = sel.questId;
}
bool noResponse = questDetailQueryNoResponse_.count(sel.questId) > 0;
bool pending = noResponse ? false : gameHandler.isQuestQueryPending(sel.questId);
const bool requestTimedOut =
(lastDetailRequestQuestId_ == sel.questId) &&
((ImGui::GetTime() - lastDetailRequestAt_) > 5.0);
if (lastDetailRequestQuestId_ == sel.questId && !pending) {
lastDetailRequestQuestId_ = 0;
questDetailQueryNoResponse_.erase(sel.questId);
} else if (requestTimedOut) {
lastDetailRequestQuestId_ = 0;
pending = false;
questDetailQueryNoResponse_.insert(sel.questId);
noResponse = true;
gameHandler.clearQuestQueryPending(sel.questId);
}
if (lastDetailRequestQuestId_ == sel.questId) {
if (pending) {
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.8f, 1.0f), "Loading quest details...");
} else {
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.8f, 1.0f), "Quest summary not available yet.");
}
if (ImGui::Button("Retry Details")) {
questDetailQueryNoResponse_.erase(sel.questId);
gameHandler.clearQuestQueryPending(sel.questId);
if (gameHandler.requestQuestQuery(sel.questId, true)) {
lastDetailRequestQuestId_ = sel.questId;
lastDetailRequestAt_ = ImGui::GetTime();
}
}
} else {
if (lastDetailRequestQuestId_ == sel.questId) lastDetailRequestQuestId_ = 0;
questDetailQueryNoResponse_.erase(sel.questId);
ImGui::TextColored(ImVec4(0.82f, 0.9f, 1.0f, 1.0f), "Summary");
std::string processedObjectives = replaceGenderPlaceholders(sel.objectives, gameHandler);
float textHeight = ImGui::GetContentRegionAvail().y * 0.45f;