diff --git a/CMakeLists.txt b/CMakeLists.txt index 53f8cf72..ee185fb8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -720,6 +720,7 @@ set(WOWEE_SOURCES src/pipeline/wowee_mage_portals.cpp src/pipeline/wowee_combat_stats.cpp src/pipeline/wowee_guild_bank.cpp + src/pipeline/wowee_quest_graph.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/dbc_layout.cpp @@ -1603,6 +1604,7 @@ add_executable(wowee_editor tools/editor/cli_mage_portals_catalog.cpp tools/editor/cli_combat_stats_catalog.cpp tools/editor/cli_guild_bank_catalog.cpp + tools/editor/cli_quest_graph_catalog.cpp tools/editor/cli_catalog_pluck.cpp tools/editor/cli_catalog_find.cpp tools/editor/cli_catalog_by_name.cpp @@ -1805,6 +1807,7 @@ add_executable(wowee_editor src/pipeline/wowee_mage_portals.cpp src/pipeline/wowee_combat_stats.cpp src/pipeline/wowee_guild_bank.cpp + src/pipeline/wowee_quest_graph.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/terrain_mesh.cpp diff --git a/include/pipeline/wowee_quest_graph.hpp b/include/pipeline/wowee_quest_graph.hpp new file mode 100644 index 00000000..fe8646f9 --- /dev/null +++ b/include/pipeline/wowee_quest_graph.hpp @@ -0,0 +1,152 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +// Wowee Open Quest Graph catalog (.wqgr) — novel +// representation of quest-chain dependencies that +// vanilla WoW carried implicitly in +// QuestRelations.dbc (the prequest column) + +// per-quest server scripts. Each WQGR entry binds +// one quest to its display name, level/class/race +// gating, prerequisite quest list (must be +// completed first), follow-up quest hints (next- +// quest suggestions for the journal UI), and quest +// type flags (Normal / Daily / Repeatable / Group / +// Raid). +// +// The variable-length prereq array gives the +// validator something interesting to check: a DFS +// cycle detector flags player-unreachable quests +// (Q1 prereq=Q2, Q2 prereq=Q3, Q3 prereq=Q1 is a +// progression deadlock — no player could ever +// satisfy the cycle). +// +// Cross-references with previously-added formats: +// WQTM: questId references the WQTM quest catalog +// (the actual quest objectives + rewards +// live in WQTM; WQGR only describes the +// dependency graph between them). +// WMS: zoneId references the WMS map catalog. +// +// Binary layout (little-endian): +// magic[4] = "WQGR" +// version (uint32) = current 1 +// nameLen + name (catalog label) +// entryCount (uint32) +// entries (each): +// questId (uint32) +// nameLen + name +// minLevel (uint8) +// maxLevel (uint8) — 0 = no upper gate +// questType (uint8) — 0=Normal / +// 1=Daily / +// 2=Repeatable / +// 3=Group / +// 4=Raid +// factionAccess (uint8) — 0=Both / +// 1=Alliance / +// 2=Horde / +// 3=Neutral +// classRestriction (uint16) — bitmask of allowed +// classIds (0 = +// no restriction) +// raceRestriction (uint16) — bitmask of allowed +// raceIds (0 = +// no restriction) +// zoneId (uint32) +// chainHeadHint (uint8) — 0/1 bool — first +// quest in a chain +// (UI sort hint) +// pad0 (uint8) +// pad1 (uint16) +// prevCount (uint32) — prereq array +// prevQuestIds (uint32 × count) +// followupCount (uint32) — hint array +// followupQuestIds (uint32 × count) +struct WoweeQuestGraph { + enum QuestType : uint8_t { + Normal = 0, + Daily = 1, + Repeatable = 2, + Group = 3, + Raid = 4, + }; + + enum FactionAccess : uint8_t { + Both = 0, + Alliance = 1, + Horde = 2, + Neutral = 3, + }; + + struct Entry { + uint32_t questId = 0; + std::string name; + uint8_t minLevel = 0; + uint8_t maxLevel = 0; + uint8_t questType = Normal; + uint8_t factionAccess = Both; + uint16_t classRestriction = 0; + uint16_t raceRestriction = 0; + uint32_t zoneId = 0; + uint8_t chainHeadHint = 0; + uint8_t pad0 = 0; + uint16_t pad1 = 0; + std::vector prevQuestIds; + std::vector followupQuestIds; + }; + + std::string name; + std::vector entries; + + bool isValid() const { return !entries.empty(); } + + const Entry* findById(uint32_t questId) const; + + // Returns all quests that have the given questId + // as a prereq (the "what unlocks once I finish + // this" lookup — used by the journal UI's + // "completing this opens" panel). + std::vector findUnlocksFrom(uint32_t questId) const; + + // Returns all quests in a zone — used by the + // zone-detail UI to populate the per-zone quest + // list. + std::vector findByZone(uint32_t zoneId) const; +}; + +class WoweeQuestGraphLoader { +public: + static bool save(const WoweeQuestGraph& cat, + const std::string& basePath); + static WoweeQuestGraph load(const std::string& basePath); + static bool exists(const std::string& basePath); + + // Preset emitters used by --gen-qgr* variants. + // + // makeStarterChain — 5-quest linear chain + // (Northshire human starter + // Q1->Q2->Q3->Q4->Q5). + // Q1 has chainHeadHint=1. + // Levels 1..5. + // makeBranchedChain — 4-quest converging chain + // (Q1 -> Q2a, Q1 -> Q2b, + // both -> Q3). Demonstrates + // DAG semantics not just + // a linear list. + // makeDailies — 3 standalone daily quests + // (Daily type, no prereqs, + // no follow-ups). Baseline + // empty-deps path. + static WoweeQuestGraph makeStarterChain(const std::string& catalogName); + static WoweeQuestGraph makeBranchedChain(const std::string& catalogName); + static WoweeQuestGraph makeDailies(const std::string& catalogName); +}; + +} // namespace pipeline +} // namespace wowee diff --git a/src/pipeline/wowee_quest_graph.cpp b/src/pipeline/wowee_quest_graph.cpp new file mode 100644 index 00000000..925bb821 --- /dev/null +++ b/src/pipeline/wowee_quest_graph.cpp @@ -0,0 +1,293 @@ +#include "pipeline/wowee_quest_graph.hpp" + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +namespace { + +constexpr char kMagic[4] = {'W', 'Q', 'G', 'R'}; +constexpr uint32_t kVersion = 1; + +template +void writePOD(std::ofstream& os, const T& v) { + os.write(reinterpret_cast(&v), sizeof(T)); +} + +template +bool readPOD(std::ifstream& is, T& v) { + is.read(reinterpret_cast(&v), sizeof(T)); + return is.gcount() == static_cast(sizeof(T)); +} + +void writeStr(std::ofstream& os, const std::string& s) { + uint32_t n = static_cast(s.size()); + writePOD(os, n); + if (n > 0) os.write(s.data(), n); +} + +bool readStr(std::ifstream& is, std::string& s) { + uint32_t n = 0; + if (!readPOD(is, n)) return false; + if (n > (1u << 20)) return false; + s.resize(n); + if (n > 0) { + is.read(s.data(), n); + if (is.gcount() != static_cast(n)) { + s.clear(); + return false; + } + } + return true; +} + +void writeU32Vec(std::ofstream& os, + const std::vector& v) { + uint32_t n = static_cast(v.size()); + writePOD(os, n); + if (n > 0) { + os.write(reinterpret_cast(v.data()), + static_cast(n * sizeof(uint32_t))); + } +} + +bool readU32Vec(std::ifstream& is, std::vector& v) { + uint32_t n = 0; + if (!readPOD(is, n)) return false; + if (n > 4096) return false; + v.resize(n); + if (n > 0) { + is.read(reinterpret_cast(v.data()), + static_cast(n * sizeof(uint32_t))); + if (is.gcount() != + static_cast(n * sizeof(uint32_t))) { + v.clear(); + return false; + } + } + return true; +} + +std::string normalizePath(std::string base) { + if (base.size() < 5 || base.substr(base.size() - 5) != ".wqgr") { + base += ".wqgr"; + } + return base; +} + +} // namespace + +const WoweeQuestGraph::Entry* +WoweeQuestGraph::findById(uint32_t questId) const { + for (const auto& e : entries) + if (e.questId == questId) return &e; + return nullptr; +} + +std::vector +WoweeQuestGraph::findUnlocksFrom(uint32_t questId) const { + std::vector out; + for (const auto& e : entries) { + for (uint32_t p : e.prevQuestIds) { + if (p == questId) { out.push_back(&e); break; } + } + } + return out; +} + +std::vector +WoweeQuestGraph::findByZone(uint32_t zoneId) const { + std::vector out; + for (const auto& e : entries) + if (e.zoneId == zoneId) out.push_back(&e); + return out; +} + +bool WoweeQuestGraphLoader::save(const WoweeQuestGraph& cat, + const std::string& basePath) { + std::ofstream os(normalizePath(basePath), std::ios::binary); + if (!os) return false; + os.write(kMagic, 4); + writePOD(os, kVersion); + writeStr(os, cat.name); + uint32_t entryCount = static_cast(cat.entries.size()); + writePOD(os, entryCount); + for (const auto& e : cat.entries) { + writePOD(os, e.questId); + writeStr(os, e.name); + writePOD(os, e.minLevel); + writePOD(os, e.maxLevel); + writePOD(os, e.questType); + writePOD(os, e.factionAccess); + writePOD(os, e.classRestriction); + writePOD(os, e.raceRestriction); + writePOD(os, e.zoneId); + writePOD(os, e.chainHeadHint); + writePOD(os, e.pad0); + writePOD(os, e.pad1); + writeU32Vec(os, e.prevQuestIds); + writeU32Vec(os, e.followupQuestIds); + } + return os.good(); +} + +WoweeQuestGraph WoweeQuestGraphLoader::load( + const std::string& basePath) { + WoweeQuestGraph out; + std::ifstream is(normalizePath(basePath), std::ios::binary); + if (!is) return out; + char magic[4]; + is.read(magic, 4); + if (std::memcmp(magic, kMagic, 4) != 0) return out; + uint32_t version = 0; + if (!readPOD(is, version) || version != kVersion) return out; + if (!readStr(is, out.name)) return out; + uint32_t entryCount = 0; + if (!readPOD(is, entryCount)) return out; + if (entryCount > (1u << 20)) return out; + out.entries.resize(entryCount); + for (auto& e : out.entries) { + if (!readPOD(is, e.questId)) { + out.entries.clear(); return out; + } + if (!readStr(is, e.name)) { + out.entries.clear(); return out; + } + if (!readPOD(is, e.minLevel) || + !readPOD(is, e.maxLevel) || + !readPOD(is, e.questType) || + !readPOD(is, e.factionAccess) || + !readPOD(is, e.classRestriction) || + !readPOD(is, e.raceRestriction) || + !readPOD(is, e.zoneId) || + !readPOD(is, e.chainHeadHint) || + !readPOD(is, e.pad0) || + !readPOD(is, e.pad1)) { + out.entries.clear(); return out; + } + if (!readU32Vec(is, e.prevQuestIds) || + !readU32Vec(is, e.followupQuestIds)) { + out.entries.clear(); return out; + } + } + return out; +} + +bool WoweeQuestGraphLoader::exists(const std::string& basePath) { + std::ifstream is(normalizePath(basePath), std::ios::binary); + return is.good(); +} + +namespace { + +WoweeQuestGraph::Entry makeQuest(uint32_t qid, const char* name, + uint8_t minL, uint8_t maxL, + uint8_t qtype, uint8_t faction, + uint32_t zoneId, + uint8_t chainHead, + std::vector prev, + std::vector followups) { + WoweeQuestGraph::Entry e; + e.questId = qid; e.name = name; + e.minLevel = minL; e.maxLevel = maxL; + e.questType = qtype; + e.factionAccess = faction; + e.zoneId = zoneId; + e.chainHeadHint = chainHead; + e.prevQuestIds = std::move(prev); + e.followupQuestIds = std::move(followups); + return e; +} + +} // namespace + +WoweeQuestGraph WoweeQuestGraphLoader::makeStarterChain( + const std::string& catalogName) { + using G = WoweeQuestGraph; + WoweeQuestGraph c; + c.name = catalogName; + // Northshire Abbey starter chain (zoneId=12): + // Q1 = chain head (no prereqs, hints next). + // Q2..Q5 form linear progression. + c.entries.push_back(makeQuest( + 100, "A Threat Within", 1, 5, + G::Normal, G::Alliance, 12, 1, + {}, {101})); + c.entries.push_back(makeQuest( + 101, "Wolves Across the Border", 2, 5, + G::Normal, G::Alliance, 12, 0, + {100}, {102})); + c.entries.push_back(makeQuest( + 102, "Kobold Camp Cleanup", 3, 6, + G::Normal, G::Alliance, 12, 0, + {101}, {103})); + c.entries.push_back(makeQuest( + 103, "Investigate Echo Ridge", 4, 7, + G::Normal, G::Alliance, 12, 0, + {102}, {104})); + c.entries.push_back(makeQuest( + 104, "Report to Goldshire", 5, 8, + G::Normal, G::Alliance, 12, 0, + {103}, {})); // last in chain, no + // followups + return c; +} + +WoweeQuestGraph WoweeQuestGraphLoader::makeBranchedChain( + const std::string& catalogName) { + using G = WoweeQuestGraph; + WoweeQuestGraph c; + c.name = catalogName; + // Demonstrates DAG semantics — Q1 unlocks both + // Q2a and Q2b; both prereq Q3: + // Q1 -> Q2a -> Q3 + // Q1 -> Q2b -> Q3 + // Q3 has TWO prereqs (must complete BOTH branches). + c.entries.push_back(makeQuest( + 200, "Discover the Crystal", 10, 14, + G::Group, G::Both, 47, 1, + {}, {201, 202})); + c.entries.push_back(makeQuest( + 201, "The Frost Branch", 11, 15, + G::Normal, G::Both, 47, 0, + {200}, {203})); + c.entries.push_back(makeQuest( + 202, "The Fire Branch", 11, 15, + G::Normal, G::Both, 47, 0, + {200}, {203})); + c.entries.push_back(makeQuest( + 203, "Forge the Amulet", 12, 16, + G::Group, G::Both, 47, 0, + {201, 202}, {})); // requires BOTH 201 + // AND 202 + return c; +} + +WoweeQuestGraph WoweeQuestGraphLoader::makeDailies( + const std::string& catalogName) { + using G = WoweeQuestGraph; + WoweeQuestGraph c; + c.name = catalogName; + // Standalone daily quests — no prereqs, no + // followups. chainHeadHint=1 since each is its + // own root. + c.entries.push_back(makeQuest( + 300, "Daily: Mana Cell Disposal", 50, 0, + G::Daily, G::Both, 100, 1, + {}, {})); + c.entries.push_back(makeQuest( + 301, "Daily: Felblood Sample", 50, 0, + G::Daily, G::Both, 100, 1, + {}, {})); + c.entries.push_back(makeQuest( + 302, "Daily: Crystal Restoration", 50, 0, + G::Daily, G::Both, 100, 1, + {}, {})); + return c; +} + +} // namespace pipeline +} // namespace wowee diff --git a/tools/editor/cli_arg_required.cpp b/tools/editor/cli_arg_required.cpp index 4b9abe88..d4f71cd8 100644 --- a/tools/editor/cli_arg_required.cpp +++ b/tools/editor/cli_arg_required.cpp @@ -404,6 +404,8 @@ const char* const kArgRequired[] = { "--gen-gbk", "--gen-gbk-raid", "--gen-gbk-small", "--info-wgbk", "--validate-wgbk", "--export-wgbk-json", "--import-wgbk-json", + "--gen-qgr-starter", "--gen-qgr-branched", "--gen-qgr-dailies", + "--info-wqgr", "--validate-wqgr", "--gen-weather-temperate", "--gen-weather-arctic", "--gen-weather-desert", "--gen-weather-stormy", "--gen-zone-atmosphere", diff --git a/tools/editor/cli_dispatch.cpp b/tools/editor/cli_dispatch.cpp index f9da6154..4c9d604f 100644 --- a/tools/editor/cli_dispatch.cpp +++ b/tools/editor/cli_dispatch.cpp @@ -176,6 +176,7 @@ #include "cli_mage_portals_catalog.hpp" #include "cli_combat_stats_catalog.hpp" #include "cli_guild_bank_catalog.hpp" +#include "cli_quest_graph_catalog.hpp" #include "cli_catalog_pluck.hpp" #include "cli_catalog_find.hpp" #include "cli_catalog_by_name.hpp" @@ -397,6 +398,7 @@ constexpr DispatchFn kDispatchTable[] = { handleMagePortalsCatalog, handleCombatStatsCatalog, handleGuildBankCatalog, + handleQuestGraphCatalog, handleCatalogPluck, handleCatalogFind, handleCatalogByName, diff --git a/tools/editor/cli_format_table.cpp b/tools/editor/cli_format_table.cpp index 317fc366..e5fa9847 100644 --- a/tools/editor/cli_format_table.cpp +++ b/tools/editor/cli_format_table.cpp @@ -134,6 +134,7 @@ constexpr FormatMagicEntry kFormats[] = { {{'W','P','R','T'}, ".wprt", "portals", "--info-wprt", "Mage portal destinations catalog"}, {{'W','C','S','T'}, ".wcst", "stats", "--info-wcst", "Combat stats baseline catalog"}, {{'W','G','B','K'}, ".wgbk", "guild", "--info-wgbk", "Guild bank tabs catalog"}, + {{'W','Q','G','R'}, ".wqgr", "quests", "--info-wqgr", "Quest graph catalog"}, {{'W','F','A','C'}, ".wfac", "factions", nullptr, "Faction catalog"}, {{'W','L','C','K'}, ".wlck", "locks", nullptr, "Lock catalog"}, {{'W','S','K','L'}, ".wskl", "skills", nullptr, "Skill catalog"}, diff --git a/tools/editor/cli_help.cpp b/tools/editor/cli_help.cpp index a7e92810..30eefee2 100644 --- a/tools/editor/cli_help.cpp +++ b/tools/editor/cli_help.cpp @@ -2601,6 +2601,16 @@ void printUsage(const char* argv0) { std::printf(" Export binary .wgbk to a human-editable JSON sidecar (defaults to .wgbk.json; perRankWithdrawalLimit emitted as 8-element JSON int array, kUnlimited = 4294967295)\n"); std::printf(" --import-wgbk-json [out-base]\n"); std::printf(" Import a .wgbk.json sidecar back into binary .wgbk (perRankWithdrawalLimit JSON int array, missing entries default to 0; round-trips per-rank caps byte-identical)\n"); + std::printf(" --gen-qgr-starter [name]\n"); + std::printf(" Emit .wqgr 5-quest linear chain (Northshire human-starter Q100..Q104, levels 1..8) with chainHeadHint=1 on Q100\n"); + std::printf(" --gen-qgr-branched [name]\n"); + std::printf(" Emit .wqgr 4-quest converging chain (Q200 -> Q201/Q202 branches, both required for Q203) — demonstrates DAG semantics not just a linear list\n"); + std::printf(" --gen-qgr-dailies [name]\n"); + std::printf(" Emit .wqgr 3 standalone daily quests (Daily type, no prereqs, no followups) — baseline empty-deps path\n"); + std::printf(" --info-wqgr [--json]\n"); + std::printf(" Print WQGR entries (questId / minLevel / maxLevel / questType / factionAccess / zoneId / chainHead / prereq + followup counts / name)\n"); + std::printf(" --validate-wqgr [--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(" --catalog-pluck [--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 [--magic ] [--json]\n"); diff --git a/tools/editor/cli_list_formats.cpp b/tools/editor/cli_list_formats.cpp index 3bb100a0..49da981c 100644 --- a/tools/editor/cli_list_formats.cpp +++ b/tools/editor/cli_list_formats.cpp @@ -156,6 +156,7 @@ constexpr FormatRow kFormats[] = { {"WPRT", ".wprt", "portals", "SpellEffect TELEPORT_UNITS + AreaTrigger","Mage portal destinations catalog (spellId -> coords binding)"}, {"WCST", ".wcst", "stats", "CharBaseInfo + GtChanceTo*.dbc + StatSystem","Combat stats baseline catalog (per-class per-level base stats)"}, {"WGBK", ".wgbk", "guild", "(absent in vanilla, TBC GuildBankTab)","Guild bank tabs catalog (per-rank withdrawal limits)"}, + {"WQGR", ".wqgr", "quests", "QuestRelations.dbc + per-quest scripts","Quest graph catalog (chain prereq DAG + cycle detection)"}, // Additional pipeline catalogs without the alternating // gen/info/validate CLI surface (loaded by the engine diff --git a/tools/editor/cli_quest_graph_catalog.cpp b/tools/editor/cli_quest_graph_catalog.cpp new file mode 100644 index 00000000..b292a4bc --- /dev/null +++ b/tools/editor/cli_quest_graph_catalog.cpp @@ -0,0 +1,371 @@ +#include "cli_quest_graph_catalog.hpp" +#include "cli_arg_parse.hpp" +#include "cli_box_emitter.hpp" + +#include "pipeline/wowee_quest_graph.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { +namespace cli { + +namespace { + +std::string stripWqgrExt(std::string base) { + stripExt(base, ".wqgr"); + return base; +} + +const char* questTypeName(uint8_t t) { + using G = wowee::pipeline::WoweeQuestGraph; + switch (t) { + case G::Normal: return "normal"; + case G::Daily: return "daily"; + case G::Repeatable: return "repeatable"; + case G::Group: return "group"; + case G::Raid: return "raid"; + default: return "?"; + } +} + +const char* factionAccessName(uint8_t f) { + using G = wowee::pipeline::WoweeQuestGraph; + switch (f) { + case G::Both: return "both"; + case G::Alliance: return "alliance"; + case G::Horde: return "horde"; + case G::Neutral: return "neutral"; + default: return "?"; + } +} + +bool saveOrError(const wowee::pipeline::WoweeQuestGraph& c, + const std::string& base, const char* cmd) { + if (!wowee::pipeline::WoweeQuestGraphLoader::save(c, base)) { + std::fprintf(stderr, "%s: failed to save %s.wqgr\n", + cmd, base.c_str()); + return false; + } + return true; +} + +void printGenSummary(const wowee::pipeline::WoweeQuestGraph& c, + const std::string& base) { + std::printf("Wrote %s.wqgr\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" quests : %zu\n", c.entries.size()); +} + +int handleGenStarter(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "NorthshireStarterChain"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWqgrExt(base); + auto c = wowee::pipeline::WoweeQuestGraphLoader:: + makeStarterChain(name); + if (!saveOrError(c, base, "gen-qgr-starter")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenBranched(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "BranchedConvergingChain"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWqgrExt(base); + auto c = wowee::pipeline::WoweeQuestGraphLoader:: + makeBranchedChain(name); + if (!saveOrError(c, base, "gen-qgr-branched")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenDailies(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "DailyQuests"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWqgrExt(base); + auto c = wowee::pipeline::WoweeQuestGraphLoader:: + makeDailies(name); + if (!saveOrError(c, base, "gen-qgr-dailies")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleInfo(int& i, int argc, char** argv) { + std::string base = argv[++i]; + bool jsonOut = consumeJsonFlag(i, argc, argv); + base = stripWqgrExt(base); + if (!wowee::pipeline::WoweeQuestGraphLoader::exists(base)) { + std::fprintf(stderr, "WQGR not found: %s.wqgr\n", + base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeQuestGraphLoader::load(base); + if (jsonOut) { + nlohmann::json j; + j["wqgr"] = base + ".wqgr"; + j["name"] = c.name; + j["count"] = c.entries.size(); + 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::printf("%s\n", j.dump(2).c_str()); + return 0; + } + std::printf("WQGR: %s.wqgr\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" quests : %zu\n", c.entries.size()); + if (c.entries.empty()) return 0; + std::printf(" id minL maxL type fact zone head prev fup name\n"); + for (const auto& e : c.entries) { + std::printf(" %4u %3u %3u %-10s %-8s %4u %s %4zu %3zu %s\n", + e.questId, e.minLevel, e.maxLevel, + questTypeName(e.questType), + factionAccessName(e.factionAccess), + e.zoneId, + e.chainHeadHint ? "Y" : "n", + e.prevQuestIds.size(), + e.followupQuestIds.size(), + e.name.c_str()); + } + return 0; +} + +// DFS cycle detection over prevQuestIds — same +// stack-based pattern as WMOD addon manifest. A +// cycle in quest prereqs means the quest is +// unreachable (player would need to complete Q1 to +// start Q2, but Q1 prereq is Q2). +std::vector findFirstCycle( + const wowee::pipeline::WoweeQuestGraph& c) { + std::map> graph; + std::set known; + for (const auto& e : c.entries) { + graph[e.questId] = e.prevQuestIds; + known.insert(e.questId); + } + enum Color : uint8_t { White = 0, Gray = 1, Black = 2 }; + std::map color; + for (uint32_t id : known) color[id] = White; + std::vector path; + std::vector cycle; + std::function dfs = [&](uint32_t node) -> bool { + color[node] = Gray; + path.push_back(node); + for (uint32_t prev : graph[node]) { + if (!known.count(prev)) continue; + if (color[prev] == Gray) { + auto it = std::find(path.begin(), path.end(), prev); + cycle.assign(it, path.end()); + cycle.push_back(prev); + return true; + } + if (color[prev] == White) { + if (dfs(prev)) return true; + } + } + color[node] = Black; + path.pop_back(); + return false; + }; + for (uint32_t id : known) { + if (color[id] == White && dfs(id)) return cycle; + } + return {}; +} + +int handleValidate(int& i, int argc, char** argv) { + std::string base = argv[++i]; + bool jsonOut = consumeJsonFlag(i, argc, argv); + base = stripWqgrExt(base); + if (!wowee::pipeline::WoweeQuestGraphLoader::exists(base)) { + std::fprintf(stderr, + "validate-wqgr: WQGR not found: %s.wqgr\n", + base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeQuestGraphLoader::load(base); + std::vector errors; + std::vector warnings; + if (c.entries.empty()) { + warnings.push_back("catalog has zero entries"); + } + std::set idsSeen; + std::set knownIds; + for (const auto& e : c.entries) knownIds.insert(e.questId); + for (size_t k = 0; k < c.entries.size(); ++k) { + const auto& e = c.entries[k]; + std::string ctx = "entry " + std::to_string(k) + + " (questId=" + std::to_string(e.questId); + if (!e.name.empty()) ctx += " " + e.name; + ctx += ")"; + if (e.questId == 0) + errors.push_back(ctx + ": questId is 0"); + if (e.name.empty()) + errors.push_back(ctx + ": name is empty"); + if (e.questType > 4) { + errors.push_back(ctx + ": questType " + + std::to_string(e.questType) + + " out of range (0..4)"); + } + if (e.factionAccess > 3) { + errors.push_back(ctx + ": factionAccess " + + std::to_string(e.factionAccess) + + " out of range (0..3)"); + } + if (e.maxLevel != 0 && e.maxLevel < e.minLevel) { + errors.push_back(ctx + ": maxLevel " + + std::to_string(e.maxLevel) + + " < minLevel " + + std::to_string(e.minLevel)); + } + // Self-prereq: a quest listing itself in + // its own prereqs is unreachable (catch-22). + for (uint32_t prev : e.prevQuestIds) { + if (prev == e.questId) { + errors.push_back(ctx + + ": quest depends on itself " + "(unreachable — catch-22)"); + } + if (!knownIds.count(prev)) { + errors.push_back(ctx + + ": prereq questId=" + + std::to_string(prev) + + " not found in catalog"); + } + } + // Followup hints to unknown ids: NOT an + // error — followups are advisory hints, + // the missing target may live in a sibling + // catalog. Just warn. + for (uint32_t fol : e.followupQuestIds) { + if (fol == e.questId) { + warnings.push_back(ctx + + ": followup hint points to self " + "(no-op — prune)"); + } + if (!knownIds.count(fol)) { + warnings.push_back(ctx + + ": followup hint points to " + "questId=" + std::to_string(fol) + + " not in this catalog (may live " + "in a sibling catalog)"); + } + } + // chainHeadHint=1 with non-empty prereqs is a + // contradiction — a chain head BY DEFINITION + // has no prereqs. Warn. + if (e.chainHeadHint && !e.prevQuestIds.empty()) { + warnings.push_back(ctx + + ": chainHeadHint=1 but quest has " + + std::to_string(e.prevQuestIds.size()) + + " prereq(s) — chain heads should have " + "no prereqs"); + } + if (!idsSeen.insert(e.questId).second) { + errors.push_back(ctx + ": duplicate questId"); + } + } + // DFS cycle on prevQuestIds — same pattern as WMOD. + auto cycle = findFirstCycle(c); + if (!cycle.empty()) { + std::string trail; + for (size_t k = 0; k < cycle.size(); ++k) { + if (k > 0) trail += " -> "; + trail += std::to_string(cycle[k]); + } + errors.push_back("prereq cycle detected: " + trail + + " — quests would be unreachable " + "(progression deadlock)"); + } + bool ok = errors.empty(); + if (jsonOut) { + nlohmann::json j; + j["wqgr"] = base + ".wqgr"; + j["ok"] = ok; + j["errors"] = errors; + j["warnings"] = warnings; + std::printf("%s\n", j.dump(2).c_str()); + return ok ? 0 : 1; + } + std::printf("validate-wqgr: %s.wqgr\n", base.c_str()); + if (ok && warnings.empty()) { + std::printf(" OK — %zu quests, all questIds unique, " + "questType 0..4, factionAccess 0..3, no " + "self-prereq, no missing prereq questId, " + "no DFS cycle (no progression deadlock)\n", + c.entries.size()); + return 0; + } + if (!warnings.empty()) { + std::printf(" warnings (%zu):\n", warnings.size()); + for (const auto& w : warnings) + std::printf(" - %s\n", w.c_str()); + } + if (!errors.empty()) { + std::printf(" ERRORS (%zu):\n", errors.size()); + for (const auto& e : errors) + std::printf(" - %s\n", e.c_str()); + } + return ok ? 0 : 1; +} + +} // namespace + +bool handleQuestGraphCatalog(int& i, int argc, char** argv, + int& outRc) { + if (std::strcmp(argv[i], "--gen-qgr-starter") == 0 && + i + 1 < argc) { + outRc = handleGenStarter(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-qgr-branched") == 0 && + i + 1 < argc) { + outRc = handleGenBranched(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-qgr-dailies") == 0 && + i + 1 < argc) { + outRc = handleGenDailies(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--info-wqgr") == 0 && i + 1 < argc) { + outRc = handleInfo(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--validate-wqgr") == 0 && + i + 1 < argc) { + outRc = handleValidate(i, argc, argv); return true; + } + return false; +} + +} // namespace cli +} // namespace editor +} // namespace wowee diff --git a/tools/editor/cli_quest_graph_catalog.hpp b/tools/editor/cli_quest_graph_catalog.hpp new file mode 100644 index 00000000..5782e706 --- /dev/null +++ b/tools/editor/cli_quest_graph_catalog.hpp @@ -0,0 +1,12 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +bool handleQuestGraphCatalog(int& i, int argc, char** argv, + int& outRc); + +} // namespace cli +} // namespace editor +} // namespace wowee