From dadefab64e8968255ac880ec51aeef4c6dc0df4e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 6 May 2026 15:31:12 -0700 Subject: [PATCH] feat(editor): add --check-zone-content for content data-quality validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sanity-checks creature/object/quest fields for plausible values. Where --check-zone-refs catches dangling references, this catches data-quality issues that pass technical validation but break gameplay: wowee_editor --check-zone-content custom_zones/MyZone Zone content: custom_zones/MyZone creature warnings: 1 object warnings : 0 quest warnings : 2 FAILED — 3 total warning(s): - creature[0] 'Wolf' has displayId=0 (will render invisibly) - quest[0] 'Hunt' has no objectives (uncompletable) - quest[0] 'Hunt' has no reward at all Per-type checks: Creatures: - empty name - 0 health (dies on spawn) - level 0 - minDamage > maxDamage (broken combat math) - non-positive or non-finite scale - displayId=0 (invisible at runtime) Objects: - empty path - non-positive or non-finite scale - non-finite position Quests: - empty title - no objectives (player can never complete) - no reward at all (XP=0, items=[], coins all 0) - requiredLevel=0 Both --check-zone-refs (link integrity) and --check-zone-content (data quality) needed — a quest can have valid NPC IDs (refs OK) AND no objectives (content broken). Run both before --pack-wcp. Verified end-to-end: zone with displayId=0 creature + objective- less + rewardless quest reports 3 warnings; after fixing all three, PASSED. --- tools/editor/main.cpp | 134 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 133 insertions(+), 1 deletion(-) diff --git a/tools/editor/main.cpp b/tools/editor/main.cpp index 1eadc6be..d7818107 100644 --- a/tools/editor/main.cpp +++ b/tools/editor/main.cpp @@ -434,6 +434,8 @@ static void printUsage(const char* argv0) { std::printf(" Markdown dep table for a zone (with on-disk presence column)\n"); std::printf(" --check-zone-refs [--json]\n"); std::printf(" Verify every referenced model/quest NPC actually exists; exit 1 on missing refs\n"); + std::printf(" --check-zone-content [--json]\n"); + std::printf(" Sanity-check creature/object/quest fields for plausible values\n"); std::printf(" --for-each-zone -- \n"); std::printf(" Run for every zone in ; '{}' in cmd is replaced with the zone path\n"); std::printf(" --scaffold-zone [tx ty] Create a blank zone in custom_zones// and exit\n"); @@ -687,7 +689,8 @@ int main(int argc, char* argv[]) { "--export-zone-csv", "--export-zone-html", "--export-project-html", "--scaffold-zone", "--add-tile", "--remove-tile", "--list-tiles", "--for-each-zone", "--zone-stats", "--info-tilemap", - "--list-zone-deps", "--check-zone-refs", "--export-zone-deps-md", + "--list-zone-deps", "--check-zone-refs", "--check-zone-content", + "--export-zone-deps-md", "--add-creature", "--add-object", "--add-quest", "--add-quest-objective", "--add-quest-reward-item", "--set-quest-reward", "--remove-quest-objective", "--clone-quest", "--clone-creature", @@ -10517,6 +10520,135 @@ int main(int argc, char* argv[]) { std::printf(" FAILED — %d issue(s):\n", totalErrors); for (const auto& e : errors) std::printf(" - %s\n", e.c_str()); return 1; + } else if (std::strcmp(argv[i], "--check-zone-content") == 0 && i + 1 < argc) { + // Sanity-check creature/object/quest fields for plausible + // values. --check-zone-refs catches dangling references; + // this catches data-quality issues like creatures with 0 HP, + // objects with negative scale, quests with no objectives. + // Both are needed — a quest can have valid NPC IDs (refs OK) + // AND no objectives (content broken). + std::string zoneDir = argv[++i]; + bool jsonOut = (i + 1 < argc && + std::strcmp(argv[i + 1], "--json") == 0); + if (jsonOut) i++; + namespace fs = std::filesystem; + if (!fs::exists(zoneDir + "/zone.json")) { + std::fprintf(stderr, + "check-zone-content: %s has no zone.json\n", zoneDir.c_str()); + return 1; + } + std::vector warnings; + int creatureWarn = 0, objectWarn = 0, questWarn = 0; + // Creatures + wowee::editor::NpcSpawner sp; + if (sp.loadFromFile(zoneDir + "/creatures.json")) { + for (size_t k = 0; k < sp.spawnCount(); ++k) { + const auto& s = sp.getSpawns()[k]; + if (s.name.empty()) { + warnings.push_back("creature[" + std::to_string(k) + "] has empty name"); + creatureWarn++; + } + if (s.health == 0) { + warnings.push_back("creature[" + std::to_string(k) + "] '" + + s.name + "' has 0 health"); + creatureWarn++; + } + if (s.level == 0) { + warnings.push_back("creature[" + std::to_string(k) + "] '" + + s.name + "' has level 0"); + creatureWarn++; + } + if (s.minDamage > s.maxDamage) { + warnings.push_back("creature[" + std::to_string(k) + "] '" + + s.name + "' has minDamage > maxDamage"); + creatureWarn++; + } + if (s.scale <= 0.0f || !std::isfinite(s.scale)) { + warnings.push_back("creature[" + std::to_string(k) + "] '" + + s.name + "' has non-positive or non-finite scale"); + creatureWarn++; + } + if (s.displayId == 0) { + warnings.push_back("creature[" + std::to_string(k) + "] '" + + s.name + "' has displayId=0 (will render invisibly)"); + creatureWarn++; + } + } + } + // Objects + wowee::editor::ObjectPlacer op; + if (op.loadFromFile(zoneDir + "/objects.json")) { + for (size_t k = 0; k < op.getObjects().size(); ++k) { + const auto& o = op.getObjects()[k]; + if (o.path.empty()) { + warnings.push_back("object[" + std::to_string(k) + "] has empty path"); + objectWarn++; + } + if (o.scale <= 0.0f || !std::isfinite(o.scale)) { + warnings.push_back("object[" + std::to_string(k) + + "] has non-positive or non-finite scale"); + objectWarn++; + } + if (!std::isfinite(o.position.x) || + !std::isfinite(o.position.y) || + !std::isfinite(o.position.z)) { + warnings.push_back("object[" + std::to_string(k) + + "] has non-finite position"); + objectWarn++; + } + } + } + // Quests + wowee::editor::QuestEditor qe; + if (qe.loadFromFile(zoneDir + "/quests.json")) { + for (size_t k = 0; k < qe.questCount(); ++k) { + const auto& q = qe.getQuests()[k]; + if (q.title.empty()) { + warnings.push_back("quest[" + std::to_string(k) + "] has empty title"); + questWarn++; + } + if (q.objectives.empty()) { + warnings.push_back("quest[" + std::to_string(k) + "] '" + + q.title + "' has no objectives (uncompletable)"); + questWarn++; + } + if (q.reward.xp == 0 && q.reward.itemRewards.empty() && + q.reward.gold == 0 && q.reward.silver == 0 && q.reward.copper == 0) { + warnings.push_back("quest[" + std::to_string(k) + "] '" + + q.title + "' has no reward at all"); + questWarn++; + } + if (q.requiredLevel == 0) { + warnings.push_back("quest[" + std::to_string(k) + "] '" + + q.title + "' has requiredLevel=0"); + questWarn++; + } + } + } + int total = creatureWarn + objectWarn + questWarn; + if (jsonOut) { + nlohmann::json j; + j["zone"] = zoneDir; + j["creatureWarnings"] = creatureWarn; + j["objectWarnings"] = objectWarn; + j["questWarnings"] = questWarn; + j["totalWarnings"] = total; + j["warnings"] = warnings; + j["passed"] = (total == 0); + std::printf("%s\n", j.dump(2).c_str()); + return total == 0 ? 0 : 1; + } + std::printf("Zone content: %s\n", zoneDir.c_str()); + std::printf(" creature warnings: %d\n", creatureWarn); + std::printf(" object warnings : %d\n", objectWarn); + std::printf(" quest warnings : %d\n", questWarn); + if (total == 0) { + std::printf(" PASSED\n"); + return 0; + } + std::printf(" FAILED — %d total warning(s):\n", total); + for (const auto& w : warnings) std::printf(" - %s\n", w.c_str()); + return 1; } else if (std::strcmp(argv[i], "--for-each-zone") == 0 && i + 1 < argc) { // Batch runner: enumerates zones in and runs the // command after '--' for each one. '{}' in the command is