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); +}