mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-04-25 13:03:50 +00:00
- character_renderer: extract duplicated fallback texture creation (white/transparent/flat-normal) into createFallbackTextures() — was copy-pasted between initialize() and clear() - wmo_renderer: replace magic 8192 with kMaxRetryTracked constant, add why-comment explaining the fallback-retry set cap (Dalaran has 2000+ unique WMO groups) - quest_handler: add why-comment on reqCount=0 fallback — escort/event quests can report kill credit without objective counts in query response
1856 lines
77 KiB
C++
1856 lines
77 KiB
C++
#include "game/quest_handler.hpp"
|
|
#include "game/game_handler.hpp"
|
|
#include "game/game_utils.hpp"
|
|
#include "game/entity.hpp"
|
|
#include "game/update_field_table.hpp"
|
|
#include "game/packet_parsers.hpp"
|
|
#include "network/world_socket.hpp"
|
|
#include "rendering/renderer.hpp"
|
|
#include "audio/ui_sound_manager.hpp"
|
|
#include "core/application.hpp"
|
|
#include "core/logger.hpp"
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <sstream>
|
|
|
|
namespace wowee {
|
|
namespace game {
|
|
|
|
QuestGiverStatus QuestHandler::getQuestGiverStatus(uint64_t guid) const {
|
|
auto it = npcQuestStatus_.find(guid);
|
|
return (it != npcQuestStatus_.end()) ? it->second : QuestGiverStatus::NONE;
|
|
}
|
|
|
|
|
|
static std::string formatCopperAmount(uint32_t amount) {
|
|
uint32_t gold = amount / 10000;
|
|
uint32_t silver = (amount / 100) % 100;
|
|
uint32_t copper = amount % 100;
|
|
|
|
std::ostringstream oss;
|
|
bool wrote = false;
|
|
if (gold > 0) {
|
|
oss << gold << "g";
|
|
wrote = true;
|
|
}
|
|
if (silver > 0) {
|
|
if (wrote) oss << " ";
|
|
oss << silver << "s";
|
|
wrote = true;
|
|
}
|
|
if (copper > 0 || !wrote) {
|
|
if (wrote) oss << " ";
|
|
oss << copper << "c";
|
|
}
|
|
return oss.str();
|
|
}
|
|
|
|
static 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) {
|
|
// Reject control characters but allow UTF-8 multi-byte sequences (0x80+)
|
|
// so localized servers (French, German, Russian, etc.) work correctly.
|
|
if (c < 0x20 && c != '\t' && c != '\n' && c != '\r') return false;
|
|
if (c >= 0x20 && c <= 0x7E && std::isalpha(c)) hasAlpha = true;
|
|
// UTF-8 continuation/lead bytes (0x80+) are allowed but don't count as alpha
|
|
// since we only need at least one ASCII letter to distinguish from binary garbage.
|
|
}
|
|
return hasAlpha;
|
|
}
|
|
|
|
static bool isPlaceholderQuestTitle(const std::string& s) {
|
|
return s.rfind("Quest #", 0) == 0;
|
|
}
|
|
|
|
static 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;
|
|
}
|
|
|
|
static bool isStrongQuestTitle(const std::string& s) {
|
|
if (!isReadableQuestText(s, 6, 72)) return false;
|
|
if (looksLikeQuestDescriptionText(s)) return false;
|
|
unsigned char first = static_cast<unsigned char>(s.front());
|
|
return std::isupper(first) != 0;
|
|
}
|
|
|
|
static int scoreQuestTitle(const std::string& s) {
|
|
if (!isReadableQuestText(s, 4, 72)) return -1000;
|
|
if (looksLikeQuestDescriptionText(s)) return -1000;
|
|
int score = 0;
|
|
score += static_cast<int>(std::min<size_t>(s.size(), 32));
|
|
unsigned char first = static_cast<unsigned char>(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;
|
|
}
|
|
|
|
static bool readCStringAt(const std::vector<uint8_t>& 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<char>(b));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
struct QuestQueryTextCandidate {
|
|
std::string title;
|
|
std::string objectives;
|
|
int score = -1000;
|
|
};
|
|
|
|
static QuestQueryTextCandidate pickBestQuestQueryTexts(const std::vector<uint8_t>& data, bool classicHint) {
|
|
QuestQueryTextCandidate best;
|
|
if (data.size() <= 9) return best;
|
|
|
|
std::vector<size_t> 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;
|
|
}
|
|
|
|
struct QuestQueryObjectives {
|
|
struct Kill { int32_t npcOrGoId; uint32_t required; };
|
|
struct Item { uint32_t itemId; uint32_t required; };
|
|
std::array<Kill, 4> kills{};
|
|
std::array<Item, 6> items{};
|
|
bool valid = false;
|
|
};
|
|
|
|
static uint32_t readU32At(const std::vector<uint8_t>& d, size_t pos) {
|
|
return static_cast<uint32_t>(d[pos])
|
|
| (static_cast<uint32_t>(d[pos + 1]) << 8)
|
|
| (static_cast<uint32_t>(d[pos + 2]) << 16)
|
|
| (static_cast<uint32_t>(d[pos + 3]) << 24);
|
|
}
|
|
|
|
static QuestQueryObjectives tryParseQuestObjectivesAt(const std::vector<uint8_t>& data,
|
|
size_t startPos, int nStrings) {
|
|
QuestQueryObjectives out;
|
|
size_t pos = startPos;
|
|
|
|
for (int si = 0; si < nStrings; ++si) {
|
|
while (pos < data.size() && data[pos] != 0) ++pos;
|
|
if (pos >= data.size()) return out;
|
|
++pos;
|
|
}
|
|
|
|
for (int i = 0; i < 4; ++i) {
|
|
if (pos + 8 > data.size()) return out;
|
|
out.kills[i].npcOrGoId = static_cast<int32_t>(readU32At(data, pos)); pos += 4;
|
|
out.kills[i].required = readU32At(data, pos); pos += 4;
|
|
}
|
|
|
|
for (int i = 0; i < 6; ++i) {
|
|
if (pos + 8 > data.size()) break;
|
|
out.items[i].itemId = readU32At(data, pos); pos += 4;
|
|
out.items[i].required = readU32At(data, pos); pos += 4;
|
|
}
|
|
|
|
out.valid = true;
|
|
return out;
|
|
}
|
|
|
|
static QuestQueryObjectives extractQuestQueryObjectives(const std::vector<uint8_t>& data, bool classicHint) {
|
|
if (data.size() < 16) return {};
|
|
|
|
const size_t base = 8;
|
|
const size_t classicStart = base + 40u * 4u;
|
|
const size_t wotlkStart = base + 55u * 4u;
|
|
|
|
if (classicHint) {
|
|
auto r = tryParseQuestObjectivesAt(data, classicStart, 4);
|
|
if (r.valid) return r;
|
|
return tryParseQuestObjectivesAt(data, wotlkStart, 5);
|
|
} else {
|
|
auto r = tryParseQuestObjectivesAt(data, wotlkStart, 5);
|
|
if (r.valid) return r;
|
|
return tryParseQuestObjectivesAt(data, classicStart, 4);
|
|
}
|
|
}
|
|
|
|
struct QuestQueryRewards {
|
|
int32_t rewardMoney = 0;
|
|
std::array<uint32_t, 4> itemId{};
|
|
std::array<uint32_t, 4> itemCount{};
|
|
std::array<uint32_t, 6> choiceItemId{};
|
|
std::array<uint32_t, 6> choiceItemCount{};
|
|
bool valid = false;
|
|
};
|
|
|
|
static QuestQueryRewards tryParseQuestRewards(const std::vector<uint8_t>& data,
|
|
bool classicLayout) {
|
|
const size_t base = 8;
|
|
const size_t fieldCount = classicLayout ? 40u : 55u;
|
|
const size_t headerEnd = base + fieldCount * 4u;
|
|
if (data.size() < headerEnd) return {};
|
|
|
|
const size_t moneyField = classicLayout ? 14u : 17u;
|
|
const size_t itemIdField = classicLayout ? 20u : 30u;
|
|
const size_t itemCountField = classicLayout ? 24u : 34u;
|
|
const size_t choiceIdField = classicLayout ? 28u : 38u;
|
|
const size_t choiceCntField = classicLayout ? 34u : 44u;
|
|
|
|
QuestQueryRewards out;
|
|
out.rewardMoney = static_cast<int32_t>(readU32At(data, base + moneyField * 4u));
|
|
for (size_t i = 0; i < 4; ++i) {
|
|
out.itemId[i] = readU32At(data, base + (itemIdField + i) * 4u);
|
|
out.itemCount[i] = readU32At(data, base + (itemCountField + i) * 4u);
|
|
}
|
|
for (size_t i = 0; i < 6; ++i) {
|
|
out.choiceItemId[i] = readU32At(data, base + (choiceIdField + i) * 4u);
|
|
out.choiceItemCount[i] = readU32At(data, base + (choiceCntField + i) * 4u);
|
|
}
|
|
out.valid = true;
|
|
return out;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constructor
|
|
// ---------------------------------------------------------------------------
|
|
|
|
QuestHandler::QuestHandler(GameHandler& owner)
|
|
: owner_(owner) {}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Opcode registrations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void QuestHandler::registerOpcodes(DispatchTable& table) {
|
|
|
|
// ---- SMSG_GOSSIP_MESSAGE ----
|
|
table[Opcode::SMSG_GOSSIP_MESSAGE] = [this](network::Packet& packet) { handleGossipMessage(packet); };
|
|
|
|
// ---- SMSG_QUESTGIVER_QUEST_LIST ----
|
|
table[Opcode::SMSG_QUESTGIVER_QUEST_LIST] = [this](network::Packet& packet) { handleQuestgiverQuestList(packet); };
|
|
|
|
// ---- SMSG_GOSSIP_COMPLETE ----
|
|
table[Opcode::SMSG_GOSSIP_COMPLETE] = [this](network::Packet& packet) { handleGossipComplete(packet); };
|
|
|
|
// ---- SMSG_GOSSIP_POI ----
|
|
table[Opcode::SMSG_GOSSIP_POI] = [this](network::Packet& packet) {
|
|
if (!packet.hasRemaining(20)) return;
|
|
/*uint32_t flags =*/ packet.readUInt32();
|
|
float poiX = packet.readFloat();
|
|
float poiY = packet.readFloat();
|
|
uint32_t icon = packet.readUInt32();
|
|
uint32_t data = packet.readUInt32();
|
|
std::string name = packet.readString();
|
|
GossipPoi poi; poi.x = poiX; poi.y = poiY; poi.icon = icon; poi.data = data; poi.name = std::move(name);
|
|
if (gossipPois_.size() >= 200) gossipPois_.erase(gossipPois_.begin());
|
|
gossipPois_.push_back(std::move(poi));
|
|
LOG_DEBUG("SMSG_GOSSIP_POI: x=", poiX, " y=", poiY, " icon=", icon);
|
|
};
|
|
|
|
// ---- SMSG_QUESTGIVER_QUEST_DETAILS ----
|
|
table[Opcode::SMSG_QUESTGIVER_QUEST_DETAILS] = [this](network::Packet& packet) { handleQuestDetails(packet); };
|
|
|
|
// ---- SMSG_QUESTLOG_FULL ----
|
|
table[Opcode::SMSG_QUESTLOG_FULL] = [this](network::Packet& /*packet*/) {
|
|
owner_.addUIError("Your quest log is full.");
|
|
owner_.addSystemChatMessage("Your quest log is full.");
|
|
};
|
|
|
|
// ---- SMSG_QUESTGIVER_REQUEST_ITEMS ----
|
|
table[Opcode::SMSG_QUESTGIVER_REQUEST_ITEMS] = [this](network::Packet& packet) { handleQuestRequestItems(packet); };
|
|
|
|
// ---- SMSG_QUESTGIVER_OFFER_REWARD ----
|
|
table[Opcode::SMSG_QUESTGIVER_OFFER_REWARD] = [this](network::Packet& packet) { handleQuestOfferReward(packet); };
|
|
|
|
// ---- SMSG_QUEST_CONFIRM_ACCEPT ----
|
|
table[Opcode::SMSG_QUEST_CONFIRM_ACCEPT] = [this](network::Packet& packet) { handleQuestConfirmAccept(packet); };
|
|
|
|
// ---- SMSG_QUEST_POI_QUERY_RESPONSE ----
|
|
table[Opcode::SMSG_QUEST_POI_QUERY_RESPONSE] = [this](network::Packet& packet) { handleQuestPoiQueryResponse(packet); };
|
|
|
|
// ---- SMSG_QUESTGIVER_STATUS ----
|
|
table[Opcode::SMSG_QUESTGIVER_STATUS] = [this](network::Packet& packet) {
|
|
if (packet.hasRemaining(9)) {
|
|
uint64_t npcGuid = packet.readUInt64();
|
|
uint8_t status = owner_.packetParsers_->readQuestGiverStatus(packet);
|
|
npcQuestStatus_[npcGuid] = static_cast<QuestGiverStatus>(status);
|
|
}
|
|
};
|
|
|
|
// ---- SMSG_QUESTGIVER_STATUS_MULTIPLE ----
|
|
table[Opcode::SMSG_QUESTGIVER_STATUS_MULTIPLE] = [this](network::Packet& packet) {
|
|
if (!packet.hasRemaining(4)) return;
|
|
uint32_t count = packet.readUInt32();
|
|
for (uint32_t i = 0; i < count; ++i) {
|
|
if (!packet.hasRemaining(9)) break;
|
|
uint64_t npcGuid = packet.readUInt64();
|
|
uint8_t status = owner_.packetParsers_->readQuestGiverStatus(packet);
|
|
npcQuestStatus_[npcGuid] = static_cast<QuestGiverStatus>(status);
|
|
}
|
|
};
|
|
|
|
// ---- SMSG_QUESTUPDATE_FAILED ----
|
|
table[Opcode::SMSG_QUESTUPDATE_FAILED] = [this](network::Packet& packet) {
|
|
if (packet.hasRemaining(4)) {
|
|
uint32_t questId = packet.readUInt32();
|
|
std::string questTitle;
|
|
for (const auto& q : questLog_)
|
|
if (q.questId == questId && !q.title.empty()) { questTitle = q.title; break; }
|
|
owner_.addSystemChatMessage(questTitle.empty() ? std::string("Quest failed!")
|
|
: ('"' + questTitle + "\" failed!"));
|
|
}
|
|
};
|
|
|
|
// ---- SMSG_QUESTUPDATE_FAILEDTIMER ----
|
|
table[Opcode::SMSG_QUESTUPDATE_FAILEDTIMER] = [this](network::Packet& packet) {
|
|
if (packet.hasRemaining(4)) {
|
|
uint32_t questId = packet.readUInt32();
|
|
std::string questTitle;
|
|
for (const auto& q : questLog_)
|
|
if (q.questId == questId && !q.title.empty()) { questTitle = q.title; break; }
|
|
owner_.addSystemChatMessage(questTitle.empty() ? std::string("Quest timed out!")
|
|
: ('"' + questTitle + "\" has timed out."));
|
|
}
|
|
};
|
|
|
|
// ---- SMSG_QUESTGIVER_QUEST_FAILED ----
|
|
table[Opcode::SMSG_QUESTGIVER_QUEST_FAILED] = [this](network::Packet& packet) {
|
|
// uint32 questId + uint32 reason
|
|
if (packet.hasRemaining(8)) {
|
|
uint32_t questId = packet.readUInt32();
|
|
uint32_t reason = packet.readUInt32();
|
|
std::string questTitle;
|
|
for (const auto& q : questLog_)
|
|
if (q.questId == questId && !q.title.empty()) { questTitle = q.title; break; }
|
|
const char* reasonStr = nullptr;
|
|
switch (reason) {
|
|
case 1: reasonStr = "failed conditions"; break;
|
|
case 2: reasonStr = "inventory full"; break;
|
|
case 3: reasonStr = "too far away"; break;
|
|
case 4: reasonStr = "another quest is blocking"; break;
|
|
case 5: reasonStr = "wrong time of day"; break;
|
|
case 6: reasonStr = "wrong race"; break;
|
|
case 7: reasonStr = "wrong class"; break;
|
|
}
|
|
std::string msg = questTitle.empty() ? "Quest" : ('"' + questTitle + '"');
|
|
msg += " failed";
|
|
if (reasonStr) msg += std::string(": ") + reasonStr;
|
|
msg += '.';
|
|
owner_.addSystemChatMessage(msg);
|
|
}
|
|
};
|
|
|
|
// ---- SMSG_QUESTGIVER_QUEST_INVALID ----
|
|
table[Opcode::SMSG_QUESTGIVER_QUEST_INVALID] = [this](network::Packet& packet) {
|
|
if (packet.hasRemaining(4)) {
|
|
uint32_t failReason = packet.readUInt32();
|
|
pendingTurnInRewardRequest_ = false;
|
|
const char* reasonStr = "Unknown";
|
|
switch (failReason) {
|
|
case 0: reasonStr = "Don't have quest"; break;
|
|
case 1: reasonStr = "Quest level too low"; break;
|
|
case 4: reasonStr = "Insufficient money"; break;
|
|
case 5: reasonStr = "Inventory full"; break;
|
|
case 13: reasonStr = "Already on that quest"; break;
|
|
case 18: reasonStr = "Already completed quest"; break;
|
|
case 19: reasonStr = "Can't take any more quests"; break;
|
|
}
|
|
LOG_WARNING("Quest invalid: reason=", failReason, " (", reasonStr, ")");
|
|
if (!pendingQuestAcceptTimeouts_.empty()) {
|
|
std::vector<uint32_t> 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"
|
|
owner_.addSystemChatMessage(std::string("Quest unavailable: ") + reasonStr);
|
|
}
|
|
}
|
|
};
|
|
|
|
// ---- SMSG_QUESTGIVER_QUEST_COMPLETE ----
|
|
table[Opcode::SMSG_QUESTGIVER_QUEST_COMPLETE] = [this](network::Packet& packet) {
|
|
if (packet.hasRemaining(4)) {
|
|
uint32_t questId = packet.readUInt32();
|
|
LOG_INFO("Quest completed: questId=", questId);
|
|
if (pendingTurnInQuestId_ == questId) {
|
|
pendingTurnInQuestId_ = 0;
|
|
pendingTurnInNpcGuid_ = 0;
|
|
pendingTurnInRewardRequest_ = false;
|
|
}
|
|
for (auto it = questLog_.begin(); it != questLog_.end(); ++it) {
|
|
if (it->questId == questId) {
|
|
// Fire toast callback before erasing
|
|
if (owner_.questCompleteCallback_) {
|
|
owner_.questCompleteCallback_(questId, it->title);
|
|
}
|
|
// Play quest-complete sound
|
|
if (auto* renderer = owner_.services().renderer) {
|
|
if (auto* sfx = renderer->getUiSoundManager())
|
|
sfx->playQuestComplete();
|
|
}
|
|
questLog_.erase(it);
|
|
LOG_INFO(" Removed quest ", questId, " from quest log");
|
|
if (owner_.addonEventCallback_)
|
|
owner_.addonEventCallback_("QUEST_TURNED_IN", {std::to_string(questId)});
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (owner_.addonEventCallback_) {
|
|
owner_.addonEventCallback_("QUEST_LOG_UPDATE", {});
|
|
owner_.addonEventCallback_("UNIT_QUEST_LOG_CHANGED", {"player"});
|
|
}
|
|
// Re-query all nearby quest giver NPCs so markers refresh
|
|
if (owner_.socket) {
|
|
for (const auto& [guid, entity] : owner_.getEntityManager().getEntities()) {
|
|
if (entity->getType() != ObjectType::UNIT) continue;
|
|
auto unit = std::static_pointer_cast<Unit>(entity);
|
|
if (unit->getNpcFlags() & 0x02) {
|
|
network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY));
|
|
qsPkt.writeUInt64(guid);
|
|
owner_.socket->send(qsPkt);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// ---- SMSG_QUESTUPDATE_ADD_KILL ----
|
|
table[Opcode::SMSG_QUESTUPDATE_ADD_KILL] = [this](network::Packet& packet) {
|
|
size_t rem = packet.getRemainingSize();
|
|
if (rem >= 12) {
|
|
uint32_t questId = packet.readUInt32();
|
|
clearPendingQuestAccept(questId);
|
|
uint32_t entry = packet.readUInt32();
|
|
uint32_t count = packet.readUInt32();
|
|
uint32_t reqCount = 0;
|
|
if (packet.hasRemaining(4)) {
|
|
reqCount = packet.readUInt32();
|
|
}
|
|
|
|
LOG_INFO("Quest kill update: questId=", questId, " entry=", entry,
|
|
" count=", count, "/", reqCount);
|
|
|
|
for (auto& quest : questLog_) {
|
|
if (quest.questId == questId) {
|
|
if (reqCount == 0) {
|
|
auto it = quest.killCounts.find(entry);
|
|
if (it != quest.killCounts.end()) reqCount = it->second.second;
|
|
}
|
|
// Fall back to killObjectives (parsed from SMSG_QUEST_QUERY_RESPONSE).
|
|
if (reqCount == 0) {
|
|
for (const auto& obj : quest.killObjectives) {
|
|
if (obj.npcOrGoId == 0 || obj.required == 0) continue;
|
|
uint32_t objEntry = static_cast<uint32_t>(
|
|
obj.npcOrGoId > 0 ? obj.npcOrGoId : -obj.npcOrGoId);
|
|
if (objEntry == entry) {
|
|
reqCount = obj.required;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// Some quests (e.g. escort/event quests) report kill credit updates without
|
|
// a corresponding objective count in SMSG_QUEST_QUERY_RESPONSE. Fall back to
|
|
// current count so the progress display shows "N/N" instead of "N/0".
|
|
if (reqCount == 0) reqCount = count;
|
|
quest.killCounts[entry] = {count, reqCount};
|
|
|
|
std::string creatureName = owner_.getCachedCreatureName(entry);
|
|
std::string progressMsg = quest.title + ": ";
|
|
if (!creatureName.empty()) {
|
|
progressMsg += creatureName + " ";
|
|
}
|
|
progressMsg += std::to_string(count) + "/" + std::to_string(reqCount);
|
|
owner_.addSystemChatMessage(progressMsg);
|
|
|
|
if (owner_.questProgressCallback_) {
|
|
owner_.questProgressCallback_(quest.title, creatureName, count, reqCount);
|
|
}
|
|
if (owner_.addonEventCallback_) {
|
|
owner_.addonEventCallback_("QUEST_WATCH_UPDATE", {std::to_string(questId)});
|
|
owner_.addonEventCallback_("QUEST_LOG_UPDATE", {});
|
|
owner_.addonEventCallback_("UNIT_QUEST_LOG_CHANGED", {"player"});
|
|
}
|
|
|
|
LOG_INFO("Updated kill count for quest ", questId, ": ",
|
|
count, "/", reqCount);
|
|
break;
|
|
}
|
|
}
|
|
} 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) {
|
|
quest.complete = true;
|
|
owner_.addSystemChatMessage("Quest Complete: " + quest.title);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// ---- SMSG_QUESTUPDATE_ADD_ITEM ----
|
|
table[Opcode::SMSG_QUESTUPDATE_ADD_ITEM] = [this](network::Packet& packet) {
|
|
if (packet.hasRemaining(8)) {
|
|
uint32_t itemId = packet.readUInt32();
|
|
uint32_t count = packet.readUInt32();
|
|
owner_.queryItemInfo(itemId, 0);
|
|
|
|
std::string itemLabel = "item #" + std::to_string(itemId);
|
|
uint32_t questItemQuality = 1;
|
|
if (const ItemQueryResponseData* info = owner_.getItemInfo(itemId)) {
|
|
if (!info->name.empty()) itemLabel = info->name;
|
|
questItemQuality = info->quality;
|
|
}
|
|
|
|
bool updatedAny = false;
|
|
for (auto& quest : questLog_) {
|
|
if (quest.complete) continue;
|
|
bool tracksItem =
|
|
quest.requiredItemCounts.count(itemId) > 0 ||
|
|
quest.itemCounts.count(itemId) > 0;
|
|
// Also check itemObjectives parsed from SMSG_QUEST_QUERY_RESPONSE in case
|
|
// requiredItemCounts hasn't been populated yet (race during quest accept).
|
|
if (!tracksItem) {
|
|
for (const auto& obj : quest.itemObjectives) {
|
|
if (obj.itemId == itemId && obj.required > 0) {
|
|
quest.requiredItemCounts.emplace(itemId, obj.required);
|
|
tracksItem = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (!tracksItem) continue;
|
|
quest.itemCounts[itemId] = count;
|
|
updatedAny = true;
|
|
}
|
|
owner_.addSystemChatMessage("Quest item: " + buildItemLink(itemId, questItemQuality, itemLabel) + " (" + std::to_string(count) + ")");
|
|
|
|
if (owner_.questProgressCallback_ && updatedAny) {
|
|
for (const auto& quest : questLog_) {
|
|
if (quest.complete) continue;
|
|
if (quest.itemCounts.count(itemId) == 0) continue;
|
|
uint32_t required = 0;
|
|
auto rIt = quest.requiredItemCounts.find(itemId);
|
|
if (rIt != quest.requiredItemCounts.end()) required = rIt->second;
|
|
if (required == 0) {
|
|
for (const auto& obj : quest.itemObjectives) {
|
|
if (obj.itemId == itemId) { required = obj.required; break; }
|
|
}
|
|
}
|
|
if (required == 0) required = count;
|
|
owner_.questProgressCallback_(quest.title, itemLabel, count, required);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (owner_.addonEventCallback_ && updatedAny) {
|
|
owner_.addonEventCallback_("QUEST_WATCH_UPDATE", {});
|
|
owner_.addonEventCallback_("QUEST_LOG_UPDATE", {});
|
|
owner_.addonEventCallback_("UNIT_QUEST_LOG_CHANGED", {"player"});
|
|
}
|
|
LOG_INFO("Quest item update: itemId=", itemId, " count=", count,
|
|
" trackedQuestsUpdated=", updatedAny);
|
|
}
|
|
};
|
|
|
|
// ---- SMSG_QUESTUPDATE_COMPLETE ----
|
|
table[Opcode::SMSG_QUESTUPDATE_COMPLETE] = [this](network::Packet& packet) {
|
|
size_t rem = packet.getRemainingSize();
|
|
if (rem >= 12) {
|
|
uint32_t questId = packet.readUInt32();
|
|
clearPendingQuestAccept(questId);
|
|
uint32_t entry = packet.readUInt32();
|
|
uint32_t count = packet.readUInt32();
|
|
uint32_t reqCount = 0;
|
|
if (packet.hasRemaining(4)) reqCount = packet.readUInt32();
|
|
if (reqCount == 0) reqCount = count;
|
|
LOG_INFO("Quest kill update (compat via COMPLETE): questId=", questId,
|
|
" entry=", entry, " count=", count, "/", reqCount);
|
|
for (auto& quest : questLog_) {
|
|
if (quest.questId == questId) {
|
|
quest.killCounts[entry] = {count, reqCount};
|
|
owner_.addSystemChatMessage(quest.title + ": " + std::to_string(count) +
|
|
"/" + std::to_string(reqCount));
|
|
break;
|
|
}
|
|
}
|
|
} else if (rem >= 4) {
|
|
uint32_t questId = packet.readUInt32();
|
|
clearPendingQuestAccept(questId);
|
|
LOG_INFO("Quest objectives completed: questId=", questId);
|
|
|
|
for (auto& quest : questLog_) {
|
|
if (quest.questId == questId) {
|
|
quest.complete = true;
|
|
owner_.addSystemChatMessage("Quest Complete: " + quest.title);
|
|
LOG_INFO("Marked quest ", questId, " as complete");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// ---- SMSG_QUEST_FORCE_REMOVE ----
|
|
table[Opcode::SMSG_QUEST_FORCE_REMOVE] = [this](network::Packet& packet) {
|
|
if (!packet.hasRemaining(4)) {
|
|
LOG_WARNING("SMSG_QUEST_FORCE_REMOVE/SET_REST_START too short");
|
|
return;
|
|
}
|
|
uint32_t value = packet.readUInt32();
|
|
|
|
// WotLK uses this opcode as SMSG_SET_REST_START
|
|
if (!isClassicLikeExpansion() && !isActiveExpansion("tbc")) {
|
|
bool nowResting = (value != 0);
|
|
if (nowResting != owner_.isResting_) {
|
|
owner_.isResting_ = nowResting;
|
|
owner_.addSystemChatMessage(owner_.isResting_ ? "You are now resting."
|
|
: "You are no longer resting.");
|
|
if (owner_.addonEventCallback_)
|
|
owner_.addonEventCallback_("PLAYER_UPDATE_RESTING", {});
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Classic/TBC: treat as QUEST_FORCE_REMOVE (uint32 questId)
|
|
uint32_t questId = value;
|
|
clearPendingQuestAccept(questId);
|
|
pendingQuestQueryIds_.erase(questId);
|
|
if (questId == 0) {
|
|
return;
|
|
}
|
|
|
|
bool removed = false;
|
|
std::string removedTitle;
|
|
for (auto it = questLog_.begin(); it != questLog_.end(); ++it) {
|
|
if (it->questId == questId) {
|
|
removedTitle = it->title;
|
|
questLog_.erase(it);
|
|
removed = true;
|
|
break;
|
|
}
|
|
}
|
|
if (currentQuestDetails_.questId == questId) {
|
|
questDetailsOpen_ = false;
|
|
questDetailsOpenTime_ = std::chrono::steady_clock::time_point{};
|
|
currentQuestDetails_ = QuestDetailsData{};
|
|
removed = true;
|
|
}
|
|
if (currentQuestRequestItems_.questId == questId) {
|
|
questRequestItemsOpen_ = false;
|
|
currentQuestRequestItems_ = QuestRequestItemsData{};
|
|
removed = true;
|
|
}
|
|
if (currentQuestOfferReward_.questId == questId) {
|
|
questOfferRewardOpen_ = false;
|
|
currentQuestOfferReward_ = QuestOfferRewardData{};
|
|
removed = true;
|
|
}
|
|
if (removed) {
|
|
if (!removedTitle.empty()) {
|
|
owner_.addSystemChatMessage("Quest removed: " + removedTitle);
|
|
} else {
|
|
owner_.addSystemChatMessage("Quest removed (ID " + std::to_string(questId) + ").");
|
|
}
|
|
if (owner_.addonEventCallback_) {
|
|
owner_.addonEventCallback_("QUEST_LOG_UPDATE", {});
|
|
owner_.addonEventCallback_("UNIT_QUEST_LOG_CHANGED", {"player"});
|
|
owner_.addonEventCallback_("QUEST_REMOVED", {std::to_string(questId)});
|
|
}
|
|
}
|
|
};
|
|
|
|
// ---- SMSG_QUEST_QUERY_RESPONSE ----
|
|
table[Opcode::SMSG_QUEST_QUERY_RESPONSE] = [this](network::Packet& packet) {
|
|
if (packet.getSize() < 8) {
|
|
LOG_WARNING("SMSG_QUEST_QUERY_RESPONSE: packet too small (", packet.getSize(), " bytes)");
|
|
return;
|
|
}
|
|
|
|
uint32_t questId = packet.readUInt32();
|
|
packet.readUInt32(); // questMethod
|
|
|
|
const bool isClassicLayout = owner_.packetParsers_ && owner_.packetParsers_->questLogStride() <= 4;
|
|
const QuestQueryTextCandidate parsed = pickBestQuestQueryTexts(packet.getData(), isClassicLayout);
|
|
const QuestQueryObjectives objs = extractQuestQueryObjectives(packet.getData(), isClassicLayout);
|
|
const QuestQueryRewards rwds = tryParseQuestRewards(packet.getData(), isClassicLayout);
|
|
|
|
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() || q.objectives.size() < 16)) {
|
|
q.objectives = parsed.objectives;
|
|
}
|
|
|
|
// Store structured kill/item objectives for later kill-count restoration.
|
|
if (objs.valid) {
|
|
for (int i = 0; i < 4; ++i) {
|
|
q.killObjectives[i].npcOrGoId = objs.kills[i].npcOrGoId;
|
|
q.killObjectives[i].required = objs.kills[i].required;
|
|
}
|
|
for (int i = 0; i < 6; ++i) {
|
|
q.itemObjectives[i].itemId = objs.items[i].itemId;
|
|
q.itemObjectives[i].required = objs.items[i].required;
|
|
}
|
|
applyPackedKillCountsFromFields(q);
|
|
for (int i = 0; i < 4; ++i) {
|
|
int32_t id = objs.kills[i].npcOrGoId;
|
|
if (id == 0 || objs.kills[i].required == 0) continue;
|
|
if (id > 0) owner_.queryCreatureInfo(static_cast<uint32_t>(id), 0);
|
|
else owner_.queryGameObjectInfo(static_cast<uint32_t>(-id), 0);
|
|
}
|
|
for (int i = 0; i < 6; ++i) {
|
|
if (objs.items[i].itemId != 0 && objs.items[i].required != 0)
|
|
owner_.queryItemInfo(objs.items[i].itemId, 0);
|
|
}
|
|
LOG_DEBUG("Quest ", questId, " objectives parsed: kills=[",
|
|
objs.kills[0].npcOrGoId, "/", objs.kills[0].required, ", ",
|
|
objs.kills[1].npcOrGoId, "/", objs.kills[1].required, ", ",
|
|
objs.kills[2].npcOrGoId, "/", objs.kills[2].required, ", ",
|
|
objs.kills[3].npcOrGoId, "/", objs.kills[3].required, "]");
|
|
}
|
|
|
|
// Store reward data and pre-fetch item info for icons.
|
|
if (rwds.valid) {
|
|
q.rewardMoney = rwds.rewardMoney;
|
|
for (int i = 0; i < 4; ++i) {
|
|
q.rewardItems[i].itemId = rwds.itemId[i];
|
|
q.rewardItems[i].count = (rwds.itemId[i] != 0) ? rwds.itemCount[i] : 0;
|
|
if (rwds.itemId[i] != 0) owner_.queryItemInfo(rwds.itemId[i], 0);
|
|
}
|
|
for (int i = 0; i < 6; ++i) {
|
|
q.rewardChoiceItems[i].itemId = rwds.choiceItemId[i];
|
|
q.rewardChoiceItems[i].count = (rwds.choiceItemId[i] != 0) ? rwds.choiceItemCount[i] : 0;
|
|
if (rwds.choiceItemId[i] != 0) owner_.queryItemInfo(rwds.choiceItemId[i], 0);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
pendingQuestQueryIds_.erase(questId);
|
|
};
|
|
|
|
// ---- SMSG_QUESTUPDATE_ADD_PVP_KILL ----
|
|
table[Opcode::SMSG_QUESTUPDATE_ADD_PVP_KILL] = [this](network::Packet& packet) {
|
|
if (packet.hasRemaining(16)) {
|
|
/*uint64_t guid =*/ packet.readUInt64();
|
|
uint32_t questId = packet.readUInt32();
|
|
uint32_t count = packet.readUInt32();
|
|
uint32_t reqCount = 0;
|
|
if (packet.hasRemaining(4)) {
|
|
reqCount = packet.readUInt32();
|
|
}
|
|
|
|
constexpr uint32_t PVP_KILL_ENTRY = 0u;
|
|
for (auto& quest : questLog_) {
|
|
if (quest.questId != questId) continue;
|
|
|
|
if (reqCount == 0) {
|
|
auto it = quest.killCounts.find(PVP_KILL_ENTRY);
|
|
if (it != quest.killCounts.end()) reqCount = it->second.second;
|
|
}
|
|
if (reqCount == 0) {
|
|
for (const auto& obj : quest.killObjectives) {
|
|
if (obj.npcOrGoId == 0 && obj.required > 0) {
|
|
reqCount = obj.required;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (reqCount == 0) reqCount = count;
|
|
quest.killCounts[PVP_KILL_ENTRY] = {count, reqCount};
|
|
|
|
std::string progressMsg = quest.title + ": PvP kills " +
|
|
std::to_string(count) + "/" + std::to_string(reqCount);
|
|
owner_.addSystemChatMessage(progressMsg);
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
|
|
// ---- Completed quests response (moved from GameHandler) ----
|
|
table[Opcode::SMSG_QUERY_QUESTS_COMPLETED_RESPONSE] = [this](network::Packet& packet) {
|
|
if (packet.hasRemaining(4)) {
|
|
uint32_t count = packet.readUInt32();
|
|
if (count <= 4096) {
|
|
for (uint32_t i = 0; i < count; ++i) {
|
|
if (!packet.hasRemaining(4)) break;
|
|
uint32_t questId = packet.readUInt32();
|
|
owner_.completedQuests_.insert(questId);
|
|
}
|
|
LOG_DEBUG("SMSG_QUERY_QUESTS_COMPLETED_RESPONSE: ", count, " completed quests");
|
|
}
|
|
}
|
|
packet.skipAll();
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public API methods
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void QuestHandler::selectGossipOption(uint32_t optionId) {
|
|
if (owner_.state != WorldState::IN_WORLD || !owner_.socket || !gossipWindowOpen_) return;
|
|
LOG_INFO("selectGossipOption: optionId=", optionId,
|
|
" npcGuid=0x", std::hex, currentGossip_.npcGuid, std::dec,
|
|
" menuId=", currentGossip_.menuId,
|
|
" numOptions=", currentGossip_.options.size());
|
|
auto packet = GossipSelectOptionPacket::build(currentGossip_.npcGuid, currentGossip_.menuId, optionId);
|
|
owner_.socket->send(packet);
|
|
|
|
for (const auto& opt : currentGossip_.options) {
|
|
if (opt.id != optionId) continue;
|
|
LOG_INFO(" matched option: id=", opt.id, " icon=", (int)opt.icon, " text='", opt.text, "'");
|
|
|
|
std::string text = opt.text;
|
|
std::string textLower = text;
|
|
std::transform(textLower.begin(), textLower.end(), textLower.begin(),
|
|
[](unsigned char c){ return static_cast<char>(std::tolower(c)); });
|
|
|
|
// Icon- and text-based NPC interaction fallbacks.
|
|
// Use flags to avoid sending the same activation packet twice when
|
|
// both the icon and text match (e.g., banker icon 6 + "deposit box").
|
|
bool sentBanker = false;
|
|
bool sentAuction = false;
|
|
|
|
if (opt.icon == 6) {
|
|
auto pkt = BankerActivatePacket::build(currentGossip_.npcGuid);
|
|
owner_.socket->send(pkt);
|
|
sentBanker = true;
|
|
LOG_INFO("Sent CMSG_BANKER_ACTIVATE (icon) for npc=0x", std::hex, currentGossip_.npcGuid, std::dec);
|
|
}
|
|
|
|
if (!sentAuction && (text == "GOSSIP_OPTION_AUCTIONEER" || textLower.find("auction") != std::string::npos)) {
|
|
auto pkt = AuctionHelloPacket::build(currentGossip_.npcGuid);
|
|
owner_.socket->send(pkt);
|
|
sentAuction = true;
|
|
LOG_INFO("Sent MSG_AUCTION_HELLO for npc=0x", std::hex, currentGossip_.npcGuid, std::dec);
|
|
}
|
|
|
|
if (!sentBanker && (text == "GOSSIP_OPTION_BANKER" || textLower.find("deposit box") != std::string::npos)) {
|
|
auto pkt = BankerActivatePacket::build(currentGossip_.npcGuid);
|
|
owner_.socket->send(pkt);
|
|
sentBanker = true;
|
|
LOG_INFO("Sent CMSG_BANKER_ACTIVATE (text) for npc=0x", std::hex, currentGossip_.npcGuid, std::dec);
|
|
}
|
|
|
|
const bool isVendor = (text == "GOSSIP_OPTION_VENDOR" ||
|
|
(textLower.find("browse") != std::string::npos &&
|
|
(textLower.find("goods") != std::string::npos || textLower.find("wares") != std::string::npos)));
|
|
const bool isArmorer = (text == "GOSSIP_OPTION_ARMORER" || textLower.find("repair") != std::string::npos);
|
|
if (isVendor || isArmorer) {
|
|
if (isArmorer) {
|
|
owner_.setVendorCanRepair(true);
|
|
}
|
|
auto pkt = ListInventoryPacket::build(currentGossip_.npcGuid);
|
|
owner_.socket->send(pkt);
|
|
LOG_INFO("Sent CMSG_LIST_INVENTORY (gossip) to npc=0x", std::hex, currentGossip_.npcGuid, std::dec,
|
|
" vendor=", (int)isVendor, " repair=", (int)isArmorer);
|
|
}
|
|
|
|
if (textLower.find("make this inn your home") != std::string::npos ||
|
|
textLower.find("set your home") != std::string::npos) {
|
|
auto bindPkt = BinderActivatePacket::build(currentGossip_.npcGuid);
|
|
owner_.socket->send(bindPkt);
|
|
LOG_INFO("Sent CMSG_BINDER_ACTIVATE for npc=0x", std::hex, currentGossip_.npcGuid, std::dec);
|
|
}
|
|
|
|
// Stable master detection
|
|
if (text == "GOSSIP_OPTION_STABLE" ||
|
|
textLower.find("stable") != std::string::npos ||
|
|
textLower.find("my pet") != std::string::npos) {
|
|
owner_.stableMasterGuid_ = currentGossip_.npcGuid;
|
|
owner_.stableWindowOpen_ = false;
|
|
auto listPkt = ListStabledPetsPacket::build(currentGossip_.npcGuid);
|
|
owner_.socket->send(listPkt);
|
|
LOG_INFO("Sent MSG_LIST_STABLED_PETS (gossip) to npc=0x",
|
|
std::hex, currentGossip_.npcGuid, std::dec);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
void QuestHandler::selectGossipQuest(uint32_t questId) {
|
|
if (owner_.state != WorldState::IN_WORLD || !owner_.socket || !gossipWindowOpen_) return;
|
|
|
|
const QuestLogEntry* activeQuest = nullptr;
|
|
for (const auto& q : questLog_) {
|
|
if (q.questId == questId) {
|
|
activeQuest = &q;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Validate against server-auth quest slot fields
|
|
auto questInServerLogSlots = [&](uint32_t qid) -> bool {
|
|
if (qid == 0 || owner_.lastPlayerFields_.empty()) return false;
|
|
const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START);
|
|
const uint8_t qStride = owner_.packetParsers_ ? owner_.packetParsers_->questLogStride() : 5;
|
|
const uint16_t ufQuestEnd = ufQuestStart + 25 * qStride;
|
|
for (const auto& [key, val] : owner_.lastPlayerFields_) {
|
|
if (key < ufQuestStart || key >= ufQuestEnd) continue;
|
|
if ((key - ufQuestStart) % qStride != 0) continue;
|
|
if (val == qid) return true;
|
|
}
|
|
return false;
|
|
};
|
|
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;
|
|
const bool shouldStartProgressFlow = activeQuestConfirmedByServer;
|
|
if (shouldStartProgressFlow) {
|
|
pendingTurnInQuestId_ = questId;
|
|
pendingTurnInNpcGuid_ = currentGossip_.npcGuid;
|
|
pendingTurnInRewardRequest_ = activeQuest ? activeQuest->complete : false;
|
|
auto packet = QuestgiverCompleteQuestPacket::build(currentGossip_.npcGuid, questId);
|
|
owner_.socket->send(packet);
|
|
} else {
|
|
pendingTurnInQuestId_ = 0;
|
|
pendingTurnInNpcGuid_ = 0;
|
|
pendingTurnInRewardRequest_ = false;
|
|
auto packet = owner_.packetParsers_
|
|
? owner_.packetParsers_->buildQueryQuestPacket(currentGossip_.npcGuid, questId)
|
|
: QuestgiverQueryQuestPacket::build(currentGossip_.npcGuid, questId);
|
|
owner_.socket->send(packet);
|
|
}
|
|
|
|
gossipWindowOpen_ = false;
|
|
}
|
|
|
|
bool QuestHandler::requestQuestQuery(uint32_t questId, bool force) {
|
|
if (questId == 0 || owner_.state != WorldState::IN_WORLD || !owner_.socket) return false;
|
|
if (!force && pendingQuestQueryIds_.count(questId)) return false;
|
|
|
|
network::Packet pkt(wireOpcode(Opcode::CMSG_QUEST_QUERY));
|
|
pkt.writeUInt32(questId);
|
|
owner_.socket->send(pkt);
|
|
pendingQuestQueryIds_.insert(questId);
|
|
|
|
// WotLK supports CMSG_QUEST_POI_QUERY to get objective map locations.
|
|
if (owner_.packetParsers_ && owner_.packetParsers_->questLogStride() == 5) {
|
|
const uint32_t wirePoiQuery = wireOpcode(Opcode::CMSG_QUEST_POI_QUERY);
|
|
if (wirePoiQuery != 0xFFFF) {
|
|
network::Packet poiPkt(static_cast<uint16_t>(wirePoiQuery));
|
|
poiPkt.writeUInt32(1); // count = 1
|
|
poiPkt.writeUInt32(questId);
|
|
owner_.socket->send(poiPkt);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void QuestHandler::setQuestTracked(uint32_t questId, bool tracked) {
|
|
if (tracked) {
|
|
trackedQuestIds_.insert(questId);
|
|
} else {
|
|
trackedQuestIds_.erase(questId);
|
|
}
|
|
}
|
|
|
|
void QuestHandler::acceptQuest() {
|
|
if (!questDetailsOpen_ || owner_.state != WorldState::IN_WORLD || !owner_.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;
|
|
questDetailsOpenTime_ = std::chrono::steady_clock::time_point{};
|
|
currentQuestDetails_ = QuestDetailsData{};
|
|
return;
|
|
}
|
|
const bool inLocalLog = hasQuestInLog(questId);
|
|
const int serverSlot = findQuestLogSlotIndexFromServer(questId);
|
|
if (serverSlot >= 0) {
|
|
LOG_INFO("Ignoring duplicate quest accept already in server quest log: questId=", questId,
|
|
" slot=", serverSlot);
|
|
questDetailsOpen_ = false;
|
|
questDetailsOpenTime_ = std::chrono::steady_clock::time_point{};
|
|
currentQuestDetails_ = QuestDetailsData{};
|
|
return;
|
|
}
|
|
if (inLocalLog) {
|
|
LOG_WARNING("Quest accept local/server mismatch, allowing re-accept: questId=", questId);
|
|
std::erase_if(questLog_, [&](const QuestLogEntry& q) { return q.questId == questId; });
|
|
}
|
|
|
|
network::Packet packet = owner_.packetParsers_
|
|
? owner_.packetParsers_->buildAcceptQuestPacket(npcGuid, questId)
|
|
: QuestgiverAcceptQuestPacket::build(npcGuid, questId);
|
|
owner_.socket->send(packet);
|
|
pendingQuestAcceptTimeouts_[questId] = 5.0f;
|
|
pendingQuestAcceptNpcGuids_[questId] = npcGuid;
|
|
|
|
// Play quest-accept sound
|
|
if (auto* renderer = owner_.services().renderer) {
|
|
if (auto* sfx = renderer->getUiSoundManager())
|
|
sfx->playQuestActivate();
|
|
}
|
|
|
|
questDetailsOpen_ = false;
|
|
questDetailsOpenTime_ = std::chrono::steady_clock::time_point{};
|
|
currentQuestDetails_ = QuestDetailsData{};
|
|
|
|
// Re-query quest giver status so marker updates (! → ?)
|
|
if (npcGuid) {
|
|
network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY));
|
|
qsPkt.writeUInt64(npcGuid);
|
|
owner_.socket->send(qsPkt);
|
|
}
|
|
}
|
|
|
|
void QuestHandler::declineQuest() {
|
|
questDetailsOpen_ = false;
|
|
questDetailsOpenTime_ = std::chrono::steady_clock::time_point{};
|
|
currentQuestDetails_ = QuestDetailsData{};
|
|
}
|
|
|
|
void QuestHandler::closeGossip() {
|
|
gossipWindowOpen_ = false;
|
|
if (owner_.addonEventCallback_) owner_.addonEventCallback_("GOSSIP_CLOSED", {});
|
|
currentGossip_ = GossipMessageData{};
|
|
}
|
|
|
|
void QuestHandler::offerQuestFromItem(uint64_t itemGuid, uint32_t questId) {
|
|
if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return;
|
|
if (itemGuid == 0 || questId == 0) {
|
|
owner_.addSystemChatMessage("Cannot start quest right now.");
|
|
return;
|
|
}
|
|
// Send CMSG_QUESTGIVER_QUERY_QUEST with the item GUID as the "questgiver."
|
|
// The server responds with SMSG_QUESTGIVER_QUEST_DETAILS which handleQuestDetails()
|
|
// picks up and opens the Accept/Decline dialog.
|
|
auto queryPkt = owner_.packetParsers_
|
|
? owner_.packetParsers_->buildQueryQuestPacket(itemGuid, questId)
|
|
: QuestgiverQueryQuestPacket::build(itemGuid, questId);
|
|
owner_.socket->send(queryPkt);
|
|
LOG_INFO("offerQuestFromItem: itemGuid=0x", std::hex, itemGuid, std::dec,
|
|
" questId=", questId);
|
|
}
|
|
|
|
void QuestHandler::completeQuest() {
|
|
if (!questRequestItemsOpen_ || owner_.state != WorldState::IN_WORLD || !owner_.socket) return;
|
|
pendingTurnInQuestId_ = currentQuestRequestItems_.questId;
|
|
pendingTurnInNpcGuid_ = currentQuestRequestItems_.npcGuid;
|
|
pendingTurnInRewardRequest_ = currentQuestRequestItems_.isCompletable();
|
|
|
|
auto packet = QuestgiverCompleteQuestPacket::build(
|
|
currentQuestRequestItems_.npcGuid, currentQuestRequestItems_.questId);
|
|
owner_.socket->send(packet);
|
|
questRequestItemsOpen_ = false;
|
|
currentQuestRequestItems_ = QuestRequestItemsData{};
|
|
}
|
|
|
|
void QuestHandler::closeQuestRequestItems() {
|
|
pendingTurnInRewardRequest_ = false;
|
|
questRequestItemsOpen_ = false;
|
|
currentQuestRequestItems_ = QuestRequestItemsData{};
|
|
}
|
|
|
|
void QuestHandler::chooseQuestReward(uint32_t rewardIndex) {
|
|
if (!questOfferRewardOpen_ || owner_.state != WorldState::IN_WORLD || !owner_.socket) return;
|
|
uint64_t npcGuid = currentQuestOfferReward_.npcGuid;
|
|
LOG_INFO("Completing quest: questId=", currentQuestOfferReward_.questId,
|
|
" npcGuid=", npcGuid, " rewardIndex=", rewardIndex);
|
|
auto packet = QuestgiverChooseRewardPacket::build(
|
|
npcGuid, currentQuestOfferReward_.questId, rewardIndex);
|
|
owner_.socket->send(packet);
|
|
pendingTurnInQuestId_ = 0;
|
|
pendingTurnInNpcGuid_ = 0;
|
|
pendingTurnInRewardRequest_ = false;
|
|
questOfferRewardOpen_ = false;
|
|
currentQuestOfferReward_ = QuestOfferRewardData{};
|
|
|
|
// Re-query quest giver status so markers update
|
|
if (npcGuid) {
|
|
network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY));
|
|
qsPkt.writeUInt64(npcGuid);
|
|
owner_.socket->send(qsPkt);
|
|
}
|
|
}
|
|
|
|
void QuestHandler::closeQuestOfferReward() {
|
|
pendingTurnInRewardRequest_ = false;
|
|
questOfferRewardOpen_ = false;
|
|
currentQuestOfferReward_ = QuestOfferRewardData{};
|
|
}
|
|
|
|
void QuestHandler::abandonQuest(uint32_t questId) {
|
|
clearPendingQuestAccept(questId);
|
|
int localIndex = -1;
|
|
for (size_t i = 0; i < questLog_.size(); ++i) {
|
|
if (questLog_[i].questId == questId) {
|
|
localIndex = static_cast<int>(i);
|
|
break;
|
|
}
|
|
}
|
|
|
|
int slotIndex = findQuestLogSlotIndexFromServer(questId);
|
|
if (slotIndex < 0 && localIndex >= 0) {
|
|
slotIndex = localIndex;
|
|
LOG_WARNING("Abandon quest using local slot fallback: questId=", questId, " slot=", slotIndex);
|
|
}
|
|
|
|
if (slotIndex >= 0 && slotIndex < 25) {
|
|
if (owner_.state == WorldState::IN_WORLD && owner_.socket) {
|
|
network::Packet pkt(wireOpcode(Opcode::CMSG_QUESTLOG_REMOVE_QUEST));
|
|
pkt.writeUInt8(static_cast<uint8_t>(slotIndex));
|
|
owner_.socket->send(pkt);
|
|
}
|
|
} else {
|
|
LOG_WARNING("Abandon quest failed: no quest-log slot found for questId=", questId);
|
|
}
|
|
|
|
if (localIndex >= 0) {
|
|
questLog_.erase(questLog_.begin() + static_cast<ptrdiff_t>(localIndex));
|
|
if (owner_.addonEventCallback_) {
|
|
owner_.addonEventCallback_("QUEST_LOG_UPDATE", {});
|
|
owner_.addonEventCallback_("UNIT_QUEST_LOG_CHANGED", {"player"});
|
|
owner_.addonEventCallback_("QUEST_REMOVED", {std::to_string(questId)});
|
|
}
|
|
}
|
|
|
|
// Remove any quest POI minimap markers for this quest.
|
|
gossipPois_.erase(
|
|
std::remove_if(gossipPois_.begin(), gossipPois_.end(),
|
|
[questId](const GossipPoi& p) { return p.data == questId; }),
|
|
gossipPois_.end());
|
|
}
|
|
|
|
void QuestHandler::shareQuestWithParty(uint32_t questId) {
|
|
if (owner_.state != WorldState::IN_WORLD || !owner_.socket) {
|
|
owner_.addSystemChatMessage("Cannot share quest: not in world.");
|
|
return;
|
|
}
|
|
if (!owner_.isInGroup()) {
|
|
owner_.addSystemChatMessage("You must be in a group to share a quest.");
|
|
return;
|
|
}
|
|
network::Packet pkt(wireOpcode(Opcode::CMSG_PUSHQUESTTOPARTY));
|
|
pkt.writeUInt32(questId);
|
|
owner_.socket->send(pkt);
|
|
// Local feedback: find quest title
|
|
for (const auto& q : questLog_) {
|
|
if (q.questId == questId && !q.title.empty()) {
|
|
owner_.addSystemChatMessage("Sharing quest: " + q.title);
|
|
return;
|
|
}
|
|
}
|
|
owner_.addSystemChatMessage("Quest shared.");
|
|
}
|
|
|
|
void QuestHandler::acceptSharedQuest() {
|
|
if (!pendingSharedQuest_ || !owner_.socket) return;
|
|
pendingSharedQuest_ = false;
|
|
network::Packet pkt(wireOpcode(Opcode::CMSG_QUEST_CONFIRM_ACCEPT));
|
|
pkt.writeUInt32(sharedQuestId_);
|
|
owner_.socket->send(pkt);
|
|
owner_.addSystemChatMessage("Accepted: " + sharedQuestTitle_);
|
|
}
|
|
|
|
void QuestHandler::declineSharedQuest() {
|
|
pendingSharedQuest_ = false;
|
|
// No response packet needed — just dismiss the UI
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Internal helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
bool QuestHandler::hasQuestInLog(uint32_t questId) const {
|
|
for (const auto& q : questLog_) {
|
|
if (q.questId == questId) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
int QuestHandler::findQuestLogSlotIndexFromServer(uint32_t questId) const {
|
|
if (questId == 0 || owner_.lastPlayerFields_.empty()) return -1;
|
|
const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START);
|
|
const uint8_t qStride = owner_.packetParsers_ ? owner_.packetParsers_->questLogStride() : 5;
|
|
for (uint16_t slot = 0; slot < 25; ++slot) {
|
|
const uint16_t idField = ufQuestStart + slot * qStride;
|
|
auto it = owner_.lastPlayerFields_.find(idField);
|
|
if (it != owner_.lastPlayerFields_.end() && it->second == questId) {
|
|
return static_cast<int>(slot);
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
void QuestHandler::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));
|
|
if (owner_.addonEventCallback_) {
|
|
owner_.addonEventCallback_("QUEST_ACCEPTED", {std::to_string(questId)});
|
|
owner_.addonEventCallback_("QUEST_LOG_UPDATE", {});
|
|
owner_.addonEventCallback_("UNIT_QUEST_LOG_CHANGED", {"player"});
|
|
}
|
|
}
|
|
|
|
bool QuestHandler::resyncQuestLogFromServerSlots(bool forceQueryMetadata) {
|
|
if (owner_.lastPlayerFields_.empty()) return false;
|
|
|
|
const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START);
|
|
const uint8_t qStride = owner_.packetParsers_ ? owner_.packetParsers_->questLogStride() : 5;
|
|
|
|
static constexpr uint32_t kQuestStatusComplete = 1;
|
|
|
|
std::unordered_map<uint32_t, bool> serverQuestComplete;
|
|
serverQuestComplete.reserve(25);
|
|
for (uint16_t slot = 0; slot < 25; ++slot) {
|
|
const uint16_t idField = ufQuestStart + slot * qStride;
|
|
const uint16_t stateField = ufQuestStart + slot * qStride + 1;
|
|
auto it = owner_.lastPlayerFields_.find(idField);
|
|
if (it == owner_.lastPlayerFields_.end()) continue;
|
|
uint32_t questId = it->second;
|
|
if (questId == 0) continue;
|
|
|
|
bool complete = false;
|
|
if (qStride >= 2) {
|
|
auto stateIt = owner_.lastPlayerFields_.find(stateField);
|
|
if (stateIt != owner_.lastPlayerFields_.end()) {
|
|
uint32_t state = stateIt->second & 0xFF;
|
|
complete = (state == kQuestStatusComplete);
|
|
}
|
|
}
|
|
serverQuestComplete[questId] = complete;
|
|
}
|
|
|
|
std::unordered_set<uint32_t> serverQuestIds;
|
|
serverQuestIds.reserve(serverQuestComplete.size());
|
|
for (const auto& [qid, _] : serverQuestComplete) serverQuestIds.insert(qid);
|
|
|
|
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;
|
|
}
|
|
|
|
size_t marked = 0;
|
|
for (auto& quest : questLog_) {
|
|
auto it = serverQuestComplete.find(quest.questId);
|
|
if (it == serverQuestComplete.end()) continue;
|
|
if (it->second && !quest.complete) {
|
|
quest.complete = true;
|
|
++marked;
|
|
LOG_DEBUG("Quest ", quest.questId, " marked complete from update fields");
|
|
}
|
|
}
|
|
|
|
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,
|
|
" markedComplete=", marked);
|
|
return true;
|
|
}
|
|
|
|
void QuestHandler::applyQuestStateFromFields(const std::map<uint16_t, uint32_t>& fields) {
|
|
const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START);
|
|
if (ufQuestStart == 0xFFFF || questLog_.empty()) return;
|
|
|
|
const uint8_t qStride = owner_.packetParsers_ ? owner_.packetParsers_->questLogStride() : 5;
|
|
if (qStride < 2) return;
|
|
|
|
static constexpr uint32_t kQuestStatusComplete = 1;
|
|
|
|
for (uint16_t slot = 0; slot < 25; ++slot) {
|
|
const uint16_t idField = ufQuestStart + slot * qStride;
|
|
const uint16_t stateField = idField + 1;
|
|
auto idIt = fields.find(idField);
|
|
if (idIt == fields.end()) continue;
|
|
uint32_t questId = idIt->second;
|
|
if (questId == 0) continue;
|
|
|
|
auto stateIt = fields.find(stateField);
|
|
if (stateIt == fields.end()) continue;
|
|
bool serverComplete = ((stateIt->second & 0xFF) == kQuestStatusComplete);
|
|
if (!serverComplete) continue;
|
|
|
|
for (auto& quest : questLog_) {
|
|
if (quest.questId == questId && !quest.complete) {
|
|
quest.complete = true;
|
|
LOG_INFO("Quest ", questId, " marked complete from VALUES update field state");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void QuestHandler::applyPackedKillCountsFromFields(QuestLogEntry& quest) {
|
|
if (owner_.lastPlayerFields_.empty()) return;
|
|
|
|
const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START);
|
|
if (ufQuestStart == 0xFFFF) return;
|
|
|
|
const uint8_t qStride = owner_.packetParsers_ ? owner_.packetParsers_->questLogStride() : 5;
|
|
if (qStride < 3) return;
|
|
|
|
int slot = findQuestLogSlotIndexFromServer(quest.questId);
|
|
if (slot < 0) return;
|
|
|
|
const uint16_t countField1 = ufQuestStart + static_cast<uint16_t>(slot) * qStride + 2;
|
|
const uint16_t countField2 = (qStride >= 5)
|
|
? static_cast<uint16_t>(countField1 + 1)
|
|
: static_cast<uint16_t>(0xFFFF);
|
|
|
|
auto f1It = owner_.lastPlayerFields_.find(countField1);
|
|
if (f1It == owner_.lastPlayerFields_.end()) return;
|
|
const uint32_t packed1 = f1It->second;
|
|
|
|
uint32_t packed2 = 0;
|
|
if (countField2 != 0xFFFF) {
|
|
auto f2It = owner_.lastPlayerFields_.find(countField2);
|
|
if (f2It != owner_.lastPlayerFields_.end()) packed2 = f2It->second;
|
|
}
|
|
|
|
auto unpack6 = [](uint32_t word, int idx) -> uint8_t {
|
|
return static_cast<uint8_t>((word >> (idx * 6)) & 0x3F);
|
|
};
|
|
const uint8_t counts[6] = {
|
|
unpack6(packed1, 0), unpack6(packed1, 1),
|
|
unpack6(packed1, 2), unpack6(packed1, 3),
|
|
unpack6(packed2, 0), unpack6(packed2, 1),
|
|
};
|
|
|
|
// Apply kill objective counts (indices 0-3).
|
|
for (int i = 0; i < 4; ++i) {
|
|
const auto& obj = quest.killObjectives[i];
|
|
if (obj.npcOrGoId == 0 || obj.required == 0) continue;
|
|
const uint32_t entryKey = static_cast<uint32_t>(
|
|
obj.npcOrGoId > 0 ? obj.npcOrGoId : -obj.npcOrGoId);
|
|
if (counts[i] == 0 && quest.killCounts.count(entryKey)) continue;
|
|
quest.killCounts[entryKey] = {counts[i], obj.required};
|
|
LOG_DEBUG("Quest ", quest.questId, " objective[", i, "]: npcOrGo=",
|
|
obj.npcOrGoId, " count=", (int)counts[i], "/", obj.required);
|
|
}
|
|
|
|
// Apply item objective counts (WotLK only).
|
|
for (int i = 0; i < 6; ++i) {
|
|
const auto& obj = quest.itemObjectives[i];
|
|
if (obj.itemId == 0 || obj.required == 0) continue;
|
|
if (i < 2 && qStride >= 5) {
|
|
uint8_t cnt = counts[4 + i];
|
|
if (cnt > 0) {
|
|
quest.itemCounts[obj.itemId] = std::max(quest.itemCounts[obj.itemId], static_cast<uint32_t>(cnt));
|
|
}
|
|
}
|
|
quest.requiredItemCounts.emplace(obj.itemId, obj.required);
|
|
}
|
|
}
|
|
|
|
void QuestHandler::clearPendingQuestAccept(uint32_t questId) {
|
|
pendingQuestAcceptTimeouts_.erase(questId);
|
|
pendingQuestAcceptNpcGuids_.erase(questId);
|
|
}
|
|
|
|
void QuestHandler::triggerQuestAcceptResync(uint32_t questId, uint64_t npcGuid, const char* reason) {
|
|
if (questId == 0 || !owner_.socket || owner_.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);
|
|
owner_.socket->send(qsPkt);
|
|
|
|
auto queryPkt = owner_.packetParsers_
|
|
? owner_.packetParsers_->buildQueryQuestPacket(npcGuid, questId)
|
|
: QuestgiverQueryQuestPacket::build(npcGuid, questId);
|
|
owner_.socket->send(queryPkt);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Packet handlers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void QuestHandler::handleGossipMessage(network::Packet& packet) {
|
|
bool ok = owner_.packetParsers_ ? owner_.packetParsers_->parseGossipMessage(packet, currentGossip_)
|
|
: GossipMessageParser::parse(packet, currentGossip_);
|
|
if (!ok) return;
|
|
if (questDetailsOpen_) return; // Don't reopen gossip while viewing quest
|
|
gossipWindowOpen_ = true;
|
|
if (owner_.addonEventCallback_) owner_.addonEventCallback_("GOSSIP_SHOW", {});
|
|
owner_.closeVendor(); // Close vendor if gossip opens
|
|
|
|
// Classify gossip quests and update quest log + overhead NPC markers.
|
|
classifyGossipQuests(true);
|
|
|
|
// Play NPC greeting voice
|
|
if (owner_.npcGreetingCallback_ && currentGossip_.npcGuid != 0) {
|
|
auto entity = owner_.getEntityManager().getEntity(currentGossip_.npcGuid);
|
|
if (entity) {
|
|
glm::vec3 npcPos(entity->getX(), entity->getY(), entity->getZ());
|
|
owner_.npcGreetingCallback_(currentGossip_.npcGuid, npcPos);
|
|
}
|
|
}
|
|
}
|
|
|
|
void QuestHandler::handleQuestgiverQuestList(network::Packet& packet) {
|
|
if (!packet.hasRemaining(8)) return;
|
|
|
|
GossipMessageData data;
|
|
data.npcGuid = packet.readUInt64();
|
|
data.menuId = 0;
|
|
data.titleTextId = 0;
|
|
|
|
std::string header = packet.readString();
|
|
if (packet.hasRemaining(8)) {
|
|
(void)packet.readUInt32(); // emoteDelay / unk
|
|
(void)packet.readUInt32(); // emote / unk
|
|
}
|
|
(void)header;
|
|
|
|
// questCount is uint8 in all WoW versions for SMSG_QUESTGIVER_QUEST_LIST.
|
|
uint32_t questCount = 0;
|
|
if (packet.hasRemaining(1)) {
|
|
questCount = packet.readUInt8();
|
|
}
|
|
|
|
const bool hasQuestFlagsField = !isClassicLikeExpansion() && !isActiveExpansion("tbc");
|
|
|
|
data.quests.reserve(questCount);
|
|
for (uint32_t i = 0; i < questCount; ++i) {
|
|
if (!packet.hasRemaining(12)) break;
|
|
GossipQuestItem q;
|
|
q.questId = packet.readUInt32();
|
|
q.questIcon = packet.readUInt32();
|
|
q.questLevel = static_cast<int32_t>(packet.readUInt32());
|
|
|
|
if (hasQuestFlagsField && packet.hasRemaining(5)) {
|
|
q.questFlags = packet.readUInt32();
|
|
q.isRepeatable = packet.readUInt8();
|
|
} else {
|
|
q.questFlags = 0;
|
|
q.isRepeatable = 0;
|
|
}
|
|
q.title = normalizeWowTextTokens(packet.readString());
|
|
if (q.questId != 0) {
|
|
data.quests.push_back(std::move(q));
|
|
}
|
|
}
|
|
|
|
currentGossip_ = std::move(data);
|
|
gossipWindowOpen_ = true;
|
|
if (owner_.addonEventCallback_) owner_.addonEventCallback_("GOSSIP_SHOW", {});
|
|
owner_.closeVendor();
|
|
|
|
classifyGossipQuests(false);
|
|
|
|
LOG_INFO("Questgiver quest list: npc=0x", std::hex, currentGossip_.npcGuid, std::dec,
|
|
" quests=", currentGossip_.quests.size());
|
|
}
|
|
|
|
// Shared quest-icon classification for gossip windows. Derives NPC quest status
|
|
// from icon values so overhead markers stay aligned with what the NPC offers.
|
|
// updateQuestLog: if true, also patches quest log completion state (gossip handler
|
|
// does this because it has the freshest data; quest-list handler skips it because
|
|
// completion updates arrive via separate packets).
|
|
void QuestHandler::classifyGossipQuests(bool updateQuestLog) {
|
|
// Icon values come from the server's QUEST_STATUS enum, not a client constant,
|
|
// so these magic numbers are protocol-defined and stable across expansions.
|
|
auto isCompletable = [](uint32_t icon) { return icon == 5 || icon == 6 || icon == 10; };
|
|
auto isIncomplete = [](uint32_t icon) { return icon == 3 || icon == 4; };
|
|
auto isAvailable = [](uint32_t icon) { return icon == 2 || icon == 7 || icon == 8; };
|
|
|
|
bool hasAvailable = false, hasReward = false, hasIncomplete = false;
|
|
for (const auto& q : currentGossip_.quests) {
|
|
bool completable = isCompletable(q.questIcon);
|
|
bool incomplete = isIncomplete(q.questIcon);
|
|
bool available = isAvailable(q.questIcon);
|
|
hasAvailable |= available;
|
|
hasReward |= completable;
|
|
hasIncomplete |= incomplete;
|
|
|
|
if (updateQuestLog) {
|
|
for (auto& entry : questLog_) {
|
|
if (entry.questId == q.questId) {
|
|
entry.complete = completable;
|
|
entry.title = q.title;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (currentGossip_.npcGuid != 0) {
|
|
QuestGiverStatus status = QuestGiverStatus::NONE;
|
|
if (hasReward) status = QuestGiverStatus::REWARD;
|
|
else if (hasAvailable) status = QuestGiverStatus::AVAILABLE;
|
|
else if (hasIncomplete) status = QuestGiverStatus::INCOMPLETE;
|
|
if (status != QuestGiverStatus::NONE)
|
|
npcQuestStatus_[currentGossip_.npcGuid] = status;
|
|
}
|
|
}
|
|
|
|
void QuestHandler::handleGossipComplete(network::Packet& packet) {
|
|
(void)packet;
|
|
|
|
// Play farewell sound before closing
|
|
if (owner_.npcFarewellCallback_ && currentGossip_.npcGuid != 0) {
|
|
auto entity = owner_.getEntityManager().getEntity(currentGossip_.npcGuid);
|
|
if (entity && entity->getType() == ObjectType::UNIT) {
|
|
glm::vec3 pos(entity->getX(), entity->getY(), entity->getZ());
|
|
owner_.npcFarewellCallback_(currentGossip_.npcGuid, pos);
|
|
}
|
|
}
|
|
|
|
gossipWindowOpen_ = false;
|
|
if (owner_.addonEventCallback_) owner_.addonEventCallback_("GOSSIP_CLOSED", {});
|
|
currentGossip_ = GossipMessageData{};
|
|
}
|
|
|
|
void QuestHandler::handleQuestPoiQueryResponse(network::Packet& packet) {
|
|
// WotLK 3.3.5a SMSG_QUEST_POI_QUERY_RESPONSE format:
|
|
// uint32 questCount
|
|
// per quest:
|
|
// uint32 questId
|
|
// uint32 poiCount
|
|
// per poi:
|
|
// uint32 poiId
|
|
// int32 objIndex (-1 = no specific objective)
|
|
// uint32 mapId
|
|
// uint32 areaId
|
|
// uint32 floorId
|
|
// uint32 unk1
|
|
// uint32 unk2
|
|
// uint32 pointCount
|
|
// per point: int32 x, int32 y
|
|
if (!packet.hasRemaining(4)) return;
|
|
const uint32_t questCount = packet.readUInt32();
|
|
for (uint32_t qi = 0; qi < questCount; ++qi) {
|
|
if (!packet.hasRemaining(8)) return;
|
|
const uint32_t questId = packet.readUInt32();
|
|
const uint32_t poiCount = packet.readUInt32();
|
|
|
|
// Remove any previously added POI markers for this quest
|
|
gossipPois_.erase(
|
|
std::remove_if(gossipPois_.begin(), gossipPois_.end(),
|
|
[questId](const GossipPoi& p) {
|
|
return p.data == questId;
|
|
}),
|
|
gossipPois_.end());
|
|
|
|
// Find the quest title for the marker label.
|
|
std::string questTitle;
|
|
for (const auto& q : questLog_) {
|
|
if (q.questId == questId) { questTitle = q.title; break; }
|
|
}
|
|
|
|
for (uint32_t pi = 0; pi < poiCount; ++pi) {
|
|
if (!packet.hasRemaining(28)) return;
|
|
packet.readUInt32(); // poiId
|
|
packet.readUInt32(); // objIndex (int32)
|
|
const uint32_t mapId = packet.readUInt32();
|
|
packet.readUInt32(); // areaId
|
|
packet.readUInt32(); // floorId
|
|
packet.readUInt32(); // unk1
|
|
packet.readUInt32(); // unk2
|
|
const uint32_t pointCount = packet.readUInt32();
|
|
if (pointCount == 0) continue;
|
|
if (packet.getRemainingSize() < pointCount * 8) return;
|
|
float sumX = 0.0f, sumY = 0.0f;
|
|
for (uint32_t pt = 0; pt < pointCount; ++pt) {
|
|
const int32_t px = static_cast<int32_t>(packet.readUInt32());
|
|
const int32_t py = static_cast<int32_t>(packet.readUInt32());
|
|
sumX += static_cast<float>(px);
|
|
sumY += static_cast<float>(py);
|
|
}
|
|
// Skip POIs for maps other than the player's current map.
|
|
if (mapId != owner_.currentMapId_) continue;
|
|
GossipPoi poi;
|
|
poi.x = sumX / static_cast<float>(pointCount);
|
|
poi.y = sumY / static_cast<float>(pointCount);
|
|
poi.icon = 6; // generic quest POI icon
|
|
poi.data = questId;
|
|
poi.name = questTitle.empty() ? "Quest objective" : questTitle;
|
|
LOG_DEBUG("Quest POI: questId=", questId, " mapId=", mapId,
|
|
" centroid=(", poi.x, ",", poi.y, ") title=", poi.name);
|
|
if (gossipPois_.size() >= 200) gossipPois_.erase(gossipPois_.begin());
|
|
gossipPois_.push_back(std::move(poi));
|
|
}
|
|
}
|
|
}
|
|
|
|
void QuestHandler::handleQuestDetails(network::Packet& packet) {
|
|
QuestDetailsData data;
|
|
bool ok = owner_.packetParsers_ ? owner_.packetParsers_->parseQuestDetails(packet, data)
|
|
: QuestDetailsParser::parse(packet, data);
|
|
if (!ok) {
|
|
LOG_WARNING("Failed to parse SMSG_QUESTGIVER_QUEST_DETAILS");
|
|
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;
|
|
}
|
|
// Pre-fetch item info for all reward items
|
|
for (const auto& item : data.rewardChoiceItems) owner_.queryItemInfo(item.itemId, 0);
|
|
for (const auto& item : data.rewardItems) owner_.queryItemInfo(item.itemId, 0);
|
|
// Delay opening the window slightly to allow item queries to complete
|
|
questDetailsOpenTime_ = std::chrono::steady_clock::now() + std::chrono::milliseconds(100);
|
|
gossipWindowOpen_ = false;
|
|
if (owner_.addonEventCallback_) owner_.addonEventCallback_("QUEST_DETAIL", {});
|
|
}
|
|
|
|
void QuestHandler::handleQuestRequestItems(network::Packet& packet) {
|
|
QuestRequestItemsData data;
|
|
if (!QuestRequestItemsParser::parse(packet, data)) {
|
|
LOG_WARNING("Failed to parse SMSG_QUESTGIVER_REQUEST_ITEMS");
|
|
return;
|
|
}
|
|
clearPendingQuestAccept(data.questId);
|
|
|
|
if (pendingTurnInRewardRequest_ &&
|
|
data.questId == pendingTurnInQuestId_ &&
|
|
data.npcGuid == pendingTurnInNpcGuid_ &&
|
|
data.isCompletable() &&
|
|
owner_.socket) {
|
|
auto rewardReq = QuestgiverRequestRewardPacket::build(data.npcGuid, data.questId);
|
|
owner_.socket->send(rewardReq);
|
|
pendingTurnInRewardRequest_ = false;
|
|
}
|
|
|
|
currentQuestRequestItems_ = data;
|
|
questRequestItemsOpen_ = true;
|
|
gossipWindowOpen_ = false;
|
|
questDetailsOpen_ = false;
|
|
questDetailsOpenTime_ = std::chrono::steady_clock::time_point{};
|
|
|
|
// Query item names for required items
|
|
for (const auto& item : data.requiredItems) {
|
|
owner_.queryItemInfo(item.itemId, 0);
|
|
}
|
|
|
|
// Server-authoritative turn-in requirements
|
|
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 = owner_.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 QuestHandler::handleQuestOfferReward(network::Packet& packet) {
|
|
QuestOfferRewardData data;
|
|
if (!QuestOfferRewardParser::parse(packet, data)) {
|
|
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;
|
|
pendingTurnInNpcGuid_ = 0;
|
|
pendingTurnInRewardRequest_ = false;
|
|
}
|
|
currentQuestOfferReward_ = data;
|
|
questOfferRewardOpen_ = true;
|
|
questRequestItemsOpen_ = false;
|
|
gossipWindowOpen_ = false;
|
|
questDetailsOpen_ = false;
|
|
questDetailsOpenTime_ = std::chrono::steady_clock::time_point{};
|
|
if (owner_.addonEventCallback_) owner_.addonEventCallback_("QUEST_COMPLETE", {});
|
|
|
|
// Query item names for reward items
|
|
for (const auto& item : data.choiceRewards)
|
|
owner_.queryItemInfo(item.itemId, 0);
|
|
for (const auto& item : data.fixedRewards)
|
|
owner_.queryItemInfo(item.itemId, 0);
|
|
}
|
|
|
|
void QuestHandler::handleQuestConfirmAccept(network::Packet& packet) {
|
|
size_t rem = packet.getRemainingSize();
|
|
if (rem < 4) return;
|
|
|
|
sharedQuestId_ = packet.readUInt32();
|
|
sharedQuestTitle_ = packet.readString();
|
|
if (packet.hasRemaining(8)) {
|
|
sharedQuestSharerGuid_ = packet.readUInt64();
|
|
}
|
|
|
|
sharedQuestSharerName_.clear();
|
|
auto entity = owner_.getEntityManager().getEntity(sharedQuestSharerGuid_);
|
|
if (auto* unit = dynamic_cast<Unit*>(entity.get())) {
|
|
sharedQuestSharerName_ = unit->getName();
|
|
}
|
|
if (sharedQuestSharerName_.empty()) {
|
|
auto nit = owner_.getPlayerNameCache().find(sharedQuestSharerGuid_);
|
|
if (nit != owner_.getPlayerNameCache().end())
|
|
sharedQuestSharerName_ = nit->second;
|
|
}
|
|
if (sharedQuestSharerName_.empty()) {
|
|
char tmp[32];
|
|
std::snprintf(tmp, sizeof(tmp), "0x%llX",
|
|
static_cast<unsigned long long>(sharedQuestSharerGuid_));
|
|
sharedQuestSharerName_ = tmp;
|
|
}
|
|
|
|
pendingSharedQuest_ = true;
|
|
owner_.addSystemChatMessage(sharedQuestSharerName_ + " has shared the quest \"" +
|
|
sharedQuestTitle_ + "\" with you.");
|
|
LOG_INFO("SMSG_QUEST_CONFIRM_ACCEPT: questId=", sharedQuestId_,
|
|
" title=", sharedQuestTitle_, " sharer=", sharedQuestSharerName_);
|
|
}
|
|
|
|
} // namespace game
|
|
} // namespace wowee
|