diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 07f67734..f81e56d8 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -495,6 +495,8 @@ public: std::string title; std::string objectives; bool complete = false; + // Objective kill counts: objectiveIndex -> (current, required) + std::unordered_map> killCounts; }; const std::vector& getQuestLog() const { return questLog_; } void abandonQuest(uint32_t questId); @@ -697,7 +699,9 @@ private: void handleCooldownEvent(network::Packet& packet); void handleAuraUpdate(network::Packet& packet, bool isAll); void handleLearnedSpell(network::Packet& packet); + void handleSupercededSpell(network::Packet& packet); void handleRemovedSpell(network::Packet& packet); + void handleUnlearnSpells(network::Packet& packet); // ---- Phase 4 handlers ---- void handleGroupInvite(network::Packet& packet); diff --git a/include/game/opcodes.hpp b/include/game/opcodes.hpp index 1ab28a41..28cd7d45 100644 --- a/include/game/opcodes.hpp +++ b/include/game/opcodes.hpp @@ -159,7 +159,9 @@ enum class Opcode : uint16_t { SMSG_UPDATE_AURA_DURATION = 0x137, SMSG_INITIAL_SPELLS = 0x12A, SMSG_LEARNED_SPELL = 0x12B, + SMSG_SUPERCEDED_SPELL = 0x12C, SMSG_REMOVED_SPELL = 0x203, + SMSG_SEND_UNLEARN_SPELLS = 0x41F, SMSG_SPELL_DELAYED = 0x1E2, SMSG_AURA_UPDATE = 0x3FA, SMSG_AURA_UPDATE_ALL = 0x495, @@ -223,8 +225,11 @@ enum class Opcode : uint16_t { SMSG_QUESTGIVER_QUEST_INVALID = 0x18F, SMSG_QUESTGIVER_QUEST_COMPLETE = 0x191, CMSG_QUESTLOG_REMOVE_QUEST = 0x194, + SMSG_QUESTUPDATE_ADD_KILL = 0x196, // Quest kill count update + SMSG_QUESTUPDATE_COMPLETE = 0x195, // Quest objectives completed CMSG_QUEST_QUERY = 0x05C, // Client requests quest data SMSG_QUEST_QUERY_RESPONSE = 0x05D, // Server sends quest data + SMSG_QUESTLOG_FULL = 0x1A3, // Full quest log on login // ---- Phase 5: Vendor ---- CMSG_LIST_INVENTORY = 0x19E, @@ -235,8 +240,10 @@ enum class Opcode : uint16_t { SMSG_BUY_FAILED = 0x1A5, // ---- Trainer ---- + CMSG_TRAINER_LIST = 0x01B0, SMSG_TRAINER_LIST = 0x01B1, CMSG_TRAINER_BUY_SPELL = 0x01B2, + SMSG_TRAINER_BUY_FAILED = 0x01B4, // ---- Phase 5: Item/Equip ---- CMSG_ITEM_QUERY_SINGLE = 0x056, diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 7f6ae490..be36ffb2 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1750,7 +1750,7 @@ public: class TrainerBuySpellPacket { public: - static network::Packet build(uint64_t trainerGuid, uint32_t trainerId, uint32_t spellId); + static network::Packet build(uint64_t trainerGuid, uint32_t spellId); }; // ============================================================ diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 24b2da5c..5917466e 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -579,9 +579,15 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_LEARNED_SPELL: handleLearnedSpell(packet); break; + case Opcode::SMSG_SUPERCEDED_SPELL: + handleSupercededSpell(packet); + break; case Opcode::SMSG_REMOVED_SPELL: handleRemovedSpell(packet); break; + case Opcode::SMSG_SEND_UNLEARN_SPELLS: + handleUnlearnSpells(packet); + break; // ---- Phase 4: Group ---- case Opcode::SMSG_GROUP_INVITE: @@ -682,6 +688,15 @@ void GameHandler::handlePacket(network::Packet& packet) { uint64_t guid = packet.readUInt64(); uint32_t spellId = packet.readUInt32(); (void)guid; + + // Add to known spells immediately for prerequisite re-evaluation + // (SMSG_LEARNED_SPELL may come separately, but we need immediate update) + bool alreadyKnown = std::find(knownSpells.begin(), knownSpells.end(), spellId) != knownSpells.end(); + if (!alreadyKnown) { + knownSpells.push_back(spellId); + LOG_INFO("Added spell ", spellId, " to known spells (trainer purchase)"); + } + const std::string& name = getSpellName(spellId); if (!name.empty()) addSystemChatMessage("You have learned " + name + "."); @@ -689,6 +704,32 @@ void GameHandler::handlePacket(network::Packet& packet) { addSystemChatMessage("Spell learned."); break; } + case Opcode::SMSG_TRAINER_BUY_FAILED: { + // Server rejected the spell purchase + // Packet format: uint64 trainerGuid, uint32 spellId, uint32 errorCode + uint64_t trainerGuid = packet.readUInt64(); + uint32_t spellId = packet.readUInt32(); + uint32_t errorCode = 0; + if (packet.getSize() - packet.getReadPos() >= 4) { + errorCode = packet.readUInt32(); + } + LOG_WARNING("Trainer buy spell failed: guid=", trainerGuid, + " spellId=", spellId, " error=", errorCode); + + const std::string& spellName = getSpellName(spellId); + std::string msg = "Cannot learn "; + if (!spellName.empty()) msg += spellName; + else msg += "spell #" + std::to_string(spellId); + + // Common error reasons + if (errorCode == 0) msg += " (not enough money)"; + else if (errorCode == 1) msg += " (not enough skill)"; + else if (errorCode == 2) msg += " (already known)"; + else if (errorCode != 0) msg += " (error " + std::to_string(errorCode) + ")"; + + addSystemChatMessage(msg); + break; + } // Silently ignore common packets we don't handle yet case Opcode::SMSG_FEATURE_SYSTEM_STATUS: @@ -851,8 +892,10 @@ void GameHandler::handlePacket(network::Packet& packet) { case 19: reasonStr = "Can't take any more quests"; break; } LOG_WARNING("Quest invalid: reason=", failReason, " (", reasonStr, ")"); - // Show error to user - addSystemChatMessage(std::string("Quest unavailable: ") + reasonStr); + // Only show error to user for real errors (not informational messages) + if (failReason != 13 && failReason != 18) { // Don't spam "already on/completed" + addSystemChatMessage(std::string("Quest unavailable: ") + reasonStr); + } } break; } @@ -883,6 +926,52 @@ void GameHandler::handlePacket(network::Packet& packet) { } break; } + case Opcode::SMSG_QUESTUPDATE_ADD_KILL: { + // Quest kill count update + if (packet.getSize() - packet.getReadPos() >= 16) { + uint32_t questId = packet.readUInt32(); + uint32_t entry = packet.readUInt32(); // Creature entry + uint32_t count = packet.readUInt32(); // Current kills + uint32_t reqCount = packet.readUInt32(); // Required kills + + LOG_INFO("Quest kill update: questId=", questId, " entry=", entry, + " count=", count, "/", reqCount); + + // Update quest log with kill count + for (auto& quest : questLog_) { + if (quest.questId == questId) { + // Store kill progress (using entry as objective index) + quest.killCounts[entry] = {count, reqCount}; + + // Show progress message + std::string progressMsg = quest.title + ": " + + std::to_string(count) + "/" + + std::to_string(reqCount); + addSystemChatMessage(progressMsg); + + LOG_INFO("Updated kill count for quest ", questId, ": ", + count, "/", reqCount); + break; + } + } + } + break; + } + case Opcode::SMSG_QUESTUPDATE_COMPLETE: { + // Quest objectives completed - mark as ready to turn in + uint32_t questId = packet.readUInt32(); + LOG_INFO("Quest objectives completed: questId=", questId); + + for (auto& quest : questLog_) { + if (quest.questId == questId) { + quest.complete = true; + addSystemChatMessage("Quest Complete: " + quest.title); + LOG_INFO("Marked quest ", questId, " as complete"); + break; + } + } + 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()); @@ -897,11 +986,50 @@ void GameHandler::handlePacket(network::Packet& packet) { LOG_INFO(" questId=", questId, " questMethod=", questMethod); - // We received quest template data - this means the quest exists - // Check if player has this quest active by checking if it's in gossip - // For now, just log that we received the data - // TODO: Parse full quest template (title, objectives, etc.) + // Parse quest title (after method comes level, flags, type, etc., then title string) + // Skip intermediate fields to get to title + if (packet.getReadPos() + 16 < packet.getSize()) { + packet.readUInt32(); // quest level + packet.readUInt32(); // min level + packet.readUInt32(); // sort ID (zone) + packet.readUInt32(); // quest type/info + packet.readUInt32(); // suggested players + packet.readUInt32(); // reputation objective faction + packet.readUInt32(); // reputation objective value + packet.readUInt32(); // required opposite faction + packet.readUInt32(); // next quest in chain + packet.readUInt32(); // XP ID + packet.readUInt32(); // reward or required money + packet.readUInt32(); // reward money max level + packet.readUInt32(); // reward spell + packet.readUInt32(); // reward spell cast + packet.readUInt32(); // reward honor + packet.readUInt32(); // reward honor multiplier + packet.readUInt32(); // source item ID + packet.readUInt32(); // quest flags + // ... there are many more fields before title, let's try to read title string + if (packet.getReadPos() + 1 < packet.getSize()) { + std::string title = packet.readString(); + LOG_INFO(" Quest title: ", title); + // Update quest log entry with title + for (auto& q : questLog_) { + if (q.questId == questId) { + q.title = title; + LOG_INFO("Updated quest log entry ", questId, " with title: ", title); + break; + } + } + } + } + + break; + } + case Opcode::SMSG_QUESTLOG_FULL: { + LOG_INFO("***** RECEIVED SMSG_QUESTLOG_FULL *****"); + LOG_INFO(" Packet size: ", packet.getSize()); + LOG_INFO(" Server uses SMSG_QUESTLOG_FULL for quest log sync!"); + // TODO: Parse quest log entries from this packet break; } case Opcode::SMSG_QUESTGIVER_REQUEST_ITEMS: @@ -1801,8 +1929,89 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { // Extract XP / inventory slot / skill fields for player entity if (block.guid == playerGuid && block.objectType == ObjectType::PLAYER) { + // Store baseline snapshot on first update + static bool baselineStored = false; + static std::map baselineFields; + + if (!baselineStored) { + baselineFields = block.fields; + baselineStored = true; + LOG_INFO("===== BASELINE PLAYER FIELDS STORED ====="); + LOG_INFO(" Total fields: ", block.fields.size()); + } + + // Diff against baseline to find changes + std::vector changedIndices; + std::vector newIndices; + std::vector removedIndices; + + for (const auto& [idx, val] : block.fields) { + auto it = baselineFields.find(idx); + if (it == baselineFields.end()) { + newIndices.push_back(idx); + } else if (it->second != val) { + changedIndices.push_back(idx); + } + } + + for (const auto& [idx, val] : baselineFields) { + if (block.fields.find(idx) == block.fields.end()) { + removedIndices.push_back(idx); + } + } + lastPlayerFields_ = block.fields; detectInventorySlotBases(block.fields); + + // Debug: Show field changes + LOG_INFO("Player update with ", block.fields.size(), " fields"); + + if (!changedIndices.empty() || !newIndices.empty() || !removedIndices.empty()) { + LOG_INFO(" ===== FIELD CHANGES DETECTED ====="); + if (!changedIndices.empty()) { + LOG_INFO(" Changed fields (", changedIndices.size(), "):"); + std::sort(changedIndices.begin(), changedIndices.end()); + for (size_t i = 0; i < std::min(size_t(30), changedIndices.size()); ++i) { + uint16_t idx = changedIndices[i]; + uint32_t oldVal = baselineFields[idx]; + uint32_t newVal = block.fields.at(idx); + LOG_INFO(" [", idx, "]: ", oldVal, " -> ", newVal, + " (0x", std::hex, oldVal, " -> 0x", newVal, std::dec, ")"); + } + if (changedIndices.size() > 30) { + LOG_INFO(" ... (", changedIndices.size() - 30, " more)"); + } + } + if (!newIndices.empty()) { + LOG_INFO(" New fields (", newIndices.size(), "):"); + std::sort(newIndices.begin(), newIndices.end()); + for (size_t i = 0; i < std::min(size_t(20), newIndices.size()); ++i) { + uint16_t idx = newIndices[i]; + uint32_t val = block.fields.at(idx); + LOG_INFO(" [", idx, "]: ", val, " (0x", std::hex, val, std::dec, ")"); + } + if (newIndices.size() > 20) { + LOG_INFO(" ... (", newIndices.size() - 20, " more)"); + } + } + if (!removedIndices.empty()) { + LOG_INFO(" Removed fields (", removedIndices.size(), "):"); + std::sort(removedIndices.begin(), removedIndices.end()); + for (size_t i = 0; i < std::min(size_t(20), removedIndices.size()); ++i) { + uint16_t idx = removedIndices[i]; + uint32_t val = baselineFields.at(idx); + LOG_INFO(" [", idx, "]: was ", val, " (0x", std::hex, val, std::dec, ")"); + } + } + } + + uint16_t maxField = 0; + for (const auto& [key, val] : block.fields) { + if (key > maxField) maxField = key; + } + + LOG_INFO(" Highest field index: ", maxField); + bool slotsChanged = false; for (const auto& [key, val] : block.fields) { if (key == 634) { playerXp_ = val; } // PLAYER_XP @@ -1813,7 +2022,41 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { if (ch.guid == playerGuid) { ch.level = val; break; } } } - else if (key == 1170) { playerMoneyCopper_ = val; } // PLAYER_FIELD_COINAGE + else if (key == 1170) { + playerMoneyCopper_ = val; + LOG_INFO("Money set from update fields: ", val, " copper"); + } // PLAYER_FIELD_COINAGE + // Parse quest log fields (PLAYER_QUEST_LOG_1_1 = UNIT_END + 10 = 158, stride 5) + // Quest slots: 158, 163, 168, 173, ... (25 slots max = up to index 278) + else if (key >= 158 && key < 283 && (key - 158) % 5 == 0) { + uint32_t questId = val; + if (questId != 0) { + // Check if quest is already in log + bool found = false; + for (auto& q : questLog_) { + if (q.questId == questId) { + found = true; + break; + } + } + if (!found) { + // Add quest to log and request quest details + QuestLogEntry entry; + entry.questId = questId; + entry.complete = false; // Will be updated by gossip or quest status packets + entry.title = "Quest #" + std::to_string(questId); + questLog_.push_back(entry); + LOG_INFO("Found quest in update fields: ", questId); + + // Request quest details from server + if (socket) { + network::Packet qPkt(static_cast(Opcode::CMSG_QUEST_QUERY)); + qPkt.writeUInt32(questId); + socket->send(qPkt); + } + } + } + } } if (applyInventoryFields(block.fields)) slotsChanged = true; if (slotsChanged) rebuildOnlineInventory(); @@ -4175,6 +4418,45 @@ void GameHandler::handleRemovedSpell(network::Packet& packet) { LOG_INFO("Removed spell: ", spellId); } +void GameHandler::handleSupercededSpell(network::Packet& packet) { + // Old spell replaced by new rank (e.g., Fireball Rank 1 -> Fireball Rank 2) + uint32_t oldSpellId = packet.readUInt32(); + uint32_t newSpellId = packet.readUInt32(); + + // Remove old spell + knownSpells.erase( + std::remove(knownSpells.begin(), knownSpells.end(), oldSpellId), + knownSpells.end()); + + // Add new spell + knownSpells.push_back(newSpellId); + + LOG_INFO("Spell superceded: ", oldSpellId, " -> ", newSpellId); + + const std::string& newName = getSpellName(newSpellId); + if (!newName.empty()) { + addSystemChatMessage("Upgraded to " + newName); + } +} + +void GameHandler::handleUnlearnSpells(network::Packet& packet) { + // Sent when unlearning multiple spells (e.g., spec change, respec) + uint32_t spellCount = packet.readUInt32(); + LOG_INFO("Unlearning ", spellCount, " spells"); + + for (uint32_t i = 0; i < spellCount && packet.getSize() - packet.getReadPos() >= 4; ++i) { + uint32_t spellId = packet.readUInt32(); + knownSpells.erase( + std::remove(knownSpells.begin(), knownSpells.end(), spellId), + knownSpells.end()); + LOG_INFO(" Unlearned spell: ", spellId); + } + + if (spellCount > 0) { + addSystemChatMessage("Unlearned " + std::to_string(spellCount) + " spells"); + } +} + // ============================================================ // Phase 4: Group/Party // ============================================================ @@ -4746,7 +5028,7 @@ void GameHandler::handleTrainerList(network::Packet& packet) { // Debug: log known spells LOG_INFO("Known spells count: ", knownSpells.size()); - if (knownSpells.size() <= 20) { + if (knownSpells.size() <= 50) { std::string spellList; for (uint32_t id : knownSpells) { if (!spellList.empty()) spellList += ", "; @@ -4755,6 +5037,21 @@ void GameHandler::handleTrainerList(network::Packet& packet) { LOG_INFO("Known spells: ", spellList); } + // Check if specific prerequisite spells are known + bool has527 = std::find(knownSpells.begin(), knownSpells.end(), 527u) != knownSpells.end(); + bool has25312 = std::find(knownSpells.begin(), knownSpells.end(), 25312u) != knownSpells.end(); + LOG_INFO("Prerequisite check: 527=", has527, " 25312=", has25312); + + // Debug: log first few trainer spells to see their state + LOG_INFO("Trainer spells received: ", currentTrainerList_.spells.size(), " spells"); + for (size_t i = 0; i < std::min(size_t(5), currentTrainerList_.spells.size()); ++i) { + const auto& s = currentTrainerList_.spells[i]; + LOG_INFO(" Spell[", i, "]: id=", s.spellId, " state=", (int)s.state, + " cost=", s.spellCost, " reqLvl=", (int)s.reqLevel, + " chain=(", s.chainNode1, ",", s.chainNode2, ",", s.chainNode3, ")"); + } + + // Ensure caches are populated loadSpellNameCache(); loadSkillLineDbc(); @@ -4768,11 +5065,21 @@ void GameHandler::trainSpell(uint32_t spellId) { LOG_WARNING("trainSpell: Not in world or no socket connection"); return; } + + // Find spell cost in trainer list + uint32_t spellCost = 0; + for (const auto& spell : currentTrainerList_.spells) { + if (spell.spellId == spellId) { + spellCost = spell.spellCost; + break; + } + } + LOG_INFO("Player money: ", playerMoneyCopper_, " copper, spell cost: ", spellCost, " copper"); + LOG_INFO("Sending CMSG_TRAINER_BUY_SPELL: guid=", currentTrainerList_.trainerGuid, - " trainerId=", currentTrainerList_.trainerType, " spellId=", spellId); + " spellId=", spellId); auto packet = TrainerBuySpellPacket::build( currentTrainerList_.trainerGuid, - currentTrainerList_.trainerType, spellId); socket->send(packet); LOG_INFO("CMSG_TRAINER_BUY_SPELL sent"); @@ -6022,6 +6329,18 @@ void GameHandler::extractSkillFields(const std::map& fields) if (skill.value == 0) continue; auto oldIt = playerSkills_.find(skillId); if (oldIt != playerSkills_.end() && skill.value > oldIt->second.value) { + // Filter out racial, generic, and hidden skills from announcements + // Category 5 = Attributes (Defense, etc.) + // Category 10 = Languages (Orcish, Common, etc.) + // Category 12 = Not Displayed (generic/hidden) + auto catIt = skillLineCategories_.find(skillId); + if (catIt != skillLineCategories_.end()) { + uint32_t category = catIt->second; + if (category == 5 || category == 10 || category == 12) { + continue; // Skip announcement for racial/generic skills + } + } + const std::string& name = getSkillName(skillId); std::string skillName = name.empty() ? ("Skill #" + std::to_string(skillId)) : name; addSystemChatMessage("Your skill in " + skillName + " has increased to " + std::to_string(skill.value) + "."); @@ -6065,6 +6384,16 @@ void GameHandler::saveCharacterConfig() { out << "action_bar_" << i << "_type=" << static_cast(actionBar[i].type) << "\n"; out << "action_bar_" << i << "_id=" << actionBar[i].id << "\n"; } + + // Save quest log + out << "quest_log_count=" << questLog_.size() << "\n"; + for (size_t i = 0; i < questLog_.size(); i++) { + const auto& quest = questLog_[i]; + out << "quest_" << i << "_id=" << quest.questId << "\n"; + out << "quest_" << i << "_title=" << quest.title << "\n"; + out << "quest_" << i << "_complete=" << (quest.complete ? 1 : 0) << "\n"; + } + LOG_INFO("Character config saved to ", path); } diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 2f5659f9..e7bf9501 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -829,6 +829,8 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock } bool UpdateObjectParser::parseUpdateFields(network::Packet& packet, UpdateBlock& block) { + size_t startPos = packet.getReadPos(); + // Read number of blocks (each block is 32 fields = 32 bits) uint8_t blockCount = packet.readUInt8(); @@ -836,7 +838,10 @@ bool UpdateObjectParser::parseUpdateFields(network::Packet& packet, UpdateBlock& return true; // No fields to update } - LOG_DEBUG(" Parsing ", (int)blockCount, " field blocks"); + uint32_t fieldsCapacity = blockCount * 32; + LOG_INFO(" UPDATE MASK PARSE:"); + LOG_INFO(" maskBlockCount = ", (int)blockCount); + LOG_INFO(" fieldsCapacity (blocks * 32) = ", fieldsCapacity); // Read update mask std::vector updateMask(blockCount); @@ -844,6 +849,10 @@ bool UpdateObjectParser::parseUpdateFields(network::Packet& packet, UpdateBlock& updateMask[i] = packet.readUInt32(); } + // Find highest set bit + uint16_t highestSetBit = 0; + uint32_t valuesReadCount = 0; + // Read field values for each bit set in mask for (int blockIdx = 0; blockIdx < blockCount; ++blockIdx) { uint32_t mask = updateMask[blockIdx]; @@ -851,15 +860,27 @@ bool UpdateObjectParser::parseUpdateFields(network::Packet& packet, UpdateBlock& for (int bit = 0; bit < 32; ++bit) { if (mask & (1 << bit)) { uint16_t fieldIndex = blockIdx * 32 + bit; + if (fieldIndex > highestSetBit) { + highestSetBit = fieldIndex; + } uint32_t value = packet.readUInt32(); block.fields[fieldIndex] = value; + valuesReadCount++; LOG_DEBUG(" Field[", fieldIndex, "] = 0x", std::hex, value, std::dec); } } } - LOG_DEBUG(" Parsed ", block.fields.size(), " fields"); + size_t endPos = packet.getReadPos(); + size_t bytesUsed = endPos - startPos; + size_t bytesRemaining = packet.getSize() - endPos; + + LOG_INFO(" highestSetBitIndex = ", highestSetBit); + LOG_INFO(" valuesReadCount = ", valuesReadCount); + LOG_INFO(" bytesUsedForFields = ", bytesUsed); + LOG_INFO(" bytesRemainingInPacket = ", bytesRemaining); + LOG_INFO(" Parsed ", block.fields.size(), " fields"); return true; } @@ -932,6 +953,10 @@ bool UpdateObjectParser::parse(network::Packet& packet, UpdateObjectData& data) // Read block count data.blockCount = packet.readUInt32(); + LOG_INFO("SMSG_UPDATE_OBJECT:"); + LOG_INFO(" objectCount = ", data.blockCount); + LOG_INFO(" packetSize = ", packet.getSize()); + // Check for out-of-range objects first if (packet.getReadPos() + 1 <= packet.getSize()) { uint8_t firstByte = packet.readUInt8(); @@ -1973,9 +1998,12 @@ bool XpGainParser::parse(network::Packet& packet, XpGainData& data) { // ============================================================ bool InitialSpellsParser::parse(network::Packet& packet, InitialSpellsData& data) { + size_t packetSize = packet.getSize(); data.talentSpec = packet.readUInt8(); uint16_t spellCount = packet.readUInt16(); + LOG_INFO("SMSG_INITIAL_SPELLS: packetSize=", packetSize, " bytes, spellCount=", spellCount); + data.spellIds.reserve(spellCount); for (uint16_t i = 0; i < spellCount; ++i) { uint32_t spellId = packet.readUInt32(); @@ -1997,8 +2025,19 @@ bool InitialSpellsParser::parse(network::Packet& packet, InitialSpellsData& data data.cooldowns.push_back(entry); } - LOG_INFO("Initial spells: ", data.spellIds.size(), " spells, ", + LOG_INFO("Initial spells parsed: ", data.spellIds.size(), " spells, ", data.cooldowns.size(), " cooldowns"); + + // Log first 10 spell IDs for debugging + if (!data.spellIds.empty()) { + std::string first10; + for (size_t i = 0; i < std::min(size_t(10), data.spellIds.size()); ++i) { + if (!first10.empty()) first10 += ", "; + first10 += std::to_string(data.spellIds[i]); + } + LOG_INFO("First spells: ", first10); + } + return true; } @@ -2682,10 +2721,9 @@ bool TrainerListParser::parse(network::Packet& packet, TrainerListData& data) { return true; } -network::Packet TrainerBuySpellPacket::build(uint64_t trainerGuid, uint32_t trainerId, uint32_t spellId) { +network::Packet TrainerBuySpellPacket::build(uint64_t trainerGuid, uint32_t spellId) { network::Packet packet(static_cast(Opcode::CMSG_TRAINER_BUY_SPELL)); packet.writeUInt64(trainerGuid); - packet.writeUInt32(trainerId); packet.writeUInt32(spellId); return packet; } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index e3e8e3fd..e0377c9f 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -110,6 +110,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { // ---- New UI elements ---- renderActionBar(gameHandler); + renderBagBar(gameHandler); renderXpBar(gameHandler); renderCastBar(gameHandler); renderCombatText(gameHandler); @@ -480,7 +481,7 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { } if (ImGui::IsItemClicked()) { std::string cmd = "xdg-open '" + url + "' &"; - system(cmd.c_str()); + [[maybe_unused]] int result = system(cmd.c_str()); } ImGui::PopStyleColor(); @@ -2580,6 +2581,139 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { } } +// ============================================================ +// Bag Bar +// ============================================================ + +void GameScreen::renderBagBar(game::GameHandler& gameHandler) { + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + auto* assetMgr = core::Application::getInstance().getAssetManager(); + + float slotSize = 42.0f; + float spacing = 4.0f; + float padding = 6.0f; + + // 5 slots: backpack + 4 bags + float barW = 5 * slotSize + 4 * spacing + padding * 2; + float barH = slotSize + padding * 2; + + // Position in bottom right corner + float barX = screenW - barW - 10.0f; + float barY = screenH - barH - 10.0f; + + ImGui::SetNextWindowPos(ImVec2(barX, barY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(barW, barH), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.9f)); + + if (ImGui::Begin("##BagBar", nullptr, flags)) { + auto& inv = gameHandler.getInventory(); + + // Load backpack icon if needed + if (!backpackIconTexture_ && assetMgr && assetMgr->isInitialized()) { + auto blpData = assetMgr->readFile("Interface\\Buttons\\Button-Backpack-Up.blp"); + if (!blpData.empty()) { + auto image = pipeline::BLPLoader::load(blpData); + if (image.isValid()) { + glGenTextures(1, &backpackIconTexture_); + glBindTexture(GL_TEXTURE_2D, backpackIconTexture_); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, image.width, image.height, 0, + GL_RGBA, GL_UNSIGNED_BYTE, image.data.data()); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glBindTexture(GL_TEXTURE_2D, 0); + } + } + } + + // Slots 1-4: Bag slots (leftmost) + for (int i = 0; i < 4; ++i) { + if (i > 0) ImGui::SameLine(0, spacing); + ImGui::PushID(i + 1); + + game::EquipSlot bagSlot = static_cast(static_cast(game::EquipSlot::BAG1) + i); + const auto& bagItem = inv.getEquipSlot(bagSlot); + + GLuint bagIcon = 0; + if (!bagItem.empty() && bagItem.item.displayInfoId != 0) { + bagIcon = inventoryScreen.getItemIcon(bagItem.item.displayInfoId); + } + + if (bagIcon) { + if (ImGui::ImageButton("##bag", (ImTextureID)(uintptr_t)bagIcon, + ImVec2(slotSize - 4, slotSize - 4), + ImVec2(0, 0), ImVec2(1, 1), + ImVec4(0.1f, 0.1f, 0.1f, 0.9f), + ImVec4(1, 1, 1, 1))) { + // TODO: Open specific bag + inventoryScreen.toggle(); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("%s", bagItem.item.name.c_str()); + } + } else { + // Empty bag slot + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); + if (ImGui::Button("##empty", ImVec2(slotSize, slotSize))) { + // Empty slot - maybe show equipment to find a bag? + } + ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Empty Bag Slot"); + } + } + + // Accept dragged item from inventory + if (ImGui::IsItemHovered() && inventoryScreen.isHoldingItem()) { + const auto& heldItem = inventoryScreen.getHeldItem(); + // Check if held item is a bag (bagSlots > 0) + if (heldItem.bagSlots > 0 && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { + // Equip the bag to inventory + auto& inventory = gameHandler.getInventory(); + inventory.setEquipSlot(bagSlot, heldItem); + inventoryScreen.returnHeldItem(inventory); + } + } + + ImGui::PopID(); + } + + // Backpack (rightmost slot) + ImGui::SameLine(0, spacing); + ImGui::PushID(0); + if (backpackIconTexture_) { + if (ImGui::ImageButton("##backpack", (ImTextureID)(uintptr_t)backpackIconTexture_, + ImVec2(slotSize - 4, slotSize - 4), + ImVec2(0, 0), ImVec2(1, 1), + ImVec4(0.1f, 0.1f, 0.1f, 0.9f), + ImVec4(1, 1, 1, 1))) { + inventoryScreen.toggle(); + } + } else { + if (ImGui::Button("B", ImVec2(slotSize, slotSize))) { + inventoryScreen.toggle(); + } + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Backpack"); + } + ImGui::PopID(); + } + ImGui::End(); + + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); +} + // ============================================================ // XP Bar // ============================================================ @@ -3353,6 +3487,11 @@ void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) { const auto& quest = gameHandler.getQuestOfferReward(); static int selectedChoice = -1; + // Auto-select if only one choice reward + if (quest.choiceRewards.size() == 1 && selectedChoice == -1) { + selectedChoice = 0; + } + std::string processedTitle = replaceGenderPlaceholders(quest.title, gameHandler); if (ImGui::Begin(processedTitle.c_str(), &open, ImGuiWindowFlags_NoCollapse)) { if (!quest.rewardText.empty()) { @@ -3365,19 +3504,74 @@ void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) { ImGui::Spacing(); ImGui::Separator(); ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Choose a reward:"); + for (size_t i = 0; i < quest.choiceRewards.size(); ++i) { const auto& item = quest.choiceRewards[i]; auto* info = gameHandler.getItemInfo(item.itemId); - char label[256]; - if (info && info->valid) - snprintf(label, sizeof(label), "%s x%u", info->name.c_str(), item.count); - else - snprintf(label, sizeof(label), "Item %u x%u", item.itemId, item.count); bool selected = (selectedChoice == static_cast(i)); - if (ImGui::Selectable(label, selected)) { + + // Get item icon if we have displayInfoId + uint32_t iconTex = 0; + if (info && info->valid && info->displayInfoId != 0) { + iconTex = inventoryScreen.getItemIcon(info->displayInfoId); + } + + // Quality color + ImVec4 qualityColor = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); // White (poor) + if (info && info->valid) { + switch (info->quality) { + case 1: qualityColor = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); break; // Common (white) + case 2: qualityColor = ImVec4(0.0f, 1.0f, 0.0f, 1.0f); break; // Uncommon (green) + case 3: qualityColor = ImVec4(0.0f, 0.5f, 1.0f, 1.0f); break; // Rare (blue) + case 4: qualityColor = ImVec4(0.64f, 0.21f, 0.93f, 1.0f); break; // Epic (purple) + case 5: qualityColor = ImVec4(1.0f, 0.5f, 0.0f, 1.0f); break; // Legendary (orange) + } + } + + // Render item with icon + ImGui::PushID(static_cast(i)); + if (ImGui::Selectable("##reward", selected, 0, ImVec2(0, 40))) { selectedChoice = static_cast(i); } + + // Draw icon and text over the selectable + ImGui::SameLine(); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() - ImGui::GetItemRectSize().x + 4); + + if (iconTex) { + ImGui::Image((void*)(intptr_t)iconTex, ImVec2(36, 36)); + ImGui::SameLine(); + } + + ImGui::BeginGroup(); + if (info && info->valid) { + ImGui::TextColored(qualityColor, "%s", info->name.c_str()); + if (item.count > 1) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.7f), "x%u", item.count); + } + // Show stats + if (info->armor > 0 || info->stamina > 0 || info->strength > 0 || + info->agility > 0 || info->intellect > 0 || info->spirit > 0) { + std::string stats; + if (info->armor > 0) stats += std::to_string(info->armor) + " Armor "; + if (info->stamina > 0) stats += "+" + std::to_string(info->stamina) + " Sta "; + if (info->strength > 0) stats += "+" + std::to_string(info->strength) + " Str "; + if (info->agility > 0) stats += "+" + std::to_string(info->agility) + " Agi "; + if (info->intellect > 0) stats += "+" + std::to_string(info->intellect) + " Int "; + if (info->spirit > 0) stats += "+" + std::to_string(info->spirit) + " Spi "; + ImGui::TextColored(ImVec4(0.0f, 1.0f, 0.0f, 1.0f), "%s", stats.c_str()); + } + } else { + ImGui::TextColored(qualityColor, "Item %u", item.itemId); + if (item.count > 0) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.7f), "x%u", item.count); + } + } + ImGui::EndGroup(); + ImGui::PopID(); } } @@ -3587,6 +3781,10 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { uint32_t ms = static_cast((money / 100) % 100); uint32_t mc = static_cast(money % 100); ImGui::Text("Your money: %ug %us %uc", mg, ms, mc); + + // Filter checkbox + static bool showUnavailable = false; + ImGui::Checkbox("Show unavailable spells", &showUnavailable); ImGui::Separator(); if (trainer.spells.empty()) { @@ -3596,23 +3794,25 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { const auto& knownSpells = gameHandler.getKnownSpells(); auto isKnown = [&](uint32_t id) { if (id == 0) return true; + // Check if spell is in knownSpells list bool found = std::find(knownSpells.begin(), knownSpells.end(), id) != knownSpells.end(); - static int debugCount = 0; - if (debugCount < 5 && !found && id != 0) { - LOG_INFO("isKnown(", id, ") = false, knownSpells.size()=", knownSpells.size()); - debugCount++; + if (found) return true; + + // Also check if spell is in trainer list with state=2 (explicitly known) + // state=0 means unavailable (could be no prereqs, wrong level, etc.) - don't count as known + for (const auto& ts : trainer.spells) { + if (ts.spellId == id && ts.state == 2) { + return true; + } } - return found; + return false; }; uint32_t playerLevel = gameHandler.getPlayerLevel(); // Renders spell rows into the current table auto renderSpellRows = [&](const std::vector& spells) { for (const auto* spell : spells) { - ImGui::TableNextRow(); - ImGui::PushID(static_cast(spell->spellId)); - - // Check prerequisites client-side + // Check prerequisites client-side first bool prereq1Met = isKnown(spell->chainNode1); bool prereq2Met = isKnown(spell->chainNode2); bool prereq3Met = isKnown(spell->chainNode3); @@ -3620,12 +3820,30 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { bool levelMet = (spell->reqLevel == 0 || playerLevel >= spell->reqLevel); bool alreadyKnown = isKnown(spell->spellId); + // Dynamically determine effective state based on current prerequisites + // Server sends state, but we override if prerequisites are now met + uint8_t effectiveState = spell->state; + if (spell->state == 1 && prereqsMet && levelMet) { + // Server said unavailable, but we now meet all requirements + effectiveState = 0; // Treat as available + } + + // Filter: skip unavailable spells if checkbox is unchecked + // Use effectiveState so spells with newly met prereqs aren't filtered + if (!showUnavailable && effectiveState == 1) { + continue; + } + + ImGui::TableNextRow(); + ImGui::PushID(static_cast(spell->spellId)); + ImVec4 color; const char* statusLabel; - if (alreadyKnown) { + // WotLK trainer states: 0=available, 1=unavailable, 2=known + if (effectiveState == 2 || alreadyKnown) { color = ImVec4(0.3f, 0.9f, 0.3f, 1.0f); statusLabel = "Known"; - } else if (spell->state == 1 && prereqsMet && levelMet) { + } else if (effectiveState == 0) { color = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); statusLabel = "Available"; } else { @@ -3693,7 +3911,8 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { // Train button - only enabled if available, affordable, prereqs met ImGui::TableSetColumnIndex(3); - bool canTrain = !alreadyKnown && spell->state == 1 + // Use effectiveState so newly available spells (after learning prereqs) can be trained + bool canTrain = !alreadyKnown && effectiveState == 0 && prereqsMet && levelMet && (money >= spell->spellCost); @@ -4079,7 +4298,6 @@ void GameScreen::renderSettingsWindow() { constexpr bool kDefaultVsync = true; constexpr bool kDefaultShadows = false; constexpr int kDefaultMusicVolume = 30; - constexpr int kDefaultSfxVolume = 100; constexpr float kDefaultMouseSensitivity = 0.2f; constexpr bool kDefaultInvertMouse = false;