Kelsidavis-WoWee/tools/editor/quest_editor.cpp
Kelsi 280fe1e6e8 fix(quest): truncate over-long title/desc/completion to SQL limits
quest_template.LogTitle is varchar(200) in AzerothCore. Edited
quest JSON could carry longer strings that would either fail the
INSERT or silently truncate at the server. Cap title at 200 chars
and the longer text fields at 8KB on load.
2026-05-06 07:01:00 -07:00

189 lines
7.5 KiB
C++

#include "quest_editor.hpp"
#include "core/logger.hpp"
#include <nlohmann/json.hpp>
#include <fstream>
#include <filesystem>
#include <unordered_set>
namespace wowee {
namespace editor {
void QuestEditor::addQuest(const Quest& q) {
Quest quest = q;
quest.id = nextId_++;
quests_.push_back(quest);
LOG_INFO("Quest added: [", quest.id, "] ", quest.title);
}
void QuestEditor::removeQuest(int index) {
if (index >= 0 && index < static_cast<int>(quests_.size()))
quests_.erase(quests_.begin() + index);
}
Quest* QuestEditor::getQuest(int index) {
if (index < 0 || index >= static_cast<int>(quests_.size())) return nullptr;
return &quests_[index];
}
bool QuestEditor::saveToFile(const std::string& path) const {
auto dir = std::filesystem::path(path).parent_path();
if (!dir.empty()) std::filesystem::create_directories(dir);
nlohmann::json arr = nlohmann::json::array();
for (const auto& q : quests_) {
nlohmann::json jq;
jq["id"] = q.id;
jq["title"] = q.title;
jq["description"] = q.description;
jq["completionText"] = q.completionText;
jq["requiredLevel"] = q.requiredLevel;
jq["questGiverNpcId"] = q.questGiverNpcId;
jq["turnInNpcId"] = q.turnInNpcId;
jq["nextQuestId"] = q.nextQuestId;
jq["reward"] = {{"xp", q.reward.xp}, {"gold", q.reward.gold},
{"silver", q.reward.silver}, {"copper", q.reward.copper}};
nlohmann::json items = nlohmann::json::array();
for (const auto& item : q.reward.itemRewards) items.push_back(item);
jq["reward"]["items"] = items;
nlohmann::json objs = nlohmann::json::array();
for (const auto& obj : q.objectives) {
objs.push_back({{"type", static_cast<int>(obj.type)},
{"desc", obj.description},
{"target", obj.targetName},
{"count", obj.targetCount}});
}
jq["objectives"] = objs;
arr.push_back(jq);
}
std::ofstream f(path);
if (!f) return false;
f << arr.dump(2) << "\n";
LOG_INFO("Quests saved: ", path, " (", quests_.size(), " quests)");
return true;
}
bool QuestEditor::loadFromFile(const std::string& path) {
std::ifstream f(path);
if (!f) return false;
try {
nlohmann::json arr = nlohmann::json::parse(f);
if (!arr.is_array()) return false;
quests_.clear();
uint32_t maxId = 0;
for (const auto& jq : arr) {
Quest q;
q.id = jq.value("id", 0u);
q.title = jq.value("title", "Untitled");
q.description = jq.value("description", "");
q.completionText = jq.value("completionText", "");
// AzerothCore quest_template.LogTitle is varchar(200); the
// Description/QuestCompletionLog are text but practically capped.
// Truncate edited values so SQL writes don't get rejected by
// length constraints or bloat the export.
if (q.title.size() > 200) q.title.resize(200);
if (q.description.size() > 8192) q.description.resize(8192);
if (q.completionText.size() > 8192) q.completionText.resize(8192);
q.requiredLevel = jq.value("requiredLevel", 1u);
// WoW levels 1-80 (WotLK). Cap to keep AzerothCore happy and
// catch obvious typos like "999".
if (q.requiredLevel == 0) q.requiredLevel = 1;
if (q.requiredLevel > 255) q.requiredLevel = 80;
q.questGiverNpcId = jq.value("questGiverNpcId", 0u);
q.turnInNpcId = jq.value("turnInNpcId", 0u);
q.nextQuestId = jq.value("nextQuestId", 0u);
if (jq.contains("reward")) {
const auto& jr = jq["reward"];
q.reward.xp = jr.value("xp", 100u);
q.reward.gold = jr.value("gold", 0u);
q.reward.silver = jr.value("silver", 0u);
q.reward.copper = jr.value("copper", 0u);
// Reward sanity caps. Highest WoW quest XP ~50k; gold realistic
// cap is hundreds. Catches typo entries like "100000000 gold".
if (q.reward.xp > 1'000'000) q.reward.xp = 1'000'000;
if (q.reward.gold > 10000) q.reward.gold = 10000;
if (q.reward.silver > 99) q.reward.silver = 99;
if (q.reward.copper > 99) q.reward.copper = 99;
if (jr.contains("items") && jr["items"].is_array()) {
for (const auto& item : jr["items"]) {
// Cap item reward count to 6 (WoW quest_template
// RewardItemId[1..6] slot capacity).
if (q.reward.itemRewards.size() >= 6) break;
q.reward.itemRewards.push_back(item.get<std::string>());
}
}
}
if (jq.contains("objectives") && jq["objectives"].is_array()) {
for (const auto& jo : jq["objectives"]) {
QuestObjective obj;
int t = jo.value("type", 0);
// Clamp to known QuestObjectiveType range to avoid
// garbage enum values from edited JSON.
if (t < 0 || t > 5) t = 0;
obj.type = static_cast<QuestObjectiveType>(t);
obj.description = jo.value("desc", "");
obj.targetName = jo.value("target", "");
obj.targetCount = jo.value("count", 1u);
if (obj.targetCount == 0) obj.targetCount = 1;
if (obj.targetCount > 1000) obj.targetCount = 1000;
// Cap stored objectives to 10 (matches SQL slot capacity)
// — also bounds the per-quest memory.
if (q.objectives.size() >= 10) break;
q.objectives.push_back(obj);
}
}
if (q.id > maxId) maxId = q.id;
quests_.push_back(q);
}
nextId_ = maxId + 1;
LOG_INFO("Quests loaded: ", path, " (", quests_.size(), " quests)");
return true;
} catch (const std::exception& e) {
LOG_ERROR("Failed to load quests from ", path, ": ", e.what());
return false;
}
}
bool QuestEditor::validateChains(std::vector<std::string>& errors) const {
errors.clear();
std::unordered_set<uint32_t> validIds;
for (const auto& q : quests_) validIds.insert(q.id);
for (const auto& q : quests_) {
if (q.nextQuestId != 0 && validIds.find(q.nextQuestId) == validIds.end()) {
errors.push_back("Quest [" + std::to_string(q.id) + "] \"" + q.title +
"\" chains to non-existent quest " + std::to_string(q.nextQuestId));
}
// Circular chain detection
if (q.nextQuestId != 0) {
std::unordered_set<uint32_t> visited;
uint32_t current = q.id;
while (current != 0) {
if (!visited.insert(current).second) {
errors.push_back("Circular quest chain detected starting from quest [" +
std::to_string(q.id) + "] \"" + q.title + "\"");
break;
}
uint32_t next = 0;
for (const auto& other : quests_) {
if (other.id == current) { next = other.nextQuestId; break; }
}
current = next;
}
}
}
return errors.empty();
}
} // namespace editor
} // namespace wowee