mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-15 17:14:07 +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:
@@ -2,6 +2,9 @@ set(DGAMETEST_SOURCES
|
||||
"GameDependencies.cpp"
|
||||
"LUBitStreamTests.cpp"
|
||||
"MailIdPinTests.cpp"
|
||||
"EconomyLedgerTests.cpp"
|
||||
"PlayerReportsLimitTests.cpp"
|
||||
"SlashCommandPermissionTests.cpp"
|
||||
)
|
||||
|
||||
add_subdirectory(dComponentsTests)
|
||||
|
||||
120
tests/dGameTests/EconomyLedgerTests.cpp
Normal file
120
tests/dGameTests/EconomyLedgerTests.cpp
Normal 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());
|
||||
}
|
||||
25
tests/dGameTests/PlayerReportsLimitTests.cpp
Normal file
25
tests/dGameTests/PlayerReportsLimitTests.cpp
Normal 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");
|
||||
}
|
||||
269
tests/dGameTests/SlashCommandPermissionTests.cpp
Normal file
269
tests/dGameTests/SlashCommandPermissionTests.cpp
Normal 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"));
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user