diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 227a27d2..7a11d97b 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -393,6 +393,28 @@ public: void declineBattlefield(uint32_t queueSlot = 0xFFFFFFFF); const std::array& getBgQueues() const { return bgQueues_; } + // BG scoreboard (MSG_PVP_LOG_DATA) + struct BgPlayerScore { + uint64_t guid = 0; + std::string name; + uint8_t team = 0; // 0=Horde, 1=Alliance + uint32_t killingBlows = 0; + uint32_t deaths = 0; + uint32_t honorableKills = 0; + uint32_t bonusHonor = 0; + std::vector> bgStats; // BG-specific fields + }; + struct BgScoreboardData { + std::vector players; + bool hasWinner = false; + uint8_t winner = 0; // 0=Horde, 1=Alliance + bool isArena = false; + }; + void requestPvpLog(); + const BgScoreboardData* getBgScoreboard() const { + return bgScoreboard_.players.empty() ? nullptr : &bgScoreboard_; + } + // Network latency (milliseconds, updated each PONG response) uint32_t getLatencyMs() const { return lastLatency; } @@ -1921,6 +1943,7 @@ private: void handleArenaTeamEvent(network::Packet& packet); void handleArenaTeamStats(network::Packet& packet); void handleArenaError(network::Packet& packet); + void handlePvpLogData(network::Packet& packet); // ---- Bank handlers ---- void handleShowBank(network::Packet& packet); @@ -2283,6 +2306,9 @@ private: // Arena team stats (indexed by team slot, updated by SMSG_ARENA_TEAM_STATS) std::vector arenaTeamStats_; + // BG scoreboard (MSG_PVP_LOG_DATA) + BgScoreboardData bgScoreboard_; + // Instance encounter boss units (slots 0-4 from SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT) std::array encounterUnitGuids_ = {}; // 0 = empty slot diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index a21caf68..c2320681 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -434,6 +434,10 @@ private: // Threat window bool showThreatWindow_ = false; void renderThreatWindow(game::GameHandler& gameHandler); + + // BG scoreboard window + bool showBgScoreboard_ = false; + void renderBgScoreboard(game::GameHandler& gameHandler); uint8_t lfgRoles_ = 0x08; // default: DPS (0x02=tank, 0x04=healer, 0x08=dps) uint32_t lfgSelectedDungeon_ = 861; // default: random dungeon (entry 861 = Random Dungeon WotLK) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 6f59d00a..4e0b8ef2 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4988,7 +4988,7 @@ void GameHandler::handlePacket(network::Packet& packet) { handleArenaError(packet); break; case Opcode::MSG_PVP_LOG_DATA: - LOG_INFO("Received MSG_PVP_LOG_DATA"); + handlePvpLogData(packet); break; case Opcode::MSG_INSPECT_ARENA_TEAMS: LOG_INFO("Received MSG_INSPECT_ARENA_TEAMS"); @@ -5207,6 +5207,7 @@ void GameHandler::handlePacket(network::Packet& packet) { std::vector* auraList = nullptr; if (auraTargetGuid == playerGuid) auraList = &playerAuras; else if (auraTargetGuid == targetGuid) auraList = &targetAuras; + else if (auraTargetGuid != 0) auraList = &unitAurasCache_[auraTargetGuid]; if (auraList && isInit) auraList->clear(); @@ -13467,6 +13468,80 @@ void GameHandler::handleArenaError(network::Packet& packet) { LOG_INFO("Arena error: ", error, " - ", msg); } +void GameHandler::requestPvpLog() { + if (state != WorldState::IN_WORLD || !socket) return; + // MSG_PVP_LOG_DATA is bidirectional: client sends an empty packet to request + network::Packet pkt(wireOpcode(Opcode::MSG_PVP_LOG_DATA)); + socket->send(pkt); + LOG_INFO("Requested PvP log data"); +} + +void GameHandler::handlePvpLogData(network::Packet& packet) { + auto remaining = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (remaining() < 1) return; + + bgScoreboard_ = BgScoreboardData{}; + bgScoreboard_.isArena = (packet.readUInt8() != 0); + + if (bgScoreboard_.isArena) { + // Skip arena-specific header (two teams × (rating change uint32 + name string + 5×uint32)) + // Rather than hardcoding arena parse we skip gracefully up to playerCount + // Each arena team block: uint32 + string + uint32*5 — variable length due to string. + // Skip by scanning for the uint32 playerCount heuristically; simply consume rest. + packet.setReadPos(packet.getSize()); + return; + } + + if (remaining() < 4) return; + uint32_t playerCount = packet.readUInt32(); + bgScoreboard_.players.reserve(playerCount); + + for (uint32_t i = 0; i < playerCount && remaining() >= 13; ++i) { + BgPlayerScore ps; + ps.guid = packet.readUInt64(); + ps.team = packet.readUInt8(); + ps.killingBlows = packet.readUInt32(); + ps.honorableKills = packet.readUInt32(); + ps.deaths = packet.readUInt32(); + ps.bonusHonor = packet.readUInt32(); + + // Resolve player name from entity manager + { + auto ent = entityManager.getEntity(ps.guid); + if (ent && (ent->getType() == game::ObjectType::PLAYER || + ent->getType() == game::ObjectType::UNIT)) { + auto u = std::static_pointer_cast(ent); + if (!u->getName().empty()) ps.name = u->getName(); + } + } + + // BG-specific stat blocks: uint32 count + N × (string fieldName + uint32 value) + if (remaining() < 4) { bgScoreboard_.players.push_back(std::move(ps)); break; } + uint32_t statCount = packet.readUInt32(); + for (uint32_t s = 0; s < statCount && remaining() >= 5; ++s) { + std::string fieldName; + while (remaining() > 0) { + char c = static_cast(packet.readUInt8()); + if (c == '\0') break; + fieldName += c; + } + uint32_t val = (remaining() >= 4) ? packet.readUInt32() : 0; + ps.bgStats.emplace_back(std::move(fieldName), val); + } + + bgScoreboard_.players.push_back(std::move(ps)); + } + + if (remaining() >= 1) { + bgScoreboard_.hasWinner = (packet.readUInt8() != 0); + if (bgScoreboard_.hasWinner && remaining() >= 1) + bgScoreboard_.winner = packet.readUInt8(); + } + + LOG_INFO("PvP log: ", bgScoreboard_.players.size(), " players, hasWinner=", + bgScoreboard_.hasWinner, " winner=", (int)bgScoreboard_.winner); +} + void GameHandler::handleOtherPlayerMovement(network::Packet& packet) { // Server relays MSG_MOVE_* for other players: packed GUID (WotLK) or full uint64 (TBC/Classic) const bool otherMoveTbc = isClassicLikeExpansion() || isActiveExpansion("tbc"); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 1e106f35..a31d8fa1 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -611,6 +611,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderGmTicketWindow(gameHandler); renderInspectWindow(gameHandler); renderThreatWindow(gameHandler); + renderBgScoreboard(gameHandler); renderObjectiveTracker(gameHandler); // renderQuestMarkers(gameHandler); // Disabled - using 3D billboard markers now if (showMinimap_) { @@ -3979,6 +3980,14 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /score command — BG scoreboard + if (cmdLower == "score") { + gameHandler.requestPvpLog(); + showBgScoreboard_ = true; + chatInputBuffer[0] = '\0'; + return; + } + // /time command if (cmdLower == "time") { gameHandler.queryServerTime(); @@ -4065,7 +4074,7 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { "Movement: /sit /stand /kneel /dismount", "Misc: /played /time /zone /afk [msg] /dnd [msg] /inspect", " /helm /cloak /trade /join /leave ", - " /unstuck /logout /ticket /help", + " /score /unstuck /logout /ticket /help", }; for (const char* line : kHelpLines) { game::MessageChatData helpMsg; @@ -17738,6 +17747,139 @@ void GameScreen::renderThreatWindow(game::GameHandler& gameHandler) { ImGui::End(); } +// ─── BG Scoreboard ──────────────────────────────────────────────────────────── +void GameScreen::renderBgScoreboard(game::GameHandler& gameHandler) { + if (!showBgScoreboard_) return; + + const game::GameHandler::BgScoreboardData* data = gameHandler.getBgScoreboard(); + + ImGui::SetNextWindowSize(ImVec2(600, 400), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(150, 100), ImGuiCond_FirstUseEver); + + const char* title = "Battleground Score###BgScore"; + if (!ImGui::Begin(title, &showBgScoreboard_, ImGuiWindowFlags_NoCollapse)) { + ImGui::End(); + return; + } + + if (!data) { + ImGui::TextDisabled("No score data yet."); + ImGui::TextDisabled("Use /score to request the scoreboard while in a battleground."); + ImGui::End(); + return; + } + + // Winner banner + if (data->hasWinner) { + const char* winnerStr = (data->winner == 1) ? "Alliance" : "Horde"; + ImVec4 winnerColor = (data->winner == 1) ? ImVec4(0.4f, 0.6f, 1.0f, 1.0f) + : ImVec4(1.0f, 0.35f, 0.35f, 1.0f); + float textW = ImGui::CalcTextSize(winnerStr).x + ImGui::CalcTextSize(" Victory!").x; + ImGui::SetCursorPosX((ImGui::GetContentRegionAvail().x - textW) * 0.5f); + ImGui::TextColored(winnerColor, "%s", winnerStr); + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "Victory!"); + ImGui::Separator(); + } + + // Refresh button + if (ImGui::SmallButton("Refresh")) { + gameHandler.requestPvpLog(); + } + ImGui::SameLine(); + ImGui::TextDisabled("%zu players", data->players.size()); + + // Score table + constexpr ImGuiTableFlags kTableFlags = + ImGuiTableFlags_ScrollY | ImGuiTableFlags_RowBg | + ImGuiTableFlags_BordersOuter | ImGuiTableFlags_BordersV | + ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_Sortable; + + // Build dynamic column count based on what BG-specific stats are present + int numBgCols = 0; + std::vector bgColNames; + for (const auto& ps : data->players) { + for (const auto& [fieldName, val] : ps.bgStats) { + // Extract short name after last '.' (e.g. "BattlegroundAB.AbFlagCaptures" → "Caps") + std::string shortName = fieldName; + auto dotPos = fieldName.rfind('.'); + if (dotPos != std::string::npos) shortName = fieldName.substr(dotPos + 1); + bool found = false; + for (const auto& n : bgColNames) { if (n == shortName) { found = true; break; } } + if (!found) bgColNames.push_back(shortName); + } + } + numBgCols = static_cast(bgColNames.size()); + + // Fixed cols: Team | Name | KB | Deaths | HKs | Honor; then BG-specific + int totalCols = 6 + numBgCols; + float tableH = ImGui::GetContentRegionAvail().y; + if (ImGui::BeginTable("##BgScoreTable", totalCols, kTableFlags, ImVec2(0.0f, tableH))) { + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("Team", ImGuiTableColumnFlags_WidthFixed, 56.0f); + ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("KB", ImGuiTableColumnFlags_WidthFixed, 38.0f); + ImGui::TableSetupColumn("Deaths", ImGuiTableColumnFlags_WidthFixed, 52.0f); + ImGui::TableSetupColumn("HKs", ImGuiTableColumnFlags_WidthFixed, 38.0f); + ImGui::TableSetupColumn("Honor", ImGuiTableColumnFlags_WidthFixed, 52.0f); + for (const auto& col : bgColNames) + ImGui::TableSetupColumn(col.c_str(), ImGuiTableColumnFlags_WidthFixed, 52.0f); + ImGui::TableHeadersRow(); + + // Sort: Alliance first, then Horde; within each team by KB desc + std::vector sorted; + sorted.reserve(data->players.size()); + for (const auto& ps : data->players) sorted.push_back(&ps); + std::stable_sort(sorted.begin(), sorted.end(), + [](const game::GameHandler::BgPlayerScore* a, + const game::GameHandler::BgPlayerScore* b) { + if (a->team != b->team) return a->team > b->team; // Alliance(1) first + return a->killingBlows > b->killingBlows; + }); + + uint64_t playerGuid = gameHandler.getPlayerGuid(); + for (const auto* ps : sorted) { + ImGui::TableNextRow(); + + // Team + ImGui::TableNextColumn(); + if (ps->team == 1) + ImGui::TextColored(ImVec4(0.4f, 0.6f, 1.0f, 1.0f), "Alliance"); + else + ImGui::TextColored(ImVec4(1.0f, 0.35f, 0.35f, 1.0f), "Horde"); + + // Name (highlight player's own row) + ImGui::TableNextColumn(); + bool isSelf = (ps->guid == playerGuid); + if (isSelf) ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f, 0.0f, 1.0f)); + const char* nameStr = ps->name.empty() ? "Unknown" : ps->name.c_str(); + ImGui::TextUnformatted(nameStr); + if (isSelf) ImGui::PopStyleColor(); + + ImGui::TableNextColumn(); ImGui::Text("%u", ps->killingBlows); + ImGui::TableNextColumn(); ImGui::Text("%u", ps->deaths); + ImGui::TableNextColumn(); ImGui::Text("%u", ps->honorableKills); + ImGui::TableNextColumn(); ImGui::Text("%u", ps->bonusHonor); + + for (const auto& col : bgColNames) { + ImGui::TableNextColumn(); + uint32_t val = 0; + for (const auto& [fieldName, fval] : ps->bgStats) { + std::string shortName = fieldName; + auto dotPos = fieldName.rfind('.'); + if (dotPos != std::string::npos) shortName = fieldName.substr(dotPos + 1); + if (shortName == col) { val = fval; break; } + } + if (val > 0) ImGui::Text("%u", val); + else ImGui::TextDisabled("-"); + } + } + ImGui::EndTable(); + } + + ImGui::End(); +} + // ─── Quest Objective Tracker ────────────────────────────────────────────────── void GameScreen::renderObjectiveTracker(game::GameHandler& gameHandler) { if (gameHandler.getState() != game::WorldState::IN_WORLD) return;