mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-17 02:02:50 +00:00
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:
96
dCommon/AccountRules.h
Normal file
96
dCommon/AccountRules.h
Normal file
@@ -0,0 +1,96 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
/**
|
||||
* Who staff may use their tools on: the self and rank rules shared by the dashboard (RouteUtils) and the in-game slash
|
||||
* commands (SlashCommandHandler). The functions here are pure; Permissions.h decides the self_* and manage_equal_rank
|
||||
* levels they are given.
|
||||
*/
|
||||
namespace AccountRules {
|
||||
// GM 9: may do anything to anyone, including themselves and other GM 9s
|
||||
constexpr uint8_t OPERATOR_LEVEL = 9;
|
||||
|
||||
/**
|
||||
* What a staff action on an account does. It decides which permission a staff member below GM 9 needs to use the
|
||||
* tool on their own account or characters (they still need the tool's own permission too).
|
||||
*/
|
||||
enum class eAccountAction : uint8_t {
|
||||
TOOLS, // nothing is gained: rescue or move a character, kick, sign out everywhere, email a reset link (self_tools)
|
||||
ITEMS, // gives items, coins or progress: edit or restore characters, missions, mail items (self_items)
|
||||
MODERATION, // changes a moderation record or account security: ban, mute, lock, strikes, GM level, password, delete (self_moderation)
|
||||
};
|
||||
|
||||
// The permission that lets staff below GM 9 do this kind of action to their own account
|
||||
inline const char* SelfPermission(eAccountAction action) {
|
||||
switch (action) {
|
||||
case eAccountAction::TOOLS: return "self_tools";
|
||||
case eAccountAction::ITEMS: return "self_items";
|
||||
default: return "self_moderation";
|
||||
}
|
||||
}
|
||||
|
||||
// The permission that lets staff below GM 9 act on accounts with their own GM level
|
||||
constexpr const char* EQUAL_RANK_PERMISSION = "manage_equal_rank";
|
||||
|
||||
// Why an actor may not act on an account (NONE: they may)
|
||||
enum class eManageDenial : uint8_t { NONE, SELF, EQUAL_RANK, HIGHER_RANK };
|
||||
|
||||
/**
|
||||
* Whether an actor may act on a target account. GM 9 may act on anyone, themselves included. Below GM 9: their own
|
||||
* account only with selfAllowed (the self_* permission for the action), an account at their own level only with
|
||||
* equalAllowed (manage_equal_rank), and never an account above their own level, whatever the permissions say.
|
||||
*/
|
||||
inline eManageDenial ManageDenial(uint8_t actorLevel, uint32_t actorAccountId, uint8_t targetLevel, uint32_t targetAccountId, bool selfAllowed, bool equalAllowed) {
|
||||
if (actorLevel >= OPERATOR_LEVEL) return eManageDenial::NONE;
|
||||
if (actorAccountId != 0 && actorAccountId == targetAccountId) return selfAllowed ? eManageDenial::NONE : eManageDenial::SELF;
|
||||
if (targetLevel > actorLevel) return eManageDenial::HIGHER_RANK;
|
||||
if (targetLevel == actorLevel && !equalAllowed) return eManageDenial::EQUAL_RANK;
|
||||
return eManageDenial::NONE;
|
||||
}
|
||||
|
||||
inline bool CanManageAccount(uint8_t actorLevel, uint32_t actorAccountId, uint8_t targetLevel, uint32_t targetAccountId, bool selfAllowed, bool equalAllowed) {
|
||||
return ManageDenial(actorLevel, actorAccountId, targetLevel, targetAccountId, selfAllowed, equalAllowed) == eManageDenial::NONE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safety rail for the server, not a limit on the operator: demoting, banning, locking or deleting a GM 9 account is
|
||||
* refused when no other GM 9 account that can still sign in (not banned or locked) would be left.
|
||||
*/
|
||||
inline bool RemovesLastOperator(uint8_t targetLevel, uint32_t otherActiveOperators) {
|
||||
return targetLevel >= OPERATOR_LEVEL && otherActiveOperators == 0;
|
||||
}
|
||||
|
||||
// Whether an actor may grant a GM level: never above their own, and only operators may create peers
|
||||
inline bool CanGrantGmLevel(uint8_t actorLevel, uint8_t newLevel) {
|
||||
if (newLevel > OPERATOR_LEVEL) return false;
|
||||
return actorLevel >= OPERATOR_LEVEL || newLevel < actorLevel;
|
||||
}
|
||||
|
||||
// Why an action was refused, for the person who tried it (empty for NONE)
|
||||
inline std::string DenialMessage(eManageDenial denial, eAccountAction action) {
|
||||
switch (denial) {
|
||||
case eManageDenial::SELF:
|
||||
return std::string("Your GM level may not do this to your own account or characters (the ") + SelfPermission(action) + " permission)";
|
||||
case eManageDenial::EQUAL_RANK:
|
||||
return std::string("You cannot manage an account with the same GM level as yours (the ") + EQUAL_RANK_PERMISSION + " permission)";
|
||||
case eManageDenial::HIGHER_RANK:
|
||||
return "You cannot manage an account with a higher GM level than yours";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// The last-operator refusal, e.g. what = "banned"
|
||||
inline std::string LastOperatorMessage(const std::string& what) {
|
||||
return "This is the last GM 9 account that can sign in, so it can't be " + what +
|
||||
". Make another GM 9 account first, so the server always has someone who can manage it.";
|
||||
}
|
||||
|
||||
/**
|
||||
* ManageDenial with the self_* and manage_equal_rank levels as the Permissions page (or the config) sets them now.
|
||||
* Used by the dashboard and the world servers alike.
|
||||
*/
|
||||
eManageDenial ManageDenialNow(uint8_t actorLevel, uint32_t actorAccountId, uint8_t targetLevel, uint32_t targetAccountId, eAccountAction action);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ set(DCOMMON_SOURCES
|
||||
"BinaryIO.cpp"
|
||||
"dConfig.cpp"
|
||||
"Diagnostics.cpp"
|
||||
"Locale.cpp"
|
||||
"Logger.cpp"
|
||||
"Game.cpp"
|
||||
"GeneralUtils.cpp"
|
||||
@@ -21,6 +22,15 @@ set(DCOMMON_SOURCES
|
||||
"Sd0.cpp"
|
||||
"Lxfml.cpp"
|
||||
"LxfmlBugged.cpp"
|
||||
"VanityXml.cpp"
|
||||
"ScheduleRules.cpp"
|
||||
"EventParts.cpp"
|
||||
"VanityEvents.cpp"
|
||||
"ZoneFile.cpp"
|
||||
"LevelFile.cpp"
|
||||
"Raw.cpp"
|
||||
"Process.cpp"
|
||||
"Permissions.cpp"
|
||||
)
|
||||
|
||||
# Workaround for compiler bug where the optimized code could result in a memcpy of 0 bytes, even though that isnt possible.
|
||||
|
||||
51
dCommon/CivilDate.h
Normal file
51
dCommon/CivilDate.h
Normal file
@@ -0,0 +1,51 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
/**
|
||||
* Calendar dates as days since 1970-01-01 and back (Howard Hinnant's algorithms), for schedules that work in whole
|
||||
* days: the dashboard's cron schedules (Cron.h) and the vanity event rules (VanityEvents.h).
|
||||
*/
|
||||
namespace CivilDate {
|
||||
struct Date { int64_t year; unsigned month; unsigned day; };
|
||||
|
||||
inline int64_t DaysFromCivil(int64_t y, unsigned m, unsigned d) {
|
||||
y -= m <= 2;
|
||||
const int64_t era = (y >= 0 ? y : y - 399) / 400;
|
||||
const auto yoe = static_cast<unsigned>(y - era * 400);
|
||||
const unsigned doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1;
|
||||
const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
return era * 146097 + static_cast<int64_t>(doe) - 719468;
|
||||
}
|
||||
|
||||
inline Date CivilFromDays(int64_t z) {
|
||||
z += 719468;
|
||||
const int64_t era = (z >= 0 ? z : z - 146096) / 146097;
|
||||
const auto doe = static_cast<unsigned>(z - era * 146097);
|
||||
const unsigned yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
const unsigned doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
const unsigned mp = (5 * doy + 2) / 153;
|
||||
const unsigned d = doy - (153 * mp + 2) / 5 + 1;
|
||||
const unsigned m = mp < 10 ? mp + 3 : mp - 9;
|
||||
return { static_cast<int64_t>(yoe) + era * 400 + (m <= 2), m, d };
|
||||
}
|
||||
|
||||
// 0 = Sunday; 1970-01-01 was a Thursday
|
||||
inline unsigned Weekday(int64_t daysSinceEpoch) {
|
||||
return static_cast<unsigned>(((daysSinceEpoch % 7) + 11) % 7);
|
||||
}
|
||||
|
||||
// The day a unix time falls on, rounding down for times before 1970 too
|
||||
inline int64_t DayOf(int64_t seconds) {
|
||||
return seconds >= 0 ? seconds / 86400 : (seconds - 86399) / 86400;
|
||||
}
|
||||
|
||||
inline bool IsLeapYear(int64_t year) {
|
||||
return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
|
||||
}
|
||||
|
||||
inline unsigned DaysInMonth(int64_t year, unsigned month) {
|
||||
static constexpr unsigned DAYS[]{ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
|
||||
return month == 2 && IsLeapYear(year) ? 29 : DAYS[(month - 1) % 12];
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
#include "Diagnostics.h"
|
||||
|
||||
#include <filesystem>
|
||||
#include "Game.h"
|
||||
#include "Logger.h"
|
||||
|
||||
@@ -245,7 +247,11 @@ void Diagnostics::SetProcessFileName(const std::string& name) {
|
||||
}
|
||||
|
||||
void Diagnostics::SetOutDirectory(const std::string& path) {
|
||||
// Crash files are named by appending to this, so it must end in a separator; the folder may not exist yet
|
||||
m_OutDirectory = path;
|
||||
if (!m_OutDirectory.empty() && m_OutDirectory.back() != '/' && m_OutDirectory.back() != '\\') m_OutDirectory += '/';
|
||||
std::error_code ec;
|
||||
if (!m_OutDirectory.empty()) std::filesystem::create_directories(m_OutDirectory, ec);
|
||||
}
|
||||
|
||||
void Diagnostics::SetProduceMemoryDump(bool value) {
|
||||
|
||||
126
dCommon/EventParts.cpp
Normal file
126
dCommon/EventParts.cpp
Normal file
@@ -0,0 +1,126 @@
|
||||
#include "EventParts.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
#include "magic_enum.hpp"
|
||||
|
||||
namespace {
|
||||
std::string Text(const nlohmann::json& json, const char* key) {
|
||||
return json.contains(key) && json[key].is_string() ? json[key].get<std::string>() : "";
|
||||
}
|
||||
}
|
||||
|
||||
std::string EventParts::KindName(eKind kind) {
|
||||
std::string name(magic_enum::enum_name(kind));
|
||||
for (auto& c : name) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
return name;
|
||||
}
|
||||
|
||||
std::optional<EventParts::eKind> EventParts::KindOf(const std::string& name) {
|
||||
return magic_enum::enum_cast<eKind>(name, magic_enum::case_insensitive);
|
||||
}
|
||||
|
||||
std::optional<std::vector<EventParts::Part>> EventParts::Parse(const nlohmann::json& json, std::string& error) {
|
||||
if (!json.is_array()) { error = "The parts are a list"; return std::nullopt; }
|
||||
std::vector<Part> parts;
|
||||
for (const auto& item : json) {
|
||||
if (!item.is_object()) { error = "Each part is an object"; return std::nullopt; }
|
||||
const auto kind = item.contains("kind") && item["kind"].is_string() ? KindOf(item["kind"].get<std::string>()) : std::nullopt;
|
||||
if (!kind) {
|
||||
std::string kinds;
|
||||
for (const auto value : magic_enum::enum_values<eKind>()) kinds += (kinds.empty() ? "" : ", ") + KindName(value);
|
||||
error = "A part's kind is one of " + kinds;
|
||||
return std::nullopt;
|
||||
}
|
||||
Part part{ .kind = *kind };
|
||||
if (item.contains("config") && item["config"].is_object()) part.config = item["config"];
|
||||
// true, or 1 as the migrations from before wrote it
|
||||
if (item.contains("applied")) part.applied = item["applied"].is_boolean() ? item["applied"].get<bool>() : item["applied"].is_number() && item["applied"].get<double>() != 0;
|
||||
if (item.contains("state") && item["state"].is_object()) part.state = item["state"];
|
||||
if (item.contains("status") && item["status"].is_string()) part.status = item["status"].get<std::string>();
|
||||
parts.push_back(std::move(part));
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
std::optional<std::vector<EventParts::Part>> EventParts::Parse(const std::string& text, std::string& error) {
|
||||
if (text.find_first_not_of(" \t\r\n") == std::string::npos) return std::vector<Part>{};
|
||||
const auto json = nlohmann::json::parse(text, nullptr, false);
|
||||
if (json.is_discarded()) { error = "The parts aren't valid JSON"; return std::nullopt; }
|
||||
return Parse(json, error);
|
||||
}
|
||||
|
||||
nlohmann::json EventParts::ToJson(const Part& part) {
|
||||
return { {"kind", KindName(part.kind)}, {"config", part.config}, {"applied", part.applied}, {"state", part.state}, {"status", part.status} };
|
||||
}
|
||||
|
||||
std::string EventParts::Write(const std::vector<Part>& parts) {
|
||||
nlohmann::json json = nlohmann::json::array();
|
||||
for (const auto& part : parts) json.push_back(ToJson(part));
|
||||
return json.dump();
|
||||
}
|
||||
|
||||
EventParts::eStep EventParts::StepFor(const Part& part, bool on) {
|
||||
if (on) return part.applied ? eStep::CONTINUE : eStep::START;
|
||||
return part.applied ? eStep::END : eStep::NONE;
|
||||
}
|
||||
|
||||
bool EventParts::Momentary(eKind kind) {
|
||||
return kind == eKind::ANNOUNCEMENT || kind == eKind::RESTART;
|
||||
}
|
||||
|
||||
EventParts::Reconciled EventParts::Reconcile(const std::vector<Part>& before, std::vector<Part> after) {
|
||||
Reconciled result;
|
||||
std::vector<bool> used(before.size()), matched(after.size());
|
||||
const auto take = [&](Part& part, size_t i) {
|
||||
used[i] = true;
|
||||
part.applied = before[i].applied;
|
||||
part.state = before[i].state;
|
||||
part.status = before[i].status;
|
||||
};
|
||||
for (auto& part : after) {
|
||||
part.applied = false;
|
||||
part.state = nlohmann::json::object();
|
||||
part.status.clear();
|
||||
}
|
||||
// The same part, then a changed momentary one in its place
|
||||
for (size_t a = 0; a < after.size(); a++) {
|
||||
for (size_t i = 0; i < before.size() && !matched[a]; i++) {
|
||||
if (!used[i] && before[i].kind == after[a].kind && before[i].config == after[a].config) { take(after[a], i); matched[a] = true; }
|
||||
}
|
||||
}
|
||||
for (size_t a = 0; a < after.size(); a++) {
|
||||
if (matched[a] || !Momentary(after[a].kind)) continue;
|
||||
for (size_t i = 0; i < before.size() && !matched[a]; i++) {
|
||||
if (!used[i] && before[i].kind == after[a].kind) { take(after[a], i); matched[a] = true; }
|
||||
}
|
||||
}
|
||||
for (size_t i = 0; i < before.size(); i++) {
|
||||
if (!used[i] && before[i].applied) result.retired.push_back(before[i]);
|
||||
}
|
||||
result.parts = std::move(after);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<VanityEvents::Changes> EventParts::VanityChanges(const std::string& eventName, const std::string& text) {
|
||||
std::vector<VanityEvents::Changes> changes;
|
||||
std::string error;
|
||||
const auto parts = Parse(text, error);
|
||||
if (!parts) return changes;
|
||||
for (const auto& part : *parts) {
|
||||
if (part.kind != eKind::VANITY) continue;
|
||||
const auto& config = part.config;
|
||||
VanityEvents::Changes change{ .name = eventName };
|
||||
change.file = Text(config, "file");
|
||||
// Names one per line, or a list
|
||||
if (config.contains("removals") && config["removals"].is_array()) {
|
||||
for (const auto& name : config["removals"]) if (name.is_string()) change.removals += name.get<std::string>() + "\n";
|
||||
} else {
|
||||
change.removals = Text(config, "removals");
|
||||
}
|
||||
if (config.contains("fileSwitches")) change.fileSwitches = config["fileSwitches"].is_string() ? config["fileSwitches"].get<std::string>() : config["fileSwitches"].dump();
|
||||
changes.push_back(std::move(change));
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
69
dCommon/EventParts.h
Normal file
69
dCommon/EventParts.h
Normal file
@@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "json.hpp"
|
||||
#include "VanityEvents.h"
|
||||
|
||||
/**
|
||||
* The parts of a scheduled event (scheduled_events.parts): what it switches on while it is on and off again after.
|
||||
* Stored as JSON, [{kind, config, applied, state, status}]:
|
||||
* feature {feature}: the feature is put in a free event_1..event_8 setting (state: slot, previousValue, previousWebWins)
|
||||
* vanity {file, removals, fileSwitches}: vanity changes (VanityEvents.h); the worlds respawn their vanity NPCs
|
||||
* live_event {type, title, message, zones, instance, config}: a live event runs while it is on (state: id)
|
||||
* announcement {title, message, zones, atStart, repeat, endMessage}: said when it starts, every `repeat` (a cron
|
||||
* schedule, UTC) while it is on, and endMessage when it ends (state: nextAt)
|
||||
* restart {when: start|end, minutes, reason}: a restart with in-game warnings is scheduled when it starts or ends
|
||||
* `applied` is whether the dashboard started the part and hasn't ended it since, so starting and ending happen once
|
||||
* each, whatever restarts in between. The dashboard does the work; the world servers only read the vanity parts.
|
||||
* Pure; unit tested.
|
||||
*/
|
||||
namespace EventParts {
|
||||
// Stored by name, so only append
|
||||
enum class eKind : uint8_t { FEATURE, VANITY, LIVE_EVENT, ANNOUNCEMENT, RESTART };
|
||||
|
||||
// "feature", "vanity", "live_event", ...
|
||||
std::string KindName(eKind kind);
|
||||
std::optional<eKind> KindOf(const std::string& name);
|
||||
|
||||
struct Part {
|
||||
eKind kind{ eKind::FEATURE };
|
||||
nlohmann::json config = nlohmann::json::object(); // what it does, as staff set it
|
||||
bool applied{}; // started and not ended since
|
||||
nlohmann::json state = nlohmann::json::object(); // what starting it left to undo or carry on
|
||||
std::string status; // the last thing that happened to it
|
||||
};
|
||||
|
||||
// The stored parts; empty text is none. nullopt and error when it can't be read
|
||||
std::optional<std::vector<Part>> Parse(const std::string& text, std::string& error);
|
||||
std::optional<std::vector<Part>> Parse(const nlohmann::json& json, std::string& error);
|
||||
inline std::optional<std::vector<Part>> Parse(const char* text, std::string& error) { return Parse(std::string(text), error); }
|
||||
|
||||
nlohmann::json ToJson(const Part& part);
|
||||
std::string Write(const std::vector<Part>& parts);
|
||||
|
||||
// What a part needs when its event is `on`
|
||||
enum class eStep { NONE, START, END, CONTINUE };
|
||||
eStep StepFor(const Part& part, bool on);
|
||||
|
||||
// Parts that only do something the moment their event starts or ends (announcements, restarts): changing one
|
||||
// while the event is on doesn't start it again (an announcement already said isn't said twice)
|
||||
bool Momentary(eKind kind);
|
||||
|
||||
/**
|
||||
* Staff changed an event's parts. Parts that stay as they were (the same kind and config) keep what the dashboard
|
||||
* did with them, and so does a changed momentary part (matched by kind, in order); the rest start afresh.
|
||||
* `retired`: parts that were started and are gone or changed now, to be undone.
|
||||
*/
|
||||
struct Reconciled {
|
||||
std::vector<Part> parts;
|
||||
std::vector<Part> retired;
|
||||
};
|
||||
Reconciled Reconcile(const std::vector<Part>& before, std::vector<Part> after);
|
||||
|
||||
// An event's vanity parts, as VanityEvents::LoadWorld takes them (none if its parts can't be read)
|
||||
std::vector<VanityEvents::Changes> VanityChanges(const std::string& eventName, const std::string& parts);
|
||||
}
|
||||
@@ -34,6 +34,18 @@ enum class eReplicaComponentType : uint32_t;
|
||||
|
||||
//! The general utils namespace
|
||||
namespace GeneralUtils {
|
||||
// Text for a SQL LIKE pattern with its wildcards (% and _) and the escape character escaped; use with ESCAPE '<escape>'.
|
||||
// '!' works the same in SQLite and MySQL (a backslash in a MySQL string literal depends on the sql_mode).
|
||||
inline std::string LikeEscape(std::string_view text, char escape = '\\') {
|
||||
std::string escaped;
|
||||
escaped.reserve(text.size());
|
||||
for (const char c : text) {
|
||||
if (c == '%' || c == '_' || c == escape) escaped += escape;
|
||||
escaped += c;
|
||||
}
|
||||
return escaped;
|
||||
}
|
||||
|
||||
//! Converts a plain ASCII string to a UTF-16 string
|
||||
/*!
|
||||
\param string The string to convert
|
||||
|
||||
227
dCommon/HotPropertySlots.h
Normal file
227
dCommon/HotPropertySlots.h
Normal file
@@ -0,0 +1,227 @@
|
||||
#ifndef __HOTPROPERTYSLOTS__H__
|
||||
#define __HOTPROPERTYSLOTS__H__
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
/**
|
||||
* The "Today's Top Properties" panel of the client's news screen (res/ui/ingame/news.gfx).
|
||||
*
|
||||
* The screen sends GetHotPropertyData (GM 1511) when it opens; the server answers with
|
||||
* NewsSendHotPropertiesInfoToClient (GM 1510), a list of properties. For each entry the client looks up the
|
||||
* PropertyTemplate row by the entry's template id (LWOCharacterComponent::HotPropertyData, 0x00cf83f0 in 1.10.64)
|
||||
* and the UI puts it in the slot for that row's spawnName. The UI only has four slots, one per small property
|
||||
* world, and ignores every other spawn name. Live always sent four entries, one per slot, in this order.
|
||||
*
|
||||
* A slot only displays what it is sent: the name, owner, reputation and so on of the entry (the property and clone
|
||||
* ids are not used). So a slot can show a property of any property world, sent with the slot's template id; only the
|
||||
* tooltip still names the slot's own world (it is the slot's fixed zone name and its template's map description).
|
||||
*/
|
||||
namespace HotPropertySlots {
|
||||
// The slots of news.gfx, in the order live sent them
|
||||
constexpr std::array<std::string_view, 4> NEWS_SPAWN_NAMES{ "AGSmallProperty", "NSSmallProperty", "GFSmallProperty", "FVSmallProperty" };
|
||||
|
||||
// What a slot shows
|
||||
enum class eMode : uint8_t {
|
||||
AUTO = 0, // the approved public property of the slot's location with the most reputation (not shown in another slot)
|
||||
PICKED = 1, // the property staff chose (AUTO again whenever it can't be shown any more)
|
||||
EMPTY = 2, // nothing: the slot stays locked
|
||||
};
|
||||
|
||||
inline std::string_view ModeName(eMode mode) {
|
||||
switch (mode) {
|
||||
case eMode::PICKED: return "picked";
|
||||
case eMode::EMPTY: return "empty";
|
||||
default: return "auto";
|
||||
}
|
||||
}
|
||||
|
||||
inline std::optional<eMode> ParseMode(std::string_view name) {
|
||||
if (name == "auto") return eMode::AUTO;
|
||||
if (name == "picked") return eMode::PICKED;
|
||||
if (name == "empty") return eMode::EMPTY;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// A mode as stored in the database; anything unknown is AUTO
|
||||
inline eMode ModeFromInt(int64_t value) {
|
||||
return value == 1 ? eMode::PICKED : value == 2 ? eMode::EMPTY : eMode::AUTO;
|
||||
}
|
||||
|
||||
struct TemplateRow { // PropertyTemplate
|
||||
uint32_t id{};
|
||||
uint32_t mapId{};
|
||||
std::string spawnName;
|
||||
};
|
||||
|
||||
struct EntranceRow { // PropertyEntranceComponent
|
||||
uint32_t mapId{};
|
||||
std::string propertyName;
|
||||
};
|
||||
|
||||
struct Slot {
|
||||
uint32_t templateId{}; // PropertyTemplate id, sent to the client as the entry's template id
|
||||
uint32_t mapId{}; // the property world
|
||||
std::string spawnName;
|
||||
bool operator==(const Slot&) const = default;
|
||||
};
|
||||
|
||||
/**
|
||||
* The slots, in NEWS_SPAWN_NAMES order: for each spawn name the news screen knows, the PropertyTemplate row of
|
||||
* that spawn name whose map a property entrance (the rocket pads players launch to properties from) leads to.
|
||||
* When several rows qualify the lowest id is used. A spawn name without such a row has no slot.
|
||||
*/
|
||||
inline std::vector<Slot> ResolveSlots(const std::vector<TemplateRow>& templates, const std::vector<EntranceRow>& entrances) {
|
||||
std::vector<Slot> slots;
|
||||
for (const auto spawnName : NEWS_SPAWN_NAMES) {
|
||||
const TemplateRow* best = nullptr;
|
||||
for (const auto& row : templates) {
|
||||
if (row.spawnName != spawnName) continue;
|
||||
const bool entered = std::any_of(entrances.begin(), entrances.end(), [&](const EntranceRow& entrance) {
|
||||
return entrance.mapId == row.mapId && entrance.propertyName == spawnName;
|
||||
});
|
||||
if (entered && (!best || row.id < best->id)) best = &row;
|
||||
}
|
||||
if (best) slots.push_back({ best->id, best->mapId, best->spawnName });
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
/**
|
||||
* The property worlds a slot can show a property of (its location): every PropertyTemplate map a property entrance
|
||||
* leads to, small and medium, ordered by their lowest template id.
|
||||
*/
|
||||
inline std::vector<uint32_t> PropertyWorlds(const std::vector<TemplateRow>& templates, const std::vector<EntranceRow>& entrances) {
|
||||
std::vector<const TemplateRow*> rows;
|
||||
for (const auto& row : templates) {
|
||||
const bool entered = std::any_of(entrances.begin(), entrances.end(), [&](const EntranceRow& entrance) {
|
||||
return entrance.mapId == row.mapId && entrance.propertyName == row.spawnName;
|
||||
});
|
||||
if (entered) rows.push_back(&row);
|
||||
}
|
||||
std::sort(rows.begin(), rows.end(), [](const TemplateRow* a, const TemplateRow* b) { return a->id < b->id; });
|
||||
std::vector<uint32_t> worlds;
|
||||
for (const auto* row : rows) {
|
||||
if (std::find(worlds.begin(), worlds.end(), row->mapId) == worlds.end()) worlds.push_back(row->mapId);
|
||||
}
|
||||
return worlds;
|
||||
}
|
||||
|
||||
// A slot's location as stored (0: none) if it is still a property world, else the slot's own world
|
||||
inline uint32_t Location(uint32_t stored, const Slot& slot, const std::vector<uint32_t>& worlds) {
|
||||
return stored != 0 && std::find(worlds.begin(), worlds.end(), stored) != worlds.end() ? stored : slot.mapId;
|
||||
}
|
||||
|
||||
constexpr int32_t PRIVACY_PUBLIC = 2; // ePropertyPrivacyOption::Public
|
||||
|
||||
// Whether a property may be featured in a slot: approved by a moderator, public, and in the slot's location
|
||||
inline bool Featurable(uint32_t modApproved, int32_t privacyOption, uint32_t propertyZone, uint32_t slotMap) {
|
||||
return modApproved == 1 && privacyOption == PRIVACY_PUBLIC && propertyZone == slotMap;
|
||||
}
|
||||
|
||||
// What a slot was set to show (featured_properties), with its location resolved
|
||||
struct Choice {
|
||||
eMode mode{ eMode::AUTO };
|
||||
uint32_t mapId{}; // the location: the world whose properties it shows
|
||||
int64_t propertyId{}; // the picked property (PICKED)
|
||||
};
|
||||
|
||||
// An approved public property that can be shown
|
||||
struct Candidate {
|
||||
int64_t propertyId{};
|
||||
uint32_t mapId{};
|
||||
uint64_t reputation{};
|
||||
};
|
||||
|
||||
// What a slot shows
|
||||
struct Showing {
|
||||
std::optional<int64_t> propertyId; // none: the slot stays locked
|
||||
bool pickFellBack{}; // PICKED, but the pick can't be shown (any more), so it shows its AUTO
|
||||
bool operator==(const Showing&) const = default;
|
||||
};
|
||||
|
||||
// How many of each location's best properties are enough to resolve the slots: every slot could draw from one world
|
||||
constexpr uint32_t CANDIDATES_PER_WORLD = NEWS_SPAWN_NAMES.size();
|
||||
|
||||
/**
|
||||
* The worlds whose best CANDIDATES_PER_WORLD candidates Resolve needs (0: every world, for full auto). The picked
|
||||
* properties have to be added to the candidates as well, when they may be shown in their slot's location.
|
||||
*/
|
||||
inline std::vector<uint32_t> CandidateWorlds(const std::vector<Choice>& choices, bool fullAuto) {
|
||||
if (fullAuto) return { 0 };
|
||||
std::vector<uint32_t> worlds;
|
||||
for (const auto& choice : choices) {
|
||||
if (choice.mode != eMode::EMPTY && std::find(worlds.begin(), worlds.end(), choice.mapId) == worlds.end()) worlds.push_back(choice.mapId);
|
||||
}
|
||||
return worlds;
|
||||
}
|
||||
|
||||
/**
|
||||
* What each slot shows (one entry per choice, in slot order), so that no property is shown twice.
|
||||
* candidates: the properties that may be shown (see CandidateWorlds); a property listed twice counts once.
|
||||
* Full auto ignores the choices: the slots show the candidates with the most reputation, in slot order.
|
||||
* Otherwise the picks come first, in slot order: a pick is shown when it is a candidate of the slot's location that
|
||||
* an earlier slot doesn't show, else the slot falls back to AUTO. Then the AUTO slots, in slot order, each show the
|
||||
* candidate of their location with the most reputation that isn't shown yet (two on one world: its #1 and #2).
|
||||
* A slot with nothing left to show, and every EMPTY slot, shows nothing.
|
||||
*/
|
||||
inline std::vector<Showing> Resolve(const std::vector<Choice>& choices, bool fullAuto, std::vector<Candidate> candidates) {
|
||||
std::stable_sort(candidates.begin(), candidates.end(), [](const Candidate& a, const Candidate& b) { return a.reputation > b.reputation; });
|
||||
std::vector<Showing> showing(choices.size());
|
||||
std::vector<int64_t> used;
|
||||
const auto isUsed = [&](int64_t id) { return std::find(used.begin(), used.end(), id) != used.end(); };
|
||||
const auto best = [&](std::optional<uint32_t> mapId) -> std::optional<int64_t> {
|
||||
for (const auto& candidate : candidates) {
|
||||
if ((!mapId || candidate.mapId == *mapId) && !isUsed(candidate.propertyId)) return candidate.propertyId;
|
||||
}
|
||||
return std::nullopt;
|
||||
};
|
||||
const auto show = [&](size_t slot, std::optional<int64_t> id) {
|
||||
showing[slot].propertyId = id;
|
||||
if (id) used.push_back(*id);
|
||||
};
|
||||
|
||||
if (fullAuto) {
|
||||
for (size_t i = 0; i < choices.size(); i++) show(i, best(std::nullopt));
|
||||
return showing;
|
||||
}
|
||||
|
||||
std::vector<size_t> autos;
|
||||
for (size_t i = 0; i < choices.size(); i++) {
|
||||
const auto& choice = choices[i];
|
||||
if (choice.mode == eMode::EMPTY) continue;
|
||||
if (choice.mode == eMode::PICKED) {
|
||||
const bool shown = !isUsed(choice.propertyId) && std::any_of(candidates.begin(), candidates.end(), [&](const Candidate& candidate) {
|
||||
return candidate.propertyId == choice.propertyId && candidate.mapId == choice.mapId;
|
||||
});
|
||||
if (shown) {
|
||||
show(i, choice.propertyId);
|
||||
continue;
|
||||
}
|
||||
showing[i].pickFellBack = true;
|
||||
}
|
||||
autos.push_back(i);
|
||||
}
|
||||
for (const auto i : autos) show(i, best(choices[i].mapId));
|
||||
return showing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which entries to send, by index. news.gfx always walks four entries and, for an entry that is missing, reuses
|
||||
* the slot of the entry before it, filling it with "undefined". So fewer than four entries are padded by
|
||||
* repeating the last one, which only fills its own slot again. None: nothing to send, every slot stays locked.
|
||||
*/
|
||||
inline std::vector<size_t> NewsOrder(size_t count) {
|
||||
std::vector<size_t> order;
|
||||
for (size_t i = 0; i < count; i++) order.push_back(i);
|
||||
while (count > 0 && order.size() < NEWS_SPAWN_NAMES.size()) order.push_back(count - 1);
|
||||
return order;
|
||||
}
|
||||
};
|
||||
|
||||
#endif //!__HOTPROPERTYSLOTS__H__
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
// C++
|
||||
#include <map>
|
||||
#include <ranges>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <sstream>
|
||||
@@ -297,6 +298,41 @@ struct LwoNameValue {
|
||||
return this->values.find(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* All values as "key=type:value" lines, for storing outside the game (e.g. mail attachments).
|
||||
* Backslashes and line breaks inside a value are escaped (\\ and \n) so every value comes back out.
|
||||
*/
|
||||
std::string ToLines() const {
|
||||
std::string lines;
|
||||
for (const auto& value : this->values | std::views::values) {
|
||||
if (!lines.empty()) lines += '\n';
|
||||
for (const char c : value->GetString()) {
|
||||
if (c == '\\') lines += "\\\\";
|
||||
else if (c == '\n') lines += "\\n";
|
||||
else lines += c;
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Inverse of ToLines; lines that do not parse are skipped
|
||||
void InsertLines(const std::string_view lines) {
|
||||
for (const auto& escaped : GeneralUtils::SplitString(lines, '\n')) {
|
||||
if (escaped.empty()) continue;
|
||||
std::string line;
|
||||
for (size_t i = 0; i < escaped.size(); i++) {
|
||||
if (escaped[i] == '\\' && i + 1 < escaped.size()) {
|
||||
const char next = escaped[++i];
|
||||
line += next == 'n' ? '\n' : next;
|
||||
} else {
|
||||
line += escaped[i];
|
||||
}
|
||||
}
|
||||
LDFPtr parsed(LDFBaseData::DataFromString(line));
|
||||
if (parsed) this->values.insert_or_assign(parsed->GetKey(), std::move(parsed));
|
||||
}
|
||||
}
|
||||
|
||||
LwoNameValue() = default;
|
||||
|
||||
LwoNameValue(const LwoNameValue& other) {
|
||||
|
||||
157
dCommon/LevelFile.cpp
Normal file
157
dCommon/LevelFile.cpp
Normal file
@@ -0,0 +1,157 @@
|
||||
#include "LevelFile.h"
|
||||
|
||||
#include <istream>
|
||||
#include <stdexcept>
|
||||
|
||||
#include "BinaryIO.h"
|
||||
#include "GeneralUtils.h"
|
||||
|
||||
void LevelFile::Read(std::istream& file) {
|
||||
const uint32_t CHNK_HEADER = ('C' + ('H' << 8) + ('N' << 16) + ('K' << 24));
|
||||
|
||||
while (!file.eof()) {
|
||||
uint32_t initPos = uint32_t(file.tellg());
|
||||
uint32_t header = 0;
|
||||
BinaryIO::BinaryRead(file, header);
|
||||
if (header == CHNK_HEADER) { //Make sure we're reading a valid CHNK
|
||||
ChunkHeader header;
|
||||
BinaryIO::BinaryRead(file, header.id);
|
||||
BinaryIO::BinaryRead(file, header.chunkVersion);
|
||||
BinaryIO::BinaryRead(file, header.chunkType);
|
||||
BinaryIO::BinaryRead(file, header.size);
|
||||
BinaryIO::BinaryRead(file, header.startPosition);
|
||||
|
||||
uint32_t target = initPos + header.size;
|
||||
file.seekg(header.startPosition);
|
||||
|
||||
//We're currently not loading env or particle data
|
||||
if (header.id == ChunkTypeID::FileInfo) {
|
||||
ReadFileInfoChunk(file, header);
|
||||
} else if (header.id == ChunkTypeID::SceneObjectData) {
|
||||
// The objects are laid out by the file's version, from its file info chunk
|
||||
const auto fileInfo = chunkHeaders.find(ChunkTypeID::FileInfo);
|
||||
ReadSceneObjectDataChunk(file, fileInfo == chunkHeaders.end() ? 0 : fileInfo->second.fileInfo.version);
|
||||
}
|
||||
|
||||
chunkHeaders.insert(std::make_pair(header.id, header));
|
||||
file.seekg(target);
|
||||
} else {
|
||||
if (initPos == std::streamoff(0)) { //Really old chunk version
|
||||
file.seekg(0);
|
||||
ChunkHeader header;
|
||||
header.id = ChunkTypeID::FileInfo;
|
||||
BinaryIO::BinaryRead(file, header.chunkVersion);
|
||||
BinaryIO::BinaryRead(file, header.chunkType);
|
||||
uint8_t important = 0;
|
||||
BinaryIO::BinaryRead(file, important);
|
||||
// file.ignore(1); //probably used
|
||||
if (header.chunkVersion > 36) {
|
||||
BinaryIO::BinaryRead(file, header.fileInfo.revision);
|
||||
}
|
||||
// HARDCODED 3
|
||||
if (header.chunkVersion >= 45) file.ignore(4);
|
||||
file.ignore(4 * (4 * 3));
|
||||
|
||||
if (header.chunkVersion >= 31) {
|
||||
if (header.chunkVersion >= 39) {
|
||||
file.ignore(12 * 4);
|
||||
|
||||
if (header.chunkVersion >= 40) {
|
||||
uint32_t s = 0;
|
||||
BinaryIO::BinaryRead(file, s);
|
||||
for (uint32_t i = 0; i < s; ++i) {
|
||||
file.ignore(4 * 3); //a uint and two floats
|
||||
}
|
||||
}
|
||||
} else {
|
||||
file.ignore(8);
|
||||
}
|
||||
|
||||
file.ignore(3 * 4);
|
||||
}
|
||||
|
||||
if (header.chunkVersion >= 36) {
|
||||
file.ignore(3 * 4);
|
||||
}
|
||||
|
||||
if (header.chunkVersion < 42) {
|
||||
file.ignore(3 * 4);
|
||||
|
||||
if (header.chunkVersion >= 33) {
|
||||
file.ignore(4 * 4);
|
||||
}
|
||||
}
|
||||
|
||||
// skydome info
|
||||
uint32_t count = 0;
|
||||
BinaryIO::BinaryRead(file, count);
|
||||
file.ignore(count);
|
||||
|
||||
if (header.chunkVersion >= 33) {
|
||||
for (uint32_t i = 0; i < 5; ++i) {
|
||||
uint32_t count = 0;
|
||||
BinaryIO::BinaryRead(file, count);
|
||||
file.ignore(count);
|
||||
}
|
||||
}
|
||||
// editor settings
|
||||
if (!important && header.chunkVersion >= 37){
|
||||
file.ignore(4);
|
||||
|
||||
uint32_t count = 0;
|
||||
BinaryIO::BinaryRead(file, count);
|
||||
file.ignore(count * 12);
|
||||
|
||||
}
|
||||
|
||||
header.id = ChunkTypeID::SceneObjectData;
|
||||
header.fileInfo.version = header.chunkVersion;
|
||||
ReadSceneObjectDataChunk(file, header.fileInfo.version);
|
||||
chunkHeaders.insert(std::make_pair(header.id, header));
|
||||
} break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void LevelFile::ReadFileInfoChunk(std::istream& file, ChunkHeader& header) {
|
||||
BinaryIO::BinaryRead(file, header.fileInfo.version);
|
||||
BinaryIO::BinaryRead(file, header.fileInfo.revision);
|
||||
BinaryIO::BinaryRead(file, header.fileInfo.enviromentChunkStart);
|
||||
BinaryIO::BinaryRead(file, header.fileInfo.objectChunkStart);
|
||||
BinaryIO::BinaryRead(file, header.fileInfo.particleChunkStart);
|
||||
}
|
||||
|
||||
void LevelFile::ReadSceneObjectDataChunk(std::istream& file, uint32_t version) {
|
||||
uint32_t objectsCount = 0;
|
||||
BinaryIO::BinaryRead(file, objectsCount);
|
||||
|
||||
for (uint32_t i = 0; i < objectsCount; ++i) {
|
||||
std::u16string ldfString;
|
||||
SceneObject obj;
|
||||
BinaryIO::BinaryRead(file, obj.id);
|
||||
BinaryIO::BinaryRead(file, obj.lot);
|
||||
|
||||
if (version >= 38) {
|
||||
int32_t tmp = 1;
|
||||
BinaryIO::BinaryRead(file, tmp);
|
||||
if (tmp > -1 && tmp < 11) obj.nodeType = tmp;
|
||||
}
|
||||
|
||||
if (version >= 32) {
|
||||
BinaryIO::BinaryRead(file, obj.glomId);
|
||||
}
|
||||
|
||||
BinaryIO::BinaryRead(file, obj.position);
|
||||
BinaryIO::BinaryRead(file, obj.rotation);
|
||||
BinaryIO::BinaryRead(file, obj.scale);
|
||||
BinaryIO::ReadString<uint32_t>(file, ldfString);
|
||||
BinaryIO::BinaryRead(file, obj.value3);
|
||||
if (file.fail()) throw std::runtime_error("Failed to read from istream.");
|
||||
|
||||
for (const auto& token : GeneralUtils::SplitString(GeneralUtils::UTF16ToWTF8(ldfString), '\n')) {
|
||||
obj.settings.ParseInsert(token);
|
||||
}
|
||||
|
||||
objects.push_back(std::move(obj));
|
||||
}
|
||||
}
|
||||
67
dCommon/LevelFile.h
Normal file
67
dCommon/LevelFile.h
Normal file
@@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <iosfwd>
|
||||
#include <map>
|
||||
#include <vector>
|
||||
|
||||
#include "dCommonVars.h"
|
||||
#include "NiPoint3.h"
|
||||
#include "NiQuaternion.h"
|
||||
#include "LDFFormat.h"
|
||||
|
||||
struct SceneObject {
|
||||
LWOOBJID id;
|
||||
LOT lot;
|
||||
uint32_t nodeType;
|
||||
uint32_t glomId;
|
||||
NiPoint3 position;
|
||||
NiQuaternion rotation = QuatUtils::IDENTITY;
|
||||
float scale = 1.0f;
|
||||
uint32_t value3;
|
||||
LwoNameValue settings;
|
||||
};
|
||||
|
||||
/**
|
||||
* A scene file (.lvl) as the world server loads it (Level): its chunk headers and every object in it, with the object's
|
||||
* settings parsed. Only reads; which objects the world spawns (feature gating, client-only objects, spawners) stays in
|
||||
* dZoneManager, so the dashboard reads scenes with the same code.
|
||||
*/
|
||||
struct LevelFile {
|
||||
enum ChunkTypeID : uint16_t {
|
||||
FileInfo = 1000,
|
||||
SceneEnviroment = 2000,
|
||||
SceneObjectData,
|
||||
SceneParticleData
|
||||
};
|
||||
|
||||
struct FileInfoChunk {
|
||||
uint32_t version{};
|
||||
uint32_t revision{};
|
||||
uint32_t enviromentChunkStart{};
|
||||
uint32_t objectChunkStart{};
|
||||
uint32_t particleChunkStart{};
|
||||
};
|
||||
|
||||
struct ChunkHeader {
|
||||
uint32_t id{};
|
||||
uint16_t chunkVersion{};
|
||||
ChunkTypeID chunkType{};
|
||||
uint32_t size{};
|
||||
uint32_t startPosition{};
|
||||
FileInfoChunk fileInfo;
|
||||
};
|
||||
|
||||
std::map<uint32_t, ChunkHeader> chunkHeaders;
|
||||
std::vector<SceneObject> objects;
|
||||
|
||||
/**
|
||||
* Reads the whole file. Throws std::runtime_error (BinaryIO) if it ends early; what was read before that stays
|
||||
* (an object is only added once all of it was read).
|
||||
*/
|
||||
void Read(std::istream& file);
|
||||
|
||||
private:
|
||||
void ReadFileInfoChunk(std::istream& file, ChunkHeader& header);
|
||||
void ReadSceneObjectDataChunk(std::istream& file, uint32_t version);
|
||||
};
|
||||
258
dCommon/LiveOpsRules.h
Normal file
258
dCommon/LiveOpsRules.h
Normal file
@@ -0,0 +1,258 @@
|
||||
#ifndef __LIVEOPSRULES__H__
|
||||
#define __LIVEOPSRULES__H__
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <limits>
|
||||
#include <optional>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include "GeneralUtils.h"
|
||||
#include "NiPoint3.h"
|
||||
|
||||
/**
|
||||
* The rules of live events and community challenges, kept free of the game and the database so both the world
|
||||
* servers and the dashboard use the same ones and they can be unit tested: where to put things, which bonus applies
|
||||
* when, how far a challenge is and who gets its rewards.
|
||||
*/
|
||||
namespace LiveOpsRules {
|
||||
// ---------------- Live events ----------------
|
||||
|
||||
enum class eEventType : uint8_t { TREASURE_HUNT, BONUS, INVASION, CELEBRATION };
|
||||
|
||||
constexpr std::string_view TypeName(eEventType type) {
|
||||
switch (type) {
|
||||
case eEventType::TREASURE_HUNT: return "treasure_hunt";
|
||||
case eEventType::BONUS: return "bonus";
|
||||
case eEventType::INVASION: return "invasion";
|
||||
case eEventType::CELEBRATION: return "celebration";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
inline std::optional<eEventType> ParseType(std::string_view name) {
|
||||
for (const auto type : { eEventType::TREASURE_HUNT, eEventType::BONUS, eEventType::INVASION, eEventType::CELEBRATION }) {
|
||||
if (TypeName(type) == name) return type;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// Events that put objects in the world need a zone to put them in
|
||||
constexpr bool NeedsZone(eEventType type) { return type != eEventType::BONUS; }
|
||||
|
||||
constexpr int64_t MIN_DURATION = 60; // seconds
|
||||
constexpr int64_t MAX_DURATION = 7 * 24 * 60 * 60;
|
||||
constexpr uint32_t MAX_TREASURES = 50; // per instance
|
||||
constexpr uint32_t MAX_WAVES = 20;
|
||||
constexpr uint32_t MAX_PER_WAVE = 30;
|
||||
constexpr uint32_t MAX_INVADERS_ALIVE = 60; // per instance, whatever the waves say
|
||||
constexpr float MAX_MULTIPLIER = 10.0f;
|
||||
|
||||
/**
|
||||
* Where to put things: `count` of the candidate positions (known to be on walkable ground), in random order, at
|
||||
* least `minSpacing` apart, and when `center` is set only those within `radius` of it. With `reuse`, candidates are
|
||||
* used again (cycling) when there are fewer than asked for, e.g. for waves of enemies around one point; without it
|
||||
* fewer come back. Spacing is relaxed rather than returning nothing when the candidates are close together.
|
||||
*/
|
||||
struct Placement {
|
||||
size_t count{};
|
||||
float minSpacing{};
|
||||
std::optional<NiPoint3> center;
|
||||
float radius{};
|
||||
bool reuse{};
|
||||
};
|
||||
|
||||
template<typename Rng>
|
||||
std::vector<NiPoint3> ChoosePositions(std::vector<NiPoint3> candidates, const Placement& placement, Rng& rng) {
|
||||
std::vector<NiPoint3> chosen;
|
||||
if (placement.count == 0) return chosen;
|
||||
if (placement.center) {
|
||||
const auto center = *placement.center;
|
||||
const auto radiusSq = placement.radius * placement.radius;
|
||||
std::erase_if(candidates, [&](const NiPoint3& point) { return NiPoint3::DistanceSquared(point, center) > radiusSq; });
|
||||
}
|
||||
if (candidates.empty()) return chosen;
|
||||
std::shuffle(candidates.begin(), candidates.end(), rng);
|
||||
|
||||
const auto spacedPick = [&](float spacing) {
|
||||
const auto spacingSq = spacing * spacing;
|
||||
for (const auto& point : candidates) {
|
||||
if (chosen.size() >= placement.count) break;
|
||||
const bool farEnough = std::ranges::none_of(chosen, [&](const NiPoint3& other) { return NiPoint3::DistanceSquared(point, other) < spacingSq; });
|
||||
if (farEnough) chosen.push_back(point);
|
||||
}
|
||||
};
|
||||
spacedPick(placement.minSpacing);
|
||||
// Too close together for the spacing asked: halve it until enough fit (or no spacing at all)
|
||||
for (auto spacing = placement.minSpacing / 2.0f; chosen.size() < std::min(placement.count, candidates.size()) && spacing > 0.5f; spacing /= 2.0f) {
|
||||
chosen.clear();
|
||||
spacedPick(spacing);
|
||||
}
|
||||
if (chosen.size() < std::min(placement.count, candidates.size())) {
|
||||
chosen.assign(candidates.begin(), candidates.begin() + std::min(placement.count, candidates.size()));
|
||||
}
|
||||
for (size_t i = 0; placement.reuse && chosen.size() < placement.count; i++) chosen.push_back(chosen[i % candidates.size()]);
|
||||
return chosen;
|
||||
}
|
||||
|
||||
// What a bonus event multiplies. 1 is no change.
|
||||
struct Multipliers {
|
||||
float coins{ 1.0f };
|
||||
float uscore{ 1.0f };
|
||||
float lootChance{ 1.0f };
|
||||
|
||||
bool Any() const { return coins != 1.0f || uscore != 1.0f || lootChance != 1.0f; }
|
||||
bool operator==(const Multipliers&) const = default;
|
||||
};
|
||||
|
||||
constexpr float ClampMultiplier(float value) {
|
||||
if (!(value >= 1.0f)) return 1.0f; // also NaN
|
||||
return std::min(value, MAX_MULTIPLIER);
|
||||
}
|
||||
|
||||
struct BonusWindow {
|
||||
int64_t startsAt{};
|
||||
int64_t endsAt{};
|
||||
Multipliers multipliers;
|
||||
};
|
||||
|
||||
constexpr bool InWindow(int64_t startsAt, int64_t endsAt, int64_t now) { return now >= startsAt && now < endsAt; }
|
||||
|
||||
// The bonus right now: each multiplier is the largest of the running windows (they don't stack)
|
||||
inline Multipliers ActiveMultipliers(const std::vector<BonusWindow>& windows, int64_t now) {
|
||||
Multipliers active;
|
||||
for (const auto& window : windows) {
|
||||
if (!InWindow(window.startsAt, window.endsAt, now)) continue;
|
||||
active.coins = std::max(active.coins, ClampMultiplier(window.multipliers.coins));
|
||||
active.uscore = std::max(active.uscore, ClampMultiplier(window.multipliers.uscore));
|
||||
active.lootChance = std::max(active.lootChance, ClampMultiplier(window.multipliers.lootChance));
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
// An amount times a multiplier, rounded, without overflowing
|
||||
template<typename T>
|
||||
T Scale(T amount, float multiplier) {
|
||||
if (multiplier == 1.0f || amount <= 0) return amount;
|
||||
const auto scaled = std::round(static_cast<double>(amount) * multiplier);
|
||||
if (scaled >= static_cast<double>(std::numeric_limits<T>::max())) return std::numeric_limits<T>::max();
|
||||
return static_cast<T>(scaled);
|
||||
}
|
||||
|
||||
// A drop chance (0-1) times a multiplier, never above certain
|
||||
constexpr float ScaleChance(float chance, float multiplier) {
|
||||
return multiplier == 1.0f ? chance : std::min(1.0f, chance * multiplier);
|
||||
}
|
||||
|
||||
// How many invasion waves should have been sent by now: the first at the start, then one every interval
|
||||
constexpr uint32_t WavesDue(int64_t startedAt, int64_t interval, uint32_t waves, int64_t now) {
|
||||
if (now < startedAt || waves == 0) return 0;
|
||||
if (interval <= 0) return waves;
|
||||
const auto due = static_cast<uint64_t>((now - startedAt) / interval) + 1;
|
||||
return static_cast<uint32_t>(std::min<uint64_t>(due, waves));
|
||||
}
|
||||
|
||||
// ---------------- Challenges ----------------
|
||||
|
||||
enum class ePhase : uint8_t { SCHEDULED, ACTIVE, COMPLETED, EXPIRED, CANCELLED };
|
||||
|
||||
constexpr std::string_view PhaseName(ePhase phase) {
|
||||
switch (phase) {
|
||||
case ePhase::SCHEDULED: return "scheduled";
|
||||
case ePhase::ACTIVE: return "active";
|
||||
case ePhase::COMPLETED: return "completed";
|
||||
case ePhase::EXPIRED: return "expired";
|
||||
case ePhase::CANCELLED: return "cancelled";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// state: the stored ILiveOps::eChallengeState (0 open, 1 completed, 2 expired, 3 cancelled)
|
||||
constexpr ePhase Phase(uint8_t state, int64_t startsAt, int64_t endsAt, int64_t now) {
|
||||
if (state == 1) return ePhase::COMPLETED;
|
||||
if (state == 2) return ePhase::EXPIRED;
|
||||
if (state == 3) return ePhase::CANCELLED;
|
||||
if (now < startsAt) return ePhase::SCHEDULED;
|
||||
if (now >= endsAt) return ePhase::EXPIRED;
|
||||
return ePhase::ACTIVE;
|
||||
}
|
||||
|
||||
// Whole percent reached, 0-100; 100 only once the target is reached
|
||||
constexpr uint8_t Percent(int64_t total, int64_t target) {
|
||||
if (target <= 0) return 100;
|
||||
if (total >= target) return 100;
|
||||
if (total <= 0) return 0;
|
||||
return static_cast<uint8_t>(std::min<int64_t>(99, total * 100 / target));
|
||||
}
|
||||
|
||||
inline const std::vector<uint8_t>& DefaultMilestones() {
|
||||
static const std::vector<uint8_t> milestones{ 25, 50, 75, 100 };
|
||||
return milestones;
|
||||
}
|
||||
|
||||
// "25,50,75,100" -> sorted, without repeats, each 1-100; anything unreadable is skipped. Empty: the defaults.
|
||||
inline std::vector<uint8_t> ParseMilestones(std::string_view text) {
|
||||
std::vector<uint8_t> milestones;
|
||||
for (const auto& part : GeneralUtils::SplitString(std::string(text), ',')) {
|
||||
auto trimmed = part;
|
||||
trimmed.erase(0, trimmed.find_first_not_of(" \t"));
|
||||
trimmed.erase(trimmed.find_last_not_of(" \t") + 1);
|
||||
const auto value = GeneralUtils::TryParse<int32_t>(trimmed);
|
||||
if (value && *value >= 1 && *value <= 100) milestones.push_back(static_cast<uint8_t>(*value));
|
||||
}
|
||||
std::ranges::sort(milestones);
|
||||
milestones.erase(std::unique(milestones.begin(), milestones.end()), milestones.end());
|
||||
return milestones.empty() ? DefaultMilestones() : milestones;
|
||||
}
|
||||
|
||||
// The highest milestone reached that is above the last one announced, if any (several passed at once: one message)
|
||||
inline std::optional<uint8_t> NextMilestone(const std::vector<uint8_t>& milestones, uint8_t lastAnnounced, int64_t total, int64_t target) {
|
||||
const auto percent = Percent(total, target);
|
||||
std::optional<uint8_t> next;
|
||||
for (const auto milestone : milestones) {
|
||||
if (milestone > lastAnnounced && milestone <= percent) next = milestone;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
// Whether a character's contribution earns the rewards: at least `minimum`, and always more than nothing
|
||||
constexpr bool Eligible(int64_t contribution, int64_t minimum) {
|
||||
return contribution > 0 && contribution >= std::max<int64_t>(1, minimum);
|
||||
}
|
||||
|
||||
// Who gets a completed challenge's rewards: characters that contributed enough and weren't rewarded yet (a restart in
|
||||
// the middle of handing them out carries on without giving anyone twice), highest contribution first
|
||||
template<typename Id>
|
||||
std::vector<std::pair<Id, int64_t>> RewardRecipients(std::vector<std::pair<Id, int64_t>> contributions, int64_t minimum, const std::vector<Id>& alreadyRewarded) {
|
||||
std::erase_if(contributions, [&](const auto& entry) {
|
||||
return !Eligible(entry.second, minimum) || std::ranges::find(alreadyRewarded, entry.first) != alreadyRewarded.end();
|
||||
});
|
||||
std::stable_sort(contributions.begin(), contributions.end(), [](const auto& a, const auto& b) { return a.second > b.second; });
|
||||
return contributions;
|
||||
}
|
||||
|
||||
// How long after a challenge ends its end is still worth announcing in game, when the announcement couldn't go out
|
||||
// at the time (the dashboard was restarting and not yet connected to the worlds)
|
||||
constexpr int64_t END_ANNOUNCEMENT_WINDOW = 60 * 60;
|
||||
|
||||
/**
|
||||
* Whether a challenge's end still has to be announced in game: it completed (state 1) or expired (state 2), it wasn't
|
||||
* announced yet, and it ended recently enough (`endedAt`, within END_ANNOUNCEMENT_WINDOW) that the news is still news.
|
||||
*/
|
||||
constexpr bool EndAnnouncementDue(uint8_t state, int64_t endAnnouncedAt, int64_t endedAt, int64_t now) {
|
||||
if (state != 1 && state != 2) return false;
|
||||
if (endAnnouncedAt != 0 || endedAt <= 0) return false;
|
||||
return now - endedAt <= END_ANNOUNCEMENT_WINDOW;
|
||||
}
|
||||
|
||||
// Whether something recorded in a zone counts for a challenge limited to `zones` (empty: every zone)
|
||||
inline bool CountsInZone(const std::vector<uint32_t>& zones, uint32_t zone) {
|
||||
return zones.empty() || std::ranges::find(zones, zone) != zones.end();
|
||||
}
|
||||
}
|
||||
|
||||
#endif //!__LIVEOPSRULES__H__
|
||||
62
dCommon/Locale.cpp
Normal file
62
dCommon/Locale.cpp
Normal file
@@ -0,0 +1,62 @@
|
||||
#include "Locale.h"
|
||||
#include "tinyxml2.h"
|
||||
#include "Logger.h"
|
||||
|
||||
namespace {
|
||||
std::unordered_map<std::string, std::string> g_Phrases;
|
||||
bool g_Loaded = false;
|
||||
const std::string g_Empty;
|
||||
}
|
||||
|
||||
void Locale::LoadFromFile(const std::string& filepath, const std::string& locale) {
|
||||
tinyxml2::XMLDocument doc;
|
||||
if (doc.LoadFile(filepath.c_str()) != tinyxml2::XML_SUCCESS) {
|
||||
LOG("Failed to load locale file: %s", filepath.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
auto* root = doc.FirstChildElement("localization");
|
||||
if (!root) return;
|
||||
|
||||
auto* phrases = root->FirstChildElement("phrases");
|
||||
if (!phrases) return;
|
||||
|
||||
for (auto* phrase = phrases->FirstChildElement("phrase"); phrase; phrase = phrase->NextSiblingElement("phrase")) {
|
||||
const char* id = phrase->Attribute("id");
|
||||
if (!id) continue;
|
||||
|
||||
for (auto* trans = phrase->FirstChildElement("translation"); trans; trans = trans->NextSiblingElement("translation")) {
|
||||
const char* loc = trans->Attribute("locale");
|
||||
if (loc && locale == loc) {
|
||||
const char* text = trans->GetText();
|
||||
if (text) {
|
||||
g_Phrases[id] = text;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
g_Loaded = true;
|
||||
LOG("Loaded %zu locale phrases from %s (locale: %s)", g_Phrases.size(), filepath.c_str(), locale.c_str());
|
||||
}
|
||||
|
||||
const std::string& Locale::GetPhrase(const std::string& phraseId) {
|
||||
auto it = g_Phrases.find(phraseId);
|
||||
if (it != g_Phrases.end()) return it->second;
|
||||
return g_Empty;
|
||||
}
|
||||
|
||||
bool Locale::IsLoaded() {
|
||||
return g_Loaded;
|
||||
}
|
||||
|
||||
std::vector<std::string> Locale::GetPhraseIdsWithPrefix(const std::string& prefix) {
|
||||
std::vector<std::string> result;
|
||||
for (const auto& [key, _] : g_Phrases) {
|
||||
if (key.compare(0, prefix.size(), prefix) == 0) {
|
||||
result.push_back(key);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
12
dCommon/Locale.h
Normal file
12
dCommon/Locale.h
Normal file
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace Locale {
|
||||
void LoadFromFile(const std::string& filepath, const std::string& locale = "en_US");
|
||||
const std::string& GetPhrase(const std::string& phraseId);
|
||||
bool IsLoaded();
|
||||
std::vector<std::string> GetPhraseIdsWithPrefix(const std::string& prefix);
|
||||
};
|
||||
34
dCommon/NiColor.h
Normal file
34
dCommon/NiColor.h
Normal file
@@ -0,0 +1,34 @@
|
||||
#ifndef NICOLOR_H
|
||||
#define NICOLOR_H
|
||||
|
||||
struct NiColor {
|
||||
float m_Red;
|
||||
float m_Green;
|
||||
float m_Blue;
|
||||
|
||||
constexpr NiColor(float red, float green, float blue) : m_Red(red), m_Green(green), m_Blue(blue) {}
|
||||
constexpr NiColor() : NiColor(0.0f, 0.0f, 0.0f) {}
|
||||
|
||||
/* reduce RGB files to grayscale, with or without alpha
|
||||
* using the equation given in Poynton's ColorFAQ at
|
||||
* <http://www.inforamp.net/~poynton/> // dead link
|
||||
* Copyright (c) 1998-01-04 Charles Poynton poynton at inforamp.net
|
||||
*
|
||||
* Y = 0.212671 * R + 0.715160 * G + 0.072169 * B
|
||||
*
|
||||
* We approximate this with
|
||||
*
|
||||
* Y = 0.21268 * R + 0.7151 * G + 0.07217 * B
|
||||
*
|
||||
* which can be expressed with integers as
|
||||
*
|
||||
* Y = (6969 * R + 23434 * G + 2365 * B)/32768
|
||||
*
|
||||
* The calculation is to be done in a linear colorspace.
|
||||
*
|
||||
* Other integer coefficents can be used via png_set_rgb_to_gray().
|
||||
*/
|
||||
float ToXYZ() const { return (m_Red * 0.212671f) + (m_Green * 0.71516f) + (m_Blue * 0.072169f); };
|
||||
};
|
||||
|
||||
#endif // NICOLOR_H
|
||||
168
dCommon/Permissions.cpp
Normal file
168
dCommon/Permissions.cpp
Normal file
@@ -0,0 +1,168 @@
|
||||
#include "Permissions.h"
|
||||
#include "AccountRules.h"
|
||||
|
||||
#include <map>
|
||||
|
||||
#include "Game.h"
|
||||
#include "dConfig.h"
|
||||
#include "GeneralUtils.h"
|
||||
|
||||
namespace {
|
||||
// Defaults match what each GM level could do before permissions were configurable
|
||||
const std::vector<Permissions::Permission> PERMISSIONS{
|
||||
// What every account can do by default; raise these to keep players (or low staff levels) out
|
||||
{ "own_characters", "Players", "Their own characters", "See their own characters: inventory, missions, progress, mail, pets, friends and name requests", 0, false, Permissions::PLAYER_LEVEL },
|
||||
{ "own_properties", "Players", "Their own properties", "See their own properties, the 3D view and model files", 0, false, Permissions::PLAYER_LEVEL },
|
||||
{ "own_history", "Players", "Their trade and mail history", "See the trades and mail their characters were part of", 0, false, Permissions::PLAYER_LEVEL },
|
||||
{ "leaderboards_view", "Players", "Leaderboards", "The Leaderboards page", 0, false, Permissions::PLAYER_LEVEL },
|
||||
{ "showcase_view", "Players", "Property showcase", "Browse other players' approved public properties and walk around them in 3D (setting showcase_public opens it to everyone)", 0, false, Permissions::PLAYER_LEVEL },
|
||||
{ "own_strikes", "Players", "Their strikes", "See the strikes on their own account and why they were given", 0, false, Permissions::PLAYER_LEVEL },
|
||||
{ "challenges_view", "Players", "Community challenges", "See the community challenges, how far along they are and what their own characters added", 0, false, Permissions::PLAYER_LEVEL },
|
||||
{ "api_access", "Players", "API access", "Make API tokens and use the dashboard's API with them. Each request still needs the permission for what it does", 0, false, Permissions::PLAYER_LEVEL },
|
||||
|
||||
{ "accounts_view", "Accounts", "View accounts", "The accounts list and other people's account pages", 1 },
|
||||
{ "accounts_notes", "Accounts", "Moderation history", "See and add notes and warnings on accounts (warnings can be sent to the player)", 2 },
|
||||
{ "accounts_kick", "Accounts", "Kick", "Disconnect an account's online sessions", 2 },
|
||||
{ "accounts_mute", "Accounts", "Mute", "Mute and unmute accounts", 2 },
|
||||
{ "accounts_ban", "Accounts", "Ban and lock", "Ban, unban, lock and unlock accounts", 4 },
|
||||
{ "accounts_links", "Accounts", "Linked accounts", "See other accounts that share a play key, email address or login address with an account", 3 },
|
||||
{ "accounts_send_reset", "Accounts", "Send password reset", "Email an account a password reset link", 4 },
|
||||
{ "accounts_manage", "Accounts", "Manage accounts", "Create accounts, change their email or password, reset their two-factor login", 8 },
|
||||
{ "accounts_gm_level", "Accounts", "Set GM levels", "Change GM levels (never to your own level or above, unless GM 9; their own only with self_moderation, and only lower)", 8 },
|
||||
{ "accounts_delete", "Accounts", "Delete accounts", "Permanently delete an account and its characters", 9 },
|
||||
// Who staff may use their tools on. Each needs the tool's own permission as well; GM 9 may always act on anyone,
|
||||
// and nobody below GM 9 can ever act on a higher GM level or raise their own
|
||||
{ "manage_equal_rank", "Accounts", "Act on their own rank", "Use their account and character tools on other accounts with the same GM level as their own (never on a higher one)", 9 },
|
||||
{ "self_tools", "Accounts", "Everyday tools on themselves", "Use tools they have on their own account and characters where nothing is gained: rescue or move their own characters, kick themselves, sign themselves out everywhere, email themselves a reset link", 1 },
|
||||
{ "self_items", "Accounts", "Give themselves items and progress", "Use tools they have on their own characters that give something: edit coins, U-score, level and items, restore versions, replace XML, change missions, give lost items back, and mail items to their own characters (also in mail to everyone)", 9 },
|
||||
{ "self_moderation", "Accounts", "Change their own record", "Use moderation and account tools they have on their own account: ban, mute, lock and lift them, give or revoke their own strikes, warnings, deleting history entries, restrictions on their own characters, lowering their own GM level, email, password or two-factor reset without the current one, deleting their own account", 9 },
|
||||
|
||||
{ "characters_view", "Characters", "View characters", "The characters list and other players' characters, including their XML", 1 },
|
||||
{ "characters_mail", "Characters", "Read mailboxes", "See other players' mail", 3 },
|
||||
{ "characters_rescue", "Characters", "Rescue", "Move a stuck character to a zone's spawn point", 3 },
|
||||
{ "characters_restrict", "Characters", "Restrict", "Block a character's trading, mail or chat", 3 },
|
||||
{ "characters_history", "Characters", "Character history", "See, compare and download earlier versions of characters", 3 },
|
||||
{ "characters_edit", "Characters", "Edit characters", "Change coins, U-score, level and items, and restore earlier versions", 8 },
|
||||
{ "characters_edit_xml", "Characters", "Replace character XML", "Upload new character XML (also needs enable_char_xml_upload=1)", 8 },
|
||||
{ "characters_missions", "Characters", "Change missions", "Complete (with or without rewards), reset or give a character a mission, e.g. for a stuck player", 5 },
|
||||
|
||||
{ "players_view", "Moderation", "See who is online", "The Online Players page and live player positions on the world map", 3 },
|
||||
{ "players_history", "Moderation", "Player movement history", "Replay where players went on the 3D world view (positions kept for position_history_days; every replay is audited)", 5 },
|
||||
{ "moderate_names", "Moderation", "Moderate names", "The moderation queue: approve and reject character names", 3 },
|
||||
{ "moderate_pet_names", "Moderation", "Moderate pet names", "Approve and reject pet names", 5 },
|
||||
{ "chat_view", "Moderation", "Read chat", "The chat log: what players said in zones, and what the chat filter stopped", 3 },
|
||||
{ "chat_private", "Moderation", "Read private chat", "Whispers and team chat in the chat log", 8 },
|
||||
{ "chat_filter_manage", "Moderation", "Chat filter", "See and change the words the chat filter allows and stops, and apply them in running worlds", 5 },
|
||||
{ "player_reports_view", "Moderation", "View player reports", "Read what players reported from the game's Report Abuse window", 2 },
|
||||
{ "player_reports_manage", "Moderation", "Handle player reports", "Act on or dismiss player reports (a strike also needs strikes_give)", 3 },
|
||||
{ "leaderboards_manage", "Moderation", "Moderate leaderboards", "Remove scores (e.g. cheated ones) and clear whole leaderboards", 5 },
|
||||
{ "moderate_properties", "Moderation", "Moderate properties", "Approve and reject public properties", 5 },
|
||||
{ "feature_properties", "Moderation", "Top properties", "Choose which approved public property each slot of the game's \"Today's Top Properties\" news panel shows", 5 },
|
||||
{ "strikes_give", "Moderation", "Give strikes", "Add a strike to an account when rejecting a name, pet name or property or removing a score, or by hand on the account", 3 },
|
||||
{ "strikes_revoke", "Moderation", "Revoke strikes", "Take back a strike; it stays on record but stops counting", 5 },
|
||||
{ "ai_suggest", "Moderation", "AI moderator helper", "Ask the AI helper (when switched on in Settings) to draft a suggestion for a report, chat message, name or economy flag. Only a draft for staff: nothing is applied or shown to players unless you do it", 3 },
|
||||
{ "properties_view", "Moderation", "View properties", "The properties list and other players' properties and models", 1 },
|
||||
{ "properties_import", "Moderation", "Import models", "Place LXFML models onto a property", 8 },
|
||||
{ "bug_reports_view", "Moderation", "View bug reports", "Read bug reports", 1 },
|
||||
{ "bug_reports_manage", "Moderation", "Handle bug reports", "Resolve and delete bug reports", 3 },
|
||||
|
||||
{ "mail_send", "Communication", "Send mail", "Send in-game mail to a player or everyone", 3 },
|
||||
{ "mail_items", "Communication", "Mail items", "Attach items to mail sent to one player (like /gmadditem; their own characters only with self_items)", 8 },
|
||||
{ "mail_broadcast_items", "Communication", "Mail items to everyone", "Attach items to mail sent to every character", 8 },
|
||||
{ "server_announce", "Communication", "Announcements", "Show an announcement to everyone online", 5 },
|
||||
{ "announcements_schedule", "Communication", "Scheduled announcements", "Set up, change and delete repeating in-game announcements, and add announcements to scheduled events", 5 },
|
||||
{ "worlds_manage", "Communication", "Shut down worlds", "Shut down one world instance (its players are disconnected)", 8 },
|
||||
{ "chat_send", "Communication", "Send chat into the game", "Post in players' chat from the dashboard or a chat bridge (e.g. a Discord bot); shown as [label] name", 8 },
|
||||
{ "server_restart", "Communication", "Scheduled restarts", "Schedule and cancel server restarts, also as part of scheduled events", 8 },
|
||||
|
||||
{ "logs_activity", "Logs", "Activity log", "Zone enter and exit history", 1 },
|
||||
{ "logs_command", "Logs", "Command log", "Slash commands players used", 1 },
|
||||
{ "logs_audit", "Logs", "Audit log", "What staff did on the dashboard", 8 },
|
||||
{ "logs_system", "Logs", "Server logs", "Server log files, log search and crash dumps", 8 },
|
||||
{ "health_view", "Logs", "Server health", "Player counts, running worlds, uptime and memory over time", 8 },
|
||||
{ "metrics_view", "Logs", "Prometheus metrics", "Read /metrics (and /api/metrics) with an API token, when metrics_enabled is on", 8 },
|
||||
|
||||
{ "reports_view", "Economy and map", "Economy reports", "Coins, U-score, items, trades and mail, item traces, duplicate scans, flags and the world map", 3 },
|
||||
{ "reports_review_flags", "Economy and map", "Review flags", "Mark economy flags dismissed or actioned", 3 },
|
||||
{ "items_restore", "Economy and map", "Give items back", "Mail a traced item back to a player, or what a character lost since one of its snapshots (with original IDs when they no longer exist)", 8 },
|
||||
{ "reports_run_checks", "Economy and map", "Run economy checks", "Run the anomaly checks by hand", 8 },
|
||||
|
||||
{ "play_keys_manage", "Server", "Play keys", "Create, edit and delete play keys, and see the key an account used", 8 },
|
||||
{ "client_files", "Server", "Client files", "Browse and download the game client's files", 8 },
|
||||
{ "vanity_manage", "Server", "Vanity NPCs", "Edit the vanity files, NPCs and plaque texts, add vanity changes to scheduled events, and respawn them in game", 8 },
|
||||
{ "tasks_view", "Server", "View scheduled tasks", "See scheduled tasks, their runs and logs", 8 },
|
||||
{ "tasks_manage", "Server", "Manage scheduled tasks", "Change schedules, switch tasks on and off, run them now", 9 },
|
||||
{ "live_events_manage", "Server", "Live events", "Start and end live events in game: treasure hunts, bonus coins/U-score/loot, invasions and celebrations; also as part of scheduled events", 8 },
|
||||
{ "challenges_manage", "Server", "Community challenges", "Create, change, cancel and delete server-wide challenges and their rewards (rewards are mailed to everyone who took part)", 8 },
|
||||
{ "events_manage", "Server", "Scheduled events: features", "Scheduled events that switch game features (event_1..event_8) on and off", 8 },
|
||||
{ "instances_manage", "Server", "Instance limits", "Change players per instance and spare instances per zone (the master server applies them)", 9 },
|
||||
{ "maintenance", "Server", "Data maintenance", "Repair tools on the Maintenance page", 9 },
|
||||
{ "backups", "Server", "Database backups", "Make, download and delete database backups (downloads also need your password)", 9 },
|
||||
{ "webhooks", "Server", "Webhooks", "Add and change outgoing alert webhooks", 9 },
|
||||
{ "email_settings", "Server", "Email settings", "Connect the mail account and send test emails", 9 },
|
||||
{ "settings", "Server", "Server settings", "Change any server setting on the Settings page (always GM 9)", 9, true },
|
||||
{ "permissions_manage", "Server", "Permissions", "Change what each GM level may do (always GM 9)", 9, true },
|
||||
|
||||
{ "dev_message_inspector", "Developer tools", "Game message inspector", "Capture the game messages an online player sends and receives, live (every capture is audited)", 8 },
|
||||
{ "dev_cdclient", "Developer tools", "CDClient browser", "Browse the game's CDClient data: objects and their components, loot, missions, skills, behaviors and activities", 8 },
|
||||
};
|
||||
|
||||
const std::map<std::string, const Permissions::Permission*>& Index() {
|
||||
static const auto index = [] {
|
||||
std::map<std::string, const Permissions::Permission*> map;
|
||||
for (const auto& permission : PERMISSIONS) map[permission.key] = &permission;
|
||||
return map;
|
||||
}();
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
namespace Permissions {
|
||||
const std::vector<Permission>& All() {
|
||||
return PERMISSIONS;
|
||||
}
|
||||
|
||||
const Permission* Find(const std::string& key) {
|
||||
const auto it = Index().find(key);
|
||||
return it == Index().end() ? nullptr : it->second;
|
||||
}
|
||||
|
||||
std::string ConfigName(const std::string& key) {
|
||||
return std::string(PREFIX) + key;
|
||||
}
|
||||
|
||||
uint8_t Resolve(const Permission& permission, const std::string& configValue) {
|
||||
if (permission.locked || configValue.empty()) return permission.defaultLevel;
|
||||
const auto parsed = GeneralUtils::TryParse<int32_t>(configValue);
|
||||
if (!parsed || *parsed < permission.minLevel || *parsed > MAX_LEVEL) return permission.defaultLevel;
|
||||
return static_cast<uint8_t>(*parsed);
|
||||
}
|
||||
|
||||
uint8_t Level(const std::string& key) {
|
||||
const auto* permission = Find(key);
|
||||
if (!permission) return MAX_LEVEL + 1;
|
||||
return Resolve(*permission, Game::config ? Game::config->GetValue(ConfigName(key)) : "");
|
||||
}
|
||||
|
||||
bool Allowed(uint8_t gmLevel, const std::string& key) {
|
||||
return gmLevel >= Level(key);
|
||||
}
|
||||
|
||||
bool CanViewCharacter(uint8_t gmLevel, uint32_t viewerAccountId, uint32_t ownerAccountId) {
|
||||
const bool own = viewerAccountId != 0 && viewerAccountId == ownerAccountId;
|
||||
return Allowed(gmLevel, "characters_view") || (own && Allowed(gmLevel, "own_characters"));
|
||||
}
|
||||
|
||||
nlohmann::json ForLevel(uint8_t gmLevel) {
|
||||
nlohmann::json can = nlohmann::json::object();
|
||||
for (const auto& permission : PERMISSIONS) can[permission.key] = Allowed(gmLevel, permission.key);
|
||||
return can;
|
||||
}
|
||||
}
|
||||
|
||||
namespace AccountRules {
|
||||
eManageDenial ManageDenialNow(uint8_t actorLevel, uint32_t actorAccountId, uint8_t targetLevel, uint32_t targetAccountId, eAccountAction action) {
|
||||
return ManageDenial(actorLevel, actorAccountId, targetLevel, targetAccountId,
|
||||
Permissions::Allowed(actorLevel, SelfPermission(action)), Permissions::Allowed(actorLevel, EQUAL_RANK_PERMISSION));
|
||||
}
|
||||
}
|
||||
61
dCommon/Permissions.h
Normal file
61
dCommon/Permissions.h
Normal file
@@ -0,0 +1,61 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include "json.hpp"
|
||||
|
||||
/**
|
||||
* What each GM level may do on the dashboard, and in the game for the slash commands paired with a permission. Every
|
||||
* permission has a default minimum GM level; server owners can change it without rebuilding, on the Permissions page
|
||||
* or with permission_<name>=<level> in dashboardconfig.ini (or the dashboard's PERMISSION_<NAME> environment
|
||||
* variable). A level set on the page beats the file. Levels are 1-9, and GM 9 can always do everything, so an operator
|
||||
* can never lock themselves out.
|
||||
* The dashboard owns these settings. The other servers (the worlds, for their slash commands) read the values the
|
||||
* dashboard stored in the database (ConfigSync), so every server sees the same levels and picks up changes when its
|
||||
* settings reload.
|
||||
*/
|
||||
namespace Permissions {
|
||||
constexpr uint8_t MIN_LEVEL = 1; // the lowest level a staff permission can be given to: players (GM 0) never get them
|
||||
constexpr uint8_t PLAYER_LEVEL = 0; // what players can do (their own characters, leaderboards, the API) can be set this low
|
||||
constexpr uint8_t MAX_LEVEL = 9;
|
||||
// The server that owns the permission_* settings, and the file its values are stored under
|
||||
constexpr std::string_view CONFIG_FILE = "dashboardconfig.ini";
|
||||
constexpr std::string_view PREFIX = "permission_";
|
||||
|
||||
struct Permission {
|
||||
std::string key; // lower_snake_case; the config setting is permission_<key>
|
||||
std::string category;
|
||||
std::string title;
|
||||
std::string description;
|
||||
uint8_t defaultLevel;
|
||||
bool locked{}; // always the default (managing permissions themselves)
|
||||
uint8_t minLevel{ MIN_LEVEL }; // the lowest level it can be set to: PLAYER_LEVEL for what players do
|
||||
};
|
||||
|
||||
const std::vector<Permission>& All();
|
||||
const Permission* Find(const std::string& key);
|
||||
|
||||
std::string ConfigName(const std::string& key);
|
||||
|
||||
inline bool IsPermissionSetting(std::string_view name) {
|
||||
return name.starts_with(PREFIX);
|
||||
}
|
||||
|
||||
// The minimum GM level for a permission right now; unknown permissions need more than GM 9 (nobody)
|
||||
uint8_t Level(const std::string& key);
|
||||
|
||||
bool Allowed(uint8_t gmLevel, const std::string& key);
|
||||
|
||||
// characters_view for anyone's character, or own_characters for the viewer's own (account 0 owns nothing)
|
||||
bool CanViewCharacter(uint8_t gmLevel, uint32_t viewerAccountId, uint32_t ownerAccountId);
|
||||
|
||||
// {key: bool} for every permission, for templates and scripts
|
||||
nlohmann::json ForLevel(uint8_t gmLevel);
|
||||
|
||||
// Pure: the level a config value gives a permission (bad or out-of-range values fall back to the default)
|
||||
uint8_t Resolve(const Permission& permission, const std::string& configValue);
|
||||
}
|
||||
73
dCommon/Process.cpp
Normal file
73
dCommon/Process.cpp
Normal file
@@ -0,0 +1,73 @@
|
||||
#include "Process.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <process.h>
|
||||
#else
|
||||
#include <fcntl.h>
|
||||
#include <spawn.h>
|
||||
#include <sys/wait.h>
|
||||
extern char** environ;
|
||||
#endif
|
||||
|
||||
namespace Process {
|
||||
bool HasShellMetacharacters(std::string_view text) {
|
||||
return std::ranges::any_of(text, [](char c) {
|
||||
switch (c) {
|
||||
case '"': case '\'': case '`': case '$': case ';': case '|': case '&': case '<': case '>':
|
||||
case '\n': case '\r': case '\0':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
int Run(const std::vector<std::string>& arguments, const std::string& stdoutFile, const std::string& stderrFile) {
|
||||
if (arguments.empty()) return -1;
|
||||
// _spawnvp takes an argument vector; cmd.exe is not involved. Output redirection is not supported here.
|
||||
std::vector<std::string> quoted;
|
||||
for (const auto& argument : arguments) {
|
||||
std::string out = "\"";
|
||||
for (const char c : argument) if (c != '"') out += c;
|
||||
quoted.push_back(out + "\"");
|
||||
}
|
||||
std::vector<const char*> argv;
|
||||
for (const auto& argument : quoted) argv.push_back(argument.c_str());
|
||||
argv.push_back(nullptr);
|
||||
const auto status = _spawnvp(_P_WAIT, arguments[0].c_str(), argv.data());
|
||||
return status < 0 ? -1 : static_cast<int>(status);
|
||||
}
|
||||
#else
|
||||
int Run(const std::vector<std::string>& arguments, const std::string& stdoutFile, const std::string& stderrFile) {
|
||||
if (arguments.empty()) return -1;
|
||||
std::vector<char*> argv;
|
||||
for (const auto& argument : arguments) argv.push_back(const_cast<char*>(argument.c_str()));
|
||||
argv.push_back(nullptr);
|
||||
|
||||
posix_spawn_file_actions_t actions;
|
||||
if (posix_spawn_file_actions_init(&actions) != 0) return -1;
|
||||
const auto redirect = [&actions](int fd, const std::string& file) {
|
||||
return posix_spawn_file_actions_addopen(&actions, fd, file.empty() ? "/dev/null" : file.c_str(),
|
||||
O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
||||
};
|
||||
pid_t pid = 0;
|
||||
int status = -1;
|
||||
if (posix_spawn_file_actions_addopen(&actions, 0, "/dev/null", O_RDONLY, 0) == 0 &&
|
||||
redirect(1, stdoutFile) == 0 && redirect(2, stderrFile) == 0 &&
|
||||
posix_spawnp(&pid, argv[0], &actions, nullptr, argv.data(), environ) == 0) {
|
||||
int waitStatus = 0;
|
||||
while (waitpid(pid, &waitStatus, 0) < 0) {
|
||||
if (errno != EINTR) { waitStatus = -1; break; }
|
||||
}
|
||||
// posix_spawnp can report a missing program as exit code 127 from the child
|
||||
status = waitStatus != -1 && WIFEXITED(waitStatus) ? WEXITSTATUS(waitStatus) : -1;
|
||||
}
|
||||
posix_spawn_file_actions_destroy(&actions);
|
||||
return status;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
23
dCommon/Process.h
Normal file
23
dCommon/Process.h
Normal file
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
// Run external programs without a shell: arguments are passed as a vector, so nothing in them is interpreted
|
||||
// ($(...), backticks, ;, quotes, redirections all stay literal).
|
||||
namespace Process {
|
||||
/**
|
||||
* Run a program and wait for it.
|
||||
* @param arguments The program (looked up on PATH when it has no slash) followed by its arguments
|
||||
* @param stdoutFile Where the program's standard output goes; empty discards it
|
||||
* @param stderrFile Where the program's error output goes; empty discards it
|
||||
* @return The program's exit code, or -1 when it could not be started or was killed by a signal
|
||||
*/
|
||||
int Run(const std::vector<std::string>& arguments, const std::string& stdoutFile = "", const std::string& stderrFile = "");
|
||||
|
||||
// Whether text contains characters a shell uses to run or chain commands (quotes, $, `, ;, |, &, <, >, newlines).
|
||||
// Parentheses, spaces and backslashes are allowed: they are common in real folder names.
|
||||
// Used to refuse suspicious paths even though Run never uses a shell.
|
||||
bool HasShellMetacharacters(std::string_view text);
|
||||
}
|
||||
84
dCommon/PropertyBehaviorActions.h
Normal file
84
dCommon/PropertyBehaviorActions.h
Normal file
@@ -0,0 +1,84 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <string_view>
|
||||
|
||||
#include "BehaviorStates.h"
|
||||
|
||||
/**
|
||||
* The property model behavior blocks the server plays (Strip::ProcNormalAction in dGame/dPropertyBehaviors/Strip.cpp),
|
||||
* by the Type the client saves for them in the behavior XML. The dashboard's behavior player serves the same tables to
|
||||
* the browser, so the two play the same blocks the same way. A block missing here is logged and skipped by both.
|
||||
*/
|
||||
namespace PropertyBehaviorActions {
|
||||
// Trigger blocks: a strip waits on its trigger until the model is interacted with, attacked, or hears the phrase
|
||||
constexpr std::string_view ON_INTERACT = "OnInteract";
|
||||
constexpr std::string_view ON_ATTACK = "OnAttack";
|
||||
constexpr std::string_view ON_CHAT = "OnChat";
|
||||
constexpr std::array TRIGGERS{ ON_INTERACT, ON_ATTACK, ON_CHAT };
|
||||
|
||||
// Moves the block's distance along a world axis at the model's speed
|
||||
struct Move {
|
||||
std::string_view type;
|
||||
char axis; // 'x', 'y' or 'z'
|
||||
float sign;
|
||||
};
|
||||
constexpr std::array MOVES{
|
||||
Move{ "MoveRight", 'x', 1.0f }, Move{ "MoveLeft", 'x', -1.0f },
|
||||
Move{ "FlyUp", 'y', 1.0f }, Move{ "FlyDown", 'y', -1.0f },
|
||||
Move{ "MoveForward", 'z', 1.0f }, Move{ "MoveBackward", 'z', -1.0f },
|
||||
};
|
||||
|
||||
// Blocks that make an object: spawns make one enemy at the model; drops give every player one powerup per unit of
|
||||
// the block's value
|
||||
struct LotBlock {
|
||||
std::string_view type;
|
||||
int32_t lot;
|
||||
};
|
||||
constexpr std::array SPAWNS{
|
||||
LotBlock{ "SpawnStromling", 10495 }, // Stromling property
|
||||
LotBlock{ "SpawnPirate", 10497 }, // Maelstrom Pirate property
|
||||
LotBlock{ "SpawnRonin", 10498 }, // Dark Ronin property
|
||||
};
|
||||
constexpr std::array DROPS{
|
||||
LotBlock{ "DropImagination", 935 }, // 1 Imagination powerup
|
||||
LotBlock{ "DropHealth", 177 }, // 1 Life powerup
|
||||
LotBlock{ "DropArmor", 6431 }, // 1 Armor powerup
|
||||
};
|
||||
|
||||
// Switches every strip of the behavior to another state's strips
|
||||
struct StateChange {
|
||||
std::string_view type;
|
||||
BehaviorState state;
|
||||
};
|
||||
constexpr std::array STATE_CHANGES{
|
||||
StateChange{ "ChangeStateHome", BehaviorState::HOME_STATE },
|
||||
StateChange{ "ChangeStateCircle", BehaviorState::CIRCLE_STATE },
|
||||
StateChange{ "ChangeStateSquare", BehaviorState::SQUARE_STATE },
|
||||
StateChange{ "ChangeStateDiamond", BehaviorState::DIAMOND_STATE },
|
||||
StateChange{ "ChangeStateTriangle", BehaviorState::TRIANGLE_STATE },
|
||||
StateChange{ "ChangeStateStar", BehaviorState::STAR_STATE },
|
||||
};
|
||||
|
||||
// The rest, each with its own handling
|
||||
constexpr std::string_view SET_SPEED = "SetSpeed";
|
||||
constexpr std::string_view SMASH = "Smash";
|
||||
constexpr std::string_view UNSMASH = "UnSmash";
|
||||
constexpr std::string_view WAIT = "Wait";
|
||||
constexpr std::string_view CHAT = "Chat";
|
||||
constexpr std::string_view PRIVATE_MESSAGE = "PrivateMessage";
|
||||
constexpr std::string_view PLAY_SOUND = "PlaySound";
|
||||
constexpr std::string_view RESTART = "Restart";
|
||||
constexpr std::array OTHERS{ SET_SPEED, SMASH, UNSMASH, WAIT, CHAT, PRIVATE_MESSAGE, PLAY_SOUND, RESTART };
|
||||
|
||||
// The speed models move at until a Set Speed block (ModelComponent)
|
||||
constexpr float DEFAULT_SPEED = 3.0f;
|
||||
|
||||
// The table entry for a block type, or nullptr
|
||||
template<typename Table>
|
||||
constexpr const typename Table::value_type* Find(const Table& table, std::string_view type) {
|
||||
for (const auto& entry : table) if (entry.type == type) return &entry;
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
501
dCommon/Raw.cpp
Normal file
501
dCommon/Raw.cpp
Normal file
@@ -0,0 +1,501 @@
|
||||
#include "Raw.h"
|
||||
#include "BinaryIO.h"
|
||||
#include "Logger.h"
|
||||
#include "SceneColor.h"
|
||||
#include <fstream>
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
|
||||
namespace {
|
||||
constexpr uint32_t kMaxResolution = 4096;
|
||||
constexpr size_t kMaxBlobBytes = 64ULL * 1024 * 1024; // 64 MiB
|
||||
constexpr uint32_t kMaxChunks = 1024;
|
||||
} // namespace
|
||||
|
||||
namespace Raw {
|
||||
|
||||
bool Chunk::IsValidForSceneLookup() const {
|
||||
return !sceneMap.empty() && colorMapResolution > 0 && !heightMap.empty()
|
||||
&& scaleFactor > 0.0f && width > 1 && height > 1;
|
||||
}
|
||||
|
||||
uint8_t Chunk::GetSceneIDAtGrid(uint32_t i, uint32_t j) const {
|
||||
const float sceneMapI = (static_cast<float>(i) / static_cast<float>(width - 1)) * static_cast<float>(colorMapResolution - 1);
|
||||
const float sceneMapJ = (static_cast<float>(j) / static_cast<float>(height - 1)) * static_cast<float>(colorMapResolution - 1);
|
||||
const uint32_t sceneI = std::min(static_cast<uint32_t>(sceneMapI), colorMapResolution - 1);
|
||||
const uint32_t sceneJ = std::min(static_cast<uint32_t>(sceneMapJ), colorMapResolution - 1);
|
||||
const uint32_t sceneIndex = sceneI * colorMapResolution + sceneJ;
|
||||
if (sceneIndex >= sceneMap.size()) return 0;
|
||||
return sceneMap[sceneIndex];
|
||||
}
|
||||
|
||||
NiPoint3 Chunk::GridToWorldPos(uint32_t i, uint32_t j) const {
|
||||
const float y = (i * width + j < heightMap.size()) ? heightMap[i * width + j] : 0.0f;
|
||||
return NiPoint3(
|
||||
(static_cast<float>(i) + (offsetX / scaleFactor)) * scaleFactor,
|
||||
y,
|
||||
(static_cast<float>(j) + (offsetZ / scaleFactor)) * scaleFactor
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Read flair attributes from stream
|
||||
*/
|
||||
static bool ReadFlairAttributes(std::istream& stream, FlairAttributes& flair) {
|
||||
try {
|
||||
BinaryIO::BinaryRead(stream, flair.id);
|
||||
BinaryIO::BinaryRead(stream, flair.scaleFactor);
|
||||
BinaryIO::BinaryRead(stream, flair.position.x);
|
||||
BinaryIO::BinaryRead(stream, flair.position.y);
|
||||
BinaryIO::BinaryRead(stream, flair.position.z);
|
||||
BinaryIO::BinaryRead(stream, flair.rotation.x);
|
||||
BinaryIO::BinaryRead(stream, flair.rotation.y);
|
||||
BinaryIO::BinaryRead(stream, flair.rotation.z);
|
||||
BinaryIO::BinaryRead(stream, flair.colorR);
|
||||
BinaryIO::BinaryRead(stream, flair.colorG);
|
||||
BinaryIO::BinaryRead(stream, flair.colorB);
|
||||
BinaryIO::BinaryRead(stream, flair.colorA);
|
||||
return true;
|
||||
} catch (const std::exception&) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Read mesh triangle data from stream
|
||||
*/
|
||||
static bool ReadMeshTri(std::istream& stream, MeshTri& meshTri) {
|
||||
try {
|
||||
BinaryIO::BinaryRead(stream, meshTri.meshTriListSize);
|
||||
meshTri.meshTriList.resize(meshTri.meshTriListSize);
|
||||
for (uint16_t i = 0; i < meshTri.meshTriListSize; ++i) {
|
||||
BinaryIO::BinaryRead(stream, meshTri.meshTriList[i]);
|
||||
}
|
||||
return true;
|
||||
} catch (const std::exception&) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Read a chunk from stream
|
||||
*/
|
||||
static bool ReadChunk(std::istream& stream, Chunk& chunk, uint16_t version) {
|
||||
try {
|
||||
// Read basic chunk info
|
||||
BinaryIO::BinaryRead(stream, chunk.id);
|
||||
if (stream.fail()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
BinaryIO::BinaryRead(stream, chunk.width);
|
||||
BinaryIO::BinaryRead(stream, chunk.height);
|
||||
BinaryIO::BinaryRead(stream, chunk.offsetX);
|
||||
BinaryIO::BinaryRead(stream, chunk.offsetZ);
|
||||
|
||||
if (stream.fail()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For version < 32, shader ID comes before texture IDs
|
||||
if (version < 32) {
|
||||
BinaryIO::BinaryRead(stream, chunk.shaderId);
|
||||
}
|
||||
|
||||
// Read texture IDs (4 textures)
|
||||
chunk.textureIds.resize(4);
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
BinaryIO::BinaryRead(stream, chunk.textureIds[i]);
|
||||
}
|
||||
|
||||
if (stream.fail()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read scale factor
|
||||
BinaryIO::BinaryRead(stream, chunk.scaleFactor);
|
||||
|
||||
if (stream.fail()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read heightmap
|
||||
const size_t width = static_cast<size_t>(chunk.width);
|
||||
const size_t height = static_cast<size_t>(chunk.height);
|
||||
|
||||
if (width == 0 || height == 0) {
|
||||
LOG("Chunk %u has invalid heightmap dimensions: width=%zu, height=%zu", chunk.id, width, height);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (width > kMaxResolution || height > kMaxResolution) {
|
||||
LOG("Chunk %u heightmap dimensions exceed maximum resolution %u: width=%zu, height=%zu", chunk.id, kMaxResolution, width, height);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (height != 0 && width > std::numeric_limits<size_t>::max() / height) {
|
||||
LOG("Chunk %u heightmap size multiplication overflows: width=%zu, height=%zu", chunk.id, width, height);
|
||||
return false;
|
||||
}
|
||||
|
||||
const size_t heightMapSize = width * height;
|
||||
const size_t elementSize = sizeof(chunk.heightMap[0]);
|
||||
|
||||
if (elementSize != 0 && heightMapSize > std::numeric_limits<size_t>::max() / elementSize) {
|
||||
LOG("Chunk %u heightmap byte size overflows: elements=%zu, elementSize=%zu", chunk.id, heightMapSize, elementSize);
|
||||
return false;
|
||||
}
|
||||
|
||||
const size_t totalBytes = heightMapSize * elementSize;
|
||||
if (totalBytes == 0 || totalBytes > kMaxBlobBytes) {
|
||||
LOG("Chunk %u heightmap total size invalid: bytes=%zu (max %zu)", chunk.id, totalBytes, kMaxBlobBytes);
|
||||
return false;
|
||||
}
|
||||
|
||||
chunk.heightMap.resize(heightMapSize);
|
||||
for (size_t i = 0; i < heightMapSize; ++i) {
|
||||
BinaryIO::BinaryRead(stream, chunk.heightMap[i]);
|
||||
}
|
||||
|
||||
if (stream.fail()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ColorMap
|
||||
if (version >= 32) {
|
||||
BinaryIO::BinaryRead(stream, chunk.colorMapResolution);
|
||||
} else {
|
||||
chunk.colorMapResolution = chunk.width - 1;
|
||||
}
|
||||
|
||||
if (chunk.colorMapResolution > kMaxResolution) {
|
||||
LOG("Chunk colorMapResolution %u exceeds maximum %u", chunk.colorMapResolution, kMaxResolution);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (version >= 32) {
|
||||
const size_t colorMapPixelCount = static_cast<size_t>(chunk.colorMapResolution) * chunk.colorMapResolution * 4;
|
||||
if (colorMapPixelCount > kMaxBlobBytes) {
|
||||
LOG("Chunk colorMap size %zu exceeds maximum %zu bytes", colorMapPixelCount, kMaxBlobBytes);
|
||||
return false;
|
||||
}
|
||||
chunk.colorMap.resize(colorMapPixelCount);
|
||||
stream.read(reinterpret_cast<char*>(chunk.colorMap.data()), static_cast<std::streamsize>(colorMapPixelCount));
|
||||
} else {
|
||||
const size_t legacyColorBytes = static_cast<size_t>(chunk.width) * chunk.width * 4;
|
||||
if (legacyColorBytes > kMaxBlobBytes) {
|
||||
LOG("Chunk legacy colorMap size %zu exceeds maximum %zu bytes", legacyColorBytes, kMaxBlobBytes);
|
||||
return false;
|
||||
}
|
||||
chunk.colorMap.resize(legacyColorBytes);
|
||||
stream.read(reinterpret_cast<char*>(chunk.colorMap.data()), static_cast<std::streamsize>(legacyColorBytes));
|
||||
}
|
||||
|
||||
if (stream.fail()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// LightMap/diffusemap.dds (v>=32 only)
|
||||
if (version >= 32) {
|
||||
uint32_t lightMapSize;
|
||||
BinaryIO::BinaryRead(stream, lightMapSize);
|
||||
|
||||
if (lightMapSize > kMaxBlobBytes) {
|
||||
LOG("Chunk lightMap size %u exceeds maximum %zu bytes", lightMapSize, kMaxBlobBytes);
|
||||
return false;
|
||||
}
|
||||
chunk.lightMap.resize(lightMapSize);
|
||||
stream.read(reinterpret_cast<char*>(chunk.lightMap.data()), static_cast<std::streamsize>(lightMapSize));
|
||||
|
||||
if (stream.fail()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Blend/texture map
|
||||
BinaryIO::BinaryRead(stream, chunk.textureMapResolution);
|
||||
|
||||
if (chunk.textureMapResolution > kMaxResolution) {
|
||||
LOG("Chunk textureMapResolution %u exceeds maximum %u", chunk.textureMapResolution, kMaxResolution);
|
||||
return false;
|
||||
}
|
||||
const size_t textureMapPixelCount = static_cast<size_t>(chunk.textureMapResolution) * chunk.textureMapResolution * 4;
|
||||
if (textureMapPixelCount > kMaxBlobBytes) {
|
||||
LOG("Chunk textureMap size %zu exceeds maximum %zu bytes", textureMapPixelCount, kMaxBlobBytes);
|
||||
return false;
|
||||
}
|
||||
chunk.textureMap.resize(textureMapPixelCount);
|
||||
stream.read(reinterpret_cast<char*>(chunk.textureMap.data()), static_cast<std::streamsize>(textureMapPixelCount));
|
||||
|
||||
if (stream.fail()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Texture settings + blend map DDS (v>=32 only)
|
||||
if (version >= 32) {
|
||||
BinaryIO::BinaryRead(stream, chunk.textureSettings);
|
||||
|
||||
uint32_t blendMapDDSSize;
|
||||
BinaryIO::BinaryRead(stream, blendMapDDSSize);
|
||||
|
||||
if (blendMapDDSSize > kMaxBlobBytes) {
|
||||
LOG("Chunk blendMap size %u exceeds maximum %zu bytes", blendMapDDSSize, kMaxBlobBytes);
|
||||
return false;
|
||||
}
|
||||
chunk.blendMap.resize(blendMapDDSSize);
|
||||
stream.read(reinterpret_cast<char*>(chunk.blendMap.data()), static_cast<std::streamsize>(blendMapDDSSize));
|
||||
|
||||
if (stream.fail()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Read flairs
|
||||
uint32_t numFlairs;
|
||||
BinaryIO::BinaryRead(stream, numFlairs);
|
||||
|
||||
if (stream.fail()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const size_t flairBytes = static_cast<size_t>(numFlairs) * sizeof(FlairAttributes);
|
||||
if (flairBytes > kMaxBlobBytes) {
|
||||
LOG("Chunk %u flair count %u exceeds maximum (byte size %zu > %zu)", chunk.id, numFlairs, flairBytes, kMaxBlobBytes);
|
||||
return false;
|
||||
}
|
||||
chunk.flairs.resize(numFlairs);
|
||||
for (uint32_t i = 0; i < numFlairs; ++i) {
|
||||
if (!ReadFlairAttributes(stream, chunk.flairs[i])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Scene map
|
||||
if (version >= 32) {
|
||||
const size_t sceneMapSize = static_cast<size_t>(chunk.colorMapResolution) * chunk.colorMapResolution;
|
||||
|
||||
if (sceneMapSize > kMaxBlobBytes) {
|
||||
LOG("Chunk sceneMap size %zu exceeds maximum %zu bytes", sceneMapSize, kMaxBlobBytes);
|
||||
return false;
|
||||
}
|
||||
chunk.sceneMap.resize(sceneMapSize);
|
||||
stream.read(reinterpret_cast<char*>(chunk.sceneMap.data()), static_cast<std::streamsize>(sceneMapSize));
|
||||
} else if (version == 31) {
|
||||
const size_t sceneMapCells = static_cast<size_t>(chunk.colorMapResolution + 1) * (chunk.colorMapResolution + 1);
|
||||
if (sceneMapCells > kMaxBlobBytes) {
|
||||
LOG("Chunk v31 sceneMap size %zu exceeds maximum %zu bytes", sceneMapCells, kMaxBlobBytes);
|
||||
return false;
|
||||
}
|
||||
std::vector<uint8_t> rawSceneMap(sceneMapCells);
|
||||
stream.read(reinterpret_cast<char*>(rawSceneMap.data()), static_cast<std::streamsize>(sceneMapCells));
|
||||
chunk.sceneMap.resize(static_cast<size_t>(chunk.colorMapResolution) * chunk.colorMapResolution);
|
||||
for (uint32_t row = 0; row < chunk.colorMapResolution; ++row) {
|
||||
for (uint32_t col = 0; col < chunk.colorMapResolution; ++col) {
|
||||
chunk.sceneMap[row * chunk.colorMapResolution + col] = rawSceneMap[row * (chunk.colorMapResolution + 1) + col];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stream.seekg(1, std::ios::cur);
|
||||
}
|
||||
|
||||
if (stream.fail()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Mesh data (v>=32 only)
|
||||
if (version < 32) {
|
||||
return true;
|
||||
}
|
||||
|
||||
BinaryIO::BinaryRead(stream, chunk.vertSize);
|
||||
|
||||
if (stream.fail()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (chunk.vertSize == 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const size_t vertBytes = static_cast<size_t>(chunk.vertSize) * sizeof(uint16_t);
|
||||
if (vertBytes > kMaxBlobBytes) {
|
||||
LOG("Chunk %u vertSize %u exceeds maximum (byte size %zu > %zu)", chunk.id, chunk.vertSize, vertBytes, kMaxBlobBytes);
|
||||
return false;
|
||||
}
|
||||
chunk.meshVertUsage.resize(chunk.vertSize);
|
||||
for (uint32_t i = 0; i < chunk.vertSize; ++i) {
|
||||
BinaryIO::BinaryRead(stream, chunk.meshVertUsage[i]);
|
||||
}
|
||||
|
||||
if (stream.fail()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
chunk.meshVertSize.resize(16);
|
||||
for (int i = 0; i < 16; ++i) {
|
||||
BinaryIO::BinaryRead(stream, chunk.meshVertSize[i]);
|
||||
}
|
||||
|
||||
if (stream.fail()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
chunk.meshTri.resize(16);
|
||||
for (int i = 0; i < 16; ++i) {
|
||||
if (!ReadMeshTri(stream, chunk.meshTri[i])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (const std::exception&) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool ReadRaw(std::istream& stream, Raw& outRaw) {
|
||||
// Get stream size
|
||||
stream.seekg(0, std::ios::end);
|
||||
auto streamSize = stream.tellg();
|
||||
stream.seekg(0, std::ios::beg);
|
||||
|
||||
if (streamSize <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Read header
|
||||
BinaryIO::BinaryRead(stream, outRaw.version);
|
||||
|
||||
if (stream.fail()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
BinaryIO::BinaryRead(stream, outRaw.dev);
|
||||
|
||||
if (stream.fail()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only read chunks if dev == 0
|
||||
if (outRaw.dev == 0) {
|
||||
BinaryIO::BinaryRead(stream, outRaw.numChunks);
|
||||
BinaryIO::BinaryRead(stream, outRaw.numChunksWidth);
|
||||
BinaryIO::BinaryRead(stream, outRaw.numChunksHeight);
|
||||
|
||||
if (outRaw.numChunks > kMaxChunks) {
|
||||
LOG("Raw numChunks %u exceeds maximum %u", outRaw.numChunks, kMaxChunks);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read all chunks
|
||||
outRaw.chunks.resize(outRaw.numChunks);
|
||||
for (uint32_t i = 0; i < outRaw.numChunks; ++i) {
|
||||
if (!ReadChunk(stream, outRaw.chunks[i], outRaw.version)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate terrain bounds from all chunks
|
||||
if (!outRaw.chunks.empty()) {
|
||||
outRaw.minBoundsX = std::numeric_limits<float>::max();
|
||||
outRaw.minBoundsZ = std::numeric_limits<float>::max();
|
||||
outRaw.maxBoundsX = std::numeric_limits<float>::lowest();
|
||||
outRaw.maxBoundsZ = std::numeric_limits<float>::lowest();
|
||||
|
||||
for (const auto& chunk : outRaw.chunks) {
|
||||
const float chunkMinX = chunk.offsetX;
|
||||
const float chunkMinZ = chunk.offsetZ;
|
||||
const float chunkMaxX = chunkMinX + (chunk.width * chunk.scaleFactor);
|
||||
const float chunkMaxZ = chunkMinZ + (chunk.height * chunk.scaleFactor);
|
||||
|
||||
outRaw.minBoundsX = std::min(outRaw.minBoundsX, chunkMinX);
|
||||
outRaw.minBoundsZ = std::min(outRaw.minBoundsZ, chunkMinZ);
|
||||
outRaw.maxBoundsX = std::max(outRaw.maxBoundsX, chunkMaxX);
|
||||
outRaw.maxBoundsZ = std::max(outRaw.maxBoundsZ, chunkMaxZ);
|
||||
}
|
||||
LOG_DEBUG("Raw terrain bounds: X[%.2f, %.2f], Z[%.2f, %.2f]",
|
||||
outRaw.minBoundsX, outRaw.maxBoundsX, outRaw.minBoundsZ, outRaw.maxBoundsZ);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (const std::exception&) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void GenerateTerrainMesh(const Raw& raw, TerrainMesh& outMesh) {
|
||||
outMesh.vertices.clear();
|
||||
outMesh.triangles.clear();
|
||||
|
||||
if (raw.chunks.empty() || raw.version < 32) {
|
||||
return; // No scene data available
|
||||
}
|
||||
|
||||
LOG_DEBUG("GenerateTerrainMesh: Processing %zu chunks", raw.chunks.size());
|
||||
|
||||
uint32_t vertexOffset = 0;
|
||||
|
||||
for (const auto& chunk : raw.chunks) {
|
||||
if (!chunk.IsValidForSceneLookup()) continue;
|
||||
|
||||
for (uint32_t i = 0; i < chunk.width; ++i) {
|
||||
for (uint32_t j = 0; j < chunk.height; ++j) {
|
||||
const uint32_t heightIndex = chunk.width * i + j;
|
||||
if (heightIndex >= chunk.heightMap.size()) continue;
|
||||
|
||||
outMesh.vertices.emplace_back(chunk.GridToWorldPos(i, j), chunk.GetSceneIDAtGrid(i, j));
|
||||
if (i > 0 && j > 0) {
|
||||
const uint32_t currentVert = vertexOffset + chunk.width * i + j;
|
||||
const uint32_t leftVert = currentVert - 1;
|
||||
const uint32_t bottomLeftVert = vertexOffset + chunk.width * (i - 1) + j - 1;
|
||||
const uint32_t bottomVert = vertexOffset + chunk.width * (i - 1) + j;
|
||||
|
||||
// First triangle
|
||||
outMesh.triangles.push_back(currentVert);
|
||||
outMesh.triangles.push_back(leftVert);
|
||||
outMesh.triangles.push_back(bottomLeftVert);
|
||||
|
||||
// Second triangle
|
||||
outMesh.triangles.push_back(bottomLeftVert);
|
||||
outMesh.triangles.push_back(bottomVert);
|
||||
outMesh.triangles.push_back(currentVert);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
vertexOffset += chunk.width * chunk.height;
|
||||
}
|
||||
}
|
||||
|
||||
bool WriteTerrainMeshToOBJ(const TerrainMesh& mesh, const std::string& path) {
|
||||
try {
|
||||
std::ofstream file(path);
|
||||
if (!file.is_open()) {
|
||||
LOG("Failed to open OBJ file for writing: %s", path.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const auto& v : mesh.vertices) {
|
||||
const NiColor& color = SceneColor::Get(v.sceneID);
|
||||
file << "v " << v.position.x << ' ' << v.position.y << ' ' << v.position.z
|
||||
<< ' ' << color.m_Red << ' ' << color.m_Green << ' ' << color.m_Blue << '\n';
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < mesh.triangles.size(); i += 3) {
|
||||
file << "f " << (mesh.triangles[i] + 1) << ' '
|
||||
<< (mesh.triangles[i + 1] + 1) << ' '
|
||||
<< (mesh.triangles[i + 2] + 1) << '\n';
|
||||
}
|
||||
|
||||
file.close();
|
||||
LOG("Successfully wrote terrain mesh to OBJ: %s (%zu vertices, %zu triangles)",
|
||||
path.c_str(), mesh.vertices.size(), mesh.triangles.size() / 3);
|
||||
return true;
|
||||
} catch (const std::exception& e) {
|
||||
LOG("Exception while writing OBJ file: %s", e.what());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace Raw
|
||||
168
dCommon/Raw.h
Normal file
168
dCommon/Raw.h
Normal file
@@ -0,0 +1,168 @@
|
||||
#pragma once
|
||||
|
||||
#ifndef __RAW_H__
|
||||
#define __RAW_H__
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
#include <string>
|
||||
#include <istream>
|
||||
#include "NiPoint3.h"
|
||||
#include "dCommonVars.h"
|
||||
|
||||
/**
|
||||
* A zone's terrain file (.raw), read whole: per chunk its heights, texture ids, color and texture blend maps, the
|
||||
* scene each terrain cell belongs to, the flairs (grass, flowers and small rocks strewn over it) and the mesh data
|
||||
* the client renders from. Guards against malformed files: sizes are capped before anything is allocated. Lives in
|
||||
* dCommon so the dashboard's 3D views read terrain with the same code.
|
||||
*/
|
||||
namespace Raw {
|
||||
|
||||
/**
|
||||
* @brief Flair attributes structure
|
||||
* Represents decorative elements on the terrain: id is a FlairTable row (its asset is the model), rotation is in
|
||||
* radians about x, y and z, and the color tints the model (the client's Flair.fx multiplies the lighting by it)
|
||||
*/
|
||||
struct FlairAttributes {
|
||||
uint32_t id;
|
||||
float scaleFactor;
|
||||
NiPoint3 position;
|
||||
NiPoint3 rotation;
|
||||
uint8_t colorR;
|
||||
uint8_t colorG;
|
||||
uint8_t colorB;
|
||||
uint8_t colorA;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Mesh triangle structure
|
||||
* Contains triangle indices for terrain mesh
|
||||
*/
|
||||
struct MeshTri {
|
||||
uint16_t meshTriListSize;
|
||||
std::vector<uint16_t> meshTriList;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Vertex with scene ID
|
||||
* Used for the generated terrain mesh to enable fast scene lookups
|
||||
*/
|
||||
struct SceneVertex {
|
||||
NiPoint3 position;
|
||||
uint8_t sceneID;
|
||||
|
||||
SceneVertex() : position(), sceneID(0) {}
|
||||
SceneVertex(const NiPoint3& pos, uint8_t scene) : position(pos), sceneID(scene) {}
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Generated terrain mesh
|
||||
* Contains vertices with scene IDs for fast scene lookups at arbitrary positions
|
||||
*/
|
||||
struct TerrainMesh {
|
||||
std::vector<SceneVertex> vertices;
|
||||
std::vector<uint32_t> triangles; // Indices into vertices array (groups of 3)
|
||||
|
||||
TerrainMesh() = default;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Terrain chunk structure
|
||||
* Represents a single chunk of terrain with heightmap, textures, and meshes
|
||||
*/
|
||||
struct Chunk {
|
||||
uint32_t id;
|
||||
uint32_t width;
|
||||
uint32_t height;
|
||||
float offsetX;
|
||||
float offsetZ;
|
||||
uint32_t shaderId;
|
||||
|
||||
// Texture IDs (4 textures per chunk)
|
||||
std::vector<uint32_t> textureIds;
|
||||
|
||||
// Terrain scale factor
|
||||
float scaleFactor;
|
||||
|
||||
// Heightmap data (width * height floats)
|
||||
std::vector<float> heightMap;
|
||||
|
||||
// Version 32+ fields
|
||||
uint32_t colorMapResolution = 0;
|
||||
std::vector<uint8_t> colorMap; // RGBA pixels (colorMap * colorMap * 4)
|
||||
std::vector<uint8_t> lightMap;
|
||||
|
||||
uint32_t textureMapResolution = 0;
|
||||
std::vector<uint8_t> textureMap; // (textureMapResolution * textureMapResolution * 4)
|
||||
uint8_t textureSettings = 0;
|
||||
std::vector<uint8_t> blendMap;
|
||||
|
||||
// Flair data
|
||||
std::vector<FlairAttributes> flairs;
|
||||
|
||||
// Scene map (version 32+)
|
||||
std::vector<uint8_t> sceneMap;
|
||||
|
||||
// Mesh data
|
||||
uint32_t vertSize = 0;
|
||||
std::vector<uint16_t> meshVertUsage;
|
||||
std::vector<uint16_t> meshVertSize;
|
||||
std::vector<MeshTri> meshTri;
|
||||
|
||||
bool IsValidForSceneLookup() const;
|
||||
uint8_t GetSceneIDAtGrid(uint32_t i, uint32_t j) const;
|
||||
NiPoint3 GridToWorldPos(uint32_t i, uint32_t j) const;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief RAW terrain file structure
|
||||
* Complete representation of a .raw terrain file
|
||||
*/
|
||||
struct Raw {
|
||||
uint16_t version;
|
||||
uint8_t dev;
|
||||
uint32_t numChunks = 0;
|
||||
uint32_t numChunksWidth = 0;
|
||||
uint32_t numChunksHeight = 0;
|
||||
std::vector<Chunk> chunks;
|
||||
|
||||
// Calculated bounds of the entire terrain
|
||||
float minBoundsX = 0.0f;
|
||||
float minBoundsZ = 0.0f;
|
||||
float maxBoundsX = 0.0f;
|
||||
float maxBoundsZ = 0.0f;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Read a RAW terrain file from an input stream
|
||||
*
|
||||
* @param stream Input stream containing RAW file data
|
||||
* @param outRaw Output RAW file structure
|
||||
* @return true if successfully read, false otherwise
|
||||
*/
|
||||
bool ReadRaw(std::istream& stream, Raw& outRaw);
|
||||
|
||||
/**
|
||||
* @brief Generate a terrain mesh from raw chunks
|
||||
* Similar to dTerrain's GenerateFinalMeshFromChunks but creates a mesh with scene IDs
|
||||
* per vertex for fast scene lookups at arbitrary positions.
|
||||
*
|
||||
* @param raw The RAW terrain data to generate mesh from
|
||||
* @param outMesh Output terrain mesh with vertices and scene IDs
|
||||
*/
|
||||
void GenerateTerrainMesh(const Raw& raw, TerrainMesh& outMesh);
|
||||
|
||||
/**
|
||||
* @brief Write terrain mesh to OBJ file for debugging/visualization
|
||||
* Merged from dTerrain's WriteFinalMeshToOBJ functionality
|
||||
* Vertices are colored based on their scene ID using a hash function
|
||||
*
|
||||
* @param mesh The terrain mesh to export
|
||||
* @param path Output path for the OBJ file
|
||||
* @return true if successfully written, false otherwise
|
||||
*/
|
||||
bool WriteTerrainMeshToOBJ(const TerrainMesh& mesh, const std::string& path);
|
||||
|
||||
} // namespace Raw
|
||||
|
||||
#endif // __RAW_H__
|
||||
489
dCommon/ScheduleRules.cpp
Normal file
489
dCommon/ScheduleRules.cpp
Normal file
@@ -0,0 +1,489 @@
|
||||
#include "ScheduleRules.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cctype>
|
||||
#include <limits>
|
||||
#include <tuple>
|
||||
|
||||
#include "magic_enum.hpp"
|
||||
#include "CivilDate.h"
|
||||
|
||||
namespace {
|
||||
using namespace ScheduleRules;
|
||||
constexpr int64_t DAY = 86400;
|
||||
constexpr int64_t NEVER = std::numeric_limits<int64_t>::max();
|
||||
// Julian day of the unix epoch, and the new moon Meeus counts lunations from (2000-01-06)
|
||||
constexpr double UNIX_EPOCH_JD = 2440587.5;
|
||||
constexpr double LUNATION_ZERO_JDE = 2451550.09766;
|
||||
// Meeus gives the phases in terrestrial time, about this many seconds ahead of UTC these years
|
||||
constexpr double DELTA_T_SECONDS = 69.0;
|
||||
|
||||
// "fri", "Friday" or 5
|
||||
std::optional<unsigned> WeekdayOf(const nlohmann::json& value) {
|
||||
static const char* NAMES[]{ "sun", "mon", "tue", "wed", "thu", "fri", "sat" };
|
||||
if (value.is_number_integer()) {
|
||||
const auto day = value.get<int64_t>();
|
||||
return day >= 0 && day <= 7 ? std::optional<unsigned>(static_cast<unsigned>(day % 7)) : std::nullopt;
|
||||
}
|
||||
if (!value.is_string()) return std::nullopt;
|
||||
auto text = value.get<std::string>();
|
||||
if (text.size() < 3) return std::nullopt;
|
||||
for (auto& c : text) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
for (unsigned day = 0; day < 7; day++) {
|
||||
if (text.starts_with(NAMES[day])) return day;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<unsigned> Digits(const std::string& text, size_t at, size_t count) {
|
||||
if (at + count > text.size()) return std::nullopt;
|
||||
unsigned value = 0;
|
||||
for (size_t i = at; i < at + count; i++) {
|
||||
if (!std::isdigit(static_cast<unsigned char>(text[i]))) return std::nullopt;
|
||||
value = value * 10 + static_cast<unsigned>(text[i] - '0');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// "10-31" -> (10, 31); Feb 29 is allowed (a year without one uses March 1)
|
||||
std::optional<std::pair<unsigned, unsigned>> MonthDay(const std::string& text) {
|
||||
if (text.size() != 5 || text[2] != '-') return std::nullopt;
|
||||
const auto month = Digits(text, 0, 2), day = Digits(text, 3, 2);
|
||||
if (!month || !day || *month < 1 || *month > 12 || *day < 1 || *day > CivilDate::DaysInMonth(2000, *month)) return std::nullopt;
|
||||
return std::pair{ *month, *day };
|
||||
}
|
||||
|
||||
// "18:30" -> minutes of the day; "24:00" only where allowEnd
|
||||
std::optional<unsigned> TimeOfDay(const std::string& text, bool allowEnd) {
|
||||
if (text.size() != 5 || text[2] != ':') return std::nullopt;
|
||||
const auto hour = Digits(text, 0, 2), minute = Digits(text, 3, 2);
|
||||
if (!hour || !minute || *minute > 59) return std::nullopt;
|
||||
if (*hour == 24 && *minute == 0 && allowEnd) return 24 * 60;
|
||||
if (*hour > 23) return std::nullopt;
|
||||
return *hour * 60 + *minute;
|
||||
}
|
||||
|
||||
// "2026-12-20" (dateOnly set) or "2026-12-20T18:00" -> local seconds
|
||||
std::optional<int64_t> DateTime(const std::string& text, bool& dateOnly) {
|
||||
if (text.size() < 10 || text[4] != '-' || text[7] != '-') return std::nullopt;
|
||||
const auto year = Digits(text, 0, 4), month = Digits(text, 5, 2), day = Digits(text, 8, 2);
|
||||
if (!year || !month || !day || *year < 1970 || *month < 1 || *month > 12 || *day < 1 || *day > CivilDate::DaysInMonth(*year, *month)) return std::nullopt;
|
||||
int64_t seconds = CivilDate::DaysFromCivil(*year, *month, *day) * DAY;
|
||||
dateOnly = text.size() == 10;
|
||||
if (dateOnly) return seconds;
|
||||
if (text.size() != 16 || (text[10] != 'T' && text[10] != ' ')) return std::nullopt;
|
||||
const auto minutes = TimeOfDay(text.substr(11), false);
|
||||
if (!minutes) return std::nullopt;
|
||||
return seconds + static_cast<int64_t>(*minutes) * 60;
|
||||
}
|
||||
|
||||
std::string Two(unsigned value) {
|
||||
return (value < 10 ? "0" : "") + std::to_string(value);
|
||||
}
|
||||
|
||||
std::string DateText(int64_t localSeconds, bool withTime) {
|
||||
const auto day = CivilDate::DayOf(localSeconds);
|
||||
const auto date = CivilDate::CivilFromDays(day);
|
||||
std::string text = std::to_string(date.year) + "-" + Two(date.month) + "-" + Two(date.day);
|
||||
if (withTime) {
|
||||
const auto minutes = static_cast<unsigned>((localSeconds - day * DAY) / 60);
|
||||
text += "T" + Two(minutes / 60) + ":" + Two(minutes % 60);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
std::string MinutesText(unsigned minutes) {
|
||||
return Two(minutes / 60) + ":" + Two(minutes % 60);
|
||||
}
|
||||
|
||||
template<typename Enum>
|
||||
std::string Lower(Enum value) {
|
||||
std::string text(magic_enum::enum_name(value));
|
||||
for (auto& c : text) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
return text;
|
||||
}
|
||||
|
||||
template<typename Enum>
|
||||
std::string Choices() {
|
||||
std::string text;
|
||||
for (const auto value : magic_enum::enum_values<Enum>()) text += (text.empty() ? "" : ", ") + Lower(value);
|
||||
return text;
|
||||
}
|
||||
|
||||
template<typename Enum>
|
||||
std::optional<Enum> EnumOf(const nlohmann::json& json, const char* key) {
|
||||
if (!json.contains(key) || !json[key].is_string()) return std::nullopt;
|
||||
return magic_enum::enum_cast<Enum>(json[key].get<std::string>(), magic_enum::case_insensitive);
|
||||
}
|
||||
|
||||
std::string Text(const nlohmann::json& json, const char* key) {
|
||||
return json.contains(key) && json[key].is_string() ? json[key].get<std::string>() : "";
|
||||
}
|
||||
|
||||
bool ParseRule(const nlohmann::json& json, Rule& rule, const std::string& where, size_t depth, size_t& count, std::string& error);
|
||||
|
||||
bool ParseGroup(const nlohmann::json& json, Rule& rule, const std::string& where, size_t depth, size_t& count, std::string& error) {
|
||||
const auto prefix = where.empty() ? std::string() : where + ": ";
|
||||
if (depth > MAX_DEPTH) { error = prefix + "groups go at most " + std::to_string(MAX_DEPTH) + " deep"; return false; }
|
||||
rule.type = eRuleType::GROUP;
|
||||
rule.match = eMatch::ANY;
|
||||
if (json.contains("match")) {
|
||||
const auto match = EnumOf<eMatch>(json, "match");
|
||||
if (!match) { error = prefix + "match is one of " + Choices<eMatch>(); return false; }
|
||||
rule.match = *match;
|
||||
}
|
||||
if (!json.contains("rules") || !json["rules"].is_array() || json["rules"].empty()) { error = prefix + "add at least one rule"; return false; }
|
||||
for (size_t i = 0; i < json["rules"].size(); i++) {
|
||||
Rule child;
|
||||
if (!ParseRule(json["rules"][i], child, (where.empty() ? "Rule " : where + ".") + std::to_string(i + 1), depth + 1, count, error)) return false;
|
||||
rule.rules.push_back(std::move(child));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ParseRule(const nlohmann::json& json, Rule& rule, const std::string& where, size_t depth, size_t& count, std::string& error) {
|
||||
const auto fail = [&](const std::string& message) { error = where + ": " + message; return false; };
|
||||
if (++count > MAX_RULES) return fail("a schedule has at most " + std::to_string(MAX_RULES) + " rules");
|
||||
if (!json.is_object()) return fail("each rule is an object");
|
||||
const auto type = EnumOf<eRuleType>(json, "type");
|
||||
if (!type) return fail("type is one of " + Choices<eRuleType>());
|
||||
rule.type = *type;
|
||||
if (json.contains("not") && !json["not"].is_boolean()) return fail("not is true or false");
|
||||
rule.invert = json.contains("not") && json["not"].get<bool>();
|
||||
switch (rule.type) {
|
||||
case eRuleType::GROUP:
|
||||
return ParseGroup(json, rule, where, depth, count, error);
|
||||
case eRuleType::YEARLY: {
|
||||
const auto from = MonthDay(Text(json, "from")), to = MonthDay(Text(json, "to"));
|
||||
if (!from || !to) return fail("from and to are a month and day, like 10-01");
|
||||
std::tie(rule.fromMonth, rule.fromDay) = *from;
|
||||
std::tie(rule.toMonth, rule.toDay) = *to;
|
||||
return true;
|
||||
}
|
||||
case eRuleType::DATES: {
|
||||
bool fromDate = false, toDate = false;
|
||||
const auto from = DateTime(Text(json, "from"), fromDate), to = DateTime(Text(json, "to"), toDate);
|
||||
if (!from || !to) return fail("from and to are dates like 2026-12-20, or with a time like 2026-12-20T18:00");
|
||||
rule.from = *from;
|
||||
// A date alone as the end means the whole of that day
|
||||
rule.to = *to + (toDate ? DAY : 0);
|
||||
if (rule.to <= rule.from) return fail("the end has to be after the start");
|
||||
return true;
|
||||
}
|
||||
case eRuleType::WEEKDAYS: {
|
||||
if (!json.contains("days") || !json["days"].is_array() || json["days"].empty()) return fail("pick at least one day");
|
||||
for (const auto& day : json["days"]) {
|
||||
const auto weekday = WeekdayOf(day);
|
||||
if (!weekday) return fail("days are sun, mon, tue, wed, thu, fri or sat");
|
||||
rule.weekdays.set(*weekday);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case eRuleType::TIME_OF_DAY: {
|
||||
const auto from = TimeOfDay(Text(json, "from"), false), to = TimeOfDay(Text(json, "to"), true);
|
||||
if (!from || !to) return fail("from and to are times like 18:00 (to may be 24:00)");
|
||||
if (*from == *to % (24 * 60)) return fail("from and to are the same time");
|
||||
rule.fromMinute = *from;
|
||||
rule.toMinute = *to;
|
||||
return true;
|
||||
}
|
||||
case eRuleType::MOON: {
|
||||
const auto phase = EnumOf<eMoonPhase>(json, "phase");
|
||||
if (!phase) return fail("phase is one of " + Choices<eMoonPhase>());
|
||||
rule.phase = *phase;
|
||||
if (json.contains("hours")) {
|
||||
if (!json["hours"].is_number_integer() || json["hours"].get<int64_t>() < 1 || json["hours"].get<int64_t>() > MAX_MOON_HOURS) {
|
||||
return fail("hours is 1 to " + std::to_string(MAX_MOON_HOURS));
|
||||
}
|
||||
rule.hours = json["hours"].get<int32_t>();
|
||||
} else if (json.contains("days")) {
|
||||
if (!json["days"].is_number_integer() || json["days"].get<int64_t>() < 0 || json["days"].get<int64_t>() > MAX_MOON_DAYS) {
|
||||
return fail("days is 0 to " + std::to_string(MAX_MOON_DAYS));
|
||||
}
|
||||
rule.days = json["days"].get<int32_t>();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return fail("unknown type");
|
||||
}
|
||||
|
||||
nlohmann::json RuleJson(const Rule& rule) {
|
||||
nlohmann::json json{ {"type", Lower(rule.type)} };
|
||||
switch (rule.type) {
|
||||
case eRuleType::GROUP: {
|
||||
json["match"] = Lower(rule.match);
|
||||
json["rules"] = nlohmann::json::array();
|
||||
for (const auto& child : rule.rules) json["rules"].push_back(RuleJson(child));
|
||||
break;
|
||||
}
|
||||
case eRuleType::YEARLY:
|
||||
json["from"] = Two(rule.fromMonth) + "-" + Two(rule.fromDay);
|
||||
json["to"] = Two(rule.toMonth) + "-" + Two(rule.toDay);
|
||||
break;
|
||||
case eRuleType::DATES: {
|
||||
json["from"] = DateText(rule.from, rule.from % DAY != 0);
|
||||
// A whole-day end goes back as the last day
|
||||
json["to"] = rule.to % DAY == 0 ? DateText(rule.to - DAY, false) : DateText(rule.to, true);
|
||||
break;
|
||||
}
|
||||
case eRuleType::WEEKDAYS: {
|
||||
static const char* NAMES[]{ "sun", "mon", "tue", "wed", "thu", "fri", "sat" };
|
||||
json["days"] = nlohmann::json::array();
|
||||
for (unsigned day = 0; day < 7; day++) if (rule.weekdays[day]) json["days"].push_back(NAMES[day]);
|
||||
break;
|
||||
}
|
||||
case eRuleType::TIME_OF_DAY:
|
||||
json["from"] = MinutesText(rule.fromMinute);
|
||||
json["to"] = MinutesText(rule.toMinute);
|
||||
break;
|
||||
case eRuleType::MOON:
|
||||
json["phase"] = Lower(rule.phase);
|
||||
if (rule.hours) json["hours"] = *rule.hours;
|
||||
else json["days"] = rule.days;
|
||||
break;
|
||||
}
|
||||
if (rule.invert) json["not"] = true;
|
||||
return json;
|
||||
}
|
||||
|
||||
double PhaseFraction(eMoonPhase phase) {
|
||||
return static_cast<double>(static_cast<uint8_t>(phase)) * 0.25;
|
||||
}
|
||||
|
||||
// The lunation (counted as PhaseTime does) around a time
|
||||
int64_t LunationAt(int64_t time) {
|
||||
const double jd = static_cast<double>(time) / DAY + UNIX_EPOCH_JD;
|
||||
return static_cast<int64_t>(std::floor((jd - LUNATION_ZERO_JDE) / SYNODIC_MONTH_DAYS));
|
||||
}
|
||||
|
||||
// The unix times a moon rule is on around the phase of one lunation
|
||||
Window MoonWindow(const Rule& rule, int64_t lunation, int32_t offsetSeconds) {
|
||||
const auto instant = PhaseTime(lunation, rule.phase);
|
||||
if (rule.hours) return { instant - *rule.hours * 3600LL, instant + *rule.hours * 3600LL };
|
||||
const auto day = CivilDate::DayOf(instant + offsetSeconds);
|
||||
return { (day - rule.days) * DAY - offsetSeconds, (day + rule.days + 1) * DAY - offsetSeconds };
|
||||
}
|
||||
|
||||
bool Matches(const Rule& rule, int64_t now, int32_t offsetSeconds) {
|
||||
const int64_t local = now + offsetSeconds;
|
||||
const auto day = CivilDate::DayOf(local);
|
||||
bool on = false;
|
||||
switch (rule.type) {
|
||||
case eRuleType::GROUP:
|
||||
if (rule.match == eMatch::ALL) on = std::all_of(rule.rules.begin(), rule.rules.end(), [&](const Rule& r) { return Matches(r, now, offsetSeconds); });
|
||||
else on = std::any_of(rule.rules.begin(), rule.rules.end(), [&](const Rule& r) { return Matches(r, now, offsetSeconds); });
|
||||
break;
|
||||
case eRuleType::YEARLY: {
|
||||
const auto date = CivilDate::CivilFromDays(day);
|
||||
const auto today = date.month * 100 + date.day, from = rule.fromMonth * 100 + rule.fromDay, to = rule.toMonth * 100 + rule.toDay;
|
||||
on = from <= to ? today >= from && today <= to : today >= from || today <= to;
|
||||
break;
|
||||
}
|
||||
case eRuleType::DATES:
|
||||
on = local >= rule.from && local < rule.to;
|
||||
break;
|
||||
case eRuleType::WEEKDAYS:
|
||||
on = rule.weekdays[CivilDate::Weekday(day)];
|
||||
break;
|
||||
case eRuleType::TIME_OF_DAY: {
|
||||
const auto minute = static_cast<unsigned>((local - day * DAY) / 60);
|
||||
on = rule.fromMinute < rule.toMinute ? minute >= rule.fromMinute && minute < rule.toMinute : minute >= rule.fromMinute || minute < rule.toMinute;
|
||||
break;
|
||||
}
|
||||
case eRuleType::MOON: {
|
||||
const auto lunation = LunationAt(now);
|
||||
for (auto k = lunation - 2; k <= lunation + 2 && !on; k++) {
|
||||
const auto window = MoonWindow(rule, k, offsetSeconds);
|
||||
on = now >= window.start && now < window.end;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return on != rule.invert;
|
||||
}
|
||||
|
||||
// The first time after `now` a rule could turn on or off (its state can't change before it)
|
||||
int64_t NextBoundary(const Rule& rule, int64_t now, int32_t offsetSeconds) {
|
||||
const int64_t local = now + offsetSeconds;
|
||||
const auto day = CivilDate::DayOf(local);
|
||||
int64_t next = NEVER;
|
||||
const auto consider = [&](int64_t time) { if (time > now) next = std::min(next, time); };
|
||||
switch (rule.type) {
|
||||
case eRuleType::GROUP:
|
||||
for (const auto& child : rule.rules) next = std::min(next, NextBoundary(child, now, offsetSeconds));
|
||||
break;
|
||||
case eRuleType::YEARLY:
|
||||
case eRuleType::WEEKDAYS:
|
||||
// Whole days: the next midnight
|
||||
consider((day + 1) * DAY - offsetSeconds);
|
||||
break;
|
||||
case eRuleType::DATES:
|
||||
consider(rule.from - offsetSeconds);
|
||||
consider(rule.to - offsetSeconds);
|
||||
break;
|
||||
case eRuleType::TIME_OF_DAY:
|
||||
for (auto d = day; d <= day + 1; d++) {
|
||||
consider(d * DAY + rule.fromMinute * 60LL - offsetSeconds);
|
||||
consider(d * DAY + rule.toMinute * 60LL - offsetSeconds);
|
||||
}
|
||||
break;
|
||||
case eRuleType::MOON: {
|
||||
const auto lunation = LunationAt(now);
|
||||
for (auto k = lunation - 2; k <= lunation + 3; k++) {
|
||||
const auto window = MoonWindow(rule, k, offsetSeconds);
|
||||
consider(window.start);
|
||||
consider(window.end);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<Schedule> ScheduleRules::ParseSchedule(const nlohmann::json& json, std::string& error) {
|
||||
if (!json.is_object()) { error = "The schedule is an object: {utcOffset, match, rules}"; return std::nullopt; }
|
||||
Schedule schedule;
|
||||
if (json.contains("utcOffset")) {
|
||||
if (!json["utcOffset"].is_number_integer() || std::abs(json["utcOffset"].get<int64_t>()) > MAX_UTC_OFFSET_MINUTES) {
|
||||
error = "utcOffset is minutes from UTC, -840 to 840";
|
||||
return std::nullopt;
|
||||
}
|
||||
schedule.utcOffsetMinutes = json["utcOffset"].get<int32_t>();
|
||||
}
|
||||
size_t count = 0;
|
||||
if (!ParseGroup(json, schedule.root, "", 1, count, error)) return std::nullopt;
|
||||
return schedule;
|
||||
}
|
||||
|
||||
std::optional<Schedule> ScheduleRules::ParseSchedule(const std::string& text, std::string& error) {
|
||||
const auto json = nlohmann::json::parse(text, nullptr, false);
|
||||
if (json.is_discarded()) { error = "The schedule isn't valid JSON"; return std::nullopt; }
|
||||
return ParseSchedule(json, error);
|
||||
}
|
||||
|
||||
nlohmann::json ScheduleRules::ToJson(const Schedule& schedule) {
|
||||
auto json = RuleJson(schedule.root);
|
||||
json.erase("type");
|
||||
json.erase("not");
|
||||
json["utcOffset"] = schedule.utcOffsetMinutes;
|
||||
return json;
|
||||
}
|
||||
|
||||
bool ScheduleRules::Active(const Schedule& schedule, int64_t now) {
|
||||
return Matches(schedule.root, now, schedule.utcOffsetMinutes * 60);
|
||||
}
|
||||
|
||||
std::optional<int64_t> ScheduleRules::NextChange(const Schedule& schedule, int64_t after, int64_t horizon) {
|
||||
const int32_t offset = schedule.utcOffsetMinutes * 60;
|
||||
const bool on = Matches(schedule.root, after, offset);
|
||||
const auto limit = after + horizon;
|
||||
// Between two boundaries nothing changes, so only they need checking (about two a day at most for most schedules)
|
||||
for (int64_t time = after; time < limit;) {
|
||||
time = NextBoundary(schedule.root, time, offset);
|
||||
if (time == NEVER || time > limit) break;
|
||||
if (Matches(schedule.root, time, offset) != on) return time;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::vector<Window> ScheduleRules::Windows(const Schedule& schedule, int64_t from, int64_t to, size_t max) {
|
||||
std::vector<Window> windows;
|
||||
if (to <= from) return windows;
|
||||
bool on = Active(schedule, from);
|
||||
int64_t start = from;
|
||||
for (int64_t time = from; windows.size() < max;) {
|
||||
const auto next = NextChange(schedule, time, to - time);
|
||||
if (!next) {
|
||||
if (on) windows.push_back({ start, to });
|
||||
break;
|
||||
}
|
||||
if (on) windows.push_back({ start, *next });
|
||||
else start = *next;
|
||||
on = !on;
|
||||
time = *next;
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
int64_t ScheduleRules::PhaseTime(int64_t lunation, eMoonPhase phase) {
|
||||
const double q = PhaseFraction(phase);
|
||||
const double k = static_cast<double>(lunation) + q;
|
||||
const double T = k / 1236.85;
|
||||
const double jdeMean = LUNATION_ZERO_JDE + 29.530588861 * k + 0.00015437 * T * T - 0.000000150 * T * T * T + 0.00000000073 * T * T * T * T;
|
||||
const auto rad = [](double degrees) { return std::fmod(degrees, 360.0) * 3.14159265358979323846 / 180.0; };
|
||||
const double E = 1 - 0.002516 * T - 0.0000074 * T * T;
|
||||
const double M = rad(2.5534 + 29.10535670 * k - 0.0000014 * T * T - 0.00000011 * T * T * T);
|
||||
const double Mp = rad(201.5643 + 385.81693528 * k + 0.0107582 * T * T + 0.00001238 * T * T * T - 0.000000058 * T * T * T * T);
|
||||
const double F = rad(160.7108 + 390.67050284 * k - 0.0016118 * T * T - 0.00000227 * T * T * T + 0.000000011 * T * T * T * T);
|
||||
const double O = rad(124.7746 - 1.56375588 * k + 0.0020672 * T * T + 0.00000215 * T * T * T);
|
||||
const auto s = [](double x) { return std::sin(x); };
|
||||
|
||||
double correction = 0;
|
||||
if (phase == eMoonPhase::NEW_MOON || phase == eMoonPhase::FULL_MOON) {
|
||||
const bool isNew = phase == eMoonPhase::NEW_MOON;
|
||||
correction = (isNew ? -0.40720 : -0.40614) * s(Mp) + (isNew ? 0.17241 : 0.17302) * E * s(M) + (isNew ? 0.01608 : 0.01614) * s(2 * Mp)
|
||||
+ (isNew ? 0.01039 : 0.01043) * s(2 * F) + (isNew ? 0.00739 : 0.00734) * E * s(Mp - M) + (isNew ? -0.00514 : -0.00515) * E * s(Mp + M)
|
||||
+ (isNew ? 0.00208 : 0.00209) * E * E * s(2 * M) - 0.00111 * s(Mp - 2 * F) - 0.00057 * s(Mp + 2 * F) + 0.00056 * E * s(2 * Mp + M)
|
||||
- 0.00042 * s(3 * Mp) + 0.00042 * E * s(M + 2 * F) + 0.00038 * E * s(M - 2 * F) - 0.00024 * E * s(2 * Mp - M) - 0.00017 * s(O)
|
||||
- 0.00007 * s(Mp + 2 * M) + 0.00004 * s(2 * Mp - 2 * F) + 0.00004 * s(3 * M) + 0.00003 * s(Mp + M - 2 * F) + 0.00003 * s(2 * Mp + 2 * F)
|
||||
- 0.00003 * s(Mp + M + 2 * F) + 0.00003 * s(Mp - M + 2 * F) - 0.00002 * s(Mp - M - 2 * F) - 0.00002 * s(3 * Mp + M) + 0.00002 * s(4 * Mp);
|
||||
} else {
|
||||
correction = -0.62801 * s(Mp) + 0.17172 * E * s(M) - 0.01183 * E * s(Mp + M) + 0.00862 * s(2 * Mp) + 0.00804 * s(2 * F)
|
||||
+ 0.00454 * E * s(Mp - M) + 0.00204 * E * E * s(2 * M) - 0.00180 * s(Mp - 2 * F) - 0.00070 * s(Mp + 2 * F) - 0.00040 * s(3 * Mp)
|
||||
- 0.00034 * E * s(2 * Mp - M) + 0.00032 * E * s(M + 2 * F) + 0.00032 * E * s(M - 2 * F) - 0.00028 * E * E * s(Mp + 2 * M)
|
||||
+ 0.00027 * E * s(2 * Mp + M) - 0.00017 * s(O) - 0.00005 * s(Mp - M - 2 * F) + 0.00004 * s(2 * Mp + 2 * F) - 0.00004 * s(Mp + M + 2 * F)
|
||||
+ 0.00004 * s(Mp - 2 * M) + 0.00003 * s(Mp + M - 2 * F) + 0.00003 * s(3 * M) + 0.00002 * s(2 * Mp - 2 * F) + 0.00002 * s(Mp - M + 2 * F)
|
||||
- 0.00002 * s(3 * Mp + M);
|
||||
const double W = 0.00306 - 0.00038 * E * std::cos(M) + 0.00026 * std::cos(Mp) - 0.00002 * std::cos(Mp - M) + 0.00002 * std::cos(Mp + M) + 0.00002 * std::cos(2 * F);
|
||||
correction += phase == eMoonPhase::FIRST_QUARTER ? W : -W;
|
||||
}
|
||||
|
||||
// The planetary terms, the same for every phase
|
||||
const double A[]{ 299.77 + 0.107408 * k - 0.009173 * T * T, 251.88 + 0.016321 * k, 251.83 + 26.651886 * k, 349.42 + 36.412478 * k,
|
||||
84.66 + 18.206239 * k, 141.74 + 53.303771 * k, 207.14 + 2.453732 * k, 154.84 + 7.306860 * k, 34.52 + 27.261239 * k,
|
||||
207.19 + 0.121824 * k, 291.34 + 1.844379 * k, 161.72 + 24.198154 * k, 239.56 + 25.513099 * k, 331.55 + 3.592518 * k };
|
||||
const double C[]{ 0.000325, 0.000165, 0.000164, 0.000126, 0.000110, 0.000062, 0.000060, 0.000056, 0.000047, 0.000042, 0.000040,
|
||||
0.000037, 0.000035, 0.000023 };
|
||||
double planetary = 0;
|
||||
for (size_t i = 0; i < std::size(A); i++) planetary += C[i] * s(rad(A[i]));
|
||||
|
||||
const double jde = jdeMean + correction + planetary;
|
||||
return static_cast<int64_t>(std::llround((jde - UNIX_EPOCH_JD) * DAY - DELTA_T_SECONDS));
|
||||
}
|
||||
|
||||
std::vector<int64_t> ScheduleRules::Phases(eMoonPhase phase, int64_t from, int64_t to) {
|
||||
std::vector<int64_t> times;
|
||||
for (auto k = LunationAt(from) - 1; k <= LunationAt(to) + 1; k++) {
|
||||
const auto time = PhaseTime(k, phase);
|
||||
if (time >= from && time < to) times.push_back(time);
|
||||
}
|
||||
return times;
|
||||
}
|
||||
|
||||
bool ScheduleRules::IsOn(eMode mode, const std::string& schedule, int64_t now) {
|
||||
if (mode == eMode::ALWAYS_ON) return true;
|
||||
if (mode != eMode::SCHEDULED) return false;
|
||||
std::string error;
|
||||
const auto parsed = ParseSchedule(schedule, error);
|
||||
return parsed && Active(*parsed, now);
|
||||
}
|
||||
|
||||
bool ScheduleRules::IsOn(eMode mode, const std::string& schedule, int64_t startsAt, int64_t endsAt, int64_t now) {
|
||||
if (mode == eMode::SCHEDULED && schedule.empty()) return now >= startsAt && now < endsAt;
|
||||
return IsOn(mode, schedule, now);
|
||||
}
|
||||
|
||||
std::vector<Window> ScheduleRules::WindowsOf(eMode mode, const std::string& schedule, int64_t startsAt, int64_t endsAt, int64_t from, int64_t to, size_t max) {
|
||||
if (to <= from || max == 0 || mode == eMode::OFF) return {};
|
||||
if (mode == eMode::ALWAYS_ON) return { { from, to } };
|
||||
if (schedule.empty()) {
|
||||
if (endsAt <= from || startsAt >= to) return {};
|
||||
return { { std::max(startsAt, from), std::min(endsAt, to) } };
|
||||
}
|
||||
std::string error;
|
||||
const auto parsed = ParseSchedule(schedule, error);
|
||||
return parsed ? Windows(*parsed, from, to, max) : std::vector<Window>{};
|
||||
}
|
||||
105
dCommon/ScheduleRules.h
Normal file
105
dCommon/ScheduleRules.h
Normal file
@@ -0,0 +1,105 @@
|
||||
#pragma once
|
||||
|
||||
#include <bitset>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "json.hpp"
|
||||
|
||||
/**
|
||||
* When a scheduled event is on (the dashboard's Events page: feature flags, vanity changes, live events, announcements
|
||||
* and restarts that switch on and off together). An event is off, on by its schedule, or always on. Its schedule is
|
||||
* either once (a start and an end) or recurring rules, as JSON: {"utcOffset": minutes, "match": "any"|"all", "rules": [rule, ...]},
|
||||
* each rule one of
|
||||
* {"type": "yearly", "from": "10-01", "to": "10-31"} every year, whole days; may wrap the year end
|
||||
* {"type": "dates", "from": "2026-12-20", "to": "2027-01-02"} once; a date alone is the whole day,
|
||||
* "2026-12-20T18:00" a time (the end is exclusive)
|
||||
* {"type": "weekdays", "days": ["fri", "sat"]}
|
||||
* {"type": "time_of_day", "from": "18:00", "to": "02:00"} every day; may wrap midnight
|
||||
* {"type": "moon", "phase": "full_moon", "days": 0} the day the phase falls on, +- days;
|
||||
* {"type": "moon", "phase": "full_moon", "hours": 12} or +- hours around the exact time
|
||||
* {"type": "group", "match": "all", "rules": [...]} rules inside rules
|
||||
* and any rule may have "not": true. Times are the server's UTC plus utcOffset (a fixed offset: no daylight saving).
|
||||
* Pure; unit tested.
|
||||
*/
|
||||
namespace ScheduleRules {
|
||||
// scheduled_events.mode; stored as numbers, so only append
|
||||
enum class eMode : uint8_t {
|
||||
OFF, // never on
|
||||
SCHEDULED, // on while its schedule says so
|
||||
ALWAYS_ON // on now, whatever the schedule says
|
||||
};
|
||||
|
||||
enum class eRuleType : uint8_t { GROUP, YEARLY, DATES, WEEKDAYS, TIME_OF_DAY, MOON };
|
||||
enum class eMatch : uint8_t { ANY, ALL };
|
||||
enum class eMoonPhase : uint8_t { NEW_MOON, FIRST_QUARTER, FULL_MOON, LAST_QUARTER };
|
||||
|
||||
constexpr size_t MAX_RULES = 64;
|
||||
constexpr size_t MAX_DEPTH = 4;
|
||||
constexpr int32_t MAX_UTC_OFFSET_MINUTES = 14 * 60;
|
||||
constexpr int32_t MAX_MOON_DAYS = 7;
|
||||
constexpr int32_t MAX_MOON_HOURS = 240;
|
||||
// How far ahead the next start or end is looked for
|
||||
constexpr int64_t HORIZON_SECONDS = 4 * 366 * 86400LL;
|
||||
constexpr double SYNODIC_MONTH_DAYS = 29.530588853;
|
||||
|
||||
struct Rule {
|
||||
eRuleType type{ eRuleType::GROUP };
|
||||
bool invert{}; // "not": on when the rule isn't
|
||||
eMatch match{ eMatch::ANY }; // GROUP
|
||||
std::vector<Rule> rules; // GROUP
|
||||
unsigned fromMonth{}, fromDay{}, toMonth{}, toDay{}; // YEARLY, both days included
|
||||
int64_t from{}, to{}; // DATES: local seconds, [from, to)
|
||||
std::bitset<7> weekdays; // WEEKDAYS: 0 = Sunday
|
||||
unsigned fromMinute{}, toMinute{}; // TIME_OF_DAY: minutes of the day, [from, to); to < from wraps midnight
|
||||
eMoonPhase phase{ eMoonPhase::FULL_MOON }; // MOON
|
||||
int32_t days{}; // MOON: the local day of the phase, and this many days either side
|
||||
std::optional<int32_t> hours; // MOON: instead, this many hours either side of the exact time
|
||||
};
|
||||
|
||||
struct Schedule {
|
||||
int32_t utcOffsetMinutes{};
|
||||
Rule root; // a GROUP
|
||||
};
|
||||
|
||||
struct Window {
|
||||
int64_t start{};
|
||||
int64_t end{};
|
||||
};
|
||||
|
||||
std::optional<Schedule> ParseSchedule(const nlohmann::json& json, std::string& error);
|
||||
// From the JSON text stored in scheduled_events.schedule
|
||||
std::optional<Schedule> ParseSchedule(const std::string& text, std::string& error);
|
||||
nlohmann::json ToJson(const Schedule& schedule);
|
||||
|
||||
// Whether the schedule is on at `now` (unix seconds)
|
||||
bool Active(const Schedule& schedule, int64_t now);
|
||||
|
||||
// The first time after `after` it turns on or off; nullopt if that isn't within `horizon` seconds
|
||||
std::optional<int64_t> NextChange(const Schedule& schedule, int64_t after, int64_t horizon = HORIZON_SECONDS);
|
||||
|
||||
// The times it is on between from and to (windows are cut to fit), at most `max` of them
|
||||
std::vector<Window> Windows(const Schedule& schedule, int64_t from, int64_t to, size_t max = 100);
|
||||
|
||||
// When a moon phase happens (unix seconds, to a minute or two; Meeus, Astronomical Algorithms ch. 49), for a
|
||||
// lunation counted from the new moon of 2000-01-06
|
||||
int64_t PhaseTime(int64_t lunation, eMoonPhase phase);
|
||||
|
||||
// Every time a phase happens between from and to
|
||||
std::vector<int64_t> Phases(eMoonPhase phase, int64_t from, int64_t to);
|
||||
|
||||
// Whether an event is on: never when OFF, always when ALWAYS_ON, when its schedule says for SCHEDULED
|
||||
bool IsOn(eMode mode, const std::string& schedule, int64_t now);
|
||||
|
||||
/**
|
||||
* The same for an event that is either recurring (schedule: rules as above) or once (schedule empty: on from startsAt
|
||||
* until endsAt, unix seconds).
|
||||
*/
|
||||
bool IsOn(eMode mode, const std::string& schedule, int64_t startsAt, int64_t endsAt, int64_t now);
|
||||
|
||||
// The times such an event is on between from and to, at most `max` of them (ALWAYS_ON: all of it; OFF or a bad schedule: none)
|
||||
std::vector<Window> WindowsOf(eMode mode, const std::string& schedule, int64_t startsAt, int64_t endsAt, int64_t from, int64_t to, size_t max = 100);
|
||||
|
||||
}
|
||||
140
dCommon/SlashCommandLevels.h
Normal file
140
dCommon/SlashCommandLevels.h
Normal file
@@ -0,0 +1,140 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include "GeneralUtils.h"
|
||||
|
||||
/**
|
||||
* Who may use each in-game slash command. Every command has a GM level in the code; server owners can change it
|
||||
* without rebuilding, on the dashboard's Permissions page or with command_level_<name>=<level> in worldconfig.ini
|
||||
* (or the COMMAND_LEVEL_<NAME> environment variable). A level set on the dashboard beats the file. World servers
|
||||
* look the level up each time a command is used, so a change applies as soon as they reload their settings.
|
||||
* A command paired with a dashboard permission (the same action) uses that permission's level instead: one setting,
|
||||
* permission_<key>. A command_level_<name> value for it is an override, kept so an older setup doesn't change silently;
|
||||
* the value "permission" (FOLLOW_PERMISSION) drops an override set in a file.
|
||||
* Shared by the world server (which enforces the levels) and the dashboard (which shows and changes them).
|
||||
*/
|
||||
namespace SlashCommandLevels {
|
||||
constexpr uint8_t MAX_LEVEL = 9;
|
||||
// Staff commands never go below this: players (GM 0) don't get them
|
||||
constexpr uint8_t STAFF_MIN_LEVEL = 1;
|
||||
// World servers read this file's settings, so the dashboard stores levels under it
|
||||
constexpr std::string_view CONFIG_FILE = "worldconfig.ini";
|
||||
constexpr std::string_view PREFIX = "command_level_";
|
||||
// command_level_<name>=permission: a paired command follows its permission, whatever a file sets
|
||||
constexpr std::string_view FOLLOW_PERMISSION = "permission";
|
||||
|
||||
// A command's name in settings, from its first alias: "Leave-Zone" -> "leave_zone", "victory!" -> "victory"
|
||||
inline std::string SettingName(std::string_view alias) {
|
||||
std::string name;
|
||||
for (const char c : alias) {
|
||||
const auto ch = static_cast<unsigned char>(c);
|
||||
if (std::isalnum(ch)) name += static_cast<char>(std::tolower(ch));
|
||||
else if (!name.empty() && name.back() != '_') name += '_';
|
||||
}
|
||||
while (!name.empty() && name.back() == '_') name.pop_back();
|
||||
return name;
|
||||
}
|
||||
|
||||
inline std::string ConfigName(std::string_view name) {
|
||||
return std::string(PREFIX) + std::string(name);
|
||||
}
|
||||
|
||||
inline bool IsLevelSetting(std::string_view key) {
|
||||
return key.starts_with(PREFIX);
|
||||
}
|
||||
|
||||
// The lowest level a command may be set to when the code doesn't say: staff commands stay with staff
|
||||
inline uint8_t DefaultMinLevel(uint8_t defaultLevel) {
|
||||
return defaultLevel > 0 ? STAFF_MIN_LEVEL : 0;
|
||||
}
|
||||
|
||||
// A usable level value: a number from the command's floor to GM 9
|
||||
inline std::optional<uint8_t> ParseLevel(uint8_t minLevel, const std::string& configValue) {
|
||||
const auto parsed = GeneralUtils::TryParse<int32_t>(configValue);
|
||||
if (!parsed || *parsed < minLevel || *parsed > MAX_LEVEL) return std::nullopt;
|
||||
return static_cast<uint8_t>(*parsed);
|
||||
}
|
||||
|
||||
// Pure: the level a config value gives a command. Fixed commands always use the default, and so do bad values
|
||||
// and values below the command's floor or above GM 9.
|
||||
inline uint8_t Resolve(uint8_t defaultLevel, uint8_t minLevel, bool fixed, const std::string& configValue) {
|
||||
if (fixed || configValue.empty()) return defaultLevel;
|
||||
return ParseLevel(minLevel, configValue).value_or(defaultLevel);
|
||||
}
|
||||
|
||||
struct PairedLevel {
|
||||
uint8_t level;
|
||||
bool overridden; // an explicit command_level_<name> value is used instead of the permission's level
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure: the level of a command paired with a dashboard permission. A usable command_level_<name> value overrides the
|
||||
* permission; no value, FOLLOW_PERMISSION or a bad value follows it. The command's floor still applies (e.g.
|
||||
* /execute never below GM 8), and fixed commands always use their default.
|
||||
*/
|
||||
inline PairedLevel ResolvePaired(uint8_t defaultLevel, uint8_t minLevel, bool fixed, uint8_t permissionLevel, const std::string& configValue) {
|
||||
if (fixed) return { defaultLevel, false };
|
||||
if (configValue != FOLLOW_PERMISSION) {
|
||||
if (const auto level = ParseLevel(minLevel, configValue)) return { *level, true };
|
||||
}
|
||||
return { std::min<uint8_t>(std::max(permissionLevel, minLevel), MAX_LEVEL), false };
|
||||
}
|
||||
|
||||
// Who is named as having set a command level that the upgrade to paired commands kept (server_config.updated_by, audit log)
|
||||
constexpr std::string_view UPGRADE_ACTOR = "[upgrade]";
|
||||
|
||||
/**
|
||||
* Pure: when a command first uses its dashboard permission's level on a server that already had it, the level to keep
|
||||
* as an override so nothing changes silently (nullopt: none needed).
|
||||
* @param hadRow the server stored this command before (a brand-new server has no rows and just follows the permissions)
|
||||
* @param followedBefore the stored row already followed this same permission (the upgrade happened already)
|
||||
* @param oldDefaultLevel the level the stored row says the code gave it
|
||||
* @param configValue its command_level_<name> value now: a usable one is already an override and stays as it is
|
||||
* @param permissionLevel the level it gets by following the permission (ResolvePaired without a value)
|
||||
*/
|
||||
inline std::optional<uint8_t> UpgradeOverride(bool hadRow, bool followedBefore, bool fixed, uint8_t oldDefaultLevel, uint8_t minLevel, const std::string& configValue, uint8_t permissionLevel) {
|
||||
if (!hadRow || followedBefore || fixed) return std::nullopt;
|
||||
if (configValue == FOLLOW_PERMISSION || ParseLevel(minLevel, configValue)) return std::nullopt;
|
||||
// The level it had: the code's (a bad value meant the code's level too), never below the floor
|
||||
const uint8_t oldLevel = std::min<uint8_t>(std::max(oldDefaultLevel, minLevel), MAX_LEVEL);
|
||||
if (oldLevel == permissionLevel) return std::nullopt;
|
||||
return oldLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who a command may be used on, when it acts on another player's account or character. Staff below GM 9 follow the
|
||||
* dashboard's rules (AccountRules.h): never a higher GM level, their own level only with manage_equal_rank, and
|
||||
* themselves only with the self_* permission for the kind of action.
|
||||
*/
|
||||
enum class eTargetRule : uint8_t {
|
||||
NONE, // doesn't act on other players (or only looks)
|
||||
TOOLS, // on themselves with self_tools (kick, move)
|
||||
ITEMS, // on themselves with self_items (mail items)
|
||||
MODERATION, // on themselves with self_moderation (ban, mute)
|
||||
OTHERS, // checked on other players only: on themselves it works as it always did, like the self-only dev commands
|
||||
};
|
||||
|
||||
// The name stored for the dashboard ("" for NONE)
|
||||
inline std::string_view TargetRuleName(eTargetRule rule) {
|
||||
switch (rule) {
|
||||
case eTargetRule::TOOLS: return "tools";
|
||||
case eTargetRule::ITEMS: return "items";
|
||||
case eTargetRule::MODERATION: return "moderation";
|
||||
case eTargetRule::OTHERS: return "others";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
inline eTargetRule TargetRuleFromName(std::string_view name) {
|
||||
for (const auto rule : { eTargetRule::TOOLS, eTargetRule::ITEMS, eTargetRule::MODERATION, eTargetRule::OTHERS }) {
|
||||
if (TargetRuleName(rule) == name) return rule;
|
||||
}
|
||||
return eTargetRule::NONE;
|
||||
}
|
||||
}
|
||||
188
dCommon/VanityEvents.cpp
Normal file
188
dCommon/VanityEvents.cpp
Normal file
@@ -0,0 +1,188 @@
|
||||
#include "VanityEvents.h"
|
||||
|
||||
#include <fstream>
|
||||
#include <map>
|
||||
#include <set>
|
||||
|
||||
#include "GeneralUtils.h"
|
||||
|
||||
VanityEvents::Merged VanityEvents::Merge(std::vector<VanityXml::Object> base, const std::vector<Overlay>& overlays) {
|
||||
Merged merged;
|
||||
merged.objects = std::move(base);
|
||||
std::map<std::string, std::vector<std::string>> touched; // NPC name -> the events that changed it, in order
|
||||
for (const auto& overlay : overlays) {
|
||||
std::set<std::string> names;
|
||||
for (const auto& object : overlay.objects) if (!object.name.empty()) names.insert(object.name);
|
||||
for (const auto& name : overlay.removals) {
|
||||
const bool present = std::any_of(merged.objects.begin(), merged.objects.end(), [&](const auto& o) { return o.name == name; });
|
||||
if (!present && !names.contains(name)) merged.warnings.push_back(overlay.event + ": there is no NPC named \"" + name + "\" to take out");
|
||||
names.insert(name);
|
||||
}
|
||||
std::erase_if(merged.objects, [&](const auto& o) { return !o.name.empty() && names.contains(o.name); });
|
||||
merged.objects.insert(merged.objects.end(), overlay.objects.begin(), overlay.objects.end());
|
||||
for (const auto& name : names) touched[name].push_back(overlay.event);
|
||||
}
|
||||
for (auto& [npc, events] : touched) {
|
||||
if (events.size() > 1) merged.conflicts.push_back({ npc, std::move(events) });
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
std::vector<std::string> VanityEvents::SplitNames(const std::string& text) {
|
||||
std::vector<std::string> names;
|
||||
for (auto line : GeneralUtils::SplitString(text, '\n')) {
|
||||
const auto first = line.find_first_not_of(" \t\r");
|
||||
if (first == std::string::npos) continue;
|
||||
line = line.substr(first, line.find_last_not_of(" \t\r") - first + 1);
|
||||
if (std::find(names.begin(), names.end(), line) == names.end()) names.push_back(line);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
bool VanityEvents::ValidFileName(const std::string& name) {
|
||||
if (name.size() < 5 || name.size() > 64 || !name.ends_with(".xml")) return false;
|
||||
return std::all_of(name.begin(), name.end() - 4, [](char c) { return std::isalnum(static_cast<unsigned char>(c)) || c == '-' || c == '_'; });
|
||||
}
|
||||
|
||||
std::optional<VanityEvents::FileSwitches> VanityEvents::ParseFileSwitches(const nlohmann::json& json, std::string& error) {
|
||||
if (!json.is_object()) { error = "File switches are an object: {\"file.xml\": true or false}"; return std::nullopt; }
|
||||
if (json.size() > MAX_FILE_SWITCHES) { error = "At most " + std::to_string(MAX_FILE_SWITCHES) + " file switches"; return std::nullopt; }
|
||||
FileSwitches switches;
|
||||
for (const auto& [name, on] : json.items()) {
|
||||
if (!ValidFileName(name)) { error = "\"" + name + "\" isn't a vanity file name (letters, digits, - and _, then .xml)"; return std::nullopt; }
|
||||
if (!on.is_boolean()) { error = name + ": switch it on (true) or off (false)"; return std::nullopt; }
|
||||
switches[name] = on.get<bool>();
|
||||
}
|
||||
return switches;
|
||||
}
|
||||
|
||||
std::optional<VanityEvents::FileSwitches> VanityEvents::ParseFileSwitches(const std::string& text, std::string& error) {
|
||||
if (text.find_first_not_of(" \t\r\n") == std::string::npos) return FileSwitches{};
|
||||
const auto json = nlohmann::json::parse(text, nullptr, false);
|
||||
if (json.is_discarded()) { error = "The file switches aren't valid JSON"; return std::nullopt; }
|
||||
return ParseFileSwitches(json, error);
|
||||
}
|
||||
|
||||
nlohmann::json VanityEvents::ToJson(const FileSwitches& switches) {
|
||||
nlohmann::json json = nlohmann::json::object();
|
||||
for (const auto& [name, on] : switches) json[name] = on;
|
||||
return json;
|
||||
}
|
||||
|
||||
namespace {
|
||||
using namespace VanityEvents;
|
||||
|
||||
// Reads vanity files into one Loaded, each file once however often it is named
|
||||
class Loader {
|
||||
public:
|
||||
Loader(const std::filesystem::path& folder, const Switches& switches) : m_Folder(folder), m_Switches(switches) {}
|
||||
|
||||
// Read a file (and what it switches on), unless it was read already
|
||||
void Read(FileLoad entry) {
|
||||
const auto name = entry.name;
|
||||
if (!m_Seen.insert(name).second) return;
|
||||
const auto index = Record(std::move(entry), true);
|
||||
// Any name the files give, as the worlds always took, as long as it stays in the vanity folder
|
||||
const std::filesystem::path relative(name);
|
||||
if (name.empty() || relative.is_absolute() || relative.has_root_path() || name.find("..") != std::string::npos) {
|
||||
m_Loaded.warnings.push_back("\"" + name + "\" isn't a file in the vanity folder");
|
||||
return;
|
||||
}
|
||||
std::ifstream in(m_Folder / name, std::ios::binary);
|
||||
if (!in) { m_Loaded.warnings.push_back(name + " not found"); return; }
|
||||
const std::string text((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||||
std::string error;
|
||||
const auto document = VanityXml::Read(text, error);
|
||||
if (!document) { m_Loaded.warnings.push_back(name + ": " + error); return; }
|
||||
m_Loaded.files.push_back(name);
|
||||
m_Loaded.trace[index].loaded = true;
|
||||
for (const auto& warning : document->warnings) m_Loaded.warnings.push_back(name + ": skipped " + warning);
|
||||
for (const auto& include : document->files) {
|
||||
const auto it = m_Switches.find(include.name);
|
||||
FileLoad child{ .name = include.name, .includedBy = name, .enabled = include.enabled, .switchedBy = it == m_Switches.end() ? "" : it->second.event };
|
||||
if (it == m_Switches.end() ? include.enabled : it->second.on) Read(std::move(child));
|
||||
else Record(std::move(child), false);
|
||||
}
|
||||
m_Loaded.objects.insert(m_Loaded.objects.end(), document->objects.begin(), document->objects.end());
|
||||
}
|
||||
|
||||
bool Met(const std::string& name) const { return m_Traced.contains(name); }
|
||||
|
||||
Loaded Take() { return std::move(m_Loaded); }
|
||||
|
||||
private:
|
||||
// The first time a file is met is where it is listed; being read later (named again, switched on) says why instead
|
||||
size_t Record(FileLoad entry, bool reading) {
|
||||
const auto [it, inserted] = m_Traced.try_emplace(entry.name, m_Loaded.trace.size());
|
||||
if (inserted) m_Loaded.trace.push_back(std::move(entry));
|
||||
else if (reading) m_Loaded.trace[it->second] = std::move(entry);
|
||||
return it->second;
|
||||
}
|
||||
|
||||
const std::filesystem::path& m_Folder;
|
||||
const Switches& m_Switches;
|
||||
Loaded m_Loaded;
|
||||
std::set<std::string> m_Seen; // read, or tried
|
||||
std::map<std::string, size_t> m_Traced; // name -> its entry in m_Loaded.trace
|
||||
};
|
||||
}
|
||||
|
||||
VanityEvents::Loaded VanityEvents::LoadFiles(const std::filesystem::path& folder, const std::string& start, const Switches& switches) {
|
||||
Loader loader(folder, switches);
|
||||
loader.Read({ .name = start, .enabled = true });
|
||||
return loader.Take();
|
||||
}
|
||||
|
||||
VanityEvents::World VanityEvents::LoadWorld(const std::filesystem::path& folder, const std::string& root, const std::vector<Changes>& events) {
|
||||
World world;
|
||||
// The file switches, later events over earlier ones
|
||||
Switches switches;
|
||||
std::map<std::string, std::vector<std::pair<std::string, bool>>> switchedBy;
|
||||
for (const auto& event : events) {
|
||||
world.events.push_back(event.name);
|
||||
std::string error;
|
||||
const auto parsed = ParseFileSwitches(event.fileSwitches, error);
|
||||
if (!parsed) { world.warnings.push_back(event.name + ": file switches: " + error); continue; }
|
||||
for (const auto& [file, on] : *parsed) {
|
||||
if (file == root) { world.warnings.push_back(event.name + ": " + root + " is always loaded; its switch is ignored"); continue; }
|
||||
switches[file] = { on, event.name };
|
||||
switchedBy[file].emplace_back(event.name, on);
|
||||
}
|
||||
}
|
||||
for (auto& [file, list] : switchedBy) {
|
||||
if (list.size() > 1) world.fileConflicts.push_back({ file, std::move(list) });
|
||||
}
|
||||
|
||||
// root.xml and what it switches on, then the files switched on that it didn't reach
|
||||
Loader loader(folder, switches);
|
||||
loader.Read({ .name = root, .enabled = true });
|
||||
for (const auto& [file, which] : switches) {
|
||||
if (which.on) loader.Read({ .name = file, .switchedBy = which.event });
|
||||
}
|
||||
for (const auto& [file, which] : switches) {
|
||||
if (!which.on && !loader.Met(file)) world.warnings.push_back(which.event + " switches " + file + " off, but no loaded file includes it");
|
||||
}
|
||||
auto base = loader.Take();
|
||||
world.files = std::move(base.trace);
|
||||
world.warnings.insert(world.warnings.end(), base.warnings.begin(), base.warnings.end());
|
||||
world.fileNpcs = base.objects.size();
|
||||
const std::set<std::string> baseFiles(base.files.begin(), base.files.end());
|
||||
|
||||
// Then the overlays
|
||||
std::vector<Overlay> overlays;
|
||||
for (const auto& event : events) {
|
||||
Overlay overlay{ .event = event.name, .removals = SplitNames(event.removals) };
|
||||
if (!event.file.empty()) {
|
||||
if (baseFiles.contains(event.file)) world.warnings.push_back(event.name + ": its overlay file " + event.file + " is loaded as a vanity file too, so its NPCs are there anyway");
|
||||
auto loaded = LoadFiles(folder, event.file, switches);
|
||||
for (const auto& warning : loaded.warnings) world.warnings.push_back(event.name + ": " + warning);
|
||||
overlay.objects = std::move(loaded.objects);
|
||||
}
|
||||
overlays.push_back(std::move(overlay));
|
||||
}
|
||||
auto merged = Merge(std::move(base.objects), overlays);
|
||||
world.objects = std::move(merged.objects);
|
||||
world.conflicts = std::move(merged.conflicts);
|
||||
world.warnings.insert(world.warnings.end(), merged.warnings.begin(), merged.warnings.end());
|
||||
return world;
|
||||
}
|
||||
153
dCommon/VanityEvents.h
Normal file
153
dCommon/VanityEvents.h
Normal file
@@ -0,0 +1,153 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "json.hpp"
|
||||
#include "ScheduleRules.h"
|
||||
#include "VanityXml.h"
|
||||
|
||||
/**
|
||||
* Vanity changes made by scheduled events (the vanity part of an event, EventParts.h): a Halloween look in October, a
|
||||
* werewolf on full-moon nights. A vanity part has file switches (vanity files it switches on or off, as root.xml's
|
||||
* <file enabled> entries do), an overlay file (a vanity file that root.xml doesn't load, whose NPCs are added) and NPC
|
||||
* names to take out; its event has the priority. The world servers and the dashboard both go through LoadWorld:
|
||||
* root.xml and the files it switches on, with the events' file switches applied, then every event's overlay laid over
|
||||
* that (VanityUtilities); the dashboard respawns the NPCs when the set of vanity parts that are on changes. The vanity
|
||||
* files are never changed.
|
||||
*
|
||||
* Pure apart from LoadFiles and LoadWorld; unit tested.
|
||||
*/
|
||||
namespace VanityEvents {
|
||||
// The overlay of one event
|
||||
struct Overlay {
|
||||
std::string event; // for conflicts and warnings
|
||||
std::vector<VanityXml::Object> objects; // added; a named one replaces every NPC of that name
|
||||
std::vector<std::string> removals; // names of NPCs taken out
|
||||
};
|
||||
|
||||
// Several events that are on change the same NPC: the last one listed wins
|
||||
struct Conflict {
|
||||
std::string npc;
|
||||
std::vector<std::string> events;
|
||||
};
|
||||
|
||||
struct Merged {
|
||||
std::vector<VanityXml::Object> objects;
|
||||
std::vector<Conflict> conflicts;
|
||||
std::vector<std::string> warnings;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lay overlays over the base NPCs, in order (see SortForMerge). For each event: its removals and every NPC with the
|
||||
* same name as one of its named objects are taken out, then its objects are added. Unnamed objects (props) are only
|
||||
* ever added.
|
||||
*/
|
||||
Merged Merge(std::vector<VanityXml::Object> base, const std::vector<Overlay>& overlays);
|
||||
|
||||
// Several events that are on switch the same file: the last one in merge order wins
|
||||
struct FileConflict {
|
||||
std::string file;
|
||||
std::vector<std::pair<std::string, bool>> switches; // event, on; in merge order
|
||||
};
|
||||
|
||||
// The merge order: lowest priority first, so the highest priority is laid on last and wins; ties by id
|
||||
template<typename Event>
|
||||
void SortForMerge(std::vector<Event>& events) {
|
||||
std::stable_sort(events.begin(), events.end(), [](const Event& a, const Event& b) {
|
||||
return a.priority != b.priority ? a.priority < b.priority : a.id < b.id;
|
||||
});
|
||||
}
|
||||
|
||||
// NPC names from a list, one per line (blank lines skipped)
|
||||
std::vector<std::string> SplitNames(const std::string& text);
|
||||
|
||||
// A vanity file name the dashboard and the worlds accept: letters, digits, - and _, then .xml
|
||||
bool ValidFileName(const std::string& name);
|
||||
|
||||
constexpr size_t MAX_FILE_SWITCHES = 64;
|
||||
|
||||
// A vanity part's file switches: vanity file name -> on (loaded) or off (not loaded)
|
||||
using FileSwitches = std::map<std::string, bool>;
|
||||
|
||||
// {"halloween.xml": true, "summer.xml": false}; names must pass ValidFileName
|
||||
std::optional<FileSwitches> ParseFileSwitches(const nlohmann::json& json, std::string& error);
|
||||
// From the stored text; empty text is no switches
|
||||
std::optional<FileSwitches> ParseFileSwitches(const std::string& text, std::string& error);
|
||||
inline std::optional<FileSwitches> ParseFileSwitches(const char* text, std::string& error) { return ParseFileSwitches(std::string(text), error); }
|
||||
nlohmann::json ToJson(const FileSwitches& switches);
|
||||
|
||||
// The switch that decides a file, after every event that is on has been laid on
|
||||
struct Switch {
|
||||
bool on{};
|
||||
std::string event;
|
||||
};
|
||||
using Switches = std::map<std::string, Switch>;
|
||||
|
||||
// Why a vanity file is or isn't loaded
|
||||
struct FileLoad {
|
||||
std::string name;
|
||||
std::string includedBy; // the loaded file whose <file> entry names it; empty for the start file and a file switched on that no loaded file names
|
||||
bool enabled{}; // what that entry says
|
||||
std::string switchedBy; // the event whose file switch decides it, if any
|
||||
bool loaded{}; // read: switched on (by its entry or an event), found and valid XML
|
||||
};
|
||||
|
||||
struct Loaded {
|
||||
std::vector<VanityXml::Object> objects;
|
||||
std::vector<std::string> files; // every file read, in order
|
||||
std::vector<FileLoad> trace; // every file read or named by a file read, in the order met, each once
|
||||
std::vector<std::string> warnings; // files that couldn't be read, and skipped parts
|
||||
};
|
||||
|
||||
/**
|
||||
* Read a vanity file and every file it switches on (and those files' switched-on files), each once, in the order the
|
||||
* worlds load them: a file's includes before its own objects. A file in `switches` is loaded or not as its switch
|
||||
* says, wherever it is named, instead of as the <file> entry says (the start file is always read). Names that leave
|
||||
* the folder are skipped.
|
||||
*/
|
||||
Loaded LoadFiles(const std::filesystem::path& folder, const std::string& start, const Switches& switches = {});
|
||||
|
||||
// What LoadWorld needs of an event that is on: its vanity changes
|
||||
struct Changes {
|
||||
std::string name;
|
||||
std::string file; // overlay file, may be empty
|
||||
std::string removals; // NPC names, one per line
|
||||
std::string fileSwitches; // JSON (ParseFileSwitches)
|
||||
};
|
||||
|
||||
// What the worlds spawn, and how it came about
|
||||
struct World {
|
||||
std::vector<VanityXml::Object> objects;
|
||||
std::vector<FileLoad> files; // the vanity files met from the root on, then those switched on alone
|
||||
std::vector<std::string> events; // the events, in merge order
|
||||
size_t fileNpcs{}; // objects from the vanity files, before the overlays
|
||||
std::vector<Conflict> conflicts; // NPCs changed by several events
|
||||
std::vector<FileConflict> fileConflicts;
|
||||
std::vector<std::string> warnings;
|
||||
};
|
||||
|
||||
/**
|
||||
* Everything the worlds load, from one place so the worlds and the dashboard can't disagree. `events` (the ones that
|
||||
* are on, in merge order) switch files first, the last one to switch a file winning; then `root` is read with those
|
||||
* switches, followed by every file switched on that it didn't reach (by name); then each event's overlay (its file,
|
||||
* with the same switches) and removals are laid on (Merge).
|
||||
*/
|
||||
World LoadWorld(const std::filesystem::path& folder, const std::string& root, const std::vector<Changes>& events);
|
||||
|
||||
/**
|
||||
* LoadWorld with events that have id, name, file, removals, fileSwitches and priority, put in merge order first.
|
||||
*/
|
||||
template<typename Event>
|
||||
World LoadWorld(const std::filesystem::path& folder, const std::string& root, std::vector<Event> events) {
|
||||
SortForMerge(events);
|
||||
std::vector<Changes> changes;
|
||||
for (const auto& event : events) changes.push_back({ event.name, event.file, event.removals, event.fileSwitches });
|
||||
return LoadWorld(folder, root, changes);
|
||||
}
|
||||
}
|
||||
126
dCommon/VanityXml.cpp
Normal file
126
dCommon/VanityXml.cpp
Normal file
@@ -0,0 +1,126 @@
|
||||
#include "VanityXml.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <charconv>
|
||||
|
||||
#include "tinyxml2.h"
|
||||
#include "GeneralUtils.h"
|
||||
|
||||
namespace {
|
||||
std::string Number(float value) {
|
||||
char buffer[32];
|
||||
const auto result = std::to_chars(buffer, buffer + sizeof(buffer), value);
|
||||
return std::string(buffer, result.ptr);
|
||||
}
|
||||
|
||||
std::optional<float> Float(const tinyxml2::XMLElement* element, const char* name) {
|
||||
const char* value = element->Attribute(name);
|
||||
return value ? GeneralUtils::TryParse<float>(value) : std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<VanityXml::Document> VanityXml::Read(const std::string& xml, std::string& error) {
|
||||
tinyxml2::XMLDocument doc;
|
||||
if (doc.Parse(xml.c_str(), xml.size()) != tinyxml2::XML_SUCCESS) {
|
||||
error = std::string("Not valid XML: ") + doc.ErrorStr();
|
||||
return std::nullopt;
|
||||
}
|
||||
Document out;
|
||||
if (const auto* files = doc.FirstChildElement("files")) {
|
||||
for (const auto* file = files->FirstChildElement("file"); file; file = file->NextSiblingElement("file")) {
|
||||
const char* name = file->Attribute("name");
|
||||
const char* enabled = file->Attribute("enabled");
|
||||
if (name) out.files.push_back({ name, enabled && std::string(enabled) == "1" });
|
||||
}
|
||||
}
|
||||
if (const auto* objects = doc.FirstChildElement("objects")) {
|
||||
for (const auto* element = objects->FirstChildElement("object"); element; element = element->NextSiblingElement("object")) {
|
||||
Object object;
|
||||
object.name = element->Attribute("name") ? element->Attribute("name") : "";
|
||||
object.lot = GeneralUtils::TryParse<int32_t>(element->Attribute("lot") ? element->Attribute("lot") : "").value_or(-1);
|
||||
const auto who = "\"" + object.name + "\"";
|
||||
if (const auto* equipment = element->FirstChildElement("equipment"); equipment && equipment->GetText()) {
|
||||
for (auto item : GeneralUtils::SplitString(equipment->GetText(), ',')) {
|
||||
// remove spaces for TryParse to work
|
||||
std::erase_if(item, [](char c) { return std::isspace(static_cast<unsigned char>(c)); });
|
||||
if (const auto lot = GeneralUtils::TryParse<uint32_t>(item)) object.equipment.push_back(static_cast<int32_t>(*lot));
|
||||
}
|
||||
}
|
||||
if (const auto* phrases = element->FirstChildElement("phrases")) {
|
||||
for (const auto* phrase = phrases->FirstChildElement("phrase"); phrase; phrase = phrase->NextSiblingElement("phrase")) {
|
||||
if (phrase->GetText()) object.phrases.push_back(phrase->GetText());
|
||||
else out.warnings.push_back(who + ": an empty phrase");
|
||||
}
|
||||
}
|
||||
if (const auto* config = element->FirstChildElement("config")) {
|
||||
for (const auto* key = config->FirstChildElement("key"); key; key = key->NextSiblingElement("key")) {
|
||||
if (key->GetText()) object.config.push_back(key->GetText());
|
||||
}
|
||||
}
|
||||
if (const auto* locations = element->FirstChildElement("locations")) {
|
||||
for (const auto* l = locations->FirstChildElement("location"); l; l = l->NextSiblingElement("location")) {
|
||||
const auto zone = GeneralUtils::TryParse<uint32_t>(l->Attribute("zone") ? l->Attribute("zone") : "");
|
||||
const auto x = Float(l, "x"), y = Float(l, "y"), z = Float(l, "z");
|
||||
const auto rw = Float(l, "rw"), rx = Float(l, "rx"), ry = Float(l, "ry"), rz = Float(l, "rz");
|
||||
if (!zone || !x || !y || !z || !rw || !rx || !ry || !rz) {
|
||||
out.warnings.push_back(who + ": a location without a zone, position or rotation");
|
||||
continue;
|
||||
}
|
||||
object.locations.push_back({ *zone, *x, *y, *z, *rw, *rx, *ry, *rz, Float(l, "chance"), Float(l, "scale") });
|
||||
}
|
||||
} else {
|
||||
out.warnings.push_back(who + ": no locations");
|
||||
}
|
||||
out.objects.push_back(std::move(object));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string VanityXml::Write(const Document& document) {
|
||||
tinyxml2::XMLDocument doc;
|
||||
if (!document.files.empty()) {
|
||||
auto* files = doc.NewElement("files");
|
||||
for (const auto& file : document.files) {
|
||||
auto* element = files->InsertNewChildElement("file");
|
||||
element->SetAttribute("name", file.name.c_str());
|
||||
element->SetAttribute("enabled", file.enabled ? "1" : "0");
|
||||
}
|
||||
doc.InsertEndChild(files);
|
||||
}
|
||||
if (!document.objects.empty() || document.files.empty()) {
|
||||
auto* objects = doc.NewElement("objects");
|
||||
for (const auto& object : document.objects) {
|
||||
auto* element = objects->InsertNewChildElement("object");
|
||||
element->SetAttribute("name", object.name.c_str());
|
||||
element->SetAttribute("lot", object.lot);
|
||||
if (!object.equipment.empty()) {
|
||||
std::string text;
|
||||
for (const auto lot : object.equipment) text += (text.empty() ? "" : ", ") + std::to_string(lot);
|
||||
element->InsertNewChildElement("equipment")->SetText(text.c_str());
|
||||
}
|
||||
if (!object.phrases.empty()) {
|
||||
auto* phrases = element->InsertNewChildElement("phrases");
|
||||
for (const auto& phrase : object.phrases) phrases->InsertNewChildElement("phrase")->SetText(phrase.c_str());
|
||||
}
|
||||
if (!object.config.empty()) {
|
||||
auto* config = element->InsertNewChildElement("config");
|
||||
for (const auto& key : object.config) config->InsertNewChildElement("key")->SetText(key.c_str());
|
||||
}
|
||||
auto* locations = element->InsertNewChildElement("locations");
|
||||
for (const auto& l : object.locations) {
|
||||
auto* location = locations->InsertNewChildElement("location");
|
||||
location->SetAttribute("zone", l.zone);
|
||||
for (const auto& [name, value] : { std::pair{ "x", l.x }, { "y", l.y }, { "z", l.z }, { "rw", l.rw }, { "rx", l.rx }, { "ry", l.ry }, { "rz", l.rz } }) {
|
||||
location->SetAttribute(name, Number(value).c_str());
|
||||
}
|
||||
if (l.chance) location->SetAttribute("chance", Number(*l.chance).c_str());
|
||||
if (l.scale) location->SetAttribute("scale", Number(*l.scale).c_str());
|
||||
}
|
||||
}
|
||||
doc.InsertEndChild(objects);
|
||||
}
|
||||
tinyxml2::XMLPrinter printer;
|
||||
doc.Print(&printer);
|
||||
return printer.CStr();
|
||||
}
|
||||
54
dCommon/VanityXml.h
Normal file
54
dCommon/VanityXml.h
Normal file
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
/**
|
||||
* The vanity NPC files (build/vanity/*.xml), read by the world servers (VanityUtilities) and edited by the dashboard:
|
||||
*
|
||||
* <files><file name="dev-tribute.xml" enabled="1"/></files> (root.xml: which other files load)
|
||||
* <objects><object name="..." lot="2279">
|
||||
* <equipment>6802, 2519</equipment>
|
||||
* <phrases><phrase>...</phrase></phrases>
|
||||
* <config><key>custom_script_client=0:scripts\...</key></config> (LDF: key=type:value)
|
||||
* <locations><location zone="1200" x y z rw rx ry rz [chance] [scale]/></locations>
|
||||
* </object></objects>
|
||||
*
|
||||
* Pure (text in, text out) so it can be unit tested.
|
||||
*/
|
||||
namespace VanityXml {
|
||||
struct Location {
|
||||
uint32_t zone{};
|
||||
float x{}, y{}, z{};
|
||||
float rw{ 1 }, rx{}, ry{}, rz{};
|
||||
std::optional<float> chance; // 0-1: how likely the NPC appears here
|
||||
std::optional<float> scale;
|
||||
};
|
||||
|
||||
struct Object {
|
||||
std::string name;
|
||||
int32_t lot{ -1 }; // LOT_NULL when the file's lot isn't a number
|
||||
std::vector<int32_t> equipment;
|
||||
std::vector<std::string> phrases;
|
||||
std::vector<std::string> config; // LDF lines, as written
|
||||
std::vector<Location> locations; // only the complete ones
|
||||
};
|
||||
|
||||
struct FileEntry {
|
||||
std::string name;
|
||||
bool enabled{};
|
||||
};
|
||||
|
||||
struct Document {
|
||||
std::vector<FileEntry> files; // only root.xml has these, normally
|
||||
std::vector<Object> objects;
|
||||
std::vector<std::string> warnings; // parts that were skipped, e.g. a location without a position
|
||||
};
|
||||
|
||||
// The file's includes and objects, or nullopt and error if it isn't XML
|
||||
std::optional<Document> Read(const std::string& xml, std::string& error);
|
||||
|
||||
std::string Write(const Document& document);
|
||||
}
|
||||
247
dCommon/ZoneFile.cpp
Normal file
247
dCommon/ZoneFile.cpp
Normal file
@@ -0,0 +1,247 @@
|
||||
#include "ZoneFile.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <istream>
|
||||
|
||||
#include "BinaryIO.h"
|
||||
#include "Game.h"
|
||||
#include "Logger.h"
|
||||
|
||||
void ZoneFile::ReadHeader(std::istream& file) {
|
||||
BinaryIO::BinaryRead(file, fileFormatVersion);
|
||||
|
||||
if (fileFormatVersion >= FileFormatVersion::Alpha) BinaryIO::BinaryRead(file, mapRevision);
|
||||
|
||||
BinaryIO::BinaryRead(file, worldID);
|
||||
|
||||
if (fileFormatVersion >= FileFormatVersion::Beta) {
|
||||
BinaryIO::BinaryRead(file, spawnpoint);
|
||||
BinaryIO::BinaryRead(file, spawnpointRotation);
|
||||
}
|
||||
|
||||
uint32_t sceneCount = 0;
|
||||
if (fileFormatVersion <= FileFormatVersion::LateAlpha) {
|
||||
uint8_t count;
|
||||
BinaryIO::BinaryRead(file, count);
|
||||
sceneCount = count;
|
||||
} else BinaryIO::BinaryRead(file, sceneCount);
|
||||
|
||||
for (uint32_t i = 0; i < sceneCount; ++i) {
|
||||
ReadScene(file);
|
||||
}
|
||||
|
||||
//Read generic zone info:
|
||||
BinaryIO::ReadString<uint8_t>(file, zonePath, BinaryIO::ReadType::String);
|
||||
BinaryIO::ReadString<uint8_t>(file, zoneRawPath, BinaryIO::ReadType::String);
|
||||
BinaryIO::ReadString<uint8_t>(file, zoneName, BinaryIO::ReadType::String);
|
||||
BinaryIO::ReadString<uint8_t>(file, zoneDesc, BinaryIO::ReadType::String);
|
||||
}
|
||||
|
||||
void ZoneFile::Read(std::istream& file) {
|
||||
ReadHeader(file);
|
||||
|
||||
if (fileFormatVersion >= FileFormatVersion::PreAlpha) {
|
||||
uint32_t transitionCount = 0;
|
||||
BinaryIO::BinaryRead(file, transitionCount);
|
||||
for (uint32_t i = 0; i < transitionCount; ++i) {
|
||||
ReadSceneTransition(file);
|
||||
}
|
||||
}
|
||||
|
||||
if (fileFormatVersion >= FileFormatVersion::EarlyAlpha) {
|
||||
BinaryIO::BinaryRead(file, pathDataLength);
|
||||
BinaryIO::BinaryRead(file, pathChunkVersion); // always should be 1
|
||||
|
||||
uint32_t pathCount;
|
||||
BinaryIO::BinaryRead(file, pathCount);
|
||||
|
||||
paths.reserve(pathCount);
|
||||
for (uint32_t i = 0; i < pathCount; ++i) ReadPath(file);
|
||||
}
|
||||
}
|
||||
|
||||
void ZoneFile::ReadScene(std::istream& file) {
|
||||
ZoneScene scene;
|
||||
|
||||
BinaryIO::ReadString<uint8_t>(file, scene.filename, BinaryIO::ReadType::String);
|
||||
|
||||
if (fileFormatVersion >= FileFormatVersion::LatePreAlpha || fileFormatVersion < FileFormatVersion::PrePreAlpha) {
|
||||
BinaryIO::BinaryRead(file, scene.id);
|
||||
}
|
||||
if (fileFormatVersion >= FileFormatVersion::LatePreAlpha) {
|
||||
BinaryIO::BinaryRead(file, scene.sceneType);
|
||||
|
||||
BinaryIO::ReadString<uint8_t>(file, scene.name, BinaryIO::ReadType::String);
|
||||
}
|
||||
|
||||
if (fileFormatVersion == FileFormatVersion::LatePreAlpha) {
|
||||
BinaryIO::BinaryRead(file, scene.unknown1);
|
||||
BinaryIO::BinaryRead(file, scene.unknown2);
|
||||
}
|
||||
|
||||
if (fileFormatVersion >= FileFormatVersion::LatePreAlpha) {
|
||||
BinaryIO::BinaryRead(file, scene.color_r);
|
||||
BinaryIO::BinaryRead(file, scene.color_b);
|
||||
BinaryIO::BinaryRead(file, scene.color_g);
|
||||
}
|
||||
|
||||
scenes.push_back(std::move(scene));
|
||||
}
|
||||
|
||||
void ZoneFile::ReadSceneTransition(std::istream& file) {
|
||||
SceneTransition sceneTrans;
|
||||
if (fileFormatVersion < FileFormatVersion::Auramar) {
|
||||
BinaryIO::ReadString<uint8_t>(file, sceneTrans.name, BinaryIO::ReadType::String);
|
||||
BinaryIO::BinaryRead(file, sceneTrans.width);
|
||||
}
|
||||
|
||||
//BROTHER MAY I HAVE SOME LOOPS?
|
||||
uint8_t loops = (fileFormatVersion <= FileFormatVersion::LatePreAlpha || fileFormatVersion >= FileFormatVersion::Launch) ? 2 : 5;
|
||||
|
||||
sceneTrans.points.reserve(loops);
|
||||
for (uint8_t i = 0; i < loops; ++i) {
|
||||
sceneTrans.points.push_back(ReadSceneTransitionInfo(file));
|
||||
}
|
||||
|
||||
sceneTransitions.push_back(sceneTrans);
|
||||
}
|
||||
|
||||
SceneTransitionInfo ZoneFile::ReadSceneTransitionInfo(std::istream& file) {
|
||||
SceneTransitionInfo info;
|
||||
BinaryIO::BinaryRead(file, info.sceneID);
|
||||
BinaryIO::BinaryRead(file, info.position);
|
||||
return info;
|
||||
}
|
||||
|
||||
void ZoneFile::ReadPath(std::istream& file) {
|
||||
Path path = Path();
|
||||
|
||||
BinaryIO::BinaryRead(file, path.pathVersion);
|
||||
|
||||
BinaryIO::ReadString<uint8_t>(file, path.pathName, BinaryIO::ReadType::WideString);
|
||||
|
||||
BinaryIO::BinaryRead(file, path.pathType);
|
||||
BinaryIO::BinaryRead(file, path.flags);
|
||||
BinaryIO::BinaryRead(file, path.pathBehavior);
|
||||
|
||||
if (path.pathType == PathType::MovingPlatform) {
|
||||
if (path.pathVersion >= 18) {
|
||||
BinaryIO::BinaryRead(file, path.movingPlatform.timeBasedMovement);
|
||||
} else if (path.pathVersion >= 13) {
|
||||
BinaryIO::ReadString<uint8_t>(file, path.movingPlatform.platformTravelSound, BinaryIO::ReadType::WideString);
|
||||
}
|
||||
} else if (path.pathType == PathType::Property) {
|
||||
BinaryIO::BinaryRead(file, path.property.pathType);
|
||||
BinaryIO::BinaryRead(file, path.property.price);
|
||||
BinaryIO::BinaryRead(file, path.property.rentalTime);
|
||||
BinaryIO::BinaryRead(file, path.property.associatedZone);
|
||||
|
||||
if (path.pathVersion >= 5) {
|
||||
BinaryIO::ReadString<uint8_t>(file, path.property.displayName, BinaryIO::ReadType::WideString);
|
||||
BinaryIO::ReadString<uint32_t>(file, path.property.displayDesc, BinaryIO::ReadType::WideString);
|
||||
}
|
||||
|
||||
if (path.pathVersion >= 6) BinaryIO::BinaryRead(file, path.property.type);
|
||||
|
||||
if (path.pathVersion >= 7) {
|
||||
BinaryIO::BinaryRead(file, path.property.cloneLimit);
|
||||
BinaryIO::BinaryRead(file, path.property.repMultiplier);
|
||||
BinaryIO::BinaryRead(file, path.property.rentalPeriod);
|
||||
}
|
||||
|
||||
if (path.pathVersion >= 8) {
|
||||
BinaryIO::BinaryRead(file, path.property.achievementRequired);
|
||||
BinaryIO::BinaryRead(file, path.property.playerZoneCoords);
|
||||
BinaryIO::BinaryRead(file, path.property.maxBuildHeight);
|
||||
}
|
||||
} else if (path.pathType == PathType::Camera) {
|
||||
BinaryIO::ReadString<uint8_t>(file, path.camera.nextPath, BinaryIO::ReadType::WideString);
|
||||
if (path.pathVersion >= 14) {
|
||||
BinaryIO::BinaryRead(file, path.camera.rotatePlayer);
|
||||
|
||||
}
|
||||
} else if (path.pathType == PathType::Spawner) {
|
||||
BinaryIO::BinaryRead(file, path.spawner.spawnedLOT);
|
||||
BinaryIO::BinaryRead(file, path.spawner.respawnTime);
|
||||
BinaryIO::BinaryRead(file, path.spawner.maxToSpawn);
|
||||
BinaryIO::BinaryRead(file, path.spawner.amountMaintained);
|
||||
BinaryIO::BinaryRead(file, path.spawner.spawnerObjID);
|
||||
BinaryIO::BinaryRead(file, path.spawner.spawnerNetActive);
|
||||
}
|
||||
|
||||
// Read waypoints
|
||||
|
||||
BinaryIO::BinaryRead(file, path.waypointCount);
|
||||
path.pathWaypoints.reserve(path.waypointCount);
|
||||
for (uint32_t i = 0; i < path.waypointCount; ++i) {
|
||||
PathWaypoint waypoint = PathWaypoint();
|
||||
|
||||
BinaryIO::BinaryRead(file, waypoint.position.x);
|
||||
BinaryIO::BinaryRead(file, waypoint.position.y);
|
||||
BinaryIO::BinaryRead(file, waypoint.position.z);
|
||||
|
||||
|
||||
if (path.pathType == PathType::Spawner || path.pathType == PathType::MovingPlatform || path.pathType == PathType::Race || path.pathType == PathType::Camera || path.pathType == PathType::Rail) {
|
||||
BinaryIO::BinaryRead(file, waypoint.rotation.w);
|
||||
BinaryIO::BinaryRead(file, waypoint.rotation.x);
|
||||
BinaryIO::BinaryRead(file, waypoint.rotation.y);
|
||||
BinaryIO::BinaryRead(file, waypoint.rotation.z);
|
||||
}
|
||||
|
||||
if (path.pathType == PathType::MovingPlatform) {
|
||||
BinaryIO::BinaryRead(file, waypoint.movingPlatform.lockPlayer);
|
||||
BinaryIO::BinaryRead(file, waypoint.speed);
|
||||
BinaryIO::BinaryRead(file, waypoint.movingPlatform.wait);
|
||||
if (path.pathVersion >= 13) {
|
||||
BinaryIO::ReadString<uint8_t>(file, waypoint.movingPlatform.departSound, BinaryIO::ReadType::WideString);
|
||||
BinaryIO::ReadString<uint8_t>(file, waypoint.movingPlatform.arriveSound, BinaryIO::ReadType::WideString);
|
||||
}
|
||||
} else if (path.pathType == PathType::Camera) {
|
||||
BinaryIO::BinaryRead(file, waypoint.camera.time);
|
||||
BinaryIO::BinaryRead(file, waypoint.camera.fov);
|
||||
BinaryIO::BinaryRead(file, waypoint.camera.tension);
|
||||
BinaryIO::BinaryRead(file, waypoint.camera.continuity);
|
||||
BinaryIO::BinaryRead(file, waypoint.camera.bias);
|
||||
} else if (path.pathType == PathType::Race) {
|
||||
BinaryIO::BinaryRead(file, waypoint.racing.isResetNode);
|
||||
BinaryIO::BinaryRead(file, waypoint.racing.isNonHorizontalCamera);
|
||||
BinaryIO::BinaryRead(file, waypoint.racing.planeWidth);
|
||||
BinaryIO::BinaryRead(file, waypoint.racing.planeHeight);
|
||||
BinaryIO::BinaryRead(file, waypoint.racing.shortestDistanceToEnd);
|
||||
} else if (path.pathType == PathType::Rail) {
|
||||
if (path.pathVersion > 16) BinaryIO::BinaryRead(file, waypoint.speed);
|
||||
}
|
||||
|
||||
// object LDF configs
|
||||
if (path.pathType == PathType::Movement || path.pathType == PathType::Spawner || path.pathType == PathType::Rail) {
|
||||
uint32_t count;
|
||||
BinaryIO::BinaryRead(file, count);
|
||||
for (uint32_t i = 0; i < count; ++i) {
|
||||
std::string parameter;
|
||||
BinaryIO::ReadString<uint8_t>(file, parameter, BinaryIO::ReadType::WideString);
|
||||
|
||||
std::string value;
|
||||
BinaryIO::ReadString<uint8_t>(file, value, BinaryIO::ReadType::WideString);
|
||||
|
||||
if (path.pathType == PathType::Movement || path.pathType == PathType::Rail) {
|
||||
// cause NetDevil puts spaces in things that don't need spaces
|
||||
parameter.erase(std::remove_if(parameter.begin(), parameter.end(), ::isspace), parameter.end());
|
||||
auto waypointCommand = WaypointCommandType::StringToWaypointCommandType(parameter);
|
||||
if (waypointCommand == eWaypointCommandType::DELAY) value.erase(std::remove_if(value.begin(), value.end(), ::isspace), value.end());
|
||||
if (waypointCommand != eWaypointCommandType::INVALID) {
|
||||
auto& command = waypoint.commands.emplace_back();
|
||||
command.command = waypointCommand;
|
||||
command.data = value;
|
||||
} else LOG("Tried to load invalid waypoint command '%s'", parameter.c_str());
|
||||
} else {
|
||||
waypoint.config.ParseInsert(parameter + "=" + value);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
path.pathWaypoints.push_back(waypoint);
|
||||
}
|
||||
paths.push_back(path);
|
||||
}
|
||||
240
dCommon/ZoneFile.h
Normal file
240
dCommon/ZoneFile.h
Normal file
@@ -0,0 +1,240 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <iosfwd>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "dCommonVars.h"
|
||||
#include "NiPoint3.h"
|
||||
#include "NiQuaternion.h"
|
||||
#include "LDFFormat.h"
|
||||
#include "eWaypointCommandType.h"
|
||||
|
||||
/**
|
||||
* A zone file (.luz) as the world server loads it (Zone::LoadZoneIntoMemory): its scenes, scene transitions and paths.
|
||||
* Only reads; what the world does with the result (spawners, triggers, navmesh heights) stays in dZoneManager, so the
|
||||
* dashboard reads zones with the same code.
|
||||
*/
|
||||
|
||||
struct WaypointCommand {
|
||||
eWaypointCommandType command{};
|
||||
std::string data;
|
||||
};
|
||||
|
||||
struct ZoneScene {
|
||||
std::string filename;
|
||||
uint32_t id{};
|
||||
uint32_t sceneType{}; //0 = general, 1 = audio?
|
||||
std::string name;
|
||||
NiPoint3 unknown1;
|
||||
float unknown2{};
|
||||
uint8_t color_r{};
|
||||
uint8_t color_g{};
|
||||
uint8_t color_b{};
|
||||
};
|
||||
|
||||
struct SceneTransitionInfo {
|
||||
uint64_t sceneID{}; //id of the scene being transitioned to.
|
||||
NiPoint3 position;
|
||||
};
|
||||
|
||||
struct SceneTransition {
|
||||
std::string name;
|
||||
std::vector<SceneTransitionInfo> points;
|
||||
float width{};
|
||||
};
|
||||
|
||||
struct MovingPlatformPathWaypoint {
|
||||
uint8_t lockPlayer{};
|
||||
float wait{};
|
||||
std::string departSound;
|
||||
std::string arriveSound;
|
||||
};
|
||||
|
||||
struct CameraPathWaypoint {
|
||||
float time{};
|
||||
float fov{};
|
||||
float tension{};
|
||||
float continuity{};
|
||||
float bias{};
|
||||
};
|
||||
|
||||
struct RacingPathWaypoint {
|
||||
uint8_t isResetNode{};
|
||||
uint8_t isNonHorizontalCamera{};
|
||||
float planeWidth{};
|
||||
float planeHeight{};
|
||||
float shortestDistanceToEnd{};
|
||||
};
|
||||
|
||||
struct PathWaypoint {
|
||||
NiPoint3 position;
|
||||
NiQuaternion rotation = QuatUtils::IDENTITY; // not included in all, but it's more convenient here
|
||||
MovingPlatformPathWaypoint movingPlatform;
|
||||
CameraPathWaypoint camera;
|
||||
RacingPathWaypoint racing;
|
||||
float speed{};
|
||||
LwoNameValue config;
|
||||
std::vector<WaypointCommand> commands;
|
||||
};
|
||||
|
||||
enum class PathType : uint32_t {
|
||||
Movement = 0,
|
||||
MovingPlatform = 1,
|
||||
Property = 2,
|
||||
Camera = 3,
|
||||
Spawner = 4,
|
||||
Showcase = 5,
|
||||
Race = 6,
|
||||
Rail = 7
|
||||
};
|
||||
|
||||
enum class PathBehavior : uint32_t {
|
||||
Loop = 0,
|
||||
Bounce = 1,
|
||||
Once = 2
|
||||
};
|
||||
|
||||
enum class PropertyPathType : int32_t {
|
||||
Path = 0,
|
||||
EntireZone = 1,
|
||||
GenetatedRectangle = 2
|
||||
};
|
||||
|
||||
enum class PropertyType : int32_t {
|
||||
Premiere = 0,
|
||||
Prize = 1,
|
||||
LUP = 2,
|
||||
Headspace = 3
|
||||
};
|
||||
|
||||
enum class PropertyRentalPeriod : uint32_t {
|
||||
Forever = 0,
|
||||
Seconds = 1,
|
||||
Minutes = 2,
|
||||
Hours = 3,
|
||||
Days = 4,
|
||||
Weeks = 5,
|
||||
Months = 6,
|
||||
Years = 7
|
||||
};
|
||||
|
||||
enum class PropertyAchievmentRequired : uint32_t {
|
||||
None = 0,
|
||||
Builder = 1,
|
||||
Craftsman = 2,
|
||||
SeniorBuilder = 3,
|
||||
JourneyMan = 4,
|
||||
MasterBuilder = 5,
|
||||
Architect = 6,
|
||||
SeniorArchitect = 7,
|
||||
MasterArchitect = 8,
|
||||
Visionary = 9,
|
||||
Exemplar = 10
|
||||
};
|
||||
|
||||
struct MovingPlatformPath {
|
||||
std::string platformTravelSound;
|
||||
uint8_t timeBasedMovement{};
|
||||
};
|
||||
|
||||
struct PropertyPath {
|
||||
PropertyPathType pathType{};
|
||||
int32_t price{};
|
||||
uint32_t rentalTime{};
|
||||
uint64_t associatedZone{};
|
||||
std::string displayName;
|
||||
std::string displayDesc;
|
||||
PropertyType type{};
|
||||
uint32_t cloneLimit{};
|
||||
float repMultiplier{};
|
||||
PropertyRentalPeriod rentalPeriod{};
|
||||
PropertyAchievmentRequired achievementRequired{};
|
||||
|
||||
// Player respawn coordinates in the main zone (not the property zone)
|
||||
NiPoint3 playerZoneCoords;
|
||||
float maxBuildHeight{};
|
||||
};
|
||||
|
||||
struct CameraPath {
|
||||
std::string nextPath;
|
||||
uint8_t rotatePlayer{};
|
||||
};
|
||||
|
||||
struct SpawnerPath {
|
||||
LOT spawnedLOT{};
|
||||
uint32_t respawnTime{};
|
||||
int32_t maxToSpawn{};
|
||||
uint32_t amountMaintained{};
|
||||
LWOOBJID spawnerObjID;
|
||||
uint8_t spawnerNetActive{};
|
||||
};
|
||||
|
||||
|
||||
struct Path {
|
||||
uint32_t pathVersion{};
|
||||
PathType pathType;
|
||||
std::string pathName;
|
||||
uint32_t flags{};
|
||||
PathBehavior pathBehavior;
|
||||
uint32_t waypointCount{};
|
||||
std::vector<PathWaypoint> pathWaypoints;
|
||||
SpawnerPath spawner;
|
||||
MovingPlatformPath movingPlatform;
|
||||
PropertyPath property;
|
||||
CameraPath camera;
|
||||
};
|
||||
|
||||
struct ZoneFile {
|
||||
enum class FileFormatVersion : uint32_t { //Times are guessed.
|
||||
PrePreAlpha = 30,
|
||||
PreAlpha = 32,
|
||||
LatePreAlpha = 33,
|
||||
EarlyAlpha = 35,
|
||||
Alpha = 36,
|
||||
LateAlpha = 37,
|
||||
Beta = 38,
|
||||
Launch = 39,
|
||||
Auramar = 40,
|
||||
Latest = 41
|
||||
};
|
||||
|
||||
FileFormatVersion fileFormatVersion{};
|
||||
uint32_t mapRevision{};
|
||||
uint32_t worldID{}; //should be equal to the MapID
|
||||
NiPoint3 spawnpoint;
|
||||
NiQuaternion spawnpointRotation = QuatUtils::IDENTITY;
|
||||
|
||||
std::vector<ZoneScene> scenes;
|
||||
|
||||
std::string zonePath; //Path to the .luz's folder as written in the file
|
||||
std::string zoneRawPath; //Path to the .raw file of this zone.
|
||||
std::string zoneName; //Name given to the zone by a level designer
|
||||
std::string zoneDesc; //Description of the zone by a level designer
|
||||
|
||||
std::vector<SceneTransition> sceneTransitions;
|
||||
|
||||
uint32_t pathDataLength{};
|
||||
uint32_t pathChunkVersion{};
|
||||
std::vector<Path> paths;
|
||||
|
||||
/**
|
||||
* Reads the whole file. Throws std::runtime_error (BinaryIO) if it ends early; the last value read may still come up
|
||||
* short without a throw, so callers that must know check the stream afterwards.
|
||||
*/
|
||||
void Read(std::istream& file);
|
||||
|
||||
/**
|
||||
* Reads only the start of the file: the version, spawn point, scenes and the terrain file's name, leaving the scene
|
||||
* transitions and paths (most of the file in big zones) unread. Throws as Read does.
|
||||
*/
|
||||
void ReadHeader(std::istream& file);
|
||||
|
||||
private:
|
||||
void ReadScene(std::istream& file);
|
||||
void ReadSceneTransition(std::istream& file);
|
||||
SceneTransitionInfo ReadSceneTransitionInfo(std::istream& file);
|
||||
void ReadPath(std::istream& file);
|
||||
};
|
||||
166
dCommon/dClient/SceneColor.h
Normal file
166
dCommon/dClient/SceneColor.h
Normal file
@@ -0,0 +1,166 @@
|
||||
#ifndef SCENE_COLOR_H
|
||||
#define SCENE_COLOR_H
|
||||
|
||||
#include "NiColor.h"
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
|
||||
namespace SceneColor {
|
||||
// these are not random values, they are the actual template colors used by the game
|
||||
static constexpr std::array<NiColor, 146> TEMPLATE_COLORS = {{
|
||||
{ 0.5019608f, 0.5019608f, 0.5019608f },
|
||||
{ 1.0f, 0.0f, 0.0f },
|
||||
{ 0.0f, 1.0f, 0.0f },
|
||||
{ 0.0f, 0.0f, 1.0f },
|
||||
{ 1.0f, 1.0f, 0.0f },
|
||||
{ 1.0f, 0.0f, 1.0f },
|
||||
{ 0.0f, 1.0f, 1.0f },
|
||||
{ 0.5019608f, 0.0f, 1.0f },
|
||||
{ 1.0f, 0.5019608f, 0.0f },
|
||||
{ 1.0f, 0.5019608f, 0.5019608f },
|
||||
{ 0.5019608f, 0.2509804f, 0.0f },
|
||||
{ 0.5019608f, 0.0f, 0.2509804f },
|
||||
{ 0.0f, 0.5019608f, 0.2509804f },
|
||||
{ 0.2509804f, 0.0f, 0.5019608f },
|
||||
{ 0.8745098f, 0.0f, 0.2509804f },
|
||||
{ 0.2509804f, 0.8745098f, 0.5019608f },
|
||||
{ 1.0f, 0.7490196f, 0.0f },
|
||||
{ 1.0f, 0.2509804f, 0.0627451f },
|
||||
{ 0.2509804f, 0.0f, 0.8745098f },
|
||||
{ 0.7490196f, 0.0627451f, 0.0627451f },
|
||||
{ 0.0627451f, 0.7490196f, 0.0627451f },
|
||||
{ 1.0f, 0.5019608f, 1.0f },
|
||||
{ 0.9372549f, 0.8705882f, 0.8039216f },
|
||||
{ 0.8039216f, 0.5843138f, 0.4588235f },
|
||||
{ 0.9921569f, 0.8509804f, 0.7098039f },
|
||||
{ 0.4705882f, 0.8588235f, 0.8862745f },
|
||||
{ 0.5294118f, 0.6627451f, 0.4196078f },
|
||||
{ 1.0f, 0.6431373f, 0.454902f },
|
||||
{ 0.9803922f, 0.9058824f, 0.7098039f },
|
||||
{ 0.6235294f, 0.5058824f, 0.4392157f },
|
||||
{ 0.9921569f, 0.4862745f, 0.4313726f },
|
||||
{ 0.0f, 0.0f, 0.0f },
|
||||
{ 0.6745098f, 0.8980392f, 0.9333333f },
|
||||
{ 0.1215686f, 0.4588235f, 0.9960784f },
|
||||
{ 0.6352941f, 0.6352941f, 0.8156863f },
|
||||
{ 0.4f, 0.6f, 0.8f },
|
||||
{ 0.05098039f, 0.5960785f, 0.7294118f },
|
||||
{ 0.4509804f, 0.4f, 0.7411765f },
|
||||
{ 0.8705882f, 0.3647059f, 0.5137255f },
|
||||
{ 0.7960784f, 0.254902f, 0.3294118f },
|
||||
{ 0.7058824f, 0.4039216f, 0.3019608f },
|
||||
{ 1.0f, 0.4980392f, 0.2862745f },
|
||||
{ 0.9176471f, 0.4941176f, 0.3647059f },
|
||||
{ 0.6901961f, 0.7176471f, 0.7764706f },
|
||||
{ 1.0f, 1.0f, 0.6f },
|
||||
{ 0.1098039f, 0.827451f, 0.6352941f },
|
||||
{ 1.0f, 0.6666667f, 0.8f },
|
||||
{ 0.8666667f, 0.2666667f, 0.572549f },
|
||||
{ 0.1137255f, 0.6745098f, 0.8392157f },
|
||||
{ 0.7372549f, 0.3647059f, 0.345098f },
|
||||
{ 0.8666667f, 0.5803922f, 0.4588235f },
|
||||
{ 0.6039216f, 0.8078431f, 0.9215686f },
|
||||
{ 1.0f, 0.7372549f, 0.8509804f },
|
||||
{ 0.9921569f, 0.8588235f, 0.427451f },
|
||||
{ 0.1686275f, 0.4235294f, 0.7686275f },
|
||||
{ 0.9372549f, 0.8039216f, 0.7215686f },
|
||||
{ 0.4313726f, 0.3176471f, 0.3764706f },
|
||||
{ 0.8078431f, 1.0f, 0.1137255f },
|
||||
{ 0.427451f, 0.682353f, 0.5058824f },
|
||||
{ 0.7647059f, 0.3921569f, 0.772549f },
|
||||
{ 0.8f, 0.4f, 0.4f },
|
||||
{ 0.9058824f, 0.7764706f, 0.5921569f },
|
||||
{ 0.9882353f, 0.8509804f, 0.4588235f },
|
||||
{ 0.6588235f, 0.8941177f, 0.627451f },
|
||||
{ 0.5843138f, 0.5686275f, 0.5490196f },
|
||||
{ 0.1098039f, 0.6745098f, 0.4705882f },
|
||||
{ 0.06666667f, 0.3921569f, 0.7058824f },
|
||||
{ 0.9411765f, 0.9098039f, 0.5686275f },
|
||||
{ 1.0f, 0.1137255f, 0.8078431f },
|
||||
{ 0.6980392f, 0.9254902f, 0.3647059f },
|
||||
{ 0.3647059f, 0.4627451f, 0.7960784f },
|
||||
{ 0.7921569f, 0.2156863f, 0.4039216f },
|
||||
{ 0.2313726f, 0.6901961f, 0.5607843f },
|
||||
{ 0.9882353f, 0.7058824f, 0.8352941f },
|
||||
{ 1.0f, 0.9568627f, 0.3098039f },
|
||||
{ 1.0f, 0.7411765f, 0.5333334f },
|
||||
{ 0.9647059f, 0.3921569f, 0.6862745f },
|
||||
{ 0.6666667f, 0.9411765f, 0.8196079f },
|
||||
{ 0.8039216f, 0.2901961f, 0.2980392f },
|
||||
{ 0.9294118f, 0.8196079f, 0.6117647f },
|
||||
{ 0.5921569f, 0.6039216f, 0.6666667f },
|
||||
{ 0.7843137f, 0.2196078f, 0.3529412f },
|
||||
{ 0.9372549f, 0.5960785f, 0.6666667f },
|
||||
{ 0.9921569f, 0.7372549f, 0.7058824f },
|
||||
{ 0.1019608f, 0.282353f, 0.4627451f },
|
||||
{ 0.1882353f, 0.7294118f, 0.5607843f },
|
||||
{ 0.772549f, 0.2941177f, 0.5490196f },
|
||||
{ 0.09803922f, 0.454902f, 0.8235294f },
|
||||
{ 0.7294118f, 0.7215686f, 0.4235294f },
|
||||
{ 1.0f, 0.4588235f, 0.2196078f },
|
||||
{ 1.0f, 0.1686275f, 0.1686275f },
|
||||
{ 0.972549f, 0.8352941f, 0.4078431f },
|
||||
{ 0.9019608f, 0.6588235f, 0.8431373f },
|
||||
{ 0.254902f, 0.2901961f, 0.2980392f },
|
||||
{ 1.0f, 0.4313726f, 0.2901961f },
|
||||
{ 0.1098039f, 0.6627451f, 0.7882353f },
|
||||
{ 1.0f, 0.8117647f, 0.6705883f },
|
||||
{ 0.772549f, 0.8156863f, 0.9019608f },
|
||||
{ 0.9921569f, 0.8666667f, 0.9019608f },
|
||||
{ 0.08235294f, 0.5019608f, 0.4705882f },
|
||||
{ 0.9882353f, 0.454902f, 0.9921569f },
|
||||
{ 0.9686275f, 0.5607843f, 0.654902f },
|
||||
{ 0.5568628f, 0.2705882f, 0.5215687f },
|
||||
{ 0.454902f, 0.2588235f, 0.7843137f },
|
||||
{ 0.6156863f, 0.5058824f, 0.7294118f },
|
||||
{ 1.0f, 0.2862745f, 0.4235294f },
|
||||
{ 0.8392157f, 0.5411765f, 0.3490196f },
|
||||
{ 0.4431373f, 0.2941177f, 0.1372549f },
|
||||
{ 1.0f, 0.282353f, 0.8156863f },
|
||||
{ 0.9333333f, 0.1254902f, 0.3019608f },
|
||||
{ 1.0f, 0.3254902f, 0.2862745f },
|
||||
{ 0.7529412f, 0.2666667f, 0.5607843f },
|
||||
{ 0.1215686f, 0.8078431f, 0.7960784f },
|
||||
{ 0.4705882f, 0.3176471f, 0.6627451f },
|
||||
{ 1.0f, 0.6078432f, 0.6666667f },
|
||||
{ 0.9882353f, 0.1568628f, 0.2784314f },
|
||||
{ 0.4627451f, 1.0f, 0.4784314f },
|
||||
{ 0.6235294f, 0.8862745f, 0.7490196f },
|
||||
{ 0.6470588f, 0.4117647f, 0.3098039f },
|
||||
{ 0.5411765f, 0.4745098f, 0.3647059f },
|
||||
{ 0.2705882f, 0.8078431f, 0.6352941f },
|
||||
{ 0.8039216f, 0.772549f, 0.7607843f },
|
||||
{ 0.5019608f, 0.854902f, 0.9215686f },
|
||||
{ 0.9254902f, 0.9176471f, 0.7450981f },
|
||||
{ 1.0f, 0.8117647f, 0.282353f },
|
||||
{ 0.9921569f, 0.3686275f, 0.3254902f },
|
||||
{ 0.9803922f, 0.654902f, 0.4235294f },
|
||||
{ 0.09411765f, 0.654902f, 0.7098039f },
|
||||
{ 0.9215686f, 0.7803922f, 0.8745098f },
|
||||
{ 0.9882353f, 0.5372549f, 0.6745098f },
|
||||
{ 0.8588235f, 0.8431373f, 0.8235294f },
|
||||
{ 0.8705882f, 0.6666667f, 0.5333334f },
|
||||
{ 0.4666667f, 0.8666667f, 0.9058824f },
|
||||
{ 1.0f, 1.0f, 0.4f },
|
||||
{ 0.572549f, 0.4313726f, 0.682353f },
|
||||
{ 0.1960784f, 0.2901961f, 0.6980392f },
|
||||
{ 0.9686275f, 0.3254902f, 0.5803922f },
|
||||
{ 1.0f, 0.627451f, 0.5372549f },
|
||||
{ 0.5607843f, 0.3137255f, 0.6156863f },
|
||||
{ 1.0f, 1.0f, 1.0f },
|
||||
{ 0.6352941f, 0.6784314f, 0.8156863f },
|
||||
{ 0.9882353f, 0.4235294f, 0.5215687f },
|
||||
{ 0.8039216f, 0.6431373f, 0.8705882f },
|
||||
{ 0.9882353f, 0.9098039f, 0.5137255f },
|
||||
{ 0.772549f, 0.8901961f, 0.5176471f },
|
||||
{ 1.0f, 0.682353f, 0.2588235f },
|
||||
}};
|
||||
|
||||
static constexpr NiColor FALLBACK_COLOR{ 1.0f, 1.0f, 1.0f };
|
||||
|
||||
inline const NiColor& Get(uint8_t index) {
|
||||
return (index < TEMPLATE_COLORS.size()) ? TEMPLATE_COLORS[index] : FALLBACK_COLOR;
|
||||
}
|
||||
} // namespace SceneColor
|
||||
|
||||
#endif // SCENE_COLOR_H
|
||||
@@ -24,40 +24,69 @@ const bool dConfig::Exists(const std::string& filepath) {
|
||||
return std::filesystem::exists(config_dir / filepath);
|
||||
}
|
||||
|
||||
void dConfig::LoadConfig() {
|
||||
std::filesystem::path config_dir = GetConfigDir();
|
||||
|
||||
std::ifstream in(config_dir / m_ConfigFilePath);
|
||||
void dConfig::LoadFile(const std::string& file) {
|
||||
std::ifstream in(GetConfigDir() / file);
|
||||
if (!in.good()) return;
|
||||
|
||||
std::string line{};
|
||||
// Comment lines directly above a key describe it (the dashboard shows them)
|
||||
std::string line{}, description{};
|
||||
while (std::getline(in, line)) {
|
||||
if (!line.empty() && line.front() != '#') ProcessLine(line);
|
||||
if (!line.empty() && line.back() == '\r') line.pop_back();
|
||||
if (line.empty()) {
|
||||
description.clear();
|
||||
} else if (line.front() == '#') {
|
||||
auto text = line.substr(1);
|
||||
if (!text.empty() && text.front() == ' ') text.erase(0, 1);
|
||||
description += (description.empty() ? "" : " ") + text;
|
||||
} else {
|
||||
ProcessLine(line, file, description);
|
||||
description.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::ifstream sharedConfig(config_dir / "sharedconfig.ini", std::ios::in);
|
||||
if (!sharedConfig.good()) return;
|
||||
|
||||
line.clear();
|
||||
while (std::getline(sharedConfig, line)) {
|
||||
if (!line.empty() && line.front() != '#') ProcessLine(line);
|
||||
}
|
||||
void dConfig::LoadConfig() {
|
||||
LoadFile(m_ConfigFilePath);
|
||||
LoadFile("sharedconfig.ini");
|
||||
}
|
||||
|
||||
void dConfig::ReloadConfig() {
|
||||
this->m_ConfigValues.clear();
|
||||
this->m_FileEntries.clear();
|
||||
this->m_FileOrder.clear();
|
||||
this->m_EnvValues.clear();
|
||||
LoadConfig();
|
||||
if (m_DatabaseSync) m_DatabaseSync(*this);
|
||||
for (const auto& handler : m_ConfigHandlers) handler();
|
||||
LogSettings();
|
||||
}
|
||||
|
||||
const std::string& dConfig::GetValue(std::string key) {
|
||||
if (const auto it = m_DatabaseOverrides.find(key); it != m_DatabaseOverrides.end()) return it->second;
|
||||
if (const auto env = GetEnvironmentValue(key)) return m_EnvValues[key] = *env;
|
||||
if (const auto it = m_ConfigValues.find(key); it != m_ConfigValues.end()) return it->second;
|
||||
if (const auto it = m_DatabaseFallbacks.find(key); it != m_DatabaseFallbacks.end()) return it->second;
|
||||
return this->m_ConfigValues[key];
|
||||
}
|
||||
|
||||
std::optional<std::string> dConfig::GetEnvironmentValue(const std::string& key) {
|
||||
std::string upper_key(key);
|
||||
std::transform(upper_key.begin(), upper_key.end(), upper_key.begin(), ::toupper);
|
||||
if (const char* env_p = std::getenv(upper_key.c_str())) {
|
||||
this->m_ConfigValues[key] = env_p;
|
||||
}
|
||||
return this->m_ConfigValues[key];
|
||||
if (const char* env_p = std::getenv(upper_key.c_str())) return std::string(env_p);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
bool dConfig::IsFileOnlyKey(const std::string& key) {
|
||||
// Programs the servers run and folders they read or serve files from: set on the dashboard, these would give the
|
||||
// settings permission shell or file access on the machine
|
||||
return key.starts_with("mysql_") || key.starts_with("sqlite_") || key == "database_type" ||
|
||||
key == "jwt_secret" || key == "totp_key" ||
|
||||
key == "backup_mysqldump" || key == "backup_folder" || key == "client_location" || key == "dump_folder";
|
||||
}
|
||||
|
||||
bool dConfig::IsSecretKey(const std::string& key) {
|
||||
return key.find("password") != std::string::npos || key.find("secret") != std::string::npos ||
|
||||
key.find("token") != std::string::npos || key.ends_with("_key");
|
||||
}
|
||||
|
||||
void dConfig::AddConfigHandler(std::function<void()> handler) {
|
||||
@@ -67,13 +96,38 @@ void dConfig::AddConfigHandler(std::function<void()> handler) {
|
||||
void dConfig::LogSettings() const {
|
||||
LOG("Configuration settings:");
|
||||
for (const auto& [key, value] : m_ConfigValues) {
|
||||
const auto& valueLog = key.find("password") != std::string::npos ? "<HIDDEN>" : value;
|
||||
LOG(" %s = %s", key.c_str(), valueLog.c_str());
|
||||
const auto override = m_DatabaseOverrides.find(key);
|
||||
const auto& shown = override != m_DatabaseOverrides.end() ? override->second : value;
|
||||
const auto& valueLog = IsSecretKey(key) ? "<HIDDEN>" : shown;
|
||||
LOG(" %s = %s%s", key.c_str(), valueLog.c_str(), override != m_DatabaseOverrides.end() ? " (from the dashboard)" : "");
|
||||
}
|
||||
for (const auto& [key, value] : m_DatabaseFallbacks) {
|
||||
if (m_ConfigValues.contains(key)) continue;
|
||||
LOG(" %s = %s (from the dashboard)", key.c_str(), IsSecretKey(key) ? "<HIDDEN>" : value.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
void dConfig::ProcessLine(const std::string& line) {
|
||||
std::vector<dConfig::FileEntry> dConfig::GetFileEntries() const {
|
||||
std::vector<FileEntry> entries;
|
||||
for (const auto& key : m_FileOrder) entries.push_back(m_FileEntries.at(key));
|
||||
return entries;
|
||||
}
|
||||
|
||||
void dConfig::SetDatabaseValues(std::map<std::string, std::string> overrides, std::map<std::string, std::string> fallbacks) {
|
||||
std::erase_if(overrides, [](const auto& entry) { return IsFileOnlyKey(entry.first); });
|
||||
std::erase_if(fallbacks, [](const auto& entry) { return IsFileOnlyKey(entry.first); });
|
||||
m_DatabaseOverrides = std::move(overrides);
|
||||
m_DatabaseFallbacks = std::move(fallbacks);
|
||||
}
|
||||
|
||||
void dConfig::SetDatabaseSync(std::function<void(dConfig&)> sync) {
|
||||
m_DatabaseSync = std::move(sync);
|
||||
if (m_DatabaseSync) m_DatabaseSync(*this);
|
||||
}
|
||||
|
||||
void dConfig::ProcessLine(const std::string& line, const std::string& file, const std::string& description) {
|
||||
auto splitLoc = line.find('=');
|
||||
if (splitLoc == std::string::npos) return;
|
||||
auto key = line.substr(0, splitLoc);
|
||||
auto value = line.substr(splitLoc + 1);
|
||||
|
||||
@@ -83,6 +137,8 @@ void dConfig::ProcessLine(const std::string& line) {
|
||||
if (this->m_ConfigValues.find(key) != this->m_ConfigValues.end()) return;
|
||||
|
||||
this->m_ConfigValues.insert(std::make_pair(key, value));
|
||||
m_FileEntries[key] = { key, value, file, description };
|
||||
m_FileOrder.push_back(key);
|
||||
}
|
||||
|
||||
std::string dConfig::GetValue(const std::string& key, const char* emptyValue) {
|
||||
|
||||
@@ -3,10 +3,21 @@
|
||||
#include <fstream>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "GeneralUtils.h"
|
||||
|
||||
/**
|
||||
* Server settings. Values come from, in order of priority:
|
||||
* 1. the database, for settings marked "web value wins" on the dashboard
|
||||
* 2. an environment variable with the key's name in upper case
|
||||
* 3. the server's own config file, then sharedconfig.ini
|
||||
* 4. the database, for settings only set on the dashboard
|
||||
* Database values are layered in by a sync function set with SetDatabaseSync once the database is connected.
|
||||
* Connection settings (and a few security keys) only ever come from files or the environment.
|
||||
*/
|
||||
class dConfig {
|
||||
public:
|
||||
dConfig(const std::string& filepath);
|
||||
@@ -38,7 +49,7 @@ public:
|
||||
void LoadConfig();
|
||||
|
||||
/**
|
||||
* Reloads the config file to reset values
|
||||
* Reloads the config file (and database values) to reset values
|
||||
*/
|
||||
void ReloadConfig();
|
||||
|
||||
@@ -46,11 +57,51 @@ public:
|
||||
void AddConfigHandler(std::function<void()> handler);
|
||||
void LogSettings() const;
|
||||
|
||||
// ---- Database layer ----
|
||||
|
||||
struct FileEntry {
|
||||
std::string key;
|
||||
std::string value;
|
||||
std::string file; // the file it came from
|
||||
std::string description; // the comment lines just above it
|
||||
};
|
||||
|
||||
// This server's own config file name, e.g. "worldconfig.ini"
|
||||
const std::string& GetFileName() const { return m_ConfigFilePath; }
|
||||
|
||||
// Every key read from the files, in the order read
|
||||
std::vector<FileEntry> GetFileEntries() const;
|
||||
|
||||
// The environment variable for a key, if set
|
||||
static std::optional<std::string> GetEnvironmentValue(const std::string& key);
|
||||
|
||||
// Settings that must never come from the database (connection details, keys that protect the database itself)
|
||||
static bool IsFileOnlyKey(const std::string& key);
|
||||
|
||||
// Settings whose values are secret: never copied from files into the database, never shown on the dashboard
|
||||
static bool IsSecretKey(const std::string& key);
|
||||
|
||||
/**
|
||||
* Values from the database: overrides beat files and the environment ("web value wins"),
|
||||
* fallbacks are only used when neither sets the key.
|
||||
*/
|
||||
void SetDatabaseValues(std::map<std::string, std::string> overrides, std::map<std::string, std::string> fallbacks);
|
||||
|
||||
// Run the database sync now and again after every reload
|
||||
void SetDatabaseSync(std::function<void(dConfig&)> sync);
|
||||
|
||||
private:
|
||||
void ProcessLine(const std::string& line);
|
||||
void ProcessLine(const std::string& line, const std::string& file, const std::string& description);
|
||||
void LoadFile(const std::string& file);
|
||||
|
||||
std::map<std::string, std::string> m_ConfigValues;
|
||||
std::map<std::string, FileEntry> m_FileEntries;
|
||||
std::vector<std::string> m_FileOrder;
|
||||
std::map<std::string, std::string> m_EnvValues;
|
||||
std::map<std::string, std::string> m_DatabaseOverrides;
|
||||
std::map<std::string, std::string> m_DatabaseFallbacks;
|
||||
std::vector<std::function<void()>> m_ConfigHandlers;
|
||||
std::function<void(dConfig&)> m_DatabaseSync;
|
||||
std::string m_ConfigFilePath;
|
||||
};
|
||||
|
||||
|
||||
17
dCommon/dEnums/BehaviorStates.h
Normal file
17
dCommon/dEnums/BehaviorStates.h
Normal file
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
|
||||
#ifndef __BEHAVIORSTATES__H__
|
||||
#define __BEHAVIORSTATES__H__
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
enum class BehaviorState : uint32_t {
|
||||
HOME_STATE = 0, //!< The HOME behavior state
|
||||
CIRCLE_STATE, //!< The CIRCLE behavior state
|
||||
SQUARE_STATE, //!< The SQUARE behavior state
|
||||
DIAMOND_STATE, //!< The DIAMOND behavior state
|
||||
TRIANGLE_STATE, //!< The TRIANGLE behavior state
|
||||
STAR_STATE //!< The STAR behavior state
|
||||
};
|
||||
|
||||
#endif //!__BEHAVIORSTATES__H__
|
||||
70
dCommon/dEnums/BehaviorTemplate.h
Normal file
70
dCommon/dEnums/BehaviorTemplate.h
Normal file
@@ -0,0 +1,70 @@
|
||||
#pragma once
|
||||
|
||||
enum class BehaviorTemplate : unsigned int {
|
||||
EMPTY, // Not a real behavior, indicates invalid behaviors
|
||||
BASIC_ATTACK,
|
||||
TAC_ARC,
|
||||
AND,
|
||||
PROJECTILE_ATTACK,
|
||||
HEAL,
|
||||
MOVEMENT_SWITCH,
|
||||
AREA_OF_EFFECT,
|
||||
PLAY_EFFECT,
|
||||
IMMUNITY,
|
||||
DAMAGE_BUFF,
|
||||
DAMAGE_ABSORBTION,
|
||||
OVER_TIME,
|
||||
IMAGINATION,
|
||||
TARGET_CASTER,
|
||||
STUN,
|
||||
DURATION,
|
||||
KNOCKBACK,
|
||||
ATTACK_DELAY,
|
||||
CAR_BOOST,
|
||||
FALL_SPEED,
|
||||
SHIELD,
|
||||
REPAIR_ARMOR,
|
||||
SPEED,
|
||||
DARK_INSPIRATION,
|
||||
LOOT_BUFF,
|
||||
VENTURE_VISION,
|
||||
SPAWN_OBJECT,
|
||||
LAY_BRICK,
|
||||
SWITCH,
|
||||
BUFF,
|
||||
JETPACK,
|
||||
SKILL_EVENT,
|
||||
CONSUME_ITEM,
|
||||
SKILL_CAST_FAILED,
|
||||
IMITATION_SKUNK_STINK,
|
||||
CHANGE_IDLE_FLAGS,
|
||||
APPLY_BUFF,
|
||||
CHAIN,
|
||||
CHANGE_ORIENTATION,
|
||||
FORCE_MOVEMENT,
|
||||
INTERRUPT,
|
||||
ALTER_COOLDOWN,
|
||||
CHARGE_UP,
|
||||
SWITCH_MULTIPLE,
|
||||
START,
|
||||
END,
|
||||
ALTER_CHAIN_DELAY,
|
||||
CAMERA,
|
||||
REMOVE_BUFF,
|
||||
GRAB,
|
||||
MODULAR_BUILD,
|
||||
NPC_COMBAT_SKILL,
|
||||
BLOCK,
|
||||
VERIFY,
|
||||
TAUNT,
|
||||
AIR_MOVEMENT,
|
||||
SPAWN_QUICKBUILD,
|
||||
PULL_TO_POINT,
|
||||
PROPERTY_ROTATE,
|
||||
DAMAGE_REDUCTION,
|
||||
PROPERTY_TELEPORT,
|
||||
PROPERTY_CLEAR_TARGET,
|
||||
TAKE_PICTURE,
|
||||
MOUNT,
|
||||
SKILL_SET
|
||||
};
|
||||
@@ -29,6 +29,31 @@ namespace MessageType {
|
||||
|
||||
NEW_SESSION_ALERT,
|
||||
|
||||
REQUEST_SERVER_LIST,
|
||||
|
||||
SERVER_LIST_RESPONSE,
|
||||
|
||||
// Dashboard -> master -> every world: act on an online player (see PlayerAction.h)
|
||||
PLAYER_ACTION,
|
||||
// World -> master: whether this world handled a PLAYER_ACTION; master -> dashboard: aggregated result
|
||||
PLAYER_ACTION_RESULT,
|
||||
// World -> master -> dashboard: rows the game just wrote, so open dashboards update at once (see DataChanged.h)
|
||||
DATA_CHANGED,
|
||||
// World -> master -> dashboard: where players are (see DashboardMessages.h)
|
||||
PLAYER_POSITIONS,
|
||||
// Dashboard -> master -> every world: show an announcement to everyone online
|
||||
ANNOUNCE,
|
||||
// Dashboard -> master: shut the whole server down now (scheduled restarts; a supervisor starts it again)
|
||||
DASHBOARD_SHUTDOWN,
|
||||
// Dashboard -> master -> every server: settings changed on the dashboard, reload the config
|
||||
CONFIG_RELOAD,
|
||||
// Dashboard -> master: shut down one world instance (uint32 zone, uint32 instance)
|
||||
INSTANCE_SHUTDOWN,
|
||||
// Dashboard -> master -> every world: start or stop capturing a player's game messages (see MessageCapture.h)
|
||||
MESSAGE_CAPTURE_CONTROL,
|
||||
// World -> master -> dashboard: captured game messages and the capture's state (see MessageCapture.h)
|
||||
MESSAGE_CAPTURE_DATA,
|
||||
|
||||
// Move everyone in one instance to another (replace or merge; see InstanceMigration.h). Sent by a world
|
||||
// for a GM command; a dashboard could send it too
|
||||
INSTANCE_MIGRATE,
|
||||
|
||||
@@ -5,7 +5,8 @@ enum class ServiceType : uint16_t {
|
||||
COMMON = 0,
|
||||
AUTH,
|
||||
CHAT,
|
||||
WORLD = 4,
|
||||
DASHBOARD,
|
||||
WORLD,
|
||||
CLIENT,
|
||||
MASTER,
|
||||
UNKNOWN
|
||||
|
||||
37
dCommon/dEnums/StatisticID.h
Normal file
37
dCommon/dEnums/StatisticID.h
Normal file
@@ -0,0 +1,37 @@
|
||||
#ifndef __STATISTICID__H__
|
||||
#define __STATISTICID__H__
|
||||
|
||||
/**
|
||||
* The IDs of each of the possible statistics
|
||||
*/
|
||||
enum StatisticID {
|
||||
CurrencyCollected = 1,
|
||||
BricksCollected,
|
||||
SmashablesSmashed,
|
||||
QuickBuildsCompleted,
|
||||
EnemiesSmashed,
|
||||
RocketsUsed,
|
||||
MissionsCompleted,
|
||||
PetsTamed,
|
||||
ImaginationPowerUpsCollected,
|
||||
LifePowerUpsCollected,
|
||||
ArmorPowerUpsCollected,
|
||||
MetersTraveled,
|
||||
TimesSmashed,
|
||||
TotalDamageTaken,
|
||||
TotalDamageHealed,
|
||||
TotalArmorRepaired,
|
||||
TotalImaginationRestored,
|
||||
TotalImaginationUsed,
|
||||
DistanceDriven,
|
||||
TimeAirborneInCar,
|
||||
RacingImaginationPowerUpsCollected,
|
||||
RacingImaginationCratesSmashed,
|
||||
RacingCarBoostsActivated,
|
||||
RacingTimesWrecked,
|
||||
RacingSmashablesSmashed,
|
||||
RacesFinished,
|
||||
FirstPlaceRaceFinishes,
|
||||
};
|
||||
|
||||
#endif //!__STATISTICID__H__
|
||||
13
dCommon/dEnums/eAccountLink.h
Normal file
13
dCommon/dEnums/eAccountLink.h
Normal file
@@ -0,0 +1,13 @@
|
||||
#ifndef __EACCOUNTLINK__H__
|
||||
#define __EACCOUNTLINK__H__
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// What two accounts have in common, from data the servers store (see IModeration::GetLinkedAccounts)
|
||||
enum class eAccountLink : uint8_t {
|
||||
PLAY_KEY, // signed up with the same play key
|
||||
EMAIL, // the same email address
|
||||
LOGIN_ADDRESS, // logged in to the game from the same network address (account_login_addresses)
|
||||
};
|
||||
|
||||
#endif //!__EACCOUNTLINK__H__
|
||||
22
dCommon/dEnums/eLeaderboardType.h
Normal file
22
dCommon/dEnums/eLeaderboardType.h
Normal file
@@ -0,0 +1,22 @@
|
||||
#ifndef __ELEADERBOARDTYPE__H__
|
||||
#define __ELEADERBOARDTYPE__H__
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
/**
|
||||
* CDClient Activities.leaderboardType. The client picks the leaderboard's columns from it
|
||||
* (LWOCharacterComponent::msgSendActivitySummaryLeaderboardData in 1.10.64); the server decides the order.
|
||||
*/
|
||||
enum class eLeaderboardType : uint32_t {
|
||||
ShootingGallery, // Score, Streak, Accuracy
|
||||
Racing, // Best Time, Best Lap, Wins
|
||||
MonumentRace, // Time taken
|
||||
FootRace, // Time left on the race's countdown at the finish (L_ACT_BASE_FOOT_RACE_CLIENT.lua sends the remaining time)
|
||||
UnusedLeaderboard4, // There is no 4 defined anywhere in the cdclient, but it takes Points.
|
||||
Survival, // Time, Points
|
||||
SurvivalNS, // Wave, Time
|
||||
Donations, // Donations
|
||||
None
|
||||
};
|
||||
|
||||
#endif //!__ELEADERBOARDTYPE__H__
|
||||
13
dCommon/dEnums/ePlayerReportKind.h
Normal file
13
dCommon/dEnums/ePlayerReportKind.h
Normal file
@@ -0,0 +1,13 @@
|
||||
#ifndef __EPLAYERREPORTKIND__H__
|
||||
#define __EPLAYERREPORTKIND__H__
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// What a player reported from the game's Report Abuse window (player_reports.kind holds the name)
|
||||
enum class ePlayerReportKind : uint8_t {
|
||||
PLAYER, // another player: ReportBug with nOtherPlayerID set
|
||||
MODEL, // an object in the world, usually a model on a property: ReportOffensiveModel
|
||||
PROPERTY, // a property, picked by its plaque: ReportOffensiveProperty
|
||||
};
|
||||
|
||||
#endif //!__EPLAYERREPORTKIND__H__
|
||||
13
dCommon/dEnums/ePlayerReportStatus.h
Normal file
13
dCommon/dEnums/ePlayerReportStatus.h
Normal file
@@ -0,0 +1,13 @@
|
||||
#ifndef __EPLAYERREPORTSTATUS__H__
|
||||
#define __EPLAYERREPORTSTATUS__H__
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// Where a player report stands (player_reports.status)
|
||||
enum class ePlayerReportStatus : uint8_t {
|
||||
OPEN,
|
||||
ACTIONED, // staff acted on it (and may have given a strike)
|
||||
DISMISSED, // nothing to act on
|
||||
};
|
||||
|
||||
#endif //!__EPLAYERREPORTSTATUS__H__
|
||||
11
dCommon/dEnums/ePropertyPrivacyOption.h
Normal file
11
dCommon/dEnums/ePropertyPrivacyOption.h
Normal file
@@ -0,0 +1,11 @@
|
||||
#ifndef __EPROPERTYPRIVACYOPTION__H__
|
||||
#define __EPROPERTYPRIVACYOPTION__H__
|
||||
|
||||
// Who may visit a property (properties.privacy_option)
|
||||
enum class PropertyPrivacyOption {
|
||||
Private = 0, // Default, only you can visit your property
|
||||
Friends = 1, // Your friends can visit your property
|
||||
Public = 2 // Requires Mythran approval, everyone can visit your property
|
||||
};
|
||||
|
||||
#endif //!__EPROPERTYPRIVACYOPTION__H__
|
||||
16
dCommon/dEnums/eStrikeSource.h
Normal file
16
dCommon/dEnums/eStrikeSource.h
Normal file
@@ -0,0 +1,16 @@
|
||||
#ifndef __ESTRIKESOURCE__H__
|
||||
#define __ESTRIKESOURCE__H__
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// What a strike against an account was given for (account_strikes.source holds the name)
|
||||
enum class eStrikeSource : uint8_t {
|
||||
MANUAL, // given by hand on the account's page
|
||||
NAME, // a rejected character name
|
||||
PET_NAME, // a rejected pet name
|
||||
PROPERTY, // a rejected property
|
||||
LEADERBOARD, // a score removed from a leaderboard
|
||||
PLAYER_REPORT, // acting on a report a player sent from the game (Player Reports)
|
||||
};
|
||||
|
||||
#endif //!__ESTRIKESOURCE__H__
|
||||
14
dCommon/dEnums/eStrikeStep.h
Normal file
14
dCommon/dEnums/eStrikeStep.h
Normal file
@@ -0,0 +1,14 @@
|
||||
#ifndef __ESTRIKESTEP__H__
|
||||
#define __ESTRIKESTEP__H__
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// What happens on its own when an account reaches a number of active strikes (strike_*_at settings).
|
||||
// account_strikes.step holds the name on the strike that set it off.
|
||||
enum class eStrikeStep : uint8_t {
|
||||
WARN, // a warning on the account's record, shown to the player if they're online
|
||||
MUTE, // muted for strike_mute_days
|
||||
BAN, // banned for strike_ban_days (0: permanently)
|
||||
};
|
||||
|
||||
#endif //!__ESTRIKESTEP__H__
|
||||
Reference in New Issue
Block a user