/* * Copyright (C) 2016+ AzerothCore , * released under GNU AGPL v3 license: * https://github.com/azerothcore/azerothcore-wotlk/blob/master/LICENSE-AGPL3 */ #include "PlayerGuideMgr.h" #include "../IndividualProgression.h" #include "Config.h" #include "DatabaseEnv.h" #include "Field.h" #include "Log.h" #include "Player.h" #include "QueryResult.h" #include "QuestDef.h" #include #include #include #include #include #include namespace MoonWell::PlayerGuide { namespace { std::string EscapeJson(std::string const& in) { std::string out; out.reserve(in.size() + 2); for (unsigned char c : in) { switch (c) { case '"': out += "\\\""; break; case '\\': out += "\\\\"; break; case '\b': out += "\\b"; break; case '\f': out += "\\f"; break; case '\n': out += "\\n"; break; case '\r': out += "\\r"; break; case '\t': out += "\\t"; break; default: if (c < 0x20) { char buf[8]; std::snprintf(buf, sizeof(buf), "\\u%04x", c); out += buf; } else out += static_cast(c); break; } } return out; } // Writes "key":number when value > 0. void WriteOptUInt(std::ostringstream& s, char const* key, uint32 v, bool& first) { if (!v) return; if (!first) s << ','; s << '"' << key << "\":" << v; first = false; } void WriteString(std::ostringstream& s, char const* key, std::string const& v, bool& first) { if (!first) s << ','; s << '"' << key << "\":\"" << EscapeJson(v) << '"'; first = false; } void WriteUInt(std::ostringstream& s, char const* key, uint32 v, bool& first) { if (!first) s << ','; s << '"' << key << "\":" << v; first = false; } void WriteBool(std::ostringstream& s, char const* key, bool v, bool& first) { if (!first) s << ','; s << '"' << key << "\":" << (v ? "true" : "false"); first = false; } // Maps an IPP ProgressionState into a target stage (1..N). // The stage ids are kept stable so the client can key // localisations off of them. struct StageInfo { uint32 id; char const* name; char const* description; }; StageInfo const STAGES[] = { { 1, "Начало пути", "Завершите низкоуровневое содержимое родного континента." }, { 2, "Огненные Недра", "Победите Рагнароса в Огненных Недрах." }, { 3, "Логово Ониксии", "Победите Ониксию." }, { 4, "Логово Крыла Тьмы", "Победите Нефариана в Логове Крыла Тьмы." }, { 5, "Подготовка к АQ", "Завершите подготовку к открытию ворот Ан'Киража." }, { 6, "Военный поход АQ", "Окажите помощь военным усилиям в Силитусе." }, { 7, "Храм Ан'Киража", "Победите Ц'Туна в Храме Ан'Киража." }, { 8, "Наксрамас (40)", "Победите КелТузада в Наксрамасе." }, { 9, "Подготовка к Запределью", "Откройте Тёмный портал и подготовьтесь к TBC." }, { 10, "Каражан и Гру'ул", "Завершите первый ярус рейдов TBC." }, { 11, "Пещера Змеиных Недр / Око Бури", "Завершите рейды второго яруса TBC." }, { 12, "Вершина Хиджала и Чёрный храм", "Победите Иллидана в Чёрном храме." }, { 13, "Зул'Аман", "Получите доступ к Зул'Аману и завершите его." }, { 14, "Плато Солнечного Колодца", "Победите Кил'джедена на Плато Солнечного Колодца." }, { 15, "Наксрамас (Нордскол)", "Завершите рейды первого яруса WotLK." }, { 16, "Ульдуар", "Победите Йогг-Сарона в Ульдуаре." }, { 17, "Испытание крестоносца", "Завершите Испытание крестоносца." }, { 18, "Ледяная Корона", "Победите Короля-лича." }, { 19, "Рубиновое святилище", "Победите Халиона в Рубиновом святилище." } }; StageInfo const* FindStage(uint32 id) { for (StageInfo const& s : STAGES) if (s.id == id) return &s; return nullptr; } // Tiered dungeon recommendations matched to the player level/stage. struct DungeonRec { uint32 stage; // recommended starting from this stage uint32 maxStage; // hidden after this stage (inclusive) uint8 minLevel; uint8 maxLevel; uint32 mapId; char const* title; }; DungeonRec const DUNGEONS[] = { { 1, 1, 15, 21, 36, "Мёртвые копи" }, { 1, 1, 17, 24, 33, "Курганы Иглошкурых" }, { 1, 2, 22, 30, 47, "Шахта Каражан" }, { 1, 2, 24, 32, 70, "Ульдаман" }, { 1, 3, 27, 38, 109, "Затонувший храм" }, { 1, 3, 30, 42, 209, "Зул'Фаррак" }, { 1, 4, 46, 54, 229, "Верхняя часть Чёрной горы" }, { 1, 4, 55, 60, 230, "Глубины Чёрной горы" }, { 1, 4, 58, 60, 533, "Наксрамас" }, { 9, 12, 58, 70, 540, "Кузня Крови" }, { 9, 12, 60, 70, 542, "Цитадель Адского пламени" }, { 9, 12, 65, 70, 553, "Ботаника" }, { 9, 12, 67, 70, 555, "Гробница Ткача Смерти" }, {14, 19, 68, 80, 574, "Утгард" }, {14, 19, 70, 80, 600, "Драк'Тарон" }, {14, 19, 72, 80, 619, "Ан'Кахет: Старое Королевство" }, {14, 19, 74, 80, 595, "Чертоги Камней" }, {14, 19, 76, 80, 658, "Очищение Стратхольма" } }; } Mgr* Mgr::instance() { static Mgr inst; return &inst; } void Mgr::LoadConfig() { _enabled = sConfigMgr->GetOption( "IndividualProgression.PlayerGuide.Enable", true); _logPayloads = sConfigMgr->GetOption( "IndividualProgression.PlayerGuide.LogPayloads", false); _chunkSize = sConfigMgr->GetOption( "IndividualProgression.PlayerGuide.ChunkSize", 230); // The addon channel hard-caps a single message body to 255 // characters. We leave room for the framing header. if (_chunkSize < 64) _chunkSize = 64; if (_chunkSize > 240) _chunkSize = 240; ReloadEncounterJournalCache(); } void Mgr::ReloadEncounterJournalCache() { _ejByMapId.clear(); // mod-encounter-journal may be disabled or its table absent. // The query then yields no result and we keep an empty cache; // recommendations that would have used EJ ids are skipped. QueryResult result = WorldDatabase.Query( "SELECT `id`, `map_id`, `name` " "FROM `custom_ej_instance`"); if (!result) { LOG_INFO("server.loading", ">> PlayerGuide: no custom_ej_instance rows found. " "Recommendations will be empty."); return; } do { Field* f = result->Fetch(); EjInstanceRef ref; ref.ejId = f[0].Get(); uint32 mapId = f[1].Get(); ref.name = f[2].Get(); // First entry wins. If the EJ has duplicates for the same // map (e.g. heroic variants) the lowest id is taken via the // INSERT order; we don't reorder. _ejByMapId.emplace(mapId, std::move(ref)); } while (result->NextRow()); LOG_INFO("server.loading", ">> PlayerGuide: cached {} EJ instance entries by mapId.", _ejByMapId.size()); } std::string Mgr::StageName(uint32 stage) const { StageInfo const* s = FindStage(stage); return s ? s->name : "Этап"; } std::string Mgr::StageDescription(uint32 stage) const { StageInfo const* s = FindStage(stage); return s ? s->description : ""; } void Mgr::AppendLevelObjective(Payload& out, Player* player, uint32 requiredLevel, uint32 order) const { Objective o; o.id = order; o.type = "level"; char buf[64]; std::snprintf(buf, sizeof(buf), "Достигнуть %u уровня", requiredLevel); o.title = buf; o.progress = player->GetLevel(); o.required = requiredLevel; o.completed = o.progress >= o.required; o.targetId = requiredLevel; o.order = order; out.objectives.push_back(std::move(o)); } void Mgr::AppendQuestObjective(Payload& out, Player* player, uint32 questId, std::string const& title, uint32 order) const { QuestStatus st = player->GetQuestStatus(questId); Objective o; o.id = order; o.type = "quest"; o.title = title; o.required = 1; o.progress = (st == QUEST_STATUS_REWARDED || st == QUEST_STATUS_COMPLETE) ? 1 : 0; o.completed = (st == QUEST_STATUS_REWARDED); o.targetId = questId; o.questId = questId; o.order = order; out.objectives.push_back(std::move(o)); } void Mgr::AppendDungeonObjective(Payload& out, uint32 mapId, std::string const& title, uint32 order) const { auto it = _ejByMapId.find(mapId); if (it == _ejByMapId.end()) return; // no EJ entry -> skip rather than emit a stub. Objective o; o.id = order; o.type = "dungeon"; o.title = it->second.name.empty() ? title : it->second.name; o.required = 1; o.targetId = it->second.ejId; // primary id for click o.ejInstanceId = it->second.ejId; // JOURNALINSTANCE key o.mapId = mapId; // WoW map id (auxiliary) o.order = order; out.objectives.push_back(std::move(o)); } void Mgr::AppendRaidObjective(Payload& out, uint32 mapId, std::string const& title, uint32 order) const { auto it = _ejByMapId.find(mapId); if (it == _ejByMapId.end()) return; Objective o; o.id = order; o.type = "raid"; o.title = it->second.name.empty() ? title : it->second.name; o.required = 1; o.targetId = it->second.ejId; o.ejInstanceId = it->second.ejId; o.mapId = mapId; o.order = order; out.objectives.push_back(std::move(o)); } void Mgr::AppendRecommendations(Payload& out, Player* player) const { uint8 level = player->GetLevel(); uint32 stage = out.stageId; std::unordered_set seen; uint32 recId = 1; for (DungeonRec const& d : DUNGEONS) { if (stage < d.stage || stage > d.maxStage) continue; if (level + 2 < d.minLevel || level > d.maxLevel + 1) continue; // Resolve EJ id from custom_ej_instance. If the EJ does // not know about this map, we cannot link to a card, so // we skip the recommendation entirely. This keeps the // contract that recommendations.instanceID is always a // valid EJ id. auto it = _ejByMapId.find(d.mapId); if (it == _ejByMapId.end()) continue; if (!seen.insert(it->second.ejId).second) continue; Recommendation r; r.id = recId++; r.type = "dungeon"; r.title = it->second.name.empty() ? d.title : it->second.name; r.ejInstanceId = it->second.ejId; // JOURNALINSTANCE key r.mapId = d.mapId; // WoW map id (auxiliary) r.priority = 100 - static_cast(std::abs(int32(level) - int32((d.minLevel + d.maxLevel) / 2))); r.description = "Подходит для вашего текущего этапа."; out.recommendations.push_back(std::move(r)); if (out.recommendations.size() >= 6) break; } } static uint32 ProgressFor(uint32 stage, uint8 level) { // Rough %: scaled across the three expansion bands. uint32 p = 0; if (stage < PROGRESSION_PRE_TBC) p = std::min(60u, (level * 60u) / 60u); else if (stage < PROGRESSION_WOTLK_TIER_1) p = 60u + std::min(20u, ((level > 60 ? level - 60 : 0) * 20u) / 10u); else p = 80u + std::min(20u, ((level > 70 ? level - 70 : 0) * 20u) / 10u); // Add a small bump per cleared major raid stage above 1. if (stage > 1) p = std::min(100u, p + (stage - 1) * 2u); return p; } Payload Mgr::BuildFor(Player* player) const { Payload out; out.version = PAYLOAD_VERSION; out.characterGuid = player->GetGUID().GetRawValue(); // ProgressionState as set by IPP. Default is 0 (start of vanilla). uint32 ipStage = player->GetPlayerSetting( "mod-individual-progression", SETTING_PROGRESSION_STATE).value; // Player's *next* stage is what the guide should be helping with. // STAGES use 1-based ids where stage id = ProgressionState+1. out.stageId = std::min(ipStage + 1, 19); out.stageName = StageName(out.stageId); out.stageDescription = StageDescription(out.stageId); out.progress = ProgressFor(ipStage, player->GetLevel()); uint32 order = 1; switch (ipStage) { case PROGRESSION_START: AppendLevelObjective(out, player, 60, order++); AppendRaidObjective(out, 409, "Победить Рагнароса в Огненных Недрах", order++); break; case PROGRESSION_MOLTEN_CORE: AppendLevelObjective(out, player, 60, order++); AppendRaidObjective(out, 249, "Победить Ониксию", order++); AppendRaidObjective(out, 469, "Победить Нефариана в Логове Крыла Тьмы", order++); break; case PROGRESSION_ONYXIA: AppendRaidObjective(out, 469, "Победить Нефариана в Логове Крыла Тьмы", order++); AppendRaidObjective(out, 309, "Зул'Гуруб (опционально)", order++); break; case PROGRESSION_BLACKWING_LAIR: AppendQuestObjective(out, player, MIGHT_OF_KALIMDOR, "Сдать ресурсы для военного похода", order++); AppendQuestObjective(out, player, BANG_A_GONG, "Открыть ворота Ан'Киража", order++); break; case PROGRESSION_PRE_AQ: AppendQuestObjective(out, player, CHAOS_AND_DESTRUCTION, "Завершить полевые задания в Силитусе", order++); AppendRaidObjective(out, 509, "Руины Ан'Киража", order++); break; case PROGRESSION_AQ_WAR: AppendRaidObjective(out, 531, "Победить Ц'Туна в Храме Ан'Киража", order++); break; case PROGRESSION_AQ: AppendRaidObjective(out, 533, "Победить КелТузада в Наксрамасе", order++); break; case PROGRESSION_NAXX40: AppendQuestObjective(out, player, INTO_THE_BREACH, "Открыть Тёмный портал", order++); AppendLevelObjective(out, player, 70, order++); break; case PROGRESSION_PRE_TBC: AppendLevelObjective(out, player, 70, order++); AppendRaidObjective(out, 532, "Каражан", order++); AppendRaidObjective(out, 565, "Логово Груула", order++); AppendRaidObjective(out, 544, "Логово Магтеридона", order++); break; case PROGRESSION_TBC_TIER_1: AppendRaidObjective(out, 548, "Пещера Змеиных Недр", order++); AppendRaidObjective(out, 550, "Око Бури — Крепость Бурь", order++); break; case PROGRESSION_TBC_TIER_2: AppendRaidObjective(out, 534, "Битва за гору Хиджал", order++); AppendRaidObjective(out, 564, "Чёрный храм", order++); break; case PROGRESSION_TBC_TIER_3: AppendRaidObjective(out, 568, "Зул'Аман", order++); break; case PROGRESSION_TBC_TIER_4: AppendRaidObjective(out, 580, "Плато Солнечного Колодца", order++); break; case PROGRESSION_TBC_TIER_5: AppendLevelObjective(out, player, 80, order++); AppendRaidObjective(out, 533, "Наксрамас (Нордскол)", order++); AppendRaidObjective(out, 616, "Око Вечности", order++); AppendRaidObjective(out, 615, "Камера Сокровищ Обсидиановой драконьей стаи", order++); break; case PROGRESSION_WOTLK_TIER_1: AppendRaidObjective(out, 603, "Победить Йогг-Сарона в Ульдуаре", order++); break; case PROGRESSION_WOTLK_TIER_2: AppendRaidObjective(out, 649, "Испытание крестоносца", order++); break; case PROGRESSION_WOTLK_TIER_3: AppendRaidObjective(out, 631, "Цитадель Ледяной Короны", order++); break; case PROGRESSION_WOTLK_TIER_4: AppendRaidObjective(out, 724, "Рубиновое святилище", order++); break; default: break; } AppendRecommendations(out, player); return out; } std::string Mgr::SerializeJson(Objective const& o) const { std::ostringstream s; s << '{'; bool first = true; WriteUInt(s, "id", o.id, first); WriteString(s, "type", o.type, first); WriteString(s, "title", o.title, first); if (!o.description.empty()) WriteString(s, "description", o.description, first); WriteBool(s, "completed", o.completed, first); WriteUInt(s, "progress", o.progress, first); WriteUInt(s, "required", o.required, first); WriteUInt(s, "targetID", o.targetId, first); WriteUInt(s, "order", o.order, first); WriteOptUInt(s, "questID", o.questId, first); WriteOptUInt(s, "questChainID", o.questChainId, first); WriteOptUInt(s, "ej_instanceID", o.ejInstanceId, first); WriteOptUInt(s, "mapID", o.mapId, first); WriteOptUInt(s, "encounterID", o.encounterId, first); WriteOptUInt(s, "achievementID", o.achievementId, first); WriteOptUInt(s, "factionID", o.factionId, first); WriteOptUInt(s, "professionID", o.professionId, first); WriteOptUInt(s, "itemID", o.itemId, first); WriteOptUInt(s, "currencyID", o.currencyId, first); s << '}'; return s.str(); } std::string Mgr::SerializeJson(Payload const& p) const { std::ostringstream s; s << '{'; s << "\"version\":" << p.version << ",\"characterGuid\":" << p.characterGuid << ",\"stageID\":" << p.stageId << ",\"stageName\":\"" << EscapeJson(p.stageName) << '"' << ",\"stageDescription\":\"" << EscapeJson(p.stageDescription) << '"' << ",\"progress\":" << std::min(p.progress, 100u); // Objectives: collapse duplicate ids and clamp progress. s << ",\"objectives\":["; std::set seenIds; bool firstObj = true; for (Objective const& src : p.objectives) { if (!seenIds.insert(src.id).second) continue; Objective o = src; if (o.required && o.progress > o.required) o.progress = o.required; if (!firstObj) s << ','; s << SerializeJson(o); firstObj = false; } s << "]"; // Recommendations. s << ",\"recommendations\":["; bool firstRec = true; for (Recommendation const& r : p.recommendations) { if (!firstRec) s << ','; s << '{'; bool f = true; WriteUInt(s, "id", r.id, f); WriteString(s, "type", r.type, f); WriteString(s, "title", r.title, f); if (!r.description.empty()) WriteString(s, "description", r.description, f); WriteOptUInt(s, "ej_instanceID", r.ejInstanceId, f); WriteOptUInt(s, "mapID", r.mapId, f); WriteOptUInt(s, "encounterID", r.encounterId, f); WriteOptUInt(s, "questID", r.questId, f); WriteOptUInt(s, "itemID", r.itemId, f); WriteUInt(s, "priority", r.priority, f); s << '}'; firstRec = false; } s << "]"; // Rewards. s << ",\"rewards\":["; bool firstRew = true; for (Reward const& r : p.rewards) { if (!firstRew) s << ','; s << '{'; bool f = true; WriteString(s, "type", r.type, f); WriteOptUInt(s, "itemID", r.itemId, f); WriteOptUInt(s, "currencyID", r.currencyId, f); WriteUInt(s, "count", std::max(r.count, 1), f); WriteOptUInt(s, "amount", r.amount, f); s << '}'; firstRew = false; } s << "]"; s << '}'; return s.str(); } }