feat(editor): add --check-zone-content for content data-quality validation

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.
This commit is contained in:
Kelsi 2026-05-06 15:31:12 -07:00
parent c9e8ad9930
commit dadefab64e

View file

@ -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 <zoneDir> [--json]\n");
std::printf(" Verify every referenced model/quest NPC actually exists; exit 1 on missing refs\n");
std::printf(" --check-zone-content <zoneDir> [--json]\n");
std::printf(" Sanity-check creature/object/quest fields for plausible values\n");
std::printf(" --for-each-zone <projectDir> -- <cmd...>\n");
std::printf(" Run <cmd...> for every zone in <projectDir>; '{}' in cmd is replaced with the zone path\n");
std::printf(" --scaffold-zone <name> [tx ty] Create a blank zone in custom_zones/<name>/ 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<std::string> 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 <projectDir> and runs the
// command after '--' for each one. '{}' in the command is