From b5de6cf2c9088993079979577d054a1f52f4c16b Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 10 Nov 2026 19:11:29 -0600 Subject: [PATCH] feat(world): the game client's level editor packets A world server answers the client's level editor: it remembers per connection whether the editor is on (and never sends such a client a zone load), takes and gives up the zone's lock, says whether files may be sent, takes them, sends the zone's files, and reloads the zone. Who may is the same as for the dashboard's zone file API: the account's editor level against zone_publish_gm_level, the zone's editors or an admin, and the lock's holder to send files. Files are unpacked, checked, written and packed on a worker thread, and every request is in the audit log. Co-Authored-By: Claude Fable 5.2 --- dGame/dUtilities/CMakeLists.txt | 1 + dGame/dUtilities/ZoneEditor.cpp | 484 ++++++++++++++++++ dGame/dUtilities/ZoneEditor.h | 44 ++ dNet/ClientPackets.cpp | 51 ++ dNet/ClientPackets.h | 47 ++ dNet/PacketFields.inc | 79 +++ dNet/WorldPackets.cpp | 54 ++ dNet/WorldPackets.h | 60 +++ dWorldServer/WorldServer.cpp | 43 +- tests/dGameTests/dNetTests/CMakeLists.txt | 3 +- .../dNetTests/PacketDecoderCoverageTests.cpp | 8 +- .../dNetTests/ZoneEditorPacketsTests.cpp | 208 ++++++++ 12 files changed, 1076 insertions(+), 6 deletions(-) create mode 100644 dGame/dUtilities/ZoneEditor.cpp create mode 100644 dGame/dUtilities/ZoneEditor.h create mode 100644 tests/dGameTests/dNetTests/ZoneEditorPacketsTests.cpp diff --git a/dGame/dUtilities/CMakeLists.txt b/dGame/dUtilities/CMakeLists.txt index ef6ed86e6..bf157e3da 100644 --- a/dGame/dUtilities/CMakeLists.txt +++ b/dGame/dUtilities/CMakeLists.txt @@ -10,6 +10,7 @@ set(DGAME_DUTILITIES_SOURCES "BrickDatabase.cpp" "LiveEvents.cpp" "HolidayEvents.cpp" "Contraband.cpp" + "ZoneEditor.cpp" "PropertyBuilders.cpp" "PropertyRent.cpp" "PropertyReputation.cpp" diff --git a/dGame/dUtilities/ZoneEditor.cpp b/dGame/dUtilities/ZoneEditor.cpp new file mode 100644 index 000000000..9b1e60067 --- /dev/null +++ b/dGame/dUtilities/ZoneEditor.cpp @@ -0,0 +1,484 @@ +#include "ZoneEditor.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "AssetManager.h" +#include "CDClientManager.h" +#include "CDZoneTableTable.h" +#include "Character.h" +#include "CharacterComponent.h" +#include "ChatPackets.h" +#include "ClientPackets.h" +#include "Database.h" +#include "Game.h" +#include "GeneralUtils.h" +#include "Logger.h" +#include "MasterPackets.h" +#include "User.h" +#include "UserManager.h" +#include "ZoneEditLock.h" +#include "ZoneFile.h" +#include "ZoneFilePack.h" +#include "ZoneFilePush.h" +#include "ZoneOverrideStore.h" +#include "ZonePublish.h" +#include "dConfig.h" +#include "dServer.h" +#include "master/WorldFiles.h" + +using ZoneEditLock::eResult; +using ZoneFilePack::eFileType; + +namespace { + constexpr const char* AUDIT_ACTION = "zone_publish"; + constexpr uint64_t MIB = 1024 * 1024; + // How long a client has to send its files after it was told it may + constexpr int64_t PUSH_GRANT_SECONDS = 600; + // What a requester is told holds the lock when nobody does (it has to lock the zone first) + constexpr const char16_t* NOBODY = u"nobody (lock the map first)"; + + struct SystemAddressLess { + bool operator()(const SystemAddress& a, const SystemAddress& b) const { + return a.binaryAddress != b.binaryAddress ? a.binaryAddress < b.binaryAddress : a.port < b.port; + } + }; + + // A push the client was told it may send + struct Grant { + uint8_t fileType{}; + int32_t levelFileId{}; + int64_t until{}; + }; + + std::set g_Editing; + std::map g_Grants; + bool g_Changing = false; // this zone's files are being checked or written here + uint32_t g_NextChange = 0; + + int64_t Now() { return static_cast(std::time(nullptr)); } + + uint32_t ConfigNumber(const char* key, uint32_t fallback) { + return GeneralUtils::TryParse(Game::config->GetValue(key)).value_or(fallback); + } + + ZoneEditLock::Levels Levels() { + ZoneEditLock::Levels levels; + levels.publish = static_cast(std::clamp(ConfigNumber("zone_publish_gm_level", ZoneEditLock::DEFAULT_PUBLISH_LEVEL), 1, 9)); + levels.admin = static_cast(std::clamp(ConfigNumber("zone_lock_admin_gm_level", ZoneEditLock::DEFAULT_ADMIN_LEVEL), 1, 9)); + return levels; + } + + ZonePublish::Limits Limits() { + ZonePublish::Limits limits; + limits.maxFileBytes = static_cast(std::max(ConfigNumber("zone_publish_max_file_mb", 64), 1)) * MIB; + limits.maxTotalBytes = static_cast(std::max(ConfigNumber("zone_publish_max_total_mb", 256), 1)) * MIB; + limits.maxFiles = std::max(ConfigNumber("zone_publish_max_files", 128), 1); + limits.maxObjects = std::max(ConfigNumber("zone_publish_max_objects", 100000), 1); + return limits; + } + + std::filesystem::path Root() { + return ZoneOverrideStore::RootFromSetting(Game::config->GetValue("zone_override_dir")); + } + + // ---- Who is asking ---- + + struct Requester { + User* user{}; + ZoneEditLock::Actor actor; + std::string name; + }; + + /** + * The account behind a connection, as the server knows it now: its editor level (the account's GM level when it + * is at editor_gm_level, else 0; nothing the client says is used) and whether it is one of this zone's editors. + */ + std::optional RequesterOf(const SystemAddress& sysAddr, uint32_t zoneId) { + auto* const user = UserManager::Instance()->GetUser(sysAddr); + if (!user || zoneId == 0) return std::nullopt; + const auto editorGmLevel = ConfigNumber("editor_gm_level", CharacterComponent::DEFAULT_EDITOR_GM_LEVEL); + Requester requester; + requester.user = user; + requester.name = user->GetUsername(); + requester.actor.accountId = user->GetAccountID(); + requester.actor.gmLevel = static_cast(CharacterComponent::EditAttribute(user->GetMaxGMLevel(), editorGmLevel)); + requester.actor.assigned = Database::Get()->IsZoneEditor(zoneId, requester.actor.accountId); + return requester; + } + + std::string AccountName(uint32_t accountId) { + const auto account = Database::Get()->GetAccountById(accountId); + return account.contains("error") ? "account " + std::to_string(accountId) : account.value("name", std::string()); + } + + void Audit(const Requester& requester, uint32_t zoneId, const std::string& action, const std::string& result) { + const auto text = "in game, zone " + std::to_string(zoneId) + ", " + action + ": " + result; + try { + Database::Get()->InsertAuditLog(requester.actor.accountId, requester.name, AUDIT_ACTION, text, 0, 0); + } catch (const std::exception& ex) { + LOG("Failed to write audit log entry: %s", ex.what()); + } + LOG("[audit] %s: %s %s", requester.name.c_str(), AUDIT_ACTION, text.c_str()); + } + + std::optional HolderOf(const std::optional& lock) { + return lock ? std::optional(lock->accountId) : std::nullopt; + } + + // ---- The zone, as the server has it ---- + + struct ZoneRef { + uint32_t zoneId{}; + std::string luzPath; // relative to res/maps/, lowercase + std::string folder; + }; + + // The zone this world runs; its .luz path comes from the ZoneTable + std::optional ThisZone() { + const auto zoneId = static_cast(Game::server->GetZoneID()); + if (zoneId == 0) return std::nullopt; + const auto* const row = CDZoneTableTable::Query(zoneId); + if (!row) return std::nullopt; + auto luz = row->zoneName; + std::replace(luz.begin(), luz.end(), '\\', '/'); + ZoneRef zone{ zoneId, ZonePublish::Canonical(luz), {} }; + zone.folder = ZonePublish::FolderOf(zone.luzPath); + if (!zone.luzPath.ends_with(".luz") || ZonePublish::NameProblem(zone.luzPath) || zone.folder.empty()) return std::nullopt; + return zone; + } + + std::optional ReadAsset(const std::string& name, bool clientOnly = false) { + auto stream = clientOnly ? Game::assetManager->GetClientFile(name) : Game::assetManager->GetFile(name); + if (!stream) return std::nullopt; + return std::string(stream.Bytes()); + } + + // A zone file's scenes and terrain file; nullopt when it can't be read + std::optional ReadZoneHeader(const std::string& bytes) { + std::istringstream stream(bytes); + ZoneFile luz; + try { + luz.ReadHeader(stream); + } catch (const std::exception&) { + return std::nullopt; + } + if (stream.fail()) return std::nullopt; + return luz; + } + + std::string BaseOf(const std::string& path) { + const auto slash = path.find_last_of('/'); + return slash == std::string::npos ? path : path.substr(slash + 1); + } + + std::string TriggersOf(const std::string& scenePath) { + const auto dot = scenePath.find_last_of('.'); + return (dot == std::string::npos ? scenePath : scenePath.substr(0, dot)) + ".lutriggers"; + } + + // ---- The worker ---- + + using Completion = std::function; + std::thread g_Worker; + std::mutex g_Mutex; + std::condition_variable g_Wake; + std::deque> g_Jobs; + std::deque g_Done; + bool g_Stopping = false; + + void WorkerLoop() { + while (true) { + std::function job; + { + std::unique_lock lock(g_Mutex); + g_Wake.wait(lock, [] { return g_Stopping || !g_Jobs.empty(); }); + if (g_Stopping) return; + job = std::move(g_Jobs.front()); + g_Jobs.pop_front(); + } + auto done = job(); + std::lock_guard lock(g_Mutex); + g_Done.push_back(std::move(done)); + } + } + + void Submit(std::function job) { + { + std::lock_guard lock(g_Mutex); + if (!g_Worker.joinable()) { + g_Stopping = false; + g_Worker = std::thread(WorkerLoop); + } + g_Jobs.push_back(std::move(job)); + } + g_Wake.notify_one(); + } + + void SendPushResponse(const SystemAddress& sysAddr, eResult result, uint8_t fileType, int32_t levelFileId, const std::u16string& lockedBy = {}) { + ClientPackets::SlashPushMapResponse response; + response.result = static_cast(result); + response.fileType = fileType; + response.levelFileId = levelFileId; + response.lockedBy = lockedBy; + response.Send(sysAddr); + } + + // The zone's instances are replaced by master, as for /reloadworld; every client here is told first + void StartReload(uint32_t zoneId, const std::string& who) { + ClientPackets::ImpendingReloadNotify notify; + notify.Broadcast(); + WorldReloadRequest request; + request.zoneId = zoneId; + request.requestedBy = ("zone files, " + who).substr(0, WorldFiles::MAX_BY); + MasterPackets::SendToMaster(request); + } +} + +namespace ZoneEditor { + void SetEditing(const SystemAddress& sysAddr, const bool editing) { + if (editing) g_Editing.insert(sysAddr); + else g_Editing.erase(sysAddr); + } + + bool IsEditing(const SystemAddress& sysAddr) { + return g_Editing.contains(sysAddr); + } + + bool MaySendZoneLoad(const SystemAddress& sysAddr) { + return !IsEditing(sysAddr); + } + + void Forget(const SystemAddress& sysAddr) { + g_Editing.erase(sysAddr); + g_Grants.erase(sysAddr); + } + + void HandleLock(const SystemAddress& sysAddr, const bool lock) { + const auto zone = ThisZone(); + const auto requester = RequesterOf(sysAddr, zone ? zone->zoneId : 0); + if (!zone || !requester) return; + const auto levels = Levels(); + auto held = Database::Get()->GetZoneLock(zone->zoneId); + auto result = lock ? ZoneEditLock::Take(requester->actor, HolderOf(held), levels) : ZoneEditLock::Release(requester->actor, HolderOf(held), levels); + std::string detail; + if (result == eResult::OK && lock) { + // Someone else got there between the look and the take + if (!Database::Get()->TakeZoneLock(zone->zoneId, requester->actor.accountId, Now())) { + held = Database::Get()->GetZoneLock(zone->zoneId); + result = held && held->accountId == requester->actor.accountId ? eResult::NO_CHANGE : eResult::LOCKED_BY_OTHER; + } + } else if (result == eResult::OK) { + Database::Get()->ReleaseZoneLock(zone->zoneId); + if (held->accountId != requester->actor.accountId) detail = " (taken away from " + AccountName(held->accountId) + ")"; + } + + const auto now = Database::Get()->GetZoneLock(zone->zoneId); + ClientPackets::SlashLockMapResponse response; + response.result = static_cast(result); + if (result == eResult::LOCKED_BY_OTHER && now) response.lockedBy.string = GeneralUtils::UTF8ToUTF16(AccountName(now->accountId)).substr(0, 32); + response.locked = now && now->accountId == requester->actor.accountId ? 1 : 0; + response.Send(sysAddr); + Audit(*requester, zone->zoneId, lock ? "lock" : "unlock", "result " + std::to_string(response.result) + detail); + } + + void HandlePushRequest(const SystemAddress& sysAddr, const uint8_t fileType, const int32_t levelFileId) { + const auto zone = ThisZone(); + const auto requester = RequesterOf(sysAddr, zone ? zone->zoneId : 0); + if (!zone || !requester) return; + g_Grants.erase(sysAddr); + if (!ZoneFilePack::ValidType(fileType) || Root().empty()) { + // Nothing a client can be told with the results there are: the nearest is that it may not + SendPushResponse(sysAddr, eResult::NOT_ASSIGNED, fileType, levelFileId); + Audit(*requester, zone->zoneId, "push request", Root().empty() ? "refused (edited zone files are turned off)" : "refused (unknown file type)"); + return; + } + const auto held = Database::Get()->GetZoneLock(zone->zoneId); + const auto result = ZoneEditLock::ChangeOrWhyNot(requester->actor, HolderOf(held), Levels()); + std::u16string lockedBy; + if (result == eResult::LOCKED_BY_OTHER) lockedBy = held ? GeneralUtils::UTF8ToUTF16(AccountName(held->accountId)) : std::u16string(NOBODY); + if (result == eResult::OK) g_Grants[sysAddr] = { fileType, levelFileId, Now() + PUSH_GRANT_SECONDS }; + SendPushResponse(sysAddr, result, fileType, levelFileId, lockedBy); + Audit(*requester, zone->zoneId, "push request (file type " + std::to_string(fileType) + ")", "result " + std::to_string(static_cast(result))); + } + + void HandlePush(const SystemAddress& sysAddr, const uint8_t fileType, const int32_t levelFileId, std::string packedFiles) { + const auto zone = ThisZone(); + const auto requester = RequesterOf(sysAddr, zone ? zone->zoneId : 0); + if (!zone || !requester) return; + const auto refuse = [&](const std::string& why) { + Audit(*requester, zone->zoneId, "push", "refused (" + why + "); nothing was written"); + ChatPackets::SendSystemMessage(sysAddr, u"The map files were not taken: " + GeneralUtils::UTF8ToUTF16(why)); + }; + + // Only what was asked for and allowed just before, and everything is looked at again now + const auto grant = g_Grants.find(sysAddr); + const bool granted = grant != g_Grants.end() && grant->second.fileType == fileType && grant->second.levelFileId == levelFileId && grant->second.until >= Now(); + if (grant != g_Grants.end()) g_Grants.erase(grant); + if (!granted) return refuse("the files were not asked for first"); + const auto root = Root(); + if (root.empty()) return refuse("edited zone files are turned off"); + const auto result = ZoneEditLock::Change(requester->actor, HolderOf(Database::Get()->GetZoneLock(zone->zoneId)), Levels()); + if (result != eResult::OK) return refuse("result " + std::to_string(static_cast(result))); + if (g_Changing) return refuse("this zone's files are being changed right now"); + const auto limits = Limits(); + if (packedFiles.size() > limits.maxTotalBytes) return refuse("larger than this server takes"); + + ZoneFilePush::Job job; + job.zoneId = zone->zoneId; + job.luzPath = zone->luzPath; + job.fileType = static_cast(fileType); + job.packedFiles = std::move(packedFiles); + job.limits = limits; + job.root = root; + job.id = "w" + std::to_string(Game::server->GetInstanceID()) + "-" + std::to_string(Now()) + "-" + std::to_string(++g_NextChange); + job.who = requester->name; + job.now = Now(); + job.keepBackups = ConfigNumber("zone_publish_max_backups", 20); + // The client's own files say which scenes the zone has, and which files a set may leave out + job.clientLuz = ReadAsset("maps/" + zone->luzPath, true); + if (const auto luz = job.clientLuz ? ReadZoneHeader(*job.clientLuz) : std::nullopt) { + for (const auto& scene : luz->scenes) { + const auto path = zone->folder + ZonePublish::Canonical(scene.filename); + job.clientScenes.insert(path); + if (Game::assetManager->HasClientFile("maps/" + path)) job.clientFiles.insert(path); + } + const auto terrain = zone->folder + ZonePublish::Canonical(luz->zoneRawPath); + if (Game::assetManager->HasClientFile("maps/" + terrain)) job.clientFiles.insert(terrain); + } + + g_Changing = true; + const auto who = *requester; + const auto zoneId = zone->zoneId; + Submit([job = std::move(job), who, zoneId, sysAddr, fileType]() -> Completion { + const auto result = ZoneFilePush::Run(job); + return [result, who, zoneId, sysAddr, fileType, id = job.id] { + g_Changing = false; + if (!result.applied) { + Audit(who, zoneId, "push (file type " + std::to_string(fileType) + ")", "refused (" + result.problem + "); nothing was written"); + ChatPackets::SendSystemMessage(sysAddr, u"The map files were not taken: " + GeneralUtils::UTF8ToUTF16(result.problem)); + return; + } + Audit(who, zoneId, "push (file type " + std::to_string(fileType) + ")", "applied as " + id + " (" + std::to_string(result.files) + " files" + + (result.backupId ? ", the set before is backup " + *result.backupId : "") + "), reload started"); + StartReload(zoneId, who.name); + }; + }); + } + + void HandlePull(const SystemAddress& sysAddr, const uint8_t fileType, const int32_t levelFileId) { + const auto zone = ThisZone(); + const auto requester = RequesterOf(sysAddr, zone ? zone->zoneId : 0); + if (!zone || !requester) return; + const auto refuse = [&](const std::string& why) { + Audit(*requester, zone->zoneId, "pull", "refused (" + why + ")"); + ChatPackets::SendSystemMessage(sysAddr, u"The map files were not sent: " + GeneralUtils::UTF8ToUTF16(why)); + }; + if (!ZoneFilePack::ValidType(fileType)) return refuse("unknown file type"); + const auto result = ZoneEditLock::Read(requester->actor, Levels()); + if (result != eResult::OK) return refuse("result " + std::to_string(static_cast(result))); + + // The files as this server reads them now (an edited set, else the client's), read here on the main thread + const auto type = static_cast(fileType); + const auto luzBytes = ReadAsset("maps/" + zone->luzPath); + const auto luz = luzBytes ? ReadZoneHeader(*luzBytes) : std::nullopt; + if (!luz) return refuse("the zone file can't be read"); + const auto limit = Limits().maxTotalBytes; + uint64_t total = 0; + bool tooLarge = false; + const auto read = [&](const std::string& path) -> std::optional { + auto bytes = ReadAsset("maps/" + path); + if (!bytes) return std::nullopt; + total += bytes->size(); + tooLarge = tooLarge || total > limit; + return ZoneFilePack::Entry{ BaseOf(path), std::move(*bytes) }; + }; + auto files = std::make_shared(); + const bool whole = type == eFileType::WHOLE_MAP; + if (whole || type == eFileType::ZONE) files->zone = ZoneFilePack::Entry{ BaseOf(zone->luzPath), *luzBytes }; + if (whole || type == eFileType::TERRAIN) files->terrain = read(zone->folder + ZonePublish::Canonical(luz->zoneRawPath)); + if (whole || type == eFileType::SCENE || type == eFileType::TRIGGERS) { + for (const auto& scene : luz->scenes) { + if (!whole && scene.id != static_cast(levelFileId)) continue; + if (ZonePublish::NameProblem(scene.filename)) continue; + const auto path = zone->folder + ZonePublish::Canonical(scene.filename); + if (whole || type == eFileType::SCENE) { + if (auto entry = read(path)) files->scenes.push_back(std::move(*entry)); + } + if (whole || type == eFileType::TRIGGERS) { + if (auto entry = read(TriggersOf(path))) files->triggers.push_back(std::move(*entry)); + } + if (!whole) break; + } + } + if (tooLarge) return refuse("larger than this server sends"); + const bool found = type == eFileType::ZONE ? files->zone.has_value() : type == eFileType::TERRAIN ? files->terrain.has_value() : + type == eFileType::SCENE ? !files->scenes.empty() : type == eFileType::TRIGGERS ? !files->triggers.empty() : files->zone && files->terrain; + if (!found) return refuse("the zone has no such file"); + + const auto who = *requester; + const auto zoneId = zone->zoneId; + Submit([files, type, who, zoneId, sysAddr, fileType, levelFileId]() -> Completion { + auto packed = std::make_shared(ZoneFilePack::Pack(type, *files)); + const auto count = files->Count(); + return [packed, count, who, zoneId, sysAddr, fileType, levelFileId] { + // Gone meanwhile: nobody to send it to + if (!UserManager::Instance()->GetUser(sysAddr)) return; + ClientPackets::SlashPullMapResponse response; + response.fileType = fileType; + response.levelFileId = levelFileId; + response.packedFiles = std::move(*packed); + response.Send(sysAddr); + Audit(who, zoneId, "pull (file type " + std::to_string(fileType) + ")", "sent " + std::to_string(count) + " file(s)"); + }; + }); + } + + void HandleReload(const SystemAddress& sysAddr) { + const auto zone = ThisZone(); + const auto requester = RequesterOf(sysAddr, zone ? zone->zoneId : 0); + if (!zone || !requester) return; + const auto result = ZoneEditLock::Read(requester->actor, Levels()); + if (result != eResult::OK) { + Audit(*requester, zone->zoneId, "reload", "refused (result " + std::to_string(static_cast(result)) + ")"); + ChatPackets::SendSystemMessage(sysAddr, u"You may not reload this map."); + return; + } + Audit(*requester, zone->zoneId, "reload", "reload started"); + StartReload(zone->zoneId, requester->name); + } + + void Update() { + std::deque done; + { + std::lock_guard lock(g_Mutex); + if (g_Done.empty()) return; + done.swap(g_Done); + } + for (auto& completion : done) { + try { + if (completion) completion(); + } catch (const std::exception& ex) { + LOG("Zone editor: finishing a request failed: %s", ex.what()); + } + } + } + + void Shutdown() { + { + std::lock_guard lock(g_Mutex); + g_Stopping = true; + } + g_Wake.notify_all(); + if (g_Worker.joinable()) g_Worker.join(); + } +} diff --git a/dGame/dUtilities/ZoneEditor.h b/dGame/dUtilities/ZoneEditor.h new file mode 100644 index 000000000..f4dd33609 --- /dev/null +++ b/dGame/dUtilities/ZoneEditor.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include + +#include "RakNetTypes.h" + +/** + * The game client's level editor, on a world server (docs/ZonePublish.md): the client says when its editor is on, + * locks the zone, sends the zone's files, asks for them and asks for a reload. Everything goes through what the + * dashboard's zone file API uses: the same checks (ZonePublish), override folder (ZoneOverrideStore), locks and + * editors (ZoneEditLock, IZoneEditing), audit log entries and world reload. Only how the files travel is different + * (ZoneFilePack). + * + * Packets are read and answered on the main thread. Unpacking, checking, writing and packing files run on one worker + * thread, which touches no connection, database or setting and hands its result back to the main thread (Update). + */ +namespace ZoneEditor { + // The client's editor was started or ended (it sends nothing else about it) + void SetEditing(const SystemAddress& sysAddr, bool editing); + bool IsEditing(const SystemAddress& sysAddr); + + /** + * Whether a client may be told to load a zone. Never while its editor is on: the client drops its connection + * when it is told to load a zone then. + */ + bool MaySendZoneLoad(const SystemAddress& sysAddr); + + // The connection is gone: nothing is kept about it + void Forget(const SystemAddress& sysAddr); + + // The editor's requests (fileType and levelFileId as the client sent them) + void HandleLock(const SystemAddress& sysAddr, bool lock); + void HandlePushRequest(const SystemAddress& sysAddr, uint8_t fileType, int32_t levelFileId); + void HandlePush(const SystemAddress& sysAddr, uint8_t fileType, int32_t levelFileId, std::string packedFiles); + void HandlePull(const SystemAddress& sysAddr, uint8_t fileType, int32_t levelFileId); + void HandleReload(const SystemAddress& sysAddr); + + // Every frame: finish what the worker did + void Update(); + + // Stops the worker (files being written are finished first) + void Shutdown(); +} diff --git a/dNet/ClientPackets.cpp b/dNet/ClientPackets.cpp index 45c181713..701e66640 100644 --- a/dNet/ClientPackets.cpp +++ b/dNet/ClientPackets.cpp @@ -1146,4 +1146,55 @@ namespace ClientPackets { } return true; } + + void SlashPushMapResponse::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(result); + bitStream.Write(fileType); + bitStream.Write(levelFileId); + for (const char16_t character : lockedBy) { + if (character == u'\0') break; + bitStream.Write(character); + } + bitStream.Write(0); + } + + bool SlashPushMapResponse::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(bitStream.Read(result)); + VALIDATE_READ(bitStream.Read(fileType)); + VALIDATE_READ(bitStream.Read(levelFileId)); + lockedBy.clear(); + for (uint16_t character = 0; ;) { + VALIDATE_READ(bitStream.Read(character)); + if (character == 0) break; + lockedBy += static_cast(character); + } + return true; + } + + void SlashPullMapResponse::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(fileType); + bitStream.Write(levelFileId); + bitStream.Write(packedFiles.data(), static_cast(packedFiles.size())); + } + + bool SlashPullMapResponse::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(bitStream.Read(fileType)); + VALIDATE_READ(bitStream.Read(levelFileId)); + packedFiles.resize(BITS_TO_BYTES(bitStream.GetNumberOfUnreadBits())); + if (!packedFiles.empty()) VALIDATE_READ(bitStream.Read(packedFiles.data(), static_cast(packedFiles.size()))); + return true; + } + + void SlashLockMapResponse::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(result); + bitStream.Write(lockedBy); + bitStream.Write(locked); + } + + bool SlashLockMapResponse::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(bitStream.Read(result)); + VALIDATE_READ(bitStream.Read(lockedBy)); + VALIDATE_READ(bitStream.Read(locked)); + return true; + } } diff --git a/dNet/ClientPackets.h b/dNet/ClientPackets.h index b48c09b63..19e961195 100644 --- a/dNet/ClientPackets.h +++ b/dNet/ClientPackets.h @@ -705,6 +705,53 @@ namespace ClientPackets { void Serialize(RakNet::BitStream& bitStream) const override; bool Deserialize(RakNet::BitStream& bitStream) override; }; + + /** + * Answers to the client's level editor (docs/ZonePublish.md). result: 0 allowed, 1 the account's editor level is + * too low, 2 the account is not one of the zone's editors, 3 another account holds the zone's lock (lockedBy + * says who), 4 nothing changed (lock answers only). + */ + + // Server -> client. The zone is about to be reloaded from its files. No payload. + struct ImpendingReloadNotify : public LUBitStream { + ImpendingReloadNotify() : LUBitStream(ServiceType::CLIENT, MessageType::Client::IMPENDING_RELOAD_NOTIFY) {} + }; + + // Server -> client. Whether the client may send the files it asked to (it then sends them when result is 0). + struct SlashPushMapResponse : public LUBitStream { + uint8_t result{}; + uint8_t fileType{}; // as asked + int32_t levelFileId{}; // as asked + // Written as UTF-16 characters and a null + std::u16string lockedBy{}; + + SlashPushMapResponse() : LUBitStream(ServiceType::CLIENT, MessageType::Client::SLASH_PUSH_MAP_RESPONSE) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + }; + + // Server -> client. The zone's files the client asked for (ZoneFilePack). + struct SlashPullMapResponse : public LUBitStream { + uint8_t fileType{}; + int32_t levelFileId{}; + // Everything after the level file ID + std::string packedFiles{}; + + SlashPullMapResponse() : LUBitStream(ServiceType::CLIENT, MessageType::Client::SLASH_PULL_MAP_RESPONSE) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + }; + + // Server -> client. How taking or giving up the zone's lock went, and whether the client holds it now. + struct SlashLockMapResponse : public LUBitStream { + uint8_t result{}; + LUWString lockedBy{ 33 }; + uint8_t locked{}; + + SlashLockMapResponse() : LUBitStream(ServiceType::CLIENT, MessageType::Client::SLASH_LOCK_MAP_RESPONSE) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + }; } #endif // CLIENTPACKETS_H diff --git a/dNet/PacketFields.inc b/dNet/PacketFields.inc index 9d6d852d7..3e6860442 100644 --- a/dNet/PacketFields.inc +++ b/dNet/PacketFields.inc @@ -89,6 +89,10 @@ json ToJson(const ClientPackets::GuildRemovePlayer& m); json ToJson(const ClientPackets::GuildLoginLogout& m); json ToJson(const ClientPackets::GuildData& m); json ToJson(const ClientPackets::GuildData::Member& m); +json ToJson(const ClientPackets::ImpendingReloadNotify& m); +json ToJson(const ClientPackets::SlashPushMapResponse& m); +json ToJson(const ClientPackets::SlashPullMapResponse& m); +json ToJson(const ClientPackets::SlashLockMapResponse& m); json ToJson(const CommonPackets::ClientVersionConfirm& m); json ToJson(const CommonPackets::ServerVersionConfirm& m); json ToJson(const CommonPackets::DisconnectNotify& m); @@ -141,6 +145,12 @@ json ToJson(const WorldPackets::HandleFunness& m); json ToJson(const WorldPackets::RequestUgcManifestInfo& m); json ToJson(const WorldPackets::UgcDownloadFailed& m); json ToJson(const WorldPackets::UIHelpTop5& m); +json ToJson(const WorldPackets::HappyFlowerModeNotify& m); +json ToJson(const WorldPackets::SlashReloadMap& m); +json ToJson(const WorldPackets::SlashPushMapRequest& m); +json ToJson(const WorldPackets::SlashPushMap& m); +json ToJson(const WorldPackets::SlashPullMap& m); +json ToJson(const WorldPackets::LockMapRequest& m); json ToJson(const ChatPackets::WorldRoutePacket& m); json ToJson(const CDClientReload& m); json ToJson(const PlayerPositions& m); @@ -909,6 +919,32 @@ json ToJson(const ClientPackets::GuildData::Member& m) { j["name"] = ToJson(m.name); return j; } +json ToJson(const ClientPackets::ImpendingReloadNotify& m) { + json j = json::object(); + return j; +} +json ToJson(const ClientPackets::SlashPushMapResponse& m) { + json j = json::object(); + j["result"] = ToJson(m.result); + j["fileType"] = ToJson(m.fileType); + j["levelFileId"] = ToJson(m.levelFileId); + j["lockedBy"] = ToJson(m.lockedBy); + return j; +} +json ToJson(const ClientPackets::SlashPullMapResponse& m) { + json j = json::object(); + j["fileType"] = ToJson(m.fileType); + j["levelFileId"] = ToJson(m.levelFileId); + j["packedFiles"] = ToJson(m.packedFiles); + return j; +} +json ToJson(const ClientPackets::SlashLockMapResponse& m) { + json j = json::object(); + j["result"] = ToJson(m.result); + j["lockedBy"] = ToJson(m.lockedBy); + j["locked"] = ToJson(m.locked); + return j; +} json ToJson(const CommonPackets::ClientVersionConfirm& m) { json j = json::object(); j["netVersion"] = ToJson(m.netVersion); @@ -1254,6 +1290,39 @@ json ToJson(const WorldPackets::UIHelpTop5& m) { j["language"] = ToJson(m.language); return j; } +json ToJson(const WorldPackets::HappyFlowerModeNotify& m) { + json j = ToJson(static_cast(m)); + j["on"] = ToJson(m.on); + return j; +} +json ToJson(const WorldPackets::SlashReloadMap& m) { + json j = ToJson(static_cast(m)); + return j; +} +json ToJson(const WorldPackets::SlashPushMapRequest& m) { + json j = ToJson(static_cast(m)); + j["fileType"] = ToJson(m.fileType); + j["levelFileId"] = ToJson(m.levelFileId); + return j; +} +json ToJson(const WorldPackets::SlashPushMap& m) { + json j = ToJson(static_cast(m)); + j["fileType"] = ToJson(m.fileType); + j["levelFileId"] = ToJson(m.levelFileId); + j["packedFiles"] = ToJson(m.packedFiles); + return j; +} +json ToJson(const WorldPackets::SlashPullMap& m) { + json j = ToJson(static_cast(m)); + j["fileType"] = ToJson(m.fileType); + j["levelFileId"] = ToJson(m.levelFileId); + return j; +} +json ToJson(const WorldPackets::LockMapRequest& m) { + json j = ToJson(static_cast(m)); + j["lock"] = ToJson(m.lock); + return j; +} json ToJson(const ChatPackets::WorldRoutePacket& m) { json j = json::object(); j["targetID"] = ToJson(m.targetID); @@ -1873,6 +1942,10 @@ const std::vector& Entries() { { ServiceType::CLIENT, static_cast(MessageType::Client::GUILD_REMOVE_PLAYER), "GuildRemovePlayer", false, &ReadWith }, { ServiceType::CLIENT, static_cast(MessageType::Client::GUILD_LOGIN_LOGOUT), "GuildLoginLogout", false, &ReadWith }, { ServiceType::CLIENT, static_cast(MessageType::Client::GUILD_DATA), "GuildData", false, &ReadWith }, + { ServiceType::CLIENT, static_cast(MessageType::Client::IMPENDING_RELOAD_NOTIFY), "ImpendingReloadNotify", false, &ReadWith }, + { ServiceType::CLIENT, static_cast(MessageType::Client::SLASH_PUSH_MAP_RESPONSE), "SlashPushMapResponse", false, &ReadWith }, + { ServiceType::CLIENT, static_cast(MessageType::Client::SLASH_PULL_MAP_RESPONSE), "SlashPullMapResponse", false, &ReadWith }, + { ServiceType::CLIENT, static_cast(MessageType::Client::SLASH_LOCK_MAP_RESPONSE), "SlashLockMapResponse", false, &ReadWith }, { ServiceType::COMMON, static_cast(MessageType::Server::VERSION_CONFIRM), "ClientVersionConfirm", true, &ReadWith }, { ServiceType::COMMON, static_cast(MessageType::Server::VERSION_CONFIRM), "ServerVersionConfirm", false, &ReadWith }, { ServiceType::COMMON, static_cast(MessageType::Server::DISCONNECT_NOTIFY), "DisconnectNotify", false, &ReadWith }, @@ -1920,6 +1993,12 @@ const std::vector& Entries() { { ServiceType::WORLD, static_cast(MessageType::World::REQUEST_UGC_MANIFEST_INFO), "RequestUgcManifestInfo", false, &ReadWith }, { ServiceType::WORLD, static_cast(MessageType::World::UGC_DOWNLOAD_FAILED), "UgcDownloadFailed", false, &ReadWith }, { ServiceType::WORLD, static_cast(MessageType::World::UI_HELP_TOP_5), "UIHelpTop5", false, &ReadWith }, + { ServiceType::WORLD, static_cast(MessageType::World::HAPPY_FLOWER_MODE_NOTIFY), "HappyFlowerModeNotify", false, &ReadWith }, + { ServiceType::WORLD, static_cast(MessageType::World::SLASH_RELOAD_MAP), "SlashReloadMap", false, &ReadWith }, + { ServiceType::WORLD, static_cast(MessageType::World::SLASH_PUSH_MAP_REQUEST), "SlashPushMapRequest", false, &ReadWith }, + { ServiceType::WORLD, static_cast(MessageType::World::SLASH_PUSH_MAP), "SlashPushMap", false, &ReadWith }, + { ServiceType::WORLD, static_cast(MessageType::World::SLASH_PULL_MAP), "SlashPullMap", false, &ReadWith }, + { ServiceType::WORLD, static_cast(MessageType::World::LOCK_MAP_REQUEST), "LockMapRequest", false, &ReadWith }, { ServiceType::CHAT, static_cast(MessageType::Chat::WORLD_ROUTE_PACKET), "WorldRoutePacket", false, &ReadWith }, { ServiceType::MASTER, static_cast(MessageType::Master::CDCLIENT_RELOAD), "CDClientReload", false, &ReadWith }, { ServiceType::MASTER, static_cast(MessageType::Master::PLAYER_POSITIONS), "PlayerPositions", false, &ReadWith }, diff --git a/dNet/WorldPackets.cpp b/dNet/WorldPackets.cpp index ec62b85ea..e600d5e58 100644 --- a/dNet/WorldPackets.cpp +++ b/dNet/WorldPackets.cpp @@ -400,4 +400,58 @@ namespace WorldPackets { VALIDATE_READ(bitStream.Read(language)); return true; } + + void HappyFlowerModeNotify::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(on); + } + + bool HappyFlowerModeNotify::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(bitStream.Read(on)); + return true; + } + + void SlashPushMapRequest::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(fileType); + bitStream.Write(levelFileId); + } + + bool SlashPushMapRequest::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(bitStream.Read(fileType)); + VALIDATE_READ(bitStream.Read(levelFileId)); + return true; + } + + void SlashPushMap::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(fileType); + bitStream.Write(levelFileId); + bitStream.Write(packedFiles.data(), static_cast(packedFiles.size())); + } + + bool SlashPushMap::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(bitStream.Read(fileType)); + VALIDATE_READ(bitStream.Read(levelFileId)); + packedFiles.resize(BITS_TO_BYTES(bitStream.GetNumberOfUnreadBits())); + if (!packedFiles.empty()) VALIDATE_READ(bitStream.Read(packedFiles.data(), static_cast(packedFiles.size()))); + return true; + } + + void SlashPullMap::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(fileType); + bitStream.Write(levelFileId); + } + + bool SlashPullMap::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(bitStream.Read(fileType)); + VALIDATE_READ(bitStream.Read(levelFileId)); + return true; + } + + void LockMapRequest::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(lock); + } + + bool LockMapRequest::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(bitStream.Read(lock)); + return true; + } } diff --git a/dNet/WorldPackets.h b/dNet/WorldPackets.h index 0b70f02fc..411965fd5 100644 --- a/dNet/WorldPackets.h +++ b/dNet/WorldPackets.h @@ -255,6 +255,66 @@ namespace WorldPackets { void Serialize(RakNet::BitStream& bitStream) const override; bool Deserialize(RakNet::BitStream& bitStream) override; }; + + /** + * The client's level editor (docs/ZonePublish.md). fileType: 0 the whole map, 1 a scene, 2 the terrain, 3 the zone + * file, 4 a scene's triggers; levelFileId names the scene for 1 and 4 and means nothing for the others. + */ + + // Client -> server. The client's level editor was started (1) or ended (0). Not answered. + struct HappyFlowerModeNotify : public WorldLUBitStream { + uint8_t on{}; + + HappyFlowerModeNotify() : WorldLUBitStream(MessageType::World::HAPPY_FLOWER_MODE_NOTIFY) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + }; + + // Client -> server. Reload the zone from its files. No payload. + struct SlashReloadMap : public WorldLUBitStream { + SlashReloadMap() : WorldLUBitStream(MessageType::World::SLASH_RELOAD_MAP) {} + }; + + // Client -> server. Asks whether it may send the zone's files; answered with ClientPackets::SlashPushMapResponse. + struct SlashPushMapRequest : public WorldLUBitStream { + uint8_t fileType{}; + int32_t levelFileId{}; + + SlashPushMapRequest() : WorldLUBitStream(MessageType::World::SLASH_PUSH_MAP_REQUEST) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + }; + + // Client -> server. The zone's files (ZoneFilePack), sent after a push request was answered with 0. + struct SlashPushMap : public WorldLUBitStream { + uint8_t fileType{}; + int32_t levelFileId{}; + // Everything after the level file ID, as sent + std::string packedFiles{}; + + SlashPushMap() : WorldLUBitStream(MessageType::World::SLASH_PUSH_MAP) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + }; + + // Client -> server. Asks for the zone's files; answered with ClientPackets::SlashPullMapResponse. + struct SlashPullMap : public WorldLUBitStream { + uint8_t fileType{}; + int32_t levelFileId{}; + + SlashPullMap() : WorldLUBitStream(MessageType::World::SLASH_PULL_MAP) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + }; + + // Client -> server. Take (1) or give up (0) the zone's edit lock; answered with ClientPackets::SlashLockMapResponse. + struct LockMapRequest : public WorldLUBitStream { + uint8_t lock{}; + + LockMapRequest() : WorldLUBitStream(MessageType::World::LOCK_MAP_REQUEST) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + }; } #endif // WORLDPACKETS_H diff --git a/dWorldServer/WorldServer.cpp b/dWorldServer/WorldServer.cpp index b84ce6b29..13921693b 100644 --- a/dWorldServer/WorldServer.cpp +++ b/dWorldServer/WorldServer.cpp @@ -13,6 +13,7 @@ #include "PropertyRent.h" #include "PropertyReputation.h" #include "LiveEvents.h" +#include "ZoneEditor.h" #include "UgcManifest.h" #include #include @@ -582,6 +583,7 @@ int main(int argc, char** argv) { DashboardNotify::SendPlayerPositions(g_InstanceID); MessageInspector::Update(); LiveEvents::Update(); + ZoneEditor::Update(); UgcManifest::Update(); Metrics::StartMeasurement(MetricVariable::UpdateSpawners); @@ -903,7 +905,8 @@ namespace { const auto* lastCharacter = newUser ? newUser->GetLastUsedChar() : nullptr; const auto seamlessCharacter = lastCharacter && WorldMigration::ArrivesSeamlessly(lastCharacter->GetObjectID()) ? lastCharacter->GetObjectID() : LWOOBJID_EMPTY; - if (Game::zoneManager->HasZone() && seamlessCharacter == LWOOBJID_EMPTY) { + // (never to a client whose level editor is on: it drops its connection when told to load a zone) + if (Game::zoneManager->HasZone() && seamlessCharacter == LWOOBJID_EMPTY && ZoneEditor::MaySendZoneLoad(it->second.sysAddr)) { float x = 0.0f; float y = 0.0f; float z = 0.0f; @@ -1333,6 +1336,7 @@ void LoadPlayer(const SystemAddress& sysAddr) { // Save and remove a user whose connection closed (or who is being dropped). Safe to call twice: the second call finds // no user and does nothing. void CleanupDisconnectedUser(const SystemAddress& sysAddr) { + ZoneEditor::Forget(sysAddr); // Players moved to another instance were saved when they were sent; saving again could overwrite what the // other instance has saved since const bool savedByMigration = WorldMigration::IsLeaving(sysAddr); @@ -1883,6 +1887,35 @@ namespace { } }; + // The client's level editor (ZoneEditor.h). Only from a signed-in client; who may do what is ZoneEditor's. + struct HappyFlowerModeNotifyPacket final : public WorldPackets::HappyFlowerModeNotify { + void Handle() override { + if (!UserManager::Instance()->GetUser(sysAddr)) return; + LOG("Client %s %s its level editor", sysAddr.ToString(), on != 0 ? "started" : "ended"); + ZoneEditor::SetEditing(sysAddr, on != 0); + } + }; + + struct SlashReloadMapPacket final : public WorldPackets::SlashReloadMap { + void Handle() override { ZoneEditor::HandleReload(sysAddr); } + }; + + struct SlashPushMapRequestPacket final : public WorldPackets::SlashPushMapRequest { + void Handle() override { ZoneEditor::HandlePushRequest(sysAddr, fileType, levelFileId); } + }; + + struct SlashPushMapPacket final : public WorldPackets::SlashPushMap { + void Handle() override { ZoneEditor::HandlePush(sysAddr, fileType, levelFileId, std::move(packedFiles)); } + }; + + struct SlashPullMapPacket final : public WorldPackets::SlashPullMap { + void Handle() override { ZoneEditor::HandlePull(sysAddr, fileType, levelFileId); } + }; + + struct LockMapRequestPacket final : public WorldPackets::LockMapRequest { + void Handle() override { ZoneEditor::HandleLock(sysAddr, lock != 0); } + }; + template std::unique_ptr Create() { return std::make_unique(); } @@ -1894,6 +1927,13 @@ namespace { { MessageType::World::LOGIN_REQUEST, Create }, { MessageType::World::CHARACTER_DELETE_REQUEST, Create }, { MessageType::World::CHARACTER_RENAME_REQUEST, Create }, + { MessageType::World::HAPPY_FLOWER_MODE_NOTIFY, Create }, + { MessageType::World::SLASH_RELOAD_MAP, Create }, + { MessageType::World::SLASH_PUSH_MAP_REQUEST, Create }, + { MessageType::World::SLASH_PUSH_MAP, Create }, + { MessageType::World::SLASH_PULL_MAP, Create }, + { MessageType::World::LOCK_MAP_REQUEST, Create }, + // (also what a client sends when its level editor ends: its player is here, so the world is sent to it again) { MessageType::World::LEVEL_LOAD_COMPLETE, Create }, { MessageType::World::POSITION_UPDATE, Create }, { MessageType::World::MAIL, Create }, @@ -2003,6 +2043,7 @@ void WorldShutdownSequence() { } void FinalizeShutdown() { + ZoneEditor::Shutdown(); LOG("Shutdown complete, zone (%i), instance (%i)", Game::server->GetZoneID(), g_InstanceID); //Delete our objects here: diff --git a/tests/dGameTests/dNetTests/CMakeLists.txt b/tests/dGameTests/dNetTests/CMakeLists.txt index fb204ca5e..4b97478f6 100644 --- a/tests/dGameTests/dNetTests/CMakeLists.txt +++ b/tests/dGameTests/dNetTests/CMakeLists.txt @@ -7,7 +7,8 @@ SET(DNET_TESTS "PacketCaptureTests.cpp" "PacketDecoderCoverageTests.cpp" "ServerTrafficTests.cpp" - "WorldPacketsTests.cpp") + "WorldPacketsTests.cpp" + "ZoneEditorPacketsTests.cpp") # Get the folder name and prepend it to the files above get_filename_component(thisFolderName ${CMAKE_CURRENT_SOURCE_DIR} NAME) diff --git a/tests/dGameTests/dNetTests/PacketDecoderCoverageTests.cpp b/tests/dGameTests/dNetTests/PacketDecoderCoverageTests.cpp index 2fbd8dcb9..908cfb12a 100644 --- a/tests/dGameTests/dNetTests/PacketDecoderCoverageTests.cpp +++ b/tests/dGameTests/dNetTests/PacketDecoderCoverageTests.cpp @@ -52,15 +52,15 @@ namespace { "CHAT GET_DONATION_TOTAL", "CHAT UPDATE_DONATION", "CHAT PRG_CSR_COMMAND", "CHAT HEARTBEAT_REQUEST_FROM_WORLD", "CHAT UPDATE_FREE_TRIAL_STATUS", "CLIENT LOGOUT_RESPONSE", "CLIENT CREATE_OBJECT", "CLIENT CREATE_CHARACTER_EXTENDED", "CLIENT CHAT_CONNECT_RESPONSE", - "CLIENT AUTH_ACCOUNT_CREATE_RESPONSE", "CLIENT CONNECT_CHAT", "CLIENT IMPENDING_RELOAD_NOTIFY", "CLIENT SLASH_PUSH_MAP_RESPONSE", - "CLIENT SLASH_PULL_MAP_RESPONSE", "CLIENT SLASH_LOCK_MAP_RESPONSE", "CLIENT BLUEPRINT_LUP_SAVE_RESPONSE", + "CLIENT AUTH_ACCOUNT_CREATE_RESPONSE", "CLIENT CONNECT_CHAT", + "CLIENT BLUEPRINT_LUP_SAVE_RESPONSE", "CLIENT BLUEPRINT_GET_ALL_DATA_RESPONSE", "CLIENT MODEL_INSTANTIATE_RESPONSE", "CLIENT GUILD_GET_STATUS_RESPONSE", "CLIENT GUILD_RANK_CHANGE", "CLIENT GUILD_STATUS", "CLIENT DB_PROXY_RESULT", "CLIENT UPDATE_CHARACTER_NAME", "CLIENT SET_NETWORK_SIMULATOR", "CLIENT INVALID_CHAT_MESSAGE", "CLIENT IN_LOGIN_QUEUE", "CLIENT GM_CLOSE_TARGET_CHAT_WINDOW", "CLIENT GENERAL_TEXT_FOR_LOCALIZATION", "CLIENT UPDATE_FREE_TRIAL_STATUS", "MASTER SHUTDOWN_IMMEDIATE", "AUTH RUNTIME_CONFIG", "CLIENT UGC_DOWNLOAD_FAILED", - "WORLD HAPPY_FLOWER_MODE_NOTIFY", "WORLD SLASH_RELOAD_MAP", "WORLD SLASH_PUSH_MAP_REQUEST", "WORLD SLASH_PUSH_MAP", - "WORLD SLASH_PULL_MAP", "WORLD LOCK_MAP_REQUEST", "WORLD HTTP_MONITOR_INFO_REQUEST", "WORLD SLASH_DEBUG_SCRIPTS", + + "WORLD HTTP_MONITOR_INFO_REQUEST", "WORLD SLASH_DEBUG_SCRIPTS", "WORLD MODELS_CLEAR", "WORLD EXHIBIT_INSERT_MODEL", "WORLD WORD_CHECK", "WORLD GET_PLAYERS_IN_ZONE", "WORLD BLUEPRINT_GET_ALL_DATA_REQUEST", "WORLD CANCEL_MAP_QUEUE", "WORLD FAKE_PRG_CSR_MESSAGE", "WORLD REQUEST_FREE_TRIAL_REFRESH", "WORLD GM_SET_FREE_TRIAL_STATUS", diff --git a/tests/dGameTests/dNetTests/ZoneEditorPacketsTests.cpp b/tests/dGameTests/dNetTests/ZoneEditorPacketsTests.cpp new file mode 100644 index 000000000..abb9ee00a --- /dev/null +++ b/tests/dGameTests/dNetTests/ZoneEditorPacketsTests.cpp @@ -0,0 +1,208 @@ +#include "ClientPackets.h" +#include "GameDependencies.h" +#include "PacketTestUtils.h" +#include "WorldPackets.h" +#include "ZoneEditLock.h" +#include "ZoneEditor.h" + +#include + +using namespace PacketTestUtils; + +namespace { + // Written, read back and written again: the same bytes, with nothing left unread + template + T RoundTrip(const T& packet) { + RakNet::BitStream first; + packet.WritePacket(first); + T copy; + EXPECT_TRUE(copy.ReadHeader(first)); + EXPECT_TRUE(copy.Deserialize(first)); + EXPECT_EQ(first.GetNumberOfUnreadBits(), 0); + RakNet::BitStream second; + copy.WritePacket(second); + EXPECT_PACKET_EQ(FromBitStream(first), FromBitStream(second)); + return copy; + } + + // Cut anywhere after its header, the packet does not read + template + void ExpectTruncatedFails(const T& packet) { + RakNet::BitStream full; + packet.WritePacket(full); + const auto bytes = full.GetNumberOfBytesUsed(); + for (uint32_t cut = 8; cut < bytes; cut++) { + RakNet::BitStream truncated(full.GetData(), cut, true); + T copy; + ASSERT_TRUE(copy.ReadHeader(truncated)); + EXPECT_FALSE(copy.Deserialize(truncated)) << "cut at " << cut << " of " << bytes; + } + } +} + +// The level editor's packets, byte for byte: what a client sends... +TEST(ZoneEditorPacketsTests, ClientToServerLayouts) { + WorldPackets::HappyFlowerModeNotify mode; + mode.on = 1; + RakNet::BitStream modeBytes; + mode.WritePacket(modeBytes); + EXPECT_PACKET_EQ(FromHex("53 04 00 08 00 00 00 00 01"), FromBitStream(modeBytes)); + EXPECT_EQ(RoundTrip(mode).on, 1); + ExpectTruncatedFails(mode); + + WorldPackets::SlashReloadMap reload; + RakNet::BitStream reloadBytes; + reload.WritePacket(reloadBytes); + EXPECT_PACKET_EQ(FromHex("53 04 00 09 00 00 00 00"), FromBitStream(reloadBytes)); + + WorldPackets::SlashPushMapRequest request; + request.fileType = 1; + request.levelFileId = 0x01020304; + RakNet::BitStream requestBytes; + request.WritePacket(requestBytes); + EXPECT_PACKET_EQ(FromHex("53 04 00 0a 00 00 00 00 01 04 03 02 01"), FromBitStream(requestBytes)); + EXPECT_EQ(RoundTrip(request).levelFileId, 0x01020304); + ExpectTruncatedFails(request); + + // The file type, the level file ID, then the packed files to the end of the packet + WorldPackets::SlashPushMap push; + push.fileType = 3; + push.levelFileId = -1; + push.packedFiles = std::string("\x0c\x00\x00\x00\x05\x00\x00\x00" "a\0.\0l\0u\0z\0\0\0" "sd0\x01\xff", 25); + RakNet::BitStream pushBytes; + push.WritePacket(pushBytes); + EXPECT_PACKET_EQ(FromHex("53 04 00 0b 00 00 00 00 03 ff ff ff ff 0c 00 00 00 05 00 00 00 61 00 2e 00 6c 00 75 00 7a 00 00 00 73 64 30 01 ff"), FromBitStream(pushBytes)); + const auto pushRead = RoundTrip(push); + EXPECT_EQ(pushRead.fileType, 3); + EXPECT_EQ(pushRead.levelFileId, -1); + EXPECT_EQ(pushRead.packedFiles, push.packedFiles); + // Nothing after the ID reads as no files, which the unpacking then refuses + push.packedFiles.clear(); + EXPECT_TRUE(RoundTrip(push).packedFiles.empty()); + + WorldPackets::SlashPullMap pull; + pull.fileType = 4; + pull.levelFileId = 7; + RakNet::BitStream pullBytes; + pull.WritePacket(pullBytes); + EXPECT_PACKET_EQ(FromHex("53 04 00 0c 00 00 00 00 04 07 00 00 00"), FromBitStream(pullBytes)); + EXPECT_EQ(RoundTrip(pull).levelFileId, 7); + ExpectTruncatedFails(pull); + + WorldPackets::LockMapRequest lock; + lock.lock = 1; + RakNet::BitStream lockBytes; + lock.WritePacket(lockBytes); + EXPECT_PACKET_EQ(FromHex("53 04 00 0d 00 00 00 00 01"), FromBitStream(lockBytes)); + EXPECT_EQ(RoundTrip(lock).lock, 1); + ExpectTruncatedFails(lock); +} + +// ...and what the server answers +TEST(ZoneEditorPacketsTests, ServerToClientLayouts) { + ClientPackets::ImpendingReloadNotify notify; + RakNet::BitStream notifyBytes; + notify.WritePacket(notifyBytes); + EXPECT_PACKET_EQ(FromHex("53 05 00 0f 00 00 00 00"), FromBitStream(notifyBytes)); + + // The result, the file type and level file ID as asked, the holder's name as UTF-16 with its null + ClientPackets::SlashPushMapResponse push; + push.result = 3; + push.fileType = 1; + push.levelFileId = 5; + push.lockedBy = u"bob"; + RakNet::BitStream pushBytes; + push.WritePacket(pushBytes); + EXPECT_PACKET_EQ(FromHex("53 05 00 12 00 00 00 00 03 01 05 00 00 00 62 00 6f 00 62 00 00 00"), FromBitStream(pushBytes)); + EXPECT_EQ(RoundTrip(push).lockedBy, u"bob"); + // Allowed: no name, only its null + ClientPackets::SlashPushMapResponse allowed; + RakNet::BitStream allowedBytes; + allowed.WritePacket(allowedBytes); + EXPECT_PACKET_EQ(FromHex("53 05 00 12 00 00 00 00 00 00 00 00 00 00 00 00"), FromBitStream(allowedBytes)); + + ClientPackets::SlashPullMapResponse pull; + pull.fileType = 2; + pull.levelFileId = 0; + pull.packedFiles = std::string("\x04\x00\x00\x00\x05\x00\x00\x00" "r\0\0\0" "sd0\x01\xff", 17); + RakNet::BitStream pullBytes; + pull.WritePacket(pullBytes); + EXPECT_PACKET_EQ(FromHex("53 05 00 13 00 00 00 00 02 00 00 00 00 04 00 00 00 05 00 00 00 72 00 00 00 73 64 30 01 ff"), FromBitStream(pullBytes)); + EXPECT_EQ(RoundTrip(pull).packedFiles, pull.packedFiles); + + // The result, 33 UTF-16 characters of holder name (66 bytes), whether the client holds the lock now + ClientPackets::SlashLockMapResponse lock; + lock.result = 3; + lock.lockedBy.string = u"al"; + lock.locked = 0; + RakNet::BitStream lockBytes; + lock.WritePacket(lockBytes); + std::string expected = "53 05 00 14 00 00 00 00 03 61 00 6c 00"; + for (int i = 0; i < 31; ++i) expected += " 00 00"; + expected += " 00"; + EXPECT_PACKET_EQ(FromHex(expected), FromBitStream(lockBytes)); + EXPECT_EQ(lockBytes.GetNumberOfBytesUsed(), 8u + 1u + 66u + 1u); + const auto lockRead = RoundTrip(lock); + EXPECT_EQ(lockRead.result, 3); + EXPECT_EQ(lockRead.lockedBy.string.substr(0, 2), u"al"); + ExpectTruncatedFails(lock); +} + +// While a client's level editor is on it is never told to load a zone +TEST(ZoneEditorTests, NoZoneLoadWhileTheEditorIsOn) { + SystemAddress client{}; + client.binaryAddress = 0x0100007f; + client.port = 2001; + SystemAddress other = client; + other.port = 2002; + + EXPECT_TRUE(ZoneEditor::MaySendZoneLoad(client)); + ZoneEditor::SetEditing(client, true); + EXPECT_TRUE(ZoneEditor::IsEditing(client)); + EXPECT_FALSE(ZoneEditor::MaySendZoneLoad(client)); + EXPECT_TRUE(ZoneEditor::MaySendZoneLoad(other)); // only that client + // Told twice, still on; ended once, off + ZoneEditor::SetEditing(client, true); + ZoneEditor::SetEditing(client, false); + EXPECT_TRUE(ZoneEditor::MaySendZoneLoad(client)); + // A connection that goes away takes its editor state with it + ZoneEditor::SetEditing(client, true); + ZoneEditor::Forget(client); + EXPECT_FALSE(ZoneEditor::IsEditing(client)); + EXPECT_TRUE(ZoneEditor::MaySendZoneLoad(client)); +} + +// What the editor's requests are answered with: 0 ok, 1 editor level too low, 2 not assigned, 3 locked by, 4 no change +TEST(ZoneEditorTests, GatesAndResults) { + using namespace ZoneEditLock; + const Levels levels{ 9, 9 }; + const Actor admin{ 1, 9, false }, low{ 2, 8, true }, off{ 3, 0, true }; + const Levels open{ 6, 9 }; + const Actor editor{ 4, 6, true }, stranger{ 5, 6, false }; + + // Locking (answered with the lock response) + EXPECT_EQ(static_cast(Take(admin, std::nullopt, levels)), 0); + EXPECT_EQ(static_cast(Take(low, std::nullopt, levels)), 1); + EXPECT_EQ(static_cast(Take(off, std::nullopt, levels)), 1); // the editor is off for the account + EXPECT_EQ(static_cast(Take(stranger, std::nullopt, open)), 2); + EXPECT_EQ(static_cast(Take(editor, 1u, open)), 3); + EXPECT_EQ(static_cast(Take(editor, 4u, open)), 4); + EXPECT_EQ(static_cast(Release(editor, std::nullopt, open)), 4); + EXPECT_EQ(static_cast(Release(editor, 4u, open)), 0); + EXPECT_EQ(static_cast(Release(stranger, 4u, open)), 3); + + // Sending files (answered with the push response, which has no 4): only the lock's holder + EXPECT_EQ(static_cast(ChangeOrWhyNot(editor, 4u, open)), 0); + EXPECT_EQ(static_cast(ChangeOrWhyNot(low, 2u, levels)), 1); + EXPECT_EQ(static_cast(ChangeOrWhyNot(stranger, std::nullopt, open)), 2); + EXPECT_EQ(static_cast(ChangeOrWhyNot(editor, 1u, open)), 3); + EXPECT_EQ(static_cast(ChangeOrWhyNot(admin, 4u, open)), 3); // an admin has to hold it too + EXPECT_EQ(static_cast(ChangeOrWhyNot(editor, std::nullopt, open)), 3); // could lock it and hasn't + EXPECT_EQ(static_cast(ChangeOrWhyNot(stranger, 4u, open)), 3); + + // Asking for the files and for a reload: an editor of the zone or an admin, lock or not + EXPECT_EQ(static_cast(Read(editor, open)), 0); + EXPECT_EQ(static_cast(Read(admin, open)), 0); + EXPECT_EQ(static_cast(Read(stranger, open)), 2); + EXPECT_EQ(static_cast(Read(off, open)), 1); +}