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