encounter journal base logic
This commit is contained in:
@@ -336,3 +336,40 @@ IndividualProgression.ExcludeAccounts = 1
|
||||
# Default: "^RNDBOT.*"
|
||||
#
|
||||
IndividualProgression.ExcludedAccountsRegex = "^RNDBOT.*"
|
||||
|
||||
########################################
|
||||
# Player Guide (Путеводитель)
|
||||
########################################
|
||||
|
||||
# IndividualProgression.PlayerGuide.Enable
|
||||
#
|
||||
# Description: Enable server-side Player Guide payload generation
|
||||
# and addon-channel delivery. When disabled the
|
||||
# module does not push any data to clients and
|
||||
# ignores client commands.
|
||||
#
|
||||
# Default: 1 - Enabled
|
||||
# 0 - Disabled
|
||||
#
|
||||
IndividualProgression.PlayerGuide.Enable = 1
|
||||
|
||||
# IndividualProgression.PlayerGuide.ChunkSize
|
||||
#
|
||||
# Description: Maximum size, in bytes, of a single addon-channel
|
||||
# chunk used to deliver guide payloads. Lower values
|
||||
# produce more chunks; higher values risk hitting
|
||||
# the 255-byte client limit. Clamped to [64, 240].
|
||||
#
|
||||
# Default: 230
|
||||
#
|
||||
IndividualProgression.PlayerGuide.ChunkSize = 230
|
||||
|
||||
# IndividualProgression.PlayerGuide.LogPayloads
|
||||
#
|
||||
# Description: Emit a debug log line for each payload pushed to
|
||||
# a client. Useful when developing the client UI.
|
||||
#
|
||||
# Default: 0 - Disabled
|
||||
# 1 - Enabled
|
||||
#
|
||||
IndividualProgression.PlayerGuide.LogPayloads = 0
|
||||
|
||||
@@ -42,6 +42,7 @@ void AddSC_ipp_spell_scripts();
|
||||
void AddSC_individualProgression_commandscript();
|
||||
void AddSC_mod_individual_progression_awareness();
|
||||
void AddSC_mod_individual_progression_player();
|
||||
void AddSC_mod_individual_progression_player_guide();
|
||||
void AddSC_npc_archmage_timear();
|
||||
void AddSC_karazhan_70();
|
||||
void AddSC_the_eye_70();
|
||||
@@ -88,6 +89,7 @@ void Addmod_individual_progressionScripts()
|
||||
AddSC_individualProgression_commandscript();
|
||||
AddSC_mod_individual_progression_awareness();
|
||||
AddSC_mod_individual_progression_player();
|
||||
AddSC_mod_individual_progression_player_guide();
|
||||
AddSC_npc_archmage_timear();
|
||||
AddSC_karazhan_70();
|
||||
AddSC_the_eye_70();
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
/*
|
||||
* 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 "Log.h"
|
||||
#include "Player.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;
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
Objective o;
|
||||
o.id = order;
|
||||
o.type = "dungeon";
|
||||
o.title = title;
|
||||
o.required = 1;
|
||||
o.targetId = mapId;
|
||||
o.instanceId = mapId;
|
||||
o.order = order;
|
||||
out.objectives.push_back(std::move(o));
|
||||
}
|
||||
|
||||
void Mgr::AppendRaidObjective(Payload& out, uint32 mapId,
|
||||
std::string const& title,
|
||||
uint32 order) const
|
||||
{
|
||||
Objective o;
|
||||
o.id = order;
|
||||
o.type = "raid";
|
||||
o.title = title;
|
||||
o.required = 1;
|
||||
o.targetId = mapId;
|
||||
o.instanceId = 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;
|
||||
if (!seen.insert(d.mapId).second)
|
||||
continue;
|
||||
|
||||
Recommendation r;
|
||||
r.id = recId++;
|
||||
r.type = "dungeon";
|
||||
r.title = d.title;
|
||||
r.instanceId = d.mapId;
|
||||
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, "instanceID", o.instanceId, 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, "instanceID", r.instanceId, 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Copyright (C) 2016+ AzerothCore <www.azerothcore.org>,
|
||||
* released under GNU AGPL v3 license:
|
||||
* https://github.com/azerothcore/azerothcore-wotlk/blob/master/LICENSE-AGPL3
|
||||
*/
|
||||
|
||||
#ifndef MOONWELL_PLAYER_GUIDE_MGR_H
|
||||
#define MOONWELL_PLAYER_GUIDE_MGR_H
|
||||
|
||||
#include "Define.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
class Player;
|
||||
|
||||
namespace MoonWell::PlayerGuide
|
||||
{
|
||||
enum : uint32 { PAYLOAD_VERSION = 1 };
|
||||
|
||||
struct Objective
|
||||
{
|
||||
uint32 id = 0;
|
||||
std::string type; // level | quest | quest_chain | dungeon |
|
||||
// raid | boss | achievement |
|
||||
// reputation | profession | item |
|
||||
// currency | custom
|
||||
std::string title;
|
||||
std::string description;
|
||||
bool completed = false;
|
||||
uint32 progress = 0;
|
||||
uint32 required = 0;
|
||||
uint32 targetId = 0;
|
||||
uint32 order = 0;
|
||||
|
||||
// Optional type-specific helpers. Zero means "not set" and
|
||||
// serialiser omits it.
|
||||
uint32 questId = 0;
|
||||
uint32 questChainId = 0;
|
||||
uint32 instanceId = 0;
|
||||
uint32 encounterId = 0;
|
||||
uint32 achievementId = 0;
|
||||
uint32 factionId = 0;
|
||||
uint32 professionId = 0;
|
||||
uint32 itemId = 0;
|
||||
uint32 currencyId = 0;
|
||||
};
|
||||
|
||||
struct Recommendation
|
||||
{
|
||||
uint32 id = 0;
|
||||
std::string type;
|
||||
std::string title;
|
||||
std::string description;
|
||||
uint32 instanceId = 0;
|
||||
uint32 encounterId = 0;
|
||||
uint32 questId = 0;
|
||||
uint32 itemId = 0;
|
||||
uint32 priority = 0;
|
||||
};
|
||||
|
||||
struct Reward
|
||||
{
|
||||
std::string type;
|
||||
uint32 itemId = 0;
|
||||
uint32 count = 1;
|
||||
uint32 currencyId = 0;
|
||||
uint32 amount = 0;
|
||||
};
|
||||
|
||||
struct Payload
|
||||
{
|
||||
uint32 version = PAYLOAD_VERSION;
|
||||
uint64 characterGuid = 0;
|
||||
|
||||
uint32 stageId = 0;
|
||||
std::string stageName;
|
||||
std::string stageDescription;
|
||||
uint32 progress = 0; // 0..100
|
||||
|
||||
std::vector<Objective> objectives;
|
||||
std::vector<Recommendation> recommendations;
|
||||
std::vector<Reward> rewards;
|
||||
};
|
||||
|
||||
class Mgr
|
||||
{
|
||||
public:
|
||||
static Mgr* instance();
|
||||
|
||||
// Reads configuration. Safe to call multiple times.
|
||||
void LoadConfig();
|
||||
|
||||
// Builds the per-character guide payload from IPP state.
|
||||
// The function is read-only with respect to the player.
|
||||
Payload BuildFor(Player* player) const;
|
||||
|
||||
// Serialises a payload to a single JSON string.
|
||||
// Validates uniqueness of objective ids and clamps progress.
|
||||
std::string SerializeJson(Payload const& payload) const;
|
||||
std::string SerializeJson(Objective const& objective) const;
|
||||
|
||||
bool IsEnabled() const { return _enabled; }
|
||||
bool LogPayloads() const { return _logPayloads; }
|
||||
uint32 ChunkSize() const { return _chunkSize; }
|
||||
|
||||
private:
|
||||
Mgr() = default;
|
||||
|
||||
// Stage helpers based on ProgressionState.
|
||||
std::string StageName(uint32 stage) const;
|
||||
std::string StageDescription(uint32 stage) const;
|
||||
|
||||
// Filling helpers.
|
||||
void AppendLevelObjective(Payload& out, Player* player,
|
||||
uint32 requiredLevel, uint32 order) const;
|
||||
void AppendQuestObjective(Payload& out, Player* player,
|
||||
uint32 questId, std::string const& title,
|
||||
uint32 order) const;
|
||||
void AppendDungeonObjective(Payload& out, uint32 mapId,
|
||||
std::string const& title,
|
||||
uint32 order) const;
|
||||
void AppendRaidObjective(Payload& out, uint32 mapId,
|
||||
std::string const& title,
|
||||
uint32 order) const;
|
||||
|
||||
void AppendRecommendations(Payload& out, Player* player) const;
|
||||
|
||||
bool _enabled = true;
|
||||
bool _logPayloads = false;
|
||||
uint32 _chunkSize = 230; // per-frame body capacity (addon channel)
|
||||
};
|
||||
}
|
||||
|
||||
#define sPlayerGuideMgr ::MoonWell::PlayerGuide::Mgr::instance()
|
||||
|
||||
#endif // MOONWELL_PLAYER_GUIDE_MGR_H
|
||||
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* 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 "PlayerGuideTransport.h"
|
||||
|
||||
#include "../IndividualProgression.h"
|
||||
|
||||
#include "Chat.h"
|
||||
#include "Config.h"
|
||||
#include "Creature.h"
|
||||
#include "Log.h"
|
||||
#include "Player.h"
|
||||
#include "QuestDef.h"
|
||||
#include "ScriptMgr.h"
|
||||
#include "SharedDefines.h"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
|
||||
namespace MoonWell::PlayerGuide
|
||||
{
|
||||
namespace
|
||||
{
|
||||
void Push(Player* player)
|
||||
{
|
||||
if (!sPlayerGuideMgr->IsEnabled())
|
||||
return;
|
||||
if (!player || !player->GetSession() || player->IsBeingTeleported())
|
||||
return;
|
||||
|
||||
Payload p = sPlayerGuideMgr->BuildFor(player);
|
||||
std::string json = sPlayerGuideMgr->SerializeJson(p);
|
||||
|
||||
if (sPlayerGuideMgr->LogPayloads())
|
||||
LOG_DEBUG("module", "[PlayerGuide] push {} bytes -> {}",
|
||||
json.size(), player->GetName());
|
||||
|
||||
Transport::SendSetData(player, json);
|
||||
}
|
||||
|
||||
// Splits "MWPG\t<cmd>\t<arg>" -> (cmd, arg). Returns false if
|
||||
// the message does not match the expected addon prefix.
|
||||
bool ParseClientMessage(std::string const& in, std::string& cmd,
|
||||
std::string& arg)
|
||||
{
|
||||
// Expected prefix: "MWPG\t".
|
||||
if (in.size() < 6)
|
||||
return false;
|
||||
if (in.compare(0, 4, ADDON_PREFIX) != 0)
|
||||
return false;
|
||||
if (in[4] != '\t')
|
||||
return false;
|
||||
|
||||
std::size_t end = in.find('\t', 5);
|
||||
if (end == std::string::npos)
|
||||
{
|
||||
cmd.assign(in, 5, in.size() - 5);
|
||||
arg.clear();
|
||||
return true;
|
||||
}
|
||||
cmd.assign(in, 5, end - 5);
|
||||
arg.assign(in, end + 1, in.size() - (end + 1));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class PlayerGuide_PlayerScript : public PlayerScript
|
||||
{
|
||||
public:
|
||||
PlayerGuide_PlayerScript()
|
||||
: PlayerScript("PlayerGuide_PlayerScript",
|
||||
{
|
||||
PLAYERHOOK_ON_LOGIN,
|
||||
PLAYERHOOK_ON_LEVEL_CHANGED,
|
||||
PLAYERHOOK_ON_PLAYER_COMPLETE_QUEST,
|
||||
PLAYERHOOK_ON_CREATURE_KILL,
|
||||
PLAYERHOOK_ON_BEFORE_SEND_CHAT_MESSAGE
|
||||
}) { }
|
||||
|
||||
void OnPlayerLogin(Player* player) override
|
||||
{
|
||||
Push(player);
|
||||
}
|
||||
|
||||
void OnPlayerLevelChanged(Player* player, uint8 /*oldLevel*/) override
|
||||
{
|
||||
Push(player);
|
||||
}
|
||||
|
||||
void OnPlayerCompleteQuest(Player* player,
|
||||
Quest const* /*quest*/) override
|
||||
{
|
||||
Push(player);
|
||||
}
|
||||
|
||||
void OnPlayerCreatureKill(Player* killer, Creature* killed) override
|
||||
{
|
||||
if (!killer || !killed)
|
||||
return;
|
||||
if (killed->GetCreatureTemplate()->rank <= CREATURE_ELITE_NORMAL)
|
||||
return;
|
||||
Push(killer);
|
||||
}
|
||||
|
||||
void OnPlayerBeforeSendChatMessage(Player* player, uint32& type,
|
||||
uint32& lang, std::string& msg) override
|
||||
{
|
||||
if (!sPlayerGuideMgr->IsEnabled())
|
||||
return;
|
||||
if (type != CHAT_MSG_WHISPER || lang != LANG_ADDON)
|
||||
return;
|
||||
|
||||
if (sPlayerGuideMgr->LogPayloads())
|
||||
LOG_DEBUG("module",
|
||||
"[PlayerGuide] raw addon <- {} ({} bytes): '{}'",
|
||||
player->GetName(), msg.size(), msg);
|
||||
|
||||
std::string cmd, arg;
|
||||
if (!ParseClientMessage(msg, cmd, arg))
|
||||
return;
|
||||
|
||||
LOG_INFO("module",
|
||||
"[PlayerGuide] <- {} cmd='{}' arg='{}' ({} bytes)",
|
||||
player->GetName(), cmd, arg, msg.size());
|
||||
|
||||
if (cmd == ClientCmd::REQUEST_REFRESH)
|
||||
{
|
||||
Push(player);
|
||||
}
|
||||
else if (cmd == ClientCmd::REQUEST_TRACK_OBJECTIVE)
|
||||
{
|
||||
// Tracking is purely client-side state for now; we just
|
||||
// re-send the payload so the client can update its
|
||||
// tracked id atomically.
|
||||
Push(player);
|
||||
}
|
||||
else if (cmd == ClientCmd::REQUEST_OPEN_TARGET)
|
||||
{
|
||||
// Reserved for future: server-driven open-target action
|
||||
// (e.g. queue LFG). For MVP a refresh is enough.
|
||||
Push(player);
|
||||
}
|
||||
else
|
||||
{
|
||||
LOG_WARN("module",
|
||||
"[PlayerGuide] unknown cmd='{}' from {}",
|
||||
cmd, player->GetName());
|
||||
return;
|
||||
}
|
||||
|
||||
// Swallow the message so it does not get relayed as a real
|
||||
// whisper. Clearing the body is enough — ChatHandler
|
||||
// returns early on empty addon messages.
|
||||
msg.clear();
|
||||
}
|
||||
};
|
||||
|
||||
class PlayerGuide_WorldScript : public WorldScript
|
||||
{
|
||||
public:
|
||||
PlayerGuide_WorldScript()
|
||||
: WorldScript("PlayerGuide_WorldScript",
|
||||
{ WORLDHOOK_ON_AFTER_CONFIG_LOAD }) { }
|
||||
|
||||
void OnAfterConfigLoad(bool /*reload*/) override
|
||||
{
|
||||
sPlayerGuideMgr->LoadConfig();
|
||||
LOG_INFO("server.loading",
|
||||
">> PlayerGuide module: {}.",
|
||||
sPlayerGuideMgr->IsEnabled() ? "enabled" : "disabled");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
void AddSC_mod_individual_progression_player_guide()
|
||||
{
|
||||
new MoonWell::PlayerGuide::PlayerGuide_PlayerScript();
|
||||
new MoonWell::PlayerGuide::PlayerGuide_WorldScript();
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright (C) 2016+ AzerothCore <www.azerothcore.org>,
|
||||
* released under GNU AGPL v3 license:
|
||||
* https://github.com/azerothcore/azerothcore-wotlk/blob/master/LICENSE-AGPL3
|
||||
*/
|
||||
|
||||
#include "PlayerGuideTransport.h"
|
||||
#include "PlayerGuideMgr.h"
|
||||
|
||||
#include "Chat.h"
|
||||
#include "Player.h"
|
||||
#include "WorldPacket.h"
|
||||
#include "WorldSession.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
|
||||
namespace MoonWell::PlayerGuide
|
||||
{
|
||||
namespace
|
||||
{
|
||||
// The client-visible chat body must stay strictly below the
|
||||
// 255-byte addon limit. We reserve room for the framing header.
|
||||
// The mgr clamps its configured chunk size into a safe range.
|
||||
constexpr std::size_t HARD_LIMIT = 250;
|
||||
|
||||
void SendOneChunk(Player* player, std::string_view command,
|
||||
uint32 seq, uint32 total, std::string_view body)
|
||||
{
|
||||
std::string msg;
|
||||
msg.reserve(ADDON_PREFIX[0] ? body.size() + 32 : body.size());
|
||||
msg.append(ADDON_PREFIX);
|
||||
msg.push_back('\t');
|
||||
msg.append(command.data(), command.size());
|
||||
msg.push_back('\t');
|
||||
|
||||
char buf[24];
|
||||
std::snprintf(buf, sizeof(buf), "%u\t%u\t", seq, total);
|
||||
msg.append(buf);
|
||||
|
||||
msg.append(body.data(), body.size());
|
||||
|
||||
if (msg.size() > HARD_LIMIT)
|
||||
msg.resize(HARD_LIMIT);
|
||||
|
||||
WorldPacket data;
|
||||
ChatHandler::BuildChatPacket(data, CHAT_MSG_WHISPER,
|
||||
LANG_ADDON, player, player, msg);
|
||||
player->GetSession()->SendPacket(&data);
|
||||
}
|
||||
}
|
||||
|
||||
void Transport::Send(Player* player, std::string_view command,
|
||||
std::string_view body)
|
||||
{
|
||||
if (!player || !player->GetSession())
|
||||
return;
|
||||
|
||||
// How much body fits into a chunk after framing.
|
||||
// Framing = prefix(4) + '\t' + cmd(N) + '\t' + "65535\t65535\t".
|
||||
// Use Mgr's configured chunk size when sensible.
|
||||
std::size_t framing = 4 + 1 + command.size() + 1 + 16;
|
||||
std::size_t budget = sPlayerGuideMgr->ChunkSize() > framing
|
||||
? (sPlayerGuideMgr->ChunkSize() - framing)
|
||||
: 64;
|
||||
|
||||
if (body.size() <= budget)
|
||||
{
|
||||
SendOneChunk(player, command, 1, 1, body);
|
||||
return;
|
||||
}
|
||||
|
||||
// Multi-chunk: split blindly by byte budget. JSON tolerates
|
||||
// concatenation on the client.
|
||||
uint32 total = static_cast<uint32>(
|
||||
(body.size() + budget - 1) / budget);
|
||||
uint32 seq = 1;
|
||||
for (std::size_t off = 0; off < body.size(); off += budget, ++seq)
|
||||
{
|
||||
std::size_t take = std::min(budget, body.size() - off);
|
||||
SendOneChunk(player, command, seq, total,
|
||||
std::string_view(body.data() + off, take));
|
||||
}
|
||||
}
|
||||
|
||||
void Transport::SendSetData(Player* player, std::string const& json)
|
||||
{
|
||||
Send(player, ServerCmd::SET_DATA, json);
|
||||
}
|
||||
|
||||
void Transport::SendNotify(Player* player, std::string const& text)
|
||||
{
|
||||
Send(player, ServerCmd::NOTIFY, text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright (C) 2016+ AzerothCore <www.azerothcore.org>,
|
||||
* released under GNU AGPL v3 license:
|
||||
* https://github.com/azerothcore/azerothcore-wotlk/blob/master/LICENSE-AGPL3
|
||||
*/
|
||||
|
||||
#ifndef MOONWELL_PLAYER_GUIDE_TRANSPORT_H
|
||||
#define MOONWELL_PLAYER_GUIDE_TRANSPORT_H
|
||||
|
||||
#include "Define.h"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
class Player;
|
||||
|
||||
namespace MoonWell::PlayerGuide
|
||||
{
|
||||
// Addon channel prefix used on both ends. The client AIO bridge
|
||||
// routes messages with this prefix to the MoonWellPlayerGuide
|
||||
// handler.
|
||||
constexpr char const* ADDON_PREFIX = "MWPG";
|
||||
|
||||
// Commands sent server -> client.
|
||||
namespace ServerCmd
|
||||
{
|
||||
constexpr char const* SET_DATA = "SetData";
|
||||
constexpr char const* SET_STAGE = "SetStage";
|
||||
constexpr char const* UPDATE_OBJECTIVE = "UpdateObjective";
|
||||
constexpr char const* NOTIFY = "Notify";
|
||||
}
|
||||
|
||||
// Commands sent client -> server.
|
||||
namespace ClientCmd
|
||||
{
|
||||
constexpr char const* REQUEST_REFRESH = "RequestRefresh";
|
||||
constexpr char const* REQUEST_TRACK_OBJECTIVE = "RequestTrackObjective";
|
||||
constexpr char const* REQUEST_OPEN_TARGET = "RequestOpenTarget";
|
||||
}
|
||||
|
||||
class Transport
|
||||
{
|
||||
public:
|
||||
// Sends a single logical message split into addon-channel
|
||||
// chunks. Wire format per chunk:
|
||||
// "MWPG\t<cmd>\t<seq>\t<total>\t<body>"
|
||||
// Single-chunk messages still use seq=1 total=1.
|
||||
static void Send(Player* player, std::string_view command,
|
||||
std::string_view body);
|
||||
|
||||
// Convenience wrappers.
|
||||
static void SendSetData(Player* player, std::string const& json);
|
||||
static void SendNotify(Player* player, std::string const& text);
|
||||
};
|
||||
}
|
||||
|
||||
#endif // MOONWELL_PLAYER_GUIDE_TRANSPORT_H
|
||||
Reference in New Issue
Block a user