feat(editor): WQGR JSON round-trip closure

Adds --export-wqgr-json / --import-wqgr-json with the established
readEnumField template factoring int+name dual encoding for both
questType ("normal"/"daily"/"repeatable"/"group"/"raid") and
factionAccess ("both"/"alliance"/"horde"/"neutral"). Variable-
length prereq + followup quest arrays serialize as JSON int
arrays.

All 3 presets (starter/branched/dailies) byte-identical binary
roundtrip OK including the branched preset's converging DAG
(Q200 -> {Q201, Q202} -> Q203 with Q203 carrying [201, 202] in
its prevQuestIds).

Live-tested DFS cycle detection: hand-mutated Northshire chain
head Q100 to depend on Q104 (the chain's last quest), creating
a 5-node loop. Validator correctly errored: "prereq cycle
detected: 100 -> 104 -> 103 -> 102 -> 101 -> 100 — quests would
be unreachable (progression deadlock)" with the full back-edge
path extracted exactly as WMOD does for addon dep cycles.

CLI flag count 1389 -> 1391.
This commit is contained in:
Kelsi 2026-05-10 04:24:49 -07:00
parent 76cda20297
commit a4ac12dbeb
3 changed files with 210 additions and 0 deletions

View file

@ -406,6 +406,7 @@ const char* const kArgRequired[] = {
"--export-wgbk-json", "--import-wgbk-json",
"--gen-qgr-starter", "--gen-qgr-branched", "--gen-qgr-dailies",
"--info-wqgr", "--validate-wqgr",
"--export-wqgr-json", "--import-wqgr-json",
"--gen-weather-temperate", "--gen-weather-arctic",
"--gen-weather-desert", "--gen-weather-stormy",
"--gen-zone-atmosphere",

View file

@ -2611,6 +2611,10 @@ void printUsage(const char* argv0) {
std::printf(" Print WQGR entries (questId / minLevel / maxLevel / questType / factionAccess / zoneId / chainHead / prereq + followup counts / name)\n");
std::printf(" --validate-wqgr <wqgr-base> [--json]\n");
std::printf(" Static checks: id+name required, questType 0..4, factionAccess 0..3, maxLevel >= minLevel, no self-prereq (catch-22), no missing prereq questId, DFS cycle detection on prevQuestIds (progression deadlock — quests would be unreachable). Warns on followup hint to self/missing-id (advisory only) and on chainHeadHint=1 with non-empty prereqs (contradicts chain-head semantics)\n");
std::printf(" --export-wqgr-json <wqgr-base> [out.json]\n");
std::printf(" Export binary .wqgr to a human-editable JSON sidecar (defaults to <base>.wqgr.json; emits both questType and factionAccess as int + name string; prevQuestIds/followupQuestIds as JSON int arrays)\n");
std::printf(" --import-wqgr-json <json-path> [out-base]\n");
std::printf(" Import a .wqgr.json sidecar back into binary .wqgr (questType int OR \"normal\"/\"daily\"/\"repeatable\"/\"group\"/\"raid\"; factionAccess int OR \"both\"/\"alliance\"/\"horde\"/\"neutral\"; prereq + followup arrays accept JSON int arrays)\n");
std::printf(" --catalog-pluck <wXXX-file> <id> [--json]\n");
std::printf(" Extract one entry by id from any registered catalog format. Auto-detects magic, dispatches to the per-format --info-* handler internally, then prints just the matching entry. Primary-key field is auto-detected (first *Id field, or first numeric)\n");
std::printf(" --catalog-find <directory> <id> [--magic <WXXX>] [--json]\n");

View file

@ -204,6 +204,63 @@ std::vector<uint32_t> findFirstCycle(
return {};
}
int parseQuestTypeToken(const std::string& s) {
using G = wowee::pipeline::WoweeQuestGraph;
if (s == "normal") return G::Normal;
if (s == "daily") return G::Daily;
if (s == "repeatable") return G::Repeatable;
if (s == "group") return G::Group;
if (s == "raid") return G::Raid;
return -1;
}
int parseFactionAccessToken(const std::string& s) {
using G = wowee::pipeline::WoweeQuestGraph;
if (s == "both") return G::Both;
if (s == "alliance") return G::Alliance;
if (s == "horde") return G::Horde;
if (s == "neutral") return G::Neutral;
return -1;
}
template <typename ParseFn>
bool readEnumField(const nlohmann::json& je,
const char* intKey,
const char* nameKey,
ParseFn parseFn,
const char* label,
uint32_t entryId,
uint8_t& outValue) {
if (je.contains(intKey)) {
const auto& v = je[intKey];
if (v.is_string()) {
int parsed = parseFn(v.get<std::string>());
if (parsed < 0) {
std::fprintf(stderr,
"import-wqgr-json: unknown %s token "
"'%s' on entry id=%u\n",
label, v.get<std::string>().c_str(),
entryId);
return false;
}
outValue = static_cast<uint8_t>(parsed);
return true;
}
if (v.is_number_integer()) {
outValue = static_cast<uint8_t>(v.get<int>());
return true;
}
}
if (je.contains(nameKey) && je[nameKey].is_string()) {
int parsed = parseFn(je[nameKey].get<std::string>());
if (parsed >= 0) {
outValue = static_cast<uint8_t>(parsed);
return true;
}
}
return true;
}
int handleValidate(int& i, int argc, char** argv) {
std::string base = argv[++i];
bool jsonOut = consumeJsonFlag(i, argc, argv);
@ -340,6 +397,146 @@ int handleValidate(int& i, int argc, char** argv) {
return ok ? 0 : 1;
}
int handleExportJson(int& i, int argc, char** argv) {
std::string base = argv[++i];
std::string out;
if (parseOptArg(i, argc, argv)) out = argv[++i];
base = stripWqgrExt(base);
if (out.empty()) out = base + ".wqgr.json";
if (!wowee::pipeline::WoweeQuestGraphLoader::exists(base)) {
std::fprintf(stderr,
"export-wqgr-json: WQGR not found: %s.wqgr\n",
base.c_str());
return 1;
}
auto c = wowee::pipeline::WoweeQuestGraphLoader::load(base);
nlohmann::json j;
j["magic"] = "WQGR";
j["version"] = 1;
j["name"] = c.name;
nlohmann::json arr = nlohmann::json::array();
for (const auto& e : c.entries) {
arr.push_back({
{"questId", e.questId},
{"name", e.name},
{"minLevel", e.minLevel},
{"maxLevel", e.maxLevel},
{"questType", e.questType},
{"questTypeName", questTypeName(e.questType)},
{"factionAccess", e.factionAccess},
{"factionAccessName",
factionAccessName(e.factionAccess)},
{"classRestriction", e.classRestriction},
{"raceRestriction", e.raceRestriction},
{"zoneId", e.zoneId},
{"chainHeadHint", e.chainHeadHint != 0},
{"prevQuestIds", e.prevQuestIds},
{"followupQuestIds", e.followupQuestIds},
});
}
j["entries"] = arr;
std::ofstream os(out);
if (!os) {
std::fprintf(stderr,
"export-wqgr-json: failed to open %s for write\n",
out.c_str());
return 1;
}
os << j.dump(2) << "\n";
std::printf("Wrote %s (%zu quests)\n",
out.c_str(), c.entries.size());
return 0;
}
int handleImportJson(int& i, int argc, char** argv) {
std::string in = argv[++i];
std::string outBase;
if (parseOptArg(i, argc, argv)) outBase = argv[++i];
if (outBase.empty()) {
outBase = in;
if (outBase.size() >= 10 &&
outBase.substr(outBase.size() - 10) == ".wqgr.json") {
outBase.resize(outBase.size() - 10);
} else {
stripExt(outBase, ".json");
stripExt(outBase, ".wqgr");
}
}
std::ifstream is(in);
if (!is) {
std::fprintf(stderr,
"import-wqgr-json: cannot open %s\n", in.c_str());
return 1;
}
nlohmann::json j;
try {
is >> j;
} catch (const std::exception& ex) {
std::fprintf(stderr,
"import-wqgr-json: JSON parse error: %s\n", ex.what());
return 1;
}
wowee::pipeline::WoweeQuestGraph c;
c.name = j.value("name", std::string{});
if (!j.contains("entries") || !j["entries"].is_array()) {
std::fprintf(stderr,
"import-wqgr-json: missing or non-array 'entries'\n");
return 1;
}
for (const auto& je : j["entries"]) {
wowee::pipeline::WoweeQuestGraph::Entry e;
e.questId = je.value("questId", 0u);
e.name = je.value("name", std::string{});
e.minLevel = static_cast<uint8_t>(
je.value("minLevel", 0));
e.maxLevel = static_cast<uint8_t>(
je.value("maxLevel", 0));
if (!readEnumField(je, "questType", "questTypeName",
parseQuestTypeToken, "questType",
e.questId, e.questType)) return 1;
if (!readEnumField(je, "factionAccess",
"factionAccessName",
parseFactionAccessToken,
"factionAccess", e.questId,
e.factionAccess)) return 1;
e.classRestriction = static_cast<uint16_t>(
je.value("classRestriction", 0));
e.raceRestriction = static_cast<uint16_t>(
je.value("raceRestriction", 0));
e.zoneId = je.value("zoneId", 0u);
e.chainHeadHint = je.value("chainHeadHint", false) ? 1 : 0;
if (je.contains("prevQuestIds") &&
je["prevQuestIds"].is_array()) {
for (const auto& v : je["prevQuestIds"]) {
if (v.is_number_unsigned() ||
v.is_number_integer()) {
e.prevQuestIds.push_back(v.get<uint32_t>());
}
}
}
if (je.contains("followupQuestIds") &&
je["followupQuestIds"].is_array()) {
for (const auto& v : je["followupQuestIds"]) {
if (v.is_number_unsigned() ||
v.is_number_integer()) {
e.followupQuestIds.push_back(
v.get<uint32_t>());
}
}
}
c.entries.push_back(e);
}
if (!wowee::pipeline::WoweeQuestGraphLoader::save(c, outBase)) {
std::fprintf(stderr,
"import-wqgr-json: failed to save %s.wqgr\n",
outBase.c_str());
return 1;
}
std::printf("Wrote %s.wqgr (%zu quests)\n",
outBase.c_str(), c.entries.size());
return 0;
}
} // namespace
bool handleQuestGraphCatalog(int& i, int argc, char** argv,
@ -363,6 +560,14 @@ bool handleQuestGraphCatalog(int& i, int argc, char** argv,
i + 1 < argc) {
outRc = handleValidate(i, argc, argv); return true;
}
if (std::strcmp(argv[i], "--export-wqgr-json") == 0 &&
i + 1 < argc) {
outRc = handleExportJson(i, argc, argv); return true;
}
if (std::strcmp(argv[i], "--import-wqgr-json") == 0 &&
i + 1 < argc) {
outRc = handleImportJson(i, argc, argv); return true;
}
return false;
}