fix: the master server only starts instances of zones in the ZoneTable

A transfer or private zone request started a world server for whatever
map ID it named, and for as many instances as were asked for. A new
instance now needs a row in the ZoneTable for its zone, and no more
than max_instances (masterconfig.ini, 256 by default, 0 for no limit)
run at once. The character select has no row and is always started.

A request that gets no instance is logged and dropped. Before, the
handlers went on to use the missing instance.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-11-10 15:37:09 -06:00
parent 5f20e0b3d2
commit e164f9aaed
8 changed files with 115 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
#ifndef __INSTANCELIMITS__H__
#define __INSTANCELIMITS__H__
#include <cstdint>
namespace InstanceLimits {
// The most world instances that run at once when the config does not say
constexpr uint32_t DefaultMaxInstances = 256;
enum class eNewInstance : uint8_t {
Allowed,
UnknownZone,
TooManyInstances
};
/**
* Whether a new world instance may be started for a zone.
* @param mapID the zone asked for, 0 is the character select and has no row in the ZoneTable
* @param isInZoneTable whether the ZoneTable has a row for the zone
* @param runningInstances the instances that run already
* @param maxInstances the most instances that may run at once, 0 for no limit
*/
constexpr eNewInstance CheckNewInstance(const uint32_t mapID, const bool isInZoneTable, const uint64_t runningInstances, const uint32_t maxInstances) {
// Players always have to be able to reach the character select
if (mapID == 0) return eNewInstance::Allowed;
if (!isInZoneTable) return eNewInstance::UnknownZone;
if (maxInstances != 0 && runningInstances >= maxInstances) return eNewInstance::TooManyInstances;
return eNewInstance::Allowed;
}
}
#endif //!__INSTANCELIMITS__H__

View File

@@ -11,6 +11,7 @@
#include "CDClientManager.h"
#include "CDZoneTableTable.h"
#include "Database.h"
#include "InstanceLimits.h"
#include "MasterPackets.h"
#include "BitStreamUtils.h"
#include "ServiceType.h"
@@ -46,6 +47,23 @@ InstanceManager::InstanceManager(const std::string& externalIP) : mExternalIP{ e
m_LastPort =
GeneralUtils::TryParse<uint16_t>(Game::config->GetValue("world_port_start")).value_or(m_LastPort);
m_LastInstanceID = LWOINSTANCEID_INVALID;
m_MaxInstances =
GeneralUtils::TryParse<uint32_t>(Game::config->GetValue("max_instances")).value_or(InstanceLimits::DefaultMaxInstances);
}
bool InstanceManager::CanCreateInstance(const LWOMAPID mapID, const LWOCLONEID cloneID) const {
const bool isInZoneTable = mapID != 0 && CDZoneTableTable::Query(mapID) != nullptr;
switch (InstanceLimits::CheckNewInstance(mapID, isInZoneTable, m_Instances.size(), m_MaxInstances)) {
case InstanceLimits::eNewInstance::UnknownZone:
LOG("Tried to create an instance of map %i/%i, which is not in the ZoneTable.", mapID, cloneID);
return false;
case InstanceLimits::eNewInstance::TooManyInstances:
LOG("Tried to create an instance of map %i/%i, but %zu instances run already and max_instances is %u.", mapID, cloneID, m_Instances.size(), m_MaxInstances);
return false;
default:
return true;
}
}
const InstancePtr& InstanceManager::GetInstance(LWOMAPID mapID, bool isFriendTransfer, LWOCLONEID cloneID) {
@@ -65,6 +83,9 @@ const InstancePtr& InstanceManager::CreateInstance(LWOMAPID mapID, LWOCLONEID cl
cloneID);
return g_Empty;
}
if (!CanCreateInstance(mapID, cloneID)) return g_Empty;
int softCap = 8;
int maxPlayers = 12;
@@ -360,6 +381,8 @@ const InstancePtr& InstanceManager::CreatePrivateInstance(LWOMAPID mapID, LWOCLO
return g_Empty;
}
if (!CanCreateInstance(mapID, cloneID)) return g_Empty;
int maxPlayers = 999;
uint32_t port = GetFreePort();

View File

@@ -234,7 +234,17 @@ private:
std::map<LWOMAPID, IServerOperations::ZoneLimit> m_ZoneLimits;
std::map<LWOMAPID, SpareBackoff> m_SpareBackoff;
/**
* The most instances that run at once, 0 for no limit
*/
uint32_t m_MaxInstances = 0;
//Private functions:
/**
* Whether a new instance may be started for a zone, logs the reason when it may not
*/
bool CanCreateInstance(LWOMAPID mapID, LWOCLONEID cloneID) const;
int GetSoftCap(LWOMAPID mapID);
int GetHardCap(LWOMAPID mapID);
const InstancePtr& CreateInstance(LWOMAPID mapID, LWOCLONEID cloneID);

View File

@@ -745,6 +745,10 @@ namespace {
return;
}
const auto& in = Game::im->GetInstance(zoneID, false, zoneClone);
if (!in) {
LOG("No instance for transfer request %llu to zone %i %i", requestID, zoneID, zoneClone);
return;
}
for (const auto& instance : Game::im->GetInstances()) {
LOG("Instance: %i/%i/%i -> %i %s", instance->GetMapID(), instance->GetCloneID(), instance->GetInstanceID(), instance == in, instance->GetSysAddr().ToString());
@@ -887,6 +891,10 @@ namespace {
void OnCreatePrivateZone(const CreatePrivateZone& request, const SystemAddress& sysAddr) {
// Passwords were cut to 50 characters when read
const auto& newInst = Game::im->CreatePrivateInstance(request.zoneID, request.cloneID, request.password.c_str());
if (!newInst) {
LOG("No private zone was created for map %i/%i", request.zoneID, request.cloneID);
return;
}
LOG("Creating private zone %i/%i/%i", newInst->GetMapID(), newInst->GetCloneID(), newInst->GetInstanceID());
}

View File

@@ -7,6 +7,10 @@ master_server_port=2000
# The port number to start world servers on. Will be incremented for each world
world_port_start=3000
# The most world instances that run at once. The character select is always started.
# A zone or property that needs a new instance past this is not started. 0 for no limit
max_instances=256
# 0 or 1, should autostart auth, chat, and char servers
prestart_servers=1

View File

@@ -62,6 +62,9 @@ if(APPLE)
add_dependencies(dGameTests dGameTestsLink)
endif()
# InstanceLimits.h
target_include_directories(dGameTests PRIVATE "${PROJECT_SOURCE_DIR}/dMasterServer")
# Local capture fixtures (tests/fixtures-local, never committed) are found from the source folder
target_compile_definitions(dGameTests PRIVATE DLU_SOURCE_DIR="${PROJECT_SOURCE_DIR}")
target_link_libraries(dGameTests ${COMMON_LIBRARIES} sqlite3 GTest::gtest_main

View File

@@ -20,6 +20,7 @@ SET(DGAMEMESSAGES_TESTS
"ClientMessagesTests.cpp"
"LoadSequenceTests.cpp"
"ChatRoutingTests.cpp"
"InstanceLimitsTests.cpp"
"TradeOfferTests.cpp"
"VendorPricingTests.cpp")

View File

@@ -0,0 +1,34 @@
#include <gtest/gtest.h>
#include "InstanceLimits.h"
using InstanceLimits::CheckNewInstance;
using InstanceLimits::eNewInstance;
TEST(InstanceLimitsTest, AZoneOfTheZoneTableIsStarted) {
EXPECT_EQ(CheckNewInstance(1000, true, 0, 256), eNewInstance::Allowed);
EXPECT_EQ(CheckNewInstance(1150, true, 255, 256), eNewInstance::Allowed);
}
TEST(InstanceLimitsTest, AZoneTheZoneTableDoesNotHaveIsRefused) {
EXPECT_EQ(CheckNewInstance(31337, false, 0, 256), eNewInstance::UnknownZone);
EXPECT_EQ(CheckNewInstance(31337, false, 0, 0), eNewInstance::UnknownZone);
// The unknown zone is the reason given, also when the limit is reached too
EXPECT_EQ(CheckNewInstance(31337, false, 256, 256), eNewInstance::UnknownZone);
}
TEST(InstanceLimitsTest, NoInstanceIsStartedPastTheLimit) {
EXPECT_EQ(CheckNewInstance(1000, true, 256, 256), eNewInstance::TooManyInstances);
EXPECT_EQ(CheckNewInstance(1000, true, 1000, 256), eNewInstance::TooManyInstances);
EXPECT_EQ(CheckNewInstance(1000, true, 1, 1), eNewInstance::TooManyInstances);
}
TEST(InstanceLimitsTest, ALimitOfZeroIsNoLimit) {
EXPECT_EQ(CheckNewInstance(1000, true, 100000, 0), eNewInstance::Allowed);
}
TEST(InstanceLimitsTest, TheCharacterSelectIsAlwaysStarted) {
// Zone 0 has no row in the ZoneTable
EXPECT_EQ(CheckNewInstance(0, false, 0, 256), eNewInstance::Allowed);
EXPECT_EQ(CheckNewInstance(0, false, 256, 256), eNewInstance::Allowed);
}