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

@@ -0,0 +1,55 @@
#include <gtest/gtest.h>
#include "AccountRules.h"
#include "Permissions.h"
#include "Game.h"
#include "dConfig.h"
using namespace AccountRules;
TEST(AccountRulesTests, RankAndSelfRules) {
// GM 9 acts on anyone, themselves and other GM 9s included
EXPECT_EQ(ManageDenial(9, 1, 9, 2, false, false), eManageDenial::NONE);
EXPECT_EQ(ManageDenial(9, 1, 9, 1, false, false), eManageDenial::NONE);
// Below GM 9: never a higher level, whatever the permissions say
EXPECT_EQ(ManageDenial(8, 1, 9, 2, true, true), eManageDenial::HIGHER_RANK);
// Their own level only with manage_equal_rank
EXPECT_EQ(ManageDenial(4, 1, 4, 2, true, false), eManageDenial::EQUAL_RANK);
EXPECT_EQ(ManageDenial(4, 1, 4, 2, false, true), eManageDenial::NONE);
// Themselves only with the self permission
EXPECT_EQ(ManageDenial(4, 1, 4, 1, false, true), eManageDenial::SELF);
EXPECT_EQ(ManageDenial(4, 1, 4, 1, true, false), eManageDenial::NONE);
// Lower levels: fine
EXPECT_EQ(ManageDenial(2, 1, 0, 2, false, false), eManageDenial::NONE);
}
TEST(AccountRulesTests, MessagesNameThePermission) {
EXPECT_NE(DenialMessage(eManageDenial::SELF, eAccountAction::ITEMS).find("self_items"), std::string::npos);
EXPECT_NE(DenialMessage(eManageDenial::SELF, eAccountAction::MODERATION).find("self_moderation"), std::string::npos);
EXPECT_NE(DenialMessage(eManageDenial::SELF, eAccountAction::TOOLS).find("self_tools"), std::string::npos);
EXPECT_NE(DenialMessage(eManageDenial::EQUAL_RANK, eAccountAction::TOOLS).find("manage_equal_rank"), std::string::npos);
EXPECT_NE(DenialMessage(eManageDenial::HIGHER_RANK, eAccountAction::TOOLS).find("higher GM level"), std::string::npos);
EXPECT_EQ(DenialMessage(eManageDenial::NONE, eAccountAction::TOOLS), "");
EXPECT_NE(LastOperatorMessage("banned").find("banned"), std::string::npos);
}
// ManageDenialNow reads the self_* and manage_equal_rank levels from the config, as any server sees them
TEST(AccountRulesTests, ManageDenialNowUsesPermissionLevels) {
auto* previous = Game::config;
dConfig config("does_not_exist_config.ini");
Game::config = &config;
// Defaults: self_tools GM 1, self_items / self_moderation / manage_equal_rank GM 9
EXPECT_EQ(ManageDenialNow(3, 7, 3, 7, eAccountAction::TOOLS), eManageDenial::NONE);
EXPECT_EQ(ManageDenialNow(8, 7, 8, 7, eAccountAction::ITEMS), eManageDenial::SELF);
EXPECT_EQ(ManageDenialNow(8, 7, 8, 7, eAccountAction::MODERATION), eManageDenial::SELF);
EXPECT_EQ(ManageDenialNow(8, 7, 8, 6, eAccountAction::TOOLS), eManageDenial::EQUAL_RANK);
// Lowered on the Permissions page (values the dashboard stored)
config.SetDatabaseValues({ { Permissions::ConfigName("self_items"), "8" }, { Permissions::ConfigName("manage_equal_rank"), "8" } }, {});
EXPECT_EQ(ManageDenialNow(8, 7, 8, 7, eAccountAction::ITEMS), eManageDenial::NONE);
EXPECT_EQ(ManageDenialNow(8, 7, 8, 6, eAccountAction::TOOLS), eManageDenial::NONE);
EXPECT_EQ(ManageDenialNow(8, 7, 9, 6, eAccountAction::TOOLS), eManageDenial::HIGHER_RANK);
Game::config = previous;
}

View File

@@ -14,6 +14,18 @@ set(DCOMMONTEST_SOURCES
"MessageIdPinTests.cpp"
"dCommonDependencies.cpp"
"LxfmlTests.cpp"
"MigrationSplitTests.cpp"
"ConfigLayerTests.cpp"
"SlashCommandLevelsTests.cpp"
"AccountRulesTests.cpp"
"VanityXmlTests.cpp"
"EventPartsTests.cpp"
"ScheduleRulesTests.cpp"
"VanityEventsTests.cpp"
"ZoneFileTests.cpp"
"LiveOpsRulesTests.cpp"
"ProcessAndLimitsTests.cpp"
"HotPropertySlotsTests.cpp"
)
add_subdirectory(dEnumsTests)
@@ -32,6 +44,8 @@ endif()
# Link needed libraries
target_link_libraries(dCommonTests ${COMMON_LIBRARIES} GTest::gtest_main)
# SpareBackoff.h (header only)
target_include_directories(dCommonTests PRIVATE "${PROJECT_SOURCE_DIR}/dMasterServer")
# Copy test files to testing directory
add_subdirectory(TestBitStreams)
@@ -39,5 +53,8 @@ file(COPY ${TESTBITSTREAMS} DESTINATION ${CMAKE_CURRENT_BINARY_DIR})
add_subdirectory(LxfmlTestFiles)
file(COPY ${LXFMLTESTFILES} DESTINATION ${CMAKE_CURRENT_BINARY_DIR})
# VanityXmlTests reads the shipped vanity files
target_compile_definitions(dCommonTests PRIVATE DLU_SOURCE_DIR="${PROJECT_SOURCE_DIR}")
# Discover the tests
gtest_discover_tests(dCommonTests)

View File

@@ -0,0 +1,113 @@
#include <gtest/gtest.h>
#include <cstdlib>
#include "dConfig.h"
#include "ConfigSync.h"
namespace {
void SetEnv(const char* name, const char* value) {
#ifdef _WIN32
_putenv_s(name, value ? value : "");
#else
if (value) setenv(name, value, 1); else unsetenv(name);
#endif
}
}
TEST(ConfigLayerTests, DatabaseValuesLayerAroundFilesAndEnvironment) {
dConfig config("does_not_exist_config.ini");
config.SetDatabaseValues({ { "zz_override", "web" }, { "mysql_host", "evil" } }, { { "zz_fallback", "web" }, { "zz_both", "web" } });
EXPECT_EQ(config.GetValue("zz_override"), "web");
EXPECT_EQ(config.GetValue("zz_fallback"), "web");
EXPECT_EQ(config.GetValue("mysql_host"), ""); // connection settings never come from the database
EXPECT_EQ(config.GetValue("zz_missing"), "");
// The environment beats a fallback but not an override
SetEnv("ZZ_BOTH", "env");
SetEnv("ZZ_OVERRIDE", "env");
EXPECT_EQ(config.GetValue("zz_both"), "env");
EXPECT_EQ(config.GetValue("zz_override"), "web");
SetEnv("ZZ_BOTH", nullptr);
SetEnv("ZZ_OVERRIDE", nullptr);
}
TEST(ConfigLayerTests, KeyRules) {
EXPECT_TRUE(dConfig::IsFileOnlyKey("mysql_password"));
EXPECT_TRUE(dConfig::IsFileOnlyKey("database_type"));
EXPECT_TRUE(dConfig::IsFileOnlyKey("jwt_secret"));
EXPECT_FALSE(dConfig::IsFileOnlyKey("max_clients"));
EXPECT_TRUE(dConfig::IsSecretKey("smtp_password"));
EXPECT_TRUE(dConfig::IsSecretKey("smtp_oauth2_client_secret"));
EXPECT_TRUE(dConfig::IsSecretKey("totp_key"));
EXPECT_FALSE(dConfig::IsSecretKey("smtp_host"));
}
TEST(ConfigLayerTests, ResolveOwnFileFirstAndRespectLocalValues) {
auto row = [](std::string file, std::string name, std::optional<std::string> web, bool wins) {
IServerConfig::Setting setting;
setting.file = std::move(file);
setting.name = std::move(name);
setting.webValue = std::move(web);
setting.webWins = wins;
return setting;
};
const std::vector<IServerConfig::Setting> rows{
row("sharedconfig.ini", "max_clients", "50", false),
row("worldconfig.ini", "max_clients", "80", false), // own file beats shared for the same key
row("sharedconfig.ini", "log_debug_statements", "1", true),
row("sharedconfig.ini", "client_location", "/web", false), // set in the file locally: fallback ignored
row("sharedconfig.ini", "no_web_value", std::nullopt, true),
};
const auto resolved = ConfigSync::Resolve(rows, "worldconfig.ini", { "client_location" });
EXPECT_EQ(resolved.fallbacks.at("max_clients"), "80");
EXPECT_EQ(resolved.overrides.at("log_debug_statements"), "1");
EXPECT_FALSE(resolved.fallbacks.contains("client_location"));
EXPECT_FALSE(resolved.overrides.contains("no_web_value"));
EXPECT_FALSE(resolved.fallbacks.contains("no_web_value"));
}
// The worlds read the dashboard's permission levels from what it stored: its web value, its file or environment, then a web fallback
TEST(ConfigLayerTests, OwnedValuesComeFromTheOwner) {
auto row = [](std::string name, std::optional<std::string> web, bool wins, std::string source, std::optional<std::string> file) {
IServerConfig::Setting setting;
setting.file = "dashboardconfig.ini";
setting.name = std::move(name);
setting.webValue = std::move(web);
setting.webWins = wins;
setting.fileSource = std::move(source);
setting.fileValue = std::move(file);
return setting;
};
std::vector<IServerConfig::Setting> rows{
row("permission_accounts_kick", "3", true, "file", "5"), // page beats the file
row("permission_accounts_ban", std::nullopt, false, "file", "6"), // the dashboard's file
row("permission_accounts_mute", std::nullopt, false, "env", "4"), // the dashboard's environment
row("permission_players_view", "2", false, "", std::nullopt), // web value without override: a fallback
row("permission_self_items", std::nullopt, false, "", std::nullopt), // cleared: nothing
row("dashboard_port", "8080", true, "file", "80"), // not a permission
};
rows.push_back(row("permission_other_file", "1", true, "", std::nullopt));
rows.back().file = "worldconfig.ini";
ConfigSync::Resolved resolved;
resolved.overrides["permission_accounts_ban"] = "9"; // what the reader had: replaced
resolved.fallbacks["permission_accounts_kick"] = "8";
ConfigSync::AddOwnedValues(resolved, rows, "dashboardconfig.ini", "permission_");
EXPECT_EQ(resolved.overrides.at("permission_accounts_kick"), "3");
EXPECT_FALSE(resolved.fallbacks.contains("permission_accounts_kick"));
EXPECT_EQ(resolved.overrides.at("permission_accounts_ban"), "6");
EXPECT_EQ(resolved.overrides.at("permission_accounts_mute"), "4");
EXPECT_EQ(resolved.fallbacks.at("permission_players_view"), "2");
EXPECT_FALSE(resolved.overrides.contains("permission_players_view"));
EXPECT_FALSE(resolved.overrides.contains("permission_self_items"));
EXPECT_FALSE(resolved.fallbacks.contains("permission_self_items"));
EXPECT_FALSE(resolved.overrides.contains("dashboard_port"));
EXPECT_FALSE(resolved.overrides.contains("permission_other_file"));
// Layered into a world's config: the dashboard's value wins over the world's own files and environment
dConfig config("does_not_exist_config.ini");
config.SetDatabaseValues(std::move(resolved.overrides), std::move(resolved.fallbacks));
EXPECT_EQ(config.GetValue("permission_accounts_ban"), "6");
EXPECT_EQ(config.GetValue("permission_players_view"), "2");
}

View File

@@ -0,0 +1,98 @@
#include <gtest/gtest.h>
#include "EventParts.h"
using namespace EventParts;
namespace {
Part Make(eKind kind, nlohmann::json config, bool applied = false) {
return { .kind = kind, .config = std::move(config), .applied = applied };
}
}
TEST(EventPartsTests, KindNames) {
EXPECT_EQ(KindName(eKind::LIVE_EVENT), "live_event");
EXPECT_EQ(KindOf("announcement"), eKind::ANNOUNCEMENT);
EXPECT_EQ(KindOf("Vanity"), eKind::VANITY);
EXPECT_FALSE(KindOf("party").has_value());
}
TEST(EventPartsTests, ParseAndWrite) {
std::string error;
EXPECT_TRUE(Parse("", error)->empty());
// applied may be 1, as the migration from the calendar and vanity events wrote it
const auto parts = Parse(R"([{"kind":"feature","config":{"feature":"oct2011content"},"applied":1,"state":{"slot":3},"status":"On in event_3"},{"kind":"vanity"}])", error);
ASSERT_TRUE(parts) << error;
ASSERT_EQ(parts->size(), 2u);
EXPECT_EQ((*parts)[0].kind, eKind::FEATURE);
EXPECT_TRUE((*parts)[0].applied);
EXPECT_EQ((*parts)[0].state["slot"], 3);
EXPECT_FALSE((*parts)[1].applied);
EXPECT_TRUE((*parts)[1].config.is_object());
const auto again = Parse(Write(*parts), error);
ASSERT_TRUE(again);
EXPECT_EQ(Write(*again), Write(*parts));
EXPECT_FALSE(Parse("{}", error));
EXPECT_FALSE(Parse(R"([{"kind":"party"}])", error));
EXPECT_NE(error.find("live_event"), std::string::npos); // says what the kinds are
EXPECT_FALSE(Parse("[", error));
}
TEST(EventPartsTests, Steps) {
const auto idle = Make(eKind::FEATURE, {});
const auto started = Make(eKind::FEATURE, {}, true);
EXPECT_EQ(StepFor(idle, true), eStep::START);
EXPECT_EQ(StepFor(started, true), eStep::CONTINUE);
EXPECT_EQ(StepFor(started, false), eStep::END);
EXPECT_EQ(StepFor(idle, false), eStep::NONE);
}
TEST(EventPartsTests, ReconcileKeepsWhatDidntChange) {
auto feature = Make(eKind::FEATURE, { {"feature", "oct2011content"} }, true);
feature.state = { {"slot", 2} };
const auto announcement = Make(eKind::ANNOUNCEMENT, { {"message", "Boo"} }, true);
const auto vanity = Make(eKind::VANITY, { {"file", "halloween.xml"} }, false);
auto live = Make(eKind::LIVE_EVENT, { {"type", "bonus"} }, true);
live.state = { {"id", 7} };
// The feature stays, the announcement's text and the live event changed, the vanity part is gone, a restart is new
const auto result = Reconcile({ feature, announcement, vanity, live }, {
Make(eKind::ANNOUNCEMENT, { {"message", "Boo!"} }), Make(eKind::FEATURE, { {"feature", "oct2011content"} }), Make(eKind::LIVE_EVENT, { {"type", "invasion"} }, true),
Make(eKind::RESTART, { {"when", "end"} }, true) });
ASSERT_EQ(result.parts.size(), 4u);
EXPECT_TRUE(result.parts[0].applied); // momentary: already said, not said again
EXPECT_TRUE(result.parts[1].applied); // the same: keeps its slot
EXPECT_EQ(result.parts[1].state["slot"], 2);
EXPECT_FALSE(result.parts[2].applied); // changed: starts afresh
EXPECT_FALSE(result.parts[3].applied); // new, whatever the page sent
// Only started parts that are gone or changed are undone
ASSERT_EQ(result.retired.size(), 1u);
EXPECT_EQ(result.retired[0].state["id"], 7);
// Two of the same kind and config are matched one to one
const auto twice = Reconcile({ announcement }, { Make(eKind::ANNOUNCEMENT, { {"message", "Boo"} }), Make(eKind::ANNOUNCEMENT, { {"message", "Boo"} }) });
EXPECT_TRUE(twice.parts[0].applied);
EXPECT_FALSE(twice.parts[1].applied);
EXPECT_TRUE(twice.retired.empty());
}
TEST(EventPartsTests, VanityChanges) {
const auto changes = VanityChanges("Halloween", R"([
{"kind":"feature","config":{"feature":"oct2011content"}},
{"kind":"vanity","config":{"file":"halloween-look.xml","removals":["Bob","Alice"],"fileSwitches":{"halloween.xml":true,"summer.xml":false}}},
{"kind":"vanity","config":{"removals":"Carl\n","file":7}}])");
ASSERT_EQ(changes.size(), 2u);
EXPECT_EQ(changes[0].name, "Halloween");
EXPECT_EQ(changes[0].file, "halloween-look.xml");
EXPECT_EQ(changes[0].removals, "Bob\nAlice\n");
std::string error;
const auto switches = VanityEvents::ParseFileSwitches(changes[0].fileSwitches, error);
ASSERT_TRUE(switches) << error;
EXPECT_EQ(switches->at("summer.xml"), false);
EXPECT_EQ(changes[1].file, ""); // not a name: ignored rather than failing the world
EXPECT_EQ(changes[1].removals, "Carl\n");
EXPECT_TRUE(VanityChanges("Broken", "[").empty());
}

View File

@@ -0,0 +1,180 @@
#include "HotPropertySlots.h"
#include <gtest/gtest.h>
using namespace HotPropertySlots;
namespace {
// A cut of the 1.10.64 CDClient: test and unused rows share the spawn names; only the live worlds have entrances
const std::vector<TemplateRow> TEMPLATES{
{ 25218, 58001, "AGSmallProperty" }, { 25219, 521, "AGSmallProperty" }, { 25220, 162, "GFSmallProperty" },
{ 25221, 63, "NSSmallProperty" }, { 25166, 1150, "AGSmallProperty" }, { 25168, 1151, "AGMedProperty" },
{ 25188, 1250, "NSSmallProperty" }, { 25189, 1251, "NSMedProperty" }, { 25191, 1350, "GFSmallProperty" },
{ 25194, 1450, "FVSmallProperty" }, { 25208, 45, "FVSmallProperty" }, { 25216, 542, "TestPath" },
};
const std::vector<EntranceRow> ENTRANCES{
{ 1150, "AGSmallProperty" }, { 1150, "AGSmallProperty" }, { 1151, "AGMedProperty" }, { 1250, "NSSmallProperty" },
{ 1251, "NSMedProperty" }, { 1350, "GFSmallProperty" }, { 1450, "FVSmallProperty" },
};
}
// The four slots are the template ids live sent: 25166, 25188, 25191, 25194, in that order
TEST(HotPropertySlotsTests, ResolvesTheFourLiveSlots) {
const auto slots = ResolveSlots(TEMPLATES, ENTRANCES);
const std::vector<Slot> expected{
{ 25166, 1150, "AGSmallProperty" }, { 25188, 1250, "NSSmallProperty" }, { 25191, 1350, "GFSmallProperty" }, { 25194, 1450, "FVSmallProperty" },
};
EXPECT_EQ(slots, expected);
}
TEST(HotPropertySlotsTests, WorldsTheNewsScreenCannotShowHaveNoSlot) {
for (const auto& slot : ResolveSlots(TEMPLATES, ENTRANCES)) {
EXPECT_NE(slot.mapId, 1151u);
EXPECT_NE(slot.mapId, 1251u);
}
}
TEST(HotPropertySlotsTests, SlotWithoutEntranceIsLeftOut) {
const std::vector<EntranceRow> entrances{ { 1250, "NSSmallProperty" }, { 1450, "FVSmallProperty" } };
const auto slots = ResolveSlots(TEMPLATES, entrances);
ASSERT_EQ(slots.size(), 2u);
EXPECT_EQ(slots[0].templateId, 25188u);
EXPECT_EQ(slots[1].templateId, 25194u);
EXPECT_TRUE(ResolveSlots(TEMPLATES, {}).empty());
}
TEST(HotPropertySlotsTests, LowestTemplateIdWinsWhenSeveralMatch) {
const std::vector<TemplateRow> templates{ { 30000, 1150, "AGSmallProperty" }, { 25166, 1150, "AGSmallProperty" } };
const auto slots = ResolveSlots(templates, ENTRANCES);
ASSERT_EQ(slots.size(), 1u);
EXPECT_EQ(slots[0].templateId, 25166u);
}
TEST(HotPropertySlotsTests, OnlyApprovedPublicPropertiesOfTheSlotsWorld) {
EXPECT_TRUE(Featurable(1, 2, 1150, 1150));
EXPECT_FALSE(Featurable(0, 2, 1150, 1150)); // awaiting review
EXPECT_FALSE(Featurable(2, 2, 1150, 1150));
EXPECT_FALSE(Featurable(1, 1, 1150, 1150)); // friends only
EXPECT_FALSE(Featurable(1, 0, 1150, 1150)); // private
EXPECT_FALSE(Featurable(1, 2, 1151, 1150)); // another world
}
// news.gfx always walks four entries and reuses the previous slot for a missing one
TEST(HotPropertySlotsTests, NewsOrderPadsWithTheLastEntry) {
EXPECT_TRUE(NewsOrder(0).empty());
EXPECT_EQ(NewsOrder(1), (std::vector<size_t>{ 0, 0, 0, 0 }));
EXPECT_EQ(NewsOrder(2), (std::vector<size_t>{ 0, 1, 1, 1 }));
EXPECT_EQ(NewsOrder(3), (std::vector<size_t>{ 0, 1, 2, 2 }));
EXPECT_EQ(NewsOrder(4), (std::vector<size_t>{ 0, 1, 2, 3 }));
}
TEST(HotPropertySlotsTests, Modes) {
for (const auto mode : { eMode::AUTO, eMode::PICKED, eMode::EMPTY }) {
EXPECT_EQ(ParseMode(ModeName(mode)), mode);
EXPECT_EQ(ModeFromInt(static_cast<int64_t>(mode)), mode);
}
EXPECT_EQ(ParseMode("bogus"), std::nullopt);
EXPECT_EQ(ModeFromInt(7), eMode::AUTO);
// Stored values the database keeps; never renumber
EXPECT_EQ(static_cast<int>(eMode::AUTO), 0);
EXPECT_EQ(static_cast<int>(eMode::PICKED), 1);
EXPECT_EQ(static_cast<int>(eMode::EMPTY), 2);
}
// Every property world players can launch to, small and medium; test and unused maps are left out
TEST(HotPropertySlotsTests, PropertyWorldsAreTheEnteredTemplateMaps) {
EXPECT_EQ(PropertyWorlds(TEMPLATES, ENTRANCES), (std::vector<uint32_t>{ 1150, 1151, 1250, 1251, 1350, 1450 }));
EXPECT_TRUE(PropertyWorlds(TEMPLATES, {}).empty());
}
TEST(HotPropertySlotsTests, LocationDefaultsToTheSlotsWorld) {
const auto worlds = PropertyWorlds(TEMPLATES, ENTRANCES);
const Slot blockYard{ 25166, 1150, "AGSmallProperty" };
EXPECT_EQ(Location(0, blockYard, worlds), 1150u); // rows from before locations
EXPECT_EQ(Location(1251, blockYard, worlds), 1251u); // Nimbus Isle
EXPECT_EQ(Location(58001, blockYard, worlds), 1150u); // not a property world (any more)
}
namespace {
// Approved public properties: 1xx on Block Yard (1150), 2xx on Avant Grove (1151), 3xx on Nimbus Rock (1250)
const std::vector<Candidate> CANDIDATES{
{ 101, 1150, 500 }, { 102, 1150, 400 }, { 103, 1150, 300 }, { 201, 1151, 900 }, { 202, 1151, 50 }, { 301, 1250, 700 },
};
std::vector<std::optional<int64_t>> Shown(const std::vector<Showing>& showing) {
std::vector<std::optional<int64_t>> ids;
for (const auto& slot : showing) ids.push_back(slot.propertyId);
return ids;
}
using Ids = std::vector<std::optional<int64_t>>;
}
TEST(HotPropertySlotsTests, AutoShowsEachLocationsBest) {
const std::vector<Choice> choices{ { eMode::AUTO, 1150 }, { eMode::AUTO, 1250 }, { eMode::AUTO, 1350 }, { eMode::AUTO, 1151 } };
EXPECT_EQ(Shown(Resolve(choices, false, CANDIDATES)), (Ids{ 101, 301, std::nullopt, 201 }));
}
TEST(HotPropertySlotsTests, AutosOnOneLocationShowItsFirstAndSecond) {
const std::vector<Choice> choices{ { eMode::AUTO, 1150 }, { eMode::AUTO, 1150 }, { eMode::AUTO, 1150 }, { eMode::AUTO, 1150 } };
EXPECT_EQ(Shown(Resolve(choices, false, CANDIDATES)), (Ids{ 101, 102, 103, std::nullopt }));
}
TEST(HotPropertySlotsTests, PicksComeFirstAndAutosSkipThem) {
// The first slot's auto would be 101, but a later slot picked it
const std::vector<Choice> choices{ { eMode::AUTO, 1150 }, { eMode::PICKED, 1150, 101 }, { eMode::EMPTY, 1350 }, { eMode::AUTO, 1150 } };
const auto showing = Resolve(choices, false, CANDIDATES);
EXPECT_EQ(Shown(showing), (Ids{ 102, 101, std::nullopt, 103 }));
EXPECT_FALSE(showing[1].pickFellBack);
}
TEST(HotPropertySlotsTests, APropertyPickedTwiceIsShownOnce) {
const std::vector<Choice> choices{ { eMode::PICKED, 1150, 103 }, { eMode::PICKED, 1150, 103 }, { eMode::AUTO, 1250 }, { eMode::AUTO, 1250 } };
const auto showing = Resolve(choices, false, CANDIDATES);
EXPECT_EQ(Shown(showing), (Ids{ 103, 101, 301, std::nullopt }));
EXPECT_FALSE(showing[0].pickFellBack);
EXPECT_TRUE(showing[1].pickFellBack);
}
TEST(HotPropertySlotsTests, AnUnavailablePickFallsBackToAuto) {
// 999 is no candidate (no longer approved or public); 301 is, but not of the slot's location
const std::vector<Choice> choices{ { eMode::PICKED, 1150, 999 }, { eMode::PICKED, 1150, 301 }, { eMode::AUTO, 1150 }, { eMode::EMPTY, 1450 } };
const auto showing = Resolve(choices, false, CANDIDATES);
EXPECT_EQ(Shown(showing), (Ids{ 101, 102, 103, std::nullopt }));
EXPECT_TRUE(showing[0].pickFellBack);
EXPECT_TRUE(showing[1].pickFellBack);
EXPECT_FALSE(showing[2].pickFellBack);
}
TEST(HotPropertySlotsTests, APickOfAnotherWorld) {
const std::vector<Choice> choices{ { eMode::PICKED, 1151, 202 }, { eMode::AUTO, 1151 }, { eMode::AUTO, 1151 }, { eMode::AUTO, 1250 } };
EXPECT_EQ(Shown(Resolve(choices, false, CANDIDATES)), (Ids{ 202, 201, std::nullopt, 301 }));
}
TEST(HotPropertySlotsTests, FullAutoShowsTheTopFourAcrossEveryWorld) {
// The choices don't matter
const std::vector<Choice> choices{ { eMode::EMPTY, 1150 }, { eMode::PICKED, 1150, 103 }, { eMode::AUTO, 1350 }, { eMode::AUTO, 1450 } };
const auto showing = Resolve(choices, true, CANDIDATES);
EXPECT_EQ(Shown(showing), (Ids{ 201, 301, 101, 102 }));
for (const auto& slot : showing) EXPECT_FALSE(slot.pickFellBack);
}
TEST(HotPropertySlotsTests, FullAutoWithFewerPropertiesThanSlots) {
const std::vector<Choice> choices(4);
EXPECT_EQ(Shown(Resolve(choices, true, { { 202, 1151, 50 }, { 301, 1250, 700 } })), (Ids{ 301, 202, std::nullopt, std::nullopt }));
EXPECT_EQ(Shown(Resolve(choices, true, {})), (Ids(4, std::nullopt)));
}
TEST(HotPropertySlotsTests, ACandidateListedTwiceCountsOnce) {
// A picked property is added to the candidates even when a world's list already has it
auto candidates = CANDIDATES;
candidates.push_back({ 101, 1150, 500 });
const std::vector<Choice> choices{ { eMode::AUTO, 1150 }, { eMode::AUTO, 1150 }, { eMode::AUTO, 1150 }, { eMode::AUTO, 1150 } };
EXPECT_EQ(Shown(Resolve(choices, false, candidates)), (Ids{ 101, 102, 103, std::nullopt }));
}
TEST(HotPropertySlotsTests, CandidateWorlds) {
const std::vector<Choice> choices{ { eMode::AUTO, 1150 }, { eMode::PICKED, 1151, 202 }, { eMode::EMPTY, 1350 }, { eMode::AUTO, 1150 } };
EXPECT_EQ(CandidateWorlds(choices, false), (std::vector<uint32_t>{ 1150, 1151 }));
EXPECT_EQ(CandidateWorlds(choices, true), (std::vector<uint32_t>{ 0 }));
}

View File

@@ -0,0 +1,153 @@
#include <gtest/gtest.h>
#include <random>
#include "LiveOpsRules.h"
using namespace LiveOpsRules;
namespace {
std::vector<NiPoint3> Line(int count, float step) {
std::vector<NiPoint3> points;
for (int i = 0; i < count; i++) points.emplace_back(static_cast<float>(i) * step, 0.0f, 0.0f);
return points;
}
float MinDistance(const std::vector<NiPoint3>& points) {
float best = 1e9f;
for (size_t i = 0; i < points.size(); i++) {
for (size_t j = i + 1; j < points.size(); j++) best = std::min(best, NiPoint3::Distance(points[i], points[j]));
}
return best;
}
}
TEST(LiveOpsRulesTests, EventTypeNamesRoundTrip) {
for (const auto type : { eEventType::TREASURE_HUNT, eEventType::BONUS, eEventType::INVASION, eEventType::CELEBRATION }) {
EXPECT_EQ(ParseType(TypeName(type)), type);
}
EXPECT_FALSE(ParseType("party").has_value());
EXPECT_FALSE(NeedsZone(eEventType::BONUS));
EXPECT_TRUE(NeedsZone(eEventType::TREASURE_HUNT));
}
TEST(LiveOpsRulesTests, PlacementOnlyUsesCandidatesAndKeepsThemApart) {
std::mt19937 rng(42);
const auto candidates = Line(20, 10.0f); // 10 apart
const auto chosen = ChoosePositions(candidates, { .count = 5, .minSpacing = 25.0f }, rng);
ASSERT_EQ(chosen.size(), 5u);
EXPECT_GE(MinDistance(chosen), 25.0f);
for (const auto& point : chosen) EXPECT_NE(std::find(candidates.begin(), candidates.end(), point), candidates.end());
}
TEST(LiveOpsRulesTests, PlacementRelaxesSpacingRatherThanGivingUp) {
std::mt19937 rng(1);
const auto chosen = ChoosePositions(Line(6, 2.0f), { .count = 6, .minSpacing = 50.0f }, rng);
EXPECT_EQ(chosen.size(), 6u);
}
TEST(LiveOpsRulesTests, PlacementNeverInventsPositionsWithoutReuse) {
std::mt19937 rng(7);
EXPECT_EQ(ChoosePositions(Line(3, 10.0f), { .count = 10, .minSpacing = 1.0f }, rng).size(), 3u);
EXPECT_TRUE(ChoosePositions({}, { .count = 10 }, rng).empty());
EXPECT_TRUE(ChoosePositions(Line(3, 10.0f), { .count = 0 }, rng).empty());
}
TEST(LiveOpsRulesTests, PlacementAroundACenterCyclesWhenReusing) {
std::mt19937 rng(3);
const auto chosen = ChoosePositions(Line(20, 10.0f), { .count = 8, .minSpacing = 1.0f, .center = NiPoint3(0.0f, 0.0f, 0.0f), .radius = 15.0f, .reuse = true }, rng);
ASSERT_EQ(chosen.size(), 8u);
for (const auto& point : chosen) EXPECT_LE(point.x, 15.0f); // only the two within the radius, used again
}
TEST(LiveOpsRulesTests, MultipliersApplyOnlyInTheirWindowAndDontStack) {
const std::vector<BonusWindow> windows{
{ 100, 200, { 2.0f, 1.0f, 1.0f } },
{ 150, 300, { 3.0f, 1.5f, 1.0f } },
{ 0, 1000, { 50.0f, 0.5f, 1.0f } }, // clamped to 10, and below 1 means no change
};
EXPECT_EQ(ActiveMultipliers({}, 120), Multipliers{});
EXPECT_FALSE(ActiveMultipliers({ windows[0] }, 99).Any());
EXPECT_EQ(ActiveMultipliers({ windows[0] }, 100).coins, 2.0f);
EXPECT_FALSE(ActiveMultipliers({ windows[0] }, 200).Any()); // the end is exclusive
const auto both = ActiveMultipliers({ windows[0], windows[1] }, 160);
EXPECT_EQ(both.coins, 3.0f);
EXPECT_EQ(both.uscore, 1.5f);
EXPECT_EQ(ActiveMultipliers({ windows[2] }, 500).coins, MAX_MULTIPLIER);
EXPECT_EQ(ActiveMultipliers({ windows[2] }, 500).uscore, 1.0f);
}
TEST(LiveOpsRulesTests, ScalingRoundsSaturatesAndLeavesNoBonusAlone) {
EXPECT_EQ(Scale<uint32_t>(15, 1.0f), 15u);
EXPECT_EQ(Scale<uint32_t>(15, 2.0f), 30u);
EXPECT_EQ(Scale<uint32_t>(15, 1.5f), 23u);
EXPECT_EQ(Scale<uint32_t>(0, 3.0f), 0u);
EXPECT_EQ(Scale<uint32_t>(4000000000u, 2.0f), UINT32_MAX);
EXPECT_EQ(Scale<int64_t>(-5, 2.0f), -5);
EXPECT_FLOAT_EQ(ScaleChance(0.3f, 2.0f), 0.6f);
EXPECT_FLOAT_EQ(ScaleChance(0.7f, 2.0f), 1.0f);
EXPECT_FLOAT_EQ(ScaleChance(0.7f, 1.0f), 0.7f);
}
TEST(LiveOpsRulesTests, WavesComeAtTheStartAndThenEveryInterval) {
EXPECT_EQ(WavesDue(100, 60, 3, 99), 0u);
EXPECT_EQ(WavesDue(100, 60, 3, 100), 1u);
EXPECT_EQ(WavesDue(100, 60, 3, 159), 1u);
EXPECT_EQ(WavesDue(100, 60, 3, 160), 2u);
EXPECT_EQ(WavesDue(100, 60, 3, 10000), 3u);
EXPECT_EQ(WavesDue(100, 60, 0, 10000), 0u);
}
TEST(LiveOpsRulesTests, ChallengePhaseAndPercent) {
EXPECT_EQ(Phase(0, 100, 200, 50), ePhase::SCHEDULED);
EXPECT_EQ(Phase(0, 100, 200, 150), ePhase::ACTIVE);
EXPECT_EQ(Phase(0, 100, 200, 200), ePhase::EXPIRED);
EXPECT_EQ(Phase(1, 100, 200, 150), ePhase::COMPLETED);
EXPECT_EQ(Phase(3, 100, 200, 150), ePhase::CANCELLED);
EXPECT_EQ(Percent(0, 1000), 0);
EXPECT_EQ(Percent(250, 1000), 25);
EXPECT_EQ(Percent(999, 1000), 99); // 100 only when reached
EXPECT_EQ(Percent(1000, 1000), 100);
EXPECT_EQ(Percent(5000, 1000), 100);
}
TEST(LiveOpsRulesTests, MilestonesAnnounceTheHighestNewOneOnce) {
const auto milestones = ParseMilestones("75, 25,50,abc,50,0,101,100");
EXPECT_EQ(milestones, (std::vector<uint8_t>{ 25, 50, 75, 100 }));
EXPECT_EQ(ParseMilestones(""), DefaultMilestones());
EXPECT_FALSE(NextMilestone(milestones, 0, 200, 1000).has_value());
EXPECT_EQ(NextMilestone(milestones, 0, 250, 1000), 25);
EXPECT_FALSE(NextMilestone(milestones, 25, 300, 1000).has_value());
EXPECT_EQ(NextMilestone(milestones, 25, 800, 1000), 75); // jumped past 50: one message
EXPECT_EQ(NextMilestone(milestones, 75, 1000, 1000), 100);
EXPECT_FALSE(NextMilestone(milestones, 100, 1000, 1000).has_value());
}
TEST(LiveOpsRulesTests, RewardsGoToThoseWhoContributedEnoughOnce) {
EXPECT_FALSE(Eligible(0, 1));
EXPECT_TRUE(Eligible(1, 1));
EXPECT_TRUE(Eligible(1, 0)); // a minimum below 1 still needs something
EXPECT_FALSE(Eligible(9, 10));
const std::vector<std::pair<int64_t, int64_t>> contributions{ { 1, 5 }, { 2, 50 }, { 3, 10 }, { 4, 0 }, { 5, 10 } };
const auto recipients = RewardRecipients(contributions, 10, std::vector<int64_t>{ 5 });
ASSERT_EQ(recipients.size(), 2u);
EXPECT_EQ(recipients[0].first, 2); // highest first
EXPECT_EQ(recipients[1].first, 3);
}
TEST(LiveOpsRulesTests, ZonesLimitWhatCounts) {
EXPECT_TRUE(CountsInZone({}, 1100));
EXPECT_TRUE(CountsInZone({ 1100, 1200 }, 1200));
EXPECT_FALSE(CountsInZone({ 1100 }, 1200));
}
TEST(LiveOpsRulesTests, EndAnnouncementDueOnceAndOnlyWhileRecent) {
constexpr int64_t now = 1'000'000;
EXPECT_TRUE(EndAnnouncementDue(1, 0, now - 4, now)); // completed during a restart
EXPECT_TRUE(EndAnnouncementDue(2, 0, now - 60, now)); // expired
EXPECT_FALSE(EndAnnouncementDue(1, now - 4, now - 4, now)); // announced already
EXPECT_FALSE(EndAnnouncementDue(0, 0, now - 4, now)); // still open
EXPECT_FALSE(EndAnnouncementDue(3, 0, now - 4, now)); // cancelled: never announced
EXPECT_FALSE(EndAnnouncementDue(1, 0, now - END_ANNOUNCEMENT_WINDOW - 1, now)); // too long ago to be news
EXPECT_FALSE(EndAnnouncementDue(1, 0, 0, now)); // no end time
}

View File

@@ -0,0 +1,46 @@
#include <gtest/gtest.h>
#include "MigrationRunner.h"
using MigrationRunner::SplitStatements;
TEST(MigrationSplitTests, SplitsAtStatementEnds) {
const auto statements = SplitStatements("CREATE TABLE a (x INT);\nINSERT INTO a VALUES (1);\n\n", false);
ASSERT_EQ(statements.size(), 2u);
EXPECT_EQ(statements[0], "CREATE TABLE a (x INT)");
EXPECT_EQ(statements[1], "\nINSERT INTO a VALUES (1)");
}
TEST(MigrationSplitTests, IgnoresSemicolonsInComments) {
const auto statements = SplitStatements(
"/* Dashboard data; see the other file. */\nCREATE TABLE a (x INT); -- trailing; note\n-- whole line; comment\nCREATE TABLE b (y INT);", false);
ASSERT_EQ(statements.size(), 2u);
EXPECT_NE(statements[0].find("CREATE TABLE a"), std::string::npos);
EXPECT_EQ(statements[0].find("Dashboard"), std::string::npos); // comments are dropped
EXPECT_NE(statements[1].find("CREATE TABLE b"), std::string::npos);
}
TEST(MigrationSplitTests, IgnoresSemicolonsInQuotes) {
const auto statements = SplitStatements("INSERT INTO t VALUES ('a;b', \"c;d\", `e;f`, 'it''s; fine');SELECT 1;", false);
ASSERT_EQ(statements.size(), 2u);
EXPECT_EQ(statements[0], "INSERT INTO t VALUES ('a;b', \"c;d\", `e;f`, 'it''s; fine')");
// A -- or /* inside a string is text, not a comment
EXPECT_EQ(SplitStatements("SELECT '--;/*';", false)[0], "SELECT '--;/*'");
}
TEST(MigrationSplitTests, BackslashesDependOnTheDatabase) {
// SQLite: a backslash is an ordinary character, so the quote after it closes the string
const auto sqlite = SplitStatements("INSERT INTO t VALUES ('scripts\\');SELECT 1;", false);
EXPECT_EQ(sqlite.size(), 2u);
// MySQL: \' is an escaped quote inside the string
const auto mysql = SplitStatements("INSERT INTO t VALUES ('it\\'s; here');SELECT 1;", true);
ASSERT_EQ(mysql.size(), 2u);
EXPECT_EQ(mysql[0], "INSERT INTO t VALUES ('it\\'s; here')");
}
TEST(MigrationSplitTests, UnterminatedPartsDoNotLoop) {
EXPECT_EQ(SplitStatements("SELECT 1; /* never closed", false).size(), 1u);
EXPECT_EQ(SplitStatements("SELECT 'never closed", false).size(), 1u);
EXPECT_TRUE(SplitStatements("", false).empty());
EXPECT_TRUE(SplitStatements(" ;; \n", false).empty());
}

View File

@@ -0,0 +1,75 @@
#include <gtest/gtest.h>
#include <filesystem>
#include <fstream>
#include "GeneralUtils.h"
#include "Process.h"
#include "SpareBackoff.h"
TEST(LikeEscapeTests, WildcardsAreLiteral) {
EXPECT_EQ(GeneralUtils::LikeEscape("50%_off\\"), "50\\%\\_off\\\\");
EXPECT_EQ(GeneralUtils::LikeEscape("50%_off!", '!'), "50!%!_off!!");
EXPECT_EQ(GeneralUtils::LikeEscape("plain", '!'), "plain");
}
TEST(ProcessTests, ShellMetacharactersAreDetected) {
EXPECT_FALSE(Process::HasShellMetacharacters("/home/me/LEGO Universe (unpacked)/res"));
EXPECT_FALSE(Process::HasShellMetacharacters("C:\\Games\\LU\\client"));
EXPECT_TRUE(Process::HasShellMetacharacters("/tmp/$(touch pwned)"));
EXPECT_TRUE(Process::HasShellMetacharacters("/tmp/`id`"));
EXPECT_TRUE(Process::HasShellMetacharacters("a\"b"));
EXPECT_TRUE(Process::HasShellMetacharacters("a; rm -rf /"));
EXPECT_TRUE(Process::HasShellMetacharacters("a\nb"));
}
#ifndef _WIN32
TEST(ProcessTests, RunsWithoutAShell) {
const auto dir = std::filesystem::temp_directory_path() / "dlu_process_test";
std::filesystem::remove_all(dir);
std::filesystem::create_directories(dir);
const auto marker = dir / "pwned";
const auto out = dir / "out.txt";
// $(...) is handed to echo as text, not run
EXPECT_EQ(Process::Run({ "echo", "$(touch " + marker.string() + ")" }, out.string()), 0);
EXPECT_FALSE(std::filesystem::exists(marker));
std::ifstream in(out);
std::string line;
std::getline(in, line);
EXPECT_EQ(line, "$(touch " + marker.string() + ")");
EXPECT_NE(Process::Run({ "false" }), 0);
EXPECT_NE(Process::Run({ "/no/such/program" }), 0);
EXPECT_EQ(Process::Run({}), -1);
std::filesystem::remove_all(dir);
}
#endif
TEST(SpareBackoffTests, CrashingSparesWaitLongerEachTime) {
SpareBackoff backoff;
EXPECT_TRUE(backoff.CanStart(0));
backoff.Started(1);
EXPECT_FALSE(backoff.CanStart(1)); // still starting
backoff.Lost(10);
EXPECT_FALSE(backoff.CanStart(15));
EXPECT_TRUE(backoff.CanStart(10 + SpareBackoff::BASE_DELAY));
backoff.Started(2);
backoff.Lost(100);
EXPECT_FALSE(backoff.CanStart(100 + SpareBackoff::BASE_DELAY));
EXPECT_TRUE(backoff.CanStart(100 + 2 * SpareBackoff::BASE_DELAY));
EXPECT_EQ(SpareBackoff::Delay(50), SpareBackoff::MAX_DELAY);
}
TEST(SpareBackoffTests, OneThatDiesSoonAfterStartingStillCounts) {
SpareBackoff backoff;
backoff.Started(1);
backoff.Running(true, 100);
EXPECT_TRUE(backoff.CanStart(101)); // ready: another may start if needed
backoff.Lost(160); // but it crashed within STABLE
EXPECT_EQ(backoff.Failures(), 1u);
backoff.Started(2);
backoff.Running(true, 1000);
backoff.Running(true, 1000 + SpareBackoff::STABLE);
EXPECT_EQ(backoff.Failures(), 0u); // ran long enough: the zone works
EXPECT_EQ(backoff.Watched(), 0u);
}

View File

@@ -0,0 +1,246 @@
#include <gtest/gtest.h>
#include "CivilDate.h"
#include "ScheduleRules.h"
using namespace ScheduleRules;
namespace {
int64_t Utc(int64_t year, unsigned month, unsigned day, int hour = 0, int minute = 0) {
return CivilDate::DaysFromCivil(year, month, day) * 86400 + hour * 3600 + minute * 60;
}
Schedule Parse(const std::string& json) {
std::string error;
const auto schedule = ParseSchedule(json, error);
EXPECT_TRUE(schedule) << json << ": " << error;
return schedule.value_or(Schedule{});
}
std::string ErrorOf(const std::string& json) {
std::string error;
EXPECT_FALSE(ParseSchedule(json, error)) << json;
return error;
}
}
TEST(ScheduleRulesTests, YearlyRangeIsWholeDays) {
const auto october = Parse(R"({"rules": [{"type": "yearly", "from": "10-01", "to": "10-31"}]})");
EXPECT_FALSE(Active(october, Utc(2026, 9, 30, 23, 59)));
EXPECT_TRUE(Active(october, Utc(2026, 10, 1)));
EXPECT_TRUE(Active(october, Utc(2026, 10, 31, 23, 59)));
EXPECT_FALSE(Active(october, Utc(2026, 11, 1)));
EXPECT_TRUE(Active(october, Utc(2031, 10, 15, 12)));
EXPECT_EQ(NextChange(october, Utc(2026, 9, 26, 12)), Utc(2026, 10, 1));
EXPECT_EQ(NextChange(october, Utc(2026, 10, 1)), Utc(2026, 11, 1));
EXPECT_EQ(NextChange(october, Utc(2026, 11, 1)), Utc(2027, 10, 1));
}
TEST(ScheduleRulesTests, YearlyRangeWrapsTheYearEnd) {
const auto holidays = Parse(R"({"rules": [{"type": "yearly", "from": "12-20", "to": "01-02"}]})");
EXPECT_FALSE(Active(holidays, Utc(2026, 12, 19, 23, 59)));
EXPECT_TRUE(Active(holidays, Utc(2026, 12, 20)));
EXPECT_TRUE(Active(holidays, Utc(2026, 12, 31, 23, 59)));
EXPECT_TRUE(Active(holidays, Utc(2027, 1, 1)));
EXPECT_TRUE(Active(holidays, Utc(2027, 1, 2, 23, 59)));
EXPECT_FALSE(Active(holidays, Utc(2027, 1, 3)));
EXPECT_FALSE(Active(holidays, Utc(2027, 6, 1)));
EXPECT_EQ(NextChange(holidays, Utc(2026, 12, 25)), Utc(2027, 1, 3));
const auto windows = Windows(holidays, Utc(2026, 1, 1), Utc(2028, 1, 1));
ASSERT_EQ(windows.size(), 3u); // the end of the last one, all of this one, the start of the next
EXPECT_EQ(windows[0].start, Utc(2026, 1, 1));
EXPECT_EQ(windows[0].end, Utc(2026, 1, 3));
EXPECT_EQ(windows[1].start, Utc(2026, 12, 20));
EXPECT_EQ(windows[1].end, Utc(2027, 1, 3));
EXPECT_EQ(windows[2].start, Utc(2027, 12, 20));
EXPECT_EQ(windows[2].end, Utc(2028, 1, 1));
}
TEST(ScheduleRulesTests, LeapDay) {
const auto leap = Parse(R"({"rules": [{"type": "yearly", "from": "02-29", "to": "02-29"}]})");
EXPECT_TRUE(Active(leap, Utc(2028, 2, 29, 12)));
EXPECT_FALSE(Active(leap, Utc(2028, 3, 1)));
EXPECT_FALSE(Active(leap, Utc(2027, 2, 28)));
EXPECT_EQ(NextChange(leap, Utc(2026, 9, 26)), Utc(2028, 2, 29));
}
TEST(ScheduleRulesTests, OneOffDates) {
// A date alone as the end is the whole of that day
const auto days = Parse(R"({"rules": [{"type": "dates", "from": "2026-12-20", "to": "2027-01-02"}]})");
EXPECT_FALSE(Active(days, Utc(2026, 12, 19, 23, 59)));
EXPECT_TRUE(Active(days, Utc(2026, 12, 20)));
EXPECT_TRUE(Active(days, Utc(2027, 1, 2, 23, 59)));
EXPECT_FALSE(Active(days, Utc(2027, 1, 3)));
EXPECT_FALSE(Active(days, Utc(2027, 12, 25))); // once only
EXPECT_EQ(NextChange(days, Utc(2027, 1, 3)), std::nullopt);
const auto times = Parse(R"({"rules": [{"type": "dates", "from": "2026-10-31T18:00", "to": "2026-11-01T02:00"}]})");
EXPECT_FALSE(Active(times, Utc(2026, 10, 31, 17, 59)));
EXPECT_TRUE(Active(times, Utc(2026, 10, 31, 18)));
EXPECT_FALSE(Active(times, Utc(2026, 11, 1, 2)));
}
TEST(ScheduleRulesTests, WeekdaysAndTimesOfDay) {
// 2026-09-25 is a Friday
const auto weekend = Parse(R"({"rules": [{"type": "weekdays", "days": ["sat", "Sunday", 0]}]})");
EXPECT_FALSE(Active(weekend, Utc(2026, 9, 25, 23, 59)));
EXPECT_TRUE(Active(weekend, Utc(2026, 9, 26)));
EXPECT_TRUE(Active(weekend, Utc(2026, 9, 27, 23, 59)));
EXPECT_FALSE(Active(weekend, Utc(2026, 9, 28)));
EXPECT_EQ(NextChange(weekend, Utc(2026, 9, 26, 10)), Utc(2026, 9, 28));
// Past midnight
const auto night = Parse(R"({"rules": [{"type": "time_of_day", "from": "22:00", "to": "02:00"}]})");
EXPECT_TRUE(Active(night, Utc(2026, 9, 26, 23)));
EXPECT_TRUE(Active(night, Utc(2026, 9, 27, 1, 59)));
EXPECT_FALSE(Active(night, Utc(2026, 9, 27, 2)));
EXPECT_FALSE(Active(night, Utc(2026, 9, 27, 21, 59)));
EXPECT_EQ(NextChange(night, Utc(2026, 9, 27, 12)), Utc(2026, 9, 27, 22));
// Friday evenings: every rule has to match
const auto fridays = Parse(R"({"match": "all", "rules": [{"type": "weekdays", "days": ["fri"]}, {"type": "time_of_day", "from": "18:00", "to": "24:00"}]})");
EXPECT_TRUE(Active(fridays, Utc(2026, 9, 25, 20)));
EXPECT_FALSE(Active(fridays, Utc(2026, 9, 25, 17)));
EXPECT_FALSE(Active(fridays, Utc(2026, 9, 26, 20)));
EXPECT_EQ(NextChange(fridays, Utc(2026, 9, 26)), Utc(2026, 10, 2, 18));
}
TEST(ScheduleRulesTests, UtcOffsetMovesTheDays) {
// October in UTC+2 starts at 22:00 UTC the day before
const auto october = Parse(R"({"utcOffset": 120, "rules": [{"type": "yearly", "from": "10-01", "to": "10-31"}]})");
EXPECT_FALSE(Active(october, Utc(2026, 9, 30, 21, 59)));
EXPECT_TRUE(Active(october, Utc(2026, 9, 30, 22)));
EXPECT_EQ(NextChange(october, Utc(2026, 9, 26)), Utc(2026, 9, 30, 22));
const auto west = Parse(R"({"utcOffset": -300, "rules": [{"type": "time_of_day", "from": "18:00", "to": "20:00"}]})");
EXPECT_TRUE(Active(west, Utc(2026, 9, 26, 23)));
}
TEST(ScheduleRulesTests, NotAndGroups) {
// October but not on weekends (a group), or one other day
const auto schedule = Parse(R"({"match": "any", "rules": [
{"type": "group", "match": "all", "rules": [{"type": "yearly", "from": "10-01", "to": "10-31"}, {"type": "weekdays", "days": ["sat", "sun"], "not": true}]},
{"type": "dates", "from": "2026-12-25", "to": "2026-12-25"}]})");
EXPECT_TRUE(Active(schedule, Utc(2026, 10, 2, 12))); // Friday
EXPECT_FALSE(Active(schedule, Utc(2026, 10, 3, 12))); // Saturday
EXPECT_TRUE(Active(schedule, Utc(2026, 12, 25, 12)));
EXPECT_FALSE(Active(schedule, Utc(2026, 12, 26, 12)));
}
// Known full moons (UTC): 2024-10-17 11:26, 2025-10-07 03:48, 2026-10-26 04:12
TEST(ScheduleRulesTests, FullMoonTimes) {
const std::vector<int64_t> known{ Utc(2024, 10, 17, 11, 26), Utc(2025, 10, 7, 3, 48), Utc(2026, 10, 26, 4, 12) };
for (const auto time : known) {
const auto found = Phases(eMoonPhase::FULL_MOON, time - 5 * 86400, time + 5 * 86400);
ASSERT_EQ(found.size(), 1u);
EXPECT_NEAR(static_cast<double>(found[0]), static_cast<double>(time), 5 * 60.0);
}
// One full moon a lunation: 12 or 13 a year
const auto year = Phases(eMoonPhase::FULL_MOON, Utc(2026, 1, 1), Utc(2027, 1, 1));
EXPECT_GE(year.size(), 12u);
EXPECT_LE(year.size(), 13u);
// New moon 2024-10-02 18:49 UTC
const auto newMoon = Phases(eMoonPhase::NEW_MOON, Utc(2024, 9, 28), Utc(2024, 10, 6));
ASSERT_EQ(newMoon.size(), 1u);
EXPECT_NEAR(static_cast<double>(newMoon[0]), static_cast<double>(Utc(2024, 10, 2, 18, 49)), 5 * 60.0);
}
TEST(ScheduleRulesTests, FullMoonRules) {
// The day it falls on
const auto day = Parse(R"({"rules": [{"type": "moon", "phase": "full_moon"}]})");
EXPECT_FALSE(Active(day, Utc(2025, 10, 6, 23, 59)));
EXPECT_TRUE(Active(day, Utc(2025, 10, 7)));
EXPECT_TRUE(Active(day, Utc(2025, 10, 7, 23, 59)));
EXPECT_FALSE(Active(day, Utc(2025, 10, 8)));
EXPECT_EQ(NextChange(day, Utc(2026, 10, 20)), Utc(2026, 10, 26));
EXPECT_EQ(NextChange(day, Utc(2026, 10, 26)), Utc(2026, 10, 27));
// The day in UTC-5 is the evening before in UTC: 2026-10-26 04:12 UTC is 23:12 on the 25th
const auto west = Parse(R"({"utcOffset": -300, "rules": [{"type": "moon", "phase": "full_moon", "days": 0}]})");
EXPECT_TRUE(Active(west, Utc(2026, 10, 25, 12)));
EXPECT_FALSE(Active(west, Utc(2026, 10, 26, 12)));
// A day either side
const auto wide = Parse(R"({"rules": [{"type": "moon", "phase": "full_moon", "days": 1}]})");
EXPECT_TRUE(Active(wide, Utc(2024, 10, 16)));
EXPECT_TRUE(Active(wide, Utc(2024, 10, 18, 23)));
EXPECT_FALSE(Active(wide, Utc(2024, 10, 19)));
// Hours around the exact time
const auto hours = Parse(R"({"rules": [{"type": "moon", "phase": "full_moon", "hours": 12}]})");
EXPECT_TRUE(Active(hours, Utc(2024, 10, 17)));
EXPECT_TRUE(Active(hours, Utc(2024, 10, 17, 23)));
EXPECT_FALSE(Active(hours, Utc(2024, 10, 18)));
EXPECT_FALSE(Active(hours, Utc(2024, 10, 16, 23)));
// Full-moon nights: the day of the full moon, 20:00 to midnight
const auto night = Parse(R"({"match": "all", "rules": [{"type": "moon", "phase": "full_moon"}, {"type": "time_of_day", "from": "20:00", "to": "24:00"}]})");
EXPECT_EQ(NextChange(night, Utc(2026, 10, 1)), Utc(2026, 10, 26, 20));
const auto windows = Windows(night, Utc(2026, 1, 1), Utc(2027, 1, 1));
EXPECT_GE(windows.size(), 12u);
for (const auto& window : windows) EXPECT_EQ(window.end - window.start, 4 * 3600);
}
TEST(ScheduleRulesTests, BadSchedules) {
EXPECT_NE(ErrorOf(R"({"rules": []})").find("at least one rule"), std::string::npos);
EXPECT_NE(ErrorOf(R"({"rules": [{"type": "easter"}]})").find("Rule 1: type is one of"), std::string::npos);
EXPECT_NE(ErrorOf(R"({"rules": [{"type": "yearly", "from": "13-01", "to": "10-31"}]})").find("Rule 1"), std::string::npos);
EXPECT_NE(ErrorOf(R"({"rules": [{"type": "yearly", "from": "02-30", "to": "03-01"}]})").find("Rule 1"), std::string::npos);
EXPECT_NE(ErrorOf(R"({"rules": [{"type": "dates", "from": "2027-01-02", "to": "2026-12-20"}]})").find("after the start"), std::string::npos);
EXPECT_NE(ErrorOf(R"({"rules": [{"type": "time_of_day", "from": "18:00", "to": "18:00"}]})").find("same time"), std::string::npos);
EXPECT_NE(ErrorOf(R"({"rules": [{"type": "weekdays", "days": ["someday"]}]})").find("days are"), std::string::npos);
EXPECT_NE(ErrorOf(R"({"rules": [{"type": "moon", "phase": "blue_moon"}]})").find("phase is one of"), std::string::npos);
EXPECT_NE(ErrorOf(R"({"rules": [{"type": "moon", "phase": "full_moon", "days": 8}]})").find("days is"), std::string::npos);
EXPECT_NE(ErrorOf(R"({"utcOffset": 900, "rules": [{"type": "weekdays", "days": ["mon"]}]})").find("utcOffset"), std::string::npos);
EXPECT_NE(ErrorOf(R"({"rules": [{"type": "group", "rules": [{"type": "group", "rules": [{"type": "group", "rules": [{"type": "group", "rules": [{"type": "weekdays", "days": ["mon"]}]}]}]}]}]})").find("deep"), std::string::npos);
EXPECT_NE(ErrorOf(R"({"rules": [{"type": "group", "match": "most", "rules": [{"type": "weekdays", "days": ["mon"]}]}]})").find("Rule 1: match"), std::string::npos);
EXPECT_NE(ErrorOf(R"({"rules": [{"type": "group", "rules": [{"type": "weekdays", "days": ["mon"]}, {"type": "nope"}]}]})").find("Rule 1.2"), std::string::npos);
EXPECT_NE(ErrorOf("not json").find("JSON"), std::string::npos);
}
TEST(ScheduleRulesTests, JsonRoundTrip) {
const std::string text = R"({"utcOffset": 60, "match": "any", "rules": [
{"type": "YEARLY", "from": "10-01", "to": "10-31"},
{"type": "dates", "from": "2026-12-20T18:00", "to": "2027-01-02"},
{"type": "weekdays", "days": ["fri", "sat"], "not": true},
{"type": "time_of_day", "from": "18:00", "to": "02:00"},
{"type": "moon", "phase": "Full_Moon", "hours": 6},
{"type": "group", "match": "all", "rules": [{"type": "moon", "phase": "new_moon", "days": 1}]}]})";
const auto first = ToJson(Parse(text));
EXPECT_EQ(first["rules"][0]["type"], "yearly");
EXPECT_EQ(first["rules"][1]["to"], "2027-01-02");
EXPECT_EQ(first["rules"][4]["phase"], "full_moon");
EXPECT_EQ(first["rules"][2]["not"], true);
EXPECT_EQ(ToJson(Parse(first.dump())), first);
}
TEST(ScheduleRulesTests, Modes) {
const std::string october = R"({"rules": [{"type": "yearly", "from": "10-01", "to": "10-31"}]})";
EXPECT_TRUE(IsOn(eMode::SCHEDULED, october, Utc(2026, 10, 5)));
EXPECT_FALSE(IsOn(eMode::SCHEDULED, october, Utc(2026, 9, 5)));
EXPECT_TRUE(IsOn(eMode::ALWAYS_ON, october, Utc(2026, 9, 5)));
EXPECT_FALSE(IsOn(eMode::OFF, october, Utc(2026, 10, 5)));
EXPECT_FALSE(IsOn(eMode::SCHEDULED, "broken", Utc(2026, 10, 5)));
}
TEST(ScheduleRulesTests, OnceEvents) {
const auto start = Utc(2026, 10, 1, 18), end = Utc(2026, 10, 2, 6);
EXPECT_FALSE(IsOn(eMode::SCHEDULED, "", start, end, start - 1));
EXPECT_TRUE(IsOn(eMode::SCHEDULED, "", start, end, start));
EXPECT_FALSE(IsOn(eMode::SCHEDULED, "", start, end, end));
EXPECT_TRUE(IsOn(eMode::ALWAYS_ON, "", start, end, end + 100));
EXPECT_FALSE(IsOn(eMode::OFF, "", start, end, start));
const auto windows = WindowsOf(eMode::SCHEDULED, "", start, end, Utc(2026, 10, 2), Utc(2026, 10, 3));
ASSERT_EQ(windows.size(), 1u);
EXPECT_EQ(windows[0].start, Utc(2026, 10, 2));
EXPECT_EQ(windows[0].end, end);
EXPECT_TRUE(WindowsOf(eMode::SCHEDULED, "", start, end, end, end + 86400).empty());
EXPECT_TRUE(WindowsOf(eMode::OFF, "", start, end, start, end).empty());
EXPECT_EQ(WindowsOf(eMode::ALWAYS_ON, "", 0, 0, start, end).size(), 1u);
// Recurring: the schedule decides, startsAt and endsAt are ignored
const std::string october = R"({"rules":[{"type":"yearly","from":"10-01","to":"10-31"}]})";
EXPECT_TRUE(IsOn(eMode::SCHEDULED, october, 0, 0, Utc(2027, 10, 5)));
EXPECT_EQ(WindowsOf(eMode::SCHEDULED, october, 0, 0, Utc(2026, 1, 1), Utc(2028, 1, 1)).size(), 2u);
}

View File

@@ -0,0 +1,75 @@
#include <gtest/gtest.h>
#include "SlashCommandLevels.h"
TEST(SlashCommandLevelsTests, SettingNameFromAlias) {
EXPECT_EQ(SlashCommandLevels::SettingName("kick"), "kick");
EXPECT_EQ(SlashCommandLevels::SettingName("removeIgnore"), "removeignore");
EXPECT_EQ(SlashCommandLevels::SettingName("leave-zone"), "leave_zone");
EXPECT_EQ(SlashCommandLevels::SettingName("victory!"), "victory");
EXPECT_EQ(SlashCommandLevels::SettingName("--a--b--"), "a_b");
EXPECT_EQ(SlashCommandLevels::ConfigName("kick"), "command_level_kick");
EXPECT_TRUE(SlashCommandLevels::IsLevelSetting("command_level_kick"));
EXPECT_FALSE(SlashCommandLevels::IsLevelSetting("permission_accounts_kick"));
}
TEST(SlashCommandLevelsTests, StaffCommandsStayWithStaff) {
EXPECT_EQ(SlashCommandLevels::DefaultMinLevel(0), 0);
EXPECT_EQ(SlashCommandLevels::DefaultMinLevel(2), SlashCommandLevels::STAFF_MIN_LEVEL);
EXPECT_EQ(SlashCommandLevels::DefaultMinLevel(9), SlashCommandLevels::STAFF_MIN_LEVEL);
}
TEST(SlashCommandLevelsTests, Resolve) {
// No value: the code's level
EXPECT_EQ(SlashCommandLevels::Resolve(2, 1, false, ""), 2);
// Raised and lowered within range
EXPECT_EQ(SlashCommandLevels::Resolve(2, 1, false, "5"), 5);
EXPECT_EQ(SlashCommandLevels::Resolve(8, 1, false, "1"), 1);
EXPECT_EQ(SlashCommandLevels::Resolve(0, 0, false, "9"), 9);
// Below the floor, above GM 9 or not a number: the default
EXPECT_EQ(SlashCommandLevels::Resolve(8, 1, false, "0"), 8);
EXPECT_EQ(SlashCommandLevels::Resolve(8, 8, false, "6"), 8);
EXPECT_EQ(SlashCommandLevels::Resolve(2, 1, false, "10"), 2);
EXPECT_EQ(SlashCommandLevels::Resolve(2, 1, false, "-1"), 2);
EXPECT_EQ(SlashCommandLevels::Resolve(2, 1, false, "abc"), 2);
// Fixed commands ignore the setting
EXPECT_EQ(SlashCommandLevels::Resolve(0, 0, true, "5"), 0);
}
// A command paired with a dashboard permission uses the permission's level; its own value is an override
TEST(SlashCommandLevelsTests, PairedCommandsFollowTheirPermission) {
using SlashCommandLevels::ResolvePaired;
// No value: the permission's level, whatever the code's default was (/mute: code GM 6, accounts_mute GM 2)
EXPECT_EQ(ResolvePaired(6, 1, false, 2, "").level, 2);
EXPECT_FALSE(ResolvePaired(6, 1, false, 2, "").overridden);
// The permission changes, the command changes with it
EXPECT_EQ(ResolvePaired(6, 1, false, 5, "").level, 5);
// An explicit value (an older setup's command_level_mute) overrides it
EXPECT_EQ(ResolvePaired(6, 1, false, 2, "6").level, 6);
EXPECT_TRUE(ResolvePaired(6, 1, false, 2, "6").overridden);
EXPECT_TRUE(ResolvePaired(6, 1, false, 2, "2").overridden); // even when it is the same level: it stays put when the permission moves
// "permission" (set on the page over a file's value) and bad values follow the permission
EXPECT_EQ(ResolvePaired(6, 1, false, 3, std::string(SlashCommandLevels::FOLLOW_PERMISSION)).level, 3);
EXPECT_FALSE(ResolvePaired(6, 1, false, 3, std::string(SlashCommandLevels::FOLLOW_PERMISSION)).overridden);
EXPECT_EQ(ResolvePaired(6, 1, false, 3, "abc").level, 3);
EXPECT_FALSE(ResolvePaired(6, 1, false, 3, "10").overridden);
// Floors still apply, to the permission's level and to overrides
EXPECT_EQ(ResolvePaired(8, 8, false, 3, "").level, 8);
EXPECT_EQ(ResolvePaired(8, 8, false, 3, "5").level, 8);
EXPECT_FALSE(ResolvePaired(8, 8, false, 3, "5").overridden);
EXPECT_EQ(ResolvePaired(2, 1, false, 0, "").level, 1); // a staff command never reaches players
// Nobody above GM 9 (an unknown permission's level)
EXPECT_EQ(ResolvePaired(2, 1, false, 10, "").level, 9);
// Fixed commands ignore both
EXPECT_EQ(ResolvePaired(0, 0, true, 5, "3").level, 0);
EXPECT_FALSE(ResolvePaired(0, 0, true, 5, "3").overridden);
}
TEST(SlashCommandLevelsTests, TargetRuleNames) {
using SlashCommandLevels::eTargetRule;
for (const auto rule : { eTargetRule::NONE, eTargetRule::TOOLS, eTargetRule::ITEMS, eTargetRule::MODERATION, eTargetRule::OTHERS }) {
EXPECT_EQ(SlashCommandLevels::TargetRuleFromName(SlashCommandLevels::TargetRuleName(rule)), rule);
}
EXPECT_EQ(SlashCommandLevels::TargetRuleName(eTargetRule::NONE), "");
EXPECT_EQ(SlashCommandLevels::TargetRuleFromName("junk"), eTargetRule::NONE);
}

View File

@@ -250,3 +250,47 @@ TEST_F(LDFTests, LDFSpeedTest) {
}
#endif //PERF
TEST_F(LDFTests, LwoNameValueLinesRoundTrip) {
LwoNameValue config;
config.Insert<int32_t>("modelType", 2);
config.Insert<LWOOBJID>("userModelID", 1152921510436607007);
config.Insert<std::u16string>("userModelName", u"My House: v2");
config.Insert<bool>("userModelHasBhvr", true);
LwoNameValue parsed;
parsed.InsertLines(config.ToLines());
ASSERT_EQ(parsed.values.size(), 4);
for (const auto& [key, value] : config.values) {
const auto it = parsed.find(key);
ASSERT_NE(it, parsed.end());
EXPECT_EQ(it->second->GetString(), value->GetString());
}
}
TEST_F(LDFTests, LwoNameValueLinesSkipsBadLines) {
LwoNameValue parsed;
parsed.InsertLines("\nnot an ldf line\ncount=1:5\n");
ASSERT_EQ(parsed.values.size(), 1);
EXPECT_NE(parsed.find(u"count"), parsed.end());
EXPECT_TRUE(LwoNameValue{}.ToLines().empty());
}
TEST_F(LDFTests, LwoNameValueLinesKeepNewlinesAndBackslashes) {
LwoNameValue config;
config.Insert<std::u16string>("userModelDesc", u"Line one\nLine two");
config.Insert<std::string>("path", "scripts\\02_server\\thing.lua");
config.Insert<std::u16string>("tricky", u"ends with a backslash\\");
const auto lines = config.ToLines();
EXPECT_EQ(std::count(lines.begin(), lines.end(), '\n'), 2); // one line per value
LwoNameValue parsed;
parsed.InsertLines(lines);
ASSERT_EQ(parsed.values.size(), 3);
for (const auto& [key, value] : config.values) {
const auto it = parsed.find(key);
ASSERT_NE(it, parsed.end());
EXPECT_EQ(it->second->GetString(), value->GetString());
}
}

View File

@@ -0,0 +1,262 @@
#include <gtest/gtest.h>
#include <filesystem>
#include <fstream>
#include "VanityEvents.h"
using namespace VanityEvents;
namespace {
VanityXml::Object Npc(const std::string& name, int32_t lot = 2279) {
VanityXml::Object object;
object.name = name;
object.lot = lot;
object.locations.push_back({ 1200, 1, 2, 3 });
return object;
}
struct TestEvent {
uint64_t id;
std::string name;
int32_t priority;
std::string file;
std::string removals;
std::string fileSwitches;
};
// A throwaway vanity folder, removed at the end of the test
struct Folder {
std::filesystem::path path;
explicit Folder(const std::string& test) {
path = std::filesystem::temp_directory_path() / ("vanity-" + test + "-" + std::to_string(::testing::UnitTest::GetInstance()->random_seed()));
std::filesystem::remove_all(path);
std::filesystem::create_directories(path);
}
~Folder() { std::filesystem::remove_all(path); }
void Write(const std::string& name, const std::string& text) const { std::ofstream(path / name) << text; }
};
std::string NpcXml(const std::string& name, int lot) {
return "<object name=\"" + name + "\" lot=\"" + std::to_string(lot) + "\"><locations><location zone=\"1200\" x=\"1\" y=\"2\" z=\"3\" rw=\"1\" rx=\"0\" ry=\"0\" rz=\"0\"/></locations></object>";
}
std::vector<std::string> Names(const std::vector<VanityXml::Object>& objects) {
std::vector<std::string> names;
for (const auto& object : objects) names.push_back(object.name);
return names;
}
const FileLoad* Find(const World& world, const std::string& name) {
for (const auto& file : world.files) if (file.name == name) return &file;
return nullptr;
}
}
TEST(VanityEventsTests, MergeReplacesRemovesAndAdds) {
std::vector<VanityXml::Object> base{ Npc("Bob"), Npc("Alice"), Npc(""), Npc("Bob", 4000) };
Overlay halloween{ "Halloween", { Npc("Bob", 9999), Npc("Pumpkin", 5000), Npc("") }, { "Alice", "Nobody" } };
const auto merged = Merge(base, { halloween });
// Both Bobs are replaced by the event's Bob; Alice is taken out; the base's unnamed prop stays
ASSERT_EQ(merged.objects.size(), 4u);
EXPECT_EQ(merged.objects[0].name, "");
EXPECT_EQ(merged.objects[1].name, "Bob");
EXPECT_EQ(merged.objects[1].lot, 9999);
EXPECT_EQ(merged.objects[2].name, "Pumpkin");
EXPECT_EQ(merged.objects[3].name, "");
EXPECT_TRUE(merged.conflicts.empty());
ASSERT_EQ(merged.warnings.size(), 1u);
EXPECT_NE(merged.warnings[0].find("Nobody"), std::string::npos);
}
TEST(VanityEventsTests, MergeOrderAndConflicts) {
std::vector<TestEvent> events{ { 1, "Moon", 5, "", "" }, { 2, "Halloween", 0, "", "" }, { 3, "Also moon", 5, "", "" } };
SortForMerge(events);
ASSERT_EQ(events.size(), 3u);
EXPECT_EQ(events[0].name, "Halloween"); // lowest priority first
EXPECT_EQ(events[1].name, "Moon"); // then by id
EXPECT_EQ(events[2].name, "Also moon");
std::vector<VanityXml::Object> base{ Npc("Bob") };
const auto merged = Merge(base, { { "Halloween", { Npc("Bob", 100) }, {} }, { "Moon", { Npc("Bob", 200) }, {} }, { "Also moon", {}, { "Bob" } } });
EXPECT_TRUE(merged.objects.empty()); // the last one took Bob out
ASSERT_EQ(merged.conflicts.size(), 1u);
EXPECT_EQ(merged.conflicts[0].npc, "Bob");
EXPECT_EQ(merged.conflicts[0].events, (std::vector<std::string>{ "Halloween", "Moon", "Also moon" }));
const auto two = Merge(base, { { "Halloween", { Npc("Bob", 100) }, {} }, { "Moon", { Npc("Bob", 200) }, {} } });
ASSERT_EQ(two.objects.size(), 1u);
EXPECT_EQ(two.objects[0].lot, 200);
}
TEST(VanityEventsTests, SplitNamesAndFileNames) {
EXPECT_EQ(SplitNames(" Bob \r\n\nAlice\nBob\n"), (std::vector<std::string>{ "Bob", "Alice" }));
EXPECT_TRUE(ValidFileName("halloween-2026_npcs.xml"));
EXPECT_FALSE(ValidFileName("../root.xml"));
EXPECT_FALSE(ValidFileName("a b.xml"));
EXPECT_FALSE(ValidFileName(".xml"));
EXPECT_FALSE(ValidFileName("halloween.txt"));
}
TEST(VanityEventsTests, LoadFilesAndOverlays) {
const Folder folder("load");
folder.Write("root.xml", R"(<files><file name="a.xml" enabled="1"/><file name="off.xml" enabled="0"/><file name="root.xml" enabled="1"/><file name="../escape.xml" enabled="1"/></files>)");
folder.Write("a.xml", "<objects>" + NpcXml("Bob", 1) + NpcXml("Alice", 2) + "</objects>");
folder.Write("off.xml", "<objects>" + NpcXml("Hidden", 3) + "</objects>");
folder.Write("halloween.xml", R"(<files><file name="extra.xml" enabled="1"/></files><objects>)" + NpcXml("Bob", 10) + "</objects>");
folder.Write("extra.xml", "<objects>" + NpcXml("Pumpkin", 11) + "</objects>");
auto base = LoadFiles(folder.path, "root.xml");
EXPECT_EQ(base.files, (std::vector<std::string>{ "root.xml", "a.xml" }));
ASSERT_EQ(base.objects.size(), 2u);
ASSERT_EQ(base.warnings.size(), 1u);
EXPECT_NE(base.warnings[0].find("../escape.xml"), std::string::npos);
std::vector<TestEvent> events{ { 1, "Halloween", 0, "halloween.xml", "Alice\n" } };
const auto world = LoadWorld(folder.path, "root.xml", events);
EXPECT_EQ(world.fileNpcs, 2u);
EXPECT_EQ(Names(world.objects), (std::vector<std::string>{ "Pumpkin", "Bob" })); // the overlay's include first, as the worlds load them
EXPECT_EQ(world.objects[1].lot, 10);
ASSERT_NE(Find(world, "off.xml"), nullptr);
EXPECT_FALSE(Find(world, "off.xml")->loaded);
EXPECT_EQ(Find(world, "off.xml")->includedBy, "root.xml");
std::vector<TestEvent> missing{ { 2, "Ghost", 0, "nope.xml", "" } };
const auto withMissing = LoadWorld(folder.path, "root.xml", missing);
EXPECT_EQ(withMissing.objects.size(), 2u);
EXPECT_TRUE(std::any_of(withMissing.warnings.begin(), withMissing.warnings.end(), [](const auto& w) { return w.find("nope.xml not found") != std::string::npos; }));
}
TEST(VanityEventsTests, FileSwitchesParse) {
std::string error;
const auto switches = ParseFileSwitches(R"({"halloween.xml": true, "summer.xml": false})", error);
ASSERT_TRUE(switches) << error;
EXPECT_EQ(*switches, (FileSwitches{ { "halloween.xml", true }, { "summer.xml", false } }));
EXPECT_EQ(ToJson(*switches).dump(), R"({"halloween.xml":true,"summer.xml":false})");
EXPECT_TRUE(ParseFileSwitches("", error)->empty());
EXPECT_FALSE(ParseFileSwitches(R"({"../root.xml": true})", error));
EXPECT_FALSE(ParseFileSwitches(R"({"a.xml": 1})", error));
EXPECT_FALSE(ParseFileSwitches(R"(["a.xml"])", error));
EXPECT_FALSE(ParseFileSwitches("{", error));
}
TEST(VanityEventsTests, FileSwitchOnLoadsAFileNothingNames) {
const Folder folder("switch-on");
folder.Write("root.xml", R"(<files><file name="a.xml" enabled="1"/></files>)");
folder.Write("a.xml", "<objects>" + NpcXml("Bob", 1) + "</objects>");
folder.Write("zeta.xml", "<objects>" + NpcXml("Zed", 3) + "</objects>");
folder.Write("halloween.xml", R"(<files><file name="pumpkins.xml" enabled="1"/></files><objects>)" + NpcXml("Witch", 2) + "</objects>");
folder.Write("pumpkins.xml", "<objects>" + NpcXml("Pumpkin", 4) + "</objects>");
std::vector<TestEvent> events{ { 1, "Halloween", 0, "", "", R"({"zeta.xml": true, "halloween.xml": true})" } };
const auto world = LoadWorld(folder.path, "root.xml", events);
// After the tree, in name order, each with what it includes
EXPECT_EQ(Names(world.objects), (std::vector<std::string>{ "Bob", "Pumpkin", "Witch", "Zed" }));
EXPECT_EQ(world.fileNpcs, 4u);
const auto* halloween = Find(world, "halloween.xml");
ASSERT_NE(halloween, nullptr);
EXPECT_TRUE(halloween->loaded);
EXPECT_EQ(halloween->includedBy, "");
EXPECT_EQ(halloween->switchedBy, "Halloween");
EXPECT_EQ(Find(world, "pumpkins.xml")->includedBy, "halloween.xml");
EXPECT_TRUE(world.warnings.empty());
EXPECT_TRUE(world.fileConflicts.empty());
// Without the event nothing changes
EXPECT_EQ(Names(LoadWorld(folder.path, "root.xml", std::vector<TestEvent>{}).objects), (std::vector<std::string>{ "Bob" }));
}
TEST(VanityEventsTests, FileSwitchOffDropsTheFileAndItsIncludes) {
const Folder folder("switch-off");
folder.Write("root.xml", R"(<files><file name="summer.xml" enabled="1"/><file name="shared.xml" enabled="1"/></files>)");
folder.Write("summer.xml", R"(<files><file name="beach.xml" enabled="1"/><file name="shared.xml" enabled="1"/></files><objects>)" + NpcXml("Surfer", 1) + "</objects>");
folder.Write("beach.xml", "<objects>" + NpcXml("Lifeguard", 2) + "</objects>");
folder.Write("shared.xml", "<objects>" + NpcXml("Mayor", 3) + "</objects>");
EXPECT_EQ(Names(LoadWorld(folder.path, "root.xml", std::vector<TestEvent>{}).objects), (std::vector<std::string>{ "Lifeguard", "Mayor", "Surfer" }));
std::vector<TestEvent> events{ { 1, "Halloween", 0, "", "", R"({"summer.xml": false, "unknown.xml": false})" } };
const auto world = LoadWorld(folder.path, "root.xml", events);
// beach.xml goes with it; shared.xml stays, root.xml includes it too
EXPECT_EQ(Names(world.objects), (std::vector<std::string>{ "Mayor" }));
const auto* summer = Find(world, "summer.xml");
ASSERT_NE(summer, nullptr);
EXPECT_FALSE(summer->loaded);
EXPECT_TRUE(summer->enabled);
EXPECT_EQ(summer->switchedBy, "Halloween");
EXPECT_EQ(Find(world, "beach.xml"), nullptr);
// Switching off a file nothing loads is worth a word
ASSERT_EQ(world.warnings.size(), 1u);
EXPECT_NE(world.warnings[0].find("unknown.xml"), std::string::npos);
}
TEST(VanityEventsTests, FileSwitchOnANestedInclude) {
const Folder folder("nested");
folder.Write("root.xml", R"(<files><file name="town.xml" enabled="1"/></files>)");
folder.Write("town.xml", R"(<files><file name="lanterns.xml" enabled="0"/><file name="stalls.xml" enabled="1"/></files><objects>)" + NpcXml("Mayor", 1) + "</objects>");
folder.Write("lanterns.xml", "<objects>" + NpcXml("Lantern", 2) + "</objects>");
folder.Write("stalls.xml", "<objects>" + NpcXml("Stall", 3) + "</objects>");
std::vector<TestEvent> events{ { 1, "Festival", 0, "", "", R"({"lanterns.xml": true, "stalls.xml": false})" } };
const auto world = LoadWorld(folder.path, "root.xml", events);
// Switched on where town.xml names it, so in its place
EXPECT_EQ(Names(world.objects), (std::vector<std::string>{ "Lantern", "Mayor" }));
const auto* lanterns = Find(world, "lanterns.xml");
ASSERT_NE(lanterns, nullptr);
EXPECT_TRUE(lanterns->loaded);
EXPECT_FALSE(lanterns->enabled);
EXPECT_EQ(lanterns->includedBy, "town.xml");
EXPECT_EQ(lanterns->switchedBy, "Festival");
EXPECT_FALSE(Find(world, "stalls.xml")->loaded);
EXPECT_TRUE(world.warnings.empty());
}
TEST(VanityEventsTests, FileSwitchConflictsGoByPriority) {
const Folder folder("conflict");
folder.Write("root.xml", R"(<files><file name="summer.xml" enabled="1"/></files>)");
folder.Write("summer.xml", "<objects>" + NpcXml("Surfer", 1) + "</objects>");
// Heat wave has the higher priority, so it is laid on last and keeps summer.xml on
std::vector<TestEvent> events{ { 2, "Heat wave", 5, "", "", R"({"summer.xml": true})" }, { 1, "Halloween", 0, "", "", R"({"summer.xml": false})" } };
const auto world = LoadWorld(folder.path, "root.xml", events);
EXPECT_EQ(world.events, (std::vector<std::string>{ "Halloween", "Heat wave" }));
EXPECT_EQ(Names(world.objects), (std::vector<std::string>{ "Surfer" }));
ASSERT_EQ(world.fileConflicts.size(), 1u);
EXPECT_EQ(world.fileConflicts[0].file, "summer.xml");
EXPECT_EQ(world.fileConflicts[0].switches, (std::vector<std::pair<std::string, bool>>{ { "Halloween", false }, { "Heat wave", true } }));
EXPECT_EQ(Find(world, "summer.xml")->switchedBy, "Heat wave");
events[0].priority = -1;
const auto flipped = LoadWorld(folder.path, "root.xml", events);
EXPECT_TRUE(flipped.objects.empty());
EXPECT_EQ(flipped.fileConflicts[0].switches.back().first, "Halloween");
}
TEST(VanityEventsTests, FileSwitchesWithOverlaysAndRemovals) {
const Folder folder("combined");
folder.Write("root.xml", R"(<files><file name="summer.xml" enabled="1"/><file name="town.xml" enabled="1"/></files>)");
folder.Write("summer.xml", "<objects>" + NpcXml("Surfer", 1) + "</objects>");
folder.Write("town.xml", "<objects>" + NpcXml("Mayor", 2) + NpcXml("Baker", 3) + "</objects>");
folder.Write("halloween.xml", "<objects>" + NpcXml("Witch", 4) + "</objects>");
folder.Write("halloween-look.xml", R"(<files><file name="summer.xml" enabled="1"/></files><objects>)" + NpcXml("Mayor", 40) + "</objects>");
// The overlay's own include is switched off too; the overlay replaces the Mayor; the Baker is taken out
std::vector<TestEvent> events{ { 1, "Halloween", 0, "halloween-look.xml", "Baker\n", R"({"halloween.xml": true, "summer.xml": false})" } };
const auto world = LoadWorld(folder.path, "root.xml", events);
EXPECT_EQ(world.fileNpcs, 3u); // Mayor, Baker, Witch
EXPECT_EQ(Names(world.objects), (std::vector<std::string>{ "Witch", "Mayor" }));
EXPECT_EQ(world.objects[1].lot, 40);
EXPECT_TRUE(world.warnings.empty());
// Taking out an NPC that only a switched-on file has works the same
std::vector<TestEvent> more{ events[0], { 2, "No witches", 1, "", "Witch\n" } };
const auto both = LoadWorld(folder.path, "root.xml", more);
EXPECT_EQ(Names(both.objects), (std::vector<std::string>{ "Mayor" }));
// A switch that can't be read is a warning, and the rest of the event still applies
std::vector<TestEvent> broken{ { 1, "Broken", 0, "", "Baker\n", "{nope" } };
const auto withBroken = LoadWorld(folder.path, "root.xml", broken);
EXPECT_EQ(Names(withBroken.objects), (std::vector<std::string>{ "Surfer", "Mayor" }));
ASSERT_FALSE(withBroken.warnings.empty());
EXPECT_NE(withBroken.warnings[0].find("file switches"), std::string::npos);
}

View File

@@ -0,0 +1,74 @@
#include <gtest/gtest.h>
#include <fstream>
#include "VanityXml.h"
namespace {
std::string ReadSource(const std::string& relative) {
std::ifstream in(std::string(DLU_SOURCE_DIR) + "/" + relative, std::ios::binary);
return std::string(std::istreambuf_iterator<char>(in), {});
}
}
// The shipped files survive a read and write with nothing lost
TEST(VanityXmlTests, ShippedFilesRoundTrip) {
for (const auto* file : { "vanity/dev-tribute.xml", "vanity/atm.xml", "vanity/demo.xml", "vanity/root.xml" }) {
std::string error;
const auto first = VanityXml::Read(ReadSource(file), error);
ASSERT_TRUE(first) << file << ": " << error;
const auto again = VanityXml::Read(VanityXml::Write(*first), error);
ASSERT_TRUE(again) << file << ": " << error;
ASSERT_EQ(first->objects.size(), again->objects.size()) << file;
ASSERT_EQ(first->files.size(), again->files.size()) << file;
for (size_t i = 0; i < first->objects.size(); i++) {
const auto& a = first->objects[i];
const auto& b = again->objects[i];
EXPECT_EQ(a.name, b.name);
EXPECT_EQ(a.lot, b.lot);
EXPECT_EQ(a.equipment, b.equipment);
EXPECT_EQ(a.phrases, b.phrases);
EXPECT_EQ(a.config, b.config);
ASSERT_EQ(a.locations.size(), b.locations.size());
for (size_t l = 0; l < a.locations.size(); l++) {
EXPECT_EQ(a.locations[l].zone, b.locations[l].zone);
EXPECT_FLOAT_EQ(a.locations[l].x, b.locations[l].x);
EXPECT_FLOAT_EQ(a.locations[l].ry, b.locations[l].ry);
EXPECT_EQ(a.locations[l].chance, b.locations[l].chance);
}
}
}
}
TEST(VanityXmlTests, DevTributeHasItsNpcs) {
std::string error;
const auto doc = VanityXml::Read(ReadSource("vanity/dev-tribute.xml"), error);
ASSERT_TRUE(doc) << error;
ASSERT_FALSE(doc->objects.empty());
EXPECT_EQ(doc->objects.front().name, "Wincent - Developer");
EXPECT_EQ(doc->objects.front().lot, 2279);
EXPECT_EQ(doc->objects.front().equipment.size(), 4u);
EXPECT_EQ(doc->objects.front().locations.front().zone, 1200u);
}
// What the world server skips, and says so
TEST(VanityXmlTests, SkipsIncompleteParts) {
std::string error;
const auto doc = VanityXml::Read(R"(<files><file name="a.xml" enabled="1"/><file name="b.xml"/><file enabled="1"/></files>)"
R"(<objects><object name="NoLot" lot="x"><equipment>1, 2 ,x</equipment><locations><location zone="1200" x="1" y="2"/>)"
R"(<location zone="1200" x="1" y="2" z="3" rw="1" rx="0" ry="0" rz="0" chance="0.25"/></locations></object>)"
R"(<object name="Nowhere" lot="5"/></objects>)", error);
ASSERT_TRUE(doc) << error;
ASSERT_EQ(doc->files.size(), 2u);
EXPECT_TRUE(doc->files[0].enabled);
EXPECT_FALSE(doc->files[1].enabled);
ASSERT_EQ(doc->objects.size(), 2u);
EXPECT_EQ(doc->objects[0].lot, -1);
EXPECT_EQ(doc->objects[0].equipment, (std::vector<int32_t>{ 1, 2 }));
ASSERT_EQ(doc->objects[0].locations.size(), 1u);
EXPECT_EQ(doc->objects[0].locations[0].chance, 0.25f);
EXPECT_FALSE(doc->objects[0].locations[0].scale);
EXPECT_TRUE(doc->objects[1].locations.empty());
EXPECT_EQ(doc->warnings.size(), 2u); // the incomplete location, the object without locations
EXPECT_FALSE(VanityXml::Read("<objects>", error));
}

View File

@@ -0,0 +1,165 @@
#include <gtest/gtest.h>
#include <cstring>
#include <sstream>
#include "LevelFile.h"
#include "ZoneFile.h"
namespace {
// Little-endian file bytes, as the client's files are laid out
struct ZoneBytes {
std::string data;
template<typename T> ZoneBytes& Put(T value) { data.append(reinterpret_cast<const char*>(&value), sizeof(T)); return *this; }
template<typename T> void At(size_t pos, T value) { std::memcpy(data.data() + pos, &value, sizeof(T)); }
ZoneBytes& Text(const std::string& text) { Put<uint8_t>(static_cast<uint8_t>(text.size())); data += text; return *this; }
ZoneBytes& Wide(const std::string& text) { Put<uint8_t>(static_cast<uint8_t>(text.size())); for (char c : text) Put<uint16_t>(static_cast<uint16_t>(c)); return *this; }
ZoneBytes& Point(float x, float y, float z) { return Put(x).Put(y).Put(z); }
void Object(uint32_t version, uint32_t lot, float x, const std::string& settings) {
Put<int64_t>(lot * 10).Put<int32_t>(static_cast<int32_t>(lot));
if (version >= 38) Put<int32_t>(0); // node type
if (version >= 32) Put<uint32_t>(0); // glom ID
Point(x, 0, 0).Put(1.0f).Put(0.0f).Put(0.0f).Put(0.0f).Put(1.0f); // position, rotation, scale
Put<uint32_t>(static_cast<uint32_t>(settings.size()));
for (char c : settings) Put<uint16_t>(static_cast<uint16_t>(c));
Put<uint32_t>(0);
}
};
std::string SampleZone() {
ZoneBytes w;
w.Put<uint32_t>(41).Put<uint32_t>(3).Put<uint32_t>(1150); // version, revision, world
w.Point(1, 2, 3).Put(1.0f).Put(0.0f).Put(0.0f).Put(0.0f); // spawn point and rotation
w.Put<uint32_t>(1);
w.Text("scene.lvl").Put<uint32_t>(7).Put<uint32_t>(2).Text("Global").Put<uint8_t>(1).Put<uint8_t>(2).Put<uint8_t>(3);
w.Text("zone").Text("zone.raw").Text("Name").Text("Description");
w.Put<uint32_t>(1);
for (int i = 0; i < 2; i++) w.Put<uint64_t>(1).Point(0, 0, 0);
w.Put<uint32_t>(0).Put<uint32_t>(1).Put<uint32_t>(3);
// Movement: its config is waypoint commands (spaces dropped from the name)
w.Put<uint32_t>(18).Wide("Patrol").Put<uint32_t>(0).Put<uint32_t>(0).Put<uint32_t>(0);
w.Put<uint32_t>(1).Point(5, 0, 5).Put<uint32_t>(1).Wide("de lay").Wide(" 2 ");
// Spawner: its config is LDF
w.Put<uint32_t>(18).Wide("Spawner").Put<uint32_t>(4).Put<uint32_t>(0).Put<uint32_t>(0);
w.Put<int32_t>(6010).Put<uint32_t>(10).Put<int32_t>(1).Put<uint32_t>(1).Put<int64_t>(123).Put<uint8_t>(1);
w.Put<uint32_t>(1).Point(1, 1, 1).Put(1.0f).Put(0.0f).Put(0.0f).Put(0.0f).Put<uint32_t>(1).Wide("spawner_node_id").Wide("1:4");
// Property
w.Put<uint32_t>(8).Wide("PropertyPath").Put<uint32_t>(2).Put<uint32_t>(0).Put<uint32_t>(0);
w.Put<int32_t>(0).Put<int32_t>(1000).Put<uint32_t>(0).Put<uint64_t>(1100);
w.Wide("Block Yard");
w.Put<uint32_t>(4); for (char c : std::string("Desc")) w.Put<uint16_t>(static_cast<uint16_t>(c));
w.Put<int32_t>(0).Put<uint32_t>(0).Put(1.0f).Put<uint32_t>(0).Put<uint32_t>(0).Point(0, 0, 0).Put(128.0f);
w.Put<uint32_t>(3).Point(0, 0, 0).Point(10, 0, 0).Point(10, 0, 10);
return w.data;
}
}
TEST(ZoneFileTests, ReadsScenesAndPaths) {
std::istringstream stream(SampleZone());
ZoneFile zone;
zone.Read(stream);
EXPECT_FALSE(stream.fail());
EXPECT_EQ(zone.fileFormatVersion, ZoneFile::FileFormatVersion::Latest);
EXPECT_EQ(zone.worldID, 1150u);
EXPECT_EQ(zone.spawnpoint, NiPoint3(1, 2, 3));
ASSERT_EQ(zone.scenes.size(), 1u);
EXPECT_EQ(zone.scenes[0].filename, "scene.lvl");
EXPECT_EQ(zone.scenes[0].id, 7u);
EXPECT_EQ(zone.scenes[0].sceneType, 2u);
EXPECT_EQ(zone.zoneRawPath, "zone.raw");
ASSERT_EQ(zone.sceneTransitions.size(), 1u);
EXPECT_EQ(zone.sceneTransitions[0].points.size(), 2u);
ASSERT_EQ(zone.paths.size(), 3u);
const auto& patrol = zone.paths[0].pathWaypoints.at(0);
ASSERT_EQ(patrol.commands.size(), 1u);
EXPECT_EQ(patrol.commands[0].command, eWaypointCommandType::DELAY);
EXPECT_EQ(patrol.commands[0].data, "2");
const auto& spawner = zone.paths[1];
EXPECT_EQ(spawner.spawner.spawnedLOT, 6010);
EXPECT_EQ(spawner.spawner.spawnerObjID, 123);
EXPECT_EQ(spawner.pathWaypoints.at(0).config.find(u"spawner_node_id")->second->GetValueAsString(), "4");
const auto& property = zone.paths[2];
EXPECT_EQ(property.pathType, PathType::Property);
EXPECT_EQ(property.property.displayName, "Block Yard");
EXPECT_EQ(property.property.displayDesc, "Desc");
EXPECT_EQ(property.property.maxBuildHeight, 128.0f);
EXPECT_EQ(property.pathWaypoints.size(), 3u);
}
TEST(ZoneFileTests, ShortFilesThrowOrFail) {
const auto zone = SampleZone();
for (const size_t length : { size_t{ 3 }, size_t{ 40 }, zone.size() / 2, zone.size() - 1 }) {
std::istringstream stream(zone.substr(0, length));
ZoneFile file;
bool threw = false;
try { file.Read(stream); } catch (const std::runtime_error&) { threw = true; }
EXPECT_TRUE(threw || stream.fail()) << length;
}
}
// Chunked files lay their objects out by the file info chunk's version
TEST(LevelFileTests, ReadsChunkedObjects) {
ZoneBytes w;
const auto chunk = [&w](uint32_t id) {
const auto start = w.data.size();
w.Put<uint32_t>(0x4B4E4843).Put<uint32_t>(id).Put<uint16_t>(1).Put<uint16_t>(0).Put<uint32_t>(0).Put<uint32_t>(0);
w.At<uint32_t>(start + 16, static_cast<uint32_t>(w.data.size()));
return start;
};
const auto end = [&w](size_t start) { w.At<uint32_t>(start + 12, static_cast<uint32_t>(w.data.size() - start)); };
const auto info = chunk(LevelFile::FileInfo);
w.Put<uint32_t>(41).Put<uint32_t>(9).Put<uint32_t>(0).Put<uint32_t>(0).Put<uint32_t>(0);
end(info);
const auto objects = chunk(LevelFile::SceneObjectData);
w.Put<uint32_t>(2);
w.Object(41, 4945, 10, "respawnname=0:NS_LW_Portal\nloadOnClientOnly=7:1");
w.Object(41, 176, -4, "spawntemplate=1:6010");
end(objects);
std::istringstream stream(w.data);
LevelFile level;
level.Read(stream);
EXPECT_EQ(level.chunkHeaders.at(LevelFile::FileInfo).fileInfo.revision, 9u);
ASSERT_EQ(level.objects.size(), 2u);
EXPECT_EQ(level.objects[0].lot, 4945);
EXPECT_EQ(level.objects[0].position.x, 10.0f);
EXPECT_EQ(level.objects[0].settings.find(u"respawnname")->second->GetValueAsString(), "NS_LW_Portal");
EXPECT_EQ(level.objects[1].id, 1760);
EXPECT_EQ(level.objects[1].settings.find(u"spawntemplate")->second->GetValueAsString(), "6010");
}
// Some live scenes (e.g. Nimbus Station's Gnarled Forest launch pad) are from before chunks
TEST(LevelFileTests, ReadsFilesWithoutChunks) {
ZoneBytes w;
w.Put<uint16_t>(30).Put<uint16_t>(0).Put<uint8_t>(1); // version, type, important
w.data.append(48 + 12, '\0'); // version 30 settings the world skips
w.Put<uint32_t>(0); // no skydome
w.Put<uint32_t>(1);
w.Object(30, 4945, 3, "respawnname=0:NS_GF");
std::istringstream stream(w.data);
LevelFile level;
level.Read(stream);
ASSERT_EQ(level.objects.size(), 1u);
EXPECT_EQ(level.objects[0].position.x, 3.0f);
EXPECT_EQ(level.objects[0].settings.find(u"respawnname")->second->GetValueAsString(), "NS_GF");
EXPECT_EQ(level.chunkHeaders.count(LevelFile::SceneObjectData), 1u);
}
TEST(LevelFileTests, DamagedFilesKeepWhatWasRead) {
ZoneBytes w;
w.Put<uint16_t>(30).Put<uint16_t>(0).Put<uint8_t>(1);
w.data.append(48 + 12, '\0');
w.Put<uint32_t>(0).Put<uint32_t>(2);
w.Object(30, 1, 0, "a=0:b");
w.Object(30, 2, 0, "c=0:d");
w.data.resize(w.data.size() - 2); // cut into the last object
std::istringstream stream(w.data);
LevelFile level;
EXPECT_THROW(level.Read(stream), std::runtime_error);
EXPECT_EQ(level.objects.size(), 1u);
}

View File

@@ -1,7 +1,9 @@
#include "Game.h"
class Logger;
class dConfig;
namespace Game
{
Logger* logger;
dConfig* config = nullptr; // Permissions.cpp reads the permission levels from it
} // namespace Game