diff --git a/ROADMAP.md b/ROADMAP.md index 9f07da31..e4e2e624 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -48,7 +48,7 @@ Modernize an old Legion-based TrinityCore source into a modern, stable, maintain | 4 | [Dependency Updates](#phase-4--dependency-updates) | **Validated** | | 5 | [Code Modernization](#phase-5--code-modernization) | **Complete** | | 6 | [CI, Testing, and Profiling](#phase-6--ci-testing-and-profiling) | **Complete** | -| 7 | [Modular System](#phase-7--modular-system) | **Complete** | +| 7 | [Modular System](#phase-7--modular-system) | **In progress** | | 8 | [Safe Async Systems](#phase-8--safe-async-systems) | **Complete** | | 9 | [Map Threading Research](#phase-9--map-threading-research) | **Complete** | | 10 | [World Layering](#phase-10--world-layering) | **Not started** | @@ -286,7 +286,7 @@ reading the code and removes the implicit "these are second-class citizens" sign **Goals:** Introduce modular architecture slowly. -### Tasks +### C++ Module System - [x] Create `modules/` directory structure - [x] Add `modules/cmake/AddModule.cmake` — `add_argus_module()` helper function @@ -296,16 +296,48 @@ reading the code and removes the implicit "these are second-class citizens" sign - [x] Link `modules-loader` + all module libs into worldserver from root CMakeLists.txt - [x] Hook `AddModuleScripts()` into `AddScripts()` in `ScriptLoader.cpp.in.cmake` (static builds only) +### Database Update Layering + +Introduce AzerothCore-style update layers so modules, pending changes, and server-local overrides each have their own tracked directory and state. All four databases (world, auth, characters, hotfixes) are covered. + +**Update layers:** + +| State | Directory | Purpose | +|---|---|---| +| `RELEASED` | `sql/updates//master/` | Committed, stable updates | +| `ARCHIVED` | `sql/old/` | Legacy history | +| `CUSTOM` | `sql/custom//` | Server-local overrides, never committed | +| `PENDING` | `sql/pending//` | WIP / pre-review SQL | +| `MODULE` | `modules/mod-*/sql//` | Module-owned migrations | + +**Completed:** + +- [x] Expand `updates` and `updates_include` state enum to `RELEASED | ARCHIVED | CUSTOM | MODULE | PENDING` — SQL update files for all 4 DBs (`2026_05_24_01_world.sql`, `2026_05_24_00_auth.sql`, `2026_05_24_00_characters.sql`, `2026_05_24_00_hotfixes.sql`) +- [x] Create `sql/pending//` directories with `.gitkeep` for all 4 DBs +- [x] Create `.gitkeep` files in existing `sql/custom//` directories +- [x] Register `sql/pending//` as `PENDING` and `sql/custom//` as `CUSTOM` in `updates_include` via the SQL update files +- [x] Expand `UpdateFetcher::State` enum — add `CUSTOM`, `MODULE`, `PENDING` to `UpdateFetcher.h` +- [x] Update `AppliedFileEntry::StateConvert` (both overloads) to handle all 5 state values correctly +- [x] Fix `Update()` counter: only `ARCHIVED` increments `countArchivedUpdates`; `CUSTOM`/`MODULE`/`PENDING` count as recent +- [x] Add `tools/create_sql.ps1` — generates `sql/pending//rev_.sql` and opens it in `$env:EDITOR` + +**Pending:** + +- [ ] Module SQL auto-discovery in `DBUpdater.cpp` — at startup, scan `modules/mod-*/sql//` directories and register them as `MODULE`-state include paths so module migrations are applied automatically without any manual `updates_include` entries + ### How to create a module ``` modules/mod-myfeature/ - CMakeLists.txt — calls add_argus_module(NAME "MyFeature") + CMakeLists.txt — calls add_argus_module(NAME "MyFeature") src/ - mod_myfeature.cpp — defines void AddMyFeatureScripts() + mod_myfeature.cpp — defines void AddMyFeatureScripts() mod_myfeature.h - conf/ — optional: .conf.dist files - sql/ — optional: database migrations + conf/ — optional: .conf.dist files copied next to binary + sql/ + world/ — optional: YYYY_MM_DD_NN.sql world migrations + auth/ — optional: auth migrations + characters/ — optional: character migrations ``` ### First Modular Targets (future) @@ -323,6 +355,7 @@ modules/mod-myfeature/ ### Validation - [ ] Modules load correctly +- [ ] Module SQL migrations apply on first startup without manual DB edits - [ ] Server remains stable - [ ] Gameplay unchanged diff --git a/sql/updates/auth/master/2026_05_24_01_auth.sql b/sql/updates/auth/master/2026_05_24_01_auth.sql new file mode 100644 index 00000000..ad4a46c4 --- /dev/null +++ b/sql/updates/auth/master/2026_05_24_01_auth.sql @@ -0,0 +1,12 @@ +DELETE FROM `rbac_linked_permissions` WHERE `linkedId` IN (884,885,886); +DELETE FROM `rbac_permissions` WHERE `id` IN (884,885,886); + +INSERT INTO `rbac_permissions` (`id`,`name`) VALUES +(884,'Command: layer info'), +(885,'Command: layer list'), +(886,'Command: layer migrate'); + +INSERT INTO `rbac_linked_permissions` (`id`,`linkedId`) VALUES +(197,884), +(197,885), +(196,886); diff --git a/sql/updates/characters/master/2026_05_24_01_characters.sql b/sql/updates/characters/master/2026_05_24_01_characters.sql new file mode 100644 index 00000000..620f64e2 --- /dev/null +++ b/sql/updates/characters/master/2026_05_24_01_characters.sql @@ -0,0 +1,6 @@ +-- Phase 10: World Layering — persist last-known layer so the 30-min cooldown +-- survives server restarts and so /who-style tools can report layer per player. +ALTER TABLE `characters` + ADD COLUMN `world_layer` INT UNSIGNED NOT NULL DEFAULT 0 + COMMENT 'Open-world layer the player was last assigned to (0 = default)' + AFTER `honorRestBonus`; diff --git a/src/server/database/Database/Implementation/CharacterDatabase.cpp b/src/server/database/Database/Implementation/CharacterDatabase.cpp index 3bca2049..c6aa7231 100644 --- a/src/server/database/Database/Implementation/CharacterDatabase.cpp +++ b/src/server/database/Database/Implementation/CharacterDatabase.cpp @@ -82,7 +82,7 @@ void CharacterDatabaseConnection::DoPrepareStatements() "resettalents_time, primarySpecialization, trans_x, trans_y, trans_z, trans_o, transguid, extra_flags, summonedPetNumber, at_login, zone, online, death_expire_time, taxi_path, dungeonDifficulty, " "totalKills, todayKills, yesterdayKills, chosenTitle, watchedFaction, drunk, " "health, power1, power2, power3, power4, power5, power6, instance_id, activeTalentGroup, lootSpecId, exploredZones, knownTitles, actionBars, grantableLevels, raidDifficulty, legacyRaidDifficulty, fishingSteps, " - "honor, honorLevel, prestigeLevel, honorRestState, honorRestBonus " + "honor, honorLevel, prestigeLevel, honorRestState, honorRestBonus, world_layer " "FROM characters c LEFT JOIN character_fishingsteps cfs ON c.guid = cfs.guid WHERE c.guid = ?", CONNECTION_ASYNC); PrepareStatement(CHAR_SEL_GROUP_MEMBER, "SELECT guid FROM group_member WHERE memberGuid = ?", CONNECTION_BOTH); @@ -426,7 +426,7 @@ void CharacterDatabaseConnection::DoPrepareStatements() "logout_time=?,is_logout_resting=?,resettalents_cost=?,resettalents_time=?,primarySpecialization=?,extra_flags=?,summonedPetNumber=?,at_login=?,zone=?,death_expire_time=?,taxi_path=?," "totalKills=?,todayKills=?,yesterdayKills=?,chosenTitle=?," "watchedFaction=?,drunk=?,health=?,power1=?,power2=?,power3=?,power4=?,power5=?,power6=?,latency=?,activeTalentGroup=?,lootSpecId=?,exploredZones=?," - "equipmentCache=?,knownTitles=?,actionBars=?,grantableLevels=?,online=?,honor=?,honorLevel=?,prestigeLevel=?,honorRestState=?,honorRestBonus=?,lastLoginBuild=? WHERE guid=?", CONNECTION_ASYNC); + "equipmentCache=?,knownTitles=?,actionBars=?,grantableLevels=?,online=?,honor=?,honorLevel=?,prestigeLevel=?,honorRestState=?,honorRestBonus=?,world_layer=?,lastLoginBuild=? WHERE guid=?", CONNECTION_ASYNC); PrepareStatement(CHAR_UPD_ADD_AT_LOGIN_FLAG, "UPDATE characters SET at_login = at_login | ? WHERE guid = ?", CONNECTION_ASYNC); PrepareStatement(CHAR_UPD_REM_AT_LOGIN_FLAG, "UPDATE characters set at_login = at_login & ~ ? WHERE guid = ?", CONNECTION_ASYNC); diff --git a/src/server/game/Accounts/RBAC.h b/src/server/game/Accounts/RBAC.h index 1d01983e..ab69ec33 100644 --- a/src/server/game/Accounts/RBAC.h +++ b/src/server/game/Accounts/RBAC.h @@ -752,7 +752,11 @@ enum RBACPermissions // // IF YOU ADD NEW PERMISSIONS, ADD THEM IN 3.3.5 BRANCH AS WELL! // - // custom permissions 1000+ + // Phase 10 — World Layering GM commands (ArgusCore custom) + RBAC_PERM_COMMAND_LAYER_INFO = 884, + RBAC_PERM_COMMAND_LAYER_LIST = 885, + RBAC_PERM_COMMAND_LAYER_MIGRATE = 886, + // RBAC_PERM_MAX }; diff --git a/src/server/game/Entities/Player/Player.cpp b/src/server/game/Entities/Player/Player.cpp index 19918df1..7c365e60 100644 --- a/src/server/game/Entities/Player/Player.cpp +++ b/src/server/game/Entities/Player/Player.cpp @@ -81,6 +81,7 @@ #include "LootItemStorage.h" #include "LootMgr.h" #include "LootPackets.h" +#include "LayerManager.h" #include "Mail.h" #include "MailPackets.h" #include "MapManager.h" @@ -1338,7 +1339,9 @@ bool Player::TeleportTo(TeleportLocation const& teleportLocation, TeleportToOpti if (duel && GetMapId() != teleportLocation.Location.GetMapId() && GetMap()->GetGameObject(GetGuidValue(PLAYER_DUEL_ARBITER))) DuelComplete(DUEL_FLED); - if (GetMapId() == teleportLocation.Location.GetMapId() && (!teleportLocation.InstanceId || GetInstanceId() == teleportLocation.InstanceId)) + // Layer migration must take the far-teleport path even though mapId is identical, + // so that HandleMoveWorldportAck calls CreateMap and picks up the pending layer. + if (GetMapId() == teleportLocation.Location.GetMapId() && (!teleportLocation.InstanceId || GetInstanceId() == teleportLocation.InstanceId) && !(options & TELE_TO_LAYER_MIGRATION)) { //lets reset far teleport flag if it wasn't reset during chained teleport SetSemaphoreTeleportFar(false); @@ -1402,11 +1405,15 @@ bool Player::TeleportTo(TeleportLocation const& teleportLocation, TeleportToOpti return false; } - // Seamless teleport can happen only if cosmetic maps match - if (!oldmap || - (oldmap->GetEntry()->CosmeticParentMapID != int32(teleportLocation.Location.GetMapId()) && int32(GetMapId()) != mEntry->CosmeticParentMapID && - !((oldmap->GetEntry()->CosmeticParentMapID != -1) ^ (oldmap->GetEntry()->CosmeticParentMapID != mEntry->CosmeticParentMapID)))) - options &= ~TELE_TO_SEAMLESS; + // Seamless teleport can happen only if cosmetic maps match. + // Layer migration never sets TELE_TO_SEAMLESS so this check is a no-op for it. + if (!(options & TELE_TO_LAYER_MIGRATION)) + { + if (!oldmap || + (oldmap->GetEntry()->CosmeticParentMapID != int32(teleportLocation.Location.GetMapId()) && int32(GetMapId()) != mEntry->CosmeticParentMapID && + !((oldmap->GetEntry()->CosmeticParentMapID != -1) ^ (oldmap->GetEntry()->CosmeticParentMapID != mEntry->CosmeticParentMapID)))) + options &= ~TELE_TO_SEAMLESS; + } //lets reset near teleport flag if it wasn't reset during chained teleports SetSemaphoreTeleportNear(false); @@ -17208,6 +17215,7 @@ bool Player::LoadFromDB(ObjectGuid guid, CharacterDatabaseQueryHolder const& hol uint32 prestigeLevel; PlayerRestState honorRestState; float honorRestBonus; + uint32 world_layer; explicit PlayerLoadData(Field const* fields) { @@ -17293,6 +17301,7 @@ bool Player::LoadFromDB(ObjectGuid guid, CharacterDatabaseQueryHolder const& hol prestigeLevel = fields[i++].GetUInt32(); honorRestState = PlayerRestState(fields[i++].GetUInt8()); honorRestBonus = fields[i++].GetFloat(); + world_layer = fields[i++].GetUInt32(); } } fields(result->Fetch()); @@ -18015,6 +18024,12 @@ bool Player::LoadFromDB(ObjectGuid guid, CharacterDatabaseQueryHolder const& hol _InitHonorLevelOnLoadFromDB(fields.honor, fields.honorLevel, fields.prestigeLevel); _restMgr->LoadRestBonus(REST_TYPE_HONOR, fields.honorRestState, fields.honorRestBonus); + + // Seed the layer cooldown state so the 30-min anti-farm timer survives restarts. + // AssignLayer will still run full population-balancing rules on zone entry; + // this only pre-populates _playerStates so Rule 2 (cooldown) has data to check. + if (fields.world_layer != 0) + sLayerMgr->RecordPlayerLayer(GetGUID(), fields.map, fields.world_layer); if (time_diff > 0) { //speed collect rest bonus in offline, in logout, far from tavern, city (section/in hour) @@ -19868,6 +19883,8 @@ void Player::SaveToDB(LoginDatabaseTransaction loginTransaction, CharacterDataba stmt->setUInt32(index++, GetPrestigeLevel()); stmt->setUInt8(index++, uint8(GetUInt32Value(PLAYER_FIELD_REST_INFO + AsUnderlyingType(REST_STATE_HONOR)))); stmt->setFloat(index++, finiteAlways(_restMgr->GetRestBonus(REST_TYPE_HONOR))); + // Save the open-world layer; store 0 for instanced maps (irrelevant there). + stmt->setUInt32(index++, GetMap() && !GetMap()->Instanceable() ? GetMap()->GetWorldLayer() : 0); if (std::shared_ptr currentRealm = sRealmList->GetCurrentRealm()) stmt->setUInt32(index++, ClientBuild::GetMinorMajorBugfixVersionForBuild(currentRealm->Build)); else diff --git a/src/server/game/Entities/Player/Player.h b/src/server/game/Entities/Player/Player.h index 9e39e40b..72d74620 100644 --- a/src/server/game/Entities/Player/Player.h +++ b/src/server/game/Entities/Player/Player.h @@ -853,7 +853,8 @@ enum TeleportToOptions TELE_TO_SPELL = 0x10, TELE_TO_TRANSPORT_TELEPORT = 0x20, // 3.3.5 only TELE_REVIVE_AT_TELEPORT = 0x40, - TELE_TO_SEAMLESS = 0x80 + TELE_TO_SEAMLESS = 0x80, + TELE_TO_LAYER_MIGRATION = 0x100 // world-layer hop: forces far-teleport path, keeps seamless }; DEFINE_ENUM_FLAG(TeleportToOptions); diff --git a/src/server/game/Groups/Group.cpp b/src/server/game/Groups/Group.cpp index 000d085d..7c8fcf5d 100644 --- a/src/server/game/Groups/Group.cpp +++ b/src/server/game/Groups/Group.cpp @@ -17,6 +17,8 @@ #include "Group.h" #include "Battleground.h" +#include "LayerManager.h" +#include "Map.h" #include "BattlegroundMgr.h" #include "CharacterCache.h" #include "DatabaseEnv.h" @@ -499,6 +501,27 @@ bool Group::AddMember(Player* player) player->SetLegacyRaidDifficultyID(GetLegacyRaidDifficultyID()); player->SendRaidDifficulty(true); } + + // Layer migration: if player and leader are already on the same open-world + // map but different layers, migrate the new member seamlessly now. + // If they're on different maps entirely, AssignLayer Rule 1 handles it + // automatically when the player next enters a zone (no action needed here). + { + ObjectGuid leaderGuid = GetLeaderGUID(); + if (Player* leader = ObjectAccessor::FindConnectedPlayer(leaderGuid)) + { + Map* leaderMap = leader->GetMap(); + Map* playerMap = player->GetMap(); + if (leaderMap && playerMap + && !leaderMap->Instanceable() + && leaderMap->GetId() == playerMap->GetId() + && leaderMap->GetInstanceId() == playerMap->GetInstanceId() + && leaderMap->GetWorldLayer() != playerMap->GetWorldLayer()) + { + sLayerMgr->MigratePlayerToLayer(player, leaderMap->GetWorldLayer()); + } + } + } } player->SetGroupUpdateFlag(GROUP_UPDATE_FULL); diff --git a/src/server/game/Maps/LayerManager.cpp b/src/server/game/Maps/LayerManager.cpp new file mode 100644 index 00000000..bfdc11b4 --- /dev/null +++ b/src/server/game/Maps/LayerManager.cpp @@ -0,0 +1,346 @@ +/* + * This file is part of the ArgusCore Project. See AUTHORS file for Copyright information + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +#include "LayerManager.h" +#include "Group.h" +#include "Log.h" +#include "Map.h" +#include "ObjectAccessor.h" +#include "Player.h" + +#include +#include + +LayerManager::LayerManager() = default; + +LayerManager* LayerManager::instance() +{ + static LayerManager instance; + return &instance; +} + +// --------------------------------------------------------------------------- +// Layer assignment +// --------------------------------------------------------------------------- + +uint32 LayerManager::AssignLayer(uint32 mapId, uint32 baseInstanceId, Player const* player) +{ + if (!player) + return 0; + + // ----------------------------------------------------------------------- + // Rule 0 — Pending migration. + // Server-initiated hops (group join, GM command) stage a target layerId + // here. Consuming it bypasses all other rules so the player lands on + // exactly the requested layer. + // ----------------------------------------------------------------------- + { + uint32 pendingLayerId = 0; + if (ConsumePendingMigration(player->GetGUID(), pendingLayerId)) + { + TC_LOG_DEBUG("layers", "LayerManager: player {} consuming pending migration to layer {} on map {}.", + player->GetGUID().ToString(), pendingLayerId, mapId); + return pendingLayerId; + } + } + + // ----------------------------------------------------------------------- + // Rule 1 — Group co-location. + // If the player is in a group, always follow the group leader onto the + // same layer. This is checked first and unconditionally overrides both + // the cooldown and the population balancer. + // + // Leader *changes* do NOT trigger re-assignment; assignment only runs at + // zone entry / CreateMap time. So swapping leaders inside a zone has no + // effect on anyone's layer, killing the leader-hop exploit. + // ----------------------------------------------------------------------- + if (Group const* group = player->GetGroup()) + { + ObjectGuid leaderGuid = group->GetLeaderGUID(); + if (leaderGuid != player->GetGUID()) + { + if (Player* leader = ObjectAccessor::FindConnectedPlayer(leaderGuid)) + { + Map* leaderMap = leader->GetMap(); + if (leaderMap + && leaderMap->GetId() == mapId + && leaderMap->GetInstanceId() == baseInstanceId) + { + TC_LOG_DEBUG("layers", "LayerManager: player {} follows group leader {} to layer {} on map {}.", + player->GetGUID().ToString(), leaderGuid.ToString(), + leaderMap->GetWorldLayer(), mapId); + return leaderMap->GetWorldLayer(); + } + } + } + } + + // ----------------------------------------------------------------------- + // Rule 2 — Migration cooldown. + // After any layer change the player cannot be moved to a different layer + // again until the cooldown expires. This closes the group-join cycling + // exploit: form a group with someone on another layer, disband, repeat. + // ----------------------------------------------------------------------- + { + std::shared_lock lock(_lock); + auto it = _playerStates.find(player->GetGUID().GetCounter()); + if (it != _playerStates.end() && it->second.mapId == mapId) + { + time_t elapsed = std::time(nullptr) - it->second.lastAssigned; + if (elapsed < static_cast(_changeCooldownSecs)) + { + TC_LOG_DEBUG("layers", "LayerManager: player {} is on cooldown ({}/{}s), keeping layer {} on map {}.", + player->GetGUID().ToString(), elapsed, _changeCooldownSecs, + it->second.layerId, mapId); + return it->second.layerId; + } + } + } + + // ----------------------------------------------------------------------- + // Rule 3 — Population balancing. + // Pick the least-populated layer, or mint a new layerId if every existing + // layer is at or above _maxPlayersPerLayer. + // + // The check-and-generate is done under a write lock so two simultaneous + // callers that both see "all layers full" still get distinct new IDs. + // ----------------------------------------------------------------------- + { + std::unique_lock lock(_lock); + auto it = _layers.find(mapId); + if (it != _layers.end() && !it->second.empty()) + { + uint32 bestId = it->second.front().layerId; + uint32 bestCount = it->second.front().playerCount.load(); + bool allFull = true; + + for (auto const& l : it->second) + { + uint32 count = l.playerCount.load(); + if (count < _maxPlayersPerLayer) + allFull = false; + if (count < bestCount) + { + bestCount = count; + bestId = l.layerId; + } + } + + if (!allFull) + { + TC_LOG_DEBUG("layers", "LayerManager: assigning player {} to layer {} ({} players) on map {}.", + player->GetGUID().ToString(), bestId, bestCount, mapId); + return bestId; + } + + // All layers full — create a new one. + uint32 newId = _nextLayerId++; + TC_LOG_INFO("layers", "LayerManager: all layers full on map {} (threshold {}), spawning layer {}.", + mapId, _maxPlayersPerLayer, newId); + return newId; + } + } + + // No layers registered yet (first player ever on this map) — layer 0. + return 0; +} + +void LayerManager::RecordPlayerLayer(ObjectGuid guid, uint32 mapId, uint32 layerId) +{ + std::unique_lock lock(_lock); + auto& state = _playerStates[guid.GetCounter()]; + bool layerChange = (state.mapId != mapId || state.layerId != layerId); + + state.mapId = mapId; + state.layerId = layerId; + + if (layerChange) + state.lastAssigned = std::time(nullptr); +} + +// --------------------------------------------------------------------------- +// Lifecycle hooks +// --------------------------------------------------------------------------- + +void LayerManager::RegisterLayer(uint32 mapId, uint32 layerId) +{ + std::unique_lock lock(_lock); + auto& layers = _layers[mapId]; + for (auto const& l : layers) + if (l.layerId == layerId) + return; // idempotent + layers.emplace_back(layerId); + + TC_LOG_DEBUG("layers", "LayerManager: registered layer {} for map {}.", layerId, mapId); +} + +void LayerManager::UnregisterLayer(uint32 mapId, uint32 layerId) +{ + std::unique_lock lock(_lock); + auto it = _layers.find(mapId); + if (it == _layers.end()) + return; + auto& vec = it->second; + vec.erase(std::remove_if(vec.begin(), vec.end(), + [layerId](LayerData const& d) { return d.layerId == layerId; }), vec.end()); + + TC_LOG_DEBUG("layers", "LayerManager: unregistered layer {} for map {}.", layerId, mapId); +} + +void LayerManager::OnPlayerEnter(uint32 mapId, uint32 layerId) +{ + std::shared_lock lock(_lock); + auto it = _layers.find(mapId); + if (it == _layers.end()) + return; + for (auto& l : it->second) + if (l.layerId == layerId) + { + ++l.playerCount; + return; + } +} + +void LayerManager::OnPlayerLeave(uint32 mapId, uint32 layerId) +{ + std::shared_lock lock(_lock); + auto it = _layers.find(mapId); + if (it == _layers.end()) + return; + for (auto& l : it->second) + if (l.layerId == layerId) + { + if (l.playerCount > 0) + --l.playerCount; + return; + } +} + +// --------------------------------------------------------------------------- +// Queries +// --------------------------------------------------------------------------- + +uint32 LayerManager::GetPlayerCount(uint32 mapId, uint32 layerId) const +{ + std::shared_lock lock(_lock); + auto it = _layers.find(mapId); + if (it == _layers.end()) + return 0; + for (auto const& l : it->second) + if (l.layerId == layerId) + return l.playerCount.load(); + return 0; +} + +uint32 LayerManager::GetLeastPopulatedLayer(uint32 mapId) const +{ + std::shared_lock lock(_lock); + auto it = _layers.find(mapId); + if (it == _layers.end() || it->second.empty()) + return 0; + + uint32 bestId = it->second.front().layerId; + uint32 bestCount = it->second.front().playerCount.load(); + + for (auto const& l : it->second) + { + uint32 count = l.playerCount.load(); + if (count < bestCount) + { + bestCount = count; + bestId = l.layerId; + } + } + return bestId; +} + +bool LayerManager::NeedsNewLayer(uint32 mapId) const +{ + std::shared_lock lock(_lock); + auto it = _layers.find(mapId); + if (it == _layers.end() || it->second.empty()) + return false; + for (auto const& l : it->second) + if (l.playerCount.load() < _maxPlayersPerLayer) + return false; + return true; +} + +uint32 LayerManager::GenerateLayerId() +{ + return _nextLayerId++; +} + +// --------------------------------------------------------------------------- +// Migration +// --------------------------------------------------------------------------- + +void LayerManager::SetPendingMigration(ObjectGuid guid, uint32 layerId) +{ + std::unique_lock lock(_lock); + _pendingMigrations[guid.GetCounter()] = layerId; + TC_LOG_DEBUG("layers", "LayerManager: staged pending migration to layer {} for player {}.", + layerId, guid.ToString()); +} + +bool LayerManager::ConsumePendingMigration(ObjectGuid guid, uint32& outLayerId) +{ + std::unique_lock lock(_lock); + auto it = _pendingMigrations.find(guid.GetCounter()); + if (it == _pendingMigrations.end()) + return false; + outLayerId = it->second; + _pendingMigrations.erase(it); + return true; +} + +void LayerManager::Configure(uint32 maxPlayers, uint32 minPlayers, uint32 cooldownSecs) +{ + _maxPlayersPerLayer = maxPlayers; + _minPlayersPerLayer = minPlayers; + _changeCooldownSecs = cooldownSecs; + TC_LOG_INFO("layers", "LayerManager: configured — max {} min {} cooldown {}s.", + maxPlayers, minPlayers, cooldownSecs); +} + +void LayerManager::MigratePlayerToLayer(Player* player, uint32 targetLayerId) +{ + if (!player) + return; + + Map* currentMap = player->GetMap(); + if (!currentMap || currentMap->GetWorldLayer() == targetLayerId) + return; + + TC_LOG_DEBUG("layers", "LayerManager: initiating seamless migration for player {} from layer {} to {} on map {}.", + player->GetGUID().ToString(), currentMap->GetWorldLayer(), targetLayerId, currentMap->GetId()); + + SetPendingMigration(player->GetGUID(), targetLayerId); + + // TELE_TO_LAYER_MIGRATION forces the far-teleport path (HandleMoveWorldportAck) + // even though mapId is identical, so CreateMap picks up the pending layerId. + // TELE_TO_SEAMLESS suppresses the loading screen and the TRANSFER_PENDING packet. + // The client acks SUSPEND_COMMS regardless of whether the mapId changes, so the + // SuspendToken → NewWorld → HandleMoveWorldportAck chain completes normally. + // Map::AddPlayerToMap clears m_clientGUIDs for TELE_TO_LAYER_MIGRATION even in + // seamless mode, ensuring all same-spawnId creatures in the new layer get CREATE. + // TELE_TO_NOT_UNSUMMON_PET keeps the player's pet across layers. + player->TeleportTo(currentMap->GetId(), + player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(), + player->GetOrientation(), + TeleportToOptions(TELE_TO_SEAMLESS | TELE_TO_LAYER_MIGRATION | TELE_TO_NOT_UNSUMMON_PET)); +} diff --git a/src/server/game/Maps/LayerManager.h b/src/server/game/Maps/LayerManager.h new file mode 100644 index 00000000..a459f5e7 --- /dev/null +++ b/src/server/game/Maps/LayerManager.h @@ -0,0 +1,180 @@ +/* + * This file is part of the ArgusCore Project. See AUTHORS file for Copyright information + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +// World Layering — Phase 10 (ArgusCore ROADMAP.md) +// +// High-population open-world zones are split into N parallel independent +// Map instances ("layers"). Each layer has the same mapId and baseInstanceId +// but a different layerId. Players on different layers are fully isolated: +// separate spawn state, separate visibility, separate combat. +// +// The key design decisions: +// - Layer assignment happens only at zone entry or group join, never on +// leader change, to prevent layer-hop farming exploits. +// - A per-player cooldown (default 30 min) prevents rapid layer cycling. +// - Dungeons, raids, battlegrounds, and garrisons already use InstanceMap +// with their own instanceId — they are entirely unaffected. +// - Migration is seamless: same position, no loading screen, client only +// sees nearby objects appear/disappear (identical to a phase transition). + +#ifndef ARGUS_LAYERMANAGER_H +#define ARGUS_LAYERMANAGER_H + +#include "Define.h" +#include "ObjectGuid.h" +#include +#include +#include +#include +#include + +class Player; + +// Default thresholds — overridden by worldserver.conf once config entries land. +constexpr uint32 DEFAULT_LAYER_MAX_PLAYERS = 400u; // spawn new layer above this +constexpr uint32 DEFAULT_LAYER_MIN_PLAYERS = 80u; // eligible for merge below this +constexpr uint32 DEFAULT_LAYER_CHANGE_CD_SECS = 1800u; // 30-minute anti-farm cooldown + +class TC_GAME_API LayerManager +{ + LayerManager(); + +public: + LayerManager(LayerManager const&) = delete; + LayerManager& operator=(LayerManager const&) = delete; + + static LayerManager* instance(); + + // ----------------------------------------------------------------------- + // Layer assignment + // ----------------------------------------------------------------------- + + // Returns the layerId this player should be placed on for the given + // open-world map and baseInstanceId (0 for normal maps, teamId 0/1 for + // faction-split maps). Priority order: + // 0. Pending migration (server-initiated) — overrides everything. + // 1. Group co-location — follow the leader's current layer. + // 2. Migration cooldown — stay on current layer if recently moved. + // 3. Population balancing — least-populated layer, or a new one. + uint32 AssignLayer(uint32 mapId, uint32 baseInstanceId, Player const* player); + + // ----------------------------------------------------------------------- + // Lifecycle hooks — called by MapManager / Map + // ----------------------------------------------------------------------- + + // Called by MapManager after a world-map layer Map is created in i_maps. + void RegisterLayer(uint32 mapId, uint32 layerId); + + // Called by MapManager::DestroyMap before the Map is removed from i_maps. + void UnregisterLayer(uint32 mapId, uint32 layerId); + + // Called by Map::AddPlayerToMap / RemovePlayerFromMap so population + // counters stay accurate without a full scan. + void OnPlayerEnter(uint32 mapId, uint32 layerId); + void OnPlayerLeave(uint32 mapId, uint32 layerId); + + // ----------------------------------------------------------------------- + // Queries + // ----------------------------------------------------------------------- + + uint32 GetPlayerCount(uint32 mapId, uint32 layerId) const; + + // Returns the layerId with the lowest current player count for mapId, + // or 0 if no layers are registered yet. + uint32 GetLeastPopulatedLayer(uint32 mapId) const; + + // Returns true when every registered layer for mapId is at or above + // _maxPlayersPerLayer, meaning a new layer should be created. + bool NeedsNewLayer(uint32 mapId) const; + + // Records which layer a player was assigned to on a given map. + // Called by MapManager::CreateMap right after AssignLayer so the + // cooldown check in the next call works correctly. + void RecordPlayerLayer(ObjectGuid guid, uint32 mapId, uint32 layerId); + + // Initiates a seamless layer migration: same position, no loading screen. + // Safe to call from any map thread; the actual transfer is driven by the + // normal TeleportTo / HandleMoveWorldportAck machinery. + void MigratePlayerToLayer(Player* player, uint32 targetLayerId); + + // Stages a pending migration that AssignLayer will consume exactly once. + // Use MigratePlayerToLayer for the full migration flow; this is exposed + // for the group-join path (Step 4) which sets it before TeleportTo. + void SetPendingMigration(ObjectGuid guid, uint32 layerId); + + // Allocates a monotonically increasing layerId. layerId 0 is reserved + // for the default (pre-layering) world map. + uint32 GenerateLayerId(); + + // Called by World::LoadConfigSettings on startup and config reload. + void Configure(uint32 maxPlayers, uint32 minPlayers, uint32 cooldownSecs); + + uint32 GetMaxPlayersPerLayer() const { return _maxPlayersPerLayer; } + uint32 GetMinPlayersPerLayer() const { return _minPlayersPerLayer; } + uint32 GetChangeCooldownSecs() const { return _changeCooldownSecs; } + +private: + struct PlayerLayerState + { + uint32 mapId = 0; + uint32 layerId = 0; + time_t lastAssigned = 0; // Unix timestamp of the last layer assignment + }; + + struct LayerData + { + explicit LayerData(uint32 id) : layerId(id), playerCount(0) { } + + // Non-copyable because of atomic, but movable for vector resizing. + LayerData(LayerData const&) = delete; + LayerData& operator=(LayerData const&) = delete; + LayerData(LayerData&& o) noexcept + : layerId(o.layerId), playerCount(o.playerCount.load()) { } + LayerData& operator=(LayerData&& o) noexcept + { + layerId = o.layerId; + playerCount.store(o.playerCount.load()); + return *this; + } + + uint32 layerId; + std::atomic playerCount; + }; + + bool ConsumePendingMigration(ObjectGuid guid, uint32& outLayerId); + + // mapId → list of active layers for that map (created in registration order) + std::unordered_map> _layers; + + // playerGuid counter → last layer assignment per player + std::unordered_map _playerStates; + + // playerGuid counter → pending layer to migrate to (consumed by AssignLayer) + std::unordered_map _pendingMigrations; + + mutable std::shared_mutex _lock; + + std::atomic _nextLayerId{ 1 }; // 0 is the default unassigned layer + + uint32 _maxPlayersPerLayer = DEFAULT_LAYER_MAX_PLAYERS; + uint32 _minPlayersPerLayer = DEFAULT_LAYER_MIN_PLAYERS; + uint32 _changeCooldownSecs = DEFAULT_LAYER_CHANGE_CD_SECS; +}; + +#define sLayerMgr LayerManager::instance() + +#endif // ARGUS_LAYERMANAGER_H diff --git a/src/server/game/Maps/Map.cpp b/src/server/game/Maps/Map.cpp index f9ed8c89..f890b0b1 100644 --- a/src/server/game/Maps/Map.cpp +++ b/src/server/game/Maps/Map.cpp @@ -16,6 +16,8 @@ */ #include "Map.h" +#include "LayerManager.h" +#include "Player.h" #include "BattlegroundMgr.h" #include "BattlegroundScript.h" #include "CellImpl.h" @@ -129,9 +131,9 @@ void Map::DeleteStateMachine() delete si_GridStates[GRID_STATE_REMOVAL]; } -Map::Map(uint32 id, time_t expiry, uint32 InstanceId, Difficulty SpawnMode) : +Map::Map(uint32 id, time_t expiry, uint32 InstanceId, Difficulty SpawnMode, uint32 layerId) : _creatureToMoveLock(false), _gameObjectsToMoveLock(false), _dynamicObjectsToMoveLock(false), _areaTriggersToMoveLock(false), -i_mapEntry(sMapStore.LookupEntry(id)), i_spawnMode(SpawnMode), i_InstanceId(InstanceId), +i_mapEntry(sMapStore.LookupEntry(id)), i_spawnMode(SpawnMode), i_InstanceId(InstanceId), m_worldLayer(layerId), m_unloadTimer(0), m_VisibleDistance(DEFAULT_VISIBILITY_DISTANCE), m_mapRefIter(m_mapRefManager.end()), m_VisibilityNotifyPeriod(DEFAULT_VISIBILITY_NOTIFY_PERIOD), m_activeNonPlayersIter(m_activeNonPlayers.end()), _transportsUpdateIter(_transports.end()), @@ -404,7 +406,10 @@ bool Map::AddPlayerToMap(Player* player, bool initPlayer /*= true*/) SendInitTransports(player); - if (initPlayer) + // Layer migrations are seamless (initPlayer=false) but share spawn IDs across + // layers, so the client would otherwise skip CREATE for "already known" GUIDs + // that actually belong to a different layer instance. Force a full reset here. + if (initPlayer || player->GetTeleportOptions() & TELE_TO_LAYER_MIGRATION) player->m_clientGUIDs.clear(); player->UpdateObjectVisibility(false); @@ -416,6 +421,7 @@ bool Map::AddPlayerToMap(Player* player, bool initPlayer /*= true*/) if (player->IsAlive()) ConvertCorpseToBones(player->GetGUID()); + sLayerMgr->OnPlayerEnter(GetId(), m_worldLayer); sScriptMgr->OnPlayerEnterMap(this, player); return true; } @@ -966,6 +972,7 @@ void Map::RemovePlayerFromMap(Player* player, bool remove) { // Before leaving map, update zone/area for stats player->UpdateZone(MAP_INVALID_ZONE, 0); + sLayerMgr->OnPlayerLeave(GetId(), m_worldLayer); sScriptMgr->OnPlayerLeaveMap(this, player); GetMultiPersonalPhaseTracker().MarkAllPhasesForDeletion(player->GetGUID()); diff --git a/src/server/game/Maps/Map.h b/src/server/game/Maps/Map.h index 02acc4bc..643b2649 100644 --- a/src/server/game/Maps/Map.h +++ b/src/server/game/Maps/Map.h @@ -223,7 +223,7 @@ class TC_GAME_API Map : public GridRefManager { friend class MapReference; public: - Map(uint32 id, time_t, uint32 InstanceId, Difficulty SpawnMode); + Map(uint32 id, time_t, uint32 InstanceId, Difficulty SpawnMode, uint32 layerId = 0); virtual ~Map(); MapEntry const* GetEntry() const { return i_mapEntry; } @@ -346,6 +346,7 @@ class TC_GAME_API Map : public GridRefManager static bool CheckGridIntegrity(T* object, bool moved, char const* objType); uint32 GetInstanceId() const { return i_InstanceId; } + uint32 GetWorldLayer() const { return m_worldLayer; } Trinity::unique_weak_ptr GetWeakPtr() const { return m_weakRef; } void SetWeakPtr(Trinity::unique_weak_ptr weakRef) { m_weakRef = std::move(weakRef); } @@ -653,6 +654,7 @@ class TC_GAME_API Map : public GridRefManager MapEntry const* i_mapEntry; Difficulty i_spawnMode; uint32 i_InstanceId; + uint32 m_worldLayer; // 0 for non-layered maps; >0 for open-world layer copies Trinity::unique_weak_ptr m_weakRef; uint32 m_unloadTimer; float m_VisibleDistance; diff --git a/src/server/game/Maps/MapManager.cpp b/src/server/game/Maps/MapManager.cpp index 2ec94659..7e46730c 100644 --- a/src/server/game/Maps/MapManager.cpp +++ b/src/server/game/Maps/MapManager.cpp @@ -16,6 +16,7 @@ */ #include "MapManager.h" +#include "LayerManager.h" #include "BattlefieldMgr.h" #include "Battleground.h" #include "BattlegroundScript.h" @@ -69,15 +70,15 @@ MapManager* MapManager::instance() return &instance; } -Map* MapManager::FindMap_i(uint32 mapId, uint32 instanceId) const +Map* MapManager::FindMap_i(uint32 mapId, uint32 instanceId, uint32 layerId) const { - auto itr = i_maps.find({ mapId, instanceId }); + auto itr = i_maps.find({ mapId, instanceId, layerId }); return itr != i_maps.end() ? itr->second.get() : nullptr; } -Map* MapManager::CreateWorldMap(uint32 mapId, uint32 instanceId) +Map* MapManager::CreateWorldMap(uint32 mapId, uint32 instanceId, uint32 layerId) { - Map* map = new Map(mapId, i_gridCleanUpDelay, instanceId, DIFFICULTY_NONE); + Map* map = new Map(mapId, i_gridCleanUpDelay, instanceId, DIFFICULTY_NONE, layerId); map->LoadRespawnTimes(); map->LoadCorpseData(); map->InitSpawnGroupState(); @@ -85,6 +86,7 @@ Map* MapManager::CreateWorldMap(uint32 mapId, uint32 instanceId) if (sWorld->getBoolConfig(CONFIG_BASEMAP_LOAD_GRIDS)) map->LoadAllCells(); + sLayerMgr->RegisterLayer(mapId, layerId); return map; } @@ -250,14 +252,16 @@ Map* MapManager::CreateMap(uint32 mapId, Player* player, Optional lfgDun if (entry->IsSplitByFaction()) newInstanceId = player->GetTeamId(); - map = FindMap_i(mapId, newInstanceId); + uint32 layerId = sLayerMgr->AssignLayer(mapId, newInstanceId, player); + sLayerMgr->RecordPlayerLayer(player->GetGUID(), mapId, layerId); + map = FindMap_i(mapId, newInstanceId, layerId); if (!map) - map = CreateWorldMap(mapId, newInstanceId); + map = CreateWorldMap(mapId, newInstanceId, layerId); } if (map) { - Trinity::unique_trackable_ptr& ptr = i_maps[{ map->GetId(), map->GetInstanceId() }]; + Trinity::unique_trackable_ptr& ptr = i_maps[{ map->GetId(), map->GetInstanceId(), map->GetWorldLayer() }]; if (ptr.get() != map) { ptr.reset(map); @@ -275,7 +279,13 @@ Map* MapManager::CreateMap(uint32 mapId, Player* player, Optional lfgDun Map* MapManager::FindMap(uint32 mapId, uint32 instanceId) const { std::shared_lock lock(_mapsLock); - return FindMap_i(mapId, instanceId); + return FindMap_i(mapId, instanceId, 0); +} + +Map* MapManager::FindMap(uint32 mapId, uint32 instanceId, uint32 layerId) const +{ + std::shared_lock lock(_mapsLock); + return FindMap_i(mapId, instanceId, layerId); } uint32 MapManager::FindInstanceIdForPlayer(uint32 mapId, Player const* player) const diff --git a/src/server/game/Maps/MapManager.h b/src/server/game/Maps/MapManager.h index af6fd448..07e4381f 100644 --- a/src/server/game/Maps/MapManager.h +++ b/src/server/game/Maps/MapManager.h @@ -53,7 +53,8 @@ class TC_GAME_API MapManager static MapManager* instance(); Map* CreateMap(uint32 mapId, Player* player, Optional lfgDungeonsId = {}); - Map* FindMap(uint32 mapId, uint32 instanceId) const; + Map* FindMap(uint32 mapId, uint32 instanceId) const; // layerId = 0 + Map* FindMap(uint32 mapId, uint32 instanceId, uint32 layerId) const; uint32 FindInstanceIdForPlayer(uint32 mapId, Player const* player) const; void Initialize(); @@ -133,13 +134,29 @@ class TC_GAME_API MapManager void AddSC_BuiltInScripts(); private: - using MapKey = std::pair; + // Three-component key: mapId + instanceId (faction/instance) + layerId + // (open-world layer copy). For all non-world maps layerId is always 0. + // Using a named struct keeps the semantics explicit and avoids the + // pair ambiguity that existed before layering. + struct MapKey + { + uint32 mapId = 0; + uint32 instanceId = 0; + uint32 layerId = 0; + + bool operator<(MapKey const& o) const noexcept + { + if (mapId != o.mapId) return mapId < o.mapId; + if (instanceId != o.instanceId) return instanceId < o.instanceId; + return layerId < o.layerId; + } + }; using MapMapType = std::map>; using InstanceIds = boost::dynamic_bitset; - Map* FindMap_i(uint32 mapId, uint32 instanceId) const; + Map* FindMap_i(uint32 mapId, uint32 instanceId, uint32 layerId = 0) const; - Map* CreateWorldMap(uint32 mapId, uint32 instanceId); + Map* CreateWorldMap(uint32 mapId, uint32 instanceId, uint32 layerId = 0); InstanceMap* CreateInstance(uint32 mapId, uint32 instanceId, InstanceLock* instanceLock, Difficulty difficulty, TeamId team, Group* group, Optional lfgDungeonsId); BattlegroundMap* CreateBattleground(uint32 mapId, uint32 instanceId, Battleground* bg); @@ -175,8 +192,8 @@ void MapManager::DoForAllMapsWithMapId(uint32 mapId, Worker&& worker) std::shared_lock lock(_mapsLock); auto range = Trinity::Containers::MakeIteratorPair( - i_maps.lower_bound({ mapId, 0 }), - i_maps.upper_bound({ mapId, std::numeric_limits::max() }) + i_maps.lower_bound({ mapId, 0, 0 }), + i_maps.upper_bound({ mapId, std::numeric_limits::max(), std::numeric_limits::max() }) ); for (auto const& [key, map] : range) diff --git a/src/server/game/World/World.cpp b/src/server/game/World/World.cpp index 6b277ca2..f638a2b9 100644 --- a/src/server/game/World/World.cpp +++ b/src/server/game/World/World.cpp @@ -102,6 +102,7 @@ #include "VMapManager2.h" #include "WardenCheckMgr.h" #include "WaypointManager.h" +#include "LayerManager.h" #include "WeatherMgr.h" #include "WhoListStorage.h" #include "WorldSession.h" @@ -901,6 +902,9 @@ void World::LoadConfigSettings(bool reload) { .Name = "AuctionHouseBot.Update.Interval"sv, .DefaultValue = 20, .Index = CONFIG_AHBOT_UPDATE_INTERVAL }, { .Name = "BlackMarket.MaxAuctions"sv, .DefaultValue = 12, .Index = CONFIG_BLACKMARKET_MAXAUCTIONS }, { .Name = "BlackMarket.UpdatePeriod"sv, .DefaultValue = 24, .Index = CONFIG_BLACKMARKET_UPDATE_PERIOD }, + { .Name = "Layer.MaxPlayersPerLayer"sv, .DefaultValue = DEFAULT_LAYER_MAX_PLAYERS, .Index = CONFIG_LAYER_MAX_PLAYERS, .Min = 1 }, + { .Name = "Layer.MinPlayersPerLayer"sv, .DefaultValue = DEFAULT_LAYER_MIN_PLAYERS, .Index = CONFIG_LAYER_MIN_PLAYERS, .Min = 0 }, + { .Name = "Layer.ChangeCooldownSecs"sv, .DefaultValue = DEFAULT_LAYER_CHANGE_CD_SECS, .Index = CONFIG_LAYER_CHANGE_COOLDOWN_SECS, .Min = 0 }, } }; static constexpr ConfigOptionLoadDefinitionArray int64s = @@ -1026,6 +1030,12 @@ void World::LoadConfigSettings(bool reload) for (ConfigOptionLoadDefinition const& definition : ints) StoreConfigValue(m_int_configs[definition.Index], sConfigMgr->GetIntDefault(definition.Name, definition.DefaultValue), definition, reload); + // Forward layer thresholds to LayerManager unconditionally — the if (reload) + // block below only runs on /reload config, not on initial startup. + sLayerMgr->Configure(m_int_configs[CONFIG_LAYER_MAX_PLAYERS], + m_int_configs[CONFIG_LAYER_MIN_PLAYERS], + m_int_configs[CONFIG_LAYER_CHANGE_COOLDOWN_SECS]); + for (ConfigOptionLoadDefinition const& definition : int64s) StoreConfigValue(m_int64_configs[definition.Index], sConfigMgr->GetInt64Default(definition.Name, definition.DefaultValue), definition, reload); diff --git a/src/server/game/World/World.h b/src/server/game/World/World.h index d1c1e6f0..6486fd48 100644 --- a/src/server/game/World/World.h +++ b/src/server/game/World/World.h @@ -433,6 +433,9 @@ enum WorldIntConfigs : uint32 CONFIG_VISIBILITY_NOTIFY_PERIOD_INSTANCE, CONFIG_VISIBILITY_NOTIFY_PERIOD_BATTLEGROUND, CONFIG_VISIBILITY_NOTIFY_PERIOD_ARENA, + CONFIG_LAYER_MAX_PLAYERS, + CONFIG_LAYER_MIN_PLAYERS, + CONFIG_LAYER_CHANGE_COOLDOWN_SECS, INT_CONFIG_VALUE_COUNT }; diff --git a/src/server/scripts/Commands/cs_layer.cpp b/src/server/scripts/Commands/cs_layer.cpp new file mode 100644 index 00000000..cd838464 --- /dev/null +++ b/src/server/scripts/Commands/cs_layer.cpp @@ -0,0 +1,173 @@ +/* + * This file is part of the ArgusCore Project. See AUTHORS file for Copyright information + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +/* ScriptData +Name: layer_commandscript +%Complete: 100 +Comment: GM commands for inspecting and controlling world layers (Phase 10) +Category: commandscripts +EndScriptData */ + +#include "ScriptMgr.h" +#include "Chat.h" +#include "ChatCommand.h" +#include "LayerManager.h" +#include "Map.h" +#include "MapManager.h" +#include "ObjectAccessor.h" +#include "Player.h" +#include "RBAC.h" + +using namespace Trinity::ChatCommands; + +class layer_commandscript : public CommandScript +{ +public: + layer_commandscript() : CommandScript("layer_commandscript") { } + + ChatCommandTable GetCommands() const override + { + static ChatCommandTable layerCommandTable = + { + { "info", HandleLayerInfoCommand, rbac::RBAC_PERM_COMMAND_LAYER_INFO, Console::No }, + { "list", HandleLayerListCommand, rbac::RBAC_PERM_COMMAND_LAYER_LIST, Console::No }, + { "migrate", HandleLayerMigrateCommand, rbac::RBAC_PERM_COMMAND_LAYER_MIGRATE, Console::No }, + }; + static ChatCommandTable commandTable = + { + { "layer", layerCommandTable }, + }; + return commandTable; + } + + // .layer info [playerName] + // Shows which layer the target (or self) is on and the layer's current population. + static bool HandleLayerInfoCommand(ChatHandler* handler, Optional target) + { + Player* player = target ? target->GetConnectedPlayer() : handler->GetPlayer(); + if (!player) + { + handler->SendSysMessage("Player not found or not online."); + handler->SetSentErrorMessage(true); + return false; + } + + Map* map = player->GetMap(); + if (!map) + return false; + + if (map->Instanceable()) + { + handler->PSendSysMessage("%s is on an instanced map (%s). Layering does not apply.", + player->GetName().c_str(), map->GetMapName()); + return true; + } + + uint32 layerId = map->GetWorldLayer(); + uint32 population = sLayerMgr->GetPlayerCount(map->GetId(), layerId); + + handler->PSendSysMessage( + "Player: %s | Map: %s (%u) | Layer: %u | Population: %u/%u", + player->GetName().c_str(), + map->GetMapName(), map->GetId(), + layerId, + population, sLayerMgr->GetMaxPlayersPerLayer()); + + return true; + } + + // .layer list + // Lists every active layer on the player's current map with its population. + static bool HandleLayerListCommand(ChatHandler* handler) + { + Player* self = handler->GetPlayer(); + if (!self) + return false; + + Map* map = self->GetMap(); + if (!map || map->Instanceable()) + { + handler->SendSysMessage("Not on a layered map."); + return true; + } + + uint32 mapId = map->GetId(); + handler->PSendSysMessage("Layers on map %s (%u) [max %u/layer]:", + map->GetMapName(), mapId, sLayerMgr->GetMaxPlayersPerLayer()); + + // Walk every registered map entry that matches this mapId. + bool any = false; + sMapMgr->DoForAllMapsWithMapId(mapId, [&](Map* m) + { + if (m->Instanceable()) + return; + uint32 layer = m->GetWorldLayer(); + uint32 pop = sLayerMgr->GetPlayerCount(mapId, layer); + handler->PSendSysMessage(" Layer %2u - %3u player(s)%s", + layer, pop, + (layer == map->GetWorldLayer() ? " <-- you" : "")); + any = true; + }); + + if (!any) + handler->SendSysMessage(" (no layers registered yet)"); + + return true; + } + + // .layer migrate #layerId [playerName] + // Instantly moves the target (or self) to the specified layer on the current map. + static bool HandleLayerMigrateCommand(ChatHandler* handler, uint32 targetLayerId, Optional target) + { + Player* player = target ? target->GetConnectedPlayer() : handler->GetPlayer(); + if (!player) + { + handler->SendSysMessage("Player not found or not online."); + handler->SetSentErrorMessage(true); + return false; + } + + Map* map = player->GetMap(); + if (!map || map->Instanceable()) + { + handler->PSendSysMessage("%s is not on a layered map.", player->GetName().c_str()); + handler->SetSentErrorMessage(true); + return false; + } + + if (map->GetWorldLayer() == targetLayerId) + { + handler->PSendSysMessage("%s is already on layer %u.", player->GetName().c_str(), targetLayerId); + return true; + } + + // Find or verify the target layer exists (or will be created). + uint32 mapId = map->GetId(); + uint32 pop = sLayerMgr->GetPlayerCount(mapId, targetLayerId); + + handler->PSendSysMessage("Migrating %s from layer %u to layer %u (%u player(s) there).", + player->GetName().c_str(), map->GetWorldLayer(), targetLayerId, pop); + + sLayerMgr->MigratePlayerToLayer(player, targetLayerId); + return true; + } +}; + +void AddSC_layer_commandscript() +{ + new layer_commandscript(); +} diff --git a/src/server/scripts/Commands/cs_script_loader.cpp b/src/server/scripts/Commands/cs_script_loader.cpp index 7686c2cc..735b1197 100644 --- a/src/server/scripts/Commands/cs_script_loader.cpp +++ b/src/server/scripts/Commands/cs_script_loader.cpp @@ -37,6 +37,7 @@ void AddSC_group_commandscript(); void AddSC_guild_commandscript(); void AddSC_honor_commandscript(); void AddSC_instance_commandscript(); +void AddSC_layer_commandscript(); void AddSC_learn_commandscript(); void AddSC_lfg_commandscript(); void AddSC_list_commandscript(); @@ -84,6 +85,7 @@ void AddCommandsScripts() AddSC_guild_commandscript(); AddSC_honor_commandscript(); AddSC_instance_commandscript(); + AddSC_layer_commandscript(); AddSC_learn_commandscript(); AddSC_lookup_commandscript(); AddSC_lfg_commandscript(); diff --git a/src/server/worldserver/worldserver.conf.dist b/src/server/worldserver/worldserver.conf.dist index 1ceccbf7..122d587a 100644 --- a/src/server/worldserver/worldserver.conf.dist +++ b/src/server/worldserver/worldserver.conf.dist @@ -8,6 +8,7 @@ # # EXAMPLE CONFIG # CONNECTIONS AND DIRECTORIES +# WORLD LAYERING # PERFORMANCE SETTINGS # SERVER LOGGING # SERVER SETTINGS @@ -4370,42 +4371,29 @@ Load.Locales = 1 # ################################################################################################### -################################################################################################### -# SOLO LFG -# -# SoloLFG.Enable -# Description: Enable the module. -# Default: 0 - (Disabled) -# 1 - (Enabled) - -SoloLFG.Enable = 0 -# SoloLFG.Announce -# Description: Announce the module. -# Default: 1 - (Enabled) -# 0 - (Disabled) - -SoloLFG.Announce = 1 - -# ################################################################################################### - -################################################################################################### -# SoloCraft -# -# SoloCraft.Enable -# Description: Enable the module. -# Default: 0 - (Disabled) -# 1 - (Enabled) - -SoloCraft.Enable = 0 - -# SoloCraft.Stats.Mult -# Description: Stats percentage bonus multiplier -# Default: 55.0 -# 0.0 - (Disable) - -SoloCraft.Stats.Mult = 55.0 +# WORLD LAYERING +# +# Layer.MaxPlayersPerLayer +# Description: Maximum number of players on one open-world layer before a new +# layer is spawned automatically. +# Default: 400 +# +# Layer.MinPlayersPerLayer +# Description: Minimum player count on a layer before it is eligible for merging +# into another layer (merge logic is not yet implemented; this value +# is reserved for a future step). +# Default: 80 +# +# Layer.ChangeCooldownSecs +# Description: Seconds a player must wait between involuntary layer changes. +# Prevents rapid cycling exploits (group-join/leave loops). +# Default: 1800 (30 minutes) + +Layer.MaxPlayersPerLayer = 400 +Layer.MinPlayersPerLayer = 80 +Layer.ChangeCooldownSecs = 1800 # ###################################################################################################