Kelsidavis-WoWee/tools/editor/npc_spawner.cpp
Kelsi 2119546a7a fix(npc): truncate over-long NPC name/modelPath at SQL limits
creature_template.name is varchar(100) in AzerothCore. Edited
creature JSON could carry longer names that would either fail
the INSERT or silently truncate. Cap name at 100 and modelPath
at 1024 on load.
2026-05-06 07:01:25 -07:00

262 lines
11 KiB
C++

#include "npc_spawner.hpp"
#include "core/logger.hpp"
#include <nlohmann/json.hpp>
#include <fstream>
#include <sstream>
#include <cmath>
#include <random>
#include <algorithm>
#include <filesystem>
namespace wowee {
namespace editor {
uint32_t NpcSpawner::nextId() { return idCounter_++; }
void NpcSpawner::placeCreature(const CreatureSpawn& spawn) {
CreatureSpawn s = spawn;
s.id = nextId();
s.selected = false;
spawns_.push_back(s);
LOG_INFO("Creature placed: ", s.name, " (id=", s.id, ") at (",
s.position.x, ",", s.position.y, ",", s.position.z, ")");
}
void NpcSpawner::removeCreature(int index) {
if (index < 0 || index >= static_cast<int>(spawns_.size())) return;
spawns_.erase(spawns_.begin() + index);
if (selectedIdx_ == index) selectedIdx_ = -1;
else if (selectedIdx_ > index) selectedIdx_--;
}
int NpcSpawner::selectAt(const glm::vec3& worldPos, float maxDist) {
clearSelection();
float bestDist = maxDist;
int bestIdx = -1;
for (int i = 0; i < static_cast<int>(spawns_.size()); i++) {
float dist = glm::length(spawns_[i].position - worldPos);
if (dist < bestDist) { bestDist = dist; bestIdx = i; }
}
if (bestIdx >= 0) {
selectedIdx_ = bestIdx;
spawns_[bestIdx].selected = true;
}
return bestIdx;
}
void NpcSpawner::clearSelection() {
if (selectedIdx_ >= 0 && selectedIdx_ < static_cast<int>(spawns_.size()))
spawns_[selectedIdx_].selected = false;
selectedIdx_ = -1;
}
CreatureSpawn* NpcSpawner::getSelected() {
if (selectedIdx_ < 0 || selectedIdx_ >= static_cast<int>(spawns_.size())) return nullptr;
return &spawns_[selectedIdx_];
}
bool NpcSpawner::saveToFile(const std::string& path) const {
auto dir = std::filesystem::path(path).parent_path();
if (!dir.empty()) std::filesystem::create_directories(dir);
nlohmann::json arr = nlohmann::json::array();
for (const auto& s : spawns_) {
nlohmann::json js;
js["id"] = s.id;
js["name"] = s.name;
js["model"] = s.modelPath;
js["displayId"] = s.displayId;
js["position"] = {s.position.x, s.position.y, s.position.z};
js["orientation"] = s.orientation;
js["scale"] = s.scale;
js["level"] = s.level;
js["health"] = s.health;
js["mana"] = s.mana;
js["minDamage"] = s.minDamage;
js["maxDamage"] = s.maxDamage;
js["armor"] = s.armor;
js["faction"] = s.faction;
js["behavior"] = static_cast<int>(s.behavior);
js["wanderRadius"] = s.wanderRadius;
js["aggroRadius"] = s.aggroRadius;
js["leashRadius"] = s.leashRadius;
js["respawnTimeMs"] = s.respawnTimeMs;
js["hostile"] = s.hostile;
js["questgiver"] = s.questgiver;
js["vendor"] = s.vendor;
js["flightmaster"] = s.flightmaster;
js["innkeeper"] = s.innkeeper;
js["trainer"] = s.trainer;
js["auctioneer"] = s.auctioneer;
js["banker"] = s.banker;
js["repair"] = s.repair;
nlohmann::json patrol = nlohmann::json::array();
for (const auto& p : s.patrolPath) {
patrol.push_back({p.position.x, p.position.y, p.position.z, p.waitTimeMs});
}
js["patrol"] = patrol;
arr.push_back(js);
}
std::ofstream f(path);
if (!f) { LOG_ERROR("Failed to write NPC file: ", path); return false; }
f << arr.dump(2) << "\n";
LOG_INFO("NPC spawns saved: ", path, " (", spawns_.size(), " creatures)");
return true;
}
void NpcSpawner::scatter(const CreatureSpawn& base, const glm::vec3& center,
float radius, int count) {
// Defensive bounds — UI sliders cap these, but the function is also
// callable programmatically. radius<=0 would either throw from the
// uniform distribution constructor or divide by zero on the sqrt
// line; an absurd count would freeze the editor and OOM.
if (count <= 0 || count > 10000) return;
if (!std::isfinite(radius) || radius <= 0.0f) return;
if (!std::isfinite(center.x) || !std::isfinite(center.y) ||
!std::isfinite(center.z)) return;
std::mt19937 rng(static_cast<uint32_t>(center.x * 100 + center.y * 37));
std::uniform_real_distribution<float> distAngle(0.0f, 6.2831853f);
std::uniform_real_distribution<float> distDist(0.0f, radius);
std::uniform_real_distribution<float> distRot(0.0f, 360.0f);
for (int i = 0; i < count; i++) {
float angle = distAngle(rng);
float dist = std::sqrt(distDist(rng) / radius) * radius;
CreatureSpawn s = base;
s.position = center + glm::vec3(std::cos(angle) * dist, std::sin(angle) * dist, 0.0f);
s.orientation = distRot(rng);
placeCreature(s);
}
}
bool NpcSpawner::loadFromFile(const std::string& path) {
std::ifstream f(path);
if (!f) { LOG_ERROR("Failed to open NPC file: ", path); return false; }
try {
auto arr = nlohmann::json::parse(f);
if (!arr.is_array()) return false;
spawns_.clear();
selectedIdx_ = -1;
idCounter_ = 1;
for (const auto& js : arr) {
CreatureSpawn s;
s.name = js.value("name", "");
s.modelPath = js.value("model", "");
// creature_template.name is varchar(100); modelPath is internal
// but capped to keep export sane. Trim instead of dropping.
if (s.name.size() > 100) s.name.resize(100);
if (s.modelPath.size() > 1024) s.modelPath.resize(1024);
s.displayId = js.value("displayId", 0u);
s.orientation = js.value("orientation", 0.0f);
// Normalise orientation to [0, 360) for consistent gizmo behaviour.
if (std::isfinite(s.orientation)) {
s.orientation = std::fmod(s.orientation, 360.0f);
if (s.orientation < 0.0f) s.orientation += 360.0f;
} else {
s.orientation = 0.0f;
}
s.scale = js.value("scale", 1.0f);
if (!std::isfinite(s.scale) || s.scale < 0.1f) s.scale = 1.0f;
s.level = js.value("level", 1u);
// WoW level cap is 80 (WotLK) but allow up to 255 for special
// bosses; 0 is invalid.
if (s.level == 0) s.level = 1;
if (s.level > 255) s.level = 255;
s.health = js.value("health", 100u);
if (s.health == 0) s.health = 1;
s.mana = js.value("mana", 0u);
s.minDamage = js.value("minDamage", 5u);
s.maxDamage = js.value("maxDamage", 10u);
// maxDmg should be >= minDmg.
if (s.maxDamage < s.minDamage) s.maxDamage = s.minDamage;
s.armor = js.value("armor", 0u);
s.faction = js.value("faction", 0u);
int beh = js.value("behavior", 0);
if (beh < 0 || beh > 3) beh = 0;
s.behavior = static_cast<CreatureBehavior>(beh);
s.wanderRadius = js.value("wanderRadius", 0.0f);
if (!std::isfinite(s.wanderRadius) || s.wanderRadius < 0.0f) s.wanderRadius = 0.0f;
if (s.wanderRadius > 1000.0f) s.wanderRadius = 1000.0f;
s.aggroRadius = js.value("aggroRadius", 15.0f);
if (!std::isfinite(s.aggroRadius) || s.aggroRadius < 0.0f) s.aggroRadius = 0.0f;
s.leashRadius = js.value("leashRadius", 40.0f);
if (!std::isfinite(s.leashRadius) || s.leashRadius < 0.0f) s.leashRadius = 40.0f;
s.respawnTimeMs = js.value("respawnTimeMs", 60000u);
// Cap respawn at 24h — typo guard, also matches AzerothCore.
if (s.respawnTimeMs > 86'400'000u) s.respawnTimeMs = 86'400'000u;
if (s.respawnTimeMs < 1000u) s.respawnTimeMs = 1000u;
s.hostile = js.value("hostile", false);
s.questgiver = js.value("questgiver", false);
s.vendor = js.value("vendor", false);
s.flightmaster = js.value("flightmaster", false);
s.innkeeper = js.value("innkeeper", false);
s.trainer = js.value("trainer", false);
s.auctioneer = js.value("auctioneer", false);
s.banker = js.value("banker", false);
s.repair = js.value("repair", false);
if (js.contains("position") && js["position"].is_array() && js["position"].size() >= 3) {
s.position = glm::vec3(js["position"][0].get<float>(),
js["position"][1].get<float>(),
js["position"][2].get<float>());
// Reject NaN/inf positions — they crash the M2 renderer's
// matrix math and produce invisible / chaos-shaped instances.
if (!std::isfinite(s.position.x) || !std::isfinite(s.position.y) ||
!std::isfinite(s.position.z)) {
s.position = glm::vec3(0.0f);
}
}
if (js.contains("patrol") && js["patrol"].is_array()) {
for (const auto& pt : js["patrol"]) {
if (pt.is_array() && pt.size() >= 4) {
PatrolPoint pp;
pp.position = glm::vec3(pt[0].get<float>(), pt[1].get<float>(), pt[2].get<float>());
// Skip waypoints with NaN/inf — would produce a path
// that warps the creature to garbage coords.
if (!std::isfinite(pp.position.x) || !std::isfinite(pp.position.y) ||
!std::isfinite(pp.position.z))
continue;
pp.waitTimeMs = pt[3].get<uint32_t>();
// Cap wait time at 10 minutes to keep AzerothCore
// happy and prevent obvious data-entry typos that
// would produce a creature that effectively never
// moves (e.g. 24h = 86400000).
if (pp.waitTimeMs > 600000) pp.waitTimeMs = 600000;
s.patrolPath.push_back(pp);
}
}
}
if (!s.name.empty()) {
// Preserve original id from JSON if present so quest hooks
// (questGiverNpcId, turnInNpcId, KillCreature targetName)
// remain stable across save/load. Bump idCounter past any
// loaded value to avoid collisions with future placements.
if (js.contains("id")) {
s.id = js["id"].get<uint32_t>();
if (s.id >= idCounter_) idCounter_ = s.id + 1;
} else {
s.id = nextId();
}
spawns_.push_back(s);
}
}
LOG_INFO("NPC spawns loaded: ", path, " (", spawns_.size(), " creatures)");
return true;
} catch (const std::exception& e) {
LOG_ERROR("Failed to parse NPC file: ", e.what());
return false;
}
}
} // namespace editor
} // namespace wowee