diff --git a/CMakeLists.txt b/CMakeLists.txt index 34161e4f..b3410882 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -609,6 +609,7 @@ set(WOWEE_SOURCES src/pipeline/wowee_skills.cpp src/pipeline/wowee_spells.cpp src/pipeline/wowee_achievements.cpp + src/pipeline/wowee_trainers.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/dbc_layout.cpp @@ -1364,6 +1365,7 @@ add_executable(wowee_editor tools/editor/cli_skills_catalog.cpp tools/editor/cli_spells_catalog.cpp tools/editor/cli_achievements_catalog.cpp + tools/editor/cli_trainers_catalog.cpp tools/editor/cli_quest_objective.cpp tools/editor/cli_quest_reward.cpp tools/editor/cli_clone.cpp @@ -1451,6 +1453,7 @@ add_executable(wowee_editor src/pipeline/wowee_skills.cpp src/pipeline/wowee_spells.cpp src/pipeline/wowee_achievements.cpp + src/pipeline/wowee_trainers.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/terrain_mesh.cpp diff --git a/include/pipeline/wowee_trainers.hpp b/include/pipeline/wowee_trainers.hpp new file mode 100644 index 00000000..48f486d5 --- /dev/null +++ b/include/pipeline/wowee_trainers.hpp @@ -0,0 +1,120 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +// Wowee Open Trainer / Vendor catalog (.wtrn) — novel +// replacement for AzerothCore-style npc_trainer + npc_vendor +// SQL tables PLUS the Blizzard TrainerSpells.dbc family. +// The 22nd open format added to the editor. +// +// Unifies trainer spell lists and vendor inventories into one +// per-NPC entry. A creature flagged Trainer or Vendor in WCRT +// references a WTRN entry that lists what they teach / sell. +// The same NPC can be both — kindMask is a bitmask covering +// the Trainer (0x01) and Vendor (0x02) kinds. +// +// Cross-references with previously-added formats: +// WTRN.entry.npcId → WCRT.entry.creatureId +// WTRN.spell.spellId → WSPL.entry.spellId +// WTRN.spell.requiredSkillId → WSKL.entry.skillId +// WTRN.item.itemId → WIT.entry.itemId +// +// Binary layout (little-endian): +// magic[4] = "WTRN" +// version (uint32) = current 1 +// nameLen + name (catalog label) +// entryCount (uint32) +// entries (each): +// npcId (uint32) +// kindMask (uint8) + pad[3] +// greetingLen + greeting +// spellCount (uint16) + itemCount (uint16) +// spells (spellCount × { +// spellId (uint32) +// moneyCostCopper (uint32) +// requiredSkillId (uint32) +// requiredSkillRank (uint16) +// requiredLevel (uint16) +// }) +// items (itemCount × { +// itemId (uint32) +// stockCount (uint32) -- 0xFFFFFFFF = unlimited +// restockSec (uint32) +// extendedCost (uint32) +// moneyCostCopper (uint32) -- 0 = use WIT.buyPrice +// }) +struct WoweeTrainer { + enum KindMask : uint8_t { + Trainer = 0x01, + Vendor = 0x02, + }; + + static constexpr uint32_t kUnlimitedStock = 0xFFFFFFFFu; + + struct SpellOffer { + uint32_t spellId = 0; + uint32_t moneyCostCopper = 0; + uint32_t requiredSkillId = 0; // 0 = no skill prerequisite + uint16_t requiredSkillRank = 0; + uint16_t requiredLevel = 1; + }; + + struct ItemOffer { + uint32_t itemId = 0; + uint32_t stockCount = kUnlimitedStock; + uint32_t restockSec = 0; + uint32_t extendedCost = 0; // 0 = copper-only + uint32_t moneyCostCopper = 0; // 0 = inherit from WIT + }; + + struct Entry { + uint32_t npcId = 0; + uint8_t kindMask = 0; + std::string greeting; + std::vector spells; + std::vector items; + }; + + std::string name; + std::vector entries; + + bool isValid() const { return !entries.empty(); } + + // Lookup by npcId — nullptr if not present. + const Entry* findByNpc(uint32_t npcId) const; + + // Decode the kindMask into a short string (e.g. + // "trainer+vendor" or just "vendor"). + static std::string kindMaskName(uint8_t k); +}; + +class WoweeTrainerLoader { +public: + static bool save(const WoweeTrainer& cat, + const std::string& basePath); + static WoweeTrainer load(const std::string& basePath); + static bool exists(const std::string& basePath); + + // Preset emitters used by --gen-trainers* variants. + // + // makeStarter — 1 NPC (Bartleby innkeeper, npcId=4001) + // acting as both vendor + trainer: sells + // 3 starter items + teaches First Aid. + // makeMageTrainer — npcId=4003 (alchemist) becomes a + // mage trainer offering Frostbolt / + // Fireball / Arcane Intellect / Blink + // at appropriate ranks. + // makeWeaponVendor — npcId=4002 (smith) sells 5 weapons + // across the WIT weapon catalog. + static WoweeTrainer makeStarter(const std::string& catalogName); + static WoweeTrainer makeMageTrainer(const std::string& catalogName); + static WoweeTrainer makeWeaponVendor(const std::string& catalogName); +}; + +} // namespace pipeline +} // namespace wowee diff --git a/src/pipeline/wowee_trainers.cpp b/src/pipeline/wowee_trainers.cpp new file mode 100644 index 00000000..f827b1a6 --- /dev/null +++ b/src/pipeline/wowee_trainers.cpp @@ -0,0 +1,234 @@ +#include "pipeline/wowee_trainers.hpp" + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +namespace { + +constexpr char kMagic[4] = {'W', 'T', 'R', 'N'}; +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) != ".wtrn") { + base += ".wtrn"; + } + return base; +} + +} // namespace + +const WoweeTrainer::Entry* WoweeTrainer::findByNpc(uint32_t npcId) const { + for (const auto& e : entries) { + if (e.npcId == npcId) return &e; + } + return nullptr; +} + +std::string WoweeTrainer::kindMaskName(uint8_t k) { + std::string s; + if (k & Trainer) s += "trainer"; + if (k & Vendor) { if (!s.empty()) s += "+"; s += "vendor"; } + if (s.empty()) s = "-"; + return s; +} + +bool WoweeTrainerLoader::save(const WoweeTrainer& 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.npcId); + writePOD(os, e.kindMask); + uint8_t pad[3] = {0, 0, 0}; + os.write(reinterpret_cast(pad), 3); + writeStr(os, e.greeting); + uint16_t spellCount = static_cast( + e.spells.size() > 0xFFFF ? 0xFFFF : e.spells.size()); + uint16_t itemCount = static_cast( + e.items.size() > 0xFFFF ? 0xFFFF : e.items.size()); + writePOD(os, spellCount); + writePOD(os, itemCount); + for (uint16_t k = 0; k < spellCount; ++k) { + const auto& s = e.spells[k]; + writePOD(os, s.spellId); + writePOD(os, s.moneyCostCopper); + writePOD(os, s.requiredSkillId); + writePOD(os, s.requiredSkillRank); + writePOD(os, s.requiredLevel); + } + for (uint16_t k = 0; k < itemCount; ++k) { + const auto& it = e.items[k]; + writePOD(os, it.itemId); + writePOD(os, it.stockCount); + writePOD(os, it.restockSec); + writePOD(os, it.extendedCost); + writePOD(os, it.moneyCostCopper); + } + } + return os.good(); +} + +WoweeTrainer WoweeTrainerLoader::load(const std::string& basePath) { + WoweeTrainer 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.npcId) || !readPOD(is, e.kindMask)) { + 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 (!readStr(is, e.greeting)) { out.entries.clear(); return out; } + uint16_t spellCount = 0, itemCount = 0; + if (!readPOD(is, spellCount) || !readPOD(is, itemCount)) { + out.entries.clear(); return out; + } + e.spells.resize(spellCount); + for (uint16_t k = 0; k < spellCount; ++k) { + auto& s = e.spells[k]; + if (!readPOD(is, s.spellId) || + !readPOD(is, s.moneyCostCopper) || + !readPOD(is, s.requiredSkillId) || + !readPOD(is, s.requiredSkillRank) || + !readPOD(is, s.requiredLevel)) { + out.entries.clear(); return out; + } + } + e.items.resize(itemCount); + for (uint16_t k = 0; k < itemCount; ++k) { + auto& it = e.items[k]; + if (!readPOD(is, it.itemId) || + !readPOD(is, it.stockCount) || + !readPOD(is, it.restockSec) || + !readPOD(is, it.extendedCost) || + !readPOD(is, it.moneyCostCopper)) { + out.entries.clear(); return out; + } + } + } + return out; +} + +bool WoweeTrainerLoader::exists(const std::string& basePath) { + std::ifstream is(normalizePath(basePath), std::ios::binary); + return is.good(); +} + +WoweeTrainer WoweeTrainerLoader::makeStarter(const std::string& catalogName) { + WoweeTrainer c; + c.name = catalogName; + { + // npcId 4001 matches WCRT.makeStarter / makeMerchants + // (Bartleby innkeeper). + WoweeTrainer::Entry e; + e.npcId = 4001; + e.kindMask = WoweeTrainer::Trainer | WoweeTrainer::Vendor; + e.greeting = "Welcome to the inn, traveler. What can I do for you?"; + // Train First Aid (skillId 129 in WSKL.makeProfessions). + e.spells.push_back({4001, 100, 129, 1, 1}); // teaches First Aid + // Sell starter items (itemIds match WIT.makeStarter: + // 2=Linen Vest, 3=Healing Potion). Use moneyCost=0 to + // mean "use WIT.buyPrice". + e.items.push_back({2, WoweeTrainer::kUnlimitedStock, 0, 0, 0}); + e.items.push_back({3, WoweeTrainer::kUnlimitedStock, 0, 0, 0}); + e.items.push_back({4, 1, 86400, 0, 0}); // 1 unique item / 24h + c.entries.push_back(e); + } + return c; +} + +WoweeTrainer WoweeTrainerLoader::makeMageTrainer(const std::string& catalogName) { + WoweeTrainer c; + c.name = catalogName; + { + // npcId 4003 = alchemist NPC repurposed as a mage + // trainer for the demo. Spell IDs match WSPL.makeMage. + WoweeTrainer::Entry e; + e.npcId = 4003; + e.kindMask = WoweeTrainer::Trainer; + e.greeting = "Magic is a craft. Will you learn?"; + // Each spell costs scaling copper, requires reagent + // skill (none here), and a minimum character level. + e.spells.push_back({116, 100, 0, 0, 4}); // Frostbolt @ lvl 4 + e.spells.push_back({133, 100, 0, 0, 1}); // Fireball @ lvl 1 + e.spells.push_back({1459, 1000, 0, 0, 10}); // Arcane Int @ lvl 10 + e.spells.push_back({1953, 5000, 0, 0, 20}); // Blink @ lvl 20 + c.entries.push_back(e); + } + return c; +} + +WoweeTrainer WoweeTrainerLoader::makeWeaponVendor(const std::string& catalogName) { + WoweeTrainer c; + c.name = catalogName; + { + // npcId 4002 = smith from WCRT.makeMerchants. Sells + // weapons matching WIT.makeWeapons itemIds. + WoweeTrainer::Entry e; + e.npcId = 4002; + e.kindMask = WoweeTrainer::Vendor; + e.greeting = "Strong steel for sturdy folk. Take a look."; + e.items.push_back({1001, WoweeTrainer::kUnlimitedStock, 0, 0, 0}); // Apprentice Sword + e.items.push_back({1002, WoweeTrainer::kUnlimitedStock, 0, 0, 0}); // Journeyman Blade + e.items.push_back({1003, 3, 3600, 0, 0}); // Steelthorn Edge: 3 in stock, refresh 1h + e.items.push_back({1004, 1, 7200, 0, 0}); // Bloodforged: 1 in stock, refresh 2h + e.items.push_back({1005, 0, 0, 0, 0}); // Doombringer: out of stock by default + 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 412f7d1c..79fd2021 100644 --- a/tools/editor/cli_arg_required.cpp +++ b/tools/editor/cli_arg_required.cpp @@ -62,6 +62,8 @@ const char* const kArgRequired[] = { "--gen-achievements", "--gen-achievements-bandit", "--gen-achievements-meta", "--info-wach", "--validate-wach", "--export-wach-json", "--import-wach-json", + "--gen-trainers", "--gen-trainers-mage", "--gen-trainers-weapons", + "--info-wtrn", "--validate-wtrn", "--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 c52219ca..d3cbce8a 100644 --- a/tools/editor/cli_dispatch.cpp +++ b/tools/editor/cli_dispatch.cpp @@ -49,6 +49,7 @@ #include "cli_skills_catalog.hpp" #include "cli_spells_catalog.hpp" #include "cli_achievements_catalog.hpp" +#include "cli_trainers_catalog.hpp" #include "cli_quest_objective.hpp" #include "cli_quest_reward.hpp" #include "cli_clone.hpp" @@ -139,6 +140,7 @@ constexpr DispatchFn kDispatchTable[] = { handleSkillsCatalog, handleSpellsCatalog, handleAchievementsCatalog, + handleTrainersCatalog, handleQuestObjective, handleQuestReward, handleClone, diff --git a/tools/editor/cli_help.cpp b/tools/editor/cli_help.cpp index 502be9b5..011b269c 100644 --- a/tools/editor/cli_help.cpp +++ b/tools/editor/cli_help.cpp @@ -1007,6 +1007,16 @@ void printUsage(const char* argv0) { std::printf(" Export binary .wach to a human-editable JSON sidecar (defaults to .wach.json)\n"); std::printf(" --import-wach-json [out-base]\n"); std::printf(" Import a .wach.json sidecar back into binary .wach (accepts kind/faction/flag int OR name forms)\n"); + std::printf(" --gen-trainers [name]\n"); + std::printf(" Emit .wtrn starter: 1 NPC (innkeeper 4001) acting as both vendor + trainer with WSKL/WIT cross-refs\n"); + std::printf(" --gen-trainers-mage [name]\n"); + std::printf(" Emit .wtrn mage trainer (npcId=4003): teaches Frostbolt/Fireball/Arcane Intellect/Blink at scaling cost\n"); + std::printf(" --gen-trainers-weapons [name]\n"); + std::printf(" Emit .wtrn weapon vendor (npcId=4002): 5 weapons with mixed unlimited/finite stock + restock timers\n"); + std::printf(" --info-wtrn [--json]\n"); + std::printf(" Print WTRN entries (npc / kind / spells with skill+level reqs / items with stock + restock)\n"); + std::printf(" --validate-wtrn [--json]\n"); + std::printf(" Static checks: npcId>0+unique, kindMask>0, Trainer needs spells, Vendor needs items, no orphan offers\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_trainers_catalog.cpp b/tools/editor/cli_trainers_catalog.cpp new file mode 100644 index 00000000..5a46d2d5 --- /dev/null +++ b/tools/editor/cli_trainers_catalog.cpp @@ -0,0 +1,316 @@ +#include "cli_trainers_catalog.hpp" +#include "cli_arg_parse.hpp" +#include "cli_box_emitter.hpp" + +#include "pipeline/wowee_trainers.hpp" +#include + +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { +namespace cli { + +namespace { + +std::string stripWtrnExt(std::string base) { + stripExt(base, ".wtrn"); + return base; +} + +bool saveOrError(const wowee::pipeline::WoweeTrainer& c, + const std::string& base, const char* cmd) { + if (!wowee::pipeline::WoweeTrainerLoader::save(c, base)) { + std::fprintf(stderr, "%s: failed to save %s.wtrn\n", + cmd, base.c_str()); + return false; + } + return true; +} + +uint32_t totalSpellOffers(const wowee::pipeline::WoweeTrainer& c) { + uint32_t n = 0; + for (const auto& e : c.entries) n += static_cast(e.spells.size()); + return n; +} + +uint32_t totalItemOffers(const wowee::pipeline::WoweeTrainer& c) { + uint32_t n = 0; + for (const auto& e : c.entries) n += static_cast(e.items.size()); + return n; +} + +void printGenSummary(const wowee::pipeline::WoweeTrainer& c, + const std::string& base) { + std::printf("Wrote %s.wtrn\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" npcs : %zu (%u spells offered, %u items offered)\n", + c.entries.size(), + totalSpellOffers(c), + totalItemOffers(c)); +} + +int handleGenStarter(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "StarterTrainers"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWtrnExt(base); + auto c = wowee::pipeline::WoweeTrainerLoader::makeStarter(name); + if (!saveOrError(c, base, "gen-trainers")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenMage(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "MageTrainer"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWtrnExt(base); + auto c = wowee::pipeline::WoweeTrainerLoader::makeMageTrainer(name); + if (!saveOrError(c, base, "gen-trainers-mage")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenWeaponVendor(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "WeaponVendor"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWtrnExt(base); + auto c = wowee::pipeline::WoweeTrainerLoader::makeWeaponVendor(name); + if (!saveOrError(c, base, "gen-trainers-weapons")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleInfo(int& i, int argc, char** argv) { + std::string base = argv[++i]; + bool jsonOut = consumeJsonFlag(i, argc, argv); + base = stripWtrnExt(base); + if (!wowee::pipeline::WoweeTrainerLoader::exists(base)) { + std::fprintf(stderr, "WTRN not found: %s.wtrn\n", base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeTrainerLoader::load(base); + if (jsonOut) { + nlohmann::json j; + j["wtrn"] = base + ".wtrn"; + j["name"] = c.name; + j["count"] = c.entries.size(); + nlohmann::json arr = nlohmann::json::array(); + for (const auto& e : c.entries) { + nlohmann::json je; + je["npcId"] = e.npcId; + je["kindMask"] = e.kindMask; + je["kindMaskName"] = wowee::pipeline::WoweeTrainer::kindMaskName(e.kindMask); + je["greeting"] = e.greeting; + nlohmann::json sa = nlohmann::json::array(); + for (const auto& s : e.spells) { + sa.push_back({ + {"spellId", s.spellId}, + {"moneyCostCopper", s.moneyCostCopper}, + {"requiredSkillId", s.requiredSkillId}, + {"requiredSkillRank", s.requiredSkillRank}, + {"requiredLevel", s.requiredLevel}, + }); + } + je["spells"] = sa; + nlohmann::json ia = nlohmann::json::array(); + for (const auto& it : e.items) { + ia.push_back({ + {"itemId", it.itemId}, + {"stockCount", it.stockCount}, + {"restockSec", it.restockSec}, + {"extendedCost", it.extendedCost}, + {"moneyCostCopper", it.moneyCostCopper}, + }); + } + je["items"] = ia; + arr.push_back(je); + } + j["entries"] = arr; + std::printf("%s\n", j.dump(2).c_str()); + return 0; + } + std::printf("WTRN: %s.wtrn\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" npcs : %zu (%u spells offered, %u items offered)\n", + c.entries.size(), + totalSpellOffers(c), + totalItemOffers(c)); + if (c.entries.empty()) return 0; + for (const auto& e : c.entries) { + std::printf("\n npcId=%u kind=%s\n", + e.npcId, + wowee::pipeline::WoweeTrainer::kindMaskName(e.kindMask).c_str()); + if (!e.greeting.empty()) { + std::printf(" greeting: \"%s\"\n", e.greeting.c_str()); + } + if (!e.spells.empty()) { + std::printf(" spells (%zu):\n", e.spells.size()); + std::printf(" spellId cost skill/rank minLvl\n"); + for (const auto& s : e.spells) { + std::printf(" %5u %5uc %4u/%-4u %u\n", + s.spellId, s.moneyCostCopper, + s.requiredSkillId, s.requiredSkillRank, + s.requiredLevel); + } + } + if (!e.items.empty()) { + std::printf(" items (%zu):\n", e.items.size()); + std::printf(" itemId stock restock override-cost\n"); + for (const auto& it : e.items) { + std::string stockStr = + it.stockCount == wowee::pipeline::WoweeTrainer::kUnlimitedStock + ? std::string("unlimited") : std::to_string(it.stockCount); + std::printf(" %5u %-9s %5us %s%uc\n", + it.itemId, stockStr.c_str(), + it.restockSec, + it.moneyCostCopper == 0 ? "(WIT) " : "", + it.moneyCostCopper); + } + } + } + return 0; +} + +int handleValidate(int& i, int argc, char** argv) { + std::string base = argv[++i]; + bool jsonOut = consumeJsonFlag(i, argc, argv); + base = stripWtrnExt(base); + if (!wowee::pipeline::WoweeTrainerLoader::exists(base)) { + std::fprintf(stderr, + "validate-wtrn: WTRN not found: %s.wtrn\n", base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeTrainerLoader::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) + + " (npcId=" + std::to_string(e.npcId) + ")"; + if (e.npcId == 0) { + errors.push_back(ctx + ": npcId is 0"); + } + if (e.kindMask == 0) { + errors.push_back(ctx + ": kindMask is 0 (NPC offers nothing)"); + } + // Trainer kind needs spells; vendor kind needs items. + if ((e.kindMask & wowee::pipeline::WoweeTrainer::Trainer) && + e.spells.empty()) { + warnings.push_back(ctx + + ": flagged Trainer but has no spells"); + } + if ((e.kindMask & wowee::pipeline::WoweeTrainer::Vendor) && + e.items.empty()) { + warnings.push_back(ctx + + ": flagged Vendor but has no items"); + } + // Items / spells with kindMask not matching are dead config. + if (!(e.kindMask & wowee::pipeline::WoweeTrainer::Trainer) && + !e.spells.empty()) { + warnings.push_back(ctx + + ": has " + std::to_string(e.spells.size()) + + " spells but Trainer bit not set (spells will be ignored)"); + } + if (!(e.kindMask & wowee::pipeline::WoweeTrainer::Vendor) && + !e.items.empty()) { + warnings.push_back(ctx + + ": has " + std::to_string(e.items.size()) + + " items but Vendor bit not set (items will be ignored)"); + } + for (size_t si = 0; si < e.spells.size(); ++si) { + const auto& s = e.spells[si]; + std::string sctx = ctx + " spell " + std::to_string(si); + if (s.spellId == 0) { + errors.push_back(sctx + ": spellId is 0"); + } + } + for (size_t ii = 0; ii < e.items.size(); ++ii) { + const auto& it = e.items[ii]; + std::string ictx = ctx + " item " + std::to_string(ii); + if (it.itemId == 0) { + errors.push_back(ictx + ": itemId is 0"); + } + // Finite stock with restockSec=0 means "single fill" + // — usually intentional but worth surfacing. + if (it.stockCount != wowee::pipeline::WoweeTrainer::kUnlimitedStock && + it.restockSec == 0 && it.stockCount > 0) { + warnings.push_back(ictx + + ": finite stock with restockSec=0 (no automatic refresh)"); + } + } + for (uint32_t prev : idsSeen) { + if (prev == e.npcId) { + errors.push_back(ctx + ": duplicate npcId"); + break; + } + } + idsSeen.push_back(e.npcId); + } + bool ok = errors.empty(); + if (jsonOut) { + nlohmann::json j; + j["wtrn"] = base + ".wtrn"; + 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-wtrn: %s.wtrn\n", base.c_str()); + if (ok && warnings.empty()) { + std::printf(" OK — %zu npcs, %u spell offers, %u item offers\n", + c.entries.size(), + totalSpellOffers(c), + totalItemOffers(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 handleTrainersCatalog(int& i, int argc, char** argv, int& outRc) { + if (std::strcmp(argv[i], "--gen-trainers") == 0 && i + 1 < argc) { + outRc = handleGenStarter(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-trainers-mage") == 0 && i + 1 < argc) { + outRc = handleGenMage(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-trainers-weapons") == 0 && i + 1 < argc) { + outRc = handleGenWeaponVendor(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--info-wtrn") == 0 && i + 1 < argc) { + outRc = handleInfo(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--validate-wtrn") == 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_trainers_catalog.hpp b/tools/editor/cli_trainers_catalog.hpp new file mode 100644 index 00000000..516c940a --- /dev/null +++ b/tools/editor/cli_trainers_catalog.hpp @@ -0,0 +1,11 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +bool handleTrainersCatalog(int& i, int argc, char** argv, int& outRc); + +} // namespace cli +} // namespace editor +} // namespace wowee