From c1c1f1b2a85dfc673017d71663edeaacdacf6dae Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 6 May 2026 13:29:23 -0700 Subject: [PATCH] feat(editor): add --export-quest-graph for Graphviz quest-chain visualization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visualizing quest chains in plain text rapidly becomes unreadable past ~10 quests. This emits a DOT file that renders to a labeled DAG you can paste into a wiki or PR description: wowee_editor --export-quest-graph custom_zones/MyZone dot -Tpng custom_zones/MyZone/quests.dot -o quests.png Node coloring conveys completion-readiness at a glance: - lightgreen: has objectives + reward (will complete in-game) - lightyellow: has objectives but no reward (uncommon) - lightgray: no objectives (won't complete — common authoring bug) - mistyrose dashed: synthetic node for a quest ID referenced by nextQuestId but missing from quests.json (broken chain) Edges: - solid black: valid chain link - dashed red labeled 'missing': dangling nextQuestId Labels include quest ID, title (DOT-escaped for safety), required level, and XP reward — enough context that the graph stands alone without needing to cross-reference quests.json. Verified on a 3-quest zone with chain 1->2->3->999 (last broken): DOT output has 3 colored nodes (lightgreen for the 2 quests with objectives, lightgray for the third), 2 solid edges, 1 dashed-red edge to a synthetic mistyrose node. --- tools/editor/main.cpp | 99 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/tools/editor/main.cpp b/tools/editor/main.cpp index 56882d95..eb12198e 100644 --- a/tools/editor/main.cpp +++ b/tools/editor/main.cpp @@ -500,6 +500,8 @@ static void printUsage(const char* argv0) { std::printf(" One-shot validate + creature/object/quest counts and exit\n"); std::printf(" --export-zone-summary-md [out.md]\n"); std::printf(" Render a markdown documentation page for a zone (manifest + content)\n"); + std::printf(" --export-quest-graph [out.dot]\n"); + std::printf(" Render quest-chain DAG as Graphviz DOT (pipe to `dot -Tpng -o quests.png`)\n"); std::printf(" --info [--json]\n"); std::printf(" Print WOM file metadata (version, counts) and exit\n"); std::printf(" --info-batches [--json]\n"); @@ -582,7 +584,7 @@ int main(int argc, char* argv[]) { "--unpack-wcp", "--pack-wcp", "--validate", "--validate-wom", "--validate-wob", "--validate-woc", "--validate-whm", "--validate-all", "--zone-summary", - "--export-zone-summary-md", + "--export-zone-summary-md", "--export-quest-graph", "--scaffold-zone", "--add-tile", "--remove-tile", "--list-tiles", "--for-each-zone", "--zone-stats", "--list-zone-deps", "--check-zone-refs", @@ -2762,6 +2764,101 @@ int main(int argc, char* argv[]) { zm.mapName.c_str(), zm.tiles.size(), sp.spawnCount(), op.getObjects().size(), qe.questCount()); return 0; + } else if (std::strcmp(argv[i], "--export-quest-graph") == 0 && i + 1 < argc) { + // Render quest chains as a Graphviz DOT graph. Visualizing + // quest dependencies in plain text rapidly becomes unreadable + // past ~10 quests; piping this through 'dot -Tpng -o q.png' + // makes complex chains immediately legible. + // + // wowee_editor --export-quest-graph custom_zones/MyZone + // dot -Tpng custom_zones/MyZone/quests.dot -o quests.png + std::string zoneDir = argv[++i]; + std::string outPath; + if (i + 1 < argc && argv[i + 1][0] != '-') { + outPath = argv[++i]; + } + std::string path = zoneDir + "/quests.json"; + if (!std::filesystem::exists(path)) { + std::fprintf(stderr, + "export-quest-graph: %s not found\n", path.c_str()); + return 1; + } + if (outPath.empty()) outPath = zoneDir + "/quests.dot"; + wowee::editor::QuestEditor qe; + if (!qe.loadFromFile(path)) { + std::fprintf(stderr, + "export-quest-graph: failed to parse %s\n", path.c_str()); + return 1; + } + std::ofstream out(outPath); + if (!out) { + std::fprintf(stderr, + "export-quest-graph: cannot write %s\n", outPath.c_str()); + return 1; + } + // DOT-escape strings (just quotes and backslashes) — quest + // titles can include arbitrary punctuation that breaks DOT + // parsing if not escaped. + auto dotEsc = [](const std::string& s) { + std::string out; + for (char c : s) { + if (c == '"' || c == '\\') out += '\\'; + out += c; + } + return out; + }; + const auto& quests = qe.getQuests(); + // Build an index of valid quest IDs so dangling chain + // pointers can be styled differently (red, dashed). + std::unordered_set validIds; + for (const auto& q : quests) validIds.insert(q.id); + out << "digraph QuestChains {\n"; + out << " // Generated by wowee_editor --export-quest-graph\n"; + out << " rankdir=LR;\n"; + out << " node [shape=box, style=filled, fontname=\"sans-serif\"];\n"; + // Nodes: one per quest, colored by completion-readiness: + // green = has objectives + reward + valid NPCs + // yellow = missing some non-fatal field (description, etc.) + // gray = no objectives (won't actually complete in-game) + for (const auto& q : quests) { + bool hasObjs = !q.objectives.empty(); + bool hasReward = (q.reward.xp > 0 || !q.reward.itemRewards.empty()); + std::string color = hasObjs ? (hasReward ? "lightgreen" : "lightyellow") + : "lightgray"; + std::string label = "[" + std::to_string(q.id) + "] " + dotEsc(q.title); + if (q.requiredLevel > 1) { + label += "\\nlvl " + std::to_string(q.requiredLevel); + } + if (q.reward.xp > 0) { + label += " " + std::to_string(q.reward.xp) + " XP"; + } + out << " q" << q.id << " [label=\"" << label + << "\", fillcolor=" << color << "];\n"; + } + // Edges: quest -> nextQuestId. Style chain-pointers to + // missing quests differently so they stand out visually. + int chainEdges = 0, brokenEdges = 0; + for (const auto& q : quests) { + if (q.nextQuestId == 0) continue; + if (validIds.count(q.nextQuestId) == 0) { + out << " q" << q.id << " -> q" << q.nextQuestId + << " [color=red, style=dashed, label=\"missing\"];\n"; + out << " q" << q.nextQuestId + << " [label=\" [" << q.nextQuestId + << "]\", fillcolor=mistyrose, style=\"filled,dashed\"];\n"; + brokenEdges++; + } else { + out << " q" << q.id << " -> q" << q.nextQuestId << ";\n"; + chainEdges++; + } + } + out << "}\n"; + out.close(); + std::printf("Wrote %s\n", outPath.c_str()); + std::printf(" %zu quests, %d chain edges, %d broken (red/dashed)\n", + quests.size(), chainEdges, brokenEdges); + std::printf(" next: dot -Tpng %s -o quests.png\n", outPath.c_str()); + return 0; } else if (std::strcmp(argv[i], "--validate") == 0 && i + 1 < argc) { std::string zoneDir = argv[++i]; // Optional --json after the dir for machine-readable output