Files
DarkflameServer/dDashboardServer/routes/CDClientSchema.h
Aaron Kimbrell caebff4dca fix: give the destroyable and buff components their real component types
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>
2026-09-28 22:30:55 -05:00

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;
}
}