feat: web dashboard and playground work

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

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

View File

@@ -2,6 +2,9 @@ set(DGAMETEST_SOURCES
"GameDependencies.cpp"
"LUBitStreamTests.cpp"
"MailIdPinTests.cpp"
"EconomyLedgerTests.cpp"
"PlayerReportsLimitTests.cpp"
"SlashCommandPermissionTests.cpp"
)
add_subdirectory(dComponentsTests)

View File

@@ -0,0 +1,120 @@
#include <gtest/gtest.h>
#include "EconomyLedger.h"
using namespace EconomyLedger;
TEST(EconomyLedgerTests, SumsFlowsPerDayAndSource) {
Accumulator ledger;
EXPECT_TRUE(ledger.Empty());
ledger.AddCoins(10, 1, 2, 100);
ledger.AddCoins(10, 1, 2, -30);
ledger.AddCoins(10, 1, 2, 50);
ledger.AddCoins(11, 1, 2, 5);
ledger.AddCoins(10, 1, 9, 0); // no change, no row
EXPECT_FALSE(ledger.Empty());
const auto batch = ledger.Take();
EXPECT_TRUE(ledger.Empty());
ASSERT_EQ(batch.currency.size(), 2);
EXPECT_EQ(batch.currency[0].day, 10);
EXPECT_EQ(batch.currency[0].gained, 150);
EXPECT_EQ(batch.currency[0].spent, 30);
EXPECT_EQ(batch.currency[1].day, 11);
EXPECT_EQ(batch.currency[1].gained, 5);
}
TEST(EconomyLedgerTests, KeepsStaffItemsApart) {
Accumulator ledger;
ledger.AddItems(10, 1727, 1, false, 3);
ledger.AddItems(10, 1727, 1, true, 3);
ledger.AddItems(10, 1727, 1, false, -1);
ledger.AddUScore(10, 7, 2, 500);
ledger.AddUScore(10, 7, 2, -200);
const auto batch = ledger.Take();
ASSERT_EQ(batch.items.size(), 2);
EXPECT_FALSE(batch.items[0].gm);
EXPECT_EQ(batch.items[0].created, 3);
EXPECT_EQ(batch.items[0].destroyed, 1);
EXPECT_TRUE(batch.items[1].gm);
ASSERT_EQ(batch.uscore.size(), 1);
EXPECT_EQ(batch.uscore[0].gained, 500);
EXPECT_EQ(batch.uscore[0].lost, 200);
}
TEST(EconomyLedgerTests, BucketsMapEventsIntoCells) {
Accumulator ledger;
const auto cell = IEconomyLedger::MAP_CELL_SIZE;
ledger.AddMapEvent(10, 1100, 0, IEconomyLedger::eMapEvent::ENEMY_KILLS, 4712, 1.0f, 1.0f, 1);
ledger.AddMapEvent(10, 1100, 0, IEconomyLedger::eMapEvent::ENEMY_KILLS, 4712, cell - 0.5f, 3.0f, 1); // same cell
ledger.AddMapEvent(10, 1100, 0, IEconomyLedger::eMapEvent::ENEMY_KILLS, 4712, -1.0f, 1.0f, 1); // negative side
ledger.AddMapEvent(10, 1100, 0, IEconomyLedger::eMapEvent::COIN_DROPS, 0, 1.0f, 1.0f, 250);
const auto batch = ledger.Take();
ASSERT_EQ(batch.mapEvents.size(), 3);
int64_t kills = 0;
for (const auto& event : batch.mapEvents) {
if (event.kind == IEconomyLedger::eMapEvent::ENEMY_KILLS && event.cellX == 0 && event.cellZ == 0) EXPECT_EQ(event.events, 2);
if (event.kind == IEconomyLedger::eMapEvent::ENEMY_KILLS && event.cellX == -1) EXPECT_EQ(event.events, 1);
if (event.kind == IEconomyLedger::eMapEvent::ENEMY_KILLS) kills += event.events;
if (event.kind == IEconomyLedger::eMapEvent::COIN_DROPS) {
EXPECT_EQ(event.events, 1);
EXPECT_EQ(event.quantity, 250);
}
}
EXPECT_EQ(kills, 3);
}
TEST(EconomyLedgerTests, SumsPlayerStatsPerDayZoneAndStaff) {
Accumulator ledger;
ledger.AddStat(10, 1100, 0, SmashablesSmashed, false, 1);
ledger.AddStat(10, 1100, 0, SmashablesSmashed, false, 2);
ledger.AddStat(10, 1100, 0, SmashablesSmashed, true, 1); // staff apart
ledger.AddStat(10, 1200, 0, SmashablesSmashed, false, 1); // another zone
ledger.AddStat(10, 1100, 0, MetersTraveled, false, 0); // nothing to add, no row
EXPECT_FALSE(ledger.Empty());
const auto batch = ledger.Take();
EXPECT_TRUE(ledger.Empty());
ASSERT_EQ(batch.stats.size(), 3);
EXPECT_EQ(batch.stats[0].zone, 1100);
EXPECT_EQ(batch.stats[0].stat, static_cast<uint32_t>(SmashablesSmashed));
EXPECT_FALSE(batch.stats[0].gm);
EXPECT_EQ(batch.stats[0].amount, 3);
EXPECT_TRUE(batch.stats[1].gm);
EXPECT_EQ(batch.stats[2].zone, 1200);
}
TEST(EconomyLedgerTests, KeepsEachPropertyApart) {
// Two properties on the same zone, same place: their events and statistics stay apart, and apart from clone 0
Accumulator ledger;
ledger.AddMapEvent(10, 1150, 0, IEconomyLedger::eMapEvent::POWERUP_DROPS, 177, 1.0f, 1.0f, 1);
ledger.AddMapEvent(10, 1150, 7, IEconomyLedger::eMapEvent::POWERUP_DROPS, 177, 1.0f, 1.0f, 1);
ledger.AddMapEvent(10, 1150, 7, IEconomyLedger::eMapEvent::POWERUP_DROPS, 177, 1.5f, 1.5f, 1);
ledger.AddMapEvent(10, 1150, 9, IEconomyLedger::eMapEvent::POWERUP_DROPS, 177, 1.0f, 1.0f, 1);
ledger.AddStat(10, 1150, 7, SmashablesSmashed, false, 2);
ledger.AddStat(10, 1150, 9, SmashablesSmashed, false, 1);
const auto batch = ledger.Take();
ASSERT_EQ(batch.mapEvents.size(), 3);
EXPECT_EQ(batch.mapEvents[0].clone, 0);
EXPECT_EQ(batch.mapEvents[1].clone, 7);
EXPECT_EQ(batch.mapEvents[1].events, 2);
EXPECT_EQ(batch.mapEvents[2].clone, 9);
ASSERT_EQ(batch.stats.size(), 2);
EXPECT_EQ(batch.stats[0].clone, 7);
EXPECT_EQ(batch.stats[0].amount, 2);
EXPECT_EQ(batch.stats[1].clone, 9);
}
TEST(EconomyLedgerTests, TransfersAreKeptInOrder) {
Accumulator ledger;
ledger.AddTransfer({ .time = 1, .method = IEconomyLedger::eTransferMethod::TRADE, .itemId = 5 });
ledger.AddTransfer({ .time = 2, .method = IEconomyLedger::eTransferMethod::MAIL_SENT, .itemId = 6 });
const auto batch = ledger.Take();
ASSERT_EQ(batch.transfers.size(), 2);
EXPECT_EQ(batch.transfers[0].itemId, 5);
EXPECT_EQ(batch.transfers[1].method, IEconomyLedger::eTransferMethod::MAIL_SENT);
EXPECT_TRUE(ledger.Take().transfers.empty());
}

View File

@@ -0,0 +1,25 @@
#include <gtest/gtest.h>
#include "PlayerReports.h"
#include "DashboardNotify.h"
TEST(PlayerReportsLimitTests, FewPerMinuteAndPerDay) {
PlayerReports::RateLimit limit;
for (size_t i = 0; i < PlayerReports::RateLimit::PER_MINUTE; i++) EXPECT_TRUE(limit.Allow(1, 1000));
EXPECT_FALSE(limit.Allow(1, 1010)); // same minute
EXPECT_TRUE(limit.Allow(2, 1010)); // another account is separate
EXPECT_TRUE(limit.Allow(1, 1061)); // a minute later
PlayerReports::RateLimit daily;
int64_t time = 0;
for (size_t i = 0; i < PlayerReports::RateLimit::PER_DAY; i++, time += 61) EXPECT_TRUE(daily.Allow(5, time));
EXPECT_FALSE(daily.Allow(5, time));
EXPECT_TRUE(daily.Allow(5, 24 * 60 * 60 + 1)); // the oldest is a day old
}
TEST(DashboardNotifyTests, AnnouncementChatLine) {
EXPECT_EQ(DashboardNotify::ChatLine("Server restart", "In 5 minutes."), "Server restart: In 5 minutes.");
EXPECT_EQ(DashboardNotify::ChatLine("Challenge complete!", "Builders: 100 bricks!"), "Challenge complete! Builders: 100 bricks!");
EXPECT_EQ(DashboardNotify::ChatLine("Ready?", "Go"), "Ready? Go");
EXPECT_EQ(DashboardNotify::ChatLine("", "Just text"), "Just text");
}

View File

@@ -0,0 +1,269 @@
#include <gtest/gtest.h>
#include "SlashCommandHandler.h"
#include "Permissions.h"
#include "Game.h"
#include "dConfig.h"
namespace {
// Game::config set to a config with these database values for the length of a test
class ScopedConfig {
public:
explicit ScopedConfig(std::map<std::string, std::string> overrides) : m_Previous(Game::config), m_Config("does_not_exist_config.ini") {
m_Config.SetDatabaseValues(std::move(overrides), {});
Game::config = &m_Config;
}
~ScopedConfig() { Game::config = m_Previous; }
private:
dConfig* m_Previous;
dConfig m_Config;
};
Command Paired(const std::string& name, eGameMasterLevel level, const std::string& permission) {
Command command{ .requiredLevel = level, .minLevel = eGameMasterLevel::FORUM_MODERATOR, .dashboardPermission = permission };
command.name = name;
return command;
}
}
// A paired command needs its permission's level, follows it when it changes, and keeps an explicit override
TEST(SlashCommandPermissionTests, PairedCommandUsesThePermissionLevel) {
const auto mute = Paired("mute", eGameMasterLevel::JUNIOR_DEVELOPER, "accounts_mute");
{
ScopedConfig config({});
EXPECT_EQ(static_cast<uint8_t>(SlashCommandHandler::GetRequiredLevel(mute)), Permissions::Find("accounts_mute")->defaultLevel);
}
{
ScopedConfig config({ { "permission_accounts_mute", "5" } });
EXPECT_EQ(SlashCommandHandler::GetRequiredLevel(mute), eGameMasterLevel::LEAD_MODERATOR);
}
{
// An older command_level_mute stays in force
ScopedConfig config({ { "permission_accounts_mute", "5" }, { "command_level_mute", "6" } });
EXPECT_EQ(SlashCommandHandler::GetRequiredLevel(mute), eGameMasterLevel::JUNIOR_DEVELOPER);
}
{
// ...until the page drops it
ScopedConfig config({ { "permission_accounts_mute", "5" }, { "command_level_mute", "permission" } });
EXPECT_EQ(SlashCommandHandler::GetRequiredLevel(mute), eGameMasterLevel::LEAD_MODERATOR);
}
// An unknown permission: an ordinary command with its own level
const auto unknown = Paired("zz_unknown", eGameMasterLevel::MODERATOR, "no_such_permission");
ScopedConfig config({ { "command_level_zz_unknown", "4" } });
EXPECT_EQ(SlashCommandHandler::GetRequiredLevel(unknown), eGameMasterLevel::SENIOR_MODERATOR);
}
TEST(SlashCommandPermissionTests, FloorsApplyToPairedCommands) {
Command command{ .requiredLevel = eGameMasterLevel::DEVELOPER, .minLevel = eGameMasterLevel::DEVELOPER, .dashboardPermission = "accounts_kick" };
command.name = "zz_floor";
ScopedConfig config({ { "permission_accounts_kick", "2" }, { "command_level_zz_floor", "3" } });
EXPECT_EQ(SlashCommandHandler::GetRequiredLevel(command), eGameMasterLevel::DEVELOPER);
Command fixed{ .requiredLevel = eGameMasterLevel::CIVILIAN, .fixedLevel = true, .dashboardPermission = "accounts_kick" };
EXPECT_EQ(SlashCommandHandler::GetRequiredLevel(fixed), eGameMasterLevel::CIVILIAN);
}
// The dashboard's self and rank rules, as the commands apply them
TEST(SlashCommandPermissionTests, TargetRulesMatchTheDashboard) {
using SlashCommandLevels::eTargetRule;
using AccountRules::eManageDenial;
ScopedConfig config({});
// Nobody below GM 9 acts on a higher level; GM 9 on anyone
EXPECT_EQ(SlashCommandHandler::TargetDenial(8, 1, 9, 2, eTargetRule::TOOLS), eManageDenial::HIGHER_RANK);
EXPECT_EQ(SlashCommandHandler::TargetDenial(8, 1, 9, 2, eTargetRule::OTHERS), eManageDenial::HIGHER_RANK);
EXPECT_EQ(SlashCommandHandler::TargetDenial(9, 1, 9, 2, eTargetRule::MODERATION), eManageDenial::NONE);
EXPECT_EQ(SlashCommandHandler::TargetDenial(9, 1, 9, 1, eTargetRule::MODERATION), eManageDenial::NONE);
// Same level: manage_equal_rank (GM 9 by default)
EXPECT_EQ(SlashCommandHandler::TargetDenial(4, 1, 4, 2, eTargetRule::MODERATION), eManageDenial::EQUAL_RANK);
EXPECT_EQ(SlashCommandHandler::TargetDenial(4, 1, 3, 2, eTargetRule::MODERATION), eManageDenial::NONE);
// Themselves: the self permission for the kind (self_tools GM 1, self_items and self_moderation GM 9)
EXPECT_EQ(SlashCommandHandler::TargetDenial(2, 1, 2, 1, eTargetRule::TOOLS), eManageDenial::NONE);
EXPECT_EQ(SlashCommandHandler::TargetDenial(8, 1, 8, 1, eTargetRule::ITEMS), eManageDenial::SELF);
EXPECT_EQ(SlashCommandHandler::TargetDenial(8, 1, 8, 1, eTargetRule::MODERATION), eManageDenial::SELF);
// OTHERS: on themselves as before (like /setlevel without a name)
EXPECT_EQ(SlashCommandHandler::TargetDenial(6, 1, 6, 1, eTargetRule::OTHERS), eManageDenial::NONE);
// NONE: not checked
EXPECT_EQ(SlashCommandHandler::TargetDenial(1, 1, 9, 2, eTargetRule::NONE), eManageDenial::NONE);
// The refusal names the permission
const auto refusal = SlashCommandHandler::TargetRefusal(eManageDenial::SELF, eTargetRule::ITEMS, "mailitem");
EXPECT_EQ(refusal.rfind("/mailitem: ", 0), 0u);
EXPECT_NE(refusal.find("self_items"), std::string::npos);
EXPECT_NE(SlashCommandHandler::TargetRefusal(eManageDenial::EQUAL_RANK, eTargetRule::MODERATION, "ban").find("manage_equal_rank"), std::string::npos);
EXPECT_EQ(SlashCommandHandler::TargetRefusal(eManageDenial::NONE, eTargetRule::TOOLS, "kick"), "");
}
TEST(SlashCommandPermissionTests, TargetRulesFollowThePermissionsPage) {
using SlashCommandLevels::eTargetRule;
using AccountRules::eManageDenial;
ScopedConfig config({ { "permission_self_moderation", "4" }, { "permission_manage_equal_rank", "4" } });
EXPECT_EQ(SlashCommandHandler::TargetDenial(4, 1, 4, 1, eTargetRule::MODERATION), eManageDenial::NONE);
EXPECT_EQ(SlashCommandHandler::TargetDenial(4, 1, 4, 2, eTargetRule::MODERATION), eManageDenial::NONE);
EXPECT_EQ(SlashCommandHandler::TargetDenial(3, 1, 3, 2, eTargetRule::MODERATION), eManageDenial::EQUAL_RANK);
EXPECT_EQ(SlashCommandHandler::TargetDenial(4, 1, 5, 2, eTargetRule::MODERATION), eManageDenial::HIGHER_RANK);
}
#include "GameDependencies.h"
class SlashCommandCatalogTest : public GameDependenciesTest {
protected:
void SetUp() override {
SetUpDependencies();
static bool started = false;
if (!started) SlashCommandHandler::Startup();
started = true;
}
void TearDown() override { TearDownDependencies(); }
};
// Which commands share a dashboard permission's level, and which rule they follow on other players
TEST_F(SlashCommandCatalogTest, PairsAndTargetRules) {
using SlashCommandLevels::eTargetRule;
const std::map<std::string, std::string> pairs{
{ "kick", "accounts_kick" }, { "mute", "accounts_mute" }, { "ban", "accounts_ban" },
{ "announce", "server_announce" }, { "setanntitle", "server_announce" }, { "setannmsg", "server_announce" },
{ "mailitem", "mail_items" }, { "approveproperty", "moderate_properties" },
{ "showall", "players_view" }, { "findplayer", "players_view" }, { "spectate", "players_view" },
{ "shutdown", "worlds_manage" }, { "shutdownuniverse", "server_restart" },
{ "uptime", "health_view" }, { "metrics", "health_view" },
// Only on the user's own character, or a movement tool: their own level (see SlashCommandHandler.cpp)
{ "teleport", "" }, { "gmadditem", "" }, { "givemoney", "" }, { "setcurrency", "" }, { "giveuscore", "" }, { "setlevel", "" },
{ "execute", "" }, { "setgmlevel", "" },
};
for (const auto& [alias, permission] : pairs) {
const auto* command = SlashCommandHandler::FindCommand(alias);
ASSERT_NE(command, nullptr) << alias;
EXPECT_EQ(command->dashboardPermission, permission) << alias;
if (!permission.empty()) {
ASSERT_NE(Permissions::Find(permission), nullptr) << permission;
EXPECT_FALSE(command->fixedLevel) << alias;
}
}
const std::map<std::string, eTargetRule> rules{
{ "kick", eTargetRule::TOOLS }, { "kill", eTargetRule::TOOLS }, { "mailitem", eTargetRule::ITEMS },
{ "ban", eTargetRule::MODERATION }, { "mute", eTargetRule::MODERATION },
{ "teleport", eTargetRule::OTHERS }, { "setlevel", eTargetRule::OTHERS }, { "execute", eTargetRule::OTHERS }, { "tpall", eTargetRule::OTHERS },
{ "gmadditem", eTargetRule::NONE }, { "spectate", eTargetRule::NONE }, { "setgmlevel", eTargetRule::NONE },
};
for (const auto& [alias, rule] : rules) {
const auto* command = SlashCommandHandler::FindCommand(alias);
ASSERT_NE(command, nullptr) << alias;
EXPECT_EQ(command->targetRule, rule) << alias;
}
// /execute keeps its floor even though the self and rank rules now apply to it too
EXPECT_EQ(SlashCommandHandler::GetRequiredLevel(*SlashCommandHandler::FindCommand("execute")), eGameMasterLevel::DEVELOPER);
EXPECT_EQ(SlashCommandHandler::GetRequiredLevel(*SlashCommandHandler::FindCommand("setgmlevel")), eGameMasterLevel::CIVILIAN);
}
// When commands start using their permission's level, a server that had them keeps each one's old level once
TEST(SlashCommandPermissionTests, UpgradeOverrideDecision) {
using SlashCommandLevels::UpgradeOverride;
// Existing server, code level differs from the permission's: keep it (/mute: GM 6, accounts_mute GM 2)
EXPECT_EQ(UpgradeOverride(true, false, false, 6, 1, "", 2), std::optional<uint8_t>(6));
// Same level: nothing to keep
EXPECT_EQ(UpgradeOverride(true, false, false, 2, 1, "", 2), std::nullopt);
// Brand-new server (no stored row), or the upgrade already happened: none
EXPECT_EQ(UpgradeOverride(false, false, false, 6, 1, "", 2), std::nullopt);
EXPECT_EQ(UpgradeOverride(true, true, false, 6, 1, "", 2), std::nullopt);
// An explicit value is already an override; "permission" means follow it
EXPECT_EQ(UpgradeOverride(true, false, false, 6, 1, "4", 2), std::nullopt);
EXPECT_EQ(UpgradeOverride(true, false, false, 6, 1, "permission", 2), std::nullopt);
// A bad value meant the code's level
EXPECT_EQ(UpgradeOverride(true, false, false, 6, 1, "junk", 2), std::optional<uint8_t>(6));
EXPECT_EQ(UpgradeOverride(true, false, true, 6, 1, "", 2), std::nullopt);
}
namespace {
// Just the tables ReportCommands uses
class UpgradeDatabase : public TestSQLDatabase {
public:
std::map<std::string, SlashCommand> commands;
std::map<std::string, Setting> config; // worldconfig.ini, by name
std::vector<std::string> audits;
std::vector<SlashCommand> GetSlashCommands() override {
std::vector<SlashCommand> rows;
for (const auto& [_, row] : commands) rows.push_back(row);
return rows;
}
void SetSlashCommand(const SlashCommand& command) override { commands[command.name] = command; }
std::vector<Setting> GetServerConfig(const std::vector<std::string>&) override {
std::vector<Setting> rows;
for (const auto& [_, row] : config) rows.push_back(row);
return rows;
}
void SetWebConfigValue(const std::string& file, const std::string& name, const std::optional<std::string>& value, bool webWins, const std::string& by) override {
auto& row = config[name];
row.file = file;
row.name = name;
row.webValue = value;
row.webWins = webWins;
row.updatedBy = by;
}
void InsertAuditLog(uint32_t, const std::string_view accountName, const std::string_view action, const std::string_view description, uint32_t, LWOOBJID) override {
audits.push_back(std::string(accountName) + " " + std::string(action) + " " + std::string(description));
}
// What a world's settings sync would hand it
std::map<std::string, std::string> WebValues() const {
std::map<std::string, std::string> values;
for (const auto& [name, row] : config) if (row.webValue) values[name] = *row.webValue;
return values;
}
};
ISlashCommands::SlashCommand OldRow(const std::string& name, uint8_t level, const std::string& permission) {
return { .name = name, .aliases = { name }, .defaultLevel = level, .minLevel = 1, .dashboardPermission = permission };
}
}
TEST_F(SlashCommandCatalogTest, ExistingServerKeepsItsLevelsOnce) {
auto* db = new UpgradeDatabase();
Database::_setDatabase(db);
// Rows written before commands followed their permission
db->commands["mute"] = OldRow("mute", 6, "accounts_mute");
db->commands["kick"] = OldRow("kick", 2, "accounts_kick");
db->commands["mailitem"] = OldRow("mailitem", 3, "mail_items");
db->commands["spectate"] = OldRow("spectate", 2, "");
db->commands["ban"] = OldRow("ban", 4, "accounts_ban");
db->config["command_level_ban"] = { .file = "worldconfig.ini", .name = "command_level_ban", .webValue = "3", .webWins = true, .updatedBy = "admin" };
SlashCommandHandler::ReportCommands();
const std::string upgrade(SlashCommandLevels::UPGRADE_ACTOR);
EXPECT_EQ(db->config.at("command_level_mute").webValue, std::optional<std::string>("6"));
EXPECT_EQ(db->config.at("command_level_mute").updatedBy, upgrade);
EXPECT_EQ(db->config.at("command_level_mailitem").webValue, std::optional<std::string>("3"));
EXPECT_EQ(db->config.at("command_level_spectate").webValue, std::optional<std::string>("2"));
EXPECT_FALSE(db->config.contains("command_level_kick")); // same level as accounts_kick
EXPECT_EQ(db->config.at("command_level_ban").updatedBy, "admin"); // its own value was already an override
EXPECT_FALSE(db->config.contains("command_level_announce")); // not stored before: follows
EXPECT_EQ(db->audits.size(), 3u);
for (const auto& audit : db->audits) EXPECT_EQ(audit.rfind(upgrade + " change_command_level ", 0), 0u) << audit;
EXPECT_TRUE(db->commands.at("mute").followsPermission);
// The world uses the kept level
Game::config->SetDatabaseValues(db->WebValues(), {});
EXPECT_EQ(SlashCommandHandler::GetRequiredLevel(*SlashCommandHandler::FindCommand("mute")), eGameMasterLevel::JUNIOR_DEVELOPER);
// Only once: another start stores nothing new
SlashCommandHandler::ReportCommands();
EXPECT_EQ(db->audits.size(), 3u);
// Dropped on the Permissions page: it follows accounts_mute, and a later start doesn't bring it back
db->SetWebConfigValue("worldconfig.ini", "command_level_mute", std::nullopt, false, "admin");
Game::config->SetDatabaseValues(db->WebValues(), {});
EXPECT_EQ(static_cast<uint8_t>(SlashCommandHandler::GetRequiredLevel(*SlashCommandHandler::FindCommand("mute"))), Permissions::Level("accounts_mute"));
SlashCommandHandler::ReportCommands();
EXPECT_FALSE(db->config.at("command_level_mute").webValue.has_value());
EXPECT_EQ(db->audits.size(), 3u);
}
TEST_F(SlashCommandCatalogTest, NewServerFollowsThePermissions) {
auto* db = new UpgradeDatabase();
Database::_setDatabase(db);
SlashCommandHandler::ReportCommands();
EXPECT_TRUE(db->config.empty());
EXPECT_TRUE(db->audits.empty());
EXPECT_TRUE(db->commands.at("mute").followsPermission);
EXPECT_FALSE(db->commands.at("gmadditem").followsPermission);
Game::config->SetDatabaseValues({}, {});
EXPECT_EQ(static_cast<uint8_t>(SlashCommandHandler::GetRequiredLevel(*SlashCommandHandler::FindCommand("mute"))), Permissions::Level("accounts_mute"));
}

View File

@@ -5,7 +5,9 @@ SET(DGAMEMESSAGES_TESTS
"MissionMessagesTests.cpp"
"RacingMessagesTests.cpp"
"UnSmashTests.cpp"
"LegacyGameMessageTests.cpp")
"LegacyGameMessageTests.cpp"
"GameMessageDecoderTests.cpp"
"HotPropertyMessageTests.cpp")
# Get the folder name and prepend it to the files above
get_filename_component(thisFolderName ${CMAKE_CURRENT_SOURCE_DIR} NAME)

View File

@@ -0,0 +1,73 @@
#include <gtest/gtest.h>
#include "GameMessageDecoder.h"
#include "dCommonVars.h"
#include "MessageType/Game.h"
#include "EchoSyncSkill.h"
#include "StartSkill.h"
TEST(GameMessageDecoderTest, DecodesATypedClientMessage) {
RakNet::BitStream payload;
payload.Write(true); // bIsMultiInteractUse
payload.Write<uint32_t>(3); // multiInteractID
payload.Write<int32_t>(2); // multiInteractType
payload.Write<LWOOBJID>(1152921504606846999LL); // object
payload.Write(false); // secondary
ASSERT_TRUE(GameMessageDecoder::CanDecode(MessageType::Game::REQUEST_USE, true));
const auto fields = GameMessageDecoder::Decode(MessageType::Game::REQUEST_USE, true, payload);
ASSERT_TRUE(fields);
EXPECT_EQ((*fields)["bIsMultiInteractUse"], true);
EXPECT_EQ((*fields)["multiInteractID"], 3);
EXPECT_EQ((*fields)["multiInteractType"], 2);
// Object IDs are strings, since they don't fit in a JavaScript number
EXPECT_EQ((*fields)["object"], "1152921504606846999");
EXPECT_EQ((*fields)["secondary"], false);
}
TEST(GameMessageDecoderTest, ReadsWhatTheServerWrites) {
// The server's own Serialize writes the message ID first; the decoder gets what follows it
StartSkill sent(1234, std::string("\x01\x02", 2), 42);
sent.optionalTargetID = 77;
RakNet::BitStream stream;
sent.Serialize(stream);
MessageType::Game id{};
ASSERT_TRUE(stream.Read(id));
ASSERT_EQ(id, MessageType::Game::START_SKILL);
const auto fields = GameMessageDecoder::Decode(MessageType::Game::START_SKILL, true, stream);
ASSERT_TRUE(fields);
EXPECT_EQ((*fields)["skillID"], 42);
EXPECT_EQ((*fields)["optionalOriginatorID"], "1234");
EXPECT_EQ((*fields)["optionalTargetID"], "77");
EXPECT_EQ((*fields)["sBitStream"], "0102");
}
TEST(GameMessageDecoderTest, DirectionMatters) {
EchoSyncSkill sent;
sent.bDone = true;
sent.uiSkillHandle = 5;
RakNet::BitStream stream;
sent.Serialize(stream);
MessageType::Game id{};
ASSERT_TRUE(stream.Read(id));
EXPECT_FALSE(GameMessageDecoder::CanDecode(MessageType::Game::ECHO_SYNC_SKILL, true));
const auto fields = GameMessageDecoder::Decode(MessageType::Game::ECHO_SYNC_SKILL, false, stream);
ASSERT_TRUE(fields);
EXPECT_EQ((*fields)["bDone"], true);
EXPECT_EQ((*fields)["uiSkillHandle"], 5);
}
TEST(GameMessageDecoderTest, UntypedMessagesAreNotDecoded) {
RakNet::BitStream payload;
payload.Write<uint32_t>(1);
EXPECT_FALSE(GameMessageDecoder::CanDecode(MessageType::Game::PLAY_EMOTE, true));
EXPECT_FALSE(GameMessageDecoder::Decode(MessageType::Game::PLAY_EMOTE, true, payload));
}
TEST(GameMessageDecoderTest, ShortPayloadsFail) {
RakNet::BitStream payload;
payload.Write<LWOOBJID>(1); // PickupItem needs two IDs
EXPECT_FALSE(GameMessageDecoder::Decode(MessageType::Game::PICKUP_ITEM, true, payload));
}

View File

@@ -0,0 +1,77 @@
#include "GameMessages.h"
#include "BitStream.h"
#include <gtest/gtest.h>
namespace {
std::u16string ReadWString(RakNet::BitStream& stream) {
uint32_t size{};
EXPECT_TRUE(stream.Read(size));
std::u16string text;
for (uint32_t i = 0; i < size; i++) {
uint16_t character{};
EXPECT_TRUE(stream.Read(character));
text.push_back(character);
}
return text;
}
}
// The layout NewsHotPropertyInfo::Deserialize (0x00c0cc20 in client 1.10.64) reads, and live servers sent
TEST(HotPropertyMessageTests, SerializesEntriesInClientOrder) {
GameMessages::NewsSendHotPropertiesInfoToClient message;
GameMessages::NewsSendHotPropertiesInfoToClient::HotPropertyInfo info;
info.propertyId = 0x1000000000000123;
info.ownerId = 0x1000000000000456;
info.ownerName = u"Owner";
info.reputation = 12345;
info.templateId = 25166;
info.name = u"Name";
info.description = u"Desc";
info.performanceCost = 42.5f;
info.lastPublished = 1318111406;
info.cloneId = 1278333;
message.properties = { info, info };
message.properties[1].templateId = 25188;
RakNet::BitStream stream;
message.Serialize(stream);
uint32_t count{};
ASSERT_TRUE(stream.Read(count));
ASSERT_EQ(count, 2u);
for (const int32_t expectedTemplate : { 25166, 25188 }) {
LWOOBJID propertyId{}, ownerId{};
uint64_t reputation{}, lastPublished{};
int32_t templateId{};
float performanceCost{};
uint32_t cloneId{};
ASSERT_TRUE(stream.Read(propertyId));
ASSERT_TRUE(stream.Read(ownerId));
EXPECT_EQ(ReadWString(stream), u"Owner");
ASSERT_TRUE(stream.Read(reputation));
ASSERT_TRUE(stream.Read(templateId));
EXPECT_EQ(ReadWString(stream), u"Name");
EXPECT_EQ(ReadWString(stream), u"Desc");
ASSERT_TRUE(stream.Read(performanceCost));
ASSERT_TRUE(stream.Read(lastPublished));
ASSERT_TRUE(stream.Read(cloneId));
EXPECT_EQ(propertyId, info.propertyId);
EXPECT_EQ(ownerId, info.ownerId);
EXPECT_EQ(reputation, 12345u);
EXPECT_EQ(templateId, expectedTemplate);
EXPECT_FLOAT_EQ(performanceCost, 42.5f);
EXPECT_EQ(lastPublished, 1318111406u);
EXPECT_EQ(cloneId, 1278333u);
}
// 4 + 2 * (8 + 8 + (4 + 5*2) + 8 + 4 + (4 + 4*2) + (4 + 4*2) + 4 + 8 + 4) = 4 + 2 * 82 bytes, nothing more
EXPECT_EQ(stream.GetNumberOfUnreadBits(), 0u);
EXPECT_EQ(stream.GetNumberOfBytesUsed(), 4u + 2u * 82u);
}
TEST(HotPropertyMessageTests, EmptyListIsJustTheCount) {
GameMessages::NewsSendHotPropertiesInfoToClient message;
RakNet::BitStream stream;
message.Serialize(stream);
EXPECT_EQ(stream.GetNumberOfBytesUsed(), 4u);
}