diff --git a/dGame/dComponents/ScriptComponent.cpp b/dGame/dComponents/ScriptComponent.cpp index dd0b1a604..a5acaed1d 100644 --- a/dGame/dComponents/ScriptComponent.cpp +++ b/dGame/dComponents/ScriptComponent.cpp @@ -98,6 +98,11 @@ CppScripts::Script* const ScriptComponent::GetScript() { void ScriptComponent::SetScript(const std::string& scriptName) { // Scripts are managed by the CppScripts class and are effecitvely singletons // and they may also be used by other script components so DON'T delete them. + // Edit mode (docs/EditMode.md): the zone control object stays, its zone script does not run + if (Game::zoneManager && Game::zoneManager->GetEditMode() && m_Parent->GetObjectID() == dZoneManager::ZONE_CONTROL_OBJECT_ID) { + m_Script = CppScripts::GetInvalidScript(); + return; + } m_Script = CppScripts::GetScript(m_Parent, scriptName); } diff --git a/dGame/dGameMessages/ZoneMessages.cpp b/dGame/dGameMessages/ZoneMessages.cpp index 310e727aa..25f531bd1 100644 --- a/dGame/dGameMessages/ZoneMessages.cpp +++ b/dGame/dGameMessages/ZoneMessages.cpp @@ -118,7 +118,8 @@ namespace GameMessages { std::vector racingControllers = Game::entityManager->GetEntitiesByComponent(eReplicaComponentType::RACING_CONTROL); for (Entity* racingController : racingControllers) { auto* racingComponent = racingController->GetComponent(); - if (racingComponent != nullptr) { + // Edit mode: a race track's zone control doesn't take the GM into a race (or send them out for having no car) + if (racingComponent != nullptr && !Game::zoneManager->GetEditMode()) { racingComponent->OnPlayerLoaded(entity); } } diff --git a/dGame/dUtilities/CMakeLists.txt b/dGame/dUtilities/CMakeLists.txt index ef6ed86e6..72ba808cb 100644 --- a/dGame/dUtilities/CMakeLists.txt +++ b/dGame/dUtilities/CMakeLists.txt @@ -5,6 +5,7 @@ set(DGAME_DUTILITIES_SOURCES "BrickDatabase.cpp" "CheatDetection.cpp" "DashboardActions.cpp" "EconomyLedger.cpp" + "EditMode.cpp" "DashboardNotify.cpp" "GUID.cpp" "LiveEvents.cpp" diff --git a/dGame/dUtilities/EditMode.cpp b/dGame/dUtilities/EditMode.cpp new file mode 100644 index 000000000..b1f986c3e --- /dev/null +++ b/dGame/dUtilities/EditMode.cpp @@ -0,0 +1,13 @@ +#include "EditMode.h" + +#include + +#include "CharacterComponent.h" +#include "Game.h" +#include "GeneralUtils.h" +#include "dConfig.h" + +eGameMasterLevel EditMode::RequiredLevel() { + const auto level = GeneralUtils::TryParse(Game::config->GetValue("editor_gm_level")).value_or(CharacterComponent::DEFAULT_EDITOR_GM_LEVEL); + return static_cast(std::clamp(level, 1, 9)); +} diff --git a/dGame/dUtilities/EditMode.h b/dGame/dUtilities/EditMode.h new file mode 100644 index 000000000..a10886ada --- /dev/null +++ b/dGame/dUtilities/EditMode.h @@ -0,0 +1,18 @@ +#pragma once + +#include "eGameMasterLevel.h" + +/** + * Edit mode (docs/EditMode.md): a private instance of a zone whose world server constructs none of the zone's level + * objects, spawners or triggers, for editing the zone in the game client. Only the plumbing is here; nothing starts + * such an instance yet. + */ +namespace EditMode { + // Whether an account may be in an edit mode instance + inline bool MayEnter(const eGameMasterLevel accountLevel, const eGameMasterLevel requiredLevel) { + return accountLevel != eGameMasterLevel::CIVILIAN && accountLevel >= requiredLevel; + } + + // The account GM level an edit mode instance lets in: editor_gm_level (9 unless set) + eGameMasterLevel RequiredLevel(); +} diff --git a/dWorldServer/WorldServer.cpp b/dWorldServer/WorldServer.cpp index 89dd0c32e..7efe90371 100644 --- a/dWorldServer/WorldServer.cpp +++ b/dWorldServer/WorldServer.cpp @@ -86,6 +86,7 @@ #include "DestroyableComponent.h" #include "Game.h" #include "PropertyManagementComponent.h" +#include "EditMode.h" #include "AssetManager.h" #include "LevelProgressionComponent.h" #include "eBlueprintSaveResponseType.h" @@ -243,6 +244,7 @@ int main(int argc, char** argv) { uint32_t cloneID = 0; uint32_t maxClients = 8; uint32_t ourPort = 2007; + bool editMode = false; //Check our arguments: for (int32_t i = 0; (i + 1) < argc; i++) { @@ -257,6 +259,7 @@ int main(int argc, char** argv) { else if (argument == "-clone") cloneID = valOptional.value_or(0); else if (argument == "-maxclients") maxClients = valOptional.value_or(8); else if (argument == "-port") ourPort = valOptional.value_or(2007); + else if (argument == "-editmode") editMode = valOptional.value_or(0) != 0; } Game::config = new dConfig("worldconfig.ini"); @@ -423,6 +426,8 @@ int main(int argc, char** argv) { Profiler::Scope navmesh("Navmesh and physics load", Profiler::Phase::PHYSICS); dpWorld::Initialize(zoneID); } + // A GM's edit mode instance (docs/EditMode.md): master says so when it starts the world + Game::zoneManager->SetEditMode(editMode); Game::zoneManager->Initialize(LWOZONEID(zoneID, g_InstanceID, cloneID)); PacketCapture::SetClone(cloneID); g_CloneID = cloneID; @@ -578,7 +583,8 @@ int main(int argc, char** argv) { DashboardNotify::Flush(); DashboardNotify::SendPlayerPositions(g_InstanceID); MessageInspector::Update(); - LiveEvents::Update(); + // (an edit mode instance has no server objects, so no live events either) + if (!Game::zoneManager->GetEditMode()) LiveEvents::Update(); UgcManifest::Update(); Metrics::StartMeasurement(MetricVariable::UpdateSpawners); @@ -889,6 +895,17 @@ namespace { } else { LOG("User %s authenticated with correct key.", username.GetAsString().c_str()); + // An edit mode instance takes only accounts that may use /editmode, however they found its address + if (Game::zoneManager->GetEditMode()) { + const auto account = Database::Get()->GetAccountInfo(username.GetAsString()); + if (!EditMode::MayEnter(account ? account->maxGmLevel : eGameMasterLevel::CIVILIAN, EditMode::RequiredLevel())) { + LOG("User %s may not enter an edit mode instance; disconnecting them", username.GetAsString().c_str()); + Game::server->Disconnect(it->second.sysAddr, eServerDisconnectIdentifiers::KICK); + g_PendingUsers.erase(it); + return; + } + } + UserManager::Instance()->DeleteUser(masterAddr); //Create our user and send them in: diff --git a/dZoneManager/Level.cpp b/dZoneManager/Level.cpp index 786b8e6a8..16140fa73 100644 --- a/dZoneManager/Level.cpp +++ b/dZoneManager/Level.cpp @@ -47,7 +47,7 @@ Level::Level(Zone* parentZone, const std::string& filepath, int32_t sceneID) { static_cast(header) = chunkHeader; m_ChunkHeaders.insert(std::make_pair(id, header)); } - LoadSceneObjects(levelFile.objects); + LoadSceneObjects(levelFile.objects, m_SceneID); } void Level::MakeSpawner(const SceneObject& obj, int32_t sceneID) { @@ -120,7 +120,18 @@ void Level::MakeSpawner(const SceneObject& obj, int32_t sceneID) { Game::zoneManager->MakeSpawner(spawnInfo); } -void Level::LoadSceneObjects(const std::vector& objects) { +void Level::LoadSceneObjects(const std::vector& objects, const int32_t sceneID) { + // Edit mode: the GM's client has the level's objects from its own files, so none is made here. The oldest files + // keep the spawn point in an object, which is still read. + if (Game::zoneManager->GetEditMode()) { + for (const auto& obj : objects) { + if (obj.lot != LOT_MARKER_PLAYER_START) continue; + Game::zoneManager->GetZoneMut()->SetSpawnPos(obj.position); + Game::zoneManager->GetZoneMut()->SetSpawnRot(obj.rotation); + } + return; + } + CDFeatureGatingTable* featureGatingTable = CDClientManager::GetTable(); CDFeatureGating gating; @@ -181,7 +192,7 @@ void Level::LoadSceneObjects(const std::vector& objects) { } if (obj.lot == 176) { //Spawner - MakeSpawner(obj, m_SceneID); + MakeSpawner(obj, sceneID); } else { //Regular object EntityInfo info; info.spawnerID = 0; @@ -191,7 +202,7 @@ void Level::LoadSceneObjects(const std::vector& objects) { info.rot = obj.rotation; info.settings = obj.settings; info.scale = obj.scale; - info.scene = m_SceneID; + info.scene = sceneID; Game::entityManager->CreateEntity(info); } } diff --git a/dZoneManager/Level.h b/dZoneManager/Level.h index 1814aed47..82173288a 100644 --- a/dZoneManager/Level.h +++ b/dZoneManager/Level.h @@ -20,11 +20,12 @@ public: static void MakeSpawner(const SceneObject& obj, int32_t sceneID = -1); + // Makes the objects and spawners of a level file. In edit mode (dZoneManager::GetEditMode) none: only the player + // start marker's place is taken + static void LoadSceneObjects(const std::vector& objects, int32_t sceneID = -1); + std::map m_ChunkHeaders; private: Zone* m_ParentZone; int32_t m_SceneID = -1; - - //private functions: - void LoadSceneObjects(const std::vector& objects); }; diff --git a/dZoneManager/Zone.cpp b/dZoneManager/Zone.cpp index 34933503b..6420f02da 100644 --- a/dZoneManager/Zone.cpp +++ b/dZoneManager/Zone.cpp @@ -138,7 +138,8 @@ void Zone::LoadZoneIntoMemory() { } } - if (m_FileFormatVersion >= Zone::FileFormatVersion::EarlyAlpha) { + // Edit mode: no spawner networks (the paths themselves are kept, they are only data) + if (m_FileFormatVersion >= Zone::FileFormatVersion::EarlyAlpha && !Game::zoneManager->GetEditMode()) { for (const Path& path : m_Paths) { if (path.pathType != PathType::Spawner) continue; SpawnerInfo info{}; @@ -286,7 +287,8 @@ void Zone::LoadScene(ZoneScene&& zoneScene) { LWOSCENEID lwoSceneID(scene.id, scene.sceneType); std::string luTriggersPath = scene.filename.substr(0, scene.filename.size() - 4) + ".lutriggers"; - if (Game::assetManager->HasFile((m_ZonePath + luTriggersPath).c_str())) LoadLUTriggers(luTriggersPath, scene); + // Edit mode: no triggers + if (!Game::zoneManager->GetEditMode() && Game::assetManager->HasFile((m_ZonePath + luTriggersPath).c_str())) LoadLUTriggers(luTriggersPath, scene); m_Scenes[lwoSceneID] = std::move(scene); } diff --git a/dZoneManager/dZoneManager.cpp b/dZoneManager/dZoneManager.cpp index 472737b3b..1765f3a09 100644 --- a/dZoneManager/dZoneManager.cpp +++ b/dZoneManager/dZoneManager.cpp @@ -57,8 +57,7 @@ void dZoneManager::Initialize(const LWOZONEID& zoneID) { EntityInfo info; info.lot = zoneControlTemplate; - /* Yep its hardcoded like this in the client too, this exact value. */ - info.id = 0x3FFF'FFFFFFFELL; + info.id = ZONE_CONTROL_OBJECT_ID; Entity* zoneControl = Game::entityManager->CreateEntity(info, nullptr, nullptr, true); m_ZoneControlObject = zoneControl; @@ -73,7 +72,9 @@ void dZoneManager::Initialize(const LWOZONEID& zoneID) { LOG("Zone prepared in: %llu ms", (endTime - startTime)); - VanityUtilities::SpawnVanity(); + // The vanity objects are server objects the client's editor would not know + if (!m_EditMode) VanityUtilities::SpawnVanity(); + else LOG("Zone %i is in edit mode: no level objects, spawners, triggers or vanity objects were loaded", zoneID.GetMapID()); } dZoneManager::~dZoneManager() { diff --git a/dZoneManager/dZoneManager.h b/dZoneManager/dZoneManager.h index 1a22bca8b..f9b29a5b6 100644 --- a/dZoneManager/dZoneManager.h +++ b/dZoneManager/dZoneManager.h @@ -23,6 +23,9 @@ private: void LoadWorldConfig(); public: + // The zone control object's ID, the same fixed value in the client (LevelManager::LoadZoneControlObject, 0x01082660) + static constexpr LWOOBJID ZONE_CONTROL_OBJECT_ID = 0x3FFF'FFFFFFFELL; + void Initialize(const LWOZONEID& zoneID); ~dZoneManager(); @@ -30,6 +33,13 @@ public: Zone* GetZoneMut() const; const Zone* GetZone() const { return GetZoneMut(); }; bool HasZone() const { return m_pZone != nullptr; }; + + /** + * A GM's edit mode (docs/EditMode.md): the zone's level objects, spawners, spawner networks and triggers are not + * loaded, so the GM's client is the only one that has them. Set before Initialize. + */ + bool GetEditMode() const { return m_EditMode; } + void SetEditMode(bool value) { m_EditMode = value; } void LoadZone(const LWOZONEID& zoneID); //Discard the current zone (if any) and loads a new zone. /* Adds a spawner to the zone with the specified ID. */ @@ -103,6 +113,7 @@ private: bool m_PlayerLoseCoinsOnDeath = false; bool m_DisableSaveLocation = false; bool m_MountsAllowed = true; + bool m_EditMode = false; bool m_PetsAllowed = true; std::map m_Spawners; std::optional m_WorldConfig = std::nullopt; diff --git a/docs/EditMode.md b/docs/EditMode.md new file mode 100644 index 000000000..538588bdd --- /dev/null +++ b/docs/EditMode.md @@ -0,0 +1,54 @@ +# Edit mode (plumbing) + +A private instance of a zone for editing the zone in the game client. The client loads the zone's objects from its own +files; the server never reads the client's files, so in a normal instance its own copies of the same objects are there +too. In an edit mode instance the server constructs none of them. + +**Not exposed yet:** no command or packet from a client starts an edit mode instance. This is what exists for one to use. + +## The flag + +| Where | What | +|---|---| +| World -> master | `MasterPackets::CreatePrivateZone::editMode`, one more byte written only when set (`ZoneInstanceManager::CreatePrivateZone(..., editMode)`) | +| Master | `Instance::GetIsEditMode`; `InstanceManager::CreatePrivateInstance(..., editMode)` | +| Master -> world | The launch argument `-editmode 1` (`StartWorldServer`) | +| World | `dZoneManager::GetEditMode`, set before the zone loads | + +A reload (`/reloadworld`, a zone file change) or a live update replaces a private instance with one that has the same +password and edit mode. A request to join a private instance that is still starting is answered once it is ready. + +## What an edit mode instance loads + +| Loaded | Not loaded | +|---|---| +| The zone file: spawn point, scenes, scene transitions, boundaries, paths (as data) | Level objects (`.lvl`), carver-only blockers | +| The terrain's scene map, the navmesh and the physics world | Spawners (LOT 176) and spawner networks (spawner paths) | +| The level files' revisions (the map checksum sent to the client) | Triggers (`.lutriggers`) | +| The zone control object, with the zone's own template | The zone control object's server script | +| The player, their items, pets and mounts; GM commands | Vanity objects, live event objects | + +The zone control object stays: the client makes its own from the `ZoneTable` template under the same fixed ID +(`LevelManager::LoadZoneControlObject`, 0x01082660), the server's construction must match it, and server code expects +the object. Its script is replaced by one that does nothing. Its other components (an activity, a race) stay but are +idle: a race track's zone control does not take the player into a race. + +The client waits on nothing a level object sends: it reports the level loaded when its own resource queue is empty and +finishes the login on its character and the server's "done loading" (`LWONetworkInterface::Run`, 0x00b32360). + +## The map checksum + +The client compares the checksum the server sends in `LOAD_STATIC_ZONE` with the one of its own files +(`LWONetworkInterface::iSendMessage`, 0x00b35870), and logs out with "invalid map version" when they differ. The +checksum is made of the zone and scene files' revisions. Edit mode does not change that: the server's copy of the zone +must have the revisions the client's has. + +## Who gets in + +The world server of an edit mode instance disconnects accounts below `editor_gm_level` (worldconfig.ini, default 9). +Master never sends anyone to a private instance from a normal transfer or matchmaking. + +## The save's `edit` attribute + +A character's save has `edit="1"` when its account's GM level is at least `editor_gm_level`, `0` for everyone else. +The client takes anything but 0 as the editor being enabled. diff --git a/tests/dGameTests/CMakeLists.txt b/tests/dGameTests/CMakeLists.txt index a2e925f81..08107c4c4 100644 --- a/tests/dGameTests/CMakeLists.txt +++ b/tests/dGameTests/CMakeLists.txt @@ -7,6 +7,7 @@ set(DGAMETEST_SOURCES "MailPacketTests.cpp" "MatchmakingTests.cpp" "EconomyLedgerTests.cpp" + "EditModeTests.cpp" "PlayerReportsLimitTests.cpp" "SlashCommandPermissionTests.cpp" "StaleSaveGuardTests.cpp" diff --git a/tests/dGameTests/EditModeTests.cpp b/tests/dGameTests/EditModeTests.cpp new file mode 100644 index 000000000..bad510cf6 --- /dev/null +++ b/tests/dGameTests/EditModeTests.cpp @@ -0,0 +1,69 @@ +// Edit mode (docs/EditMode.md): who may enter, and a level file's objects not being made. +#include "GameDependencies.h" + +#include "EditMode.h" +#include "EntityManager.h" +#include "Level.h" +#include "LevelFile.h" +#include "Zone.h" +#include "GeneralUtils.h" +#include "dConfig.h" +#include "eObjectBits.h" +#include "dZoneManager.h" + +#include + +class EditModeTests : public GameDependenciesTest { +protected: + void SetUp() override { SetUpDependencies(); } + void TearDown() override { TearDownDependencies(); } +}; + +TEST_F(EditModeTests, OnlyEditorAccountsMayEnter) { + EXPECT_TRUE(EditMode::MayEnter(eGameMasterLevel::OPERATOR, eGameMasterLevel::OPERATOR)); + EXPECT_FALSE(EditMode::MayEnter(eGameMasterLevel::DEVELOPER, eGameMasterLevel::OPERATOR)); + EXPECT_FALSE(EditMode::MayEnter(eGameMasterLevel::CIVILIAN, eGameMasterLevel::OPERATOR)); + EXPECT_FALSE(EditMode::MayEnter(eGameMasterLevel::CIVILIAN, eGameMasterLevel::CIVILIAN)); + EXPECT_TRUE(EditMode::MayEnter(eGameMasterLevel::DEVELOPER, eGameMasterLevel::JUNIOR_DEVELOPER)); + + // editor_gm_level, 9 unless set + EXPECT_EQ(EditMode::RequiredLevel(), eGameMasterLevel::OPERATOR); + Game::config->SetDatabaseValues({ { "editor_gm_level", "8" } }, {}); + EXPECT_EQ(EditMode::RequiredLevel(), eGameMasterLevel::DEVELOPER); + Game::config->SetDatabaseValues({ { "editor_gm_level", "0" } }, {}); + EXPECT_EQ(EditMode::RequiredLevel(), eGameMasterLevel::FORUM_MODERATOR); + Game::config->SetDatabaseValues({}, {}); +} + +// In edit mode a level file's objects and spawners are not made; the player start marker still gives the spawn point +TEST_F(EditModeTests, LevelObjectsAreNotMade) { + Game::zoneManager->SetEditMode(true); + ASSERT_TRUE(Game::zoneManager->GetEditMode()); + + std::vector objects; + SceneObject object; + object.id = 1001; + object.lot = 2365; + objects.push_back(object); + SceneObject spawner; + spawner.id = 1002; + spawner.lot = 176; + objects.push_back(spawner); + SceneObject start; + start.id = 1003; + start.lot = LOT_MARKER_PLAYER_START; + start.position = NiPoint3(10.0f, 20.0f, 30.0f); + objects.push_back(start); + + Level::LoadSceneObjects(objects, 4); + + // (a level object's ID gets the client bit when it is made) + for (const LWOOBJID id : { 1001, 1002, 1003 }) { + EXPECT_EQ(Game::entityManager->GetEntity(id), nullptr); + LWOOBJID made = id; + GeneralUtils::SetBit(made, eObjectBits::CLIENT); + EXPECT_EQ(Game::entityManager->GetEntity(made), nullptr); + } + EXPECT_TRUE(Game::zoneManager->GetSpawners().empty()); + EXPECT_EQ(Game::zoneManager->GetZone()->GetSpawnPos(), NiPoint3(10.0f, 20.0f, 30.0f)); +}