Files
moonwell-core/modules/mod-individual-progression/src/playerGuide/PlayerGuideMgr.cpp
T

650 lines
25 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* Copyright (C) 2016+ AzerothCore <www.azerothcore.org>,
* 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 <algorithm>
#include <cstdio>
#include <cstdlib>
#include <set>
#include <sstream>
#include <unordered_set>
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<char>(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<bool>(
"IndividualProgression.PlayerGuide.Enable", true);
_logPayloads = sConfigMgr->GetOption<bool>(
"IndividualProgression.PlayerGuide.LogPayloads", false);
_chunkSize = sConfigMgr->GetOption<uint32>(
"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>();
uint32 mapId = f[1].Get<uint32>();
ref.name = f[2].Get<std::string>();
// 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<uint32> 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<uint32>(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<uint32>(60u, (level * 60u) / 60u);
else if (stage < PROGRESSION_WOTLK_TIER_1)
p = 60u + std::min<uint32>(20u,
((level > 60 ? level - 60 : 0) * 20u) / 10u);
else
p = 80u + std::min<uint32>(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<uint32>(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<uint32>(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<uint32>(p.progress, 100u);
// Objectives: collapse duplicate ids and clamp progress.
s << ",\"objectives\":[";
std::set<uint32> 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<uint32>(r.count, 1), f);
WriteOptUInt(s, "amount", r.amount, f);
s << '}';
firstRew = false;
}
s << "]";
s << '}';
return s.str();
}
}