diff --git a/dDashboardServer/DashboardServer.cpp b/dDashboardServer/DashboardServer.cpp index 00d929c99..c7ad718cd 100644 --- a/dDashboardServer/DashboardServer.cpp +++ b/dDashboardServer/DashboardServer.cpp @@ -120,6 +120,7 @@ namespace ServerState { bool g_UgcEnabled{}; uint32_t g_UgcPid{}; std::vector g_WorldInstances{}; + std::vector g_PendingWorlds{}; std::mutex g_StatusMutex{}; } @@ -208,6 +209,7 @@ namespace { ServerState::g_UgcPid = list.ugcPid; ServerState::g_WorldInstances.clear(); + ServerState::g_PendingWorlds.clear(); for (const auto& instance : list.instances) { WorldInstanceInfo info; info.mapID = instance.mapID; @@ -219,7 +221,13 @@ namespace { info.isPrivate = instance.isPrivate != 0; info.zoneName = GetZoneDisplayName(info.mapID); AddPropertyDetails(info); - ServerState::g_WorldInstances.push_back(info); + using eState = MasterPackets::ServerListResponse::eState; + if (instance.state == eState::READY) { + ServerState::g_WorldInstances.push_back(info); + } else { + info.state = instance.state == eState::STARTING ? "starting" : "stopping"; + ServerState::g_PendingWorlds.push_back(info); + } } LOG_DEBUG("Received server list: auth=%s chat=%s ugc=%s worlds=%u", @@ -275,6 +283,8 @@ namespace { instances.push_back(info); } + std::erase_if(ServerState::g_PendingWorlds, [&](const WorldInstanceInfo& w) { return w.mapID == zoneID && w.instanceID == instanceID; }); + LOG("World ready: zone %i instance %i", zoneID, instanceID); } diff --git a/dDashboardServer/routes/LiveWorld.cpp b/dDashboardServer/routes/LiveWorld.cpp index 6c54d09fd..ad3c5d24a 100644 --- a/dDashboardServer/routes/LiveWorld.cpp +++ b/dDashboardServer/routes/LiveWorld.cpp @@ -245,7 +245,7 @@ namespace LiveWorld { JsonSuccess(reply, { {"message", "Shutting it down"} }); }); - Route(eHTTPMethod::GET, "/api/worlds", Perm("players_view"), "Every running world with its instance and clone IDs, address, players and, for property instances, the property and owner", + Route(eHTTPMethod::GET, "/api/worlds", Perm("players_view"), "Every world with its instance and clone IDs, address, players and, for property instances, the property and owner; worlds still starting or shutting down have state: starting|stopping", [](HTTPReply& reply, const HTTPContext&) { nlohmann::json worlds = nlohmann::json::array(); std::lock_guard lock(ServerState::g_StatusMutex); @@ -254,6 +254,12 @@ namespace LiveWorld { {"isPrivate", w.isPrivate}, {"ip", w.ip}, {"port", w.port}, {"propertyId", w.propertyId}, {"propertyName", w.propertyName}, {"ownerId", w.ownerId}, {"ownerName", w.ownerName} }); } + // Launched but not connected yet, or shutting down + for (const auto& w : ServerState::g_PendingWorlds) { + worlds.push_back({ {"mapID", w.mapID}, {"zoneName", w.zoneName}, {"instanceID", w.instanceID}, {"cloneID", w.cloneID}, {"players", 0}, + {"isPrivate", w.isPrivate}, {"ip", w.ip}, {"port", w.port}, {"propertyId", w.propertyId}, {"propertyName", w.propertyName}, + {"ownerId", w.ownerId}, {"ownerName", w.ownerName}, {"state", w.state} }); + } JsonSuccess(reply, { {"worlds", worlds} }); }); diff --git a/dDashboardServer/routes/ServerState.h b/dDashboardServer/routes/ServerState.h index a21048fde..da187c2b0 100644 --- a/dDashboardServer/routes/ServerState.h +++ b/dDashboardServer/routes/ServerState.h @@ -39,6 +39,8 @@ struct WorldInstanceInfo { std::string propertyName{}; std::string ownerId{}; std::string ownerName{}; + // "starting" (launched, not connected yet) or "stopping"; worlds that are up are in g_WorldInstances instead + std::string state{}; }; namespace ServerState { @@ -49,6 +51,8 @@ namespace ServerState { extern bool g_UgcEnabled; extern uint32_t g_UgcPid; extern std::vector g_WorldInstances; + // Worlds master launched that aren't connected yet, or that are shutting down (kept apart: not running) + extern std::vector g_PendingWorlds; extern std::mutex g_StatusMutex; inline nlohmann::json GetServerStateJson() { @@ -77,6 +81,12 @@ namespace ServerState { }); } + data["startingWorlds"] = nlohmann::json::array(); + for (const auto& world : g_PendingWorlds) { + data["startingWorlds"].push_back({ {"mapID", world.mapID}, {"instanceID", world.instanceID}, {"cloneID", world.cloneID}, + {"zoneName", world.zoneName}, {"state", world.state} }); + } + data["stats"]["onlinePlayers"] = totalOnlinePlayers; data["stats"]["worlds"] = g_WorldInstances.size(); return data; @@ -108,6 +118,13 @@ namespace ServerState { } } state["worlds"] = std::move(worlds); + // Starting and stopping worlds without clone IDs (a property instance only says it's a property) + nlohmann::json pending = nlohmann::json::array(); + for (const auto& world : state.value("startingWorlds", nlohmann::json::array())) { + pending.push_back({ {"mapID", world.value("mapID", 0u)}, {"instanceID", world.value("instanceID", 0u)}, {"zoneName", world.value("zoneName", "")}, + {"state", world.value("state", "")}, {"property", world.value("cloneID", 0u) != 0} }); + } + state["startingWorlds"] = std::move(pending); return state; } } diff --git a/dDashboardServer/routes/SettingsCatalog.cpp b/dDashboardServer/routes/SettingsCatalog.cpp index 3cc4cd9d9..57ce91b3f 100644 --- a/dDashboardServer/routes/SettingsCatalog.cpp +++ b/dDashboardServer/routes/SettingsCatalog.cpp @@ -129,6 +129,11 @@ namespace { c.AddSection("Startup"); c.Add(Bool(MASTER, "prestart_servers", "Start auth, chat and char servers", "Master starts the other servers itself.", true, true)); + { + auto worlds = List(MASTER, "prestart_worlds", "Worlds started with master", "Started when master starts, before anyone asks for them. Empty: character select (0) and Venture Explorer (1000).", eListOf::ZONE, "0,1000"); + worlds.restart = true; + c.Add(When(std::move(worlds), MASTER, "prestart_servers", { "1" })); + } c.Add(Bool(MASTER, "enable_dashboard", "Start the web dashboard", "", false, true)); c.Add(Bool(SHARED, "skip_account_creation", "Skip the first-account prompt", "For non-interactive setups: master doesn't ask for an account when there are none.", false, true)); diff --git a/dDashboardServer/templates/index.jinja2 b/dDashboardServer/templates/index.jinja2 index a6f581428..ad2f31e12 100644 --- a/dDashboardServer/templates/index.jinja2 +++ b/dDashboardServer/templates/index.jinja2 @@ -124,6 +124,17 @@ $(function () { // Staff get the detailed list (address, which property a clone is); everyone else the basic one from the status var detailedWorlds = DASH.can('players_view'); + // Worlds master launched that haven't connected yet, and those shutting down + function stateBadge(row) { + if (row.state === 'starting') return ' ' + fmt.badge('Starting', 'info'); + if (row.state === 'stopping') return ' ' + fmt.badge('Shutting down', 'secondary'); + return ''; + } + function withPending(data) { + return (data.worlds || []).concat((data.startingWorlds || []).map(function (w) { + return { zoneName: w.zoneName, mapID: w.mapID, instanceID: w.property ? '' : w.instanceID, cloneID: w.cloneID || 0, players: 0, state: w.state }; + })); + } var worlds = $('#worldInstancesTable').DataTable({ paging: false, searching: false, @@ -133,7 +144,7 @@ $(function () { { data: 'zoneName', render: function (d, t, row) { // Staff who read chat can open this world on the Chat Log: its chat, players and stats var name = DASH.can('chat_view') && row.mapID ? fmt.link('/chat_log#world=' + row.mapID + ':' + row.instanceID, d) : esc(d); - return name + ' (' + esc(row.mapID) + ')'; } }, + return name + ' (' + esc(row.mapID) + ')' + stateBadge(row); } }, // Without players_view, property instances come merged per zone ({properties: n}) and without clone IDs { data: 'instanceID', render: function (d, t, row) { return row.properties ? esc(row.properties) + ' propert' + (row.properties === 1 ? 'y' : 'ies') : esc(d); } } ].concat(detailedWorlds ? [{ data: 'cloneID' }, { data: 'propertyName', defaultContent: '', render: function (d, t, row) { @@ -142,6 +153,7 @@ $(function () { } }] : []).concat([ { data: 'players', render: function (d) { return fmt.badge(d, 'primary'); } } ]).concat(detailedWorlds ? [{ data: 'isPrivate', render: function (d) { return d ? fmt.badge('Private', 'warning') : fmt.badge('Public', 'success'); } }, { data: 'port', defaultContent: '', render: function (d, t, row) { return '' + esc((row.ip || '') + ':' + d) + ''; } }] : []).concat(DASH.can('worlds_manage') ? [{ data: null, orderable: false, className: 'text-end', render: function (d, t, row) { + if (row.state) return ''; return ''; } }] : []), language: { emptyTable: 'No active world instances' } @@ -211,7 +223,7 @@ $(function () { if (data.worlds) { setText('world-count', data.stats && data.stats.worlds !== undefined ? data.stats.worlds : data.worlds.length); if (detailedWorlds) loadDetailedWorlds(); - else worlds.clear().rows.add(data.worlds).draw(false); + else worlds.clear().rows.add(withPending(data)).draw(false); } if (data.stats) { setText('online-players', data.stats.onlinePlayers); diff --git a/dMasterServer/InstanceManager.cpp b/dMasterServer/InstanceManager.cpp index 65bfbf944..62e405dfa 100644 --- a/dMasterServer/InstanceManager.cpp +++ b/dMasterServer/InstanceManager.cpp @@ -80,6 +80,7 @@ const InstancePtr& InstanceManager::CreateInstance(LWOMAPID mapID, LWOCLONEID cl StartWorldServer(mapID, port, m_LastInstanceID, maxPlayers, cloneID); m_Instances.push_back(std::move(newInstance)); + if (m_OnInstancesChanged) m_OnInstancesChanged(); if (m_Instances.back()) { LOG("Created new instance: %i/%i/%i with min/max %i/%i", mapID, m_LastInstanceID, cloneID, softCap, maxPlayers); @@ -146,6 +147,7 @@ void InstanceManager::AddInstance(InstancePtr& instance) { if (instance == nullptr) return; m_Instances.push_back(std::move(instance)); + if (m_OnInstancesChanged) m_OnInstancesChanged(); } void InstanceManager::RemoveInstance(const InstancePtr& instance) { @@ -156,6 +158,7 @@ void InstanceManager::RemoveInstance(const InstancePtr& instance) { if (!Game::ShouldShutdown()) RedirectPendingRequests(instance); m_Instances.erase(m_Instances.begin() + i); + if (m_OnInstancesChanged) m_OnInstancesChanged(); break; } @@ -164,6 +167,7 @@ void InstanceManager::RemoveInstance(const InstancePtr& instance) { void InstanceManager::ReadyInstance(const InstancePtr& instance) { instance->SetIsReady(true); + if (m_OnInstancesChanged) m_OnInstancesChanged(); auto& pending = instance->GetPendingRequests(); @@ -288,6 +292,7 @@ const InstancePtr& InstanceManager::CreatePrivateInstance(LWOMAPID mapID, LWOCLO StartWorldServer(mapID, port, m_LastInstanceID, maxPlayers, cloneID); m_Instances.push_back(std::move(newInstance)); + if (m_OnInstancesChanged) m_OnInstancesChanged(); if (m_Instances.back()) return m_Instances.back(); else LOG("Failed to create a new instance!"); diff --git a/dMasterServer/InstanceManager.h b/dMasterServer/InstanceManager.h index 203338f3a..4b3d2445e 100644 --- a/dMasterServer/InstanceManager.h +++ b/dMasterServer/InstanceManager.h @@ -1,4 +1,6 @@ #pragma once + +#include #include #include "SpareBackoff.h" #include @@ -138,6 +140,9 @@ public: const std::vector& GetInstances() const; void AddInstance(InstancePtr& instance); + + // Called on the main thread whenever a world is launched, becomes ready or is removed (for the dashboard's list) + void SetOnInstancesChanged(std::function callback) { m_OnInstancesChanged = std::move(callback); } void RemoveInstance(const InstancePtr& instance); void ReadyInstance(const InstancePtr& instance); @@ -187,4 +192,5 @@ private: int GetSoftCap(LWOMAPID mapID); int GetHardCap(LWOMAPID mapID); const InstancePtr& CreateInstance(LWOMAPID mapID, LWOCLONEID cloneID); + std::function m_OnInstancesChanged; }; diff --git a/dMasterServer/MasterServer.cpp b/dMasterServer/MasterServer.cpp index f407208aa..c46734851 100644 --- a/dMasterServer/MasterServer.cpp +++ b/dMasterServer/MasterServer.cpp @@ -88,6 +88,38 @@ SystemAddress ugcServerMasterPeerSysAddr; uint32_t ugcServerPid = 0; namespace { + // Every server for the dashboard: auth, chat, the UGC server and the worlds, including those launched but not + // connected yet (starting) and those shutting down + MasterPackets::ServerListResponse BuildServerList() { + using eState = MasterPackets::ServerListResponse::eState; + MasterPackets::ServerListResponse response; + response.authOnline = authServerMasterPeerSysAddr != UNASSIGNED_SYSTEM_ADDRESS ? 1 : 0; + response.chatOnline = chatServerMasterPeerSysAddr != UNASSIGNED_SYSTEM_ADDRESS ? 1 : 0; + response.ugcEnabled = Game::config->GetValue("enable_ugc_server") == "1" ? 1 : 0; + response.ugcOnline = ugcServerMasterPeerSysAddr != UNASSIGNED_SYSTEM_ADDRESS ? 1 : 0; + response.ugcPid = ugcServerPid; + if (!Game::im) return response; + for (const auto& inst : Game::im->GetInstances()) { + if (!inst || inst->GetShutdownComplete()) continue; + auto& entry = response.instances.emplace_back(); + entry.mapID = inst->GetMapID(); + entry.instanceID = inst->GetInstanceID(); + entry.cloneID = inst->GetCloneID(); + entry.players = static_cast(inst->GetCurrentClientCount()); + entry.ip = LUString(inst->GetIP()); + entry.port = inst->GetPort(); + entry.isPrivate = inst->GetIsPrivate() ? 1 : 0; + entry.state = inst->GetIsShuttingDown() ? eState::STOPPING : inst->GetIsReady() ? eState::READY : eState::STARTING; + } + return response; + } + + // Tells the dashboard at once when a world is launched, connects or goes away (it also asks every 30 seconds) + void PushServerListToDashboard() { + if (dashboardServerMasterPeerSysAddr == UNASSIGNED_SYSTEM_ADDRESS) return; + MasterPackets::SendTo(dashboardServerMasterPeerSysAddr, BuildServerList()); + } + // Dashboard player actions waiting for world servers to answer struct PendingPlayerAction { ePlayerAction action{}; @@ -418,6 +450,7 @@ int main(int argc, char** argv) { //Create additional objects here: Game::im = new InstanceManager(Game::server->GetIP()); + Game::im->SetOnInstancesChanged(PushServerListToDashboard); //Get CDClient initial information try { @@ -442,8 +475,17 @@ int main(int argc, char** argv) { if (Game::config->GetValue("prestart_servers") != "0") { StartChatServer(); - Game::im->GetInstance(0, false, 0); - Game::im->GetInstance(1000, false, 0); + // The worlds started with master (prestart_worlds, zone ids; missing or empty: character select and Venture Explorer) + for (auto part : GeneralUtils::SplitString(Game::config->GetValue("prestart_worlds", "0,1000"), ',')) { + std::erase_if(part, [](const char c) { return std::isspace(static_cast(c)); }); + if (part.empty()) continue; + const auto zoneId = GeneralUtils::TryParse(part); + if (!zoneId) { + LOG("prestart_worlds: '%s' isn't a zone id; skipped", part.c_str()); + continue; + } + Game::im->GetInstance(*zoneId, false, 0); + } StartAuthServer(); } @@ -804,6 +846,7 @@ namespace { LOG("Got shutdown response from zone %i clone %i instance %i port %i", instance->GetMapID(), instance->GetCloneID(), instance->GetInstanceID(), instance->GetPort()); instance->SetIsShuttingDown(true); + PushServerListToDashboard(); } void OnShutdownUniverse(const ShutdownUniverse& request, const SystemAddress& sysAddr) { @@ -937,25 +980,7 @@ namespace { void OnRequestServerList(const RequestServerList& request, const SystemAddress& sysAddr) { LOG("Dashboard requested server list"); - ServerListResponse response; - response.authOnline = authServerMasterPeerSysAddr != UNASSIGNED_SYSTEM_ADDRESS ? 1 : 0; - response.chatOnline = chatServerMasterPeerSysAddr != UNASSIGNED_SYSTEM_ADDRESS ? 1 : 0; - response.ugcEnabled = Game::config->GetValue("enable_ugc_server") == "1" ? 1 : 0; - response.ugcOnline = ugcServerMasterPeerSysAddr != UNASSIGNED_SYSTEM_ADDRESS ? 1 : 0; - response.ugcPid = ugcServerPid; - - for (const auto& inst : Game::im->GetInstances()) { - if (!inst || !inst->GetIsReady() || inst->GetIsShuttingDown()) continue; - auto& entry = response.instances.emplace_back(); - entry.mapID = inst->GetMapID(); - entry.instanceID = inst->GetInstanceID(); - entry.cloneID = inst->GetCloneID(); - entry.players = static_cast(inst->GetCurrentClientCount()); - entry.ip = LUString(inst->GetIP()); - entry.port = inst->GetPort(); - entry.isPrivate = inst->GetIsPrivate() ? 1 : 0; - } - + const auto response = BuildServerList(); MasterPackets::SendTo(sysAddr, response); } diff --git a/dNet/MasterPackets.cpp b/dNet/MasterPackets.cpp index ebe95daba..2dc9710ce 100644 --- a/dNet/MasterPackets.cpp +++ b/dNet/MasterPackets.cpp @@ -244,6 +244,7 @@ namespace MasterPackets { bitStream.Write(ugcEnabled); bitStream.Write(ugcOnline); bitStream.Write(ugcPid); + for (const auto& instance : instances) bitStream.Write(static_cast(instance.state)); } bool ServerListResponse::Deserialize(RakNet::BitStream& bitStream) { @@ -265,6 +266,12 @@ namespace MasterPackets { VALIDATE_READ(bitStream.Read(ugcEnabled)); VALIDATE_READ(bitStream.Read(ugcOnline)); VALIDATE_READ(bitStream.Read(ugcPid)); + for (auto& instance : instances) { + uint8_t state{}; + VALIDATE_READ(bitStream.Read(state)); + if (state > static_cast(eState::STOPPING)) return false; + instance.state = static_cast(state); + } return true; } diff --git a/dNet/MasterPackets.h b/dNet/MasterPackets.h index 3b6a65bc2..3637508e9 100644 --- a/dNet/MasterPackets.h +++ b/dNet/MasterPackets.h @@ -249,6 +249,9 @@ namespace MasterPackets { // More than this many worlds is a broken packet static constexpr uint32_t MAX_INSTANCES = 10000; + // Where a world is: connected, launched but not connected yet, or shutting down + enum class eState : uint8_t { READY = 0, STARTING = 1, STOPPING = 2 }; + struct Instance { LWOMAPID mapID{}; LWOINSTANCEID instanceID{}; @@ -257,6 +260,7 @@ namespace MasterPackets { LUString ip{}; uint32_t port{}; uint8_t isPrivate{}; + eState state{ eState::READY }; // written after the UGC fields, one byte per instance }; uint8_t authOnline{}; diff --git a/resources/masterconfig.ini b/resources/masterconfig.ini index 4bc2fe095..a02e78e93 100644 --- a/resources/masterconfig.ini +++ b/resources/masterconfig.ini @@ -10,6 +10,9 @@ world_port_start=3000 # 0 or 1, should autostart auth, chat, and char servers prestart_servers=1 +# The worlds (zone ids, comma separated) started with master when prestart_servers is on. Empty: 0,1000 +prestart_worlds=0,1000 + master_password=3.25DARKFLAME1 # Enable the web dashboard (0 = disabled, 1 = enabled) diff --git a/tests/dGameTests/dNetTests/MasterPacketsTests.cpp b/tests/dGameTests/dNetTests/MasterPacketsTests.cpp index a37479ea0..3b0ebef07 100644 --- a/tests/dGameTests/dNetTests/MasterPacketsTests.cpp +++ b/tests/dGameTests/dNetTests/MasterPacketsTests.cpp @@ -352,6 +352,7 @@ TEST(MasterPacketsTests, ServerListMatchesLegacy) { entry.ip = LUString(instance.ip); entry.port = instance.port; entry.isPrivate = instance.isPrivate ? 1 : 0; + entry.state = static_cast(i % 3); } response.ugcEnabled = 1; response.ugcOnline = count % 2; @@ -362,6 +363,7 @@ TEST(MasterPacketsTests, ServerListMatchesLegacy) { b.Write(1); b.Write(count % 2); b.Write(4242 + count); + for (size_t i = 0; i < count; i++) b.Write(static_cast(i % 3)); // each world's state }), StructPacket(response)); RakNet::BitStream stream; LoadPayload(stream, response); @@ -373,6 +375,7 @@ TEST(MasterPacketsTests, ServerListMatchesLegacy) { EXPECT_EQ(legacy.instances[i].players, copy.instances[i].players); EXPECT_EQ(legacy.instances[i].ip, copy.instances[i].ip.string); EXPECT_EQ(legacy.instances[i].isPrivate, copy.instances[i].isPrivate != 0); + EXPECT_EQ(static_cast(copy.instances[i].state), i % 3); } EXPECT_EQ(copy.ugcEnabled, 1); EXPECT_EQ(copy.ugcOnline, count % 2); diff --git a/tests/dWebTests/ServerStateTests.cpp b/tests/dWebTests/ServerStateTests.cpp index ded2f8a39..89f0c6d8f 100644 --- a/tests/dWebTests/ServerStateTests.cpp +++ b/tests/dWebTests/ServerStateTests.cpp @@ -10,6 +10,7 @@ namespace ServerState { bool g_UgcEnabled{}; uint32_t g_UgcPid{}; std::vector g_WorldInstances{}; + std::vector g_PendingWorlds{}; std::mutex g_StatusMutex{}; }