feat: web dashboard and playground work

The NexusDashboard-parity dashboard (dDashboardServer) and everything built on it on the experimental branch:
accounts, characters, properties and moderation tools, permissions shared with in-game slash commands, economy
reports, World 3D and property 3D views with client scenery, scheduled events (features, vanity changes, live
events, announcements, restarts), vanity files and events, the CDClient browser, the message inspector with saved
captures, chat filter tools, community challenges, live ops, the AI moderator helper, and the server-side changes
they need.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-26 17:52:10 -05:00
parent 854d02f509
commit e213a7aeff
681 changed files with 88310 additions and 1321 deletions

View File

@@ -10,7 +10,6 @@
#include "dCommonVars.h"
#include "dConfig.h"
#include "dServer.h"
#include "tinyxml2.h"
#include "Game.h"
#include "Logger.h"
#include "BinaryPathFinder.h"
@@ -19,18 +18,24 @@
#include "dZoneManager.h"
#include "ObjectIDManager.h"
#include "Level.h"
#include "VanityXml.h"
#include "VanityEvents.h"
#include "EventParts.h"
#include "Database.h"
#include <ctime>
#include <fstream>
namespace {
std::vector<VanityObject> objects;
std::set<std::string> loadedFiles;
LWOOBJID testament = LWOOBJID_EMPTY; // Nimbus Station's testament sign
}
void SetupNPCTalk(Entity* npc);
void NPCTalk(Entity* npc);
void ParseXml(const std::string& file);
void AddObjects(const std::vector<VanityXml::Object>& documentObjects);
std::vector<VanityXml::Object> LoadVanity(const std::filesystem::path& folder);
LWOOBJID SpawnSpawner(const VanityObject& object, const VanityObjectLocation& location);
Entity* SpawnObject(const VanityObject& object, const VanityObjectLocation& location);
VanityObject* GetObject(const std::string& name);
@@ -38,6 +43,11 @@ VanityObject* GetObject(const std::string& name);
void VanityUtilities::SpawnVanity() {
const uint32_t zoneID = Game::server->GetZoneID();
// Spawning again (the dashboard's reload) replaces what's there. Destroyed outright rather than smashed,
// so players don't see the old ones linger through a death next to the new ones.
Game::entityManager->DestroyEntity(Game::entityManager->GetEntity(testament));
testament = LWOOBJID_EMPTY;
if (zoneID == 1200) {
{
EntityInfo info;
@@ -50,26 +60,24 @@ void VanityUtilities::SpawnVanity() {
auto* entity = Game::entityManager->CreateEntity(info);
Game::entityManager->ConstructEntity(entity);
testament = entity->GetObjectID();
}
}
if (Game::config->GetValue("disable_vanity") == "1") return;
for (const auto& npc : objects) {
if (npc.m_ID == LWOOBJID_EMPTY) continue;
if (npc.m_LOT == 176) {
Game::zoneManager->RemoveSpawner(npc.m_ID);
} else {
auto* entity = Game::entityManager->GetEntity(npc.m_ID);
if (!entity) continue;
entity->Smash(LWOOBJID_EMPTY, eKillType::VIOLENT);
Game::entityManager->DestroyEntity(Game::entityManager->GetEntity(npc.m_ID));
}
}
objects.clear();
loadedFiles.clear();
ParseXml((BinaryPathFinder::GetBinaryDir() / "vanity/root.xml").string());
if (Game::config->GetValue("disable_vanity") == "1") return;
AddObjects(LoadVanity(BinaryPathFinder::GetBinaryDir() / "vanity"));
// Loop through all objects
for (auto& object : objects) {
@@ -143,182 +151,95 @@ Entity* SpawnObject(const VanityObject& object, const VanityObjectLocation& loca
return entity;
}
void ParseXml(const std::string& file) {
if (loadedFiles.contains(file)) {
LOG("Trying to load vanity file %s twice!!!", file.c_str());
return;
// root.xml and the files it switches on, with the vanity parts of the scheduled events that are on now: their file
// switches, then their overlays
std::vector<VanityXml::Object> LoadVanity(const std::filesystem::path& folder) {
std::vector<IServerOperations::ScheduledEvent> events;
try {
events = Database::Get()->GetScheduledEvents();
} catch (const std::exception& ex) {
LOG("Could not read the scheduled events, spawning without their vanity changes: %s", ex.what());
}
loadedFiles.insert(file);
// Read the entire file
std::ifstream xmlFile(file);
std::string xml((std::istreambuf_iterator<char>(xmlFile)), std::istreambuf_iterator<char>());
// Parse the XML
tinyxml2::XMLDocument doc;
doc.Parse(xml.c_str(), xml.size());
// Read the objects
auto* files = doc.FirstChildElement("files");
if (files) {
for (auto* file = files->FirstChildElement("file"); file != nullptr; file = file->NextSiblingElement("file")) {
std::string enabled = file->Attribute("enabled");
std::string filename = file->Attribute("name");
if (enabled != "1") {
continue;
}
ParseXml((BinaryPathFinder::GetBinaryDir() / "vanity" / filename).string());
const auto now = static_cast<int64_t>(std::time(nullptr));
std::erase_if(events, [now](const auto& event) {
return !ScheduleRules::IsOn(static_cast<ScheduleRules::eMode>(event.mode), event.schedule, event.startsAt, event.endsAt, now);
});
VanityEvents::SortForMerge(events);
std::vector<VanityEvents::Changes> changes;
for (const auto& event : events) {
for (auto& change : EventParts::VanityChanges(event.name, event.parts)) {
LOG("Vanity changes of event %s are on", event.name.c_str());
changes.push_back(std::move(change));
}
}
auto world = VanityEvents::LoadWorld(folder, "root.xml", changes);
for (const auto& warning : world.warnings) LOG("Vanity: %s", warning.c_str());
for (const auto& conflict : world.fileConflicts) {
LOG("Vanity file %s is switched by more than one event; %s wins", conflict.file.c_str(), conflict.switches.back().first.c_str());
}
for (const auto& conflict : world.conflicts) {
LOG("Vanity NPC %s is changed by more than one event; %s wins", conflict.npc.c_str(), conflict.events.back().c_str());
}
return std::move(world.objects);
}
void AddObjects(const std::vector<VanityXml::Object>& documentObjects) {
// Read the objects
auto* objectsElement = doc.FirstChildElement("objects");
const uint32_t currentZoneID = Game::server->GetZoneID();
if (objectsElement) {
for (auto* object = objectsElement->FirstChildElement("object"); object != nullptr; object = object->NextSiblingElement("object")) {
// for use later when adding to the vector of VanityObjects
bool useLocationsAsRandomSpawnPoint = false;
// Get the NPC name
auto* name = object->Attribute("name");
for (const auto& object : documentObjects) {
// for use later when adding to the vector of VanityObjects
bool useLocationsAsRandomSpawnPoint = false;
if (!name) name = "";
if (object.lot == LOT_NULL) {
LOG("Failed to parse object lot");
continue;
}
// Get the NPC lot
auto lot = GeneralUtils::TryParse<LOT>(object->Attribute("lot")).value_or(LOT_NULL);
std::vector<std::u16string> keys = {};
LwoNameValue config;
for (const auto& data : object.config) {
const auto& configData = config.ParseInsert(data);
if (configData->GetKey() == u"useLocationsAsRandomSpawnPoint" && configData->GetValueType() == eLDFType::LDF_TYPE_BOOLEAN) {
useLocationsAsRandomSpawnPoint = static_cast<const LDFData<bool>*>(configData.get())->GetValue();
config.Erase(u"useLocationsAsRandomSpawnPoint");
continue;
}
keys.push_back(configData->GetKey());
}
if (!keys.empty()) config.Insert<std::vector<std::u16string>>(u"syncLDF", keys);
if (lot == LOT_NULL) {
LOG("Failed to parse object lot");
VanityObject objectData{
.m_Name = object.name,
.m_LOT = object.lot,
.m_Equipment = object.equipment,
.m_Phrases = object.phrases,
.m_Config = config
};
for (const auto& location : object.locations) {
if (location.zone != currentZoneID) {
continue;
}
// Get the equipment
auto* equipment = object->FirstChildElement("equipment");
std::vector<LOT> inventory;
if (equipment) {
auto* text = equipment->GetText();
if (text != nullptr) {
std::string equipmentString(text);
std::vector<std::string> splitEquipment = GeneralUtils::SplitString(equipmentString, ',');
for (auto& item : splitEquipment) {
// remove spaces for tryParse to work
item.erase(remove_if(item.begin(), item.end(), isspace), item.end());
auto itemInt = GeneralUtils::TryParse<uint32_t>(item);
if (itemInt) inventory.push_back(itemInt.value());
}
}
}
// Get the phrases
auto* phrases = object->FirstChildElement("phrases");
std::vector<std::string> phraseList = {};
if (phrases) {
for (auto* phrase = phrases->FirstChildElement("phrase"); phrase != nullptr;
phrase = phrase->NextSiblingElement("phrase")) {
// Get the phrase
auto* text = phrase->GetText();
if (text == nullptr) {
LOG("Failed to parse NPC phrase");
continue;
}
phraseList.push_back(text);
}
}
auto* configElement = object->FirstChildElement("config");
std::vector<std::u16string> keys = {};
LwoNameValue config;
if (configElement) {
for (auto* key = configElement->FirstChildElement("key"); key != nullptr;
key = key->NextSiblingElement("key")) {
// Get the config data
auto* data = key->GetText();
if (!data) continue;
const auto& configData = config.ParseInsert(data);
if (configData->GetKey() == u"useLocationsAsRandomSpawnPoint" && configData->GetValueType() == eLDFType::LDF_TYPE_BOOLEAN) {
useLocationsAsRandomSpawnPoint = static_cast<const LDFData<bool>*>(configData.get())->GetValue();
config.Erase(u"useLocationsAsRandomSpawnPoint");
continue;
}
keys.push_back(configData->GetKey());
}
}
if (!keys.empty()) config.Insert<std::vector<std::u16string>>(u"syncLDF", keys);
VanityObject objectData{
.m_Name = name,
.m_LOT = lot,
.m_Equipment = inventory,
.m_Phrases = phraseList,
.m_Config = config
VanityObjectLocation locationData{
.m_Chance = location.chance.value_or(1.0f),
.m_Position = { location.x, location.y, location.z },
.m_Rotation = { location.rw, location.rx, location.ry, location.rz },
.m_Scale = location.scale.value_or(1.0f),
};
// Get the locations
auto* locations = object->FirstChildElement("locations");
objectData.m_Locations[location.zone].push_back(locationData);
if (locations == nullptr) {
LOG("Failed to parse NPC locations");
continue;
}
for (auto* location = locations->FirstChildElement("location"); location != nullptr;
location = location->NextSiblingElement("location")) {
// Get the location data
auto zoneID = GeneralUtils::TryParse<uint32_t>(location->Attribute("zone"));
auto x = GeneralUtils::TryParse<float>(location->Attribute("x"));
auto y = GeneralUtils::TryParse<float>(location->Attribute("y"));
auto z = GeneralUtils::TryParse<float>(location->Attribute("z"));
auto rw = GeneralUtils::TryParse<float>(location->Attribute("rw"));
auto rx = GeneralUtils::TryParse<float>(location->Attribute("rx"));
auto ry = GeneralUtils::TryParse<float>(location->Attribute("ry"));
auto rz = GeneralUtils::TryParse<float>(location->Attribute("rz"));
if (!zoneID || !x || !y || !z || !rw || !rx || !ry || !rz) {
LOG("Failed to parse NPC location data");
continue;
}
if (zoneID.value() != currentZoneID) {
continue;
}
VanityObjectLocation locationData{
.m_Position = { x.value(), y.value(), z.value() },
.m_Rotation = { rw.value(), rx.value(), ry.value(), rz.value() },
};
if (location->Attribute("chance")) {
locationData.m_Chance = GeneralUtils::TryParse<float>(location->Attribute("chance")).value_or(1.0f);
}
if (location->Attribute("scale")) {
locationData.m_Scale = GeneralUtils::TryParse<float>(location->Attribute("scale")).value_or(1.0f);
}
const auto& it = objectData.m_Locations.find(zoneID.value());
if (it != objectData.m_Locations.end()) {
it->second.push_back(locationData);
} else {
std::vector<VanityObjectLocation> locations;
locations.push_back(locationData);
objectData.m_Locations.insert(std::make_pair(zoneID.value(), locations));
}
if (!useLocationsAsRandomSpawnPoint) {
objects.push_back(objectData);
objectData.m_Locations.clear();
}
}
if (useLocationsAsRandomSpawnPoint && !objectData.m_Locations.empty()) {
if (!useLocationsAsRandomSpawnPoint) {
objects.push_back(objectData);
objectData.m_Locations.clear();
}
}
if (useLocationsAsRandomSpawnPoint && !objectData.m_Locations.empty()) {
objects.push_back(objectData);
}
}
}