mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 10:53:44 +00:00
eReplicaComponentType had the destroyable component's registry type (7) named BUFF, the real buff component (98) as BUFF_REAL, and a made-up DESTROYABLE = 1000 that DestroyableComponent was stored under. Now DESTROYABLE = 7 and BUFF = 98, as in ComponentsRegistry and the client. Undone with it: - Destructible stats came from whichever of the "buff" (7), quick build and collectible registry ids was set, so the few objects without a type 7 entry read a DestructibleComponent row with an unrelated id (the NJ dragon relics 16482-16485 via their collectible id, 125 quick build LOTs when placed with is_smashable). The type 7 entry is used now; objects without one keep the defaults (is_smashable objects: 1 health, smashable, factions -1 and 6; collectibles: an empty destroyable). The client does the same (LWODestroyableComponent::AllocateComponents / DoObjectComponentLoad). - DestroyableComponent::Reinitialize, an unused copy of that pick order. - WriteComponents' destroyableSerialized flags: the components are written from a list in the client's order, and where the destroyable goes (its own place after the buff, right before a quick build that has no registry entry for it, or after the render component) is one function. - The dashboard's registry 7 -> DESTROYABLE mapping; the destroyable type also has a name there now (1000 was outside magic_enum's range). Component types are not stored or sent as enum numbers anywhere besides the CDClient's own values, which now match. migrations/cdserver/4 is unrelated (it restores LOT 12916's registry rows that migration 0 overwrote) and stays. Verified: dGameTests ReplicaComponentOrderTests serialize players, enemies, smashables, quick builds and collectibles with and without a registry entry, NPCs, pets, vehicles, models and an entity with every listed component with the new code and a frozen copy of the old WriteComponents, and expect the same bits for construction and serialization (a deliberately wrong destroyable place fails them). Full ctest passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
396 lines
16 KiB
C++
396 lines
16 KiB
C++
#pragma once
|
|
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <cstdint>
|
|
#include <map>
|
|
#include <optional>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <variant>
|
|
#include <vector>
|
|
|
|
#include "json.hpp"
|
|
#include "GeneralUtils.h"
|
|
#include "magic_enum.hpp"
|
|
#include "eReplicaComponentType.h"
|
|
|
|
/**
|
|
* The CDClient browser's view of the CDClient database: the tables and columns (read from the database's own schema
|
|
* at startup, never listed here), which columns point at rows of other tables, and safe row queries built from them.
|
|
*
|
|
* Queries only ever name tables and columns that are in the schema, quoted, and every value is a bound parameter, so
|
|
* nothing a browser sends becomes SQL. Pure, so it is unit tested.
|
|
*/
|
|
namespace CDClientSchema {
|
|
enum class eValueType : uint8_t { INTEGER, REAL, TEXT };
|
|
|
|
struct Column {
|
|
std::string name;
|
|
std::string type; // as declared: int32, int64, int_bool, real, text_4, ...
|
|
eValueType valueType{ eValueType::TEXT };
|
|
};
|
|
|
|
struct Table {
|
|
std::string name;
|
|
std::vector<Column> columns;
|
|
|
|
const Column* Find(std::string_view column) const {
|
|
const auto it = std::ranges::find_if(columns, [&](const Column& c) { return c.name == column; });
|
|
return it == columns.end() ? nullptr : &*it;
|
|
}
|
|
};
|
|
|
|
// How a declared column type is compared and bound
|
|
inline eValueType ValueType(std::string type) {
|
|
std::transform(type.begin(), type.end(), type.begin(), [](unsigned char c) { return std::tolower(c); });
|
|
if (type.find("int") != std::string::npos) return eValueType::INTEGER;
|
|
if (type.find("real") != std::string::npos || type.find("float") != std::string::npos || type.find("double") != std::string::npos) return eValueType::REAL;
|
|
return eValueType::TEXT;
|
|
}
|
|
|
|
inline std::string Lower(std::string_view text) {
|
|
std::string lower(text);
|
|
std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char c) { return std::tolower(c); });
|
|
return lower;
|
|
}
|
|
|
|
class Schema {
|
|
public:
|
|
void Add(Table table) {
|
|
for (auto& column : table.columns) column.valueType = ValueType(column.type);
|
|
m_ByLowerName[Lower(table.name)] = table.name;
|
|
m_Tables[table.name] = std::move(table);
|
|
}
|
|
|
|
const Table* Find(std::string_view name) const {
|
|
const auto it = m_Tables.find(std::string(name));
|
|
return it == m_Tables.end() ? nullptr : &it->second;
|
|
}
|
|
|
|
// A table by name ignoring case (the CDClient's names mix cases)
|
|
const Table* FindIgnoringCase(std::string_view name) const {
|
|
const auto it = m_ByLowerName.find(Lower(name));
|
|
return it == m_ByLowerName.end() ? nullptr : Find(it->second);
|
|
}
|
|
|
|
const std::map<std::string, Table>& Tables() const { return m_Tables; }
|
|
|
|
private:
|
|
std::map<std::string, Table> m_Tables;
|
|
std::map<std::string, std::string> m_ByLowerName;
|
|
};
|
|
|
|
// ---- Links between tables ----
|
|
|
|
// What a column's values point at. The browser has a page for each.
|
|
enum class eLink : uint8_t { OBJECT, LOOT_MATRIX, LOOT_TABLE, RARITY_TABLE, BEHAVIOR, SKILL, MISSION, ACTIVITY, ICON, ZONE, EMOTE };
|
|
|
|
struct LinkTarget {
|
|
std::string_view table;
|
|
std::string_view column;
|
|
};
|
|
|
|
// The table and column each kind of link points at
|
|
inline LinkTarget Target(eLink link) {
|
|
switch (link) {
|
|
case eLink::OBJECT: return { "Objects", "id" };
|
|
case eLink::LOOT_MATRIX: return { "LootMatrix", "LootMatrixIndex" };
|
|
case eLink::LOOT_TABLE: return { "LootTable", "LootTableIndex" };
|
|
case eLink::RARITY_TABLE: return { "RarityTable", "RarityTableIndex" };
|
|
case eLink::BEHAVIOR: return { "BehaviorTemplate", "behaviorID" };
|
|
case eLink::SKILL: return { "SkillBehavior", "skillID" };
|
|
case eLink::MISSION: return { "Missions", "id" };
|
|
case eLink::ACTIVITY: return { "Activities", "ActivityID" };
|
|
case eLink::ICON: return { "Icons", "IconID" };
|
|
case eLink::ZONE: return { "ZoneTable", "zoneID" };
|
|
case eLink::EMOTE: return { "Emotes", "id" };
|
|
}
|
|
return {};
|
|
}
|
|
|
|
/**
|
|
* The CDClient declares no foreign keys, so which columns point where is a matter of naming. Each rule names a
|
|
* column (lowercase) and optionally the only table it applies to; the first match wins, so table-specific rules
|
|
* come first. A rule only takes effect for integer columns the schema actually has, pointing at a table it has.
|
|
*/
|
|
struct LinkRule {
|
|
eLink link;
|
|
std::string_view table; // empty: any table
|
|
std::string_view column;
|
|
};
|
|
|
|
inline const std::vector<LinkRule>& LinkRules() {
|
|
static const std::vector<LinkRule> rules{
|
|
// ActivityRewards.objectTemplate is the activity (Loot looks rewards up by activity ID)
|
|
{ eLink::ACTIVITY, "ActivityRewards", "objecttemplate" },
|
|
{ eLink::MISSION, "MissionTasks", "id" },
|
|
{ eLink::MISSION, "MissionText", "id" },
|
|
{ eLink::OBJECT, "ComponentsRegistry", "id" },
|
|
// Only these tables' behaviorID is a skill behavior; DevModelBehaviors and ObjectBehaviors hold model behaviors
|
|
{ eLink::BEHAVIOR, "SkillBehavior", "behaviorid" },
|
|
{ eLink::BEHAVIOR, "BehaviorParameter", "behaviorid" },
|
|
{ eLink::BEHAVIOR, "BehaviorTemplate", "behaviorid" },
|
|
{ eLink::MISSION, "CollectibleComponent", "requirement_mission" },
|
|
|
|
{ eLink::OBJECT, "", "lot" }, { eLink::OBJECT, "", "itemid" }, { eLink::OBJECT, "", "objecttemplate" },
|
|
{ eLink::OBJECT, "", "ndobjectid" }, { eLink::OBJECT, "", "offer_objectid" }, { eLink::OBJECT, "", "target_objectid" },
|
|
{ eLink::OBJECT, "", "reward_item1" }, { eLink::OBJECT, "", "reward_item2" }, { eLink::OBJECT, "", "reward_item3" },
|
|
{ eLink::OBJECT, "", "reward_item4" }, { eLink::OBJECT, "", "reward_item1_repeatable" }, { eLink::OBJECT, "", "reward_item2_repeatable" },
|
|
{ eLink::OBJECT, "", "reward_item3_repeatable" }, { eLink::OBJECT, "", "reward_item4_repeatable" },
|
|
{ eLink::OBJECT, "", "attachmentlot" }, { eLink::OBJECT, "", "npclot" }, { eLink::OBJECT, "", "puzzlemodellot" },
|
|
{ eLink::OBJECT, "", "commendationlot" }, { eLink::OBJECT, "", "currencylot" }, { eLink::OBJECT, "", "createdlot" },
|
|
{ eLink::OBJECT, "", "optionalcostlot" }, { eLink::OBJECT, "", "zonecontroltemplate" },
|
|
{ eLink::LOOT_MATRIX, "", "lootmatrixindex" }, { eLink::LOOT_MATRIX, "", "lootmatrixid" },
|
|
{ eLink::LOOT_TABLE, "", "loottableindex" },
|
|
{ eLink::RARITY_TABLE, "", "raritytableindex" },
|
|
{ eLink::SKILL, "", "skillid" },
|
|
{ eLink::MISSION, "", "missionid" },
|
|
{ eLink::ACTIVITY, "", "activityid" },
|
|
{ eLink::ICON, "", "iconid" },
|
|
// Zones: where an activity is played, where a launchpad or property goes
|
|
{ eLink::ZONE, "", "zoneid" }, { eLink::ZONE, "", "instancemapid" }, { eLink::ZONE, "", "mapid" }, { eLink::ZONE, "", "vendormapid" },
|
|
{ eLink::ZONE, "", "targetzone" }, { eLink::ZONE, "", "defaultzoneid" },
|
|
// Emotes a mission unlocks (Character::UnlockEmote)
|
|
{ eLink::EMOTE, "", "reward_emote" }, { eLink::EMOTE, "", "reward_emote2" }, { eLink::EMOTE, "", "reward_emote3" },
|
|
{ eLink::EMOTE, "", "reward_emote4" }, { eLink::EMOTE, "", "emoteid" },
|
|
};
|
|
return rules;
|
|
}
|
|
|
|
// What a column of a table points at, if anything
|
|
inline std::optional<eLink> LinkFor(const Schema& schema, const Table& table, const Column& column) {
|
|
if (column.valueType != eValueType::INTEGER || column.type.find("bool") != std::string::npos) return std::nullopt;
|
|
const auto name = Lower(column.name);
|
|
for (const auto& rule : LinkRules()) {
|
|
if (!rule.table.empty() && rule.table != table.name) continue;
|
|
if (rule.column != name) continue;
|
|
const auto target = Target(rule.link);
|
|
const auto* targetTable = schema.Find(target.table);
|
|
if (!targetTable || !targetTable->Find(target.column)) return std::nullopt;
|
|
// A table's own key isn't a link to itself
|
|
if (target.table == table.name && target.column == column.name) return std::nullopt;
|
|
return rule.link;
|
|
}
|
|
return std::nullopt;
|
|
}
|
|
|
|
// Every (table, column) that points at `link`, for "used by" lists
|
|
inline std::vector<std::pair<const Table*, const Column*>> ColumnsLinkingTo(const Schema& schema, eLink link) {
|
|
std::vector<std::pair<const Table*, const Column*>> columns;
|
|
for (const auto& [name, table] : schema.Tables()) {
|
|
for (const auto& column : table.columns) {
|
|
if (LinkFor(schema, table, column) == link) columns.emplace_back(&table, &column);
|
|
}
|
|
}
|
|
return columns;
|
|
}
|
|
|
|
// The link's name for the page ("loot_matrix")
|
|
inline std::string LinkName(eLink link) {
|
|
return Lower(magic_enum::enum_name(link));
|
|
}
|
|
|
|
/**
|
|
* The table holding a component type's rows (ComponentsRegistry.component_id is its id). The CDClient doesn't say,
|
|
* so the type's name is matched to a table called <Name>Component (VENDOR -> VendorComponent, BASE_COMBAT_AI ->
|
|
* BaseCombatAIComponent). The types below use differently named tables, as the server's components read them.
|
|
*/
|
|
inline const Table* ComponentTable(const Schema& schema, eReplicaComponentType type) {
|
|
using enum eReplicaComponentType;
|
|
static const std::map<eReplicaComponentType, std::string_view> DIFFERENT{
|
|
{ DESTROYABLE, "DestructibleComponent" },
|
|
{ CONTROLLABLE_PHYSICS, "PhysicsComponent" }, { SIMPLE_PHYSICS, "PhysicsComponent" }, { PHANTOM_PHYSICS, "PhysicsComponent" },
|
|
{ RIGID_BODY_PHANTOM_PHYSICS, "PhysicsComponent" }, { HAVOK_VEHICLE_PHYSICS, "VehiclePhysics" },
|
|
{ QUICK_BUILD, "RebuildComponent" }, { MISSION_OFFER, "MissionNPCComponent" }, { ROCKET_LAUNCH, "RocketLaunchpadControlComponent" },
|
|
};
|
|
if (const auto it = DIFFERENT.find(type); it != DIFFERENT.end()) return schema.Find(it->second);
|
|
std::string name;
|
|
for (const char c : magic_enum::enum_name(type)) if (c != '_') name += c;
|
|
if (name.empty()) return nullptr;
|
|
return schema.FindIgnoringCase(name + "Component");
|
|
}
|
|
|
|
// ---- Row queries ----
|
|
|
|
using Value = std::variant<int64_t, double, std::string>;
|
|
|
|
struct Filter {
|
|
std::string column;
|
|
std::string op; // = != < <= > >= contains starts null notnull
|
|
std::string value;
|
|
};
|
|
|
|
struct RowQuery {
|
|
std::string table;
|
|
std::vector<Filter> filters;
|
|
std::string search; // any text column containing it, or any integer column equal to it
|
|
std::string orderColumn; // empty: table order
|
|
bool ascending{ true };
|
|
uint32_t start{};
|
|
uint32_t length{ 50 };
|
|
};
|
|
|
|
constexpr uint32_t MAX_ROWS = 500;
|
|
constexpr size_t MAX_FILTERS = 16;
|
|
|
|
struct BuiltQuery {
|
|
std::string select; // the page of rows
|
|
std::string count; // how many rows match
|
|
std::vector<Value> params; // for `select`: the WHERE parameters, then limit and offset
|
|
std::vector<Value> countParams; // for `count`
|
|
};
|
|
|
|
// "name" with any quote doubled
|
|
inline std::string Quote(std::string_view identifier) {
|
|
std::string quoted = "\"";
|
|
for (const char c : identifier) {
|
|
if (c == '"') quoted += '"';
|
|
quoted += c;
|
|
}
|
|
return quoted + "\"";
|
|
}
|
|
|
|
// Text for LIKE with its wildcards escaped (ESCAPE '\')
|
|
inline std::string LikeEscape(std::string_view text) {
|
|
return GeneralUtils::LikeEscape(text, '\\');
|
|
}
|
|
|
|
// A filter value as the column's type; nullopt when it isn't one (e.g. "abc" for an integer column)
|
|
inline std::optional<Value> Typed(const Column& column, const std::string& text) {
|
|
if (column.valueType == eValueType::TEXT) return Value{ text };
|
|
try {
|
|
size_t used = 0;
|
|
if (column.valueType == eValueType::INTEGER) {
|
|
const auto value = std::stoll(text, &used);
|
|
if (used == text.size()) return Value{ static_cast<int64_t>(value) };
|
|
} else {
|
|
const auto value = std::stod(text, &used);
|
|
if (used == text.size()) return Value{ value };
|
|
}
|
|
} catch (const std::exception&) {}
|
|
return std::nullopt;
|
|
}
|
|
|
|
/**
|
|
* The SQL for a page of a table's rows. Sets `error` and returns nullopt for an unknown table or column, an
|
|
* unknown operator or a value that doesn't fit its column.
|
|
*/
|
|
inline std::optional<BuiltQuery> BuildRowQuery(const Schema& schema, const RowQuery& query, std::string& error) {
|
|
const auto* table = schema.Find(query.table);
|
|
if (!table) {
|
|
error = "Unknown table";
|
|
return std::nullopt;
|
|
}
|
|
if (query.filters.size() > MAX_FILTERS) {
|
|
error = "At most " + std::to_string(MAX_FILTERS) + " filters";
|
|
return std::nullopt;
|
|
}
|
|
|
|
std::vector<std::string> conditions;
|
|
std::vector<Value> params;
|
|
for (const auto& filter : query.filters) {
|
|
const auto* column = table->Find(filter.column);
|
|
if (!column) {
|
|
error = "Unknown column " + filter.column;
|
|
return std::nullopt;
|
|
}
|
|
const auto quoted = Quote(column->name);
|
|
if (filter.op == "null" || filter.op == "notnull") {
|
|
conditions.push_back(quoted + (filter.op == "null" ? " IS NULL" : " IS NOT NULL"));
|
|
continue;
|
|
}
|
|
if (filter.op == "contains" || filter.op == "starts") {
|
|
conditions.push_back("CAST(" + quoted + " AS TEXT) LIKE ? ESCAPE '\\'");
|
|
params.emplace_back((filter.op == "contains" ? "%" : "") + LikeEscape(filter.value) + "%");
|
|
continue;
|
|
}
|
|
static const std::vector<std::string> COMPARISONS{ "=", "!=", "<", "<=", ">", ">=" };
|
|
if (std::ranges::find(COMPARISONS, filter.op) == COMPARISONS.end()) {
|
|
error = "Unknown operator " + filter.op;
|
|
return std::nullopt;
|
|
}
|
|
const auto value = Typed(*column, filter.value);
|
|
if (!value) {
|
|
error = column->name + " holds numbers";
|
|
return std::nullopt;
|
|
}
|
|
conditions.push_back(quoted + " " + filter.op + " ?");
|
|
params.push_back(*value);
|
|
}
|
|
|
|
if (!query.search.empty()) {
|
|
std::vector<std::string> any;
|
|
for (const auto& column : table->columns) {
|
|
if (column.valueType == eValueType::TEXT) {
|
|
any.push_back(Quote(column.name) + " LIKE ? ESCAPE '\\'");
|
|
params.emplace_back("%" + LikeEscape(query.search) + "%");
|
|
} else if (const auto number = Typed(column, query.search)) {
|
|
any.push_back(Quote(column.name) + " = ?");
|
|
params.push_back(*number);
|
|
}
|
|
}
|
|
conditions.push_back(any.empty() ? "0" : "(" + [&] {
|
|
std::string joined;
|
|
for (size_t i = 0; i < any.size(); i++) joined += (i ? " OR " : "") + any[i];
|
|
return joined;
|
|
}() + ")");
|
|
}
|
|
|
|
std::string where;
|
|
for (size_t i = 0; i < conditions.size(); i++) where += (i ? " AND " : " WHERE ") + conditions[i];
|
|
|
|
std::string order;
|
|
if (!query.orderColumn.empty()) {
|
|
const auto* column = table->Find(query.orderColumn);
|
|
if (!column) {
|
|
error = "Unknown column " + query.orderColumn;
|
|
return std::nullopt;
|
|
}
|
|
order = " ORDER BY " + Quote(column->name) + (query.ascending ? " ASC" : " DESC");
|
|
}
|
|
|
|
BuiltQuery built;
|
|
built.count = "SELECT COUNT(*) FROM " + Quote(table->name) + where + ";";
|
|
built.countParams = params;
|
|
built.select = "SELECT * FROM " + Quote(table->name) + where + order + " LIMIT ? OFFSET ?;";
|
|
built.params = std::move(params);
|
|
built.params.emplace_back(static_cast<int64_t>(std::clamp<uint32_t>(query.length, 1, MAX_ROWS)));
|
|
built.params.emplace_back(static_cast<int64_t>(query.start));
|
|
return built;
|
|
}
|
|
|
|
// ---- Behaviors ----
|
|
|
|
/**
|
|
* Whether a behavior parameter holds a child behavior's ID. The server's behaviors read their children by these
|
|
* names (Behavior::GetAction): "action", "miss action", "on_success", "behavior 1", ... but not the "*faction*"
|
|
* parameters, which are faction IDs.
|
|
*/
|
|
inline bool IsChildBehaviorParameter(std::string_view parameter) {
|
|
const auto name = Lower(parameter);
|
|
if (name.find("faction") != std::string::npos) return false;
|
|
return name.starts_with("behavior") || name.starts_with("on_") || name.find("action") != std::string::npos;
|
|
}
|
|
|
|
/**
|
|
* Mission IDs in Missions.prereqMissionID, which lists them separated by commas, pipes, ampersands or spaces,
|
|
* sometimes with a ":state" suffix or in brackets ("(1,2)|3", "123:4").
|
|
*/
|
|
inline std::vector<int32_t> PrerequisiteMissions(std::string_view text) {
|
|
std::vector<int32_t> ids;
|
|
for (size_t i = 0; i < text.size();) {
|
|
if (!std::isdigit(static_cast<unsigned char>(text[i]))) { i++; continue; }
|
|
size_t end = i;
|
|
while (end < text.size() && std::isdigit(static_cast<unsigned char>(text[end]))) end++;
|
|
const bool isState = i > 0 && text[i - 1] == ':';
|
|
if (!isState && end - i <= 9) {
|
|
const auto id = static_cast<int32_t>(std::stol(std::string(text.substr(i, end - i))));
|
|
if (std::ranges::find(ids, id) == ids.end()) ids.push_back(id);
|
|
}
|
|
i = end;
|
|
}
|
|
return ids;
|
|
}
|
|
}
|