diff --git a/.gitignore b/.gitignore index 4ddf59ab..013f805b 100644 --- a/.gitignore +++ b/.gitignore @@ -100,3 +100,10 @@ node_modules/ # Python cache artifacts tools/__pycache__/ *.pyc + +# artifacts +.codex-loop/ + +# Local agent instructions +AGENTS.md +codex-loop.sh diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 1f04eb22..7ce07326 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -2369,6 +2369,7 @@ private: void addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource, uint8_t powerType = 0, uint64_t srcGuid = 0, uint64_t dstGuid = 0); + bool shouldLogSpellstealAura(uint64_t casterGuid, uint64_t victimGuid, uint32_t spellId); void addSystemChatMessage(const std::string& message); /** @@ -2575,7 +2576,15 @@ private: std::unordered_set hostileAttackers_; std::vector combatText; static constexpr size_t MAX_COMBAT_LOG = 500; + struct RecentSpellstealLogEntry { + uint64_t casterGuid = 0; + uint64_t victimGuid = 0; + uint32_t spellId = 0; + std::chrono::steady_clock::time_point timestamp{}; + }; + static constexpr size_t MAX_RECENT_SPELLSTEAL_LOGS = 32; std::deque combatLog_; + std::deque recentSpellstealLogs_; std::deque areaTriggerMsgs_; // unitGuid → sorted threat list (descending by threat value) std::unordered_map> threatLists_; diff --git a/include/game/spell_defines.hpp b/include/game/spell_defines.hpp index a3944a0e..ffaf6bb2 100644 --- a/include/game/spell_defines.hpp +++ b/include/game/spell_defines.hpp @@ -51,16 +51,16 @@ struct ActionBarSlot { struct CombatTextEntry { enum Type : uint8_t { MELEE_DAMAGE, SPELL_DAMAGE, HEAL, MISS, DODGE, PARRY, BLOCK, - CRIT_DAMAGE, CRIT_HEAL, PERIODIC_DAMAGE, PERIODIC_HEAL, ENVIRONMENTAL, - ENERGIZE, XP_GAIN, IMMUNE, ABSORB, RESIST, PROC_TRIGGER, - DISPEL, INTERRUPT + EVADE, CRIT_DAMAGE, CRIT_HEAL, PERIODIC_DAMAGE, PERIODIC_HEAL, ENVIRONMENTAL, + ENERGIZE, POWER_DRAIN, XP_GAIN, IMMUNE, ABSORB, RESIST, DEFLECT, REFLECT, PROC_TRIGGER, + DISPEL, STEAL, INTERRUPT, INSTAKILL }; Type type; int32_t amount = 0; uint32_t spellId = 0; float age = 0.0f; // Seconds since creation (for fadeout) bool isPlayerSource = false; // True if player dealt this - uint8_t powerType = 0; // For ENERGIZE: 0=mana,1=rage,2=focus,3=energy,6=runicpower + uint8_t powerType = 0; // For ENERGIZE/POWER_DRAIN: 0=mana,1=rage,2=focus,3=energy,6=runicpower static constexpr float LIFETIME = 2.5f; bool isExpired() const { return age >= LIFETIME; } diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index d864b57e..f29eecb7 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1858,7 +1858,7 @@ public: /** SMSG_SPELL_GO data (simplified) */ struct SpellGoMissEntry { uint64_t targetGuid = 0; - uint8_t missType = 0; // 0=MISS 1=DODGE 2=PARRY 3=BLOCK 4=EVADE 5=IMMUNE 6=DEFLECT 7=ABSORB 8=RESIST + uint8_t missType = 0; // 0=MISS 1=DODGE 2=PARRY 3=BLOCK 4=EVADE 5=IMMUNE 6=DEFLECT 7=ABSORB 8=RESIST 11=REFLECT }; struct SpellGoData { diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 13e6171b..74e1782c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -123,6 +123,40 @@ std::string formatCopperAmount(uint32_t amount) { return oss.str(); } +std::string displaySpellName(GameHandler& handler, uint32_t spellId) { + if (spellId == 0) return {}; + const std::string& name = handler.getSpellName(spellId); + if (!name.empty()) return name; + return "spell " + std::to_string(spellId); +} + +std::string formatSpellNameList(GameHandler& handler, + const std::vector& spellIds, + size_t maxShown = 3) { + if (spellIds.empty()) return {}; + + const size_t shownCount = std::min(spellIds.size(), maxShown); + std::ostringstream oss; + for (size_t i = 0; i < shownCount; ++i) { + if (i > 0) { + if (shownCount == 2) { + oss << " and "; + } else if (i == shownCount - 1) { + oss << ", and "; + } else { + oss << ", "; + } + } + oss << displaySpellName(handler, spellIds[i]); + } + + if (spellIds.size() > shownCount) { + oss << ", and " << (spellIds.size() - shownCount) << " more"; + } + + return oss.str(); +} + bool readCStringAt(const std::vector& data, size_t start, std::string& out, size_t& nextPos) { out.clear(); if (start >= data.size()) return false; @@ -2066,8 +2100,11 @@ void GameHandler::handlePacket(network::Packet& packet) { uint64_t victim = readPrGuid(); if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t spellId = packet.readUInt32(); - if (victim == playerGuid) + if (victim == playerGuid) { addCombatText(CombatTextEntry::RESIST, 0, spellId, false, 0, caster, victim); + } else if (caster == playerGuid) { + addCombatText(CombatTextEntry::RESIST, 0, spellId, true, 0, caster, victim); + } packet.setReadPos(packet.getSize()); break; } @@ -2643,33 +2680,34 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Spell log miss ---- case Opcode::SMSG_SPELLLOGMISS: { // All expansions: uint32 spellId first. - // WotLK: spellId(4) + packed_guid caster + uint8 unk + uint32 count - // + count × (packed_guid victim + uint8 missInfo) - // [missInfo==11(REFLECT): + uint32 reflectSpellId + uint8 reflectResult] - // TBC/Classic: spellId(4) + uint64 caster + uint8 unk + uint32 count - // + count × (uint64 victim + uint8 missInfo) - const bool spellMissTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + // WotLK/Classic: spellId(4) + packed_guid caster + uint8 unk + uint32 count + // + count × (packed_guid victim + uint8 missInfo) + // TBC: spellId(4) + uint64 caster + uint8 unk + uint32 count + // + count × (uint64 victim + uint8 missInfo) + // All expansions append uint32 reflectSpellId + uint8 reflectResult when + // missInfo==11 (REFLECT). + const bool spellMissUsesFullGuid = isActiveExpansion("tbc"); auto readSpellMissGuid = [&]() -> uint64_t { - if (spellMissTbcLike) + if (spellMissUsesFullGuid) return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0; return UpdateObjectParser::readPackedGuid(packet); }; // spellId prefix present in all expansions if (packet.getSize() - packet.getReadPos() < 4) break; - /*uint32_t spellId =*/ packet.readUInt32(); - if (packet.getSize() - packet.getReadPos() < (spellMissTbcLike ? 8 : 1)) break; + uint32_t spellId = packet.readUInt32(); + if (packet.getSize() - packet.getReadPos() < (spellMissUsesFullGuid ? 8u : 1u)) break; uint64_t casterGuid = readSpellMissGuid(); if (packet.getSize() - packet.getReadPos() < 5) break; /*uint8_t unk =*/ packet.readUInt8(); uint32_t count = packet.readUInt32(); count = std::min(count, 32u); for (uint32_t i = 0; i < count; ++i) { - if (packet.getSize() - packet.getReadPos() < (spellMissTbcLike ? 9u : 2u)) break; + if (packet.getSize() - packet.getReadPos() < (spellMissUsesFullGuid ? 9u : 2u)) break; uint64_t victimGuid = readSpellMissGuid(); if (packet.getSize() - packet.getReadPos() < 1) break; uint8_t missInfo = packet.readUInt8(); // REFLECT (11): extra uint32 reflectSpellId + uint8 reflectResult - if (missInfo == 11 && !spellMissTbcLike) { + if (missInfo == 11) { if (packet.getSize() - packet.getReadPos() >= 5) { /*uint32_t reflectSpellId =*/ packet.readUInt32(); /*uint8_t reflectResult =*/ packet.readUInt8(); @@ -2683,19 +2721,20 @@ void GameHandler::handlePacket(network::Packet& packet) { CombatTextEntry::DODGE, // 1=DODGE CombatTextEntry::PARRY, // 2=PARRY CombatTextEntry::BLOCK, // 3=BLOCK - CombatTextEntry::MISS, // 4=EVADE + CombatTextEntry::EVADE, // 4=EVADE CombatTextEntry::IMMUNE, // 5=IMMUNE - CombatTextEntry::MISS, // 6=DEFLECT + CombatTextEntry::DEFLECT, // 6=DEFLECT CombatTextEntry::ABSORB, // 7=ABSORB CombatTextEntry::RESIST, // 8=RESIST }; - CombatTextEntry::Type ct = (missInfo < 9) ? missTypes[missInfo] : CombatTextEntry::MISS; + CombatTextEntry::Type ct = (missInfo < 9) ? missTypes[missInfo] + : (missInfo == 11 ? CombatTextEntry::REFLECT : CombatTextEntry::MISS); if (casterGuid == playerGuid) { // We cast a spell and it missed the target - addCombatText(ct, 0, 0, true, 0, casterGuid, victimGuid); + addCombatText(ct, 0, spellId, true, 0, casterGuid, victimGuid); } else if (victimGuid == playerGuid) { // Enemy spell missed us (we dodged/parried/blocked/resisted/etc.) - addCombatText(ct, 0, 0, false, 0, casterGuid, victimGuid); + addCombatText(ct, 0, spellId, false, 0, casterGuid, victimGuid); } } break; @@ -3954,13 +3993,20 @@ void GameHandler::handlePacket(network::Packet& packet) { } else if (auraType == 98) { // PERIODIC_MANA_LEECH: miscValue(powerType) + amount + float multiplier if (packet.getSize() - packet.getReadPos() < 12) break; - /*uint32_t powerType =*/ packet.readUInt32(); + uint8_t powerType = static_cast(packet.readUInt32()); uint32_t amount = packet.readUInt32(); - /*float multiplier =*/ packet.readUInt32(); // read as raw uint32 (float bits) - // Show as periodic damage from victim's perspective (mana drained) + float multiplier = packet.readFloat(); if (isPlayerVictim && amount > 0) - addCombatText(CombatTextEntry::PERIODIC_DAMAGE, static_cast(amount), - spellId, false, 0, casterGuid, victimGuid); + addCombatText(CombatTextEntry::POWER_DRAIN, static_cast(amount), + spellId, false, powerType, casterGuid, victimGuid); + if (isPlayerCaster && amount > 0 && multiplier > 0.0f && std::isfinite(multiplier)) { + const uint32_t gainedAmount = static_cast( + std::lround(static_cast(amount) * static_cast(multiplier))); + if (gainedAmount > 0) { + addCombatText(CombatTextEntry::ENERGIZE, static_cast(gainedAmount), + spellId, true, powerType, casterGuid, casterGuid); + } + } } else { // Unknown/untracked aura type — stop parsing this event safely packet.setReadPos(packet.getSize()); @@ -3975,14 +4021,22 @@ void GameHandler::handlePacket(network::Packet& packet) { // TBC: full uint64 victim + uint64 caster + uint32 spellId + uint8 powerType + int32 amount // Classic/Vanilla: packed_guid (same as WotLK) const bool energizeTbc = isActiveExpansion("tbc"); - size_t rem = packet.getSize() - packet.getReadPos(); - if (rem < (energizeTbc ? 8u : 2u)) { packet.setReadPos(packet.getSize()); break; } - uint64_t victimGuid = energizeTbc - ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); - uint64_t casterGuid = energizeTbc - ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); - rem = packet.getSize() - packet.getReadPos(); - if (rem < 6) { packet.setReadPos(packet.getSize()); break; } + auto readEnergizeGuid = [&]() -> uint64_t { + if (energizeTbc) + return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0; + return UpdateObjectParser::readPackedGuid(packet); + }; + if (packet.getSize() - packet.getReadPos() < (energizeTbc ? 8u : 1u)) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t victimGuid = readEnergizeGuid(); + if (packet.getSize() - packet.getReadPos() < (energizeTbc ? 8u : 1u)) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t casterGuid = readEnergizeGuid(); + if (packet.getSize() - packet.getReadPos() < 9) { + packet.setReadPos(packet.getSize()); break; + } uint32_t spellId = packet.readUInt32(); uint8_t energizePowerType = packet.readUInt8(); int32_t amount = static_cast(packet.readUInt32()); @@ -6087,8 +6141,6 @@ void GameHandler::handlePacket(network::Packet& packet) { } // ---- Spell combat logs (consume) ---- - case Opcode::SMSG_AURACASTLOG: - case Opcode::SMSG_SPELLBREAKLOG: case Opcode::SMSG_SPELLDAMAGESHIELD: { // Classic/TBC: uint64 victim + uint64 caster + spellId(4) + damage(4) + schoolMask(4) // WotLK: packed_guid victim + packed_guid caster + spellId(4) + damage(4) + absorbed(4) + schoolMask(4) @@ -6119,6 +6171,12 @@ void GameHandler::handlePacket(network::Packet& packet) { } break; } + case Opcode::SMSG_AURACASTLOG: + case Opcode::SMSG_SPELLBREAKLOG: + // These packets are not damage-shield events. Consume them without + // synthesizing reflected damage entries or misattributing GUIDs. + packet.setReadPos(packet.getSize()); + break; case Opcode::SMSG_SPELLORDAMAGE_IMMUNE: { // WotLK: packed casterGuid + packed victimGuid + uint32 spellId + uint8 saveType // TBC/Classic: full uint64 casterGuid + full uint64 victimGuid + uint32 + uint8 @@ -6160,37 +6218,68 @@ void GameHandler::handlePacket(network::Packet& packet) { /*uint32_t dispelSpell =*/ packet.readUInt32(); uint8_t isStolen = packet.readUInt8(); uint32_t count = packet.readUInt32(); - // Collect first dispelled spell id/name; process all entries for combat log - // Each entry: uint32 spellId + uint8 isPositive (5 bytes in WotLK/TBC/Classic) - uint32_t firstDispelledId = 0; - std::string firstSpellName; - for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 5; ++i) { + // Preserve every dispelled aura in the combat log instead of collapsing + // multi-aura packets down to the first entry only. + const size_t dispelEntrySize = dispelTbcLike ? 8u : 5u; + std::vector dispelledIds; + dispelledIds.reserve(count); + for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= dispelEntrySize; ++i) { uint32_t dispelledId = packet.readUInt32(); - /*uint8_t isPositive =*/ packet.readUInt8(); - if (i == 0) { - firstDispelledId = dispelledId; - const std::string& nm = getSpellName(dispelledId); - firstSpellName = nm.empty() ? ("spell " + std::to_string(dispelledId)) : nm; + if (dispelTbcLike) { + /*uint32_t unk =*/ packet.readUInt32(); + } else { + /*uint8_t isPositive =*/ packet.readUInt8(); + } + if (dispelledId != 0) { + dispelledIds.push_back(dispelledId); } } // Show system message if player was victim or caster if (victimGuid == playerGuid || casterGuid == playerGuid) { - const char* verb = isStolen ? "stolen" : "dispelled"; - if (!firstSpellName.empty()) { + std::vector loggedIds; + if (isStolen) { + loggedIds.reserve(dispelledIds.size()); + for (uint32_t dispelledId : dispelledIds) { + if (shouldLogSpellstealAura(casterGuid, victimGuid, dispelledId)) + loggedIds.push_back(dispelledId); + } + } else { + loggedIds = dispelledIds; + } + + const std::string displaySpellNames = formatSpellNameList(*this, loggedIds); + if (!displaySpellNames.empty()) { char buf[256]; - if (victimGuid == playerGuid && casterGuid != playerGuid) - std::snprintf(buf, sizeof(buf), "%s was %s.", firstSpellName.c_str(), verb); - else if (casterGuid == playerGuid) - std::snprintf(buf, sizeof(buf), "You %s %s.", verb, firstSpellName.c_str()); - else - std::snprintf(buf, sizeof(buf), "%s %s.", firstSpellName.c_str(), verb); + const char* passiveVerb = loggedIds.size() == 1 ? "was" : "were"; + if (isStolen) { + if (victimGuid == playerGuid && casterGuid != playerGuid) + std::snprintf(buf, sizeof(buf), "%s %s stolen.", + displaySpellNames.c_str(), passiveVerb); + else if (casterGuid == playerGuid) + std::snprintf(buf, sizeof(buf), "You steal %s.", displaySpellNames.c_str()); + else + std::snprintf(buf, sizeof(buf), "%s %s stolen.", + displaySpellNames.c_str(), passiveVerb); + } else { + if (victimGuid == playerGuid && casterGuid != playerGuid) + std::snprintf(buf, sizeof(buf), "%s %s dispelled.", + displaySpellNames.c_str(), passiveVerb); + else if (casterGuid == playerGuid) + std::snprintf(buf, sizeof(buf), "You dispel %s.", displaySpellNames.c_str()); + else + std::snprintf(buf, sizeof(buf), "%s %s dispelled.", + displaySpellNames.c_str(), passiveVerb); + } addSystemChatMessage(buf); } - // Add dispel event to combat log - if (firstDispelledId != 0) { + // Preserve stolen auras as spellsteal events so the log wording stays accurate. + if (!loggedIds.empty()) { bool isPlayerCaster = (casterGuid == playerGuid); - addCombatText(CombatTextEntry::DISPEL, 0, firstDispelledId, isPlayerCaster, 0, - casterGuid, victimGuid); + for (uint32_t dispelledId : loggedIds) { + addCombatText(isStolen ? CombatTextEntry::STEAL : CombatTextEntry::DISPEL, + 0, dispelledId, isPlayerCaster, 0, + casterGuid, victimGuid); + } } } packet.setReadPos(packet.getSize()); @@ -6219,47 +6308,69 @@ void GameHandler::handlePacket(network::Packet& packet) { /*uint32_t stealSpellId =*/ packet.readUInt32(); /*uint8_t isStolen =*/ packet.readUInt8(); uint32_t stealCount = packet.readUInt32(); - // Collect stolen spell info; show feedback when we are caster or victim - uint32_t firstStolenId = 0; - std::string stolenName; - for (uint32_t i = 0; i < stealCount && packet.getSize() - packet.getReadPos() >= 5; ++i) { + // Preserve every stolen aura in the combat log instead of only the first. + const size_t stealEntrySize = stealTbcLike ? 8u : 5u; + std::vector stolenIds; + stolenIds.reserve(stealCount); + for (uint32_t i = 0; i < stealCount && packet.getSize() - packet.getReadPos() >= stealEntrySize; ++i) { uint32_t stolenId = packet.readUInt32(); - /*uint8_t isPos =*/ packet.readUInt8(); - if (i == 0) { - firstStolenId = stolenId; - const std::string& nm = getSpellName(stolenId); - stolenName = nm.empty() ? ("spell " + std::to_string(stolenId)) : nm; + if (stealTbcLike) { + /*uint32_t unk =*/ packet.readUInt32(); + } else { + /*uint8_t isPos =*/ packet.readUInt8(); + } + if (stolenId != 0) { + stolenIds.push_back(stolenId); } } if (stealCaster == playerGuid || stealVictim == playerGuid) { - if (!stolenName.empty()) { + std::vector loggedIds; + loggedIds.reserve(stolenIds.size()); + for (uint32_t stolenId : stolenIds) { + if (shouldLogSpellstealAura(stealCaster, stealVictim, stolenId)) + loggedIds.push_back(stolenId); + } + + const std::string displaySpellNames = formatSpellNameList(*this, loggedIds); + if (!displaySpellNames.empty()) { char buf[256]; if (stealCaster == playerGuid) - std::snprintf(buf, sizeof(buf), "You stole %s.", stolenName.c_str()); + std::snprintf(buf, sizeof(buf), "You stole %s.", displaySpellNames.c_str()); else - std::snprintf(buf, sizeof(buf), "%s was stolen.", stolenName.c_str()); + std::snprintf(buf, sizeof(buf), "%s %s stolen.", displaySpellNames.c_str(), + loggedIds.size() == 1 ? "was" : "were"); addSystemChatMessage(buf); } - // Add dispel/steal to combat log using DISPEL type (isStolen=true for steals) - if (firstStolenId != 0) { + // Some servers emit both SPELLDISPELLOG(isStolen=1) and SPELLSTEALLOG + // for the same aura. Keep the first event and suppress the duplicate. + if (!loggedIds.empty()) { bool isPlayerCaster = (stealCaster == playerGuid); - addCombatText(CombatTextEntry::DISPEL, 0, firstStolenId, isPlayerCaster, 0, - stealCaster, stealVictim); + for (uint32_t stolenId : loggedIds) { + addCombatText(CombatTextEntry::STEAL, 0, stolenId, isPlayerCaster, 0, + stealCaster, stealVictim); + } } } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_SPELL_CHANCE_PROC_LOG: { - // Format (all expansions): PackedGuid target + PackedGuid caster + uint32 spellId + ... - if (packet.getSize() - packet.getReadPos() < 3) { + // WotLK: packed_guid target + packed_guid caster + uint32 spellId + ... + // TBC/Classic: uint64 target + uint64 caster + uint32 spellId + ... + const bool procChanceTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + auto readProcChanceGuid = [&]() -> uint64_t { + if (procChanceTbcLike) + return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0; + return UpdateObjectParser::readPackedGuid(packet); + }; + if (packet.getSize() - packet.getReadPos() < (procChanceTbcLike ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } - uint64_t procTargetGuid = UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() < 2) { + uint64_t procTargetGuid = readProcChanceGuid(); + if (packet.getSize() - packet.getReadPos() < (procChanceTbcLike ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } - uint64_t procCasterGuid = UpdateObjectParser::readPackedGuid(packet); + uint64_t procCasterGuid = readProcChanceGuid(); if (packet.getSize() - packet.getReadPos() < 4) { packet.setReadPos(packet.getSize()); break; } @@ -6286,11 +6397,9 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t ikSpell = (ik_rem() >= 4) ? packet.readUInt32() : 0; // Show kill/death feedback for the local player if (ikCaster == playerGuid) { - // We killed a target instantly — show a KILL combat text hit - addCombatText(CombatTextEntry::MELEE_DAMAGE, 0, ikSpell, true, 0, ikCaster, ikVictim); + addCombatText(CombatTextEntry::INSTAKILL, 0, ikSpell, true, 0, ikCaster, ikVictim); } else if (ikVictim == playerGuid) { - // We were instantly killed — show a large incoming hit - addCombatText(CombatTextEntry::MELEE_DAMAGE, 0, ikSpell, false, 0, ikCaster, ikVictim); + addCombatText(CombatTextEntry::INSTAKILL, 0, ikSpell, false, 0, ikCaster, ikVictim); addSystemChatMessage("You were killed by an instant-kill effect."); } LOG_DEBUG("SMSG_SPELLINSTAKILLLOG: caster=0x", std::hex, ikCaster, @@ -6309,9 +6418,26 @@ void GameHandler::handlePacket(network::Packet& packet) { // Effect 49 = FEED_PET: uint32 itemEntry // Effect 114= CREATE_ITEM2: uint32 itemEntry (same layout as CREATE_ITEM) const bool exeTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + const auto hasFullPackedGuid = [&packet]() -> bool { + if (packet.getReadPos() >= packet.getSize()) { + return false; + } + const auto& rawData = packet.getData(); + const uint8_t mask = rawData[packet.getReadPos()]; + size_t guidBytes = 1; + for (int bit = 0; bit < 8; ++bit) { + if ((mask & (1u << bit)) != 0) { + ++guidBytes; + } + } + return packet.getSize() - packet.getReadPos() >= guidBytes; + }; if (packet.getSize() - packet.getReadPos() < (exeTbcLike ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } + if (!exeTbcLike && !hasFullPackedGuid()) { + packet.setReadPos(packet.getSize()); break; + } uint64_t exeCaster = exeTbcLike ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 8) { @@ -6330,31 +6456,50 @@ void GameHandler::handlePacket(network::Packet& packet) { if (effectType == 10) { // SPELL_EFFECT_POWER_DRAIN: packed_guid target + uint32 amount + uint32 powerType + float multiplier for (uint32_t li = 0; li < effectLogCount; ++li) { - if (packet.getSize() - packet.getReadPos() < 1) break; + if (packet.getSize() - packet.getReadPos() < (exeTbcLike ? 8u : 1u) + || (!exeTbcLike && !hasFullPackedGuid())) { + packet.setReadPos(packet.getSize()); break; + } uint64_t drainTarget = exeTbcLike - ? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0) + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 12) { packet.setReadPos(packet.getSize()); break; } uint32_t drainAmount = packet.readUInt32(); uint32_t drainPower = packet.readUInt32(); // 0=mana,1=rage,3=energy,6=runic - /*float drainMult =*/ packet.readFloat(); + float drainMult = packet.readFloat(); if (drainAmount > 0) { if (drainTarget == playerGuid) - addCombatText(CombatTextEntry::PERIODIC_DAMAGE, static_cast(drainAmount), exeSpellId, false, 0, + addCombatText(CombatTextEntry::POWER_DRAIN, static_cast(drainAmount), exeSpellId, false, + static_cast(drainPower), exeCaster, drainTarget); - else if (isPlayerCaster) - addCombatText(CombatTextEntry::ENERGIZE, static_cast(drainAmount), exeSpellId, true, - static_cast(drainPower), exeCaster, drainTarget); + if (isPlayerCaster) { + if (drainTarget != playerGuid) { + addCombatText(CombatTextEntry::POWER_DRAIN, static_cast(drainAmount), exeSpellId, true, + static_cast(drainPower), exeCaster, drainTarget); + } + if (drainMult > 0.0f && std::isfinite(drainMult)) { + const uint32_t gainedAmount = static_cast( + std::lround(static_cast(drainAmount) * static_cast(drainMult))); + if (gainedAmount > 0) { + addCombatText(CombatTextEntry::ENERGIZE, static_cast(gainedAmount), exeSpellId, true, + static_cast(drainPower), exeCaster, exeCaster); + } + } + } } LOG_DEBUG("SMSG_SPELLLOGEXECUTE POWER_DRAIN: spell=", exeSpellId, - " power=", drainPower, " amount=", drainAmount); + " power=", drainPower, " amount=", drainAmount, + " multiplier=", drainMult); } } else if (effectType == 11) { // SPELL_EFFECT_HEALTH_LEECH: packed_guid target + uint32 amount + float multiplier for (uint32_t li = 0; li < effectLogCount; ++li) { - if (packet.getSize() - packet.getReadPos() < 1) break; + if (packet.getSize() - packet.getReadPos() < (exeTbcLike ? 8u : 1u) + || (!exeTbcLike && !hasFullPackedGuid())) { + packet.setReadPos(packet.getSize()); break; + } uint64_t leechTarget = exeTbcLike - ? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0) + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 8) { packet.setReadPos(packet.getSize()); break; } uint32_t leechAmount = packet.readUInt32(); @@ -6365,7 +6510,7 @@ void GameHandler::handlePacket(network::Packet& packet) { exeCaster, leechTarget); else if (isPlayerCaster) addCombatText(CombatTextEntry::HEAL, static_cast(leechAmount), exeSpellId, true, 0, - exeCaster, leechTarget); + exeCaster, exeCaster); } LOG_DEBUG("SMSG_SPELLLOGEXECUTE HEALTH_LEECH: spell=", exeSpellId, " amount=", leechAmount); } @@ -6394,9 +6539,12 @@ void GameHandler::handlePacket(network::Packet& packet) { } else if (effectType == 26) { // SPELL_EFFECT_INTERRUPT_CAST: packed_guid target + uint32 interrupted_spell_id for (uint32_t li = 0; li < effectLogCount; ++li) { - if (packet.getSize() - packet.getReadPos() < 1) break; + if (packet.getSize() - packet.getReadPos() < (exeTbcLike ? 8u : 1u) + || (!exeTbcLike && !hasFullPackedGuid())) { + packet.setReadPos(packet.getSize()); break; + } uint64_t icTarget = exeTbcLike - ? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0) + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 4) { packet.setReadPos(packet.getSize()); break; } uint32_t icSpellId = packet.readUInt32(); @@ -6860,11 +7008,11 @@ void GameHandler::handlePacket(network::Packet& packet) { ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (rl_rem() < 4) { packet.setReadPos(packet.getSize()); break; } uint32_t spellId = packet.readUInt32(); - // Show RESIST when player is the victim; show as caster-side MISS when player is attacker + // Show RESIST when the player is involved on either side. if (victimGuid == playerGuid) { - addCombatText(CombatTextEntry::MISS, 0, spellId, false, 0, attackerGuid, victimGuid); + addCombatText(CombatTextEntry::RESIST, 0, spellId, false, 0, attackerGuid, victimGuid); } else if (attackerGuid == playerGuid) { - addCombatText(CombatTextEntry::MISS, 0, spellId, true, 0, attackerGuid, victimGuid); + addCombatText(CombatTextEntry::RESIST, 0, spellId, true, 0, attackerGuid, victimGuid); } packet.setReadPos(packet.getSize()); break; @@ -14061,6 +14209,31 @@ void GameHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, uint combatLog_.push_back(std::move(log)); } +bool GameHandler::shouldLogSpellstealAura(uint64_t casterGuid, uint64_t victimGuid, uint32_t spellId) { + if (spellId == 0) return false; + + const auto now = std::chrono::steady_clock::now(); + constexpr auto kRecentWindow = std::chrono::seconds(1); + while (!recentSpellstealLogs_.empty() && + now - recentSpellstealLogs_.front().timestamp > kRecentWindow) { + recentSpellstealLogs_.pop_front(); + } + + for (auto it = recentSpellstealLogs_.begin(); it != recentSpellstealLogs_.end(); ++it) { + if (it->casterGuid == casterGuid && + it->victimGuid == victimGuid && + it->spellId == spellId) { + recentSpellstealLogs_.erase(it); + return false; + } + } + + if (recentSpellstealLogs_.size() >= MAX_RECENT_SPELLSTEAL_LOGS) + recentSpellstealLogs_.pop_front(); + recentSpellstealLogs_.push_back({casterGuid, victimGuid, spellId, now}); + return true; +} + void GameHandler::updateCombatText(float deltaTime) { for (auto& entry : combatText) { entry.age += deltaTime; @@ -16253,14 +16426,14 @@ void GameHandler::handleAttackerStateUpdate(network::Packet& packet) { addCombatText(CombatTextEntry::MELEE_DAMAGE, data.totalDamage, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); addCombatText(CombatTextEntry::BLOCK, static_cast(data.blocked), 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else if (data.victimState == 5) { - // VICTIMSTATE_EVADE: NPC evaded (out of combat zone). Show as miss. - addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); + // VICTIMSTATE_EVADE: NPC evaded (out of combat zone). + addCombatText(CombatTextEntry::EVADE, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else if (data.victimState == 6) { // VICTIMSTATE_IS_IMMUNE: Target is immune to this attack. addCombatText(CombatTextEntry::IMMUNE, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else if (data.victimState == 7) { // VICTIMSTATE_DEFLECT: Attack was deflected (e.g. shield slam reflect). - addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); + addCombatText(CombatTextEntry::DEFLECT, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else { auto type = data.isCrit() ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::MELEE_DAMAGE; addCombatText(type, data.totalDamage, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); @@ -16862,23 +17035,30 @@ void GameHandler::handleSpellGo(network::Packet& packet) { // Clear unit cast bar when the spell lands (for any tracked unit) unitCastStates_.erase(data.casterUnit); - // Show miss/dodge/parry/etc combat text when player's spells miss targets - if (data.casterUnit == playerGuid && !data.missTargets.empty()) { + // Preserve spellId and actual participants for spell-go miss results. + // This keeps the persistent combat log aligned with the later GUID fixes. + if (!data.missTargets.empty()) { static const CombatTextEntry::Type missTypes[] = { CombatTextEntry::MISS, // 0=MISS CombatTextEntry::DODGE, // 1=DODGE CombatTextEntry::PARRY, // 2=PARRY CombatTextEntry::BLOCK, // 3=BLOCK - CombatTextEntry::MISS, // 4=EVADE + CombatTextEntry::EVADE, // 4=EVADE CombatTextEntry::IMMUNE, // 5=IMMUNE - CombatTextEntry::MISS, // 6=DEFLECT + CombatTextEntry::DEFLECT, // 6=DEFLECT CombatTextEntry::ABSORB, // 7=ABSORB CombatTextEntry::RESIST, // 8=RESIST }; - // Show text for each miss (usually just 1 target per spell go) + const uint64_t spellCasterGuid = data.casterUnit != 0 ? data.casterUnit : data.casterGuid; + const bool playerIsCaster = (spellCasterGuid == playerGuid); + for (const auto& m : data.missTargets) { - CombatTextEntry::Type ct = (m.missType < 9) ? missTypes[m.missType] : CombatTextEntry::MISS; - addCombatText(ct, 0, 0, true); + if (!playerIsCaster && m.targetGuid != playerGuid) { + continue; + } + CombatTextEntry::Type ct = (m.missType < 9) ? missTypes[m.missType] + : (m.missType == 11 ? CombatTextEntry::REFLECT : CombatTextEntry::MISS); + addCombatText(ct, 0, data.spellId, playerIsCaster, 0, spellCasterGuid, m.targetGuid); } } diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 041af211..7077d0ab 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -421,6 +421,11 @@ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& da m.targetGuid = UpdateObjectParser::readPackedGuid(packet); if (rem() < 1) break; m.missType = packet.readUInt8(); + if (m.missType == 11) { + if (rem() < 5) break; + (void)packet.readUInt32(); + (void)packet.readUInt8(); + } data.missTargets.push_back(m); } // Check if we read all expected misses diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index 935b34ae..d218926a 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -1261,7 +1261,9 @@ bool TbcPacketParsers::parseSpellStart(network::Packet& packet, SpellStartData& // WotLK uses packed GUIDs and adds a timestamp (u32) after castFlags. // ============================================================================ bool TbcPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data) { - if (packet.getSize() - packet.getReadPos() < 19) return false; + // Fixed header before hit/miss lists: + // casterGuid(u64) + casterUnit(u64) + castCount(u8) + spellId(u32) + castFlags(u32) + if (packet.getSize() - packet.getReadPos() < 25) return false; data.casterGuid = packet.readUInt64(); // full GUID in TBC data.casterUnit = packet.readUInt64(); // full GUID in TBC @@ -1304,6 +1306,13 @@ bool TbcPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data) SpellGoMissEntry m; m.targetGuid = packet.readUInt64(); // full GUID in TBC m.missType = packet.readUInt8(); + if (m.missType == 11) { + if (packet.getReadPos() + 5 > packet.getSize()) { + break; + } + (void)packet.readUInt32(); + (void)packet.readUInt8(); + } data.missTargets.push_back(m); } // Check if we read all expected misses diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index dbcbf4c9..4c0a0b1e 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3632,8 +3632,9 @@ bool SpellStartParser::parse(network::Packet& packet, SpellStartData& data) { } bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { - // Upfront validation: packed GUID(1-8) + packed GUID(1-8) + castCount(1) + spellId(4) + castFlags(4) + timestamp(4) + hitCount(1) + missCount(1) = 24 bytes minimum - if (packet.getSize() - packet.getReadPos() < 24) return false; + // Packed GUIDs are variable-length, so only require the smallest possible + // shape up front: 2 GUID masks + fixed fields through missCount. + if (packet.getSize() - packet.getReadPos() < 17) return false; size_t startPos = packet.getReadPos(); data.casterGuid = UpdateObjectParser::readPackedGuid(packet); @@ -3660,12 +3661,13 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { data.hitTargets.reserve(data.hitCount); for (uint8_t i = 0; i < data.hitCount; ++i) { - if (packet.getSize() - packet.getReadPos() < 8) { + // WotLK hit targets are packed GUIDs, like the caster and miss targets. + if (packet.getSize() - packet.getReadPos() < 1) { LOG_WARNING("Spell go: truncated hit targets at index ", (int)i, "/", (int)data.hitCount); data.hitCount = i; break; } - data.hitTargets.push_back(packet.readUInt64()); + data.hitTargets.push_back(UpdateObjectParser::readPackedGuid(packet)); } // Validate missCount field exists @@ -3682,7 +3684,8 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { data.missTargets.reserve(data.missCount); for (uint8_t i = 0; i < data.missCount; ++i) { - // Each miss entry: packed GUID(1-8 bytes) + missType(1 byte), validate before reading + // Each miss entry: packed GUID(1-8 bytes) + missType(1 byte). + // REFLECT additionally appends uint32 reflectSpellId + uint8 reflectResult. if (packet.getSize() - packet.getReadPos() < 2) { LOG_WARNING("Spell go: truncated miss targets at index ", (int)i, "/", (int)data.missCount); data.missCount = i; @@ -3691,6 +3694,15 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { SpellGoMissEntry m; m.targetGuid = UpdateObjectParser::readPackedGuid(packet); // packed GUID in WotLK m.missType = (packet.getSize() - packet.getReadPos() >= 1) ? packet.readUInt8() : 0; + if (m.missType == 11) { + if (packet.getSize() - packet.getReadPos() < 5) { + LOG_WARNING("Spell go: truncated reflect payload at miss index ", (int)i, "/", (int)data.missCount); + data.missCount = i; + break; + } + (void)packet.readUInt32(); + (void)packet.readUInt8(); + } data.missTargets.push_back(m); } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 2f30ee64..788150b6 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8342,6 +8342,11 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) : ImVec4(0.4f, 0.9f, 1.0f, alpha); break; + case game::CombatTextEntry::EVADE: + snprintf(text, sizeof(text), outgoing ? "Evade" : "You Evade"); + color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) + : ImVec4(0.4f, 0.9f, 1.0f, alpha); + break; case game::CombatTextEntry::PERIODIC_DAMAGE: snprintf(text, sizeof(text), "-%d", entry.amount); color = outgoing ? @@ -8366,6 +8371,16 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { default: color = ImVec4(0.3f, 0.6f, 1.0f, alpha); break; // Mana (0): blue } break; + case game::CombatTextEntry::POWER_DRAIN: + snprintf(text, sizeof(text), "-%d", entry.amount); + switch (entry.powerType) { + case 1: color = ImVec4(1.0f, 0.35f, 0.35f, alpha); break; + case 2: color = ImVec4(1.0f, 0.7f, 0.2f, alpha); break; + case 3: color = ImVec4(1.0f, 0.95f, 0.35f, alpha); break; + case 6: color = ImVec4(0.45f, 0.95f, 0.85f, alpha); break; + default: color = ImVec4(0.45f, 0.75f, 1.0f, alpha); break; + } + break; case game::CombatTextEntry::XP_GAIN: snprintf(text, sizeof(text), "+%d XP", entry.amount); color = ImVec4(0.7f, 0.3f, 1.0f, alpha); // Purple for XP @@ -8388,6 +8403,16 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { snprintf(text, sizeof(text), "Resisted"); color = ImVec4(0.7f, 0.7f, 0.7f, alpha); // Grey for resist break; + case game::CombatTextEntry::DEFLECT: + snprintf(text, sizeof(text), outgoing ? "Deflect" : "You Deflect"); + color = outgoing ? ImVec4(0.7f, 0.7f, 0.7f, alpha) + : ImVec4(0.5f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::REFLECT: + snprintf(text, sizeof(text), outgoing ? "Reflected" : "You Reflect"); + color = outgoing ? ImVec4(0.85f, 0.75f, 1.0f, alpha) + : ImVec4(0.75f, 0.85f, 1.0f, alpha); + break; case game::CombatTextEntry::PROC_TRIGGER: { const std::string& procName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; if (!procName.empty()) @@ -8397,6 +8422,44 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { color = ImVec4(1.0f, 0.85f, 0.0f, alpha); // Gold for proc break; } + case game::CombatTextEntry::DISPEL: + if (entry.spellId != 0) { + const std::string& dispelledName = gameHandler.getSpellName(entry.spellId); + if (!dispelledName.empty()) + snprintf(text, sizeof(text), "Dispel %s", dispelledName.c_str()); + else + snprintf(text, sizeof(text), "Dispel"); + } else { + snprintf(text, sizeof(text), "Dispel"); + } + color = ImVec4(0.6f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::STEAL: + if (entry.spellId != 0) { + const std::string& stolenName = gameHandler.getSpellName(entry.spellId); + if (!stolenName.empty()) + snprintf(text, sizeof(text), "Spellsteal %s", stolenName.c_str()); + else + snprintf(text, sizeof(text), "Spellsteal"); + } else { + snprintf(text, sizeof(text), "Spellsteal"); + } + color = ImVec4(0.8f, 0.7f, 1.0f, alpha); + break; + case game::CombatTextEntry::INTERRUPT: { + const std::string& interruptedName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; + if (!interruptedName.empty()) + snprintf(text, sizeof(text), "Interrupt %s", interruptedName.c_str()); + else + snprintf(text, sizeof(text), "Interrupt"); + color = ImVec4(1.0f, 0.6f, 0.9f, alpha); + break; + } + case game::CombatTextEntry::INSTAKILL: + snprintf(text, sizeof(text), outgoing ? "Kill!" : "Killed!"); + color = outgoing ? ImVec4(1.0f, 0.25f, 0.25f, alpha) + : ImVec4(1.0f, 0.1f, 0.1f, alpha); + break; default: snprintf(text, sizeof(text), "%d", entry.amount); color = ImVec4(1.0f, 1.0f, 1.0f, alpha); @@ -20214,6 +20277,13 @@ void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { snprintf(desc, sizeof(desc), "%s blocks %s's attack (%d blocked)", tgt, src, e.amount); color = ImVec4(0.65f, 0.75f, 0.65f, 1.0f); break; + case T::EVADE: + if (spell) + snprintf(desc, sizeof(desc), "%s evades %s's %s", tgt, src, spell); + else + snprintf(desc, sizeof(desc), "%s evades %s's attack", tgt, src); + color = ImVec4(0.65f, 0.65f, 0.65f, 1.0f); + break; case T::IMMUNE: if (spell) snprintf(desc, sizeof(desc), "%s is immune to %s", tgt, spell); @@ -20243,6 +20313,20 @@ void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { snprintf(desc, sizeof(desc), "Resisted"); color = ImVec4(0.6f, 0.6f, 0.9f, 1.0f); break; + case T::DEFLECT: + if (spell) + snprintf(desc, sizeof(desc), "%s deflects %s's %s", tgt, src, spell); + else + snprintf(desc, sizeof(desc), "%s deflects %s's attack", tgt, src); + color = ImVec4(0.65f, 0.8f, 0.95f, 1.0f); + break; + case T::REFLECT: + if (spell) + snprintf(desc, sizeof(desc), "%s reflects %s's %s", tgt, src, spell); + else + snprintf(desc, sizeof(desc), "%s reflects %s's attack", tgt, src); + color = ImVec4(0.8f, 0.7f, 1.0f, 1.0f); + break; case T::ENVIRONMENTAL: snprintf(desc, sizeof(desc), "Environmental damage: %d", e.amount); color = ImVec4(1.0f, 0.5f, 0.2f, 1.0f); @@ -20254,6 +20338,13 @@ void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { snprintf(desc, sizeof(desc), "%s gains %d power", tgt, e.amount); color = ImVec4(0.4f, 0.6f, 1.0f, 1.0f); break; + case T::POWER_DRAIN: + if (spell) + snprintf(desc, sizeof(desc), "%s loses %d power to %s's %s", tgt, e.amount, src, spell); + else + snprintf(desc, sizeof(desc), "%s loses %d power", tgt, e.amount); + color = ImVec4(0.45f, 0.75f, 1.0f, 1.0f); + break; case T::XP_GAIN: snprintf(desc, sizeof(desc), "You gain %d experience", e.amount); color = ImVec4(0.8f, 0.6f, 1.0f, 1.0f); @@ -20276,6 +20367,17 @@ void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { snprintf(desc, sizeof(desc), "%s dispels from %s", src, tgt); color = ImVec4(0.6f, 0.9f, 1.0f, 1.0f); break; + case T::STEAL: + if (spell && e.isPlayerSource) + snprintf(desc, sizeof(desc), "You steal %s from %s", spell, tgt); + else if (spell) + snprintf(desc, sizeof(desc), "%s steals %s from %s", src, spell, tgt); + else if (e.isPlayerSource) + snprintf(desc, sizeof(desc), "You steal from %s", tgt); + else + snprintf(desc, sizeof(desc), "%s steals from %s", src, tgt); + color = ImVec4(0.8f, 0.7f, 1.0f, 1.0f); + break; case T::INTERRUPT: if (spell && e.isPlayerSource) snprintf(desc, sizeof(desc), "You interrupt %s's %s", tgt, spell); @@ -20287,6 +20389,17 @@ void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { snprintf(desc, sizeof(desc), "%s interrupted", tgt); color = ImVec4(1.0f, 0.6f, 0.9f, 1.0f); break; + case T::INSTAKILL: + if (spell && e.isPlayerSource) + snprintf(desc, sizeof(desc), "You instantly kill %s with %s", tgt, spell); + else if (spell) + snprintf(desc, sizeof(desc), "%s instantly kills %s with %s", src, tgt, spell); + else if (e.isPlayerSource) + snprintf(desc, sizeof(desc), "You instantly kill %s", tgt); + else + snprintf(desc, sizeof(desc), "%s instantly kills %s", src, tgt); + color = ImVec4(1.0f, 0.2f, 0.2f, 1.0f); + break; default: snprintf(desc, sizeof(desc), "Combat event (type %d, amount %d)", (int)e.type, e.amount); color = ImVec4(0.7f, 0.7f, 0.7f, 1.0f);