Files
DarkflameServer/tests/dWebTests/CDClientSchemaTests.cpp
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

275 lines
12 KiB
C++

#include <gtest/gtest.h>
#include "CDClientRules.h"
#include "CDClientSchema.h"
using namespace CDClientSchema;
namespace {
// A small CDClient-like schema
Schema TestSchema() {
Schema schema;
schema.Add({ "Objects", { { "id", "int32" }, { "name", "text_4" }, { "placeable", "int_bool" }, { "interactionDistance", "real" } } });
schema.Add({ "LootMatrix", { { "LootMatrixIndex", "int32" }, { "LootTableIndex", "int32" }, { "percent", "real" } } });
schema.Add({ "LootTable", { { "itemid", "int32" }, { "LootTableIndex", "int32" } } });
schema.Add({ "DestructibleComponent", { { "id", "int32" }, { "LootMatrixIndex", "int32" }, { "death_behavior", "int32" } } });
schema.Add({ "Missions", { { "id", "int32" }, { "reward_item1", "int32" }, { "prereqMissionID", "text_4" } } });
schema.Add({ "ActivityRewards", { { "objectTemplate", "int32" }, { "LootMatrixIndex", "int32" } } });
schema.Add({ "Activities", { { "ActivityID", "int32" } } });
schema.Add({ "ObjectSkills", { { "objectTemplate", "int32" }, { "skillID", "int32" } } });
schema.Add({ "ObjectBehaviors", { { "BehaviorID", "int64" } } });
schema.Add({ "VendorComponent", { { "id", "int32" } } });
schema.Add({ "RenderComponent", { { "id", "int32" } } });
schema.Add({ "PhysicsComponent", { { "id", "int32" } } });
schema.Add({ "Weird\"Name", { { "a\"b", "text_4" } } });
return schema;
}
const Table& T(const Schema& schema, const char* name) { return *schema.Find(name); }
const Column& C(const Schema& schema, const char* table, const char* column) { return *schema.Find(table)->Find(column); }
}
TEST(CDClientSchemaTest, ValueTypes) {
EXPECT_EQ(ValueType("int32"), eValueType::INTEGER);
EXPECT_EQ(ValueType("int_bool"), eValueType::INTEGER);
EXPECT_EQ(ValueType("INT64"), eValueType::INTEGER);
EXPECT_EQ(ValueType("REAL"), eValueType::REAL);
EXPECT_EQ(ValueType("text_4"), eValueType::TEXT);
}
TEST(CDClientSchemaTest, Quoting) {
EXPECT_EQ(Quote("Objects"), "\"Objects\"");
EXPECT_EQ(Quote("a\"b"), "\"a\"\"b\"");
EXPECT_EQ(LikeEscape("50%_off\\"), "50\\%\\_off\\\\");
}
TEST(CDClientSchemaTest, Links) {
const auto schema = TestSchema();
EXPECT_EQ(LinkFor(schema, T(schema, "LootTable"), C(schema, "LootTable", "itemid")), eLink::OBJECT);
EXPECT_EQ(LinkFor(schema, T(schema, "LootMatrix"), C(schema, "LootMatrix", "LootTableIndex")), eLink::LOOT_TABLE);
EXPECT_EQ(LinkFor(schema, T(schema, "DestructibleComponent"), C(schema, "DestructibleComponent", "LootMatrixIndex")), eLink::LOOT_MATRIX);
EXPECT_EQ(LinkFor(schema, T(schema, "Missions"), C(schema, "Missions", "reward_item1")), eLink::OBJECT);
EXPECT_EQ(LinkFor(schema, T(schema, "ObjectSkills"), C(schema, "ObjectSkills", "objectTemplate")), eLink::OBJECT);
// Table-specific rules win: ActivityRewards.objectTemplate is the activity
EXPECT_EQ(LinkFor(schema, T(schema, "ActivityRewards"), C(schema, "ActivityRewards", "objectTemplate")), eLink::ACTIVITY);
// A table's own key isn't a link
EXPECT_FALSE(LinkFor(schema, T(schema, "LootMatrix"), C(schema, "LootMatrix", "LootMatrixIndex")));
// Text and boolean columns never link
EXPECT_FALSE(LinkFor(schema, T(schema, "Missions"), C(schema, "Missions", "prereqMissionID")));
EXPECT_FALSE(LinkFor(schema, T(schema, "Objects"), C(schema, "Objects", "placeable")));
// Model behaviors aren't skill behaviors
EXPECT_FALSE(LinkFor(schema, T(schema, "ObjectBehaviors"), C(schema, "ObjectBehaviors", "BehaviorID")));
// No target table in the schema: no link
EXPECT_FALSE(LinkFor(schema, T(schema, "ObjectSkills"), C(schema, "ObjectSkills", "skillID")));
const auto toMatrices = ColumnsLinkingTo(schema, eLink::LOOT_MATRIX);
EXPECT_EQ(toMatrices.size(), 2u); // ActivityRewards and DestructibleComponent
}
TEST(CDClientSchemaTest, ComponentTables) {
const auto schema = TestSchema();
const auto name = [&](eReplicaComponentType type) {
const auto* table = ComponentTable(schema, type);
return table ? table->name : std::string("-");
};
EXPECT_EQ(name(eReplicaComponentType::VENDOR), "VendorComponent");
EXPECT_EQ(name(eReplicaComponentType::RENDER), "RenderComponent");
EXPECT_EQ(name(eReplicaComponentType::DESTROYABLE), "DestructibleComponent");
EXPECT_EQ(name(eReplicaComponentType::SIMPLE_PHYSICS), "PhysicsComponent");
EXPECT_EQ(name(eReplicaComponentType::PET), "-");
}
TEST(CDClientSchemaTest, RowQuery) {
const auto schema = TestSchema();
std::string error;
RowQuery query;
query.table = "Objects";
query.filters = { { "id", ">=", "10" }, { "name", "contains", "50%" }, { "interactionDistance", "null", "" } };
query.orderColumn = "name";
query.ascending = false;
query.start = 20;
query.length = 10;
const auto built = BuildRowQuery(schema, query, error);
ASSERT_TRUE(built) << error;
EXPECT_EQ(built->select, "SELECT * FROM \"Objects\" WHERE \"id\" >= ? AND CAST(\"name\" AS TEXT) LIKE ? ESCAPE '\\' AND \"interactionDistance\" IS NULL "
"ORDER BY \"name\" DESC LIMIT ? OFFSET ?;");
EXPECT_EQ(built->count, "SELECT COUNT(*) FROM \"Objects\" WHERE \"id\" >= ? AND CAST(\"name\" AS TEXT) LIKE ? ESCAPE '\\' AND \"interactionDistance\" IS NULL;");
ASSERT_EQ(built->params.size(), 4u);
EXPECT_EQ(std::get<int64_t>(built->params[0]), 10);
EXPECT_EQ(std::get<std::string>(built->params[1]), "%50\\%%");
EXPECT_EQ(std::get<int64_t>(built->params[2]), 10);
EXPECT_EQ(std::get<int64_t>(built->params[3]), 20);
EXPECT_EQ(built->countParams.size(), 2u);
}
TEST(CDClientSchemaTest, RowQuerySearchesEveryColumn) {
const auto schema = TestSchema();
std::string error;
RowQuery query;
query.table = "Objects";
query.search = "12";
const auto built = BuildRowQuery(schema, query, error);
ASSERT_TRUE(built);
// Integer and real columns compare as numbers, text columns contain it
EXPECT_NE(built->select.find("(\"id\" = ? OR \"name\" LIKE ? ESCAPE '\\' OR \"placeable\" = ? OR \"interactionDistance\" = ?)"), std::string::npos);
query.search = "abc";
const auto text = BuildRowQuery(schema, query, error);
ASSERT_TRUE(text);
EXPECT_NE(text->select.find("(\"name\" LIKE ? ESCAPE '\\')"), std::string::npos);
}
TEST(CDClientSchemaTest, RowQueryRejectsWhatIsNotInTheSchema) {
const auto schema = TestSchema();
std::string error;
RowQuery query;
query.table = "sqlite_master";
EXPECT_FALSE(BuildRowQuery(schema, query, error));
EXPECT_EQ(error, "Unknown table");
query.table = "Objects";
query.filters = { { "id; DROP TABLE Objects", "=", "1" } };
EXPECT_FALSE(BuildRowQuery(schema, query, error));
query.filters = { { "id", "OR 1=1 --", "1" } };
EXPECT_FALSE(BuildRowQuery(schema, query, error));
query.filters = { { "id", "=", "1 OR 1=1" } };
EXPECT_FALSE(BuildRowQuery(schema, query, error)); // not a number
query.filters = {};
query.orderColumn = "nope";
EXPECT_FALSE(BuildRowQuery(schema, query, error));
query.orderColumn = "";
query.filters.assign(MAX_FILTERS + 1, { "id", "=", "1" });
EXPECT_FALSE(BuildRowQuery(schema, query, error));
}
TEST(CDClientSchemaTest, RowQueryQuotesOddNamesAndCapsLength) {
const auto schema = TestSchema();
std::string error;
RowQuery query;
query.table = "Weird\"Name";
query.filters = { { "a\"b", "=", "x" } };
query.length = 100000;
const auto built = BuildRowQuery(schema, query, error);
ASSERT_TRUE(built);
EXPECT_EQ(built->select, "SELECT * FROM \"Weird\"\"Name\" WHERE \"a\"\"b\" = ? LIMIT ? OFFSET ?;");
EXPECT_EQ(std::get<int64_t>(built->params[1]), MAX_ROWS);
}
TEST(CDClientSchemaTest, ChildBehaviorParameters) {
EXPECT_TRUE(IsChildBehaviorParameter("action"));
EXPECT_TRUE(IsChildBehaviorParameter("miss action"));
EXPECT_TRUE(IsChildBehaviorParameter("behavior 3"));
EXPECT_TRUE(IsChildBehaviorParameter("on_fail_armor"));
EXPECT_TRUE(IsChildBehaviorParameter("action_true"));
EXPECT_TRUE(IsChildBehaviorParameter("ground_action"));
EXPECT_FALSE(IsChildBehaviorParameter("include_faction"));
EXPECT_FALSE(IsChildBehaviorParameter("isEnemyFaction"));
EXPECT_FALSE(IsChildBehaviorParameter("radius"));
}
TEST(CDClientSchemaTest, PrerequisiteMissions) {
EXPECT_EQ(PrerequisiteMissions("815|812|813"), (std::vector<int32_t>{ 815, 812, 813 }));
EXPECT_EQ(PrerequisiteMissions("(509|229)&(812|813)"), (std::vector<int32_t>{ 509, 229, 812, 813 }));
EXPECT_EQ(PrerequisiteMissions("1296, (1684 | 1680)"), (std::vector<int32_t>{ 1296, 1684, 1680 }));
// ":2" is a mission state, not a mission
EXPECT_EQ(PrerequisiteMissions("236:2"), (std::vector<int32_t>{ 236 }));
EXPECT_TRUE(PrerequisiteMissions("").empty());
}
TEST(CDClientRulesTest, RarityChances) {
using namespace CDClientRules;
// RarityTable 2 as the server loads it (randmax descending): a roll takes the lowest row at or above it
const std::vector<RarityRow> rows{ { 1.0, 4 }, { 0.95, 3 }, { 0.75, 2 }, { 0.5, 1 } };
const auto chances = RarityChances(rows);
ASSERT_EQ(chances.size(), 4u);
EXPECT_EQ(chances[0].first, 4);
EXPECT_NEAR(chances[0].second, 0.05, 1e-9);
EXPECT_NEAR(chances[1].second, 0.2, 1e-9);
EXPECT_NEAR(chances[2].second, 0.25, 1e-9);
EXPECT_EQ(chances[3].first, 1);
EXPECT_NEAR(chances[3].second, 0.5, 1e-9);
// A roll above every row keeps rarity 1
const auto partial = RarityChances({ { 0.9, 2 }, { 0.4, 1 } });
ASSERT_EQ(partial.size(), 2u);
EXPECT_EQ(partial[0].first, 1);
EXPECT_NEAR(partial[0].second, 0.1 + 0.4, 1e-9);
EXPECT_NEAR(partial[1].second, 0.5, 1e-9);
}
TEST(CDClientRulesTest, Candidates) {
using namespace CDClientRules;
// Items of the rolled rarity
EXPECT_EQ(Candidates({ 1, 3, 4, 3 }, 3), (std::vector<size_t>{ 1, 3 }));
// None of it: the next lower rarity
EXPECT_EQ(Candidates({ 4, 3, 3, 1 }, 2), (std::vector<size_t>{ 3 }));
// A lower rarity found alone doesn't stop the search: the server keeps taking lower ones until two match
EXPECT_EQ(Candidates({ 3, 1, 1 }, 4), (std::vector<size_t>{ 0, 1, 2 }));
EXPECT_TRUE(Candidates({ 4 }, 2).empty());
}
TEST(CDClientRulesTest, ChancesAndOdds) {
using namespace CDClientRules;
const std::vector<RarityRow> rows{ { 1.0, 4 }, { 0.95, 3 }, { 0.75, 2 }, { 0.5, 1 } };
const auto perDrop = ChancePerDrop({ 1, 1, 2, 3, 4 }, rows);
EXPECT_NEAR(perDrop[0], 0.25, 1e-9);
EXPECT_NEAR(perDrop[2], 0.25, 1e-9);
EXPECT_NEAR(perDrop[3], 0.2, 1e-9);
EXPECT_NEAR(perDrop[4], 0.05, 1e-9);
double total = 0;
for (const auto chance : perDrop) total += chance;
EXPECT_NEAR(total, 1.0, 1e-9);
const auto once = Odds(0.5, 1, 1, 0.25);
EXPECT_NEAR(once.atLeastOne, 0.125, 1e-9);
EXPECT_NEAR(once.expected, 0.125, 1e-9);
const auto twice = Odds(1.0, 2, 2, 0.5);
EXPECT_NEAR(twice.atLeastOne, 0.75, 1e-9);
EXPECT_NEAR(twice.expected, 1.0, 1e-9);
// 1 to 3 drops: 1 - (0.5 + 0.25 + 0.125) / 3
EXPECT_NEAR(Odds(1.0, 1, 3, 0.5).atLeastOne, 1.0 - 0.875 / 3.0, 1e-9);
EXPECT_EQ(VendorStockChance(0, 3, 10), 1.0);
EXPECT_NEAR(VendorStockChance(2, 4, 10), 0.3, 1e-9);
EXPECT_EQ(VendorStockChance(5, 5, 2), 1.0);
}
TEST(CDClientRulesTest, Prerequisites) {
using namespace CDClientRules;
const auto terms = [](std::string_view text) {
std::string out;
for (const auto& term : ParsePrerequisites(text)) {
out += std::to_string(term.mission) + (term.state ? ":" + std::to_string(term.state) : "") + (term.orRest ? " or " : " and ");
}
return out;
};
// Right to left, brackets don't group: 509 or (229 and (812 or 813))
EXPECT_EQ(terms("(509|229)&(812|813)"), "509 or 229 and 812 or 813 and ");
EXPECT_EQ(terms("1|2,3"), "1 or 2 and 3 and ");
EXPECT_EQ(terms("1170:2"), "1170:2 and ");
EXPECT_EQ(terms("1882:10 | 1882:2"), "1882:10 or 1882:2 and ");
// Nothing: always met; a trailing "|" makes the whole thing always met, a trailing "," changes nothing
EXPECT_EQ(terms(""), "0 and ");
EXPECT_EQ(terms("5|"), "5 or 0 and ");
EXPECT_EQ(terms("5,"), "5 and ");
}
TEST(CDClientRulesTest, TaskMeanings) {
using namespace CDClientRules;
EXPECT_EQ(MeaningOf(eMissionTaskType::SMASH).target, (std::vector<eLink>{ eLink::OBJECT }));
EXPECT_TRUE(MeaningOf(eMissionTaskType::SMASH).targetGroupIds);
EXPECT_EQ(MeaningOf(eMissionTaskType::META).target, (std::vector<eLink>{ eLink::MISSION }));
EXPECT_FALSE(MeaningOf(eMissionTaskType::TALK_TO_NPC).targetGroupIds);
EXPECT_EQ(MeaningOf(eMissionTaskType::USE_SKILL).parameters, (std::vector<eLink>{ eLink::SKILL }));
EXPECT_TRUE(MeaningOf(eMissionTaskType::RACING).racingParameter);
EXPECT_FALSE(MeaningOf(eMissionTaskType::BUY).progressed);
// As MissionTask reads targetGroup: leading spaces are fine, anything else that isn't a number is skipped
EXPECT_EQ(NumberList("1,2, 3,-1,x, 4 "), (std::vector<int64_t>{ 1, 2, 3 }));
EXPECT_TRUE(NumberList("").empty());
}