From ff0aa1a3c8c2987e1adb2b821fe2387853969753 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 9 May 2026 15:11:08 -0700 Subject: [PATCH] feat(pipeline): add WLOT (Wowee Loot Table) format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Novel open replacement for AzerothCore-style creature_loot_template / gameobject_loot_template SQL tables. The 13th open format added to the editor. Pairs naturally with the WIT item catalog from the preceding commit: each loot drop's itemId references an entry in a WIT file, so a content pack ships both the item definitions and the loot tables that reference them. The runtime composes WIT + WLOT + WSPN to drive the full "creature dies, drops items" flow without any SQL. Format: • magic "WLOT", version 1, little-endian • per table: creatureId / flags / dropCount / moneyMin..Max / itemDropCount + drops[] • per drop: itemId / chancePercent (float, 0..100) / minQty / maxQty / drop_flags Table flags: QuestOnly, GroupOnly, Pickpocket Drop flags: QuestRequired, GroupRollOnly, AlwaysDrop dropCount is the slot budget — how many distinct drops to roll per kill. Each item drop is rolled independently against its chancePercent (so dropCount=2 with 4 candidate drops at varying chances gives the classic "up to 2 distinct items per kill" behavior). Drops with the AlwaysDrop flag bypass the slot budget — used for guaranteed quest items. API: WoweeLootLoader::save / load / exists / findByCreatureId; presets makeStarter (1 table, 1 drop), makeBandit (4 candidates, dropCount=2, matches the camp spawns from WSPN at creatureId=1000), makeBoss (6 candidates including guaranteed quest item via AlwaysDrop and a group-only epic at 5%). CLI added (5 flags, 486 documented total now): --gen-loot / --gen-loot-bandit / --gen-loot-boss --info-wlot / --validate-wlot Validator catches: creatureId=0, duplicates, chance not in 0..100, NaN chance, money min > max, minQty > maxQty, dropCount=0 with non-empty drops list (silent dead config). All 3 presets save / load / re-validate clean. The bandit table's creatureId=1000 deliberately matches WSPN's makeCamp creatureId so the open-format demo content pack already has working cross-references. --- CMakeLists.txt | 3 + include/pipeline/wowee_loot.hpp | 107 +++++++++++ src/pipeline/wowee_loot.cpp | 212 ++++++++++++++++++++++ tools/editor/cli_arg_required.cpp | 2 + tools/editor/cli_dispatch.cpp | 2 + tools/editor/cli_help.cpp | 10 ++ tools/editor/cli_loot_catalog.cpp | 284 ++++++++++++++++++++++++++++++ tools/editor/cli_loot_catalog.hpp | 11 ++ 8 files changed, 631 insertions(+) create mode 100644 include/pipeline/wowee_loot.hpp create mode 100644 src/pipeline/wowee_loot.cpp create mode 100644 tools/editor/cli_loot_catalog.cpp create mode 100644 tools/editor/cli_loot_catalog.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index bc0aa751..5c09bd51 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -600,6 +600,7 @@ set(WOWEE_SOURCES src/pipeline/wowee_sound.cpp src/pipeline/wowee_spawns.cpp src/pipeline/wowee_items.cpp + src/pipeline/wowee_loot.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/dbc_layout.cpp @@ -1346,6 +1347,7 @@ add_executable(wowee_editor tools/editor/cli_sound_catalog.cpp tools/editor/cli_spawns_catalog.cpp tools/editor/cli_items_catalog.cpp + tools/editor/cli_loot_catalog.cpp tools/editor/cli_quest_objective.cpp tools/editor/cli_quest_reward.cpp tools/editor/cli_clone.cpp @@ -1424,6 +1426,7 @@ add_executable(wowee_editor src/pipeline/wowee_sound.cpp src/pipeline/wowee_spawns.cpp src/pipeline/wowee_items.cpp + src/pipeline/wowee_loot.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/terrain_mesh.cpp diff --git a/include/pipeline/wowee_loot.hpp b/include/pipeline/wowee_loot.hpp new file mode 100644 index 00000000..8f3cbe0d --- /dev/null +++ b/include/pipeline/wowee_loot.hpp @@ -0,0 +1,107 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +// Wowee Open Loot Table (.wlot) — novel replacement for the +// creature_loot_template / gameobject_loot_template SQL +// tables AzerothCore-style servers use to drive what drops +// when a creature is killed (or a chest is opened). The +// 13th open format added to the editor. +// +// Pairs naturally with the WIT item catalog from the +// previous commit: each loot drop's itemId references an +// entry in a WIT file, so a content pack ships both the +// item definitions and the loot tables that reference them. +// +// One file holds many creature loot tables in one catalog. +// Each table has a moneyMin/moneyMax range plus a list of +// possible item drops. dropCount controls how many distinct +// drops to roll per kill (one die roll per slot, independent). +// +// Binary layout (little-endian): +// magic[4] = "WLOT" +// version (uint32) = current 1 +// nameLen (uint32) + name bytes -- catalog label +// entryCount (uint32) +// entries (each): +// creatureId (uint32) +// flags (uint32) +// dropCount (uint8) + pad[3] +// moneyMinCopper (uint32) +// moneyMaxCopper (uint32) +// itemDropCount (uint32) +// itemDrops (itemDropCount × { +// itemId (uint32) +// chancePercent (float) -- 0..100 +// minQty (uint8) +// maxQty (uint8) +// drop_flags (uint8) + pad[1] +// }) +struct WoweeLoot { + enum TableFlags : uint32_t { + QuestOnly = 0x01, // table only used while killer has matching quest + GroupOnly = 0x02, // table only used in group / raid (not solo) + Pickpocket = 0x04, // alternate table used by rogues, not normal kill + }; + + enum DropFlags : uint8_t { + QuestRequired = 0x01, // drop only if killer has matching quest + GroupRollOnly = 0x02, // skip on solo kills (rare/epic-tier loot) + AlwaysDrop = 0x04, // bypass dropCount slot limit + }; + + struct ItemDrop { + uint32_t itemId = 0; + float chancePercent = 100.0f; + uint8_t minQty = 1; + uint8_t maxQty = 1; + uint8_t flags = 0; + }; + + struct Entry { + uint32_t creatureId = 0; + uint32_t flags = 0; + uint8_t dropCount = 1; // distinct drops rolled per kill + uint32_t moneyMinCopper = 0; + uint32_t moneyMaxCopper = 0; + std::vector itemDrops; + }; + + std::string name; + std::vector entries; + + bool isValid() const { return !entries.empty(); } + + // Lookup by creatureId — nullptr if not present. + const Entry* findByCreatureId(uint32_t creatureId) const; +}; + +class WoweeLootLoader { +public: + static bool save(const WoweeLoot& cat, + const std::string& basePath); + static WoweeLoot load(const std::string& basePath); + static bool exists(const std::string& basePath); + + // Preset emitters used by --gen-loot* variants. + // + // makeStarter — minimal: 1 creature with 1 drop slot, + // 1 item @ 50% chance + 0..50 copper. + // makeBandit — bandit table: dropCount=2, 4 candidate + // items (linen, cloth, knife, ale), each + // at distinct chances; 5..50 copper. + // makeBoss — elite boss: dropCount=4, 6 candidates + // including a guaranteed quest item, plus + // GroupRollOnly epic at 5%; 50..200 silver. + static WoweeLoot makeStarter(const std::string& catalogName); + static WoweeLoot makeBandit(const std::string& catalogName); + static WoweeLoot makeBoss(const std::string& catalogName); +}; + +} // namespace pipeline +} // namespace wowee diff --git a/src/pipeline/wowee_loot.cpp b/src/pipeline/wowee_loot.cpp new file mode 100644 index 00000000..46c3ad86 --- /dev/null +++ b/src/pipeline/wowee_loot.cpp @@ -0,0 +1,212 @@ +#include "pipeline/wowee_loot.hpp" + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +namespace { + +constexpr char kMagic[4] = {'W', 'L', 'O', 'T'}; +constexpr uint32_t kVersion = 1; + +template +void writePOD(std::ofstream& os, const T& v) { + os.write(reinterpret_cast(&v), sizeof(T)); +} + +template +bool readPOD(std::ifstream& is, T& v) { + is.read(reinterpret_cast(&v), sizeof(T)); + return is.gcount() == static_cast(sizeof(T)); +} + +void writeStr(std::ofstream& os, const std::string& s) { + uint32_t n = static_cast(s.size()); + writePOD(os, n); + if (n > 0) os.write(s.data(), n); +} + +bool readStr(std::ifstream& is, std::string& s) { + uint32_t n = 0; + if (!readPOD(is, n)) return false; + if (n > (1u << 20)) return false; + s.resize(n); + if (n > 0) { + is.read(s.data(), n); + if (is.gcount() != static_cast(n)) { + s.clear(); + return false; + } + } + return true; +} + +std::string normalizePath(std::string base) { + if (base.size() < 5 || base.substr(base.size() - 5) != ".wlot") { + base += ".wlot"; + } + return base; +} + +} // namespace + +const WoweeLoot::Entry* WoweeLoot::findByCreatureId(uint32_t creatureId) const { + for (const auto& e : entries) { + if (e.creatureId == creatureId) return &e; + } + return nullptr; +} + +bool WoweeLootLoader::save(const WoweeLoot& cat, + const std::string& basePath) { + std::ofstream os(normalizePath(basePath), std::ios::binary); + if (!os) return false; + os.write(kMagic, 4); + writePOD(os, kVersion); + writeStr(os, cat.name); + uint32_t entryCount = static_cast(cat.entries.size()); + writePOD(os, entryCount); + for (const auto& e : cat.entries) { + writePOD(os, e.creatureId); + writePOD(os, e.flags); + writePOD(os, e.dropCount); + uint8_t pad[3] = {0, 0, 0}; + os.write(reinterpret_cast(pad), 3); + writePOD(os, e.moneyMinCopper); + writePOD(os, e.moneyMaxCopper); + uint32_t dropN = static_cast(e.itemDrops.size()); + writePOD(os, dropN); + for (const auto& d : e.itemDrops) { + writePOD(os, d.itemId); + writePOD(os, d.chancePercent); + writePOD(os, d.minQty); + writePOD(os, d.maxQty); + writePOD(os, d.flags); + uint8_t dpad = 0; + writePOD(os, dpad); + } + } + return os.good(); +} + +WoweeLoot WoweeLootLoader::load(const std::string& basePath) { + WoweeLoot out; + std::ifstream is(normalizePath(basePath), std::ios::binary); + if (!is) return out; + char magic[4]; + is.read(magic, 4); + if (std::memcmp(magic, kMagic, 4) != 0) return out; + uint32_t version = 0; + if (!readPOD(is, version) || version != kVersion) return out; + if (!readStr(is, out.name)) return out; + uint32_t entryCount = 0; + if (!readPOD(is, entryCount)) return out; + if (entryCount > (1u << 20)) return out; + out.entries.resize(entryCount); + for (auto& e : out.entries) { + if (!readPOD(is, e.creatureId) || + !readPOD(is, e.flags) || + !readPOD(is, e.dropCount)) { + out.entries.clear(); return out; + } + uint8_t pad[3]; + is.read(reinterpret_cast(pad), 3); + if (is.gcount() != 3) { out.entries.clear(); return out; } + if (!readPOD(is, e.moneyMinCopper) || + !readPOD(is, e.moneyMaxCopper)) { + out.entries.clear(); return out; + } + uint32_t dropN = 0; + if (!readPOD(is, dropN)) { out.entries.clear(); return out; } + if (dropN > (1u << 16)) { out.entries.clear(); return out; } + e.itemDrops.resize(dropN); + for (auto& d : e.itemDrops) { + if (!readPOD(is, d.itemId) || + !readPOD(is, d.chancePercent) || + !readPOD(is, d.minQty) || + !readPOD(is, d.maxQty) || + !readPOD(is, d.flags)) { + out.entries.clear(); return out; + } + uint8_t dpad = 0; + if (!readPOD(is, dpad)) { + out.entries.clear(); return out; + } + } + } + return out; +} + +bool WoweeLootLoader::exists(const std::string& basePath) { + std::ifstream is(normalizePath(basePath), std::ios::binary); + return is.good(); +} + +WoweeLoot WoweeLootLoader::makeStarter(const std::string& catalogName) { + WoweeLoot c; + c.name = catalogName; + { + WoweeLoot::Entry e; + e.creatureId = 1; + e.dropCount = 1; + e.moneyMinCopper = 0; e.moneyMaxCopper = 50; + e.itemDrops.push_back({3, 50.0f, 1, 1, 0}); // healing potion 50% + c.entries.push_back(e); + } + return c; +} + +WoweeLoot WoweeLootLoader::makeBandit(const std::string& catalogName) { + WoweeLoot c; + c.name = catalogName; + { + WoweeLoot::Entry e; + e.creatureId = 1000; // matches the camp spawns from WSPN + e.dropCount = 2; + e.moneyMinCopper = 5; e.moneyMaxCopper = 50; + // Each item is rolled independently against its + // chancePercent; the dropCount=2 means up to 2 + // distinct items per kill (the runtime is responsible + // for pickin which 2 to roll first). + e.itemDrops.push_back({2, 35.0f, 1, 1, 0}); // linen vest @ 35% + e.itemDrops.push_back({101, 25.0f, 1, 3, 0}); // bolt of cloth @ 25% + e.itemDrops.push_back({1001, 10.0f, 1, 1, 0}); // apprentice sword @ 10% + e.itemDrops.push_back({102, 60.0f, 1, 1, 0}); // ale flask @ 60% + c.entries.push_back(e); + } + return c; +} + +WoweeLoot WoweeLootLoader::makeBoss(const std::string& catalogName) { + WoweeLoot c; + c.name = catalogName; + { + WoweeLoot::Entry e; + e.creatureId = 9999; + e.flags = 0; + e.dropCount = 4; + // Boss money: 50..200 silver = 5000..20000 copper. + e.moneyMinCopper = 5000; + e.moneyMaxCopper = 20000; + // Guaranteed quest item. + e.itemDrops.push_back({4, 100.0f, 1, 1, + WoweeLoot::QuestRequired | WoweeLoot::AlwaysDrop}); + // Common drops. + e.itemDrops.push_back({2, 80.0f, 1, 1, 0}); // chest + e.itemDrops.push_back({1002, 40.0f, 1, 1, 0}); // journeyman blade + e.itemDrops.push_back({2002, 30.0f, 1, 1, 0}); // iron chest + // Group-only epic drop (low chance). + e.itemDrops.push_back({1004, 5.0f, 1, 1, + WoweeLoot::GroupRollOnly}); // bloodforged + // Mass-loot trade goods. + e.itemDrops.push_back({101, 90.0f, 2, 5, 0}); // bolt of cloth + c.entries.push_back(e); + } + return c; +} + +} // namespace pipeline +} // namespace wowee diff --git a/tools/editor/cli_arg_required.cpp b/tools/editor/cli_arg_required.cpp index ae108146..6b4a0685 100644 --- a/tools/editor/cli_arg_required.cpp +++ b/tools/editor/cli_arg_required.cpp @@ -34,6 +34,8 @@ const char* const kArgRequired[] = { "--export-wspn-json", "--import-wspn-json", "--gen-items", "--gen-items-weapons", "--gen-items-armor", "--info-wit", "--validate-wit", + "--gen-loot", "--gen-loot-bandit", "--gen-loot-boss", + "--info-wlot", "--validate-wlot", "--gen-weather-temperate", "--gen-weather-arctic", "--gen-weather-desert", "--gen-weather-stormy", "--gen-zone-atmosphere", diff --git a/tools/editor/cli_dispatch.cpp b/tools/editor/cli_dispatch.cpp index 8ac19b13..0a02e0cd 100644 --- a/tools/editor/cli_dispatch.cpp +++ b/tools/editor/cli_dispatch.cpp @@ -40,6 +40,7 @@ #include "cli_sound_catalog.hpp" #include "cli_spawns_catalog.hpp" #include "cli_items_catalog.hpp" +#include "cli_loot_catalog.hpp" #include "cli_quest_objective.hpp" #include "cli_quest_reward.hpp" #include "cli_clone.hpp" @@ -121,6 +122,7 @@ constexpr DispatchFn kDispatchTable[] = { handleSoundCatalog, handleSpawnsCatalog, handleItemsCatalog, + handleLootCatalog, handleQuestObjective, handleQuestReward, handleClone, diff --git a/tools/editor/cli_help.cpp b/tools/editor/cli_help.cpp index 317469b7..dcc9a3fe 100644 --- a/tools/editor/cli_help.cpp +++ b/tools/editor/cli_help.cpp @@ -877,6 +877,16 @@ void printUsage(const char* argv0) { std::printf(" Print WIT item entries (id / ilvl / quality / class / slot / buy price / name)\n"); std::printf(" --validate-wit [--json]\n"); std::printf(" Static checks: itemId>0 + unique, weapon damage>0 + min<=max, equippable durability>0, sell [name]\n"); + std::printf(" Emit .wlot starter loot catalog: 1 creature with 1 drop slot, 1 item @ 50%%, 0..50 copper\n"); + std::printf(" --gen-loot-bandit [name]\n"); + std::printf(" Emit .wlot bandit loot table: dropCount=2, 4 candidate items, 5..50 copper\n"); + std::printf(" --gen-loot-boss [name]\n"); + std::printf(" Emit .wlot elite boss table: dropCount=4, 6 candidates incl. quest item + group-only epic, 50..200 silver\n"); + std::printf(" --info-wlot [--json]\n"); + std::printf(" Print WLOT loot tables (creatureId / dropCount / money range / per-drop chance + qty + flags)\n"); + std::printf(" --validate-wlot [--json]\n"); + std::printf(" Static checks: creatureId>0 + unique, chance in 0..100, minQty<=maxQty, money min<=max\n"); std::printf(" --gen-weather-temperate [zoneName]\n"); std::printf(" Emit .wow weather schedule: clear-dominant + occasional rain + fog (forest / grassland)\n"); std::printf(" --gen-weather-arctic [zoneName]\n"); diff --git a/tools/editor/cli_loot_catalog.cpp b/tools/editor/cli_loot_catalog.cpp new file mode 100644 index 00000000..8390c641 --- /dev/null +++ b/tools/editor/cli_loot_catalog.cpp @@ -0,0 +1,284 @@ +#include "cli_loot_catalog.hpp" +#include "cli_arg_parse.hpp" +#include "cli_box_emitter.hpp" + +#include "pipeline/wowee_loot.hpp" +#include + +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { +namespace cli { + +namespace { + +std::string stripWlotExt(std::string base) { + stripExt(base, ".wlot"); + return base; +} + +bool saveOrError(const wowee::pipeline::WoweeLoot& c, + const std::string& base, const char* cmd) { + if (!wowee::pipeline::WoweeLootLoader::save(c, base)) { + std::fprintf(stderr, "%s: failed to save %s.wlot\n", + cmd, base.c_str()); + return false; + } + return true; +} + +uint32_t totalDrops(const wowee::pipeline::WoweeLoot& c) { + uint32_t n = 0; + for (const auto& e : c.entries) n += static_cast(e.itemDrops.size()); + return n; +} + +void printGenSummary(const wowee::pipeline::WoweeLoot& c, + const std::string& base) { + std::printf("Wrote %s.wlot\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" tables : %zu (%u drop entries total)\n", + c.entries.size(), totalDrops(c)); +} + +int handleGenStarter(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "StarterLoot"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWlotExt(base); + auto c = wowee::pipeline::WoweeLootLoader::makeStarter(name); + if (!saveOrError(c, base, "gen-loot")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenBandit(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "BanditLoot"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWlotExt(base); + auto c = wowee::pipeline::WoweeLootLoader::makeBandit(name); + if (!saveOrError(c, base, "gen-loot-bandit")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenBoss(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "BossLoot"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWlotExt(base); + auto c = wowee::pipeline::WoweeLootLoader::makeBoss(name); + if (!saveOrError(c, base, "gen-loot-boss")) return 1; + printGenSummary(c, base); + return 0; +} + +void appendDropFlagsStr(std::string& s, uint8_t flags) { + if (flags & wowee::pipeline::WoweeLoot::QuestRequired) s += "quest "; + if (flags & wowee::pipeline::WoweeLoot::GroupRollOnly) s += "group "; + if (flags & wowee::pipeline::WoweeLoot::AlwaysDrop) s += "always "; + if (s.empty()) s = "-"; + else if (s.back() == ' ') s.pop_back(); +} + +void appendTableFlagsStr(std::string& s, uint32_t flags) { + if (flags & wowee::pipeline::WoweeLoot::QuestOnly) s += "quest-only "; + if (flags & wowee::pipeline::WoweeLoot::GroupOnly) s += "group-only "; + if (flags & wowee::pipeline::WoweeLoot::Pickpocket) s += "pickpocket "; + if (s.empty()) s = "-"; + else if (s.back() == ' ') s.pop_back(); +} + +int handleInfo(int& i, int argc, char** argv) { + std::string base = argv[++i]; + bool jsonOut = consumeJsonFlag(i, argc, argv); + base = stripWlotExt(base); + if (!wowee::pipeline::WoweeLootLoader::exists(base)) { + std::fprintf(stderr, "WLOT not found: %s.wlot\n", base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeLootLoader::load(base); + if (jsonOut) { + nlohmann::json j; + j["wlot"] = base + ".wlot"; + j["name"] = c.name; + j["count"] = c.entries.size(); + j["totalDrops"] = totalDrops(c); + nlohmann::json arr = nlohmann::json::array(); + for (const auto& e : c.entries) { + std::string fs; + appendTableFlagsStr(fs, e.flags); + nlohmann::json je; + je["creatureId"] = e.creatureId; + je["flags"] = e.flags; + je["flagsStr"] = fs; + je["dropCount"] = e.dropCount; + je["moneyMinCopper"] = e.moneyMinCopper; + je["moneyMaxCopper"] = e.moneyMaxCopper; + nlohmann::json drops = nlohmann::json::array(); + for (const auto& d : e.itemDrops) { + std::string dfs; + appendDropFlagsStr(dfs, d.flags); + drops.push_back({ + {"itemId", d.itemId}, + {"chancePercent", d.chancePercent}, + {"minQty", d.minQty}, + {"maxQty", d.maxQty}, + {"flags", d.flags}, + {"flagsStr", dfs}, + }); + } + je["itemDrops"] = drops; + arr.push_back(je); + } + j["entries"] = arr; + std::printf("%s\n", j.dump(2).c_str()); + return 0; + } + std::printf("WLOT: %s.wlot\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" tables : %zu (%u drop entries total)\n", + c.entries.size(), totalDrops(c)); + if (c.entries.empty()) return 0; + for (const auto& e : c.entries) { + std::string fs; + appendTableFlagsStr(fs, e.flags); + std::printf("\n creatureId=%u dropCount=%u money=%u..%uc flags=%s\n", + e.creatureId, e.dropCount, + e.moneyMinCopper, e.moneyMaxCopper, + fs.c_str()); + if (e.itemDrops.empty()) { + std::printf(" *no item drops*\n"); + continue; + } + std::printf(" itemId chance%% qty flags\n"); + for (const auto& d : e.itemDrops) { + std::string dfs; + appendDropFlagsStr(dfs, d.flags); + std::printf(" %6u %5.1f %u..%u %s\n", + d.itemId, d.chancePercent, + d.minQty, d.maxQty, dfs.c_str()); + } + } + return 0; +} + +int handleValidate(int& i, int argc, char** argv) { + std::string base = argv[++i]; + bool jsonOut = consumeJsonFlag(i, argc, argv); + base = stripWlotExt(base); + if (!wowee::pipeline::WoweeLootLoader::exists(base)) { + std::fprintf(stderr, + "validate-wlot: WLOT not found: %s.wlot\n", base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeLootLoader::load(base); + std::vector errors; + std::vector warnings; + if (c.entries.empty()) { + warnings.push_back("catalog has zero entries"); + } + std::vector idsSeen; + idsSeen.reserve(c.entries.size()); + for (size_t k = 0; k < c.entries.size(); ++k) { + const auto& e = c.entries[k]; + std::string ctx = "entry " + std::to_string(k) + + " (creatureId=" + std::to_string(e.creatureId) + ")"; + if (e.creatureId == 0) { + errors.push_back(ctx + ": creatureId is 0"); + } + if (e.moneyMinCopper > e.moneyMaxCopper) { + errors.push_back(ctx + ": moneyMin > moneyMax"); + } + if (e.dropCount == 0 && !e.itemDrops.empty()) { + warnings.push_back(ctx + + ": dropCount=0 but item drops are defined (none will be rolled)"); + } + for (size_t di = 0; di < e.itemDrops.size(); ++di) { + const auto& d = e.itemDrops[di]; + std::string dctx = ctx + " drop " + std::to_string(di) + + " (itemId=" + std::to_string(d.itemId) + ")"; + if (d.itemId == 0) { + errors.push_back(dctx + ": itemId is 0"); + } + if (!std::isfinite(d.chancePercent) || + d.chancePercent < 0.0f || d.chancePercent > 100.0f) { + errors.push_back(dctx + + ": chancePercent must be in 0..100, got " + + std::to_string(d.chancePercent)); + } + if (d.minQty == 0) { + warnings.push_back(dctx + ": minQty=0 (drop with zero quantity)"); + } + if (d.minQty > d.maxQty) { + errors.push_back(dctx + ": minQty > maxQty"); + } + } + for (uint32_t prev : idsSeen) { + if (prev == e.creatureId) { + errors.push_back(ctx + ": duplicate creatureId"); + break; + } + } + idsSeen.push_back(e.creatureId); + } + bool ok = errors.empty(); + if (jsonOut) { + nlohmann::json j; + j["wlot"] = base + ".wlot"; + j["ok"] = ok; + j["errors"] = errors; + j["warnings"] = warnings; + std::printf("%s\n", j.dump(2).c_str()); + return ok ? 0 : 1; + } + std::printf("validate-wlot: %s.wlot\n", base.c_str()); + if (ok && warnings.empty()) { + std::printf(" OK — %zu tables, %u total drops, all creatureIds unique\n", + c.entries.size(), totalDrops(c)); + return 0; + } + if (!warnings.empty()) { + std::printf(" warnings (%zu):\n", warnings.size()); + for (const auto& w : warnings) + std::printf(" - %s\n", w.c_str()); + } + if (!errors.empty()) { + std::printf(" ERRORS (%zu):\n", errors.size()); + for (const auto& e : errors) + std::printf(" - %s\n", e.c_str()); + } + return ok ? 0 : 1; +} + +} // namespace + +bool handleLootCatalog(int& i, int argc, char** argv, int& outRc) { + if (std::strcmp(argv[i], "--gen-loot") == 0 && i + 1 < argc) { + outRc = handleGenStarter(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-loot-bandit") == 0 && i + 1 < argc) { + outRc = handleGenBandit(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-loot-boss") == 0 && i + 1 < argc) { + outRc = handleGenBoss(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--info-wlot") == 0 && i + 1 < argc) { + outRc = handleInfo(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--validate-wlot") == 0 && i + 1 < argc) { + outRc = handleValidate(i, argc, argv); return true; + } + return false; +} + +} // namespace cli +} // namespace editor +} // namespace wowee diff --git a/tools/editor/cli_loot_catalog.hpp b/tools/editor/cli_loot_catalog.hpp new file mode 100644 index 00000000..fbd8bded --- /dev/null +++ b/tools/editor/cli_loot_catalog.hpp @@ -0,0 +1,11 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +bool handleLootCatalog(int& i, int argc, char** argv, int& outRc); + +} // namespace cli +} // namespace editor +} // namespace wowee