From d2e623de9fd06b7395eaad17da67635248b977bf Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sun, 10 May 2026 04:53:06 -0700 Subject: [PATCH] feat(pipeline): WBHV creature behavior catalog (136th open format) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Novel replacement for the implicit creature-behavior rules vanilla WoW carried in creature_template.AIName + per-creature C++ scripts in the server's ScriptMgr (most rare-elites and bosses had hand-coded class-derived behaviors). Each WBHV entry binds one combat behavior archetype to its creature kind (Melee / Caster / Tank / Healer / Pet / Beast), aggro / leash radii, evade-on-leash policy (ResetToSpawn / HealAtPath / FleeToSpawn / NoEvade for raid bosses), corpse persistence duration, default rotation spell, and a variable-length list of special abilities (spellId + cooldown + use-chance triplets in basis points). Three presets covering common archetypes: --gen-bhv-melee 3 entry-tier melee creatures (Kobold Worker + Timber Wolf + Stranglethorn Raptor) with 1 special ability each --gen-bhv-caster 3 caster patterns (Defias Wizard with Polymorph + Frost Nova / Murloc Coastrunner with Frost Bolt + Lesser Heal / Voidwalker Pet Pattern with Taunt + Sacrifice + Suffering — Sacrifice intentionally has useChancePct=0 as owner-triggered, exercising the validator owner-triggered warning) --gen-bhv-boss 1 Onyxia-pattern dragon (Tank kind, NoEvade leash, 600s corpse for 40-man loot distribution, 4 abilities including 90s-CD Deep Breath) Validator catches: id+name required, creatureKind 0..5, evadeBehavior 0..3, aggroRadius > 0, no duplicate behaviorIds, no zero-spellId specials, no duplicate spellId within same behavior. CRITICAL invariant: leashRadius >= aggroRadius (else creature evades back to spawn before reaching its target — permanently un-killable from outside the leash radius). Warns on corpseDuration < 60s (looting may fail in busy zones), and useChancePct=0 on a special ability (correctly flagged on the Voidwalker Sacrifice spec — verified live in smoke-test). Format count 135 -> 136. CLI flag count 1418 -> 1425. --- CMakeLists.txt | 3 + include/pipeline/wowee_creature_behavior.hpp | 145 ++++++++ src/pipeline/wowee_creature_behavior.cpp | 286 +++++++++++++++ tools/editor/cli_arg_required.cpp | 2 + .../editor/cli_creature_behavior_catalog.cpp | 339 ++++++++++++++++++ .../editor/cli_creature_behavior_catalog.hpp | 12 + tools/editor/cli_dispatch.cpp | 2 + tools/editor/cli_format_table.cpp | 1 + tools/editor/cli_help.cpp | 10 + tools/editor/cli_list_formats.cpp | 1 + 10 files changed, 801 insertions(+) create mode 100644 include/pipeline/wowee_creature_behavior.hpp create mode 100644 src/pipeline/wowee_creature_behavior.cpp create mode 100644 tools/editor/cli_creature_behavior_catalog.cpp create mode 100644 tools/editor/cli_creature_behavior_catalog.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index f189d9c0..b3c8f956 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -724,6 +724,7 @@ set(WOWEE_SOURCES src/pipeline/wowee_crafting_recipes.cpp src/pipeline/wowee_world_locations.cpp src/pipeline/wowee_soulbind_rules.cpp + src/pipeline/wowee_creature_behavior.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/dbc_layout.cpp @@ -1611,6 +1612,7 @@ add_executable(wowee_editor tools/editor/cli_crafting_recipes_catalog.cpp tools/editor/cli_world_locations_catalog.cpp tools/editor/cli_soulbind_rules_catalog.cpp + tools/editor/cli_creature_behavior_catalog.cpp tools/editor/cli_catalog_pluck.cpp tools/editor/cli_catalog_find.cpp tools/editor/cli_catalog_by_name.cpp @@ -1817,6 +1819,7 @@ add_executable(wowee_editor src/pipeline/wowee_crafting_recipes.cpp src/pipeline/wowee_world_locations.cpp src/pipeline/wowee_soulbind_rules.cpp + src/pipeline/wowee_creature_behavior.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/terrain_mesh.cpp diff --git a/include/pipeline/wowee_creature_behavior.hpp b/include/pipeline/wowee_creature_behavior.hpp new file mode 100644 index 00000000..0517678d --- /dev/null +++ b/include/pipeline/wowee_creature_behavior.hpp @@ -0,0 +1,145 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +// Wowee Open Creature Behavior Tree catalog (.wbhv) +// — novel replacement for the implicit creature-AI +// rules vanilla WoW carried in +// creature_template.AIName + per-creature C++ +// scripts in the server's ScriptMgr (most rare- +// elites and bosses had hand-coded class-derived +// AI). Each WBHV entry binds one combat behavior +// archetype to its creature kind (Melee / Caster / +// Tank / Healer / Pet / Beast), aggro / leash +// radii, evade-on-leash policy, corpse persistence +// duration, default rotation spell, and a variable- +// length list of special abilities (spellId + +// cooldown + use-chance triplets). +// +// Cross-references with previously-added formats: +// WCRT: behaviorId is referenced by WCRT creature +// entries (each creature picks one WBHV +// policy). +// WSPL: mainAttackSpellId and every special +// ability spellId reference the WSPL spell +// catalog. +// +// Binary layout (little-endian): +// magic[4] = "WBHV" +// version (uint32) = current 1 +// nameLen + name (catalog label) +// entryCount (uint32) +// entries (each): +// behaviorId (uint32) +// nameLen + name +// creatureKind (uint8) — 0=Melee / +// 1=Caster / +// 2=Tank / +// 3=Healer / +// 4=Pet / +// 5=Beast +// evadeBehavior (uint8) — 0=ResetToSpawn +// /1=HealAtPath +// /2=FleeToSpawn +// /3=NoEvade +// pad0 (uint16) +// aggroRadius (float) +// leashRadius (float) +// corpseDurationSec (uint32) +// mainAttackSpellId (uint32) +// specialAbilityCount (uint32) +// specialAbilities (each: spellId(4) + +// cooldownMs(4) + +// useChancePct(2) + +// pad1(2)) = 12 bytes +struct WoweeCreatureBehavior { + enum CreatureKind : uint8_t { + Melee = 0, + Caster = 1, + Tank = 2, + Healer = 3, + Pet = 4, + Beast = 5, + }; + + enum EvadeBehavior : uint8_t { + ResetToSpawn = 0, // teleport home + full + // HP/mana + HealAtPath = 1, // run home, regen along + // path + FleeToSpawn = 2, // run home but stay + // attackable + NoEvade = 3, // permanent leash — + // bosses only + }; + + struct SpecialAbility { + uint32_t spellId = 0; + uint32_t cooldownMs = 0; + uint16_t useChancePct = 0; // basis + // points + // 0..10000 + uint16_t pad1 = 0; + }; + + struct Entry { + uint32_t behaviorId = 0; + std::string name; + uint8_t creatureKind = Melee; + uint8_t evadeBehavior = ResetToSpawn; + uint16_t pad0 = 0; + float aggroRadius = 0.f; + float leashRadius = 0.f; + uint32_t corpseDurationSec = 0; + uint32_t mainAttackSpellId = 0; + std::vector specialAbilities; + }; + + std::string name; + std::vector entries; + + bool isValid() const { return !entries.empty(); } + + const Entry* findById(uint32_t behaviorId) const; + + // Returns all behaviors of one kind — used by the + // creature-template editor to suggest archetype + // policies when authoring a new creature. + std::vector findByKind(uint8_t creatureKind) const; +}; + +class WoweeCreatureBehaviorLoader { +public: + static bool save(const WoweeCreatureBehavior& cat, + const std::string& basePath); + static WoweeCreatureBehavior load(const std::string& basePath); + static bool exists(const std::string& basePath); + + // Preset emitters used by --gen-bhv* variants. + // + // makeMeleeBehaviors — 3 entry-tier melee + // creatures (Kobold / + // Wolf / Raptor) with + // 1 special each. + // makeCasterBehaviors — 3 caster creatures + // (Defias Wizard / + // Murloc Coastrunner / + // Voidwalker) with 2-3 + // spells in rotation. + // makeBossBehaviors — 1 boss-style behavior + // (Onyxia-pattern) with + // 4 special abilities, + // NoEvade, and 600s + // corpse duration. + static WoweeCreatureBehavior makeMeleeBehaviors(const std::string& catalogName); + static WoweeCreatureBehavior makeCasterBehaviors(const std::string& catalogName); + static WoweeCreatureBehavior makeBossBehaviors(const std::string& catalogName); +}; + +} // namespace pipeline +} // namespace wowee diff --git a/src/pipeline/wowee_creature_behavior.cpp b/src/pipeline/wowee_creature_behavior.cpp new file mode 100644 index 00000000..a8ebf52b --- /dev/null +++ b/src/pipeline/wowee_creature_behavior.cpp @@ -0,0 +1,286 @@ +#include "pipeline/wowee_creature_behavior.hpp" + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +namespace { + +constexpr char kMagic[4] = {'W', 'B', 'H', 'V'}; +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) != ".wbhv") { + base += ".wbhv"; + } + return base; +} + +} // namespace + +const WoweeCreatureBehavior::Entry* +WoweeCreatureBehavior::findById(uint32_t behaviorId) const { + for (const auto& e : entries) + if (e.behaviorId == behaviorId) return &e; + return nullptr; +} + +std::vector +WoweeCreatureBehavior::findByKind(uint8_t creatureKind) const { + std::vector out; + for (const auto& e : entries) + if (e.creatureKind == creatureKind) out.push_back(&e); + return out; +} + +bool WoweeCreatureBehaviorLoader::save( + const WoweeCreatureBehavior& 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.behaviorId); + writeStr(os, e.name); + writePOD(os, e.creatureKind); + writePOD(os, e.evadeBehavior); + writePOD(os, e.pad0); + writePOD(os, e.aggroRadius); + writePOD(os, e.leashRadius); + writePOD(os, e.corpseDurationSec); + writePOD(os, e.mainAttackSpellId); + uint32_t specCount = + static_cast(e.specialAbilities.size()); + writePOD(os, specCount); + for (const auto& s : e.specialAbilities) { + writePOD(os, s.spellId); + writePOD(os, s.cooldownMs); + writePOD(os, s.useChancePct); + writePOD(os, s.pad1); + } + } + return os.good(); +} + +WoweeCreatureBehavior WoweeCreatureBehaviorLoader::load( + const std::string& basePath) { + WoweeCreatureBehavior 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.behaviorId)) { + out.entries.clear(); return out; + } + if (!readStr(is, e.name)) { + out.entries.clear(); return out; + } + if (!readPOD(is, e.creatureKind) || + !readPOD(is, e.evadeBehavior) || + !readPOD(is, e.pad0) || + !readPOD(is, e.aggroRadius) || + !readPOD(is, e.leashRadius) || + !readPOD(is, e.corpseDurationSec) || + !readPOD(is, e.mainAttackSpellId)) { + out.entries.clear(); return out; + } + uint32_t specCount = 0; + if (!readPOD(is, specCount)) { + out.entries.clear(); return out; + } + // Sanity cap — real bosses cap at ~6 + // abilities; format cap 32. + if (specCount > 32) { + out.entries.clear(); return out; + } + e.specialAbilities.resize(specCount); + for (auto& s : e.specialAbilities) { + if (!readPOD(is, s.spellId) || + !readPOD(is, s.cooldownMs) || + !readPOD(is, s.useChancePct) || + !readPOD(is, s.pad1)) { + out.entries.clear(); return out; + } + } + } + return out; +} + +bool WoweeCreatureBehaviorLoader::exists( + const std::string& basePath) { + std::ifstream is(normalizePath(basePath), std::ios::binary); + return is.good(); +} + +namespace { + +WoweeCreatureBehavior::Entry makeBehavior( + uint32_t behaviorId, const char* name, + uint8_t creatureKind, uint8_t evadeBehavior, + float aggroRadius, float leashRadius, + uint32_t corpseDurationSec, uint32_t mainAttackSpellId, + std::vector + specials) { + WoweeCreatureBehavior::Entry e; + e.behaviorId = behaviorId; e.name = name; + e.creatureKind = creatureKind; + e.evadeBehavior = evadeBehavior; + e.aggroRadius = aggroRadius; + e.leashRadius = leashRadius; + e.corpseDurationSec = corpseDurationSec; + e.mainAttackSpellId = mainAttackSpellId; + e.specialAbilities = std::move(specials); + return e; +} + +WoweeCreatureBehavior::SpecialAbility makeSpec( + uint32_t spellId, uint32_t cooldownMs, + uint16_t useChancePct) { + WoweeCreatureBehavior::SpecialAbility s; + s.spellId = spellId; + s.cooldownMs = cooldownMs; + s.useChancePct = useChancePct; + return s; +} + +} // namespace + +WoweeCreatureBehavior WoweeCreatureBehaviorLoader::makeMeleeBehaviors( + const std::string& catalogName) { + using B = WoweeCreatureBehavior; + WoweeCreatureBehavior c; + c.name = catalogName; + // Kobold: aggro 8yd, leash 30yd, melee swing + // (spellId 0 = no override = use default melee). + // 1 special: throw-rock at 5% chance. + c.entries.push_back(makeBehavior( + 1, "Kobold Worker", + B::Melee, B::ResetToSpawn, + 8.f, 30.f, 60, 0, + {makeSpec(11876 /* throw rock */, 8000, 500)})); + // Wolf: aggro 10yd (predator scent), pet-style + // claw-bite rotation. + c.entries.push_back(makeBehavior( + 2, "Timber Wolf", + B::Beast, B::ResetToSpawn, + 10.f, 35.f, 60, 0, + {makeSpec(3009 /* claw */, 5000, 1500)})); + // Raptor: aggro 9yd, faster ResetToSpawn (these + // are noted runners). 1 chase-leap special. + c.entries.push_back(makeBehavior( + 3, "Stranglethorn Raptor", + B::Beast, B::ResetToSpawn, + 9.f, 40.f, 60, 0, + {makeSpec(7165 /* leap */, 12000, 2000)})); + return c; +} + +WoweeCreatureBehavior +WoweeCreatureBehaviorLoader::makeCasterBehaviors( + const std::string& catalogName) { + using B = WoweeCreatureBehavior; + WoweeCreatureBehavior c; + c.name = catalogName; + // Defias Wizard: caster, fireball default rotation, + // Polymorph + Frost Nova specials. + c.entries.push_back(makeBehavior( + 10, "Defias Wizard", + B::Caster, B::ResetToSpawn, + 20.f, 60.f, 60, 133 /* Fireball */, + {makeSpec(118 /* Polymorph */, 30000, 3000), + makeSpec(122 /* Frost Nova */, 25000, 2500)})); + // Murloc Coastrunner: low-level caster with bolt- + // type ability + heal-self special. + c.entries.push_back(makeBehavior( + 11, "Murloc Coastrunner", + B::Caster, B::ResetToSpawn, + 15.f, 35.f, 60, 11979 /* Frost Bolt */, + {makeSpec(2050 /* Lesser Heal */, 20000, 1500)})); + // Voidwalker (warlock pet pattern): tank-caster + // hybrid with taunt + sacrifice. + c.entries.push_back(makeBehavior( + 12, "Voidwalker Pet Pattern", + B::Tank, B::ResetToSpawn, + 12.f, 40.f, 60, 0 /* default melee */, + {makeSpec(7264 /* Taunt */, 10000, 5000), + makeSpec(7812 /* Sacrifice */, 0, 0), // 0% + // use, + // master- + // triggered + // only + makeSpec(17767 /* Suffering */, 30000, 2000)})); + return c; +} + +WoweeCreatureBehavior WoweeCreatureBehaviorLoader::makeBossBehaviors( + const std::string& catalogName) { + using B = WoweeCreatureBehavior; + WoweeCreatureBehavior c; + c.name = catalogName; + // Onyxia-pattern boss: NoEvade (raid bosses + // permanent-leash to encounter zone), 600s + // (10min) corpse duration so 40-man raid can + // distribute loot. 4 abilities in rotation. + c.entries.push_back(makeBehavior( + 100, "Onyxia-Pattern Dragon Boss", + B::Tank, B::NoEvade, // dragons render as + // Tank kind for AI + // threat dispatch + 50.f, 999.f, 600, 0 /* default melee + flame */, + {makeSpec(18395 /* Wing Buffet */, 25000, 4000), + makeSpec(18392 /* Flame Breath */, 12000, 5000), + makeSpec(18435 /* Tail Sweep */, 20000, 3500), + makeSpec(18650 /* Deep Breath */, 90000, 2500)})); + return c; +} + +} // namespace pipeline +} // namespace wowee diff --git a/tools/editor/cli_arg_required.cpp b/tools/editor/cli_arg_required.cpp index 5de05a98..01c0d387 100644 --- a/tools/editor/cli_arg_required.cpp +++ b/tools/editor/cli_arg_required.cpp @@ -416,6 +416,8 @@ const char* const kArgRequired[] = { "--gen-bnd-vanilla", "--gen-bnd-tbc", "--gen-bnd-wotlk", "--info-wbnd", "--validate-wbnd", "--export-wbnd-json", "--import-wbnd-json", + "--gen-bhv-melee", "--gen-bhv-caster", "--gen-bhv-boss", + "--info-wbhv", "--validate-wbhv", "--gen-weather-temperate", "--gen-weather-arctic", "--gen-weather-desert", "--gen-weather-stormy", "--gen-zone-atmosphere", diff --git a/tools/editor/cli_creature_behavior_catalog.cpp b/tools/editor/cli_creature_behavior_catalog.cpp new file mode 100644 index 00000000..b627280c --- /dev/null +++ b/tools/editor/cli_creature_behavior_catalog.cpp @@ -0,0 +1,339 @@ +#include "cli_creature_behavior_catalog.hpp" +#include "cli_arg_parse.hpp" +#include "cli_box_emitter.hpp" + +#include "pipeline/wowee_creature_behavior.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { +namespace cli { + +namespace { + +std::string stripWbhvExt(std::string base) { + stripExt(base, ".wbhv"); + return base; +} + +const char* creatureKindName(uint8_t k) { + using B = wowee::pipeline::WoweeCreatureBehavior; + switch (k) { + case B::Melee: return "melee"; + case B::Caster: return "caster"; + case B::Tank: return "tank"; + case B::Healer: return "healer"; + case B::Pet: return "pet"; + case B::Beast: return "beast"; + default: return "?"; + } +} + +const char* evadeBehaviorName(uint8_t e) { + using B = wowee::pipeline::WoweeCreatureBehavior; + switch (e) { + case B::ResetToSpawn: return "resettospawn"; + case B::HealAtPath: return "healatpath"; + case B::FleeToSpawn: return "fleetospawn"; + case B::NoEvade: return "noevade"; + default: return "?"; + } +} + +bool saveOrError(const wowee::pipeline::WoweeCreatureBehavior& c, + const std::string& base, const char* cmd) { + if (!wowee::pipeline::WoweeCreatureBehaviorLoader::save(c, base)) { + std::fprintf(stderr, "%s: failed to save %s.wbhv\n", + cmd, base.c_str()); + return false; + } + return true; +} + +void printGenSummary(const wowee::pipeline::WoweeCreatureBehavior& c, + const std::string& base) { + std::printf("Wrote %s.wbhv\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" behaviors: %zu\n", c.entries.size()); +} + +int handleGenMelee(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "MeleeBehaviors"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWbhvExt(base); + auto c = wowee::pipeline::WoweeCreatureBehaviorLoader:: + makeMeleeBehaviors(name); + if (!saveOrError(c, base, "gen-bhv-melee")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenCaster(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "CasterBehaviors"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWbhvExt(base); + auto c = wowee::pipeline::WoweeCreatureBehaviorLoader:: + makeCasterBehaviors(name); + if (!saveOrError(c, base, "gen-bhv-caster")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenBoss(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "BossBehaviors"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWbhvExt(base); + auto c = wowee::pipeline::WoweeCreatureBehaviorLoader:: + makeBossBehaviors(name); + if (!saveOrError(c, base, "gen-bhv-boss")) 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 = stripWbhvExt(base); + if (!wowee::pipeline::WoweeCreatureBehaviorLoader::exists(base)) { + std::fprintf(stderr, "WBHV not found: %s.wbhv\n", + base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeCreatureBehaviorLoader::load(base); + if (jsonOut) { + nlohmann::json j; + j["wbhv"] = base + ".wbhv"; + j["name"] = c.name; + j["count"] = c.entries.size(); + nlohmann::json arr = nlohmann::json::array(); + for (const auto& e : c.entries) { + nlohmann::json specs = nlohmann::json::array(); + for (const auto& s : e.specialAbilities) { + specs.push_back({ + {"spellId", s.spellId}, + {"cooldownMs", s.cooldownMs}, + {"useChancePct", s.useChancePct}, + }); + } + arr.push_back({ + {"behaviorId", e.behaviorId}, + {"name", e.name}, + {"creatureKind", e.creatureKind}, + {"creatureKindName", + creatureKindName(e.creatureKind)}, + {"evadeBehavior", e.evadeBehavior}, + {"evadeBehaviorName", + evadeBehaviorName(e.evadeBehavior)}, + {"aggroRadius", e.aggroRadius}, + {"leashRadius", e.leashRadius}, + {"corpseDurationSec", e.corpseDurationSec}, + {"mainAttackSpellId", e.mainAttackSpellId}, + {"specialAbilities", specs}, + }); + } + j["entries"] = arr; + std::printf("%s\n", j.dump(2).c_str()); + return 0; + } + std::printf("WBHV: %s.wbhv\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" behaviors: %zu\n", c.entries.size()); + if (c.entries.empty()) return 0; + std::printf(" id kind evade aggro leash corpse main-spell specs name\n"); + for (const auto& e : c.entries) { + std::printf(" %4u %-7s %-13s %5.1f %5.1f %5us %8u %5zu %s\n", + e.behaviorId, + creatureKindName(e.creatureKind), + evadeBehaviorName(e.evadeBehavior), + e.aggroRadius, e.leashRadius, + e.corpseDurationSec, + e.mainAttackSpellId, + e.specialAbilities.size(), + e.name.c_str()); + } + return 0; +} + +int handleValidate(int& i, int argc, char** argv) { + std::string base = argv[++i]; + bool jsonOut = consumeJsonFlag(i, argc, argv); + base = stripWbhvExt(base); + if (!wowee::pipeline::WoweeCreatureBehaviorLoader::exists(base)) { + std::fprintf(stderr, + "validate-wbhv: WBHV not found: %s.wbhv\n", + base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeCreatureBehaviorLoader::load(base); + std::vector errors; + std::vector warnings; + if (c.entries.empty()) { + warnings.push_back("catalog has zero entries"); + } + std::set idsSeen; + for (size_t k = 0; k < c.entries.size(); ++k) { + const auto& e = c.entries[k]; + std::string ctx = "entry " + std::to_string(k) + + " (id=" + std::to_string(e.behaviorId); + if (!e.name.empty()) ctx += " " + e.name; + ctx += ")"; + if (e.behaviorId == 0) + errors.push_back(ctx + ": behaviorId is 0"); + if (e.name.empty()) + errors.push_back(ctx + ": name is empty"); + if (e.creatureKind > 5) { + errors.push_back(ctx + ": creatureKind " + + std::to_string(e.creatureKind) + + " out of range (0..5)"); + } + if (e.evadeBehavior > 3) { + errors.push_back(ctx + ": evadeBehavior " + + std::to_string(e.evadeBehavior) + + " out of range (0..3)"); + } + if (e.aggroRadius <= 0.f) { + errors.push_back(ctx + + ": aggroRadius must be > 0 (creature would " + "never engage)"); + } + // CRITICAL invariant: leashRadius MUST be >= + // aggroRadius, else the creature would evade + // back to spawn before reaching its target — + // permanently un-killable from outside the + // leash radius. + if (e.leashRadius > 0.f && + e.leashRadius < e.aggroRadius) { + errors.push_back(ctx + + ": leashRadius=" + + std::to_string(e.leashRadius) + + " < aggroRadius=" + + std::to_string(e.aggroRadius) + + " — creature would evade before " + "engaging (un-killable from outside " + "the leash)"); + } + // Corpse < 60s makes looting impossible for + // anyone but the killer (even the killer in + // a busy zone). + if (e.corpseDurationSec > 0 && + e.corpseDurationSec < 60) { + warnings.push_back(ctx + + ": corpseDurationSec=" + + std::to_string(e.corpseDurationSec) + + " is below 60s — looting may fail in " + "busy zones"); + } + // Per-special checks. + std::set specSpellsSeen; + for (size_t s = 0; s < e.specialAbilities.size(); ++s) { + const auto& sp = e.specialAbilities[s]; + if (sp.spellId == 0) { + errors.push_back(ctx + + ": specialAbility[" + + std::to_string(s) + + "].spellId is 0"); + } + // useChancePct == 0 means the ability is + // never auto-fired — only valid for owner- + // triggered (e.g., warlock Sacrifice). + // Warn so the editor flags it. + if (sp.useChancePct == 0 && sp.spellId != 0) { + warnings.push_back(ctx + + ": specialAbility[" + + std::to_string(s) + + "].useChancePct=0 — ability never " + "auto-fires; verify intentional " + "(e.g. owner-triggered like Sacrifice)"); + } + // Same spellId twice in same behavior is + // a copy-paste bug — both entries would + // share an internal-cooldown bucket but + // count as separate slots. + if (sp.spellId != 0 && + !specSpellsSeen.insert(sp.spellId).second) { + errors.push_back(ctx + + ": specialAbility spellId " + + std::to_string(sp.spellId) + + " appears twice in same behavior — " + "duplicate slot is wasted (merge or " + "rename)"); + } + } + if (!idsSeen.insert(e.behaviorId).second) { + errors.push_back(ctx + ": duplicate behaviorId"); + } + } + bool ok = errors.empty(); + if (jsonOut) { + nlohmann::json j; + j["wbhv"] = base + ".wbhv"; + 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-wbhv: %s.wbhv\n", base.c_str()); + if (ok && warnings.empty()) { + std::printf(" OK — %zu behaviors, all behaviorIds " + "unique, creatureKind 0..5, evadeBehavior " + "0..3, aggroRadius > 0, leashRadius >= " + "aggroRadius (creature can engage), no " + "zero-spellId specials, no duplicate " + "specials within same behavior\n", + c.entries.size()); + 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 handleCreatureBehaviorCatalog(int& i, int argc, char** argv, + int& outRc) { + if (std::strcmp(argv[i], "--gen-bhv-melee") == 0 && + i + 1 < argc) { + outRc = handleGenMelee(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-bhv-caster") == 0 && + i + 1 < argc) { + outRc = handleGenCaster(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-bhv-boss") == 0 && + i + 1 < argc) { + outRc = handleGenBoss(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--info-wbhv") == 0 && i + 1 < argc) { + outRc = handleInfo(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--validate-wbhv") == 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_creature_behavior_catalog.hpp b/tools/editor/cli_creature_behavior_catalog.hpp new file mode 100644 index 00000000..0499eecc --- /dev/null +++ b/tools/editor/cli_creature_behavior_catalog.hpp @@ -0,0 +1,12 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +bool handleCreatureBehaviorCatalog(int& i, int argc, char** argv, + int& outRc); + +} // namespace cli +} // namespace editor +} // namespace wowee diff --git a/tools/editor/cli_dispatch.cpp b/tools/editor/cli_dispatch.cpp index cab8bb97..bf3a9c88 100644 --- a/tools/editor/cli_dispatch.cpp +++ b/tools/editor/cli_dispatch.cpp @@ -180,6 +180,7 @@ #include "cli_crafting_recipes_catalog.hpp" #include "cli_world_locations_catalog.hpp" #include "cli_soulbind_rules_catalog.hpp" +#include "cli_creature_behavior_catalog.hpp" #include "cli_catalog_pluck.hpp" #include "cli_catalog_find.hpp" #include "cli_catalog_by_name.hpp" @@ -405,6 +406,7 @@ constexpr DispatchFn kDispatchTable[] = { handleCraftingRecipesCatalog, handleWorldLocationsCatalog, handleSoulbindRulesCatalog, + handleCreatureBehaviorCatalog, handleCatalogPluck, handleCatalogFind, handleCatalogByName, diff --git a/tools/editor/cli_format_table.cpp b/tools/editor/cli_format_table.cpp index bf61144c..d11de8c2 100644 --- a/tools/editor/cli_format_table.cpp +++ b/tools/editor/cli_format_table.cpp @@ -138,6 +138,7 @@ constexpr FormatMagicEntry kFormats[] = { {{'W','C','R','A'}, ".wcra", "crafting", "--info-wcra", "Crafting recipe catalog"}, {{'W','L','O','C'}, ".wloc", "world", "--info-wloc", "World locations catalog"}, {{'W','B','N','D'}, ".wbnd", "loot", "--info-wbnd", "Soulbind rules catalog"}, + {{'W','B','H','V'}, ".wbhv", "ai", "--info-wbhv", "Creature behavior catalog"}, {{'W','F','A','C'}, ".wfac", "factions", nullptr, "Faction catalog"}, {{'W','L','C','K'}, ".wlck", "locks", nullptr, "Lock catalog"}, {{'W','S','K','L'}, ".wskl", "skills", nullptr, "Skill catalog"}, diff --git a/tools/editor/cli_help.cpp b/tools/editor/cli_help.cpp index dad16267..e443e2e2 100644 --- a/tools/editor/cli_help.cpp +++ b/tools/editor/cli_help.cpp @@ -2657,6 +2657,16 @@ void printUsage(const char* argv0) { std::printf(" Export binary .wbnd to a human-editable JSON sidecar (defaults to .wbnd.json; emits both bindKind and itemQualityFloor as int + name string)\n"); std::printf(" --import-wbnd-json [out-base]\n"); std::printf(" Import a .wbnd.json sidecar back into binary .wbnd (bindKind int OR \"bindonpickup\"/\"bindonequip\"/\"bindonuse\"/\"bindonaccount\"/\"soulbound\"/\"nobind\"; itemQualityFloor int OR \"poor\"/\"common\"/\"uncommon\"/\"rare\"/\"epic\"/\"legendary\"/\"artifact\"/\"heirloom\")\n"); + std::printf(" --gen-bhv-melee [name]\n"); + std::printf(" Emit .wbhv 3 entry-tier melee creature behaviors (Kobold Worker / Timber Wolf / Stranglethorn Raptor) with 1 special each (throw-rock / claw / leap)\n"); + std::printf(" --gen-bhv-caster [name]\n"); + std::printf(" Emit .wbhv 3 caster behaviors (Defias Wizard with Polymorph+FrostNova / Murloc Coastrunner with FrostBolt+heal / Voidwalker tank-pet pattern with Taunt+Sacrifice+Suffering)\n"); + std::printf(" --gen-bhv-boss [name]\n"); + std::printf(" Emit .wbhv 1 Onyxia-pattern dragon boss (Tank kind, NoEvade leash, 600s corpse, 4 abilities including 90s-cooldown Deep Breath)\n"); + std::printf(" --info-wbhv [--json]\n"); + std::printf(" Print WBHV entries (id / creatureKind / evadeBehavior / aggro / leash / corpse / mainAttackSpellId / specials count / name)\n"); + std::printf(" --validate-wbhv [--json]\n"); + std::printf(" Static checks: id+name required, creatureKind 0..5, evadeBehavior 0..3, aggroRadius > 0, no duplicate behaviorIds, no zero-spellId specials, no duplicate spellId within same behavior; CRITICAL invariant: leashRadius >= aggroRadius (else creature evades before engaging — un-killable from outside leash). Warns on corpseDuration < 60s (looting may fail in busy zones), useChancePct=0 on a special (ability never auto-fires; verify owner-triggered intent like warlock Sacrifice)\n"); std::printf(" --catalog-pluck [--json]\n"); std::printf(" Extract one entry by id from any registered catalog format. Auto-detects magic, dispatches to the per-format --info-* handler internally, then prints just the matching entry. Primary-key field is auto-detected (first *Id field, or first numeric)\n"); std::printf(" --catalog-find [--magic ] [--json]\n"); diff --git a/tools/editor/cli_list_formats.cpp b/tools/editor/cli_list_formats.cpp index 653801f2..48a10f17 100644 --- a/tools/editor/cli_list_formats.cpp +++ b/tools/editor/cli_list_formats.cpp @@ -160,6 +160,7 @@ constexpr FormatRow kFormats[] = { {"WCRA", ".wcra", "crafting", "SpellReagents.dbc + Spell.dbc effect-24","Crafting recipe catalog (trade-skill recipe expansion)"}, {"WLOC", ".wloc", "world", "AreaPOI + GO spawns + AreaTrigger.dbc","World locations catalog (POI/RareSpawn/HerbNode/MineralVein/FishingSpot/Trigger/PortalLand)"}, {"WBND", ".wbnd", "loot", "ItemTemplate.bondingType + LootMgr", "Soulbind rules catalog (BoP/BoE/BoU/BoA + raid-trade window)"}, + {"WBHV", ".wbhv", "ai", "creature_template.AIName + ScriptMgr","Creature behavior catalog (combat AI archetypes + special abilities)"}, // Additional pipeline catalogs without the alternating // gen/info/validate CLI surface (loaded by the engine