diff --git a/CMakeCache.txt b/CMakeCache.txt new file mode 100644 index 0000000000..87a102b394 --- /dev/null +++ b/CMakeCache.txt @@ -0,0 +1,79 @@ +# This is the CMakeCache file. +# For build in directory: /home/wow_admin/wow_cata_server/source/TrinityCore +# It was generated by CMake: /usr/bin/cmake +# You can edit this file to change values found and used by cmake. +# If you do not want to change any of the values, simply exit the editor. +# If you do want to change a value, simply edit, save, and exit the editor. +# The syntax for the file is as follows: +# KEY:TYPE=VALUE +# KEY is the name of a variable in the cache. +# TYPE is a hint to GUIs for the type of VALUE, DO NOT EDIT TYPE!. +# VALUE is the current value for the KEY. + +######################## +# EXTERNAL cache entries +######################## + +//Value Computed by CMake. +CMAKE_FIND_PACKAGE_REDIRECTS_DIR:STATIC=/home/wow_admin/wow_cata_server/source/TrinityCore/CMakeFiles/pkgRedirects + +//Value Computed by CMake +CMAKE_PROJECT_DESCRIPTION:STATIC= + +//Value Computed by CMake +CMAKE_PROJECT_HOMEPAGE_URL:STATIC= + +//Value Computed by CMake +CMAKE_PROJECT_NAME:STATIC=TrinityCore + +//Path to a program. +CMAKE_UNAME:FILEPATH=/usr/bin/uname + +//Value Computed by CMake +TrinityCore_BINARY_DIR:STATIC=/home/wow_admin/wow_cata_server/source/TrinityCore + +//Value Computed by CMake +TrinityCore_IS_TOP_LEVEL:STATIC=ON + +//Value Computed by CMake +TrinityCore_SOURCE_DIR:STATIC=/home/wow_admin/wow_cata_server/source/TrinityCore + + +######################## +# INTERNAL cache entries +######################## + +//This is the directory where this CMakeCache.txt was created +CMAKE_CACHEFILE_DIR:INTERNAL=/home/wow_admin/wow_cata_server/source/TrinityCore +//Major version of cmake used to create the current loaded cache +CMAKE_CACHE_MAJOR_VERSION:INTERNAL=3 +//Minor version of cmake used to create the current loaded cache +CMAKE_CACHE_MINOR_VERSION:INTERNAL=28 +//Patch version of cmake used to create the current loaded cache +CMAKE_CACHE_PATCH_VERSION:INTERNAL=3 +//Path to CMake executable. +CMAKE_COMMAND:INTERNAL=/usr/bin/cmake +//Path to cpack program executable. +CMAKE_CPACK_COMMAND:INTERNAL=/usr/bin/cpack +//Path to ctest program executable. +CMAKE_CTEST_COMMAND:INTERNAL=/usr/bin/ctest +//Name of external makefile project generator. +CMAKE_EXTRA_GENERATOR:INTERNAL= +//Name of generator. +CMAKE_GENERATOR:INTERNAL=Unix Makefiles +//Generator instance identifier. +CMAKE_GENERATOR_INSTANCE:INTERNAL= +//Name of generator platform. +CMAKE_GENERATOR_PLATFORM:INTERNAL= +//Name of generator toolset. +CMAKE_GENERATOR_TOOLSET:INTERNAL= +//Source directory with the top level CMakeLists.txt file for this +// project +CMAKE_HOME_DIRECTORY:INTERNAL=/home/wow_admin/wow_cata_server/source/TrinityCore +//number of local generators +CMAKE_NUMBER_OF_MAKEFILES:INTERNAL=1 +//Platform information initialized +CMAKE_PLATFORM_INFO_INITIALIZED:INTERNAL=1 +//Path to CMake installation. +CMAKE_ROOT:INTERNAL=/usr/share/cmake-3.28 + diff --git a/CMakeFiles/cmake.check_cache b/CMakeFiles/cmake.check_cache new file mode 100644 index 0000000000..3dccd73172 --- /dev/null +++ b/CMakeFiles/cmake.check_cache @@ -0,0 +1 @@ +# This file is generated by cmake for dependency checking of the CMakeCache.txt file diff --git a/src/server/scripts/Custom/custom_script_loader.cpp b/src/server/scripts/Custom/custom_script_loader.cpp index ae00dc1afd..e79a2ac228 100644 --- a/src/server/scripts/Custom/custom_script_loader.cpp +++ b/src/server/scripts/Custom/custom_script_loader.cpp @@ -21,9 +21,14 @@ // VIANO_ADVENTURERS void AddSC_viano_adventurers(); +// Viano Server diagnostics +void AddSC_vcore_diagnostics(); + // The name of this function should match: // void Add${NameOfDirectory}Scripts() void AddCustomScripts() { AddSC_viano_adventurers(); + AddSC_vcore_diagnostics(); + } diff --git a/src/server/scripts/Custom/vcore_diagnostics.cpp b/src/server/scripts/Custom/vcore_diagnostics.cpp new file mode 100644 index 0000000000..8e6f950f83 --- /dev/null +++ b/src/server/scripts/Custom/vcore_diagnostics.cpp @@ -0,0 +1,2334 @@ +/* + * Vcore Diagnostics Server v0.5 + * + * Target: The Cataclysm Preservation Project / Vcore API line. + * + * Provides: + * - short-lived per-player in-memory blackbox + * - persistent tester reproduction sessions + * - server snapshot attached to bug reports + * + * Deliberately does NOT patch gameplay. + */ + +#include "ScriptMgr.h" +#include "Chat.h" +#include "ChatCommand.h" +#include "DatabaseEnv.h" +#include "Group.h" +#include "Player.h" +#include "Spell.h" +#include "WorldSession.h" +#include "RBAC.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace VCoreDiagnostics +{ + using Clock = std::chrono::system_clock; + + static constexpr std::size_t BLACKBOX_MAX_EVENTS = 64; + static constexpr std::chrono::seconds BLACKBOX_MAX_AGE{120}; + + static constexpr std::size_t MAX_PLAYER_REPORT_TEXT_BYTES = 4096; + static constexpr std::size_t MAX_CLIENT_CONTEXT_BYTES = 16384; + static constexpr std::size_t MAX_TESTER_NOTE_BYTES = 4096; + static constexpr std::chrono::seconds PLAYER_REPORT_COOLDOWN{10}; + + static constexpr char ADDON_PROTOCOL_PREFIX[] = "VCORE1|CHUNK|"; + static constexpr std::size_t MAX_ADDON_TRANSPORT_BYTES = 24576; + static constexpr uint32 MAX_ADDON_TRANSPORT_CHUNKS = 128; + static constexpr std::chrono::seconds ADDON_TRANSPORT_TIMEOUT{30}; + + struct BlackboxEvent + { + Clock::time_point time; + std::string type; + std::string dataJson; + }; + + struct PendingAddonPayload + { + char operation = 0; + std::string requestId; + uint32 totalChunks = 0; + uint32 receivedChunks = 0; + std::size_t receivedBytes = 0; + Clock::time_point startedAt{}; + std::vector chunks; + std::vector received; + }; + + struct PlayerDiagnosticsState + { + std::deque blackbox; + std::optional activeReproductionId; + std::optional activeReportId; + std::optional lastPlayerReportAt; + std::optional pendingAddonPayload; + }; + + static std::unordered_map g_states; + + static uint64 PlayerKey(Player const* player) + { + return uint64(player->GetGUID().GetCounter()); + } + + static std::string JsonEscape(std::string const& input) + { + std::ostringstream out; + + for (char c : input) + { + switch (c) + { + case '\\': out << "\\\\"; break; + case '"': out << "\\\""; break; + case '\n': out << "\\n"; break; + case '\r': out << "\\r"; break; + case '\t': out << "\\t"; break; + default: + if (static_cast(c) < 0x20) + out << ' '; + else + out << c; + break; + } + } + + return out.str(); + } + + static int64 UnixMs(Clock::time_point tp) + { + return std::chrono::duration_cast( + tp.time_since_epoch()).count(); + } + + static bool IsPayloadSizeValid(std::string const& value, std::size_t maxBytes) + { + return value.size() <= maxBytes; + } + + static bool LooksLikeJsonObject(std::string const& value) + { + if (value.empty()) + return true; + + std::size_t first = value.find_first_not_of(" \t\r\n"); + std::size_t last = value.find_last_not_of(" \t\r\n"); + + if (first == std::string::npos || last == std::string::npos) + return true; + + return value[first] == '{' && value[last] == '}'; + } + + static bool CanCreatePlayerReport(Player* player) + { + if (!player) + return false; + + auto& state = g_states[PlayerKey(player)]; + + if (!state.lastPlayerReportAt) + return true; + + return Clock::now() - *state.lastPlayerReportAt >= PLAYER_REPORT_COOLDOWN; + } + + static void SendTransportStatus(Player* player, std::string const& text) + { + if (!player || !player->GetSession()) + return; + + ChatHandler(player->GetSession()).PSendSysMessage( + "[Vcore Diagnostics] %s", + text.c_str()); + } + + static bool ParseUnsigned(std::string const& value, uint32& out) + { + if (value.empty()) + return false; + + char* end = nullptr; + unsigned long parsed = std::strtoul(value.c_str(), &end, 10); + + if (!end || *end != '\0') + return false; + + if (parsed > 0xFFFFFFFFul) + return false; + + out = uint32(parsed); + return true; + } + + static bool IsQuestInPlayerLog(Player* player, uint32 questId) + { + if (!player || !questId) + return false; + + for (uint8 slot = 0; slot < MAX_QUEST_LOG_SIZE; ++slot) + { + if (player->GetQuestSlotQuestId(slot) == questId) + return true; + } + + return false; + } + + static QueryResult QuerySync(std::string const& sql); + + static std::string QuestTitle(uint32 questId) + { + if (!questId) + return ""; + + std::ostringstream sql; + sql << "SELECT COALESCE(LogTitle,'') " + << "FROM cata_world.quest_template " + << "WHERE ID=" << questId << " LIMIT 1"; + + QueryResult result = QuerySync(sql.str()); + if (!result) + return ""; + + return result->Fetch()[0].GetString(); + } + + static bool ParseLengthPrefixedPair( + std::string const& payload, + std::string& first, + std::string& second) + { + std::size_t colon = payload.find(':'); + if (colon == std::string::npos) + return false; + + uint32 firstLength = 0; + if (!ParseUnsigned(payload.substr(0, colon), firstLength)) + return false; + + std::size_t dataStart = colon + 1; + if (firstLength > payload.size() - dataStart) + return false; + + first = payload.substr(dataStart, firstLength); + second = payload.substr(dataStart + firstLength); + return true; + } + + static std::string MakeToken(char const* prefix, Player const* player) + { + std::ostringstream out; + out << prefix << "-" + << player->GetGUID().GetCounter() << "-" + << UnixMs(Clock::now()); + return out.str(); + } + + static void Prune(PlayerDiagnosticsState& state) + { + auto const now = Clock::now(); + + while (!state.blackbox.empty() && + now - state.blackbox.front().time > BLACKBOX_MAX_AGE) + { + state.blackbox.pop_front(); + } + + while (state.blackbox.size() > BLACKBOX_MAX_EVENTS) + state.blackbox.pop_front(); + } + + static std::string TargetSnapshot(Player* player) + { + Unit* selected = player->GetSelectedUnit(); + + if (!selected) + return "null"; + + std::ostringstream out; + out << "{"; + out << "\"guid\":" << selected->GetGUID().GetCounter() << ","; + out << "\"type\":\"" << (selected->ToCreature() ? "creature" : "unit") << "\""; + + if (Creature* creature = selected->ToCreature()) + { + out << ",\"entry\":" << creature->GetEntry(); + out << ",\"spawn_id\":" << creature->GetSpawnId(); + } + + out << "}"; + return out.str(); + } + + static std::string ActiveQuestSnapshot(Player* player) + { + std::ostringstream out; + out << "["; + + bool first = true; + + for (uint8 slot = 0; slot < MAX_QUEST_LOG_SIZE; ++slot) + { + uint32 questId = player->GetQuestSlotQuestId(slot); + if (!questId) + continue; + + if (!first) + out << ","; + + first = false; + + out << "{"; + out << "\"id\":" << questId << ","; + out << "\"status\":" << uint32(player->GetQuestStatus(questId)); + out << "}"; + } + + out << "]"; + return out.str(); + } + + static std::string BuildServerSnapshot(Player* player) + { + std::ostringstream out; + + out << "{"; + + out << "\"character\":{"; + out << "\"guid\":" << player->GetGUID().GetCounter() << ","; + out << "\"name\":\"" << JsonEscape(player->GetName()) << "\","; + out << "\"account_id\":" << player->GetSession()->GetAccountId() << ","; + out << "\"level\":" << uint32(player->getLevel()) << ","; + out << "\"class\":" << uint32(player->getClass()); + out << "},"; + + out << "\"location\":{"; + out << "\"map_id\":" << player->GetMapId() << ","; + out << "\"zone_id\":" << player->GetZoneId() << ","; + out << "\"area_id\":" << player->GetAreaId() << ","; + out << "\"x\":" << player->GetPositionX() << ","; + out << "\"y\":" << player->GetPositionY() << ","; + out << "\"z\":" << player->GetPositionZ() << ","; + out << "\"o\":" << player->GetOrientation(); + out << "},"; + + out << "\"target\":" << TargetSnapshot(player) << ","; + out << "\"active_quests\":" << ActiveQuestSnapshot(player) << ","; + + out << "\"state\":{"; + out << "\"alive\":" << (player->IsAlive() ? "true" : "false") << ","; + out << "\"in_combat\":" << (player->IsInCombat() ? "true" : "false") << ","; + out << "\"in_group\":" << (player->GetGroup() ? "true" : "false"); + out << "}"; + + out << "}"; + + return out.str(); + } + + static std::string BuildBlackboxJson(Player* player) + { + auto& state = g_states[PlayerKey(player)]; + Prune(state); + + std::ostringstream out; + out << "["; + + bool first = true; + auto const now = Clock::now(); + + for (BlackboxEvent const& event : state.blackbox) + { + if (!first) + out << ","; + + first = false; + + int64 offsetMs = + std::chrono::duration_cast( + event.time - now).count(); + + out << "{"; + out << "\"offset_ms\":" << offsetMs << ","; + out << "\"type\":\"" << JsonEscape(event.type) << "\","; + out << "\"data\":" << (event.dataJson.empty() ? "{}" : event.dataJson); + out << "}"; + } + + out << "]"; + return out.str(); + } + + static void ExecuteSync(std::string sql) + { + WorldDatabase.DirectExecute(sql.c_str()); + } + + static QueryResult QuerySync(std::string const& sql) + { + return WorldDatabase.Query(sql.c_str()); + } + + static std::string HexEncode(std::string const& input) + { + static char const* HEX = "0123456789ABCDEF"; + std::string out; + out.reserve(input.size() * 2); + + for (unsigned char c : input) + { + out.push_back(HEX[(c >> 4) & 0x0F]); + out.push_back(HEX[c & 0x0F]); + } + + return out; + } + + static void SendAddonResponse( + Player* player, + std::string const& requestId, + std::string const& payload) + { + if (!player || !player->GetSession()) + return; + + static constexpr std::size_t RESPONSE_CHUNK_SIZE = 180; + std::size_t total = (payload.size() + RESPONSE_CHUNK_SIZE - 1) / RESPONSE_CHUNK_SIZE; + if (total == 0) + total = 1; + + for (std::size_t i = 0; i < total; ++i) + { + std::size_t first = i * RESPONSE_CHUNK_SIZE; + std::string chunk = payload.substr(first, RESPONSE_CHUNK_SIZE); + + std::ostringstream body; + body << "VCORE2|RESP|" + << requestId << "|" + << (i + 1) << "|" + << total << "|" + << chunk; + + WorldPacket data; + ChatHandler::BuildChatPacket( + data, + CHAT_MSG_WHISPER, + LANG_ADDON, + player, + player, + body.str(), + 0U, + "", + DEFAULT_LOCALE, + "VCORE"); + + player->SendDirectMessage(&data); + } + } + + static void SendAddonError( + Player* player, + std::string const& requestId, + std::string const& code, + std::string const& message) + { + SendAddonResponse( + player, + requestId, + "ERROR|" + code + "|" + HexEncode(message)); + } + + static bool IsSeverityValid(std::string const& severity, bool allowUnknown) + { + return severity == "low" || + severity == "medium" || + severity == "high" || + severity == "critical" || + (allowUnknown && severity == "unknown"); + } + + enum class ReportCreateResult + { + Ok, + InvalidPlayer, + EmptyDescription, + DescriptionTooLarge, + ClientContextTooLarge, + InvalidClientContext, + InvalidQuest, + RateLimited, + DatabaseFailure + }; + + struct ReportCreateResponse + { + ReportCreateResult result = ReportCreateResult::DatabaseFailure; + std::optional reportId; + }; + + static void InsertReproductionEvent( + uint64 reproductionId, + char const* source, + char const* eventType, + std::string dataJson) + { + std::string safeSource = source ? source : "server"; + std::string safeType = eventType ? eventType : "unknown"; + std::string safeData = std::move(dataJson); + + WorldDatabase.EscapeString(safeSource); + WorldDatabase.EscapeString(safeType); + WorldDatabase.EscapeString(safeData); + + std::ostringstream sql; + sql << "INSERT INTO vcore_dev.bug_reproduction_events " + << "(reproduction_id, source, event_type, data_json) VALUES (" + << reproductionId << ", '" + << safeSource << "', '" + << safeType << "', '" + << safeData << "')"; + + ExecuteSync(sql.str()); + } + + static void Record(Player* player, std::string type, std::string dataJson = "{}") + { + if (!player) + return; + + auto& state = g_states[PlayerKey(player)]; + + state.blackbox.push_back( + { Clock::now(), std::move(type), dataJson.empty() ? "{}" : std::move(dataJson) }); + + Prune(state); + + if (state.activeReproductionId) + { + BlackboxEvent const& event = state.blackbox.back(); + + InsertReproductionEvent( + *state.activeReproductionId, + "server", + event.type.c_str(), + event.dataJson); + } + } + + static std::optional ResolveReproductionIdByToken(std::string token) + { + WorldDatabase.EscapeString(token); + + std::ostringstream sql; + sql << "SELECT reproduction_id " + << "FROM vcore_dev.bug_reproductions " + << "WHERE session_token='" << token << "' LIMIT 1"; + + QueryResult result = QuerySync(sql.str()); + + if (!result) + return std::nullopt; + + return (*result)[0].GetUInt64(); + } + + static std::optional ResolveReportIdByToken(std::string token) + { + WorldDatabase.EscapeString(token); + + std::ostringstream sql; + sql << "SELECT report_id " + << "FROM vcore_dev.bug_reports " + << "WHERE report_token='" << token << "' LIMIT 1"; + + QueryResult result = QuerySync(sql.str()); + + if (!result) + return std::nullopt; + + return (*result)[0].GetUInt64(); + } + + static std::optional StartReproductionForReport( + Player* player, + uint64 reportId, + uint32 questId = 0) + { + if (!player || !reportId) + return std::nullopt; + + if (questId && !IsQuestInPlayerLog(player, questId)) + return std::nullopt; + + auto& state = g_states[PlayerKey(player)]; + + if (state.activeReproductionId) + return state.activeReproductionId; + + std::ostringstream reportQuery; + reportQuery + << "SELECT COALESCE(bug_id,0), status " + << "FROM vcore_dev.bug_reports " + << "WHERE report_id=" << reportId << " LIMIT 1"; + + QueryResult reportResult = QuerySync(reportQuery.str()); + if (!reportResult) + return std::nullopt; + + Field* reportFields = reportResult->Fetch(); + uint64 bugId = reportFields[0].GetUInt64(); + std::string reportStatus = reportFields[1].GetString(); + + if (reportStatus == "closed") + return std::nullopt; + + std::string token = MakeToken("repro", player); + std::string safeToken = token; + std::string safeName = player->GetName(); + + WorldDatabase.EscapeString(safeToken); + WorldDatabase.EscapeString(safeName); + + std::ostringstream sql; + sql << "INSERT INTO vcore_dev.bug_reproductions " + << "(session_token, bug_id, report_id, quest_id, tester_account_id, tester_character_guid, " + << "tester_character_name, result, map_id, zone_id, area_id, " + << "position_x, position_y, position_z, orientation, started_at, created_at) " + << "VALUES ('" + << safeToken << "', "; + + if (bugId) + sql << bugId; + else + sql << "NULL"; + + sql << ", " + << reportId << ", "; + + if (questId) + sql << questId; + else + sql << "NULL"; + + sql << ", " + << player->GetSession()->GetAccountId() << ", " + << player->GetGUID().GetCounter() << ", '" + << safeName << "', 'running', " + << player->GetMapId() << ", " + << player->GetZoneId() << ", " + << player->GetAreaId() << ", " + << player->GetPositionX() << ", " + << player->GetPositionY() << ", " + << player->GetPositionZ() << ", " + << player->GetOrientation() + << ", NOW(), NOW())"; + + ExecuteSync(sql.str()); + + auto id = ResolveReproductionIdByToken(token); + if (!id) + return std::nullopt; + + state.activeReproductionId = *id; + state.activeReportId = reportId; + + std::ostringstream updateReport; + updateReport + << "UPDATE vcore_dev.bug_reports SET " + << "status=IF(status='open','investigating',status), " + << "updated_at=NOW() " + << "WHERE report_id=" << reportId; + ExecuteSync(updateReport.str()); + + InsertReproductionEvent( + *id, + "server", + "reproduction_started", + "{\"snapshot\":" + BuildServerSnapshot(player) + "}"); + + return id; + } + + static bool FinishReproduction( + Player* player, + std::string result, + std::string observation) + { + if (!player) + return false; + + auto& state = g_states[PlayerKey(player)]; + + if (!state.activeReproductionId) + return false; + + uint64 id = *state.activeReproductionId; + + InsertReproductionEvent( + id, + "server", + "reproduction_stopped", + "{\"snapshot\":" + BuildServerSnapshot(player) + "}"); + + std::string safeResult = result.empty() ? "unknown" : result; + std::string safeObservation = std::move(observation); + + WorldDatabase.EscapeString(safeResult); + WorldDatabase.EscapeString(safeObservation); + + std::ostringstream sql; + sql << "UPDATE vcore_dev.bug_reproductions SET " + << "result='" << safeResult << "', " + << "observation='" << safeObservation << "', " + << "finished_at=NOW() " + << "WHERE reproduction_id=" << id; + + ExecuteSync(sql.str()); + + state.activeReproductionId.reset(); + state.activeReportId.reset(); + + return true; + } + + static bool AddTesterNote(Player* player, std::string note) + { + if (!player || note.empty()) + return false; + + if (!IsPayloadSizeValid(note, MAX_TESTER_NOTE_BYTES)) + return false; + + auto& state = g_states[PlayerKey(player)]; + + if (!state.activeReproductionId) + return false; + + std::ostringstream data; + data << "{"; + data << "\"text\":\"" << JsonEscape(note) << "\","; + data << "\"snapshot\":" << BuildServerSnapshot(player); + data << "}"; + + InsertReproductionEvent( + *state.activeReproductionId, + "tester", + "tester_note", + data.str()); + + return true; + } + + static ReportCreateResponse CreateBugReport( + Player* player, + std::string reportedSeverity, + std::string description, + std::string clientContextJson = "{}", + uint32 questId = 0) + { + ReportCreateResponse response; + + if (!player) + { + response.result = ReportCreateResult::InvalidPlayer; + return response; + } + + if (!IsSeverityValid(reportedSeverity, false)) + { + response.result = ReportCreateResult::DatabaseFailure; + return response; + } + + if (description.empty()) + { + response.result = ReportCreateResult::EmptyDescription; + return response; + } + + if (!IsPayloadSizeValid(description, MAX_PLAYER_REPORT_TEXT_BYTES)) + { + response.result = ReportCreateResult::DescriptionTooLarge; + return response; + } + + if (!IsPayloadSizeValid(clientContextJson, MAX_CLIENT_CONTEXT_BYTES)) + { + response.result = ReportCreateResult::ClientContextTooLarge; + return response; + } + + if (!LooksLikeJsonObject(clientContextJson)) + { + response.result = ReportCreateResult::InvalidClientContext; + return response; + } + + if (questId && !IsQuestInPlayerLog(player, questId)) + { + response.result = ReportCreateResult::InvalidQuest; + return response; + } + + if (!CanCreatePlayerReport(player)) + { + response.result = ReportCreateResult::RateLimited; + return response; + } + + if (clientContextJson.empty()) + clientContextJson = "{}"; + + std::string token = MakeToken("report", player); + std::string safeToken = token; + std::string safeSeverity = std::move(reportedSeverity); + std::string safeDescription = std::move(description); + std::string safeName = player->GetName(); + + WorldDatabase.EscapeString(safeToken); + WorldDatabase.EscapeString(safeSeverity); + WorldDatabase.EscapeString(safeDescription); + WorldDatabase.EscapeString(safeName); + + Unit* target = player->GetSelectedUnit(); + + uint64 targetGuid = + target ? uint64(target->GetGUID().GetCounter()) : uint64(0); + + uint32 targetEntry = + target && target->ToCreature() + ? target->ToCreature()->GetEntry() + : uint32(0); + + std::ostringstream context; + context << "{"; + context << "\"client\":" << clientContextJson << ","; + context << "\"server\":" << BuildServerSnapshot(player) << ","; + context << "\"server_blackbox\":" << BuildBlackboxJson(player) << ","; + context << "\"meta\":{"; + context << "\"client_context_trusted\":false,"; + context << "\"server_context_trusted\":true"; + context << "}"; + context << "}"; + + std::string contextJson = context.str(); + WorldDatabase.EscapeString(contextJson); + + std::ostringstream sql; + sql << "INSERT INTO vcore_dev.bug_reports " + << "(report_token, bug_id, source_type, account_id, character_guid, " + << "character_name, reported_severity, status, description, quest_id, map_id, zone_id, area_id, " + << "position_x, position_y, position_z, orientation, " + << "target_guid, target_entry, target_type, context_json, created_at) " + << "VALUES ('" + << safeToken << "', NULL, 'player', " + << player->GetSession()->GetAccountId() << ", " + << player->GetGUID().GetCounter() << ", '" + << safeName << "', '" + << safeSeverity << "', 'open', '" + << safeDescription << "', "; + + if (questId) + sql << questId; + else + sql << "NULL"; + + sql << ", " + << player->GetMapId() << ", " + << player->GetZoneId() << ", " + << player->GetAreaId() << ", " + << player->GetPositionX() << ", " + << player->GetPositionY() << ", " + << player->GetPositionZ() << ", " + << player->GetOrientation() << ", "; + + if (targetGuid) + sql << targetGuid; + else + sql << "NULL"; + + sql << ", "; + + if (targetEntry) + sql << targetEntry; + else + sql << "NULL"; + + sql << ", "; + + if (target && target->ToCreature()) + sql << "'creature'"; + else if (target) + sql << "'unit'"; + else + sql << "NULL"; + + sql << ", '" << contextJson << "', NOW())"; + + ExecuteSync(sql.str()); + + auto reportId = ResolveReportIdByToken(token); + if (!reportId) + { + response.result = ReportCreateResult::DatabaseFailure; + return response; + } + + g_states[PlayerKey(player)].lastPlayerReportAt = Clock::now(); + + response.result = ReportCreateResult::Ok; + response.reportId = reportId; + return response; + } + + static ReportCreateResponse SubmitPlayerReport( + Player* player, + std::string reportedSeverity, + std::string description, + std::string clientContextJson, + uint32 questId = 0) + { + return CreateBugReport( + player, + std::move(reportedSeverity), + std::move(description), + std::move(clientContextJson), + questId); + } + + static std::optional StartTesterReproduction( + Player* player, + uint64 reportId, + uint32 questId = 0) + { + return StartReproductionForReport(player, reportId, questId); + } + + static bool AddTesterReproductionNote( + Player* player, + std::string note) + { + return AddTesterNote(player, std::move(note)); + } + + static bool FinishTesterReproduction( + Player* player, + std::string result, + std::string observation) + { + return FinishReproduction( + player, + std::move(result), + std::move(observation)); + } + + + static bool HasTesterPermission(Player* player); + + static std::string SeverityOrderSql(char const* column) + { + std::ostringstream sql; + sql << "CASE " << column + << " WHEN 'critical' THEN 1" + << " WHEN 'high' THEN 2" + << " WHEN 'medium' THEN 3" + << " WHEN 'low' THEN 4" + << " ELSE 5 END"; + return sql.str(); + } + + static bool SplitFirst( + std::string const& value, + char delimiter, + std::string& first, + std::string& rest) + { + std::size_t pos = value.find(delimiter); + if (pos == std::string::npos) + return false; + + first = value.substr(0, pos); + rest = value.substr(pos + 1); + return true; + } + + static void SendActiveQuestList( + Player* player, + std::string const& requestId) + { + std::ostringstream response; + response << "QUEST_LIST|"; + + bool first = true; + + for (uint8 slot = 0; slot < MAX_QUEST_LOG_SIZE; ++slot) + { + uint32 questId = player->GetQuestSlotQuestId(slot); + if (!questId) + continue; + + if (!first) + response << "\n"; + first = false; + + response + << questId << "|" + << uint32(player->GetQuestStatus(questId)) << "|" + << HexEncode(QuestTitle(questId)); + } + + SendAddonResponse(player, requestId, response.str()); + } + + static void SendPermissions(Player* player, std::string const& requestId) + { + bool tester = HasTesterPermission(player); + SendAddonResponse( + player, + requestId, + std::string("PERM|") + (tester ? "1" : "0")); + + auto it = g_states.find(PlayerKey(player)); + if (it != g_states.end() && + it->second.activeReproductionId && + it->second.activeReportId) + { + std::ostringstream response; + response << "SESSION|" + << *it->second.activeReproductionId << "|" + << *it->second.activeReportId; + SendAddonResponse(player, requestId, response.str()); + } + else + { + SendAddonResponse(player, requestId, "SESSION|0|0"); + } + } + + static void SendReportList( + Player* player, + std::string const& requestId, + std::string sort, + uint32 page) + { + if (page == 0) + page = 1; + + if (sort != "newest" && sort != "area" && sort != "severity") + sort = "newest"; + + uint32 offset = (page - 1) * 8; + + std::ostringstream sql; + sql << "SELECT report_id, reported_severity, status, " + << "COALESCE(bug_id,0), COALESCE(zone_id,0), COALESCE(area_id,0), " + << "DATE_FORMAT(created_at,'%Y-%m-%d %H:%i'), " + << "COALESCE(character_name,''), " + << "LEFT(REPLACE(REPLACE(description,'\\r',' '),'\\n',' '),120), " + << "COALESCE(JSON_UNQUOTE(JSON_EXTRACT(context_json,'$.client.zone.zone_text')),'') " + << "FROM vcore_dev.bug_reports "; + + if (sort == "area") + sql << "ORDER BY COALESCE(zone_id,0), COALESCE(area_id,0), report_id DESC "; + else if (sort == "severity") + sql << "ORDER BY " << SeverityOrderSql("reported_severity") << ", report_id DESC "; + else + sql << "ORDER BY report_id DESC "; + + sql << "LIMIT 9 OFFSET " << offset; + + QueryResult result = QuerySync(sql.str()); + + std::ostringstream response; + response << "REPORT_LIST|" << page << "|" << (page > 1 ? 1 : 0) << "|"; + + bool hasNext = false; + std::vector rows; + + if (result) + { + uint32 rowIndex = 0; + do + { + Field* fields = result->Fetch(); + + if (rowIndex >= 8) + { + hasNext = true; + break; + } + + std::ostringstream row; + row << fields[0].GetUInt64() << "|" + << fields[1].GetString() << "|" + << fields[2].GetString() << "|" + << fields[3].GetUInt64() << "|" + << fields[4].GetUInt32() << "|" + << fields[5].GetUInt32() << "|" + << HexEncode(fields[6].GetString()) << "|" + << HexEncode(fields[7].GetString()) << "|" + << HexEncode(fields[8].GetString()) << "|" + << HexEncode(fields[9].GetString()); + + rows.push_back(row.str()); + ++rowIndex; + } while (result->NextRow()); + } + + response << (hasNext ? 1 : 0) << "|"; + for (std::size_t i = 0; i < rows.size(); ++i) + { + if (i) + response << "\n"; + response << rows[i]; + } + + SendAddonResponse(player, requestId, response.str()); + } + + static void SendReportDetail( + Player* player, + std::string const& requestId, + uint64 reportId) + { + std::ostringstream sql; + sql << "SELECT report_id, reported_severity, status, COALESCE(bug_id,0), " + << "COALESCE(zone_id,0), COALESCE(area_id,0), " + << "DATE_FORMAT(created_at,'%Y-%m-%d %H:%i:%s'), " + << "COALESCE(character_name,''), description, " + << "COALESCE(closure_reason,''), COALESCE(closure_note,''), " + << "COALESCE(JSON_UNQUOTE(JSON_EXTRACT(context_json,'$.client.zone.zone_text')),''), " + << "COALESCE(JSON_UNQUOTE(JSON_EXTRACT(context_json,'$.client.zone.subzone_text')),''), " + << "COALESCE(quest_id,0), " + << "COALESCE((SELECT LogTitle FROM cata_world.quest_template q WHERE q.ID=bug_reports.quest_id LIMIT 1),'') " + << "FROM vcore_dev.bug_reports WHERE report_id=" << reportId << " LIMIT 1"; + + QueryResult result = QuerySync(sql.str()); + if (!result) + { + SendAddonError(player, requestId, "REPORT_NOT_FOUND", "Report not found."); + return; + } + + Field* fields = result->Fetch(); + + std::ostringstream response; + response << "REPORT_DETAIL|" + << fields[0].GetUInt64() << "|" + << fields[1].GetString() << "|" + << fields[2].GetString() << "|" + << fields[3].GetUInt64() << "|" + << fields[4].GetUInt32() << "|" + << fields[5].GetUInt32() << "|" + << HexEncode(fields[6].GetString()) << "|" + << HexEncode(fields[7].GetString()) << "|" + << HexEncode(fields[8].GetString()) << "|" + << HexEncode(fields[9].GetString()) << "|" + << HexEncode(fields[10].GetString()) << "|" + << HexEncode(fields[11].GetString()) << "|" + << HexEncode(fields[12].GetString()) << "|" + << fields[13].GetUInt32() << "|" + << HexEncode(fields[14].GetString()); + + SendAddonResponse(player, requestId, response.str()); + + std::ostringstream reproSql; + reproSql + << "SELECT reproduction_id, result, " + << "DATE_FORMAT(COALESCE(finished_at,started_at),'%Y-%m-%d %H:%i:%s'), " + << "COALESCE(tester_character_name,''), COALESCE(observation,''), " + << "COALESCE(quest_id,0), " + << "COALESCE((SELECT LogTitle FROM cata_world.quest_template q WHERE q.ID=bug_reproductions.quest_id LIMIT 1),'') " + << "FROM vcore_dev.bug_reproductions " + << "WHERE report_id=" << reportId << " " + << "ORDER BY reproduction_id DESC LIMIT 12"; + + QueryResult reproResult = QuerySync(reproSql.str()); + std::ostringstream reproResponse; + reproResponse << "REPRO_LIST|" << reportId << "|"; + + bool first = true; + if (reproResult) + { + do + { + Field* r = reproResult->Fetch(); + if (!first) + reproResponse << "\n"; + first = false; + + reproResponse + << r[0].GetUInt64() << "|" + << r[1].GetString() << "|" + << HexEncode(r[2].GetString()) << "|" + << HexEncode(r[3].GetString()) << "|" + << HexEncode(r[4].GetString()) << "|" + << r[5].GetUInt32() << "|" + << HexEncode(r[6].GetString()); + } while (reproResult->NextRow()); + } + + SendAddonResponse(player, requestId, reproResponse.str()); + } + + static void SendBugList( + Player* player, + std::string const& requestId, + std::string sort, + uint32 page) + { + if (page == 0) + page = 1; + + if (sort != "newest" && sort != "area" && sort != "severity") + sort = "newest"; + + uint32 offset = (page - 1) * 8; + + std::ostringstream sql; + sql << "SELECT bug_id, title, status, severity, category, " + << "COALESCE(primary_zone_id,0), COALESCE(primary_area_id,0), " + << "DATE_FORMAT(updated_at,'%Y-%m-%d %H:%i') " + << "FROM vcore_dev.bugs "; + + if (sort == "area") + sql << "ORDER BY COALESCE(primary_zone_id,0), COALESCE(primary_area_id,0), bug_id DESC "; + else if (sort == "severity") + sql << "ORDER BY " << SeverityOrderSql("severity") << ", bug_id DESC "; + else + sql << "ORDER BY updated_at DESC, bug_id DESC "; + + sql << "LIMIT 9 OFFSET " << offset; + + QueryResult result = QuerySync(sql.str()); + + std::ostringstream response; + response << "BUG_LIST|" << page << "|" << (page > 1 ? 1 : 0) << "|"; + + bool hasNext = false; + std::vector rows; + + if (result) + { + uint32 rowIndex = 0; + do + { + Field* fields = result->Fetch(); + + if (rowIndex >= 8) + { + hasNext = true; + break; + } + + std::ostringstream row; + row << fields[0].GetUInt64() << "|" + << HexEncode(fields[1].GetString()) << "|" + << fields[2].GetString() << "|" + << fields[3].GetString() << "|" + << fields[4].GetString() << "|" + << fields[5].GetUInt32() << "|" + << fields[6].GetUInt32() << "|" + << HexEncode(fields[7].GetString()); + + rows.push_back(row.str()); + ++rowIndex; + } while (result->NextRow()); + } + + response << (hasNext ? 1 : 0) << "|"; + for (std::size_t i = 0; i < rows.size(); ++i) + { + if (i) + response << "\n"; + response << rows[i]; + } + + SendAddonResponse(player, requestId, response.str()); + } + + static void SendBugDetail( + Player* player, + std::string const& requestId, + uint64 bugId) + { + std::ostringstream sql; + sql << "SELECT bug_id, title, status, severity, category, " + << "COALESCE(description,''), COALESCE(primary_zone_id,0), " + << "COALESCE(primary_area_id,0), DATE_FORMAT(updated_at,'%Y-%m-%d %H:%i:%s'), " + << "(SELECT COUNT(*) FROM vcore_dev.bug_reports r WHERE r.bug_id=bugs.bug_id), " + << "(SELECT COUNT(*) FROM vcore_dev.bug_reproductions p WHERE p.bug_id=bugs.bug_id) " + << "FROM vcore_dev.bugs WHERE bug_id=" << bugId << " LIMIT 1"; + + QueryResult result = QuerySync(sql.str()); + if (!result) + { + SendAddonError(player, requestId, "BUG_NOT_FOUND", "Bug not found."); + return; + } + + Field* fields = result->Fetch(); + + std::ostringstream response; + response << "BUG_DETAIL|" + << fields[0].GetUInt64() << "|" + << HexEncode(fields[1].GetString()) << "|" + << fields[2].GetString() << "|" + << fields[3].GetString() << "|" + << fields[4].GetString() << "|" + << HexEncode(fields[5].GetString()) << "|" + << fields[6].GetUInt32() << "|" + << fields[7].GetUInt32() << "|" + << HexEncode(fields[8].GetString()) << "|" + << fields[9].GetUInt64() << "|" + << fields[10].GetUInt64(); + + SendAddonResponse(player, requestId, response.str()); + } + + static std::optional ResolveBugIdByToken(std::string token) + { + WorldDatabase.EscapeString(token); + std::ostringstream sql; + sql << "SELECT bug_id FROM vcore_dev.bugs " + << "WHERE bug_token='" << token << "' LIMIT 1"; + + QueryResult result = QuerySync(sql.str()); + if (!result) + return std::nullopt; + + return (*result)[0].GetUInt64(); + } + + static std::optional CreateBugFromReport( + Player* player, + uint64 reportId, + std::string severity, + std::string title) + { + if (!player || !reportId || !IsSeverityValid(severity, false) || title.empty()) + return std::nullopt; + + std::ostringstream sourceSql; + sourceSql + << "SELECT description, COALESCE(target_type,''), COALESCE(target_entry,0), " + << "COALESCE(map_id,0), COALESCE(zone_id,0), COALESCE(area_id,0), " + << "COALESCE(bug_id,0) " + << "FROM vcore_dev.bug_reports WHERE report_id=" << reportId << " LIMIT 1"; + + QueryResult sourceResult = QuerySync(sourceSql.str()); + if (!sourceResult) + return std::nullopt; + + Field* source = sourceResult->Fetch(); + if (source[6].GetUInt64()) + return source[6].GetUInt64(); + + std::string token = MakeToken("bug", player); + std::string safeToken = token; + std::string safeTitle = std::move(title); + std::string safeSeverity = std::move(severity); + std::string safeDescription = source[0].GetString(); + std::string safeEntityType = source[1].GetString(); + + WorldDatabase.EscapeString(safeToken); + WorldDatabase.EscapeString(safeTitle); + WorldDatabase.EscapeString(safeSeverity); + WorldDatabase.EscapeString(safeDescription); + WorldDatabase.EscapeString(safeEntityType); + + std::ostringstream insert; + insert + << "INSERT INTO vcore_dev.bugs " + << "(bug_token,title,status,severity,category,primary_entity_type,primary_entity_id," + << "description,primary_map_id,primary_zone_id,primary_area_id,created_at,updated_at) " + << "VALUES ('" << safeToken << "','" << safeTitle + << "','new','" << safeSeverity << "','unknown',"; + + if (!safeEntityType.empty()) + insert << "'" << safeEntityType << "'"; + else + insert << "NULL"; + + insert << ","; + + uint64 targetEntry = source[2].GetUInt64(); + if (targetEntry) + insert << targetEntry; + else + insert << "NULL"; + + insert + << ",'" << safeDescription << "'," + << source[3].GetUInt32() << "," + << source[4].GetUInt32() << "," + << source[5].GetUInt32() + << ",NOW(),NOW())"; + + ExecuteSync(insert.str()); + + auto bugId = ResolveBugIdByToken(token); + if (!bugId) + return std::nullopt; + + std::ostringstream link; + link + << "UPDATE vcore_dev.bug_reports SET bug_id=" << *bugId + << ", status='linked', updated_at=NOW() WHERE report_id=" << reportId; + ExecuteSync(link.str()); + + std::ostringstream linkRepros; + linkRepros + << "UPDATE vcore_dev.bug_reproductions SET bug_id=" << *bugId + << " WHERE report_id=" << reportId; + ExecuteSync(linkRepros.str()); + + return bugId; + } + + static bool LinkReportToBug(uint64 reportId, uint64 bugId) + { + std::ostringstream check; + check + << "SELECT bug_id FROM vcore_dev.bugs WHERE bug_id=" << bugId << " LIMIT 1"; + if (!QuerySync(check.str())) + return false; + + std::ostringstream update; + update + << "UPDATE vcore_dev.bug_reports SET bug_id=" << bugId + << ", status='linked', updated_at=NOW() WHERE report_id=" << reportId; + ExecuteSync(update.str()); + + std::ostringstream repro; + repro + << "UPDATE vcore_dev.bug_reproductions SET bug_id=" << bugId + << " WHERE report_id=" << reportId; + ExecuteSync(repro.str()); + + std::ostringstream touch; + touch << "UPDATE vcore_dev.bugs SET updated_at=NOW() WHERE bug_id=" << bugId; + ExecuteSync(touch.str()); + + return true; + } + + static bool CloseReport( + Player* player, + uint64 reportId, + std::string reason, + std::string note) + { + if (!player || !reportId) + return false; + + std::string safeReason = std::move(reason); + std::string safeNote = std::move(note); + WorldDatabase.EscapeString(safeReason); + WorldDatabase.EscapeString(safeNote); + + std::ostringstream sql; + sql + << "UPDATE vcore_dev.bug_reports SET status='closed', " + << "closed_at=NOW(), closed_by_account_id=" + << player->GetSession()->GetAccountId() + << ", closure_reason='" << safeReason + << "', closure_note='" << safeNote + << "', updated_at=NOW() WHERE report_id=" << reportId; + ExecuteSync(sql.str()); + return true; + } + + static bool ReopenReport(uint64 reportId) + { + std::ostringstream sql; + sql + << "UPDATE vcore_dev.bug_reports SET " + << "status=IF(bug_id IS NULL,'open','linked'), " + << "closed_at=NULL, closed_by_account_id=NULL, " + << "closure_reason=NULL, closure_note=NULL, updated_at=NOW() " + << "WHERE report_id=" << reportId; + ExecuteSync(sql.str()); + return true; + } + + static std::optional ResolveDiagnosticRunIdByToken(std::string token) + { + WorldDatabase.EscapeString(token); + + std::ostringstream sql; + sql << "SELECT diagnostic_run_id FROM vcore_dev.diagnostic_runs " + << "WHERE run_token='" << token << "' LIMIT 1"; + + QueryResult result = QuerySync(sql.str()); + if (!result) + return std::nullopt; + + return (*result)[0].GetUInt64(); + } + + static std::optional QueueDiagnosticRun(Player* player, uint64 bugId) + { + if (!player || !bugId) + return std::nullopt; + + std::ostringstream check; + check << "SELECT bug_id FROM vcore_dev.bugs WHERE bug_id=" << bugId << " LIMIT 1"; + if (!QuerySync(check.str())) + return std::nullopt; + + std::string token = MakeToken("diag", player); + std::string safeToken = token; + WorldDatabase.EscapeString(safeToken); + + std::ostringstream insert; + insert + << "INSERT INTO vcore_dev.diagnostic_runs " + << "(run_token,bug_id,requested_by_account_id,requested_by_character_guid,status,created_at) " + << "VALUES ('" << safeToken << "'," << bugId << "," + << player->GetSession()->GetAccountId() << "," + << player->GetGUID().GetCounter() + << ",'queued',NOW())"; + ExecuteSync(insert.str()); + + return ResolveDiagnosticRunIdByToken(token); + } + + + static bool HasTesterPermission(Player* player) + { + return player && + player->GetSession() && + player->GetSession()->HasPermission(rbac::RBAC_PERM_COMMAND_GM); + } + + static void DispatchAddonPayload( + Player* player, + char operation, + std::string const& requestId, + std::string const& payload) + { + if (!player) + return; + + // Ordinary players: permissions query and report creation only. + if (operation == 'P') + { + SendPermissions(player, requestId); + return; + } + + if (operation == 'U') + { + SendActiveQuestList(player, requestId); + return; + } + + if (operation == 'R') + { + std::string severity; + std::string remainder; + if (!SplitFirst(payload, '|', severity, remainder)) + { + SendAddonError(player, requestId, "BAD_REPORT", "Invalid report payload."); + return; + } + + std::string description; + std::string clientContext; + uint32 questId = 0; + + std::string maybeQuest; + std::string reportBody; + bool parsedNewFormat = false; + + if (SplitFirst(remainder, '|', maybeQuest, reportBody)) + { + uint32 parsedQuest = 0; + if (ParseUnsigned(maybeQuest, parsedQuest) && + ParseLengthPrefixedPair(reportBody, description, clientContext)) + { + questId = parsedQuest; + parsedNewFormat = true; + } + } + + if (!parsedNewFormat && + !ParseLengthPrefixedPair(remainder, description, clientContext)) + { + SendAddonError(player, requestId, "BAD_REPORT", "Invalid report payload."); + return; + } + + ReportCreateResponse response = + SubmitPlayerReport(player, severity, description, clientContext, questId); + + if (response.result == ReportCreateResult::Ok && response.reportId) + { + SendAddonResponse( + player, + requestId, + "REPORT_OK|" + std::to_string(*response.reportId)); + } + else if (response.result == ReportCreateResult::InvalidQuest) + { + SendAddonError( + player, + requestId, + "INVALID_QUEST", + "Selected quest is not in the character's current quest log."); + } + else if (response.result == ReportCreateResult::RateLimited) + { + SendAddonError(player, requestId, "COOLDOWN", "Report cooldown active."); + } + else if (response.result == ReportCreateResult::DescriptionTooLarge || + response.result == ReportCreateResult::ClientContextTooLarge) + { + SendAddonError(player, requestId, "TOO_LARGE", "Report payload too large."); + } + else + { + SendAddonError(player, requestId, "REPORT_FAILED", "Report could not be saved."); + } + + return; + } + + if (!HasTesterPermission(player)) + { + SendAddonError( + player, + requestId, + "PERMISSION", + "Insufficient permission for tester/bug management."); + return; + } + + if (operation == 'L') + { + std::string sort; + std::string pageText; + if (!SplitFirst(payload, '|', sort, pageText)) + { + sort = "newest"; + pageText = "1"; + } + + uint32 page = 1; + ParseUnsigned(pageText, page); + SendReportList(player, requestId, sort, page); + return; + } + + if (operation == 'D') + { + uint32 reportId = 0; + if (!ParseUnsigned(payload, reportId) || !reportId) + { + SendAddonError(player, requestId, "BAD_REPORT_ID", "Invalid report ID."); + return; + } + + SendReportDetail(player, requestId, reportId); + return; + } + + if (operation == 'B') + { + std::string sort; + std::string pageText; + if (!SplitFirst(payload, '|', sort, pageText)) + { + sort = "newest"; + pageText = "1"; + } + + uint32 page = 1; + ParseUnsigned(pageText, page); + SendBugList(player, requestId, sort, page); + return; + } + + if (operation == 'G') + { + uint32 bugId = 0; + if (!ParseUnsigned(payload, bugId) || !bugId) + { + SendAddonError(player, requestId, "BAD_BUG_ID", "Invalid bug ID."); + return; + } + + SendBugDetail(player, requestId, bugId); + return; + } + + if (operation == 'S') + { + uint32 reportId = 0; + uint32 questId = 0; + + std::string reportText = payload; + std::string questText; + std::string remainder; + + if (SplitFirst(payload, '|', reportText, remainder)) + questText = remainder; + + if (!ParseUnsigned(reportText, reportId) || !reportId) + { + SendAddonError(player, requestId, "BAD_REPORT_ID", "Invalid report ID."); + return; + } + + if (!questText.empty() && !ParseUnsigned(questText, questId)) + { + SendAddonError(player, requestId, "BAD_QUEST_ID", "Invalid quest ID."); + return; + } + + auto reproductionId = + StartTesterReproduction(player, uint64(reportId), questId); + + if (!reproductionId) + { + SendAddonError( + player, + requestId, + "REPRO_START_FAILED", + "Could not start reproduction. Report/quest may be unavailable."); + return; + } + + std::ostringstream response; + response << "REPRO_STARTED|" << *reproductionId << "|" << reportId << "|" << questId; + SendAddonResponse(player, requestId, response.str()); + return; + } + + if (operation == 'N') + { + if (!AddTesterReproductionNote(player, payload)) + { + SendAddonError( + player, + requestId, + "NOTE_FAILED", + "No active reproduction or invalid note."); + return; + } + + SendAddonResponse(player, requestId, "REPRO_NOTE_OK"); + return; + } + + if (operation == 'F') + { + std::string result; + std::string observation; + + if (!ParseLengthPrefixedPair(payload, result, observation)) + { + SendAddonError(player, requestId, "BAD_FINISH", "Invalid finish payload."); + return; + } + + if (result != "reproduced" && + result != "not_reproduced" && + result != "inconclusive") + { + SendAddonError(player, requestId, "BAD_RESULT", "Invalid reproduction result."); + return; + } + + auto it = g_states.find(PlayerKey(player)); + uint64 reproId = + it != g_states.end() && it->second.activeReproductionId + ? *it->second.activeReproductionId + : 0; + + if (!FinishTesterReproduction(player, result, observation)) + { + SendAddonError(player, requestId, "NO_ACTIVE_REPRO", "No active reproduction."); + return; + } + + SendAddonResponse( + player, + requestId, + "REPRO_FINISHED|" + std::to_string(reproId) + "|" + result); + return; + } + + if (operation == 'A') + { + auto it = g_states.find(PlayerKey(player)); + uint64 reproId = + it != g_states.end() && it->second.activeReproductionId + ? *it->second.activeReproductionId + : 0; + + std::string reason = payload.empty() + ? std::string("aborted by tester") + : payload; + + if (!FinishTesterReproduction(player, "aborted", reason)) + { + SendAddonError(player, requestId, "NO_ACTIVE_REPRO", "No active reproduction."); + return; + } + + SendAddonResponse( + player, + requestId, + "REPRO_FINISHED|" + std::to_string(reproId) + "|aborted"); + return; + } + + if (operation == 'C') + { + std::string reportText; + std::string rest; + if (!SplitFirst(payload, '|', reportText, rest)) + { + SendAddonError(player, requestId, "BAD_CREATE_BUG", "Invalid create-bug payload."); + return; + } + + std::string severity; + std::string title; + if (!SplitFirst(rest, '|', severity, title)) + { + SendAddonError(player, requestId, "BAD_CREATE_BUG", "Invalid create-bug payload."); + return; + } + + uint32 reportId = 0; + if (!ParseUnsigned(reportText, reportId) || !reportId) + { + SendAddonError(player, requestId, "BAD_REPORT_ID", "Invalid report ID."); + return; + } + + auto bugId = CreateBugFromReport(player, reportId, severity, title); + if (!bugId) + { + SendAddonError(player, requestId, "BUG_CREATE_FAILED", "Could not create bug."); + return; + } + + std::ostringstream response; + response << "BUG_CREATED|" << *bugId << "|" << reportId; + SendAddonResponse(player, requestId, response.str()); + return; + } + + if (operation == 'K') + { + std::string reportText; + std::string bugText; + if (!SplitFirst(payload, '|', reportText, bugText)) + { + SendAddonError(player, requestId, "BAD_LINK", "Invalid link payload."); + return; + } + + uint32 reportId = 0; + uint32 bugId = 0; + if (!ParseUnsigned(reportText, reportId) || + !ParseUnsigned(bugText, bugId) || + !reportId || !bugId || + !LinkReportToBug(reportId, bugId)) + { + SendAddonError(player, requestId, "LINK_FAILED", "Could not link report to bug."); + return; + } + + std::ostringstream response; + response << "REPORT_LINKED|" << reportId << "|" << bugId; + SendAddonResponse(player, requestId, response.str()); + return; + } + + if (operation == 'X') + { + std::string reportText; + std::string rest; + if (!SplitFirst(payload, '|', reportText, rest)) + { + SendAddonError(player, requestId, "BAD_CLOSE", "Invalid close payload."); + return; + } + + std::string reason; + std::string note; + if (!SplitFirst(rest, '|', reason, note)) + { + reason = rest; + note.clear(); + } + + uint32 reportId = 0; + if (!ParseUnsigned(reportText, reportId) || + !reportId || + !CloseReport(player, reportId, reason, note)) + { + SendAddonError(player, requestId, "CLOSE_FAILED", "Could not close report."); + return; + } + + SendAddonResponse( + player, + requestId, + "REPORT_CLOSED|" + std::to_string(reportId)); + return; + } + + if (operation == 'O') + { + uint32 reportId = 0; + if (!ParseUnsigned(payload, reportId) || !reportId || !ReopenReport(reportId)) + { + SendAddonError(player, requestId, "REOPEN_FAILED", "Could not reopen report."); + return; + } + + SendAddonResponse( + player, + requestId, + "REPORT_REOPENED|" + std::to_string(reportId)); + return; + } + + if (operation == 'Q') + { + uint32 bugId = 0; + if (!ParseUnsigned(payload, bugId) || !bugId) + { + SendAddonError(player, requestId, "BAD_BUG_ID", "Invalid bug ID."); + return; + } + + auto runId = QueueDiagnosticRun(player, bugId); + if (!runId) + { + SendAddonError(player, requestId, "DIAG_FAILED", "Could not queue diagnostic run."); + return; + } + + std::ostringstream response; + response << "DIAG_QUEUED|" << *runId << "|" << bugId; + SendAddonResponse(player, requestId, response.str()); + return; + } + + SendAddonError(player, requestId, "UNKNOWN_OPERATION", "Unknown AddOn operation."); + } + + static void HandleAddonTransport(Player* player, std::string const& message) + { + if (!player) + return; + + if (message.rfind(ADDON_PROTOCOL_PREFIX, 0) != 0) + return; + + std::string body = message.substr( + std::char_traits::length(ADDON_PROTOCOL_PREFIX)); + + // VCORE1|CHUNK||||| + std::size_t p1 = body.find('|'); + std::size_t p2 = p1 == std::string::npos ? std::string::npos : body.find('|', p1 + 1); + std::size_t p3 = p2 == std::string::npos ? std::string::npos : body.find('|', p2 + 1); + std::size_t p4 = p3 == std::string::npos ? std::string::npos : body.find('|', p3 + 1); + + if (p1 == std::string::npos || + p2 == std::string::npos || + p3 == std::string::npos || + p4 == std::string::npos) + { + SendTransportStatus(player, "Malformed AddOn message."); + return; + } + + std::string opText = body.substr(0, p1); + std::string requestId = body.substr(p1 + 1, p2 - p1 - 1); + std::string indexText = body.substr(p2 + 1, p3 - p2 - 1); + std::string totalText = body.substr(p3 + 1, p4 - p3 - 1); + std::string chunk = body.substr(p4 + 1); + + if (opText.size() != 1 || requestId.empty() || requestId.size() > 32) + { + SendTransportStatus(player, "Malformed AddOn message header."); + return; + } + + uint32 index = 0; + uint32 total = 0; + + if (!ParseUnsigned(indexText, index) || + !ParseUnsigned(totalText, total) || + index == 0 || + total == 0 || + index > total || + total > MAX_ADDON_TRANSPORT_CHUNKS) + { + SendTransportStatus(player, "Invalid AddOn chunk metadata."); + return; + } + + auto& state = g_states[PlayerKey(player)]; + auto const now = Clock::now(); + + if (state.pendingAddonPayload && + now - state.pendingAddonPayload->startedAt > ADDON_TRANSPORT_TIMEOUT) + { + state.pendingAddonPayload.reset(); + } + + char operation = opText[0]; + + if (!state.pendingAddonPayload || + state.pendingAddonPayload->requestId != requestId || + state.pendingAddonPayload->operation != operation || + state.pendingAddonPayload->totalChunks != total) + { + PendingAddonPayload pending; + pending.operation = operation; + pending.requestId = requestId; + pending.totalChunks = total; + pending.startedAt = now; + pending.chunks.resize(total); + pending.received.resize(total, false); + state.pendingAddonPayload = std::move(pending); + } + + PendingAddonPayload& pending = *state.pendingAddonPayload; + std::size_t slot = std::size_t(index - 1); + + if (!pending.received[slot]) + { + if (pending.receivedBytes + chunk.size() > MAX_ADDON_TRANSPORT_BYTES) + { + state.pendingAddonPayload.reset(); + SendTransportStatus(player, "AddOn payload too large."); + return; + } + + pending.chunks[slot] = std::move(chunk); + pending.received[slot] = true; + pending.receivedBytes += pending.chunks[slot].size(); + ++pending.receivedChunks; + } + + if (pending.receivedChunks != pending.totalChunks) + return; + + std::string payload; + payload.reserve(pending.receivedBytes); + + for (std::string const& part : pending.chunks) + payload += part; + + char completedOperation = pending.operation; + std::string completedRequestId = pending.requestId; + state.pendingAddonPayload.reset(); + + DispatchAddonPayload(player, completedOperation, completedRequestId, payload); + } + + class VCoreDiagnosticsPlayerScript : public PlayerScript + { + public: + VCoreDiagnosticsPlayerScript() + : PlayerScript("VCoreDiagnosticsPlayerScript") + { + } + + void OnLogin(Player* player, bool /*firstLogin*/) override + { + g_states.try_emplace(PlayerKey(player)); + Record(player, "login"); + } + + void OnLogout(Player* player) override + { + if (!player) + return; + + auto it = g_states.find(PlayerKey(player)); + + if (it != g_states.end() && it->second.activeReproductionId) + { + FinishReproduction( + player, + "aborted", + "Player logged out while reproduction was active."); + } + + g_states.erase(PlayerKey(player)); + } + + void OnChat( + Player* player, + uint32 type, + uint32 lang, + std::string& msg, + Player* receiver) override + { + // The existing Vcore AddOn whisper path ends up in this PlayerScript + // hook. Restrict diagnostics transport to self-whispers only. + if (!player || + receiver != player || + type != CHAT_MSG_WHISPER || + lang != uint32(LANG_ADDON)) + { + return; + } + + HandleAddonTransport(player, msg); + } + + void OnUpdateZone(Player* player, uint32 newZone, uint32 newArea) override + { + std::ostringstream data; + data << "{\"zone_id\":" << newZone + << ",\"area_id\":" << newArea << "}"; + + Record(player, "zone_changed", data.str()); + } + + void OnMapChanged(Player* player) override + { + std::ostringstream data; + data << "{\"map_id\":" << player->GetMapId() << "}"; + + Record(player, "map_changed", data.str()); + } + + void OnQuestStatusChange(Player* player, uint32 questId) override + { + std::ostringstream data; + data << "{\"quest_id\":" << questId + << ",\"status\":" << uint32(player->GetQuestStatus(questId)) + << "}"; + + Record(player, "quest_status_changed", data.str()); + } + + void OnSpellCast(Player* player, Spell* spell, bool /*skipCheck*/) override + { + if (!spell || !spell->GetSpellInfo()) + return; + + std::ostringstream data; + data << "{\"spell_id\":" << spell->GetSpellInfo()->Id << "}"; + + Record(player, "spell_cast", data.str()); + } + + void OnCreatureKill(Player* killer, Creature* killed) override + { + if (!killer || !killed) + return; + + std::ostringstream data; + data << "{\"entry\":" << killed->GetEntry() + << ",\"guid\":" << killed->GetGUID().GetCounter() + << "}"; + + Record(killer, "creature_kill", data.str()); + } + + void OnPlayerRepop(Player* player) override + { + Record(player, "player_repop"); + } + }; + + class VCoreDiagnosticsCommandScript : public CommandScript + { + public: + VCoreDiagnosticsCommandScript() + : CommandScript("VCoreDiagnosticsCommandScript") + { + } + + std::vector GetCommands() const override + { + static std::vector reproductionCommands = + { + { "start", rbac::RBAC_PERM_COMMAND_GM, false, &HandleReproStart, "" }, + { "stop", rbac::RBAC_PERM_COMMAND_GM, false, &HandleReproStop, "" }, + { "abort", rbac::RBAC_PERM_COMMAND_GM, false, &HandleReproAbort, "" }, + { "note", rbac::RBAC_PERM_COMMAND_GM, false, &HandleReproNote, "" }, + { "status", rbac::RBAC_PERM_COMMAND_GM, false, &HandleReproStatus, "" }, + }; + + static std::vector diagnosticsCommands = + { + { "repro", rbac::RBAC_PERM_COMMAND_GM, false, nullptr, "", reproductionCommands }, + { "report", rbac::RBAC_PERM_COMMAND_GM, false, &HandleReport, "" }, + }; + + static std::vector commandTable = + { + { "vdiag", rbac::RBAC_PERM_COMMAND_GM, false, nullptr, "", diagnosticsCommands }, + }; + + return commandTable; + } + + static bool HandleReproStart(ChatHandler* handler, char const* args) + { + if (!args || !*args) + return false; + + char* end = nullptr; + unsigned long long parsed = std::strtoull(args, &end, 10); + + if (end == args || parsed == 0) + return false; + + Player* player = handler->GetSession()->GetPlayer(); + + auto id = StartReproductionForReport(player, uint64(parsed)); + + if (!id) + { + handler->SendSysMessage("Could not start Vcore reproduction."); + return false; + } + + handler->PSendSysMessage( + "Vcore reproduction started from report: REPRO-%llu", + static_cast(*id)); + + return true; + } + + static bool HandleReproStop(ChatHandler* handler, char const* args) + { + Player* player = handler->GetSession()->GetPlayer(); + std::string observation = args ? args : ""; + + if (!FinishReproduction(player, "reproduced", observation)) + { + handler->SendSysMessage("No active Vcore reproduction."); + return false; + } + + handler->SendSysMessage("Vcore reproduction stopped."); + return true; + } + + static bool HandleReproAbort(ChatHandler* handler, char const* args) + { + Player* player = handler->GetSession()->GetPlayer(); + + std::string reason = + (args && *args) + ? std::string(args) + : std::string("aborted by tester"); + + if (!FinishReproduction(player, "aborted", reason)) + { + handler->SendSysMessage("No active Vcore reproduction."); + return false; + } + + handler->SendSysMessage("Vcore reproduction aborted."); + return true; + } + + static bool HandleReproNote(ChatHandler* handler, char const* args) + { + if (!args || !*args) + return false; + + Player* player = handler->GetSession()->GetPlayer(); + + if (!AddTesterNote(player, args)) + { + handler->SendSysMessage("No active Vcore reproduction."); + return false; + } + + handler->SendSysMessage("Vcore tester note stored."); + return true; + } + + static bool HandleReproStatus(ChatHandler* handler, char const* /*args*/) + { + Player* player = handler->GetSession()->GetPlayer(); + + auto it = g_states.find(PlayerKey(player)); + + if (it == g_states.end() || !it->second.activeReproductionId) + { + handler->SendSysMessage("No active Vcore reproduction."); + return true; + } + + handler->PSendSysMessage( + "Active Vcore reproduction: REPRO-%llu", + static_cast(*it->second.activeReproductionId)); + + return true; + } + + static bool HandleReport(ChatHandler* handler, char const* args) + { + if (!args || !*args) + return false; + + Player* player = handler->GetSession()->GetPlayer(); + + ReportCreateResponse response = SubmitPlayerReport(player, "medium", args, "{}"); + + if (response.result != ReportCreateResult::Ok || !response.reportId) + { + switch (response.result) + { + case ReportCreateResult::RateLimited: + handler->SendSysMessage("Vcore report rejected: report cooldown active."); + break; + case ReportCreateResult::DescriptionTooLarge: + handler->SendSysMessage("Vcore report rejected: description too large."); + break; + default: + handler->SendSysMessage("Could not create Vcore bug report."); + break; + } + + return false; + } + + handler->PSendSysMessage( + "Vcore bug report created: REPORT-%llu", + static_cast(*response.reportId)); + + return true; + } + }; +} + +void AddSC_vcore_diagnostics() +{ + new VCoreDiagnostics::VCoreDiagnosticsPlayerScript(); + new VCoreDiagnostics::VCoreDiagnosticsCommandScript(); +} diff --git a/src/server/scripts/Custom/vcore_diagnostics.cpp.before_quest_selection b/src/server/scripts/Custom/vcore_diagnostics.cpp.before_quest_selection new file mode 100644 index 0000000000..a686421ced --- /dev/null +++ b/src/server/scripts/Custom/vcore_diagnostics.cpp.before_quest_selection @@ -0,0 +1,2190 @@ +/* + * Vcore Diagnostics Server v0.4 + * + * Target: The Cataclysm Preservation Project / Vcore API line. + * + * Provides: + * - short-lived per-player in-memory blackbox + * - persistent tester reproduction sessions + * - server snapshot attached to bug reports + * + * Deliberately does NOT patch gameplay. + */ + +#include "ScriptMgr.h" +#include "Chat.h" +#include "ChatCommand.h" +#include "DatabaseEnv.h" +#include "Group.h" +#include "Player.h" +#include "Spell.h" +#include "WorldSession.h" +#include "RBAC.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace VCoreDiagnostics +{ + using Clock = std::chrono::system_clock; + + static constexpr std::size_t BLACKBOX_MAX_EVENTS = 64; + static constexpr std::chrono::seconds BLACKBOX_MAX_AGE{120}; + + static constexpr std::size_t MAX_PLAYER_REPORT_TEXT_BYTES = 4096; + static constexpr std::size_t MAX_CLIENT_CONTEXT_BYTES = 16384; + static constexpr std::size_t MAX_TESTER_NOTE_BYTES = 4096; + static constexpr std::chrono::seconds PLAYER_REPORT_COOLDOWN{10}; + + static constexpr char ADDON_PROTOCOL_PREFIX[] = "VCORE1|CHUNK|"; + static constexpr std::size_t MAX_ADDON_TRANSPORT_BYTES = 24576; + static constexpr uint32 MAX_ADDON_TRANSPORT_CHUNKS = 128; + static constexpr std::chrono::seconds ADDON_TRANSPORT_TIMEOUT{30}; + + struct BlackboxEvent + { + Clock::time_point time; + std::string type; + std::string dataJson; + }; + + struct PendingAddonPayload + { + char operation = 0; + std::string requestId; + uint32 totalChunks = 0; + uint32 receivedChunks = 0; + std::size_t receivedBytes = 0; + Clock::time_point startedAt{}; + std::vector chunks; + std::vector received; + }; + + struct PlayerDiagnosticsState + { + std::deque blackbox; + std::optional activeReproductionId; + std::optional activeReportId; + std::optional lastPlayerReportAt; + std::optional pendingAddonPayload; + }; + + static std::unordered_map g_states; + + static uint64 PlayerKey(Player const* player) + { + return uint64(player->GetGUID().GetCounter()); + } + + static std::string JsonEscape(std::string const& input) + { + std::ostringstream out; + + for (char c : input) + { + switch (c) + { + case '\\': out << "\\\\"; break; + case '"': out << "\\\""; break; + case '\n': out << "\\n"; break; + case '\r': out << "\\r"; break; + case '\t': out << "\\t"; break; + default: + if (static_cast(c) < 0x20) + out << ' '; + else + out << c; + break; + } + } + + return out.str(); + } + + static int64 UnixMs(Clock::time_point tp) + { + return std::chrono::duration_cast( + tp.time_since_epoch()).count(); + } + + static bool IsPayloadSizeValid(std::string const& value, std::size_t maxBytes) + { + return value.size() <= maxBytes; + } + + static bool LooksLikeJsonObject(std::string const& value) + { + if (value.empty()) + return true; + + std::size_t first = value.find_first_not_of(" \t\r\n"); + std::size_t last = value.find_last_not_of(" \t\r\n"); + + if (first == std::string::npos || last == std::string::npos) + return true; + + return value[first] == '{' && value[last] == '}'; + } + + static bool CanCreatePlayerReport(Player* player) + { + if (!player) + return false; + + auto& state = g_states[PlayerKey(player)]; + + if (!state.lastPlayerReportAt) + return true; + + return Clock::now() - *state.lastPlayerReportAt >= PLAYER_REPORT_COOLDOWN; + } + + static void SendTransportStatus(Player* player, std::string const& text) + { + if (!player || !player->GetSession()) + return; + + ChatHandler(player->GetSession()).PSendSysMessage( + "[Vcore Diagnostics] %s", + text.c_str()); + } + + static bool ParseUnsigned(std::string const& value, uint32& out) + { + if (value.empty()) + return false; + + char* end = nullptr; + unsigned long parsed = std::strtoul(value.c_str(), &end, 10); + + if (!end || *end != '\0') + return false; + + if (parsed > 0xFFFFFFFFul) + return false; + + out = uint32(parsed); + return true; + } + + static bool ParseLengthPrefixedPair( + std::string const& payload, + std::string& first, + std::string& second) + { + std::size_t colon = payload.find(':'); + if (colon == std::string::npos) + return false; + + uint32 firstLength = 0; + if (!ParseUnsigned(payload.substr(0, colon), firstLength)) + return false; + + std::size_t dataStart = colon + 1; + if (firstLength > payload.size() - dataStart) + return false; + + first = payload.substr(dataStart, firstLength); + second = payload.substr(dataStart + firstLength); + return true; + } + + static std::string MakeToken(char const* prefix, Player const* player) + { + std::ostringstream out; + out << prefix << "-" + << player->GetGUID().GetCounter() << "-" + << UnixMs(Clock::now()); + return out.str(); + } + + static void Prune(PlayerDiagnosticsState& state) + { + auto const now = Clock::now(); + + while (!state.blackbox.empty() && + now - state.blackbox.front().time > BLACKBOX_MAX_AGE) + { + state.blackbox.pop_front(); + } + + while (state.blackbox.size() > BLACKBOX_MAX_EVENTS) + state.blackbox.pop_front(); + } + + static std::string TargetSnapshot(Player* player) + { + Unit* selected = player->GetSelectedUnit(); + + if (!selected) + return "null"; + + std::ostringstream out; + out << "{"; + out << "\"guid\":" << selected->GetGUID().GetCounter() << ","; + out << "\"type\":\"" << (selected->ToCreature() ? "creature" : "unit") << "\""; + + if (Creature* creature = selected->ToCreature()) + { + out << ",\"entry\":" << creature->GetEntry(); + out << ",\"spawn_id\":" << creature->GetSpawnId(); + } + + out << "}"; + return out.str(); + } + + static std::string ActiveQuestSnapshot(Player* player) + { + std::ostringstream out; + out << "["; + + bool first = true; + + for (uint8 slot = 0; slot < MAX_QUEST_LOG_SIZE; ++slot) + { + uint32 questId = player->GetQuestSlotQuestId(slot); + if (!questId) + continue; + + if (!first) + out << ","; + + first = false; + + out << "{"; + out << "\"id\":" << questId << ","; + out << "\"status\":" << uint32(player->GetQuestStatus(questId)); + out << "}"; + } + + out << "]"; + return out.str(); + } + + static std::string BuildServerSnapshot(Player* player) + { + std::ostringstream out; + + out << "{"; + + out << "\"character\":{"; + out << "\"guid\":" << player->GetGUID().GetCounter() << ","; + out << "\"name\":\"" << JsonEscape(player->GetName()) << "\","; + out << "\"account_id\":" << player->GetSession()->GetAccountId() << ","; + out << "\"level\":" << uint32(player->getLevel()) << ","; + out << "\"class\":" << uint32(player->getClass()); + out << "},"; + + out << "\"location\":{"; + out << "\"map_id\":" << player->GetMapId() << ","; + out << "\"zone_id\":" << player->GetZoneId() << ","; + out << "\"area_id\":" << player->GetAreaId() << ","; + out << "\"x\":" << player->GetPositionX() << ","; + out << "\"y\":" << player->GetPositionY() << ","; + out << "\"z\":" << player->GetPositionZ() << ","; + out << "\"o\":" << player->GetOrientation(); + out << "},"; + + out << "\"target\":" << TargetSnapshot(player) << ","; + out << "\"active_quests\":" << ActiveQuestSnapshot(player) << ","; + + out << "\"state\":{"; + out << "\"alive\":" << (player->IsAlive() ? "true" : "false") << ","; + out << "\"in_combat\":" << (player->IsInCombat() ? "true" : "false") << ","; + out << "\"in_group\":" << (player->GetGroup() ? "true" : "false"); + out << "}"; + + out << "}"; + + return out.str(); + } + + static std::string BuildBlackboxJson(Player* player) + { + auto& state = g_states[PlayerKey(player)]; + Prune(state); + + std::ostringstream out; + out << "["; + + bool first = true; + auto const now = Clock::now(); + + for (BlackboxEvent const& event : state.blackbox) + { + if (!first) + out << ","; + + first = false; + + int64 offsetMs = + std::chrono::duration_cast( + event.time - now).count(); + + out << "{"; + out << "\"offset_ms\":" << offsetMs << ","; + out << "\"type\":\"" << JsonEscape(event.type) << "\","; + out << "\"data\":" << (event.dataJson.empty() ? "{}" : event.dataJson); + out << "}"; + } + + out << "]"; + return out.str(); + } + + static void ExecuteSync(std::string sql) + { + WorldDatabase.DirectExecute(sql.c_str()); + } + + static QueryResult QuerySync(std::string const& sql) + { + return WorldDatabase.Query(sql.c_str()); + } + + static std::string HexEncode(std::string const& input) + { + static char const* HEX = "0123456789ABCDEF"; + std::string out; + out.reserve(input.size() * 2); + + for (unsigned char c : input) + { + out.push_back(HEX[(c >> 4) & 0x0F]); + out.push_back(HEX[c & 0x0F]); + } + + return out; + } + + static void SendAddonResponse( + Player* player, + std::string const& requestId, + std::string const& payload) + { + if (!player || !player->GetSession()) + return; + + static constexpr std::size_t RESPONSE_CHUNK_SIZE = 180; + std::size_t total = (payload.size() + RESPONSE_CHUNK_SIZE - 1) / RESPONSE_CHUNK_SIZE; + if (total == 0) + total = 1; + + for (std::size_t i = 0; i < total; ++i) + { + std::size_t first = i * RESPONSE_CHUNK_SIZE; + std::string chunk = payload.substr(first, RESPONSE_CHUNK_SIZE); + + std::ostringstream body; + body << "VCORE2|RESP|" + << requestId << "|" + << (i + 1) << "|" + << total << "|" + << chunk; + + WorldPacket data; + ChatHandler::BuildChatPacket( + data, + CHAT_MSG_WHISPER, + LANG_ADDON, + player, + player, + body.str(), + 0U, + "", + DEFAULT_LOCALE, + "VCORE"); + + player->SendDirectMessage(&data); + } + } + + static void SendAddonError( + Player* player, + std::string const& requestId, + std::string const& code, + std::string const& message) + { + SendAddonResponse( + player, + requestId, + "ERROR|" + code + "|" + HexEncode(message)); + } + + static bool IsSeverityValid(std::string const& severity, bool allowUnknown) + { + return severity == "low" || + severity == "medium" || + severity == "high" || + severity == "critical" || + (allowUnknown && severity == "unknown"); + } + + enum class ReportCreateResult + { + Ok, + InvalidPlayer, + EmptyDescription, + DescriptionTooLarge, + ClientContextTooLarge, + InvalidClientContext, + RateLimited, + DatabaseFailure + }; + + struct ReportCreateResponse + { + ReportCreateResult result = ReportCreateResult::DatabaseFailure; + std::optional reportId; + }; + + static void InsertReproductionEvent( + uint64 reproductionId, + char const* source, + char const* eventType, + std::string dataJson) + { + std::string safeSource = source ? source : "server"; + std::string safeType = eventType ? eventType : "unknown"; + std::string safeData = std::move(dataJson); + + WorldDatabase.EscapeString(safeSource); + WorldDatabase.EscapeString(safeType); + WorldDatabase.EscapeString(safeData); + + std::ostringstream sql; + sql << "INSERT INTO vcore_dev.bug_reproduction_events " + << "(reproduction_id, source, event_type, data_json) VALUES (" + << reproductionId << ", '" + << safeSource << "', '" + << safeType << "', '" + << safeData << "')"; + + ExecuteSync(sql.str()); + } + + static void Record(Player* player, std::string type, std::string dataJson = "{}") + { + if (!player) + return; + + auto& state = g_states[PlayerKey(player)]; + + state.blackbox.push_back( + { Clock::now(), std::move(type), dataJson.empty() ? "{}" : std::move(dataJson) }); + + Prune(state); + + if (state.activeReproductionId) + { + BlackboxEvent const& event = state.blackbox.back(); + + InsertReproductionEvent( + *state.activeReproductionId, + "server", + event.type.c_str(), + event.dataJson); + } + } + + static std::optional ResolveReproductionIdByToken(std::string token) + { + WorldDatabase.EscapeString(token); + + std::ostringstream sql; + sql << "SELECT reproduction_id " + << "FROM vcore_dev.bug_reproductions " + << "WHERE session_token='" << token << "' LIMIT 1"; + + QueryResult result = QuerySync(sql.str()); + + if (!result) + return std::nullopt; + + return (*result)[0].GetUInt64(); + } + + static std::optional ResolveReportIdByToken(std::string token) + { + WorldDatabase.EscapeString(token); + + std::ostringstream sql; + sql << "SELECT report_id " + << "FROM vcore_dev.bug_reports " + << "WHERE report_token='" << token << "' LIMIT 1"; + + QueryResult result = QuerySync(sql.str()); + + if (!result) + return std::nullopt; + + return (*result)[0].GetUInt64(); + } + + static std::optional StartReproductionForReport( + Player* player, + uint64 reportId) + { + if (!player || !reportId) + return std::nullopt; + + auto& state = g_states[PlayerKey(player)]; + + if (state.activeReproductionId) + return state.activeReproductionId; + + std::ostringstream reportQuery; + reportQuery + << "SELECT COALESCE(bug_id,0), status " + << "FROM vcore_dev.bug_reports " + << "WHERE report_id=" << reportId << " LIMIT 1"; + + QueryResult reportResult = QuerySync(reportQuery.str()); + if (!reportResult) + return std::nullopt; + + Field* reportFields = reportResult->Fetch(); + uint64 bugId = reportFields[0].GetUInt64(); + std::string reportStatus = reportFields[1].GetString(); + + if (reportStatus == "closed") + return std::nullopt; + + std::string token = MakeToken("repro", player); + std::string safeToken = token; + std::string safeName = player->GetName(); + + WorldDatabase.EscapeString(safeToken); + WorldDatabase.EscapeString(safeName); + + std::ostringstream sql; + sql << "INSERT INTO vcore_dev.bug_reproductions " + << "(session_token, bug_id, report_id, tester_account_id, tester_character_guid, " + << "tester_character_name, result, map_id, zone_id, area_id, " + << "position_x, position_y, position_z, orientation, started_at, created_at) " + << "VALUES ('" + << safeToken << "', "; + + if (bugId) + sql << bugId; + else + sql << "NULL"; + + sql << ", " + << reportId << ", " + << player->GetSession()->GetAccountId() << ", " + << player->GetGUID().GetCounter() << ", '" + << safeName << "', 'running', " + << player->GetMapId() << ", " + << player->GetZoneId() << ", " + << player->GetAreaId() << ", " + << player->GetPositionX() << ", " + << player->GetPositionY() << ", " + << player->GetPositionZ() << ", " + << player->GetOrientation() + << ", NOW(), NOW())"; + + ExecuteSync(sql.str()); + + auto id = ResolveReproductionIdByToken(token); + if (!id) + return std::nullopt; + + state.activeReproductionId = *id; + state.activeReportId = reportId; + + std::ostringstream updateReport; + updateReport + << "UPDATE vcore_dev.bug_reports SET " + << "status=IF(status='open','investigating',status), " + << "updated_at=NOW() " + << "WHERE report_id=" << reportId; + ExecuteSync(updateReport.str()); + + InsertReproductionEvent( + *id, + "server", + "reproduction_started", + "{\"snapshot\":" + BuildServerSnapshot(player) + "}"); + + return id; + } + + static bool FinishReproduction( + Player* player, + std::string result, + std::string observation) + { + if (!player) + return false; + + auto& state = g_states[PlayerKey(player)]; + + if (!state.activeReproductionId) + return false; + + uint64 id = *state.activeReproductionId; + + InsertReproductionEvent( + id, + "server", + "reproduction_stopped", + "{\"snapshot\":" + BuildServerSnapshot(player) + "}"); + + std::string safeResult = result.empty() ? "unknown" : result; + std::string safeObservation = std::move(observation); + + WorldDatabase.EscapeString(safeResult); + WorldDatabase.EscapeString(safeObservation); + + std::ostringstream sql; + sql << "UPDATE vcore_dev.bug_reproductions SET " + << "result='" << safeResult << "', " + << "observation='" << safeObservation << "', " + << "finished_at=NOW() " + << "WHERE reproduction_id=" << id; + + ExecuteSync(sql.str()); + + state.activeReproductionId.reset(); + state.activeReportId.reset(); + + return true; + } + + static bool AddTesterNote(Player* player, std::string note) + { + if (!player || note.empty()) + return false; + + if (!IsPayloadSizeValid(note, MAX_TESTER_NOTE_BYTES)) + return false; + + auto& state = g_states[PlayerKey(player)]; + + if (!state.activeReproductionId) + return false; + + std::ostringstream data; + data << "{"; + data << "\"text\":\"" << JsonEscape(note) << "\","; + data << "\"snapshot\":" << BuildServerSnapshot(player); + data << "}"; + + InsertReproductionEvent( + *state.activeReproductionId, + "tester", + "tester_note", + data.str()); + + return true; + } + + static ReportCreateResponse CreateBugReport( + Player* player, + std::string reportedSeverity, + std::string description, + std::string clientContextJson = "{}") + { + ReportCreateResponse response; + + if (!player) + { + response.result = ReportCreateResult::InvalidPlayer; + return response; + } + + if (!IsSeverityValid(reportedSeverity, false)) + { + response.result = ReportCreateResult::DatabaseFailure; + return response; + } + + if (description.empty()) + { + response.result = ReportCreateResult::EmptyDescription; + return response; + } + + if (!IsPayloadSizeValid(description, MAX_PLAYER_REPORT_TEXT_BYTES)) + { + response.result = ReportCreateResult::DescriptionTooLarge; + return response; + } + + if (!IsPayloadSizeValid(clientContextJson, MAX_CLIENT_CONTEXT_BYTES)) + { + response.result = ReportCreateResult::ClientContextTooLarge; + return response; + } + + if (!LooksLikeJsonObject(clientContextJson)) + { + response.result = ReportCreateResult::InvalidClientContext; + return response; + } + + if (!CanCreatePlayerReport(player)) + { + response.result = ReportCreateResult::RateLimited; + return response; + } + + if (clientContextJson.empty()) + clientContextJson = "{}"; + + std::string token = MakeToken("report", player); + std::string safeToken = token; + std::string safeSeverity = std::move(reportedSeverity); + std::string safeDescription = std::move(description); + std::string safeName = player->GetName(); + + WorldDatabase.EscapeString(safeToken); + WorldDatabase.EscapeString(safeSeverity); + WorldDatabase.EscapeString(safeDescription); + WorldDatabase.EscapeString(safeName); + + Unit* target = player->GetSelectedUnit(); + + uint64 targetGuid = + target ? uint64(target->GetGUID().GetCounter()) : uint64(0); + + uint32 targetEntry = + target && target->ToCreature() + ? target->ToCreature()->GetEntry() + : uint32(0); + + std::ostringstream context; + context << "{"; + context << "\"client\":" << clientContextJson << ","; + context << "\"server\":" << BuildServerSnapshot(player) << ","; + context << "\"server_blackbox\":" << BuildBlackboxJson(player) << ","; + context << "\"meta\":{"; + context << "\"client_context_trusted\":false,"; + context << "\"server_context_trusted\":true"; + context << "}"; + context << "}"; + + std::string contextJson = context.str(); + WorldDatabase.EscapeString(contextJson); + + std::ostringstream sql; + sql << "INSERT INTO vcore_dev.bug_reports " + << "(report_token, bug_id, source_type, account_id, character_guid, " + << "character_name, reported_severity, status, description, map_id, zone_id, area_id, " + << "position_x, position_y, position_z, orientation, " + << "target_guid, target_entry, target_type, context_json, created_at) " + << "VALUES ('" + << safeToken << "', NULL, 'player', " + << player->GetSession()->GetAccountId() << ", " + << player->GetGUID().GetCounter() << ", '" + << safeName << "', '" + << safeSeverity << "', 'open', '" + << safeDescription << "', " + << player->GetMapId() << ", " + << player->GetZoneId() << ", " + << player->GetAreaId() << ", " + << player->GetPositionX() << ", " + << player->GetPositionY() << ", " + << player->GetPositionZ() << ", " + << player->GetOrientation() << ", "; + + if (targetGuid) + sql << targetGuid; + else + sql << "NULL"; + + sql << ", "; + + if (targetEntry) + sql << targetEntry; + else + sql << "NULL"; + + sql << ", "; + + if (target && target->ToCreature()) + sql << "'creature'"; + else if (target) + sql << "'unit'"; + else + sql << "NULL"; + + sql << ", '" << contextJson << "', NOW())"; + + ExecuteSync(sql.str()); + + auto reportId = ResolveReportIdByToken(token); + if (!reportId) + { + response.result = ReportCreateResult::DatabaseFailure; + return response; + } + + g_states[PlayerKey(player)].lastPlayerReportAt = Clock::now(); + + response.result = ReportCreateResult::Ok; + response.reportId = reportId; + return response; + } + + static ReportCreateResponse SubmitPlayerReport( + Player* player, + std::string reportedSeverity, + std::string description, + std::string clientContextJson) + { + return CreateBugReport( + player, + std::move(reportedSeverity), + std::move(description), + std::move(clientContextJson)); + } + + static std::optional StartTesterReproduction( + Player* player, + uint64 reportId) + { + return StartReproductionForReport(player, reportId); + } + + static bool AddTesterReproductionNote( + Player* player, + std::string note) + { + return AddTesterNote(player, std::move(note)); + } + + static bool FinishTesterReproduction( + Player* player, + std::string result, + std::string observation) + { + return FinishReproduction( + player, + std::move(result), + std::move(observation)); + } + + + static bool HasTesterPermission(Player* player); + + static std::string SeverityOrderSql(char const* column) + { + std::ostringstream sql; + sql << "CASE " << column + << " WHEN 'critical' THEN 1" + << " WHEN 'high' THEN 2" + << " WHEN 'medium' THEN 3" + << " WHEN 'low' THEN 4" + << " ELSE 5 END"; + return sql.str(); + } + + static bool SplitFirst( + std::string const& value, + char delimiter, + std::string& first, + std::string& rest) + { + std::size_t pos = value.find(delimiter); + if (pos == std::string::npos) + return false; + + first = value.substr(0, pos); + rest = value.substr(pos + 1); + return true; + } + + static void SendPermissions(Player* player, std::string const& requestId) + { + bool tester = HasTesterPermission(player); + SendAddonResponse( + player, + requestId, + std::string("PERM|") + (tester ? "1" : "0")); + + auto it = g_states.find(PlayerKey(player)); + if (it != g_states.end() && + it->second.activeReproductionId && + it->second.activeReportId) + { + std::ostringstream response; + response << "SESSION|" + << *it->second.activeReproductionId << "|" + << *it->second.activeReportId; + SendAddonResponse(player, requestId, response.str()); + } + else + { + SendAddonResponse(player, requestId, "SESSION|0|0"); + } + } + + static void SendReportList( + Player* player, + std::string const& requestId, + std::string sort, + uint32 page) + { + if (page == 0) + page = 1; + + if (sort != "newest" && sort != "area" && sort != "severity") + sort = "newest"; + + uint32 offset = (page - 1) * 8; + + std::ostringstream sql; + sql << "SELECT report_id, reported_severity, status, " + << "COALESCE(bug_id,0), COALESCE(zone_id,0), COALESCE(area_id,0), " + << "DATE_FORMAT(created_at,'%Y-%m-%d %H:%i'), " + << "COALESCE(character_name,''), " + << "LEFT(REPLACE(REPLACE(description,'\\r',' '),'\\n',' '),120), " + << "COALESCE(JSON_UNQUOTE(JSON_EXTRACT(context_json,'$.client.zone.zone_text')),'') " + << "FROM vcore_dev.bug_reports "; + + if (sort == "area") + sql << "ORDER BY COALESCE(zone_id,0), COALESCE(area_id,0), report_id DESC "; + else if (sort == "severity") + sql << "ORDER BY " << SeverityOrderSql("reported_severity") << ", report_id DESC "; + else + sql << "ORDER BY report_id DESC "; + + sql << "LIMIT 9 OFFSET " << offset; + + QueryResult result = QuerySync(sql.str()); + + std::ostringstream response; + response << "REPORT_LIST|" << page << "|" << (page > 1 ? 1 : 0) << "|"; + + bool hasNext = false; + std::vector rows; + + if (result) + { + uint32 rowIndex = 0; + do + { + Field* fields = result->Fetch(); + + if (rowIndex >= 8) + { + hasNext = true; + break; + } + + std::ostringstream row; + row << fields[0].GetUInt64() << "|" + << fields[1].GetString() << "|" + << fields[2].GetString() << "|" + << fields[3].GetUInt64() << "|" + << fields[4].GetUInt32() << "|" + << fields[5].GetUInt32() << "|" + << HexEncode(fields[6].GetString()) << "|" + << HexEncode(fields[7].GetString()) << "|" + << HexEncode(fields[8].GetString()) << "|" + << HexEncode(fields[9].GetString()); + + rows.push_back(row.str()); + ++rowIndex; + } while (result->NextRow()); + } + + response << (hasNext ? 1 : 0) << "|"; + for (std::size_t i = 0; i < rows.size(); ++i) + { + if (i) + response << "\n"; + response << rows[i]; + } + + SendAddonResponse(player, requestId, response.str()); + } + + static void SendReportDetail( + Player* player, + std::string const& requestId, + uint64 reportId) + { + std::ostringstream sql; + sql << "SELECT report_id, reported_severity, status, COALESCE(bug_id,0), " + << "COALESCE(zone_id,0), COALESCE(area_id,0), " + << "DATE_FORMAT(created_at,'%Y-%m-%d %H:%i:%s'), " + << "COALESCE(character_name,''), description, " + << "COALESCE(closure_reason,''), COALESCE(closure_note,''), " + << "COALESCE(JSON_UNQUOTE(JSON_EXTRACT(context_json,'$.client.zone.zone_text')),''), " + << "COALESCE(JSON_UNQUOTE(JSON_EXTRACT(context_json,'$.client.zone.subzone_text')),'') " + << "FROM vcore_dev.bug_reports WHERE report_id=" << reportId << " LIMIT 1"; + + QueryResult result = QuerySync(sql.str()); + if (!result) + { + SendAddonError(player, requestId, "REPORT_NOT_FOUND", "Report not found."); + return; + } + + Field* fields = result->Fetch(); + + std::ostringstream response; + response << "REPORT_DETAIL|" + << fields[0].GetUInt64() << "|" + << fields[1].GetString() << "|" + << fields[2].GetString() << "|" + << fields[3].GetUInt64() << "|" + << fields[4].GetUInt32() << "|" + << fields[5].GetUInt32() << "|" + << HexEncode(fields[6].GetString()) << "|" + << HexEncode(fields[7].GetString()) << "|" + << HexEncode(fields[8].GetString()) << "|" + << HexEncode(fields[9].GetString()) << "|" + << HexEncode(fields[10].GetString()) << "|" + << HexEncode(fields[11].GetString()) << "|" + << HexEncode(fields[12].GetString()); + + SendAddonResponse(player, requestId, response.str()); + + std::ostringstream reproSql; + reproSql + << "SELECT reproduction_id, result, " + << "DATE_FORMAT(COALESCE(finished_at,started_at),'%Y-%m-%d %H:%i:%s'), " + << "COALESCE(tester_character_name,''), COALESCE(observation,'') " + << "FROM vcore_dev.bug_reproductions " + << "WHERE report_id=" << reportId << " " + << "ORDER BY reproduction_id DESC LIMIT 12"; + + QueryResult reproResult = QuerySync(reproSql.str()); + std::ostringstream reproResponse; + reproResponse << "REPRO_LIST|" << reportId << "|"; + + bool first = true; + if (reproResult) + { + do + { + Field* r = reproResult->Fetch(); + if (!first) + reproResponse << "\n"; + first = false; + + reproResponse + << r[0].GetUInt64() << "|" + << r[1].GetString() << "|" + << HexEncode(r[2].GetString()) << "|" + << HexEncode(r[3].GetString()) << "|" + << HexEncode(r[4].GetString()); + } while (reproResult->NextRow()); + } + + SendAddonResponse(player, requestId, reproResponse.str()); + } + + static void SendBugList( + Player* player, + std::string const& requestId, + std::string sort, + uint32 page) + { + if (page == 0) + page = 1; + + if (sort != "newest" && sort != "area" && sort != "severity") + sort = "newest"; + + uint32 offset = (page - 1) * 8; + + std::ostringstream sql; + sql << "SELECT bug_id, title, status, severity, category, " + << "COALESCE(primary_zone_id,0), COALESCE(primary_area_id,0), " + << "DATE_FORMAT(updated_at,'%Y-%m-%d %H:%i') " + << "FROM vcore_dev.bugs "; + + if (sort == "area") + sql << "ORDER BY COALESCE(primary_zone_id,0), COALESCE(primary_area_id,0), bug_id DESC "; + else if (sort == "severity") + sql << "ORDER BY " << SeverityOrderSql("severity") << ", bug_id DESC "; + else + sql << "ORDER BY updated_at DESC, bug_id DESC "; + + sql << "LIMIT 9 OFFSET " << offset; + + QueryResult result = QuerySync(sql.str()); + + std::ostringstream response; + response << "BUG_LIST|" << page << "|" << (page > 1 ? 1 : 0) << "|"; + + bool hasNext = false; + std::vector rows; + + if (result) + { + uint32 rowIndex = 0; + do + { + Field* fields = result->Fetch(); + + if (rowIndex >= 8) + { + hasNext = true; + break; + } + + std::ostringstream row; + row << fields[0].GetUInt64() << "|" + << HexEncode(fields[1].GetString()) << "|" + << fields[2].GetString() << "|" + << fields[3].GetString() << "|" + << fields[4].GetString() << "|" + << fields[5].GetUInt32() << "|" + << fields[6].GetUInt32() << "|" + << HexEncode(fields[7].GetString()); + + rows.push_back(row.str()); + ++rowIndex; + } while (result->NextRow()); + } + + response << (hasNext ? 1 : 0) << "|"; + for (std::size_t i = 0; i < rows.size(); ++i) + { + if (i) + response << "\n"; + response << rows[i]; + } + + SendAddonResponse(player, requestId, response.str()); + } + + static void SendBugDetail( + Player* player, + std::string const& requestId, + uint64 bugId) + { + std::ostringstream sql; + sql << "SELECT bug_id, title, status, severity, category, " + << "COALESCE(description,''), COALESCE(primary_zone_id,0), " + << "COALESCE(primary_area_id,0), DATE_FORMAT(updated_at,'%Y-%m-%d %H:%i:%s'), " + << "(SELECT COUNT(*) FROM vcore_dev.bug_reports r WHERE r.bug_id=bugs.bug_id), " + << "(SELECT COUNT(*) FROM vcore_dev.bug_reproductions p WHERE p.bug_id=bugs.bug_id) " + << "FROM vcore_dev.bugs WHERE bug_id=" << bugId << " LIMIT 1"; + + QueryResult result = QuerySync(sql.str()); + if (!result) + { + SendAddonError(player, requestId, "BUG_NOT_FOUND", "Bug not found."); + return; + } + + Field* fields = result->Fetch(); + + std::ostringstream response; + response << "BUG_DETAIL|" + << fields[0].GetUInt64() << "|" + << HexEncode(fields[1].GetString()) << "|" + << fields[2].GetString() << "|" + << fields[3].GetString() << "|" + << fields[4].GetString() << "|" + << HexEncode(fields[5].GetString()) << "|" + << fields[6].GetUInt32() << "|" + << fields[7].GetUInt32() << "|" + << HexEncode(fields[8].GetString()) << "|" + << fields[9].GetUInt64() << "|" + << fields[10].GetUInt64(); + + SendAddonResponse(player, requestId, response.str()); + } + + static std::optional ResolveBugIdByToken(std::string token) + { + WorldDatabase.EscapeString(token); + std::ostringstream sql; + sql << "SELECT bug_id FROM vcore_dev.bugs " + << "WHERE bug_token='" << token << "' LIMIT 1"; + + QueryResult result = QuerySync(sql.str()); + if (!result) + return std::nullopt; + + return (*result)[0].GetUInt64(); + } + + static std::optional CreateBugFromReport( + Player* player, + uint64 reportId, + std::string severity, + std::string title) + { + if (!player || !reportId || !IsSeverityValid(severity, false) || title.empty()) + return std::nullopt; + + std::ostringstream sourceSql; + sourceSql + << "SELECT description, COALESCE(target_type,''), COALESCE(target_entry,0), " + << "COALESCE(map_id,0), COALESCE(zone_id,0), COALESCE(area_id,0), " + << "COALESCE(bug_id,0) " + << "FROM vcore_dev.bug_reports WHERE report_id=" << reportId << " LIMIT 1"; + + QueryResult sourceResult = QuerySync(sourceSql.str()); + if (!sourceResult) + return std::nullopt; + + Field* source = sourceResult->Fetch(); + if (source[6].GetUInt64()) + return source[6].GetUInt64(); + + std::string token = MakeToken("bug", player); + std::string safeToken = token; + std::string safeTitle = std::move(title); + std::string safeSeverity = std::move(severity); + std::string safeDescription = source[0].GetString(); + std::string safeEntityType = source[1].GetString(); + + WorldDatabase.EscapeString(safeToken); + WorldDatabase.EscapeString(safeTitle); + WorldDatabase.EscapeString(safeSeverity); + WorldDatabase.EscapeString(safeDescription); + WorldDatabase.EscapeString(safeEntityType); + + std::ostringstream insert; + insert + << "INSERT INTO vcore_dev.bugs " + << "(bug_token,title,status,severity,category,primary_entity_type,primary_entity_id," + << "description,primary_map_id,primary_zone_id,primary_area_id,created_at,updated_at) " + << "VALUES ('" << safeToken << "','" << safeTitle + << "','new','" << safeSeverity << "','unknown',"; + + if (!safeEntityType.empty()) + insert << "'" << safeEntityType << "'"; + else + insert << "NULL"; + + insert << ","; + + uint64 targetEntry = source[2].GetUInt64(); + if (targetEntry) + insert << targetEntry; + else + insert << "NULL"; + + insert + << ",'" << safeDescription << "'," + << source[3].GetUInt32() << "," + << source[4].GetUInt32() << "," + << source[5].GetUInt32() + << ",NOW(),NOW())"; + + ExecuteSync(insert.str()); + + auto bugId = ResolveBugIdByToken(token); + if (!bugId) + return std::nullopt; + + std::ostringstream link; + link + << "UPDATE vcore_dev.bug_reports SET bug_id=" << *bugId + << ", status='linked', updated_at=NOW() WHERE report_id=" << reportId; + ExecuteSync(link.str()); + + std::ostringstream linkRepros; + linkRepros + << "UPDATE vcore_dev.bug_reproductions SET bug_id=" << *bugId + << " WHERE report_id=" << reportId; + ExecuteSync(linkRepros.str()); + + return bugId; + } + + static bool LinkReportToBug(uint64 reportId, uint64 bugId) + { + std::ostringstream check; + check + << "SELECT bug_id FROM vcore_dev.bugs WHERE bug_id=" << bugId << " LIMIT 1"; + if (!QuerySync(check.str())) + return false; + + std::ostringstream update; + update + << "UPDATE vcore_dev.bug_reports SET bug_id=" << bugId + << ", status='linked', updated_at=NOW() WHERE report_id=" << reportId; + ExecuteSync(update.str()); + + std::ostringstream repro; + repro + << "UPDATE vcore_dev.bug_reproductions SET bug_id=" << bugId + << " WHERE report_id=" << reportId; + ExecuteSync(repro.str()); + + std::ostringstream touch; + touch << "UPDATE vcore_dev.bugs SET updated_at=NOW() WHERE bug_id=" << bugId; + ExecuteSync(touch.str()); + + return true; + } + + static bool CloseReport( + Player* player, + uint64 reportId, + std::string reason, + std::string note) + { + if (!player || !reportId) + return false; + + std::string safeReason = std::move(reason); + std::string safeNote = std::move(note); + WorldDatabase.EscapeString(safeReason); + WorldDatabase.EscapeString(safeNote); + + std::ostringstream sql; + sql + << "UPDATE vcore_dev.bug_reports SET status='closed', " + << "closed_at=NOW(), closed_by_account_id=" + << player->GetSession()->GetAccountId() + << ", closure_reason='" << safeReason + << "', closure_note='" << safeNote + << "', updated_at=NOW() WHERE report_id=" << reportId; + ExecuteSync(sql.str()); + return true; + } + + static bool ReopenReport(uint64 reportId) + { + std::ostringstream sql; + sql + << "UPDATE vcore_dev.bug_reports SET " + << "status=IF(bug_id IS NULL,'open','linked'), " + << "closed_at=NULL, closed_by_account_id=NULL, " + << "closure_reason=NULL, closure_note=NULL, updated_at=NOW() " + << "WHERE report_id=" << reportId; + ExecuteSync(sql.str()); + return true; + } + + static std::optional ResolveDiagnosticRunIdByToken(std::string token) + { + WorldDatabase.EscapeString(token); + + std::ostringstream sql; + sql << "SELECT diagnostic_run_id FROM vcore_dev.diagnostic_runs " + << "WHERE run_token='" << token << "' LIMIT 1"; + + QueryResult result = QuerySync(sql.str()); + if (!result) + return std::nullopt; + + return (*result)[0].GetUInt64(); + } + + static std::optional QueueDiagnosticRun(Player* player, uint64 bugId) + { + if (!player || !bugId) + return std::nullopt; + + std::ostringstream check; + check << "SELECT bug_id FROM vcore_dev.bugs WHERE bug_id=" << bugId << " LIMIT 1"; + if (!QuerySync(check.str())) + return std::nullopt; + + std::string token = MakeToken("diag", player); + std::string safeToken = token; + WorldDatabase.EscapeString(safeToken); + + std::ostringstream insert; + insert + << "INSERT INTO vcore_dev.diagnostic_runs " + << "(run_token,bug_id,requested_by_account_id,requested_by_character_guid,status,created_at) " + << "VALUES ('" << safeToken << "'," << bugId << "," + << player->GetSession()->GetAccountId() << "," + << player->GetGUID().GetCounter() + << ",'queued',NOW())"; + ExecuteSync(insert.str()); + + return ResolveDiagnosticRunIdByToken(token); + } + + + static bool HasTesterPermission(Player* player) + { + return player && + player->GetSession() && + player->GetSession()->HasPermission(rbac::RBAC_PERM_COMMAND_GM); + } + + static void DispatchAddonPayload( + Player* player, + char operation, + std::string const& requestId, + std::string const& payload) + { + if (!player) + return; + + // Ordinary players: permissions query and report creation only. + if (operation == 'P') + { + SendPermissions(player, requestId); + return; + } + + if (operation == 'R') + { + std::string severity; + std::string remainder; + if (!SplitFirst(payload, '|', severity, remainder)) + { + SendAddonError(player, requestId, "BAD_REPORT", "Invalid report payload."); + return; + } + + std::string description; + std::string clientContext; + + if (!ParseLengthPrefixedPair(remainder, description, clientContext)) + { + SendAddonError(player, requestId, "BAD_REPORT", "Invalid report payload."); + return; + } + + ReportCreateResponse response = + SubmitPlayerReport(player, severity, description, clientContext); + + if (response.result == ReportCreateResult::Ok && response.reportId) + { + SendAddonResponse( + player, + requestId, + "REPORT_OK|" + std::to_string(*response.reportId)); + } + else if (response.result == ReportCreateResult::RateLimited) + { + SendAddonError(player, requestId, "COOLDOWN", "Report cooldown active."); + } + else if (response.result == ReportCreateResult::DescriptionTooLarge || + response.result == ReportCreateResult::ClientContextTooLarge) + { + SendAddonError(player, requestId, "TOO_LARGE", "Report payload too large."); + } + else + { + SendAddonError(player, requestId, "REPORT_FAILED", "Report could not be saved."); + } + + return; + } + + if (!HasTesterPermission(player)) + { + SendAddonError( + player, + requestId, + "PERMISSION", + "Insufficient permission for tester/bug management."); + return; + } + + if (operation == 'L') + { + std::string sort; + std::string pageText; + if (!SplitFirst(payload, '|', sort, pageText)) + { + sort = "newest"; + pageText = "1"; + } + + uint32 page = 1; + ParseUnsigned(pageText, page); + SendReportList(player, requestId, sort, page); + return; + } + + if (operation == 'D') + { + uint32 reportId = 0; + if (!ParseUnsigned(payload, reportId) || !reportId) + { + SendAddonError(player, requestId, "BAD_REPORT_ID", "Invalid report ID."); + return; + } + + SendReportDetail(player, requestId, reportId); + return; + } + + if (operation == 'B') + { + std::string sort; + std::string pageText; + if (!SplitFirst(payload, '|', sort, pageText)) + { + sort = "newest"; + pageText = "1"; + } + + uint32 page = 1; + ParseUnsigned(pageText, page); + SendBugList(player, requestId, sort, page); + return; + } + + if (operation == 'G') + { + uint32 bugId = 0; + if (!ParseUnsigned(payload, bugId) || !bugId) + { + SendAddonError(player, requestId, "BAD_BUG_ID", "Invalid bug ID."); + return; + } + + SendBugDetail(player, requestId, bugId); + return; + } + + if (operation == 'S') + { + uint32 reportId = 0; + if (!ParseUnsigned(payload, reportId) || !reportId) + { + SendAddonError(player, requestId, "BAD_REPORT_ID", "Invalid report ID."); + return; + } + + auto reproductionId = + StartTesterReproduction(player, uint64(reportId)); + + if (!reproductionId) + { + SendAddonError( + player, + requestId, + "REPRO_START_FAILED", + "Could not start reproduction. The report may be closed or missing."); + return; + } + + std::ostringstream response; + response << "REPRO_STARTED|" << *reproductionId << "|" << reportId; + SendAddonResponse(player, requestId, response.str()); + return; + } + + if (operation == 'N') + { + if (!AddTesterReproductionNote(player, payload)) + { + SendAddonError( + player, + requestId, + "NOTE_FAILED", + "No active reproduction or invalid note."); + return; + } + + SendAddonResponse(player, requestId, "REPRO_NOTE_OK"); + return; + } + + if (operation == 'F') + { + std::string result; + std::string observation; + + if (!ParseLengthPrefixedPair(payload, result, observation)) + { + SendAddonError(player, requestId, "BAD_FINISH", "Invalid finish payload."); + return; + } + + if (result != "reproduced" && + result != "not_reproduced" && + result != "inconclusive") + { + SendAddonError(player, requestId, "BAD_RESULT", "Invalid reproduction result."); + return; + } + + auto it = g_states.find(PlayerKey(player)); + uint64 reproId = + it != g_states.end() && it->second.activeReproductionId + ? *it->second.activeReproductionId + : 0; + + if (!FinishTesterReproduction(player, result, observation)) + { + SendAddonError(player, requestId, "NO_ACTIVE_REPRO", "No active reproduction."); + return; + } + + SendAddonResponse( + player, + requestId, + "REPRO_FINISHED|" + std::to_string(reproId) + "|" + result); + return; + } + + if (operation == 'A') + { + auto it = g_states.find(PlayerKey(player)); + uint64 reproId = + it != g_states.end() && it->second.activeReproductionId + ? *it->second.activeReproductionId + : 0; + + std::string reason = payload.empty() + ? std::string("aborted by tester") + : payload; + + if (!FinishTesterReproduction(player, "aborted", reason)) + { + SendAddonError(player, requestId, "NO_ACTIVE_REPRO", "No active reproduction."); + return; + } + + SendAddonResponse( + player, + requestId, + "REPRO_FINISHED|" + std::to_string(reproId) + "|aborted"); + return; + } + + if (operation == 'C') + { + std::string reportText; + std::string rest; + if (!SplitFirst(payload, '|', reportText, rest)) + { + SendAddonError(player, requestId, "BAD_CREATE_BUG", "Invalid create-bug payload."); + return; + } + + std::string severity; + std::string title; + if (!SplitFirst(rest, '|', severity, title)) + { + SendAddonError(player, requestId, "BAD_CREATE_BUG", "Invalid create-bug payload."); + return; + } + + uint32 reportId = 0; + if (!ParseUnsigned(reportText, reportId) || !reportId) + { + SendAddonError(player, requestId, "BAD_REPORT_ID", "Invalid report ID."); + return; + } + + auto bugId = CreateBugFromReport(player, reportId, severity, title); + if (!bugId) + { + SendAddonError(player, requestId, "BUG_CREATE_FAILED", "Could not create bug."); + return; + } + + std::ostringstream response; + response << "BUG_CREATED|" << *bugId << "|" << reportId; + SendAddonResponse(player, requestId, response.str()); + return; + } + + if (operation == 'K') + { + std::string reportText; + std::string bugText; + if (!SplitFirst(payload, '|', reportText, bugText)) + { + SendAddonError(player, requestId, "BAD_LINK", "Invalid link payload."); + return; + } + + uint32 reportId = 0; + uint32 bugId = 0; + if (!ParseUnsigned(reportText, reportId) || + !ParseUnsigned(bugText, bugId) || + !reportId || !bugId || + !LinkReportToBug(reportId, bugId)) + { + SendAddonError(player, requestId, "LINK_FAILED", "Could not link report to bug."); + return; + } + + std::ostringstream response; + response << "REPORT_LINKED|" << reportId << "|" << bugId; + SendAddonResponse(player, requestId, response.str()); + return; + } + + if (operation == 'X') + { + std::string reportText; + std::string rest; + if (!SplitFirst(payload, '|', reportText, rest)) + { + SendAddonError(player, requestId, "BAD_CLOSE", "Invalid close payload."); + return; + } + + std::string reason; + std::string note; + if (!SplitFirst(rest, '|', reason, note)) + { + reason = rest; + note.clear(); + } + + uint32 reportId = 0; + if (!ParseUnsigned(reportText, reportId) || + !reportId || + !CloseReport(player, reportId, reason, note)) + { + SendAddonError(player, requestId, "CLOSE_FAILED", "Could not close report."); + return; + } + + SendAddonResponse( + player, + requestId, + "REPORT_CLOSED|" + std::to_string(reportId)); + return; + } + + if (operation == 'O') + { + uint32 reportId = 0; + if (!ParseUnsigned(payload, reportId) || !reportId || !ReopenReport(reportId)) + { + SendAddonError(player, requestId, "REOPEN_FAILED", "Could not reopen report."); + return; + } + + SendAddonResponse( + player, + requestId, + "REPORT_REOPENED|" + std::to_string(reportId)); + return; + } + + if (operation == 'Q') + { + uint32 bugId = 0; + if (!ParseUnsigned(payload, bugId) || !bugId) + { + SendAddonError(player, requestId, "BAD_BUG_ID", "Invalid bug ID."); + return; + } + + auto runId = QueueDiagnosticRun(player, bugId); + if (!runId) + { + SendAddonError(player, requestId, "DIAG_FAILED", "Could not queue diagnostic run."); + return; + } + + std::ostringstream response; + response << "DIAG_QUEUED|" << *runId << "|" << bugId; + SendAddonResponse(player, requestId, response.str()); + return; + } + + SendAddonError(player, requestId, "UNKNOWN_OPERATION", "Unknown AddOn operation."); + } + + static void HandleAddonTransport(Player* player, std::string const& message) + { + if (!player) + return; + + if (message.rfind(ADDON_PROTOCOL_PREFIX, 0) != 0) + return; + + std::string body = message.substr( + std::char_traits::length(ADDON_PROTOCOL_PREFIX)); + + // VCORE1|CHUNK||||| + std::size_t p1 = body.find('|'); + std::size_t p2 = p1 == std::string::npos ? std::string::npos : body.find('|', p1 + 1); + std::size_t p3 = p2 == std::string::npos ? std::string::npos : body.find('|', p2 + 1); + std::size_t p4 = p3 == std::string::npos ? std::string::npos : body.find('|', p3 + 1); + + if (p1 == std::string::npos || + p2 == std::string::npos || + p3 == std::string::npos || + p4 == std::string::npos) + { + SendTransportStatus(player, "Malformed AddOn message."); + return; + } + + std::string opText = body.substr(0, p1); + std::string requestId = body.substr(p1 + 1, p2 - p1 - 1); + std::string indexText = body.substr(p2 + 1, p3 - p2 - 1); + std::string totalText = body.substr(p3 + 1, p4 - p3 - 1); + std::string chunk = body.substr(p4 + 1); + + if (opText.size() != 1 || requestId.empty() || requestId.size() > 32) + { + SendTransportStatus(player, "Malformed AddOn message header."); + return; + } + + uint32 index = 0; + uint32 total = 0; + + if (!ParseUnsigned(indexText, index) || + !ParseUnsigned(totalText, total) || + index == 0 || + total == 0 || + index > total || + total > MAX_ADDON_TRANSPORT_CHUNKS) + { + SendTransportStatus(player, "Invalid AddOn chunk metadata."); + return; + } + + auto& state = g_states[PlayerKey(player)]; + auto const now = Clock::now(); + + if (state.pendingAddonPayload && + now - state.pendingAddonPayload->startedAt > ADDON_TRANSPORT_TIMEOUT) + { + state.pendingAddonPayload.reset(); + } + + char operation = opText[0]; + + if (!state.pendingAddonPayload || + state.pendingAddonPayload->requestId != requestId || + state.pendingAddonPayload->operation != operation || + state.pendingAddonPayload->totalChunks != total) + { + PendingAddonPayload pending; + pending.operation = operation; + pending.requestId = requestId; + pending.totalChunks = total; + pending.startedAt = now; + pending.chunks.resize(total); + pending.received.resize(total, false); + state.pendingAddonPayload = std::move(pending); + } + + PendingAddonPayload& pending = *state.pendingAddonPayload; + std::size_t slot = std::size_t(index - 1); + + if (!pending.received[slot]) + { + if (pending.receivedBytes + chunk.size() > MAX_ADDON_TRANSPORT_BYTES) + { + state.pendingAddonPayload.reset(); + SendTransportStatus(player, "AddOn payload too large."); + return; + } + + pending.chunks[slot] = std::move(chunk); + pending.received[slot] = true; + pending.receivedBytes += pending.chunks[slot].size(); + ++pending.receivedChunks; + } + + if (pending.receivedChunks != pending.totalChunks) + return; + + std::string payload; + payload.reserve(pending.receivedBytes); + + for (std::string const& part : pending.chunks) + payload += part; + + char completedOperation = pending.operation; + std::string completedRequestId = pending.requestId; + state.pendingAddonPayload.reset(); + + DispatchAddonPayload(player, completedOperation, completedRequestId, payload); + } + + class VCoreDiagnosticsPlayerScript : public PlayerScript + { + public: + VCoreDiagnosticsPlayerScript() + : PlayerScript("VCoreDiagnosticsPlayerScript") + { + } + + void OnLogin(Player* player, bool /*firstLogin*/) override + { + g_states.try_emplace(PlayerKey(player)); + Record(player, "login"); + } + + void OnLogout(Player* player) override + { + if (!player) + return; + + auto it = g_states.find(PlayerKey(player)); + + if (it != g_states.end() && it->second.activeReproductionId) + { + FinishReproduction( + player, + "aborted", + "Player logged out while reproduction was active."); + } + + g_states.erase(PlayerKey(player)); + } + + void OnChat( + Player* player, + uint32 type, + uint32 lang, + std::string& msg, + Player* receiver) override + { + // The existing Vcore AddOn whisper path ends up in this PlayerScript + // hook. Restrict diagnostics transport to self-whispers only. + if (!player || + receiver != player || + type != CHAT_MSG_WHISPER || + lang != uint32(LANG_ADDON)) + { + return; + } + + HandleAddonTransport(player, msg); + } + + void OnUpdateZone(Player* player, uint32 newZone, uint32 newArea) override + { + std::ostringstream data; + data << "{\"zone_id\":" << newZone + << ",\"area_id\":" << newArea << "}"; + + Record(player, "zone_changed", data.str()); + } + + void OnMapChanged(Player* player) override + { + std::ostringstream data; + data << "{\"map_id\":" << player->GetMapId() << "}"; + + Record(player, "map_changed", data.str()); + } + + void OnQuestStatusChange(Player* player, uint32 questId) override + { + std::ostringstream data; + data << "{\"quest_id\":" << questId + << ",\"status\":" << uint32(player->GetQuestStatus(questId)) + << "}"; + + Record(player, "quest_status_changed", data.str()); + } + + void OnSpellCast(Player* player, Spell* spell, bool /*skipCheck*/) override + { + if (!spell || !spell->GetSpellInfo()) + return; + + std::ostringstream data; + data << "{\"spell_id\":" << spell->GetSpellInfo()->Id << "}"; + + Record(player, "spell_cast", data.str()); + } + + void OnCreatureKill(Player* killer, Creature* killed) override + { + if (!killer || !killed) + return; + + std::ostringstream data; + data << "{\"entry\":" << killed->GetEntry() + << ",\"guid\":" << killed->GetGUID().GetCounter() + << "}"; + + Record(killer, "creature_kill", data.str()); + } + + void OnPlayerRepop(Player* player) override + { + Record(player, "player_repop"); + } + }; + + class VCoreDiagnosticsCommandScript : public CommandScript + { + public: + VCoreDiagnosticsCommandScript() + : CommandScript("VCoreDiagnosticsCommandScript") + { + } + + std::vector GetCommands() const override + { + static std::vector reproductionCommands = + { + { "start", rbac::RBAC_PERM_COMMAND_GM, false, &HandleReproStart, "" }, + { "stop", rbac::RBAC_PERM_COMMAND_GM, false, &HandleReproStop, "" }, + { "abort", rbac::RBAC_PERM_COMMAND_GM, false, &HandleReproAbort, "" }, + { "note", rbac::RBAC_PERM_COMMAND_GM, false, &HandleReproNote, "" }, + { "status", rbac::RBAC_PERM_COMMAND_GM, false, &HandleReproStatus, "" }, + }; + + static std::vector diagnosticsCommands = + { + { "repro", rbac::RBAC_PERM_COMMAND_GM, false, nullptr, "", reproductionCommands }, + { "report", rbac::RBAC_PERM_COMMAND_GM, false, &HandleReport, "" }, + }; + + static std::vector commandTable = + { + { "vdiag", rbac::RBAC_PERM_COMMAND_GM, false, nullptr, "", diagnosticsCommands }, + }; + + return commandTable; + } + + static bool HandleReproStart(ChatHandler* handler, char const* args) + { + if (!args || !*args) + return false; + + char* end = nullptr; + unsigned long long parsed = std::strtoull(args, &end, 10); + + if (end == args || parsed == 0) + return false; + + Player* player = handler->GetSession()->GetPlayer(); + + auto id = StartReproductionForReport(player, uint64(parsed)); + + if (!id) + { + handler->SendSysMessage("Could not start Vcore reproduction."); + return false; + } + + handler->PSendSysMessage( + "Vcore reproduction started from report: REPRO-%llu", + static_cast(*id)); + + return true; + } + + static bool HandleReproStop(ChatHandler* handler, char const* args) + { + Player* player = handler->GetSession()->GetPlayer(); + std::string observation = args ? args : ""; + + if (!FinishReproduction(player, "reproduced", observation)) + { + handler->SendSysMessage("No active Vcore reproduction."); + return false; + } + + handler->SendSysMessage("Vcore reproduction stopped."); + return true; + } + + static bool HandleReproAbort(ChatHandler* handler, char const* args) + { + Player* player = handler->GetSession()->GetPlayer(); + + std::string reason = + (args && *args) + ? std::string(args) + : std::string("aborted by tester"); + + if (!FinishReproduction(player, "aborted", reason)) + { + handler->SendSysMessage("No active Vcore reproduction."); + return false; + } + + handler->SendSysMessage("Vcore reproduction aborted."); + return true; + } + + static bool HandleReproNote(ChatHandler* handler, char const* args) + { + if (!args || !*args) + return false; + + Player* player = handler->GetSession()->GetPlayer(); + + if (!AddTesterNote(player, args)) + { + handler->SendSysMessage("No active Vcore reproduction."); + return false; + } + + handler->SendSysMessage("Vcore tester note stored."); + return true; + } + + static bool HandleReproStatus(ChatHandler* handler, char const* /*args*/) + { + Player* player = handler->GetSession()->GetPlayer(); + + auto it = g_states.find(PlayerKey(player)); + + if (it == g_states.end() || !it->second.activeReproductionId) + { + handler->SendSysMessage("No active Vcore reproduction."); + return true; + } + + handler->PSendSysMessage( + "Active Vcore reproduction: REPRO-%llu", + static_cast(*it->second.activeReproductionId)); + + return true; + } + + static bool HandleReport(ChatHandler* handler, char const* args) + { + if (!args || !*args) + return false; + + Player* player = handler->GetSession()->GetPlayer(); + + ReportCreateResponse response = SubmitPlayerReport(player, "medium", args, "{}"); + + if (response.result != ReportCreateResult::Ok || !response.reportId) + { + switch (response.result) + { + case ReportCreateResult::RateLimited: + handler->SendSysMessage("Vcore report rejected: report cooldown active."); + break; + case ReportCreateResult::DescriptionTooLarge: + handler->SendSysMessage("Vcore report rejected: description too large."); + break; + default: + handler->SendSysMessage("Could not create Vcore bug report."); + break; + } + + return false; + } + + handler->PSendSysMessage( + "Vcore bug report created: REPORT-%llu", + static_cast(*response.reportId)); + + return true; + } + }; +} + +void AddSC_vcore_diagnostics() +{ + new VCoreDiagnostics::VCoreDiagnosticsPlayerScript(); + new VCoreDiagnostics::VCoreDiagnosticsCommandScript(); +} diff --git a/src/server/scripts/EasternKingdoms/zone_hillsbrad_foothills.cpp b/src/server/scripts/EasternKingdoms/zone_hillsbrad_foothills.cpp index e1a1768223..17b9247a42 100644 --- a/src/server/scripts/EasternKingdoms/zone_hillsbrad_foothills.cpp +++ b/src/server/scripts/EasternKingdoms/zone_hillsbrad_foothills.cpp @@ -1760,6 +1760,644 @@ private: } }; + +// WOW-PRIVATE-SERVER PATCH: Human Infestation (28138) +// +// Restores the missing capture logic for the Titanium Shackles. +// Spell 88797 used on a Hillsbrad Sentry at <= 35% health grants +// quest credit through the Captured Human Proxy and replaces the +// sentry with the matching Captured Hillsbrad Human follower. +enum HumanInfestation +{ + QUEST_HUMAN_INFESTATION = 28138, + + NPC_HILLSBRAD_MINER = 2269, + NPC_HILLSBRAD_SENTRY = 2270, + NPC_HILLSBRAD_FOREMAN = 2503, + + NPC_CAPTURED_HUMAN_PROXY = 47697, + NPC_CAPTURED_HUMAN_MODEL_3629 = 47689, + NPC_CAPTURED_HUMAN_MODEL_3633 = 47693, + NPC_CAPTURED_HUMAN_MODEL_3632 = 47694, + NPC_CAPTURED_HUMAN_MODEL_5785 = 47695, + + SPELL_TITANIUM_SHACKLES = 88797 +}; + +struct npc_hillsbrad_captured_human : public ScriptedAI +{ + npc_hillsbrad_captured_human(Creature* creature) + : ScriptedAI(creature), _checkTimer(1000) { } + + void IsSummonedBy(Unit* summoner) override + { + Player* player = summoner ? summoner->ToPlayer() : nullptr; + if (!player) + { + me->DespawnOrUnsummon(); + return; + } + + me->SetReactState(REACT_PASSIVE); + me->AttackStop(); + me->CombatStop(true); + me->SetFaction(player->GetFaction()); + + // Follow slightly behind the player. + me->GetMotionMaster()->MoveFollow(player, 2.0f, float(M_PI)); + } + + void UpdateAI(uint32 diff) override + { + if (_checkTimer > diff) + { + _checkTimer -= diff; + return; + } + + _checkTimer = 1000; + + TempSummon* summon = me->ToTempSummon(); + if (!summon) + { + me->DespawnOrUnsummon(); + return; + } + + Unit* summoner = summon->GetSummoner(); + Player* player = summoner ? summoner->ToPlayer() : nullptr; + if (!player) + { + me->DespawnOrUnsummon(); + return; + } + + QuestStatus const status = player->GetQuestStatus(QUEST_HUMAN_INFESTATION); + + // Keep followers while the quest is incomplete or complete-but-not-turned-in. + // Remove them after turn-in, abandonment, or failure. + if (status == QUEST_STATUS_NONE || + status == QUEST_STATUS_REWARDED || + status == QUEST_STATUS_FAILED) + { + me->DespawnOrUnsummon(); + } + } + +private: + uint32 _checkTimer; +}; + +class spell_hillsbrad_titanium_shackles : public SpellScript +{ + void HandleHit() + { + Player* player = GetCaster() ? GetCaster()->ToPlayer() : nullptr; + Creature* target = GetHitCreature(); + + if (!player || !target) + return; + + if (player->GetQuestStatus(QUEST_HUMAN_INFESTATION) != QUEST_STATUS_INCOMPLETE) + return; + + if (!target->IsAlive()) + return; + + switch (target->GetEntry()) + { + case NPC_HILLSBRAD_MINER: + case NPC_HILLSBRAD_SENTRY: + case NPC_HILLSBRAD_FOREMAN: + break; + default: + return; + } + + if (target->GetHealthPct() > 35.0f) + return; + + uint32 captiveEntry = 0; + + // Preserve the Sentry's visual family where possible: + // 3632 <-> 5784 and 5785 <-> 5786 are the two model pairs. + switch (target->GetDisplayId()) +{ + case 3629: + captiveEntry = NPC_CAPTURED_HUMAN_MODEL_3629; + break; + + case 3633: + captiveEntry = NPC_CAPTURED_HUMAN_MODEL_3633; + break; + + case 3632: + case 5784: + captiveEntry = NPC_CAPTURED_HUMAN_MODEL_3632; + break; + + case 5785: + case 5786: + captiveEntry = NPC_CAPTURED_HUMAN_MODEL_5785; + break; + + default: + { + uint32 const capturedHumans[] = + { + NPC_CAPTURED_HUMAN_MODEL_3629, + NPC_CAPTURED_HUMAN_MODEL_3633, + NPC_CAPTURED_HUMAN_MODEL_3632, + NPC_CAPTURED_HUMAN_MODEL_5785 + }; + + captiveEntry = capturedHumans[urand(0, 3)]; + break; + } +} + + player->KilledMonsterCredit(NPC_CAPTURED_HUMAN_PROXY); + + // Player-owned summon so logout naturally cleans it up. + player->SummonCreature( + captiveEntry, + target->GetPosition(), + TEMPSUMMON_MANUAL_DESPAWN + ); + + target->DespawnOrUnsummon(); + } + + void Register() override + { + OnHit.Register(&spell_hillsbrad_titanium_shackles::HandleHit); + } +}; + +// WOW-PRIVATE-SERVER PATCH: Coastal Delicacies (28146) +// +// Restores the missing Hungry Mine Creeper companion and feeding sequence. +// The companion exists only in the intended Hillsbrad subzones, follows the +// quest owner, and only consumes Murlocs actually killed by that owner. +enum CoastalDelicacies +{ + QUEST_COASTAL_DELICACIES = 28146, + + NPC_HUNGRY_MINE_CREEPER = 47662, + NPC_MURLOC_EATEN_PROXY = 47752, + NPC_TORN_FIN_MUCKDWELLER = 2374, + NPC_TORN_FIN_COASTRUNNER = 2375, + + AREA_WESTERN_STRAND = 295, + AREA_AZURELODE_MINE = 288, + + POINT_COASTAL_FEED_CORPSE = 1, + + COASTAL_STATE_FOLLOWING = 0, + COASTAL_STATE_MOVING_TO_CORPSE = 1, + COASTAL_STATE_EATING = 2 +}; + +static bool IsCoastalDelicaciesArea(uint32 areaId) +{ + return areaId == AREA_WESTERN_STRAND || areaId == AREA_AZURELODE_MINE; +} + +static Creature* FindOwnedHungryMineCreeper(Player* player) +{ + if (!player) + return nullptr; + + std::list creepers; + GetCreatureListWithEntryInGrid(creepers, player, NPC_HUNGRY_MINE_CREEPER, 120.0f); + + for (Creature* creeper : creepers) + { + if (!creeper) + continue; + + TempSummon* summon = creeper->ToTempSummon(); + if (!summon) + continue; + + if (summon->GetSummoner() == player) + return creeper; + } + + return nullptr; +} + +static void DespawnOwnedHungryMineCreeper(Player* player) +{ + if (Creature* creeper = FindOwnedHungryMineCreeper(player)) + creeper->DespawnOrUnsummon(); +} + +static void EnsureHungryMineCreeper(Player* player) +{ + if (!player) + return; + + if (player->GetQuestStatus(QUEST_COASTAL_DELICACIES) != QUEST_STATUS_INCOMPLETE || + !IsCoastalDelicaciesArea(player->GetAreaId())) + { + DespawnOwnedHungryMineCreeper(player); + return; + } + + if (FindOwnedHungryMineCreeper(player)) + return; + + player->SummonCreature( + NPC_HUNGRY_MINE_CREEPER, + player->GetPosition(), + TEMPSUMMON_MANUAL_DESPAWN + ); +} + +struct npc_hillsbrad_hungry_mine_creeper : public ScriptedAI +{ + npc_hillsbrad_hungry_mine_creeper(Creature* creature) + : ScriptedAI(creature), + _state(COASTAL_STATE_FOLLOWING), + _eatTimer(0), + _ownerCheckTimer(500) { } + + void IsSummonedBy(Unit* summoner) override + { + Player* player = summoner ? summoner->ToPlayer() : nullptr; + if (!player) + { + me->DespawnOrUnsummon(); + return; + } + + _ownerGuid = player->GetGUID(); + + me->SetReactState(REACT_PASSIVE); + me->AttackStop(); + me->CombatStop(true); + me->SetFaction(player->GetFaction()); + me->SetWalk(false); + FollowOwner(); + } + + void SetGUID(ObjectGuid const& guid, int32 id) override + { + if (id != POINT_COASTAL_FEED_CORPSE || _state != COASTAL_STATE_FOLLOWING) + return; + + Creature* corpse = ObjectAccessor::GetCreature(*me, guid); + if (!IsValidFeedCorpse(corpse)) + return; + + _corpseGuid = guid; + _state = COASTAL_STATE_MOVING_TO_CORPSE; + + me->AttackStop(); + me->CombatStop(true); + me->GetMotionMaster()->MovePoint( + POINT_COASTAL_FEED_CORPSE, + corpse->GetPosition(), + false + ); + } + + void MovementInform(uint32 type, uint32 id) override + { + if (type != POINT_MOTION_TYPE || id != POINT_COASTAL_FEED_CORPSE) + return; + + if (_state == COASTAL_STATE_MOVING_TO_CORPSE) + BeginEating(); + } + + void UpdateAI(uint32 diff) override + { + if (_ownerCheckTimer > diff) + _ownerCheckTimer -= diff; + else + { + _ownerCheckTimer = 500; + + Player* player = GetOwnerPlayer(); + if (!player || + player->GetQuestStatus(QUEST_COASTAL_DELICACIES) != QUEST_STATUS_INCOMPLETE || + !IsCoastalDelicaciesArea(player->GetAreaId())) + { + me->DespawnOrUnsummon(); + return; + } + + if (_state == COASTAL_STATE_FOLLOWING) + { + if (me->GetDistance(player) > 4.0f) + FollowOwner(); + } + else if (_state == COASTAL_STATE_MOVING_TO_CORPSE) + { + Creature* corpse = GetFeedCorpse(); + if (!IsValidFeedCorpse(corpse)) + { + ResetToFollowing(); + return; + } + + if (me->GetDistance(corpse) <= 2.5f) + BeginEating(); + } + } + + if (_state == COASTAL_STATE_EATING) + { + if (_eatTimer > diff) + _eatTimer -= diff; + else + FinishEating(); + } + } + +private: + Player* GetOwnerPlayer() + { + return ObjectAccessor::GetPlayer(*me, _ownerGuid); + } + + Creature* GetFeedCorpse() + { + if (_corpseGuid.IsEmpty()) + return nullptr; + + return ObjectAccessor::GetCreature(*me, _corpseGuid); + } + + bool IsValidFeedCorpse(Creature* corpse) const + { + if (!corpse || corpse->IsAlive()) + return false; + + return corpse->GetEntry() == NPC_TORN_FIN_MUCKDWELLER || + corpse->GetEntry() == NPC_TORN_FIN_COASTRUNNER; + } + + void FollowOwner() + { + if (Player* player = GetOwnerPlayer()) + me->GetMotionMaster()->MoveFollow(player, 2.0f, float(M_PI)); + } + + void BeginEating() + { + Creature* corpse = GetFeedCorpse(); + if (!IsValidFeedCorpse(corpse)) + { + ResetToFollowing(); + return; + } + + _state = COASTAL_STATE_EATING; + _eatTimer = 2000; + + me->StopMoving(); + me->SetFacingToObject(corpse); + + // Emote 54 = one-shot special 1H attack in this client build. + // For a spider this provides a short generic feeding/attack motion. + me->HandleEmoteCommand(54); + } + + void FinishEating() + { + Player* player = GetOwnerPlayer(); + Creature* corpse = GetFeedCorpse(); + + if (!player || + player->GetQuestStatus(QUEST_COASTAL_DELICACIES) != QUEST_STATUS_INCOMPLETE || + !IsValidFeedCorpse(corpse)) + { + ResetToFollowing(); + return; + } + + player->KilledMonsterCredit(NPC_MURLOC_EATEN_PROXY); + corpse->DespawnOrUnsummon(); + + ResetToFollowing(); + } + + void ResetToFollowing() + { + _corpseGuid.Clear(); + _state = COASTAL_STATE_FOLLOWING; + _eatTimer = 0; + FollowOwner(); + } + + ObjectGuid _ownerGuid; + ObjectGuid _corpseGuid; + uint8 _state; + uint32 _eatTimer; + uint32 _ownerCheckTimer; +}; + +class player_hillsbrad_coastal_delicacies : public PlayerScript +{ +public: + player_hillsbrad_coastal_delicacies() + : PlayerScript("player_hillsbrad_coastal_delicacies") { } + + void OnQuestStatusChange(Player* player, uint32 questId) override + { + if (!player || questId != QUEST_COASTAL_DELICACIES) + return; + + EnsureHungryMineCreeper(player); + } + + void OnLogin(Player* player, bool /*firstLogin*/) override + { + if (player && player->GetQuestStatus(QUEST_COASTAL_DELICACIES) == QUEST_STATUS_INCOMPLETE) + EnsureHungryMineCreeper(player); + } + + void OnUpdateZone(Player* player, uint32 /*newZone*/, uint32 /*newArea*/) override + { + if (player && player->GetQuestStatus(QUEST_COASTAL_DELICACIES) == QUEST_STATUS_INCOMPLETE) + EnsureHungryMineCreeper(player); + else + DespawnOwnedHungryMineCreeper(player); + } + + void OnMapChanged(Player* player) override + { + if (player && player->GetQuestStatus(QUEST_COASTAL_DELICACIES) == QUEST_STATUS_INCOMPLETE) + EnsureHungryMineCreeper(player); + else + DespawnOwnedHungryMineCreeper(player); + } + + void OnCreatureKill(Player* killer, Creature* killed) override + { + if (!killer || !killed) + return; + + if (killer->GetQuestStatus(QUEST_COASTAL_DELICACIES) != QUEST_STATUS_INCOMPLETE || + !IsCoastalDelicaciesArea(killer->GetAreaId())) + return; + + if (killed->GetEntry() != NPC_TORN_FIN_MUCKDWELLER && + killed->GetEntry() != NPC_TORN_FIN_COASTRUNNER) + return; + + Creature* creeper = FindOwnedHungryMineCreeper(killer); + if (!creeper || !creeper->IsAIEnabled()) + return; + + creeper->AI()->SetGUID(killed->GetGUID(), POINT_COASTAL_FEED_CORPSE); + } +}; + +// WOW-PRIVATE-SERVER PATCH: Deep Mine Rescue (28156) +// +// Restores Dumass' missing rescue behavior. Quest acceptance is observed +// through PlayerScript because this TrinityCore branch does not expose a +// quest-accept hook on CreatureAI/CreatureScript. +enum DeepMineRescue +{ + QUEST_DEEP_MINE_RESCUE = 28156, + + NPC_DUMASS_RESCUE = 47681, + NPC_CAPTAIN_KEYTON = 47554, + + SPELL_PARALYTIC_POISON = 87969 +}; + +struct npc_hillsbrad_dumass_rescue : public ScriptedAI +{ + npc_hillsbrad_dumass_rescue(Creature* creature) + : ScriptedAI(creature), _checkTimer(1000), _active(false), _finished(false) { } + + void StartRescue(Player* player) + { + if (!player) + return; + + _playerGuid = player->GetGUID(); + _active = true; + _finished = false; + _checkTimer = 250; + + me->RemoveAurasDueToSpell(SPELL_PARALYTIC_POISON); + me->SetReactState(REACT_DEFENSIVE); + me->SetWalk(false); + me->GetMotionMaster()->MoveFollow(player, 2.0f, float(M_PI)); + } + + void UpdateAI(uint32 diff) override + { + if (_active && !_finished) + { + if (_checkTimer > diff) + _checkTimer -= diff; + else + { + _checkTimer = 1000; + + Player* player = ObjectAccessor::GetPlayer(*me, _playerGuid); + if (!player) + { + ResetRescue(); + return; + } + + QuestStatus const status = player->GetQuestStatus(QUEST_DEEP_MINE_RESCUE); + if (status == QUEST_STATUS_NONE || + status == QUEST_STATUS_REWARDED || + status == QUEST_STATUS_FAILED) + { + ResetRescue(); + return; + } + + if (status == QUEST_STATUS_INCOMPLETE) + { + if (Creature* keyton = me->FindNearestCreature(NPC_CAPTAIN_KEYTON, 12.0f, true)) + { + _finished = true; + _active = false; + + me->CombatStop(true); + me->AttackStop(); + me->StopMoving(); + me->SetFacingToObject(keyton); + + player->GroupEventHappens(QUEST_DEEP_MINE_RESCUE, me); + + // Let the original world spawn return through normal respawn handling. + me->DespawnOrUnsummon(5s); + return; + } + + // Combat can interrupt following. Resume once Dumass is free again. + if (!me->IsInCombat() && me->GetDistance(player) > 4.0f) + me->GetMotionMaster()->MoveFollow(player, 2.0f, float(M_PI)); + } + } + } + + if (UpdateVictim()) + DoMeleeAttackIfReady(); + } + +private: + void ResetRescue() + { + _active = false; + _finished = false; + _playerGuid.Clear(); + + me->CombatStop(true); + me->AttackStop(); + me->GetMotionMaster()->MoveTargetedHome(); + + if (!me->HasAura(SPELL_PARALYTIC_POISON)) + me->CastSpell(me, SPELL_PARALYTIC_POISON, true); + } + + ObjectGuid _playerGuid; + uint32 _checkTimer; + bool _active; + bool _finished; +}; + +class player_hillsbrad_deep_mine_rescue : public PlayerScript +{ +public: + player_hillsbrad_deep_mine_rescue() + : PlayerScript("player_hillsbrad_deep_mine_rescue") { } + + void OnQuestStatusChange(Player* player, uint32 questId) override + { + if (!player || questId != QUEST_DEEP_MINE_RESCUE) + return; + + if (player->GetQuestStatus(QUEST_DEEP_MINE_RESCUE) != QUEST_STATUS_INCOMPLETE) + return; + + if (Creature* dumass = player->FindNearestCreature(NPC_DUMASS_RESCUE, 50.0f, true)) + { + if (dumass->IsAIEnabled()) + { + if (npc_hillsbrad_dumass_rescue* ai = + dynamic_cast(dumass->AI())) + { + ai->StartRescue(player); + } + } + } + } +}; + enum GloriousHarvest { QUEST_GLORIOUS_HARVEST = 28114, @@ -1810,6 +2448,12 @@ void AddSC_hillsbrad_foothills() RegisterCreatureAI(npc_hillsbrad_welcome_steed); RegisterCreatureAI(npc_hillsbrad_welcome_actor); RegisterCreatureAI(npc_hillsbrad_creeper_egg); + RegisterCreatureAI(npc_hillsbrad_captured_human); + RegisterCreatureAI(npc_hillsbrad_dumass_rescue); + RegisterCreatureAI(npc_hillsbrad_hungry_mine_creeper); + new player_hillsbrad_deep_mine_rescue(); + new player_hillsbrad_coastal_delicacies(); + RegisterSpellScript(spell_hillsbrad_titanium_shackles); RegisterSpellScript(spell_brazie_summon_plant); RegisterSpellScript(spell_brazie_highlight); RegisterSpellScript(spell_brazie_create_random_seed_sack); diff --git a/src/server/scripts/EasternKingdoms/zone_hillsbrad_foothills.cpp.bak_welcome_scene_v2_20260921_172948 b/src/server/scripts/EasternKingdoms/zone_hillsbrad_foothills.cpp.bak_welcome_scene_v2_20260921_172948 deleted file mode 100644 index bf10e0206e..0000000000 --- a/src/server/scripts/EasternKingdoms/zone_hillsbrad_foothills.cpp.bak_welcome_scene_v2_20260921_172948 +++ /dev/null @@ -1,1744 +0,0 @@ -/* -* This file is part of the TrinityCore Project. See AUTHORS file for Copyright information -* -* This program is free software; you can redistribute it and/or modify it -* under the terms of the GNU General Public License as published by the -* Free Software Foundation; either version 2 of the License, or (at your -* option) any later version. -* -* This program is distributed in the hope that it will be useful, but WITHOUT -* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -* more details. -* -* You should have received a copy of the GNU General Public License along -* with this program. If not, see . -*/ - -#include "ScriptMgr.h" -#include "CellImpl.h" -#include "CreatureAIImpl.h" -#include "CombatAI.h" -#include "GridNotifiersImpl.h" -#include "MotionMaster.h" -#include "ObjectAccessor.h" -#include "Player.h" -#include "PhasingHandler.h" -#include "ScriptedCreature.h" -#include "ScriptedGossip.h" -#include "SpellScript.h" -#include "SpellMgr.h" -#include "SpellHistory.h" -#include "TemporarySummon.h" -#include "Vehicle.h" - -namespace HillsbradFoothills -{ -enum PlansVsZombies -{ - // Quest related - QUEST_BASIC_BOTANY = 28733, - CREDIT_BASIC_BOTANY = 49189, - - QUEST_FLOWER_POWER = 28617, - CREDIT_FLOWER_POWER = 49542, - - // Brazie Vehicle - SPELL_PROGRESS_BAR = 92259, - SPELL_50_POWER = 91987, - SPELL_CREATE_LAWMOWER = 91680, - SPELL_CREATE_EMPTY_SPOT = 91676, - SPELL_CREATE_SPITTER_TARGET = 91988, - SPELL_CREATE_GOAL_STALKER = 91740, - SPELL_CREATE_BATTERY = 92816, - SPELL_CREATE_RANDOM_SUN_POWER = 91741, - SPELL_SPITTER_HIGHLIGHT = 92276, - SPELL_SUNFLOWER_HIGHLIGHT = 94032, - SPELL_EJECT_ALL_PASSENGERS = 68576, - - SPELL_PLANT_SPITTER = 91649, - SPELL_PLANT_SUNFLOWER = 91646, - SPELL_PLANT_FREEZYA = 92440, - - // Player Spells - SPELL_CREATE_BOTANISTS_VEHICLE = 91683, - SPELL_CREATE_ZOMBIE = 91739, - SPELL_CREATE_GHOUL = 91834, - SPELL_CREATE_RANDOM_FREEZYA_SACK = 92435, - SPELL_GET_FREEZYA_SEEDS = 92434, - - NPC_SPITTER_TARGET = 49385, - NPC_EMPTY_SPOT = 49190, - NPC_PLACE_IT_HERE = 51098, - NPC_SOLAR_POWER = 49194, - NPC_SPITTER = 49200, - NPC_SUNFLOWER = 49193, - NPC_ZOMBIE = 49209, - NPC_GHOUL = 49232, - NPC_FREEZYA_SEEDS = 49604, - NPC_FREEZYA = 49618, - NPC_GOAL_STALKER = 49210, - NPC_FERTILITIZE_O_TRON_2000 = 49192, - - // Empty Spot - SPELL_HEALTH_TRIGGER = 93006, - SPELL_VENOM_SPIT = 91748, - SPELL_SUNLIGHT = 93968, - SPELL_FREEZYA_BLAST = 92441, - - // Solar Power - SPELL_GAIN_SOLAR_POWER_SPELLCLICK = 91687, - SPELL_GAIN_SOLAR_POWER_ENERGIZE = 94068, - - // Zombie - SPELL_ZOMBIE_GROWL = 93961, - // SPELL_HEALTH_TRIGGER = 93006, - SPELL_LEAKING_GUTS = 93244, - SPELL_GHOUL_GROWL = 93962, - SPELL_BUCKET_HEAD = 93230, - SPELL_BUCKET = 93236, - - // Fertilitize-o-tron 2000 - SPELL_LAWNMOWER_STARTUP_SOUND = 93969, - SPELL_DAZED = 1604, - SPELL_PLAGUE_GAS = 92084, - - // Texts - SAY_ANNOUNCE_TUTORIAL_1 = 49559, - SAY_ANNOUNCE_TUTORIAL_2 = 49553, - SAY_ANNOUNCE_TUTORIAL_3 = 49558, - SAY_ANNOUNCE_TUTORIAL_4 = 49552, - SAY_ANNOUNCE_MASSIVE_WAVE = 49409, - SAY_ANNOUNCE_PLANT_SUNFLOWER = 49566, - SAY_ANNOUNCE_PLANT_MORE_SUNFLOWERS = 49567, - SAY_ANNOUNCE_DROPPED_SEED = 49685, - SAY_ANNOUNCE_LEVEL_1_COMPLETE = 49551, - - SAY_ANNOUNCE_GAINED_FREEZYA_SEEDS = 0, - SAY_ANNOUNCE_GAINED_STRANGLER_SEEDS = 1, - SAY_ANNOUNCE_ZOMBIES_ATE_BRAIN = 2, - - // Events - EVENT_SPITTER_HIGHLIGHT = 1, - EVENT_SUMMON_SOLAR_POWER = 2, - EVENT_CREATE_ZOMBIE = 3, - EVENT_MOVE_TO_GOAL_POSITION = 4, - EVENT_CHECK_VICTIM_STATE = 5, - EVENT_ANNOUNCE_GOOD_JOB = 6, - EVENT_ANNOUNCE_PLACING_SPITTERS = 7, - EVENT_ANNOUNCE_MASSIVE_WAVE = 8, - EVENT_ANNOUNCE_LEVEL_COMPLETED = 9, - EVENT_EJECT_PASSENGER = 10, - EVENT_SUNFLOWER_HIGHLIGHT = 11, - EVENT_ANNOUNCE_PLANT_MORE_FLOWERS = 12, - EVENT_SUNLIGHT = 13, - EVENT_CREATE_GHOUL = 14, - EVENT_KILL_LANE = 15, - EVENT_PLAGUE_GAS = 16, - - // Actions - ACTION_FIRST_SPITTER_PLANTED = 1, - ACTION_FIRST_SUNFLOWER_PLANTED = 2, - ACTION_ZOMBIE_DAMAGED = 3, - ACTION_ZOMBIE_DIED = 4, - ACTION_GHOUL_DAMAGED = 5, - ACTION_QUEST_FAILED = 6, - - // Misc - SEAT_0 = 0, - - MAX_TARGET_POSITIONS = 5, - MAX_EMPTY_SPOT_POSITIONS = 40, - CENTER_ZOMBIE_LANE_INDEX = 2, - - POINT_GOAL_STALKER = 1, - POINT_SPITTER_TARGET = 2, - - DATA_CURRENT_ENTRY = 1, - - PHASE_ID_QUEST = 365, - - // Levels - LEVEL_TUTORIAL = 0, - LEVEL_1 = 1 -}; - -Position const LawnMowerPositions[MAX_TARGET_POSITIONS] -{ - { -116.5816f, 237.6823f, 53.2755f }, - { -116.2934f, 228.9896f, 53.2755f }, - { -116.0833f, 220.7691f, 53.2755f }, - { -116.2674f, 213.0243f, 53.2755f }, - { -115.9132f, 204.6198f, 53.2755f } -}; - -Position const GoalStalkerPositions[MAX_TARGET_POSITIONS] -{ - { -116.5816f, 237.6823f, 53.2755f }, - { -116.2934f, 228.9896f, 53.2755f }, - { -116.0833f, 220.7691f, 53.2755f }, - { -116.2674f, 213.0243f, 53.2755f }, - { -115.9132f, 204.6198f, 53.2755f } -}; - -Position const SpitterTargetPositions[MAX_TARGET_POSITIONS] -{ - { -39.83333f, 237.441f, 53.27685f }, - { -40.13715f, 229.6163f, 53.33047f }, - { -40.10764f, 221.4618f, 53.27604f }, - { -40.06424f, 212.9444f, 53.3602f }, - { -40.14236f, 205.0538f, 53.27604f } -}; - -Position const EmptySpotPositions[MAX_EMPTY_SPOT_POSITIONS] -{ - { -108.7604f, 221.0226f, 53.35883f }, - { -108.3733f, 212.9219f, 53.35883f }, - { -108.3108f, 229.2309f, 53.35883f }, - { -107.9792f, 203.8629f, 53.35883f }, - { -100.1684f, 220.6615f, 53.35883f }, - { -99.92188f, 212.1406f, 53.35883f }, - { -99.9375f, 204.8385f, 53.35883f }, - { -100.0399f, 229.7882f, 53.35883f }, - { -108.5486f, 237.4844f, 53.35883f }, - { -91.59549f, 220.8715f, 53.35883f }, - { -91.51563f, 212.7379f, 53.35883f }, - { -100.3385f, 237.2656f, 53.35883f }, - { -91.68056f, 229.1997f, 53.35883f }, - { -91.32291f, 204.5434f, 53.35883f }, - { -91.77604f, 237.5816f, 53.35883f }, - { -83.93403f, 213.066f, 53.35883f }, - { -83.21354f, 220.8542f, 53.35883f }, - { -83.61459f, 204.592f, 53.35883f }, - { -83.80903f, 229.6788f, 53.35883f }, - { -83.52257f, 237.217f, 53.35883f }, - { -75.24827f, 213.3108f, 53.35883f }, - { -75.00174f, 220.9861f, 53.35883f }, - { -75.34375f, 204.5382f, 53.35883f }, - { -75.25521f, 229.5278f, 53.35883f }, - { -75.10416f, 238.0816f, 53.35883f }, - { -66.7934f, 213.071f, 53.35883f }, - { -66.5469f, 220.747f, 53.35883f }, - { -66.8889f, 204.299f, 53.35883f }, - { -66.8003f, 229.288f, 53.35883f }, - { -66.6493f, 237.842f, 53.35883f }, - { -58.4965f, 212.372f, 53.35883f }, - { -58.25f, 220.047f, 53.35883f }, - { -58.5035f, 228.589f, 53.35883f }, - { -58.592f, 203.599f, 53.35883f }, - { -58.3524f, 237.142f, 53.35883f }, - { -49.6059f, 212.569f, 53.35883f }, - { -49.3594f, 220.245f, 53.35883f }, - { -49.6128f, 228.786f, 53.35883f }, - { -49.7014f, 203.797f, 53.35883f }, - { -49.4618f, 237.34f, 53.35883f } -}; - -float const EmptySpotOrientation = 6.143559f; -float const LawnmowerOrientation = 6.178465f; -float const SpitterTargetOrientation = 6.232892f; -float const GoalStalkerOrientation = 6.178465f; - -Position const SolarPowerJumpPos = { -64.24827f, 181.3004f, 53.97961f }; -Position const FreezyaSeedSummonPos = { -49.3594f, 220.245f, 56.35883f, 0.01745329f }; -Position const FreezyaSeedJumpPos = { -116.2847f, 216.7865f, 53.2755f }; - -struct npc_brazie_the_bonatist_vehicle : public VehicleAI -{ - npc_brazie_the_bonatist_vehicle(Creature* creature) : VehicleAI(creature), _summons(me), _currentLevel(LEVEL_TUTORIAL), - _deadZombieCount(0), _damagedZombieCount(0), _damagedGhoulCount(0), _currentStage(0), _timeUntilNextSolarPower(0s) { } - - void InizializeAI() - { - // Figure out at what level we currently are - InitializeLevel(); - - // Add available plants to vehicle action bar depending on level progress - SetupActionBar(); - - // Start events for each corresponding level - switch (_currentLevel) - { - case LEVEL_TUTORIAL: - _timeUntilNextSolarPower = 4s; - _events.ScheduleEvent(EVENT_SPITTER_HIGHLIGHT, 1s); - _events.ScheduleEvent(EVENT_SUMMON_SOLAR_POWER, 1s); - break; - case LEVEL_1: - _timeUntilNextSolarPower = 5s; - _events.ScheduleEvent(EVENT_SUNFLOWER_HIGHLIGHT, 1s); - _events.ScheduleEvent(EVENT_SUMMON_SOLAR_POWER, _timeUntilNextSolarPower + 500ms); - _events.ScheduleEvent(EVENT_CREATE_ZOMBIE, 41s); - break; - default: - break; - } - } - - void JustSummoned(Creature* summon) override - { - _summons.Summon(summon); - - if (summon->GetEntry() == NPC_EMPTY_SPOT) - _spotGUIDs.push_back(summon->GetGUID()); - } - - void PassengerBoarded(Unit* passenger, int8 /*seatId*/, bool apply) override - { - if (!passenger || passenger->GetTypeId() != TYPEID_PLAYER || !me->GetCharmerOrOwner()) - return; - - if (apply) - { - PhasingHandler::AddPhase(passenger, PHASE_ID_QUEST, true); - passenger->CastSpell(passenger, SPELL_PROGRESS_BAR, true); - SetupSpawns(); - } - else - { - _events.Reset(); - passenger->RemoveAurasDueToSpell(SPELL_PROGRESS_BAR); - passenger->RemoveAurasDueToSpell(SPELL_CREATE_BOTANISTS_VEHICLE); - PhasingHandler::RemovePhase(passenger, PHASE_ID_QUEST, true); - _summons.DespawnAll(); - me->DespawnOrUnsummon(100ms); - } - } - - void SpellHit(WorldObject* caster, SpellInfo const* spell) override - { - switch (spell->Id) - { - case SPELL_GET_FREEZYA_SEEDS: - me->m_spells[2] = SPELL_PLANT_FREEZYA; - if (Player* player = caster->ToPlayer()) - { - Talk(SAY_ANNOUNCE_GAINED_FREEZYA_SEEDS, player); - player->VehicleSpellInitialize(); - } - break; - default: - break; - } - } - - void DoAction(int32 action) override - { - switch (action) - { - case ACTION_FIRST_SPITTER_PLANTED: - me->RemoveAurasDueToSpell(SPELL_SPITTER_HIGHLIGHT); - _events.ScheduleEvent(EVENT_CREATE_ZOMBIE, 500ms); - if (Creature* highlight = me->FindNearestCreature(NPC_PLACE_IT_HERE, 100.0f, true)) - highlight->DespawnOrUnsummon(); - break; - case ACTION_FIRST_SUNFLOWER_PLANTED: - me->RemoveAurasDueToSpell(SPELL_SUNFLOWER_HIGHLIGHT); - if (Creature* highlight = me->FindNearestCreature(NPC_PLACE_IT_HERE, 100.0f, true)) - highlight->DespawnOrUnsummon(); - break; - case ACTION_ZOMBIE_DAMAGED: - _damagedZombieCount++; - switch (_currentLevel) - { - case LEVEL_TUTORIAL: - if (_currentStage < 2) - EnterNextStage(); - break; - case LEVEL_1: - if (_currentStage == 0 && _damagedZombieCount == 1) - EnterNextStage(); - if (_currentStage == 1 && _damagedZombieCount == 2) - EnterNextStage(); - break; - default: - break; - } - break; - case ACTION_ZOMBIE_DIED: - _deadZombieCount++; - switch (_currentLevel) - { - case LEVEL_TUTORIAL: - if (_deadZombieCount == 3) - EnterNextStage(); - if (_deadZombieCount == 8) - EnterNextStage(); - break; - case LEVEL_1: - if (_deadZombieCount == 4 && _currentStage == 2) - EnterNextStage(); - if (_deadZombieCount == 11 && _currentStage == 3) - EnterNextStage(); - if (_deadZombieCount == 27 && _currentStage == 4) - EnterNextStage(); - break; - default: - break; - } - break; - case ACTION_GHOUL_DAMAGED: - _damagedGhoulCount++; - switch (_currentLevel) - { - case LEVEL_1: - if (_damagedGhoulCount == 1) - { - if (Player* player = GetPlayer()) - { - player->Whisper(SAY_ANNOUNCE_DROPPED_SEED, player, true); - player->CastSpell(player, SPELL_CREATE_RANDOM_FREEZYA_SACK, true); - } - } - break; - default: - break; - } - break; - case ACTION_QUEST_FAILED: - if (Player* player = GetPlayer()) - { - switch (_currentLevel) - { - case LEVEL_TUTORIAL: - player->FailQuest(QUEST_BASIC_BOTANY); - break; - case LEVEL_1: - player->FailQuest(QUEST_FLOWER_POWER); - break; - default: - break; - } - } - Talk(SAY_ANNOUNCE_ZOMBIES_ATE_BRAIN); - _summons.DespawnEntry(NPC_ZOMBIE); - _summons.DespawnEntry(NPC_GHOUL); - _events.Reset(); - _events.ScheduleEvent(EVENT_EJECT_PASSENGER, 2s + 300ms); - break; - default: - break; - } - } - - void UpdateAI(uint32 diff) override - { - _events.Update(diff); - - while (uint32 eventId = _events.ExecuteEvent()) - { - switch (eventId) - { - case EVENT_SPITTER_HIGHLIGHT: - case EVENT_SUNFLOWER_HIGHLIGHT: - { - uint32 textId = 0; - uint32 spellId = 0; - - switch (eventId) - { - case EVENT_SPITTER_HIGHLIGHT: - textId = SAY_ANNOUNCE_TUTORIAL_1; - spellId = SPELL_SPITTER_HIGHLIGHT; - break; - case EVENT_SUNFLOWER_HIGHLIGHT: - textId = SAY_ANNOUNCE_PLANT_SUNFLOWER; - spellId = SPELL_SUNFLOWER_HIGHLIGHT; - break; - default: - break; - } - - if (Player* player = GetPlayer()) - { - player->Whisper(textId, player, true); - player->CastSpell(player, spellId, true); - } - if (eventId == EVENT_SUNFLOWER_HIGHLIGHT) - _events.ScheduleEvent(EVENT_ANNOUNCE_PLANT_MORE_FLOWERS, 1s); - break; - } - case EVENT_SUMMON_SOLAR_POWER: - { - uint8 randomPositionIndex = urand(0, MAX_EMPTY_SPOT_POSITIONS - 1); - Position pos = EmptySpotPositions[randomPositionIndex]; - float angle = frand(0, float(M_PI * 2)); - float x = pos.GetPositionX() + cos(angle) * 3; - float y = pos.GetPositionY() + sin(angle) * 3; - - if (Player* player = GetPlayer()) - player->CastSpell(Position{ x, y, pos.GetPositionZ() + 50.0f }, SPELL_CREATE_RANDOM_SUN_POWER, true); - - switch (_currentLevel) - { - case LEVEL_TUTORIAL: - _events.Repeat(_timeUntilNextSolarPower); - break; - case LEVEL_1: - if (_timeUntilNextSolarPower == 5s) - _timeUntilNextSolarPower = 10s; - else - _timeUntilNextSolarPower = 5s; - _events.Repeat(_timeUntilNextSolarPower); - break; - default: - break; - } - break; - } - case EVENT_CREATE_ZOMBIE: - case EVENT_CREATE_GHOUL: - { - // The tutorial quest only spawns zombies in the center lane - uint8 index = _currentLevel == LEVEL_TUTORIAL ? static_cast(CENTER_ZOMBIE_LANE_INDEX) : static_cast(urand(0, MAX_TARGET_POSITIONS - 1)); - Position const& pos = SpitterTargetPositions[index]; - float angle = pos.GetAngle(GoalStalkerPositions[index]); - - uint32 spellId = 0; - switch (eventId) - { - case EVENT_CREATE_ZOMBIE: - spellId = SPELL_CREATE_ZOMBIE; - break; - case EVENT_CREATE_GHOUL: - spellId = SPELL_CREATE_GHOUL; - break; - default: - break; - } - - if (Player* player = GetPlayer()) - player->CastSpell(Position{ pos.GetPositionX(), pos.GetPositionY(), pos.GetPositionZ(), angle }, spellId, true); - break; - } - case EVENT_ANNOUNCE_GOOD_JOB: - if (Player* player = GetPlayer()) - player->Whisper(SAY_ANNOUNCE_TUTORIAL_2, player, true); - _events.ScheduleEvent(EVENT_CREATE_ZOMBIE, 1ms); - break; - case EVENT_ANNOUNCE_PLACING_SPITTERS: - if (Player* player = GetPlayer()) - player->Whisper(SAY_ANNOUNCE_TUTORIAL_3, player, true); - _events.ScheduleEvent(EVENT_CREATE_ZOMBIE, 1ms); - break; - case EVENT_ANNOUNCE_MASSIVE_WAVE: - if (Player* player = GetPlayer()) - player->Whisper(SAY_ANNOUNCE_MASSIVE_WAVE, player, true); - break; - case EVENT_ANNOUNCE_LEVEL_COMPLETED: - { - if (Player* player = GetPlayer()) - { - uint32 textId = 0; - uint32 creditEntry = 0; - switch (_currentLevel) - { - case LEVEL_TUTORIAL: - textId = SAY_ANNOUNCE_TUTORIAL_4; - creditEntry = CREDIT_BASIC_BOTANY; - break; - case LEVEL_1: - textId = SAY_ANNOUNCE_LEVEL_1_COMPLETE; - creditEntry = CREDIT_FLOWER_POWER; - break; - default: - break; - } - - player->Whisper(textId, player, true); - player->RemoveAurasDueToSpell(SPELL_PROGRESS_BAR); - player->KilledMonsterCredit(creditEntry); - } - break; - } - case EVENT_EJECT_PASSENGER: - DoCastSelf(SPELL_EJECT_ALL_PASSENGERS, true); - break; - case EVENT_ANNOUNCE_PLANT_MORE_FLOWERS: - if (Player* player = GetPlayer()) - player->Whisper(SAY_ANNOUNCE_PLANT_MORE_SUNFLOWERS, player, true); - break; - default: - break; - } - } - - } - -public: - bool IsSpotFree(Position const pos) - { - for (ObjectGuid guid : _spotGUIDs) - { - if (Creature* spot = ObjectAccessor::GetCreature(*me, guid)) - if (spot->GetDistance(pos) <= 5.0f) - if (spot->IsAIEnabled()) - if (spot->AI()->GetData(DATA_CURRENT_ENTRY) == NPC_EMPTY_SPOT) - return true; - } - - return false; - } - -private: - Player* GetPlayer() - { - if (Unit* creator = ObjectAccessor::GetUnit(*me, me->GetCreatorGUID())) - return creator->ToPlayer(); - - return nullptr; - } - - void SetupSpawns() - { - Unit* charmer = me->GetCharmerOrOwner(); - if (!charmer) - return; - - for (uint8 i = 0; i < MAX_TARGET_POSITIONS; i++) - { - Position pos = LawnMowerPositions[i]; - charmer->CastSpell(Position{ pos.GetPositionX(), pos.GetPositionY(), pos.GetPositionZ(), LawnmowerOrientation }, SPELL_CREATE_LAWMOWER, true); - - pos = GoalStalkerPositions[i]; - charmer->CastSpell(Position{ pos.GetPositionX(), pos.GetPositionY(), pos.GetPositionZ(), GoalStalkerOrientation }, SPELL_CREATE_GOAL_STALKER, true); - - pos = SpitterTargetPositions[i]; - charmer->CastSpell(Position{ pos.GetPositionX(), pos.GetPositionY(), pos.GetPositionZ(), SpitterTargetOrientation }, SPELL_CREATE_SPITTER_TARGET, true); - } - - for (uint8 i = 0; i < MAX_EMPTY_SPOT_POSITIONS; i++) - { - Position pos = EmptySpotPositions[i]; - charmer->CastSpell(Position{ pos.GetPositionX(), pos.GetPositionY(), pos.GetPositionZ(), EmptySpotOrientation }, SPELL_CREATE_EMPTY_SPOT, true); - } - } - - void InitializeLevel() - { - if (Player* player = GetPlayer()) - { - if (player->GetQuestStatus(QUEST_BASIC_BOTANY) == QUEST_STATUS_REWARDED - && player->GetQuestStatus(QUEST_FLOWER_POWER) != QUEST_STATUS_REWARDED) - _currentLevel = LEVEL_1; - } - } - - void SetupActionBar() - { - if (_currentLevel == LEVEL_TUTORIAL) - me->m_spells[0] = SPELL_PLANT_SPITTER; - else if (_currentLevel == LEVEL_1) - { - me->m_spells[0] = SPELL_PLANT_SUNFLOWER; - me->m_spells[1] = SPELL_PLANT_SPITTER; - } - } - - void EnterNextStage() - { - _currentStage++; - - switch (_currentLevel) - { - case LEVEL_TUTORIAL: - if (_currentStage == 1) - _events.ScheduleEvent(EVENT_ANNOUNCE_GOOD_JOB, 1ms); - else if (_currentStage == 2) - _events.ScheduleEvent(EVENT_ANNOUNCE_PLACING_SPITTERS, 1ms); - else if (_currentStage == 3) - StartMassiveWave(); - else if (_currentStage == 4) - { - FinishLevel(); - return; - } - break; - case LEVEL_1: - if (_currentStage == 1) - CreateZombies(2, 5s, 7s); - else if (_currentStage == 2) - CreateZombies(3, 8s, 7s); - else if (_currentStage == 3) - { - CreateZombies(3, 35s, 5s); - CreateGhouls(2, 6s, 39s); - } - else if (_currentStage == 4) - StartMassiveWave(); - else if (_currentStage == 5) - { - FinishLevel(); - return; - } - break; - default: - break; - } - - if (Player* player = GetPlayer()) - player->ModifyPower(POWER_ALTERNATE_POWER, 1); - } - - void StartMassiveWave() - { - switch (_currentLevel) - { - case LEVEL_TUTORIAL: - _events.ScheduleEvent(EVENT_ANNOUNCE_MASSIVE_WAVE, 1ms); - CreateZombies(5, 1s + 800ms, 3s); - break; - case LEVEL_1: - _events.ScheduleEvent(EVENT_ANNOUNCE_MASSIVE_WAVE, 500ms); - CreateZombies(2, 2s, 3s); - CreateZombies(5, 2s, 3s); - CreateZombies(1, 20s, 0s); - CreateZombies(2, 20s, 2s); - CreateZombies(1, 29s, 0s); - CreateZombies(1, 29s, 0s); - - CreateGhouls(2, 8s, 3s); - CreateGhouls(1, 26s, 0s); - CreateGhouls(1, 26s, 0s); - break; - default: - break; - } - } - - void FinishLevel() - { - _events.Reset(); - _events.ScheduleEvent(EVENT_ANNOUNCE_LEVEL_COMPLETED, 1ms); - _events.ScheduleEvent(EVENT_EJECT_PASSENGER, 1s + 600ms); - } - - void CreateZombies(uint8 amount, Milliseconds const& delay, Milliseconds const& repeatTimer) - { - for (uint8 i = 0; i < amount; i++) - _events.ScheduleEvent(EVENT_CREATE_ZOMBIE, delay + (repeatTimer * i)); - } - - void CreateGhouls(uint8 amount, Milliseconds const& delay, Milliseconds const& repeatTimer) - { - for (uint8 i = 0; i < amount; i++) - _events.ScheduleEvent(EVENT_CREATE_GHOUL, delay + (repeatTimer * i)); - } - - EventMap _events; - SummonList _summons; - GuidVector _spotGUIDs; - uint8 _currentLevel; - uint8 _deadZombieCount; - uint8 _damagedZombieCount; - uint8 _damagedGhoulCount; - uint8 _currentStage; - Seconds _timeUntilNextSolarPower; -}; - -struct npc_brazie_fertilitize_o_tron_2000 : public ScriptedAI -{ - npc_brazie_fertilitize_o_tron_2000(Creature* creature) : ScriptedAI(creature) { } - - void IsSummonedBy(Unit* summoner) override - { - me->SetDisplayFromModel(1); - - if (Creature* vehicle = summoner->GetVehicleCreatureBase()) - if (vehicle->IsAIEnabled()) - vehicle->AI()->JustSummoned(me); - } - - void AttackStart(Unit* who) override - { - if (me->GetPosition().GetExactDist(who->GetPosition()) > 3.0f) - return; - - if (Creature* spitterTarget = me->FindNearestCreature(NPC_SPITTER_TARGET, 150.0f, true)) - { - Position pos = me->GetPosition(); - pos.SetOrientation(pos.GetAngle(spitterTarget)); - - if (pos.HasInLine(who, 5.0f, 0.f)) - CreatureAI::AttackStart(who); - } - } - - void JustEngagedWith(Unit* who) override - { - DoCast(who, SPELL_DAZED, true); - DoCastSelf(SPELL_LAWNMOWER_STARTUP_SOUND, true); - me->SetReactState(REACT_PASSIVE); - me->AttackStop(); - _events.ScheduleEvent(EVENT_KILL_LANE, Milliseconds(300)); - } - - void MovementInform(uint32 type, uint32 id) override - { - if (type != POINT_MOTION_TYPE && type != EFFECT_MOTION_TYPE) - return; - - if (id == POINT_SPITTER_TARGET) - me->DespawnOrUnsummon(); - } - - void UpdateAI(uint32 diff) override - { - _events.Update(diff); - - while (uint32 eventId = _events.ExecuteEvent()) - { - switch (eventId) - { - case EVENT_KILL_LANE: - if (Creature* spitterTarget = me->FindNearestCreature(NPC_SPITTER_TARGET, 150.0f, true)) - me->GetMotionMaster()->MovePoint(POINT_SPITTER_TARGET, spitterTarget->GetPosition(), false); - _events.ScheduleEvent(EVENT_PLAGUE_GAS, Milliseconds(200)); - break; - case EVENT_PLAGUE_GAS: - DoCastAOE(SPELL_PLAGUE_GAS, true); - _events.Repeat(Milliseconds(500)); - break; - default: - break; - } - } - - } -private: - EventMap _events; -}; - -struct npc_brazie_spot : public ScriptedAI -{ - npc_brazie_spot(Creature* creature) : ScriptedAI(creature) - { - Initialize(); - } - - void Initialize() - { - _currentEntry = me->GetEntry(); - } - - void DamageTaken(Unit* /*attacker*/, uint32& damage) override - { - if (damage >= me->GetHealth()) - { - damage = 0; - - me->SetFullHealth(); - me->UpdateEntry(NPC_EMPTY_SPOT); - _currentEntry = NPC_EMPTY_SPOT; - me->SetDisplayFromModel(0); - me->AttackStop(); - me->SetFacingTo(me->GetHomePosition().GetOrientation()); - _events.Reset(); - } - } - - void IsSummonedBy(Unit* summoner) override - { - me->m_SightDistance = 150.0f; - me->m_CombatDistance = 150.0f; - - me->SetDisplayFromModel(0); - - if (Creature* vehicle = summoner->GetVehicleCreatureBase()) - if (vehicle->IsAIEnabled()) - vehicle->AI()->JustSummoned(me); - } - - void AttackStart(Unit* who) override - { - if (_currentEntry == NPC_EMPTY_SPOT || _currentEntry == NPC_SUNFLOWER) - return; - - if (who->GetEntry() != NPC_ZOMBIE && who->GetEntry() != NPC_GHOUL) - return; - - if (Creature* spitterTarget = me->FindNearestCreature(NPC_SPITTER_TARGET, 150.0f, true)) - { - Position pos = me->GetPosition(); - pos.SetOrientation(pos.GetAngle(spitterTarget)); - - if (pos.HasInLine(who, 5.0f, 0.f)) - { - me->SetFacingToObject(spitterTarget); - me->SetNoCallAssistance(true); - CreatureAI::AttackStart(who); - } - } - } - - uint32 GetData(uint32 type) const override - { - if (type == DATA_CURRENT_ENTRY) - return _currentEntry; - - return 0; - } - - void SpellHit(WorldObject* /*caster*/, SpellInfo const* spell) override - { - switch (spell->Id) - { - case SPELL_PLANT_SPITTER: - me->UpdateEntry(NPC_SPITTER); - _currentEntry = NPC_SPITTER; - break; - case SPELL_PLANT_SUNFLOWER: - me->UpdateEntry(NPC_SUNFLOWER); - _currentEntry = NPC_SUNFLOWER; - _events.ScheduleEvent(EVENT_SUNLIGHT, 16s); - break; - case SPELL_PLANT_FREEZYA: - me->UpdateEntry(NPC_FREEZYA); - _currentEntry = NPC_FREEZYA; - break; - default: - break; - } - - if (spell->Id != SPELL_PLAGUE_GAS) - me->SetDisplayFromModel(1); - } - - void UpdateAI(uint32 diff) override - { - _events.Update(diff); - - while (uint32 eventId = _events.ExecuteEvent()) - { - switch (eventId) - { - case EVENT_SUNLIGHT: - DoCastSelf(SPELL_SUNLIGHT, true); - if (TempSummon* summon = me->ToTempSummon()) - { - if (Unit* summoner = summon->GetSummoner()) - { - uint8 randomPositionIndex = urand(0, MAX_EMPTY_SPOT_POSITIONS - 1); - Position pos = EmptySpotPositions[randomPositionIndex]; - float angle = frand(0, float(M_PI * 2)); - float x = pos.GetPositionX() + cos(angle) * 3; - float y = pos.GetPositionY() + sin(angle) * 3; - - summoner->CastSpell(Position{ x, y, pos.GetPositionZ() + 50.0f }, SPELL_CREATE_RANDOM_SUN_POWER, true); - } - } - _events.Repeat(17s); - break; - default: - break; - } - } - - if (_currentEntry == NPC_SPITTER) - DoSpellAttackIfReady(SPELL_VENOM_SPIT); - else if (_currentEntry == NPC_FREEZYA) - DoSpellAttackIfReady(SPELL_FREEZYA_BLAST); - } - -private: - EventMap _events; - uint32 _currentEntry; - GuidVector _zombieGuids; -}; - -struct npc_brazie_zombie : public ScriptedAI -{ - npc_brazie_zombie(Creature* creature) : ScriptedAI(creature), _pathIndex(0), _notifiedLowHealth(false) - { - } - - void IsSummonedBy(Unit* summoner) override - { - me->m_SightDistance = 3.0f; - me->m_CombatDistance = 3.0f; - - me->SetWalk(true); - - if (Creature* vehicle = summoner->GetVehicleCreatureBase()) - if (vehicle->IsAIEnabled()) - vehicle->AI()->JustSummoned(me); - - for (uint8 i = 0; i < MAX_TARGET_POSITIONS; i++) - if (me->GetPosition().GetExactDist(SpitterTargetPositions[i]) <= 1.0f) - _pathIndex = i; - - if (me->GetEntry() == NPC_ZOMBIE) - DoCastSelf(SPELL_ZOMBIE_GROWL); - else if (me->GetEntry() == NPC_GHOUL) - { - me->SetDisplayFromModel(3); - DoCastSelf(SPELL_GHOUL_GROWL, true); - DoCastSelf(SPELL_BUCKET_HEAD, true); - } - - _events.ScheduleEvent(EVENT_MOVE_TO_GOAL_POSITION, 2s + 400ms); - } - - void AttackStart(Unit* who) override - { - // Just to make sure that a zombie wont call nearby zombies for aid - if (who->GetDistance(me) > 3.0f) - return; - - // If we have a plant very close to us we assume that this plant has been added afterwards - if (me->GetPosition().GetExactDist(who) < 3.0f) - return; - - // Do not attack plants behind me if they got replanted - if (!who->isInFront(me, 1.8f)) - return; - - Position pos = me->GetPosition(); - pos.SetOrientation(pos.GetAngle(GoalStalkerPositions[_pathIndex])); - - if (pos.HasInLine(who, 4.8f, 0.f)) - { - if (Creature* target = who->ToCreature()) - { - if (target->IsAIEnabled()) - { - if (target->AI()->GetData(DATA_CURRENT_ENTRY) != NPC_EMPTY_SPOT || target->GetEntry() == NPC_FERTILITIZE_O_TRON_2000) - { - me->SetNoCallAssistance(true); - me->StopMoving(); - CreatureAI::AttackStart(who); - } - } - } - } - } - - void JustDied(Unit* /*killer*/) override - { - if (TempSummon* summon = me->ToTempSummon()) - if (Unit* summoner = summon->GetSummoner()) - if (Creature* vehicle = summoner->GetVehicleCreatureBase()) - if (vehicle->IsAIEnabled()) - vehicle->AI()->DoAction(ACTION_ZOMBIE_DIED); - - me->DespawnOrUnsummon(4s); - } - - void DamageTaken(Unit* /*attacker*/, uint32& damage) override - { - if (me->HealthBelowPctDamaged(50, damage) && !_notifiedLowHealth) - { - if (me->GetEntry() == NPC_ZOMBIE) - DoCastSelf(SPELL_LEAKING_GUTS, true); - else if (me->GetEntry() == NPC_GHOUL) - { - me->RemoveAurasDueToSpell(SPELL_BUCKET_HEAD); - DoCastSelf(SPELL_BUCKET, true); - } - - if (TempSummon* summon = me->ToTempSummon()) - { - if (Unit* summoner = summon->GetSummoner()) - { - if (Creature* vehicle = summoner->GetVehicleCreatureBase()) - { - if (vehicle->IsAIEnabled()) - { - if (me->GetEntry() == NPC_ZOMBIE) - vehicle->AI()->DoAction(ACTION_ZOMBIE_DAMAGED); - else if (me->GetEntry() == NPC_GHOUL) - vehicle->AI()->DoAction(ACTION_GHOUL_DAMAGED); - } - } - } - } - - _notifiedLowHealth = true; - } - } - - void MovementInform(uint32 type, uint32 id) override - { - if (type != POINT_MOTION_TYPE && type != EFFECT_MOTION_TYPE) - return; - - if (id == POINT_GOAL_STALKER) - if (me->GetPosition().GetExactDist(GoalStalkerPositions[_pathIndex]) <= 0.1f) - if (TempSummon* summon = me->ToTempSummon()) - if (Unit* summoner = summon->GetSummoner()) - if (Creature* vehicle = summoner->GetVehicleCreatureBase()) - if (vehicle->IsAIEnabled()) - vehicle->AI()->DoAction(ACTION_QUEST_FAILED); - } - - void UpdateAI(uint32 diff) override - { - _events.Update(diff); - - if (me->HasUnitState(UNIT_STATE_CASTING)) - return; - - while (uint32 eventId = _events.ExecuteEvent()) - { - switch (eventId) - { - case EVENT_MOVE_TO_GOAL_POSITION: - me->GetMotionMaster()->MovePoint(POINT_GOAL_STALKER, GoalStalkerPositions[_pathIndex], false); - _events.ScheduleEvent(EVENT_CHECK_VICTIM_STATE, 1s); - break; - case EVENT_CHECK_VICTIM_STATE: - if (Unit* victim = me->GetVictim()) - { - if (Creature* target = victim->ToCreature()) - { - if (target->IsAIEnabled()) - { - if (target->AI()->GetData(DATA_CURRENT_ENTRY) == NPC_EMPTY_SPOT) - { - me->AttackStop(); - me->SetWalk(true); - me->GetMotionMaster()->MovePoint(POINT_GOAL_STALKER, GoalStalkerPositions[_pathIndex], false); - } - } - } - } - _events.Repeat(1s); - break; - default: - break; - } - } - DoMeleeAttackIfReady(); - - } -private: - EventMap _events; - uint8 _pathIndex; - bool _notifiedLowHealth; -}; - -struct npc_brazie_vehicle_notifier : public ScriptedAI -{ - npc_brazie_vehicle_notifier(Creature* creature) : ScriptedAI(creature) { } - - void IsSummonedBy(Unit* summoner) override - { - switch (me->GetEntry()) - { - case NPC_SOLAR_POWER: - case NPC_FREEZYA_SEEDS: - me->SetDisplayFromModel(1); - me->SetFlag(UNIT_NPC_FLAGS, UNIT_NPC_FLAG_SPELLCLICK); - break; - case NPC_PLACE_IT_HERE: - me->Relocate(EmptySpotPositions[0]); - me->SetDisplayFromModel(1); - break; - default: - me->SetDisplayFromModel(0); - break; - } - - if (Creature* vehicle = summoner->GetVehicleCreatureBase()) - if (vehicle->IsAIEnabled()) - vehicle->AI()->JustSummoned(me); - } - - void SpellHit(WorldObject* caster, SpellInfo const* spell) override - { - if (!caster || !caster->IsUnit()) - return; - - switch (spell->Id) - { - case SPELL_GAIN_SOLAR_POWER_SPELLCLICK: - if (Unit* vehicle = caster->ToUnit()->GetVehicleCreatureBase()) - DoCast(vehicle, SPELL_GAIN_SOLAR_POWER_ENERGIZE, true); - me->GetMotionMaster()->MoveJump(SolarPowerJumpPos, 45.0f, 20.0f); - me->SetFlag(UNIT_FIELD_FLAGS, UNIT_FLAG_NOT_SELECTABLE | UNIT_FLAG_IMMUNE_TO_PC | UNIT_FLAG_IMMUNE_TO_NPC); - me->DespawnOrUnsummon(Seconds(2)); - break; - case SPELL_GET_FREEZYA_SEEDS: - me->GetMotionMaster()->MoveJump(FreezyaSeedJumpPos, 45.0f, 20.0f); - me->SetFlag(UNIT_FIELD_FLAGS, UNIT_FLAG_NOT_SELECTABLE | UNIT_FLAG_IMMUNE_TO_PC | UNIT_FLAG_IMMUNE_TO_NPC); - me->DespawnOrUnsummon(Seconds(2)); - break; - default: - break; - } - } -}; - -class spell_brazie_summon_plant : public SpellScript -{ - bool Validate(SpellInfo const* /*spellInfo*/) override - { - return ValidateSpellInfo( - { - SPELL_SUNFLOWER_HIGHLIGHT, - SPELL_SPITTER_HIGHLIGHT - }); - } - - SpellCastResult CheckLocation() - { - if (Creature* creature = GetCaster()->ToCreature()) - if (CAST_AI(npc_brazie_the_bonatist_vehicle, - creature->AI())->IsSpotFree(GetExplTargetDest()->GetPosition())) - return SPELL_CAST_OK; - - return SPELL_FAILED_BAD_TARGETS; - } - - void PreventEffect(SpellEffIndex effIndex) - { - PreventHitEffect(effIndex); - } - - void HandleDummy(SpellEffIndex /*effIndex*/) - { - Unit* caster = GetCaster(); - if (!caster || caster->GetTypeId() != TYPEID_UNIT) - return; - - Creature* creature = caster->ToCreature(); - - if (creature->IsAIEnabled()) - { - if (creature->HasAura(SPELL_SUNFLOWER_HIGHLIGHT)) - creature->AI()->DoAction(ACTION_FIRST_SUNFLOWER_PLANTED); - - if (creature->HasAura(SPELL_SPITTER_HIGHLIGHT)) - creature->AI()->DoAction(ACTION_FIRST_SPITTER_PLANTED); - } - } - - void Register() override - { - OnCheckCast.Register(&spell_brazie_summon_plant::CheckLocation); - OnEffectHitTarget.Register(&spell_brazie_summon_plant::HandleDummy, EFFECT_1, SPELL_EFFECT_DUMMY); - OnEffectHit.Register(&spell_brazie_summon_plant::PreventEffect, EFFECT_0, SPELL_EFFECT_TRANS_DOOR); - } -}; - -class spell_brazie_highlight : public SpellScript -{ - void SetDest(SpellDestination& dest) - { - dest.Relocate(EmptySpotPositions[0]); - } - - void Register() override - { - OnDestinationTargetSelect.Register(&spell_brazie_highlight::SetDest, EFFECT_1, TARGET_DEST_NEARBY_ENTRY); - } -}; - -class spell_brazie_create_random_seed_sack : public SpellScript -{ - bool Validate(SpellInfo const* /*spellInfo*/) override - { - return ValidateSpellInfo({ SPELL_CREATE_RANDOM_FREEZYA_SACK }); - } - - void SetDest(SpellDestination& dest) - { - if (GetSpellInfo()->Id == SPELL_CREATE_RANDOM_FREEZYA_SACK) - dest.Relocate(FreezyaSeedSummonPos); - } - - void Register() override - { - OnDestinationTargetSelect.Register(&spell_brazie_create_random_seed_sack::SetDest, EFFECT_0, TARGET_DEST_NEARBY_ENTRY); - } -}; - -class spell_brazie_spit : public SpellScript -{ - bool Validate(SpellInfo const* /*spellInfo*/) override - { - return ValidateSpellInfo( - { - SPELL_VENOM_SPIT, - SPELL_FREEZYA_BLAST - }); - } - - void FilterTargets(std::list& targets) - { - if (targets.empty()) - return; - - if (GetSpellInfo()->Id == SPELL_VENOM_SPIT) - { - targets.sort(Trinity::ObjectDistanceOrderPred(GetCaster(), true)); - targets.resize(1); - } - else - { - std::list targetsBackup = targets; - targetsBackup.remove_if(Trinity::UnitAuraCheck(true, SPELL_FREEZYA_BLAST)); - if (targetsBackup.empty()) - { - targets.sort(Trinity::ObjectDistanceOrderPred(GetCaster(), true)); - targets.resize(1); - } - else - { - targets = targetsBackup; - targets.sort(Trinity::ObjectDistanceOrderPred(GetCaster(), true)); - targets.resize(1); - } - } - } - - void Register() override - { - OnObjectAreaTargetSelect.Register(&spell_brazie_spit::FilterTargets, EFFECT_ALL, TARGET_UNIT_CONE_CASTER_TO_DEST_ENEMY); - } -}; - -// WOW-PRIVATE-SERVER PATCH: Welcome to the Machine (28096) -// -// Restores the missing Hillsbrad quest scene around High Executor Darthalia's -// skeletal steed. Spoken lines are provided through creature_text rows that -// reference the original BroadcastText IDs, so existing locales remain usable. -enum WelcomeToTheMachine -{ - QUEST_WELCOME_TO_THE_MACHINE = 28096, - - NPC_HIGH_EXECUTOR_DARTHALIA = 2215, - NPC_JOHNNY_AWESOME = 47442, - NPC_KINGSLAYER_ORKUS = 47443, - NPC_DUMASS = 47444, - NPC_SKELETAL_STEED = 47445, - - DATA_WELCOME_STAGE = 1, - - STAGE_IDLE = 0, - STAGE_DUMASS_GOSSIP = 1, - STAGE_ORKUS_GOSSIP = 2, - STAGE_JOHNNY_GOSSIP = 3, - STAGE_FINISHED = 4, - - ACTION_DUMASS_DONE = 1, - ACTION_ORKUS_DONE = 2, - ACTION_JOHNNY_DONE = 3, - - GOSSIP_ACTION_DUMASS_1 = GOSSIP_ACTION_INFO_DEF + 101, - GOSSIP_ACTION_DUMASS_2 = GOSSIP_ACTION_INFO_DEF + 102, - GOSSIP_ACTION_ORKUS_1 = GOSSIP_ACTION_INFO_DEF + 103, - GOSSIP_ACTION_ORKUS_2 = GOSSIP_ACTION_INFO_DEF + 104, - GOSSIP_ACTION_ORKUS_3 = GOSSIP_ACTION_INFO_DEF + 105, - GOSSIP_ACTION_JOHNNY_1 = GOSSIP_ACTION_INFO_DEF + 106, - GOSSIP_ACTION_JOHNNY_2 = GOSSIP_ACTION_INFO_DEF + 107, - GOSSIP_ACTION_JOHNNY_3 = GOSSIP_ACTION_INFO_DEF + 108, - - GOSSIP_MENU_DUMASS_1 = 12273, - GOSSIP_MENU_DUMASS_2 = 12272, - GOSSIP_MENU_ORKUS_1 = 12276, - GOSSIP_MENU_ORKUS_2 = 12275, - GOSSIP_MENU_ORKUS_3 = 12274, - GOSSIP_MENU_JOHNNY_1 = 12279, - GOSSIP_MENU_JOHNNY_2 = 12278, - GOSSIP_MENU_JOHNNY_3 = 12277, - - SAY_DARTHALIA_STAND_TALL = 50, - SAY_DARTHALIA_FIRST_ONE = 51, - SAY_DARTHALIA_DUMASS_COMMENT = 52, - SAY_DARTHALIA_ORKUS_WARNING = 53, - SAY_DARTHALIA_ORKUS_COMMENT = 54, - SAY_DARTHALIA_JOHNNY_WARNING = 55, - - SAY_DUMASS_HELLO = 50, - SAY_DUMASS_NEW = 51, - SAY_DUMASS_WAIT = 52, - SAY_DUMASS_NORTH = 53, - - SAY_ORKUS_LOKTAR = 50, - SAY_ORKUS_PUNY = 51, - SAY_ORKUS_WHAT_HAVE_YOU_GOT = 52, - SAY_ORKUS_BOAST = 53, - SAY_ORKUS_RISK = 54, - SAY_ORKUS_WILL_DO_IT = 55, - SAY_ORKUS_FAREWELL = 56, - - SAY_JOHNNY_ARRIVED = 50, - SAY_JOHNNY_HEIRLOOMS = 51, - SAY_JOHNNY_WHAT_ELSE = 52, - SAY_JOHNNY_ONE_QUEST = 53, - SAY_JOHNNY_FAREWELL = 54 -}; - -enum WelcomeToTheMachineEvents -{ - EVENT_WELCOME_DARTHALIA_1 = 1, - EVENT_WELCOME_DARTHALIA_2, - EVENT_WELCOME_SUMMON_DUMASS, - EVENT_WELCOME_DUMASS_1, - EVENT_WELCOME_DUMASS_2, - EVENT_WELCOME_ENABLE_DUMASS, - EVENT_WELCOME_DARTHALIA_DUMASS_COMMENT, - EVENT_WELCOME_DARTHALIA_ORKUS_WARNING, - EVENT_WELCOME_SUMMON_ORKUS, - EVENT_WELCOME_ORKUS_1, - EVENT_WELCOME_ORKUS_2, - EVENT_WELCOME_ORKUS_3, - EVENT_WELCOME_ORKUS_4, - EVENT_WELCOME_ENABLE_ORKUS, - EVENT_WELCOME_DARTHALIA_ORKUS_COMMENT, - EVENT_WELCOME_DARTHALIA_JOHNNY_WARNING, - EVENT_WELCOME_SUMMON_JOHNNY, - EVENT_WELCOME_JOHNNY_1, - EVENT_WELCOME_JOHNNY_2, - EVENT_WELCOME_ENABLE_JOHNNY -}; - -struct npc_hillsbrad_welcome_steed : public VehicleAI -{ - npc_hillsbrad_welcome_steed(Creature* creature) - : VehicleAI(creature), _summons(me), _stage(STAGE_IDLE) { } - - void JustSummoned(Creature* summon) override - { - _summons.Summon(summon); - } - - void PassengerBoarded(Unit* passenger, int8 /*seatId*/, bool apply) override - { - Player* player = passenger ? passenger->ToPlayer() : nullptr; - if (!player) - return; - - if (!apply) - { - ResetScene(); - return; - } - - if (player->GetQuestStatus(QUEST_WELCOME_TO_THE_MACHINE) != QUEST_STATUS_INCOMPLETE) - return; - - _playerGuid = player->GetGUID(); - _stage = STAGE_IDLE; - _events.Reset(); - _summons.DespawnAll(); - - _events.ScheduleEvent(EVENT_WELCOME_DARTHALIA_1, 500ms); - _events.ScheduleEvent(EVENT_WELCOME_DARTHALIA_2, 3s); - _events.ScheduleEvent(EVENT_WELCOME_SUMMON_DUMASS, 5s); - _events.ScheduleEvent(EVENT_WELCOME_DUMASS_1, 6s); - _events.ScheduleEvent(EVENT_WELCOME_DUMASS_2, 8s); - _events.ScheduleEvent(EVENT_WELCOME_ENABLE_DUMASS, 11s); - } - - uint32 GetData(uint32 type) const override - { - if (type == DATA_WELCOME_STAGE) - return _stage; - - return 0; - } - - void DoAction(int32 action) override - { - Player* player = GetScenePlayer(); - if (!player || player->GetQuestStatus(QUEST_WELCOME_TO_THE_MACHINE) != QUEST_STATUS_INCOMPLETE) - return; - - switch (action) - { - case ACTION_DUMASS_DONE: - if (_stage != STAGE_DUMASS_GOSSIP) - return; - - _stage = STAGE_IDLE; - _events.ScheduleEvent(EVENT_WELCOME_DARTHALIA_DUMASS_COMMENT, 2s); - _events.ScheduleEvent(EVENT_WELCOME_DARTHALIA_ORKUS_WARNING, 5s); - _events.ScheduleEvent(EVENT_WELCOME_SUMMON_ORKUS, 7s); - _events.ScheduleEvent(EVENT_WELCOME_ORKUS_1, 8s); - _events.ScheduleEvent(EVENT_WELCOME_ORKUS_2, 10s); - _events.ScheduleEvent(EVENT_WELCOME_ORKUS_3, 12s); - _events.ScheduleEvent(EVENT_WELCOME_ORKUS_4, 15s); - _events.ScheduleEvent(EVENT_WELCOME_ENABLE_ORKUS, 18s); - break; - - case ACTION_ORKUS_DONE: - if (_stage != STAGE_ORKUS_GOSSIP) - return; - - _stage = STAGE_IDLE; - _events.ScheduleEvent(EVENT_WELCOME_DARTHALIA_ORKUS_COMMENT, 2s); - _events.ScheduleEvent(EVENT_WELCOME_DARTHALIA_JOHNNY_WARNING, 5s); - _events.ScheduleEvent(EVENT_WELCOME_SUMMON_JOHNNY, 7s); - _events.ScheduleEvent(EVENT_WELCOME_JOHNNY_1, 8s); - _events.ScheduleEvent(EVENT_WELCOME_JOHNNY_2, 11s); - _events.ScheduleEvent(EVENT_WELCOME_ENABLE_JOHNNY, 15s); - break; - - case ACTION_JOHNNY_DONE: - if (_stage == STAGE_JOHNNY_GOSSIP) - _stage = STAGE_FINISHED; - break; - - default: - break; - } - } - - void UpdateAI(uint32 diff) override - { - _events.Update(diff); - - while (uint32 eventId = _events.ExecuteEvent()) - { - Player* player = GetScenePlayer(); - if (!player || player->GetQuestStatus(QUEST_WELCOME_TO_THE_MACHINE) != QUEST_STATUS_INCOMPLETE) - { - ResetScene(); - return; - } - - switch (eventId) - { - case EVENT_WELCOME_DARTHALIA_1: TalkDarthalia(SAY_DARTHALIA_STAND_TALL, player); break; - case EVENT_WELCOME_DARTHALIA_2: TalkDarthalia(SAY_DARTHALIA_FIRST_ONE, player); break; - case EVENT_WELCOME_SUMMON_DUMASS: SummonActor(NPC_DUMASS); break; - case EVENT_WELCOME_DUMASS_1: TalkActor(NPC_DUMASS, SAY_DUMASS_HELLO, player); break; - case EVENT_WELCOME_DUMASS_2: TalkActor(NPC_DUMASS, SAY_DUMASS_NEW, player); break; - case EVENT_WELCOME_ENABLE_DUMASS: _stage = STAGE_DUMASS_GOSSIP; break; - - case EVENT_WELCOME_DARTHALIA_DUMASS_COMMENT: TalkDarthalia(SAY_DARTHALIA_DUMASS_COMMENT, player); break; - case EVENT_WELCOME_DARTHALIA_ORKUS_WARNING: TalkDarthalia(SAY_DARTHALIA_ORKUS_WARNING, player); break; - case EVENT_WELCOME_SUMMON_ORKUS: SummonActor(NPC_KINGSLAYER_ORKUS); break; - case EVENT_WELCOME_ORKUS_1: TalkActor(NPC_KINGSLAYER_ORKUS, SAY_ORKUS_LOKTAR, player); break; - case EVENT_WELCOME_ORKUS_2: TalkActor(NPC_KINGSLAYER_ORKUS, SAY_ORKUS_PUNY, player); break; - case EVENT_WELCOME_ORKUS_3: TalkActor(NPC_KINGSLAYER_ORKUS, SAY_ORKUS_WHAT_HAVE_YOU_GOT, player); break; - case EVENT_WELCOME_ORKUS_4: TalkActor(NPC_KINGSLAYER_ORKUS, SAY_ORKUS_BOAST, player); break; - case EVENT_WELCOME_ENABLE_ORKUS: _stage = STAGE_ORKUS_GOSSIP; break; - - case EVENT_WELCOME_DARTHALIA_ORKUS_COMMENT: TalkDarthalia(SAY_DARTHALIA_ORKUS_COMMENT, player); break; - case EVENT_WELCOME_DARTHALIA_JOHNNY_WARNING: TalkDarthalia(SAY_DARTHALIA_JOHNNY_WARNING, player); break; - case EVENT_WELCOME_SUMMON_JOHNNY: SummonActor(NPC_JOHNNY_AWESOME); break; - case EVENT_WELCOME_JOHNNY_1: TalkActor(NPC_JOHNNY_AWESOME, SAY_JOHNNY_ARRIVED, player); break; - case EVENT_WELCOME_JOHNNY_2: TalkActor(NPC_JOHNNY_AWESOME, SAY_JOHNNY_HEIRLOOMS, player); break; - case EVENT_WELCOME_ENABLE_JOHNNY: _stage = STAGE_JOHNNY_GOSSIP; break; - default: break; - } - } - } - -private: - Player* GetScenePlayer() - { - return ObjectAccessor::GetPlayer(*me, _playerGuid); - } - - Creature* GetSceneActor(uint32 entry) - { - for (ObjectGuid const& guid : _summons) - if (Creature* summon = ObjectAccessor::GetCreature(*me, guid)) - if (summon->GetEntry() == entry) - return summon; - - return nullptr; - } - - void SummonActor(uint32 entry) - { - if (GetSceneActor(entry)) - return; - - float x = me->GetPositionX() + 4.0f; - float y = me->GetPositionY() + 4.0f; - float z = me->GetPositionZ(); - - if (Creature* actor = me->SummonCreature(entry, x, y, z, me->GetOrientation(), TEMPSUMMON_MANUAL_DESPAWN)) - { - actor->SetReactState(REACT_PASSIVE); - actor->SetFacingToObject(me); - } - } - - void TalkActor(uint32 entry, uint8 groupId, Player* player) - { - if (Creature* actor = GetSceneActor(entry)) - if (actor->IsAIEnabled()) - actor->AI()->Talk(groupId, player); - } - - void TalkDarthalia(uint8 groupId, Player* player) - { - if (Creature* darthalia = me->FindNearestCreature(NPC_HIGH_EXECUTOR_DARTHALIA, 40.0f, true)) - if (darthalia->IsAIEnabled()) - darthalia->AI()->Talk(groupId, player); - } - - void ResetScene() - { - _events.Reset(); - _summons.DespawnAll(); - _playerGuid.Clear(); - _stage = STAGE_IDLE; - } - - EventMap _events; - SummonList _summons; - ObjectGuid _playerGuid; - uint8 _stage; -}; - -struct npc_hillsbrad_welcome_actor : public ScriptedAI -{ - npc_hillsbrad_welcome_actor(Creature* creature) : ScriptedAI(creature) { } - - bool GossipHello(Player* player) override - { - if (!IsValidScenePlayer(player)) - return false; - - Creature* vehicle = player->GetVehicleCreatureBase(); - if (!vehicle || !vehicle->IsAIEnabled()) - return false; - - uint32 stage = vehicle->AI()->GetData(DATA_WELCOME_STAGE); - - ClearGossipMenuFor(player); - - switch (me->GetEntry()) - { - case NPC_DUMASS: - if (stage != STAGE_DUMASS_GOSSIP) - return false; - - AddGossipItemFor(player, GOSSIP_MENU_DUMASS_1, 0, - GOSSIP_SENDER_MAIN, GOSSIP_ACTION_DUMASS_1); - break; - - case NPC_KINGSLAYER_ORKUS: - if (stage != STAGE_ORKUS_GOSSIP) - return false; - - AddGossipItemFor(player, GOSSIP_MENU_ORKUS_1, 0, - GOSSIP_SENDER_MAIN, GOSSIP_ACTION_ORKUS_1); - break; - - case NPC_JOHNNY_AWESOME: - if (stage != STAGE_JOHNNY_GOSSIP) - return false; - - AddGossipItemFor(player, GOSSIP_MENU_JOHNNY_1, 0, - GOSSIP_SENDER_MAIN, GOSSIP_ACTION_JOHNNY_1); - break; - - default: - return false; - } - - SendGossipMenuFor(player, DEFAULT_GOSSIP_MESSAGE, me->GetGUID()); - return true; - } - - bool GossipSelect(Player* player, uint32 /*menuId*/, uint32 gossipListId) override - { - if (!IsValidScenePlayer(player)) - return false; - - Creature* vehicle = player->GetVehicleCreatureBase(); - if (!vehicle || !vehicle->IsAIEnabled()) - return false; - - uint32 const action = player->PlayerTalkClass->GetGossipOptionAction(gossipListId); - ClearGossipMenuFor(player); - - switch (action) - { - case GOSSIP_ACTION_DUMASS_1: - Talk(SAY_DUMASS_WAIT, player); - AddGossipItemFor(player, GOSSIP_MENU_DUMASS_2, 0, - GOSSIP_SENDER_MAIN, GOSSIP_ACTION_DUMASS_2); - SendGossipMenuFor(player, DEFAULT_GOSSIP_MESSAGE, me->GetGUID()); - return true; - - case GOSSIP_ACTION_DUMASS_2: - Talk(SAY_DUMASS_NORTH, player); - player->KilledMonsterCredit(NPC_DUMASS); - CloseGossipMenuFor(player); - vehicle->AI()->DoAction(ACTION_DUMASS_DONE); - return true; - - case GOSSIP_ACTION_ORKUS_1: - Talk(SAY_ORKUS_RISK, player); - AddGossipItemFor(player, GOSSIP_MENU_ORKUS_2, 0, - GOSSIP_SENDER_MAIN, GOSSIP_ACTION_ORKUS_2); - SendGossipMenuFor(player, DEFAULT_GOSSIP_MESSAGE, me->GetGUID()); - return true; - - case GOSSIP_ACTION_ORKUS_2: - Talk(SAY_ORKUS_WILL_DO_IT, player); - AddGossipItemFor(player, GOSSIP_MENU_ORKUS_3, 0, - GOSSIP_SENDER_MAIN, GOSSIP_ACTION_ORKUS_3); - SendGossipMenuFor(player, DEFAULT_GOSSIP_MESSAGE, me->GetGUID()); - return true; - - case GOSSIP_ACTION_ORKUS_3: - Talk(SAY_ORKUS_FAREWELL, player); - player->KilledMonsterCredit(NPC_KINGSLAYER_ORKUS); - CloseGossipMenuFor(player); - vehicle->AI()->DoAction(ACTION_ORKUS_DONE); - return true; - - case GOSSIP_ACTION_JOHNNY_1: - Talk(SAY_JOHNNY_WHAT_ELSE, player); - AddGossipItemFor(player, GOSSIP_MENU_JOHNNY_2, 0, - GOSSIP_SENDER_MAIN, GOSSIP_ACTION_JOHNNY_2); - SendGossipMenuFor(player, DEFAULT_GOSSIP_MESSAGE, me->GetGUID()); - return true; - - case GOSSIP_ACTION_JOHNNY_2: - Talk(SAY_JOHNNY_ONE_QUEST, player); - AddGossipItemFor(player, GOSSIP_MENU_JOHNNY_3, 0, - GOSSIP_SENDER_MAIN, GOSSIP_ACTION_JOHNNY_3); - SendGossipMenuFor(player, DEFAULT_GOSSIP_MESSAGE, me->GetGUID()); - return true; - - case GOSSIP_ACTION_JOHNNY_3: - Talk(SAY_JOHNNY_FAREWELL, player); - player->KilledMonsterCredit(NPC_JOHNNY_AWESOME); - CloseGossipMenuFor(player); - vehicle->AI()->DoAction(ACTION_JOHNNY_DONE); - return true; - - default: - return false; - } - } - -private: - bool IsValidScenePlayer(Player* player) const - { - if (!player || player->GetQuestStatus(QUEST_WELCOME_TO_THE_MACHINE) != QUEST_STATUS_INCOMPLETE) - return false; - - Creature* vehicle = player->GetVehicleCreatureBase(); - return vehicle && vehicle->GetEntry() == NPC_SKELETAL_STEED; - } -}; - -} - -void AddSC_hillsbrad_foothills() -{ - using namespace HillsbradFoothills; - RegisterCreatureAI(npc_brazie_the_bonatist_vehicle); - RegisterCreatureAI(npc_brazie_fertilitize_o_tron_2000); - RegisterCreatureAI(npc_brazie_spot); - RegisterCreatureAI(npc_brazie_zombie); - RegisterCreatureAI(npc_brazie_vehicle_notifier); - RegisterCreatureAI(npc_hillsbrad_welcome_steed); - RegisterCreatureAI(npc_hillsbrad_welcome_actor); - RegisterSpellScript(spell_brazie_summon_plant); - RegisterSpellScript(spell_brazie_highlight); - RegisterSpellScript(spell_brazie_create_random_seed_sack); - RegisterSpellScript(spell_brazie_spit); -}