feat(editor): add --export-quest-graph for Graphviz quest-chain visualization

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 <missing> node.
This commit is contained in:
Kelsi 2026-05-06 13:29:23 -07:00
parent a183143002
commit c1c1f1b2a8

View file

@ -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 <zoneDir> [out.md]\n");
std::printf(" Render a markdown documentation page for a zone (manifest + content)\n");
std::printf(" --export-quest-graph <zoneDir> [out.dot]\n");
std::printf(" Render quest-chain DAG as Graphviz DOT (pipe to `dot -Tpng -o quests.png`)\n");
std::printf(" --info <wom-base> [--json]\n");
std::printf(" Print WOM file metadata (version, counts) and exit\n");
std::printf(" --info-batches <wom-base> [--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<uint32_t> 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=\"<missing> [" << 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