From 334d4d3df649942567ce4b414e37684de47cffed Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 19 Feb 2026 00:30:21 -0800 Subject: [PATCH] Fix quest log titles and full-row selection behavior --- include/game/game_handler.hpp | 2 + include/ui/quest_log_screen.hpp | 2 + src/game/game_handler.cpp | 256 ++++++++++++++++++++--------- src/ui/quest_log_screen.cpp | 283 ++++++++++++++++++++++++++------ 4 files changed, 417 insertions(+), 126 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index b7013000..8e732096 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -707,6 +707,7 @@ public: }; const std::vector& getQuestLog() const { return questLog_; } void abandonQuest(uint32_t questId); + bool requestQuestQuery(uint32_t questId, bool force = false); const std::unordered_map& getWorldStates() const { return worldStates_; } std::optional getWorldState(uint32_t key) const { auto it = worldStates_.find(key); @@ -1435,6 +1436,7 @@ private: // Quest log std::vector questLog_; + std::unordered_set pendingQuestQueryIds_; // Quest giver status per NPC std::unordered_map npcQuestStatus_; diff --git a/include/ui/quest_log_screen.hpp b/include/ui/quest_log_screen.hpp index 4a64fc99..d2db34e7 100644 --- a/include/ui/quest_log_screen.hpp +++ b/include/ui/quest_log_screen.hpp @@ -2,6 +2,7 @@ #include "game/game_handler.hpp" #include +#include namespace wowee { namespace ui { @@ -16,6 +17,7 @@ private: bool open = false; bool lKeyWasDown = false; int selectedIndex = -1; + uint32_t lastDetailRequestQuestId_ = 0; }; }} // namespace wowee::ui diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 0de4f9ab..3eb12581 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -106,6 +106,134 @@ std::string formatCopperAmount(uint32_t amount) { } return oss.str(); } + +bool readCStringAt(const std::vector& data, size_t start, std::string& out, size_t& nextPos) { + out.clear(); + if (start >= data.size()) return false; + size_t i = start; + while (i < data.size()) { + uint8_t b = data[i++]; + if (b == 0) { + nextPos = i; + return true; + } + out.push_back(static_cast(b)); + } + return false; +} + +bool isReadableQuestText(const std::string& s, size_t minLen, size_t maxLen) { + if (s.size() < minLen || s.size() > maxLen) return false; + bool hasAlpha = false; + for (unsigned char c : s) { + if (c < 0x20 || c > 0x7E) return false; + if (std::isalpha(c)) hasAlpha = true; + } + return hasAlpha; +} + +bool isPlaceholderQuestTitle(const std::string& s) { + return s.rfind("Quest #", 0) == 0; +} + +bool looksLikeQuestDescriptionText(const std::string& s) { + int spaces = 0; + int commas = 0; + for (unsigned char c : s) { + if (c == ' ') spaces++; + if (c == ',') commas++; + } + const int words = spaces + 1; + if (words > 8) return true; + if (commas > 0 && words > 5) return true; + if (s.find(". ") != std::string::npos) return true; + if (s.find(':') != std::string::npos && words > 5) return true; + return false; +} + +bool isStrongQuestTitle(const std::string& s) { + if (!isReadableQuestText(s, 6, 72)) return false; + if (looksLikeQuestDescriptionText(s)) return false; + unsigned char first = static_cast(s.front()); + return std::isupper(first) != 0; +} + +int scoreQuestTitle(const std::string& s) { + if (!isReadableQuestText(s, 4, 72)) return -1000; + if (looksLikeQuestDescriptionText(s)) return -1000; + int score = 0; + score += static_cast(std::min(s.size(), 32)); + unsigned char first = static_cast(s.front()); + if (std::isupper(first)) score += 20; + if (std::islower(first)) score -= 20; + if (s.find(' ') != std::string::npos) score += 8; + if (s.find('.') != std::string::npos) score -= 18; + if (s.find('!') != std::string::npos || s.find('?') != std::string::npos) score -= 6; + return score; +} + +struct QuestQueryTextCandidate { + std::string title; + std::string objectives; + int score = -1000; +}; + +QuestQueryTextCandidate pickBestQuestQueryTexts(const std::vector& data, bool classicHint) { + QuestQueryTextCandidate best; + if (data.size() <= 9) return best; + + std::vector seedOffsets; + const size_t base = 8; + const size_t classicOffset = base + 40u * 4u; + const size_t wotlkOffset = base + 55u * 4u; + if (classicHint) { + seedOffsets.push_back(classicOffset); + seedOffsets.push_back(wotlkOffset); + } else { + seedOffsets.push_back(wotlkOffset); + seedOffsets.push_back(classicOffset); + } + for (size_t off : seedOffsets) { + if (off < data.size()) { + std::string title; + size_t next = off; + if (readCStringAt(data, off, title, next)) { + QuestQueryTextCandidate c; + c.title = title; + c.score = scoreQuestTitle(title) + 20; // Prefer expected struct offsets + + std::string s2; + size_t n2 = next; + if (readCStringAt(data, next, s2, n2) && isReadableQuestText(s2, 8, 600)) { + c.objectives = s2; + } + if (c.score > best.score) best = c; + } + } + } + + // Fallback: scan packet for best printable C-string title candidate. + for (size_t start = 8; start < data.size(); ++start) { + std::string title; + size_t next = start; + if (!readCStringAt(data, start, title, next)) continue; + + QuestQueryTextCandidate c; + c.title = title; + c.score = scoreQuestTitle(title); + if (c.score < 0) continue; + + std::string s2, s3; + size_t n2 = next, n3 = next; + if (readCStringAt(data, next, s2, n2)) { + if (isReadableQuestText(s2, 8, 600)) c.objectives = s2; + else if (readCStringAt(data, n2, s3, n3) && isReadableQuestText(s3, 8, 600)) c.objectives = s3; + } + if (c.score > best.score) best = c; + } + + return best; +} } // namespace @@ -1764,6 +1892,7 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } uint32_t questId = packet.readUInt32(); + pendingQuestQueryIds_.erase(questId); if (questId == 0) { // Some servers emit a zero-id variant during world bootstrap. // Treat as no-op to avoid false "Quest removed" spam. @@ -1805,75 +1934,43 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_QUEST_QUERY_RESPONSE: { - // Quest data from server (big packet with title, objectives, rewards, etc.) - LOG_INFO("SMSG_QUEST_QUERY_RESPONSE: packet size=", packet.getSize()); - if (packet.getSize() < 8) { LOG_WARNING("SMSG_QUEST_QUERY_RESPONSE: packet too small (", packet.getSize(), " bytes)"); break; } uint32_t questId = packet.readUInt32(); - uint32_t questMethod = packet.readUInt32(); + packet.readUInt32(); // questMethod - LOG_INFO(" questId=", questId, " questMethod=", questMethod); - - // SMSG_QUEST_QUERY_RESPONSE layout varies by expansion. - // - // Classic/Turtle (1.12.x) after questId+questMethod: - // 16 header uint32s (questLevel, zoneOrSort, type, suggestedPlayers, - // repFaction, repValue, nextChain, xpId, - // rewMoney, rewMoneyMax, rewSpell, rewSpellCast, - // rewHonor, rewHonorMult, srcItemId, questFlags) - // 8 reward items (4 slots × 2: itemId + count) - // 12 choice items (6 slots × 2: itemId + count) - // 4 POI uint32s (mapId, x, y, opt) - // = 40 uint32s before title string - // - // WotLK (3.3.5) after questId+questMethod: - // 21 header uint32s (adds minLevel, questInfoId, 2nd repFaction/Value, questFlags2) - // 12 reward items (4 slots × 3: itemId + count + displayId) - // 18 choice items (6 slots × 3: itemId + count + displayId) - // 4 POI uint32s - // = 55 uint32s before title string - // - // Read all numeric fields, then look for the title string. - // Using packetParsers_->questLogStride() as expansion discriminator: - // stride==3 → Classic layout (40 skips) - // stride==5 → WotLK layout (55 skips) const bool isClassicLayout = packetParsers_ && packetParsers_->questLogStride() == 3; - const int skipCount = isClassicLayout ? 40 : 55; + const QuestQueryTextCandidate parsed = pickBestQuestQueryTexts(packet.getData(), isClassicLayout); - for (int i = 0; i < skipCount; ++i) { - packet.readUInt32(); - } - - if (packet.getReadPos() < packet.getSize()) { - std::string title = packet.readString(); - LOG_INFO(" Quest title: '", title, "'"); - - // Only update if we got a non-empty, printable title (guards against - // landing in the middle of binary reward data on wrong layouts). - bool validTitle = !title.empty(); - if (validTitle) { - for (char c : title) { - if ((unsigned char)c < 0x20 && c != '\t') { validTitle = false; break; } - } - } - - if (validTitle) { - for (auto& q : questLog_) { - if (q.questId == questId) { - q.title = title; - LOG_INFO("Updated quest log entry ", questId, " with title: ", title); - break; - } - } - } else { - LOG_INFO(" Skipping non-printable title (wrong layout?) for quest ", questId); - } + for (auto& q : questLog_) { + if (q.questId != questId) continue; + + const int existingScore = scoreQuestTitle(q.title); + const bool parsedStrong = isStrongQuestTitle(parsed.title); + const bool parsedLongEnough = parsed.title.size() >= 6; + const bool notShorterThanExisting = + isPlaceholderQuestTitle(q.title) || q.title.empty() || parsed.title.size() + 2 >= q.title.size(); + const bool shouldReplaceTitle = + parsed.score > -1000 && + parsedStrong && + parsedLongEnough && + notShorterThanExisting && + (isPlaceholderQuestTitle(q.title) || q.title.empty() || parsed.score >= existingScore + 12); + + if (shouldReplaceTitle && !parsed.title.empty()) { + q.title = parsed.title; + } + if (!parsed.objectives.empty() && + (q.objectives.empty() || parsed.objectives.size() > q.objectives.size())) { + q.objectives = parsed.objectives; + } + break; } + pendingQuestQueryIds_.erase(questId); break; } case Opcode::SMSG_QUESTLOG_FULL: { @@ -2463,6 +2560,7 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { hasPlayerExploredZones_ = false; playerSkills_.clear(); questLog_.clear(); + pendingQuestQueryIds_.clear(); npcQuestStatus_.clear(); hostileAttackers_.clear(); combatText.clear(); @@ -4157,12 +4255,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { questLog_.push_back(entry); LOG_INFO("Found quest in update fields: ", questId); - // Request quest details from server - if (socket) { - network::Packet qPkt(wireOpcode(Opcode::CMSG_QUEST_QUERY)); - qPkt.writeUInt32(questId); - socket->send(qPkt); - } + requestQuestQuery(questId); } } } @@ -4472,11 +4565,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { entry.title = "Quest #" + std::to_string(qId); questLog_.push_back(entry); LOG_INFO("Quest found in VALUES update: ", qId); - if (socket) { - network::Packet qPkt(wireOpcode(Opcode::CMSG_QUEST_QUERY)); - qPkt.writeUInt32(qId); - socket->send(qPkt); - } + requestQuestQuery(qId); } } else { // Quest slot cleared — remove from log if present @@ -8579,22 +8668,14 @@ void GameHandler::selectGossipQuest(uint32_t questId) { } } - LOG_INFO("selectGossipQuest: questId=", questId, " isInLog=", isInLog, " isCompletable=", isCompletable); - LOG_INFO(" Current quest log size: ", questLog_.size()); - for (const auto& q : questLog_) { - LOG_INFO(" Quest ", q.questId, ": complete=", q.complete); - } - if (isInLog && isCompletable) { // Quest is ready to turn in - request reward - LOG_INFO("Turning in quest: questId=", questId, " npcGuid=", currentGossip.npcGuid); 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 - LOG_INFO("Querying quest details: questId=", questId, " npcGuid=", currentGossip.npcGuid); auto packet = QuestgiverQueryQuestPacket::build(currentGossip.npcGuid, questId); socket->send(packet); } @@ -8602,6 +8683,17 @@ void GameHandler::selectGossipQuest(uint32_t questId) { gossipWindowOpen = false; } +bool GameHandler::requestQuestQuery(uint32_t questId, bool force) { + if (questId == 0 || state != WorldState::IN_WORLD || !socket) return false; + if (!force && pendingQuestQueryIds_.count(questId)) return false; + + network::Packet pkt(wireOpcode(Opcode::CMSG_QUEST_QUERY)); + pkt.writeUInt32(questId); + socket->send(pkt); + pendingQuestQueryIds_.insert(questId); + return true; +} + void GameHandler::handleQuestDetails(network::Packet& packet) { QuestDetailsData data; bool ok = packetParsers_ ? packetParsers_->parseQuestDetails(packet, data) @@ -8611,6 +8703,16 @@ void GameHandler::handleQuestDetails(network::Packet& packet) { return; } currentQuestDetails = data; + for (auto& q : questLog_) { + if (q.questId != data.questId) continue; + if (!data.title.empty() && (isPlaceholderQuestTitle(q.title) || data.title.size() >= q.title.size())) { + q.title = data.title; + } + if (!data.objectives.empty() && (q.objectives.empty() || data.objectives.size() > q.objectives.size())) { + q.objectives = data.objectives; + } + break; + } questDetailsOpen = true; gossipWindowOpen = false; } diff --git a/src/ui/quest_log_screen.cpp b/src/ui/quest_log_screen.cpp index ff0bdec0..25e4d68e 100644 --- a/src/ui/quest_log_screen.cpp +++ b/src/ui/quest_log_screen.cpp @@ -2,6 +2,7 @@ #include "core/application.hpp" #include "core/input.hpp" #include +#include namespace wowee { namespace ui { @@ -22,37 +23,23 @@ std::string replaceGenderPlaceholders(const std::string& text, game::GameHandler std::string result = text; auto trim = [](std::string& s) { - s.erase(0, s.find_first_not_of(" \t\n\r")); - s.erase(s.find_last_not_of(" \t\n\r") + 1); + const char* ws = " \t\n\r"; + size_t start = s.find_first_not_of(ws); + if (start == std::string::npos) { s.clear(); return; } + size_t end = s.find_last_not_of(ws); + s = s.substr(start, end - start + 1); }; - // Replace simple placeholders + // Replace $g placeholders size_t pos = 0; + pos = 0; while ((pos = result.find('$', pos)) != std::string::npos) { if (pos + 1 >= result.length()) break; + char marker = result[pos + 1]; + if (marker != 'g' && marker != 'G') { pos++; continue; } - char code = result[pos + 1]; - std::string replacement; - - switch (code) { - case 'n': case 'N': replacement = playerName; break; - case 'p': replacement = pronouns.subject; break; - case 'o': replacement = pronouns.object; break; - case 's': replacement = pronouns.possessive; break; - case 'S': replacement = pronouns.possessiveP; break; - case 'g': pos++; continue; - default: pos++; continue; - } - - result.replace(pos, 2, replacement); - pos += replacement.length(); - } - - // Replace $g placeholders - pos = 0; - while ((pos = result.find("$g", pos)) != std::string::npos) { size_t endPos = result.find(';', pos); - if (endPos == std::string::npos) break; + if (endPos == std::string::npos) { pos += 2; continue; } std::string placeholder = result.substr(pos + 2, endPos - pos - 2); @@ -93,14 +80,130 @@ std::string replaceGenderPlaceholders(const std::string& text, game::GameHandler pos += replacement.length(); } + // Replace simple placeholders + pos = 0; + while ((pos = result.find('$', pos)) != std::string::npos) { + if (pos + 1 >= result.length()) break; + + char code = result[pos + 1]; + std::string replacement; + + switch (code) { + case 'n': case 'N': replacement = playerName; break; + case 'p': replacement = pronouns.subject; break; + case 'o': replacement = pronouns.object; break; + case 's': replacement = pronouns.possessive; break; + case 'S': replacement = pronouns.possessiveP; break; + case 'r': replacement = pronouns.object; break; + case 'b': replacement = "\n"; break; + case 'g': case 'G': pos++; continue; + default: pos++; continue; + } + + result.replace(pos, 2, replacement); + pos += replacement.length(); + } + + // WoW markup linebreak token + pos = 0; + while ((pos = result.find("|n", pos)) != std::string::npos) { + result.replace(pos, 2, "\n"); + pos += 1; + } + return result; } + +std::string cleanQuestTitleForUi(const std::string& raw, uint32_t questId) { + std::string s = raw; + + auto looksUtf16LeBytes = [](const std::string& str) -> bool { + if (str.size() < 6) return false; + size_t nulCount = 0; + size_t oddNul = 0; + for (size_t i = 0; i < str.size(); i++) { + if (str[i] == '\0') { + nulCount++; + if (i & 1) oddNul++; + } + } + return (nulCount >= str.size() / 4) && (oddNul >= (nulCount * 3) / 4); + }; + + if (looksUtf16LeBytes(s)) { + std::string collapsed; + collapsed.reserve(s.size() / 2); + for (size_t i = 0; i + 1 < s.size(); i += 2) { + unsigned char lo = static_cast(s[i]); + unsigned char hi = static_cast(s[i + 1]); + if (lo == 0 && hi == 0) break; + if (hi != 0) { collapsed.clear(); break; } + collapsed.push_back(static_cast(lo)); + } + if (!collapsed.empty()) s = std::move(collapsed); + } + + // Keep a stable ASCII view for list rendering; malformed multibyte/UTF-16 noise + // is a common source of one-glyph/half-glyph quest labels. + std::string ascii; + ascii.reserve(s.size()); + for (unsigned char uc : s) { + if (uc >= 0x20 && uc <= 0x7E) ascii.push_back(static_cast(uc)); + else if (uc == '\t' || uc == '\n' || uc == '\r') ascii.push_back(' '); + } + if (ascii.size() >= 4) s = std::move(ascii); + + for (char& c : s) { + unsigned char uc = static_cast(c); + if (uc == 0) { c = ' '; continue; } + if (uc < 0x20 && c != '\n' && c != '\t') c = ' '; + } + while (!s.empty() && s.front() == ' ') s.erase(s.begin()); + while (!s.empty() && s.back() == ' ') s.pop_back(); + + int alphaCount = 0; + int spaceCount = 0; + int shortWordCount = 0; + int wordCount = 0; + int currentWordLen = 0; + for (char c : s) { + if (std::isalpha(static_cast(c))) alphaCount++; + if (c == ' ') { + spaceCount++; + if (currentWordLen > 0) { + wordCount++; + if (currentWordLen <= 1) shortWordCount++; + currentWordLen = 0; + } + } else { + currentWordLen++; + } + } + if (currentWordLen > 0) { + wordCount++; + if (currentWordLen <= 1) shortWordCount++; + } + + // Heuristic for broken UTF-16-like text that turns into "T h e B e g i n n i n g". + if (wordCount >= 6 && shortWordCount == wordCount && static_cast(s.size()) > 12) { + std::string compact; + compact.reserve(s.size()); + for (char c : s) { + if (c != ' ') compact.push_back(c); + } + if (compact.size() >= 4) s = compact; + } + + if (s.size() < 4) s = "Quest #" + std::to_string(questId); + if (s.size() > 72) s = s.substr(0, 72) + "..."; + return s; +} } // anonymous namespace void QuestLogScreen::render(game::GameHandler& gameHandler) { // L key toggle (edge-triggered) - bool wantsTextInput = ImGui::GetIO().WantTextInput; - bool lDown = !wantsTextInput && core::Input::getInstance().isKeyPressed(SDL_SCANCODE_L); + ImGuiIO& io = ImGui::GetIO(); + bool lDown = !io.WantTextInput && core::Input::getInstance().isKeyPressed(SDL_SCANCODE_L); if (lDown && !lKeyWasDown) { open = !open; } @@ -112,52 +215,125 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) { float screenW = window ? static_cast(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(window->getHeight()) : 720.0f; - float logW = 380.0f; - float logH = std::min(450.0f, screenH - 120.0f); + float logW = std::min(980.0f, screenW - 80.0f); + float logH = std::min(620.0f, screenH - 100.0f); float logX = (screenW - logW) * 0.5f; - float logY = 80.0f; + float logY = 50.0f; ImGui::SetNextWindowPos(ImVec2(logX, logY), ImGuiCond_FirstUseEver); ImGui::SetNextWindowSize(ImVec2(logW, logH), ImGuiCond_FirstUseEver); bool stillOpen = true; if (ImGui::Begin("Quest Log", &stillOpen)) { + const float footerHeight = 42.0f; + ImGui::BeginChild("QuestLogMain", ImVec2(0, -footerHeight), false); + const auto& quests = gameHandler.getQuestLog(); + if (selectedIndex >= static_cast(quests.size())) { + selectedIndex = quests.empty() ? -1 : static_cast(quests.size()) - 1; + } + + int activeCount = 0; + int completeCount = 0; + for (const auto& q : quests) { + if (q.complete) completeCount++; + else activeCount++; + } + + ImGui::TextColored(ImVec4(0.95f, 0.85f, 0.35f, 1.0f), "Active: %d", activeCount); + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.45f, 0.95f, 0.45f, 1.0f), "Ready: %d", completeCount); + ImGui::Separator(); if (quests.empty()) { - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "No active quests."); + ImGui::Spacing(); + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.75f, 1.0f), "No active quests."); } else { - // Left panel: quest list - ImGui::BeginChild("QuestList", ImVec2(0, -ImGui::GetFrameHeightWithSpacing() - 4), true); + float paneW = ImGui::GetContentRegionAvail().x * 0.42f; + if (paneW < 260.0f) paneW = 260.0f; + if (paneW > 420.0f) paneW = 420.0f; + + ImGui::BeginChild("QuestListPane", ImVec2(paneW, 0), true); + ImGui::TextColored(ImVec4(0.85f, 0.82f, 0.74f, 1.0f), "Quest List"); + ImGui::Separator(); for (size_t i = 0; i < quests.size(); i++) { const auto& q = quests[i]; ImGui::PushID(static_cast(i)); - ImVec4 color = q.complete - ? ImVec4(0.0f, 1.0f, 0.0f, 1.0f) // Green for complete - : ImVec4(1.0f, 0.82f, 0.0f, 1.0f); // Gold for active - bool selected = (selectedIndex == static_cast(i)); - if (ImGui::Selectable("##quest", selected, 0, ImVec2(0, 20))) { - selectedIndex = static_cast(i); - } - ImGui::SameLine(); - ImGui::TextColored(color, "%s%s", - q.title.c_str(), - q.complete ? " (Complete)" : ""); + std::string displayTitle = cleanQuestTitleForUi(q.title, q.questId); + std::string rowText = displayTitle + (q.complete ? " [Ready]" : ""); + float rowH = 24.0f; + float rowW = ImGui::GetContentRegionAvail().x; + if (rowW < 1.0f) rowW = 1.0f; + bool clicked = ImGui::InvisibleButton("questRowBtn", ImVec2(rowW, rowH)); + bool hovered = ImGui::IsItemHovered(); + + ImVec2 rowMin = ImGui::GetItemRectMin(); + ImVec2 rowMax = ImGui::GetItemRectMax(); + ImDrawList* draw = ImGui::GetWindowDrawList(); + if (selected || hovered) { + ImU32 bg = selected ? IM_COL32(75, 95, 120, 190) : IM_COL32(60, 60, 60, 120); + draw->AddRectFilled(rowMin, rowMax, bg, 3.0f); + } + + ImU32 txt = q.complete ? IM_COL32(120, 255, 120, 255) : IM_COL32(230, 230, 230, 255); + draw->AddText(ImVec2(rowMin.x + 8.0f, rowMin.y + 4.0f), txt, rowText.c_str()); + + if (clicked) { + selectedIndex = static_cast(i); + if (q.objectives.empty()) { + if (gameHandler.requestQuestQuery(q.questId)) { + lastDetailRequestQuestId_ = q.questId; + } + } else if (lastDetailRequestQuestId_ == q.questId) { + lastDetailRequestQuestId_ = 0; + } + } ImGui::PopID(); } ImGui::EndChild(); + ImGui::SameLine(); + ImGui::BeginChild("QuestDetailsPane", ImVec2(0, 0), true); + // Details panel for selected quest if (selectedIndex >= 0 && selectedIndex < static_cast(quests.size())) { const auto& sel = quests[static_cast(selectedIndex)]; + std::string selectedTitle = cleanQuestTitleForUi(sel.title, sel.questId); + ImGui::TextWrapped("%s", selectedTitle.c_str()); + ImGui::TextColored(sel.complete ? ImVec4(0.45f, 1.0f, 0.45f, 1.0f) : ImVec4(1.0f, 0.84f, 0.2f, 1.0f), + "%s", sel.complete ? "Ready to turn in" : "In progress"); + ImGui::SameLine(); + ImGui::TextDisabled("(Quest #%u)", sel.questId); + ImGui::Separator(); - if (!sel.objectives.empty()) { - ImGui::Separator(); + if (sel.objectives.empty()) { + if (lastDetailRequestQuestId_ != sel.questId) { + if (gameHandler.requestQuestQuery(sel.questId)) { + lastDetailRequestQuestId_ = sel.questId; + } + } + if (lastDetailRequestQuestId_ == sel.questId) { + 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")) { + if (gameHandler.requestQuestQuery(sel.questId, true)) { + lastDetailRequestQuestId_ = sel.questId; + } + } + } else { + if (lastDetailRequestQuestId_ == sel.questId) lastDetailRequestQuestId_ = 0; + 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; + if (textHeight < 120.0f) textHeight = 120.0f; + ImGui::BeginChild("QuestObjectiveText", ImVec2(0, textHeight), true); ImGui::TextWrapped("%s", processedObjectives.c_str()); + ImGui::EndChild(); } if (!sel.killCounts.empty() || !sel.itemCounts.empty()) { @@ -178,14 +354,23 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) { // Abandon button if (!sel.complete) { ImGui::Separator(); - if (ImGui::Button("Abandon Quest")) { + if (ImGui::Button("Abandon Quest", ImVec2(150.0f, 0.0f))) { gameHandler.abandonQuest(sel.questId); - if (selectedIndex >= static_cast(quests.size())) { - selectedIndex = static_cast(quests.size()) - 1; - } + selectedIndex = -1; } } + } else { + ImGui::TextColored(ImVec4(0.72f, 0.72f, 0.76f, 1.0f), "Select a quest to view details."); } + ImGui::EndChild(); + } + ImGui::EndChild(); + + ImGui::Separator(); + float closeW = ImGui::GetContentRegionAvail().x; + if (closeW < 220.0f) closeW = 220.0f; + if (ImGui::Button("Close Quest Log", ImVec2(closeW, 34.0f))) { + stillOpen = false; } } ImGui::End();