feat: worlds starting up on the dashboard; the prestarted worlds are a setting

The dashboard shows worlds master has launched but that aren't connected
yet (Starting) and those shutting down, on the main page's world list
and in /api/worlds (state: starting|stopping), without a shutdown
button. Master's server list now carries each world's state (appended
after the UGC fields, one byte per world) and master pushes the list to
the dashboard whenever a world is launched, becomes ready, is told to
shut down or goes away, instead of the dashboard only seeing it on its
30 second poll. Starting worlds are kept apart from the running ones,
so counts, events and shutdown requests still only see running worlds.

prestart_worlds (masterconfig.ini, zone ids) lists the worlds master
starts when prestart_servers is on, instead of the hardcoded character
select (0) and Venture Explorer (1000), which stay the default when it
is missing or empty. It is on the Settings page as a zone list (restart
only), shown when prestart_servers is on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 19:19:19 -05:00
parent f58213ad4c
commit f80cffb86b
13 changed files with 129 additions and 25 deletions

View File

@@ -120,6 +120,7 @@ namespace ServerState {
bool g_UgcEnabled{};
uint32_t g_UgcPid{};
std::vector<WorldInstanceInfo> g_WorldInstances{};
std::vector<WorldInstanceInfo> 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);
}

View File

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

View File

@@ -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<WorldInstanceInfo> g_WorldInstances;
// Worlds master launched that aren't connected yet, or that are shutting down (kept apart: not running)
extern std::vector<WorldInstanceInfo> 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;
}
}

View File

@@ -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));

View File

@@ -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 + ' <span class="text-body-secondary">(' + esc(row.mapID) + ')</span>'; } },
return name + ' <span class="text-body-secondary">(' + esc(row.mapID) + ')</span>' + 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 '<code class="small">' + esc((row.ip || '') + ':' + d) + '</code>'; } }] : []).concat(DASH.can('worlds_manage') ? [{ data: null, orderable: false, className: 'text-end', render: function (d, t, row) {
if (row.state) return '';
return '<button class="btn btn-sm btn-outline-danger text-nowrap" data-shutdown-zone="' + esc(row.mapID) + '" data-shutdown-instance="' + esc(row.instanceID) + '">Shut down</button>';
} }] : []),
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);