@babylonjs-toolkit/server 0.0.0-stage → 9.35.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CUSTOM.md +1690 -0
- package/DEPLOY.md +2012 -0
- package/README.md +1081 -2
- package/lib/AbuseLimits.d.ts +64 -0
- package/lib/AbuseLimits.js +121 -0
- package/lib/Authentication.d.ts +86 -0
- package/lib/Authentication.js +272 -0
- package/lib/BootSessionOwner.d.ts +86 -0
- package/lib/BootSessionOwner.js +116 -0
- package/lib/ColyseusLoggerAdapter.d.ts +32 -0
- package/lib/ColyseusLoggerAdapter.js +73 -0
- package/lib/DedicatedSceneRegistry.d.ts +52 -0
- package/lib/DedicatedSceneRegistry.js +74 -0
- package/lib/DedicatedSession.d.ts +152 -0
- package/lib/DedicatedSession.js +173 -0
- package/lib/DualTransport.d.ts +112 -0
- package/lib/DualTransport.js +197 -0
- package/lib/GameServer.d.ts +64 -0
- package/lib/GameServer.js +647 -0
- package/lib/GameServerConfig.d.ts +387 -0
- package/lib/GameServerConfig.js +1059 -0
- package/lib/GameServerLobbyRoom.d.ts +33 -0
- package/lib/GameServerLobbyRoom.js +56 -0
- package/lib/GameServerLogger.d.ts +111 -0
- package/lib/GameServerLogger.js +190 -0
- package/lib/GameServerMetrics.d.ts +158 -0
- package/lib/GameServerMetrics.js +276 -0
- package/lib/GameServerState.d.ts +164 -0
- package/lib/GameServerState.js +26 -0
- package/lib/HeadlessRuntime.d.ts +316 -0
- package/lib/HeadlessRuntime.js +380 -0
- package/lib/JoinGate.d.ts +34 -0
- package/lib/JoinGate.js +221 -0
- package/lib/NetworkAddresses.d.ts +33 -0
- package/lib/NetworkAddresses.js +90 -0
- package/lib/PlayerTokens.d.ts +17 -0
- package/lib/PlayerTokens.js +24 -0
- package/lib/RoomServerTransport.d.ts +73 -0
- package/lib/RoomServerTransport.js +90 -0
- package/lib/ServerEndpoints.d.ts +133 -0
- package/lib/ServerEndpoints.js +247 -0
- package/lib/ServerInit.d.ts +28 -0
- package/lib/ServerInit.js +113 -0
- package/lib/SessionRoom.d.ts +391 -0
- package/lib/SessionRoom.js +930 -0
- package/lib/TlsCertificates.d.ts +35 -0
- package/lib/TlsCertificates.js +130 -0
- package/lib/admin/AccountRoutes.d.ts +8 -0
- package/lib/admin/AccountRoutes.js +502 -0
- package/lib/admin/AdminAccounts.d.ts +329 -0
- package/lib/admin/AdminAccounts.js +607 -0
- package/lib/admin/AdminApiRouter.d.ts +202 -0
- package/lib/admin/AdminApiRouter.js +465 -0
- package/lib/admin/AdminApiTypes.d.ts +1782 -0
- package/lib/admin/AdminApiTypes.js +4 -0
- package/lib/admin/AdminAudit.d.ts +113 -0
- package/lib/admin/AdminAudit.js +266 -0
- package/lib/admin/AdminCli.d.ts +7 -0
- package/lib/admin/AdminCli.js +351 -0
- package/lib/admin/AdminClock.d.ts +51 -0
- package/lib/admin/AdminClock.js +10 -0
- package/lib/admin/AdminConsole.d.ts +39 -0
- package/lib/admin/AdminConsole.js +325 -0
- package/lib/admin/AdminConstants.d.ts +147 -0
- package/lib/admin/AdminConstants.js +167 -0
- package/lib/admin/AdminCrypto.d.ts +19 -0
- package/lib/admin/AdminCrypto.js +29 -0
- package/lib/admin/AdminExtensionsHost.d.ts +176 -0
- package/lib/admin/AdminExtensionsHost.js +307 -0
- package/lib/admin/AdminPasswords.d.ts +33 -0
- package/lib/admin/AdminPasswords.js +92 -0
- package/lib/admin/AdminPermissions.d.ts +28 -0
- package/lib/admin/AdminPermissions.js +74 -0
- package/lib/admin/AdminRecords.d.ts +196 -0
- package/lib/admin/AdminRecords.js +3 -0
- package/lib/admin/AdminRequestBodies.d.ts +47 -0
- package/lib/admin/AdminRequestBodies.js +84 -0
- package/lib/admin/AdminRequestGate.d.ts +38 -0
- package/lib/admin/AdminRequestGate.js +59 -0
- package/lib/admin/AdminResponses.d.ts +103 -0
- package/lib/admin/AdminResponses.js +164 -0
- package/lib/admin/AdminServices.d.ts +99 -0
- package/lib/admin/AdminServices.js +4 -0
- package/lib/admin/AdminStaticFiles.d.ts +29 -0
- package/lib/admin/AdminStaticFiles.js +97 -0
- package/lib/admin/AdminStore.d.ts +152 -0
- package/lib/admin/AdminStore.js +46 -0
- package/lib/admin/AlertChannels.d.ts +43 -0
- package/lib/admin/AlertChannels.js +120 -0
- package/lib/admin/AlertEngine.d.ts +231 -0
- package/lib/admin/AlertEngine.js +681 -0
- package/lib/admin/AlertRoutes.d.ts +8 -0
- package/lib/admin/AlertRoutes.js +180 -0
- package/lib/admin/ApiKeyRoutes.d.ts +8 -0
- package/lib/admin/ApiKeyRoutes.js +53 -0
- package/lib/admin/ApiKeys.d.ts +63 -0
- package/lib/admin/ApiKeys.js +167 -0
- package/lib/admin/AuditRoutes.d.ts +8 -0
- package/lib/admin/AuditRoutes.js +106 -0
- package/lib/admin/AuthToolRoutes.d.ts +13 -0
- package/lib/admin/AuthToolRoutes.js +164 -0
- package/lib/admin/BanList.d.ts +87 -0
- package/lib/admin/BanList.js +152 -0
- package/lib/admin/CborDecoder.d.ts +18 -0
- package/lib/admin/CborDecoder.js +201 -0
- package/lib/admin/ColyseusCloudClient.d.ts +313 -0
- package/lib/admin/ColyseusCloudClient.js +272 -0
- package/lib/admin/ColyseusCloudRoutes.d.ts +8 -0
- package/lib/admin/ColyseusCloudRoutes.js +91 -0
- package/lib/admin/ColyseusCloudService.d.ts +180 -0
- package/lib/admin/ColyseusCloudService.js +476 -0
- package/lib/admin/ColyseusCloudSettings.d.ts +16 -0
- package/lib/admin/ColyseusCloudSettings.js +29 -0
- package/lib/admin/ColyseusCloudViews.d.ts +105 -0
- package/lib/admin/ColyseusCloudViews.js +371 -0
- package/lib/admin/CommonPasswords.d.ts +6 -0
- package/lib/admin/CommonPasswords.js +19 -0
- package/lib/admin/ConfigOverrides.d.ts +174 -0
- package/lib/admin/ConfigOverrides.js +406 -0
- package/lib/admin/ConfigRoutes.d.ts +8 -0
- package/lib/admin/ConfigRoutes.js +91 -0
- package/lib/admin/ConsoleSessions.d.ts +112 -0
- package/lib/admin/ConsoleSessions.js +201 -0
- package/lib/admin/DedicatedRoutes.d.ts +21 -0
- package/lib/admin/DedicatedRoutes.js +254 -0
- package/lib/admin/ExtensionRoutes.d.ts +8 -0
- package/lib/admin/ExtensionRoutes.js +145 -0
- package/lib/admin/FileAdminStore.d.ts +157 -0
- package/lib/admin/FileAdminStore.js +349 -0
- package/lib/admin/FleetDirectory.d.ts +323 -0
- package/lib/admin/FleetDirectory.js +489 -0
- package/lib/admin/FleetLeader.d.ts +43 -0
- package/lib/admin/FleetLeader.js +89 -0
- package/lib/admin/FleetRoutes.d.ts +55 -0
- package/lib/admin/FleetRoutes.js +465 -0
- package/lib/admin/HealthWarnings.d.ts +10 -0
- package/lib/admin/HealthWarnings.js +95 -0
- package/lib/admin/LeaderboardRoutes.d.ts +8 -0
- package/lib/admin/LeaderboardRoutes.js +569 -0
- package/lib/admin/LogBuffer.d.ts +60 -0
- package/lib/admin/LogBuffer.js +92 -0
- package/lib/admin/LogRedaction.d.ts +15 -0
- package/lib/admin/LogRedaction.js +72 -0
- package/lib/admin/LogRoutes.d.ts +45 -0
- package/lib/admin/LogRoutes.js +307 -0
- package/lib/admin/MaintenanceMode.d.ts +41 -0
- package/lib/admin/MaintenanceMode.js +74 -0
- package/lib/admin/MaintenanceWindows.d.ts +91 -0
- package/lib/admin/MaintenanceWindows.js +207 -0
- package/lib/admin/MetricsRoutes.d.ts +8 -0
- package/lib/admin/MetricsRoutes.js +209 -0
- package/lib/admin/MetricsSampler.d.ts +124 -0
- package/lib/admin/MetricsSampler.js +211 -0
- package/lib/admin/OneTimeCodes.d.ts +60 -0
- package/lib/admin/OneTimeCodes.js +162 -0
- package/lib/admin/OpenIdConnect.d.ts +146 -0
- package/lib/admin/OpenIdConnect.js +335 -0
- package/lib/admin/Passkeys.d.ts +130 -0
- package/lib/admin/Passkeys.js +432 -0
- package/lib/admin/PlayerRoutes.d.ts +20 -0
- package/lib/admin/PlayerRoutes.js +676 -0
- package/lib/admin/RedisAdminStore.d.ts +114 -0
- package/lib/admin/RedisAdminStore.js +258 -0
- package/lib/admin/RolloutCoordinator.d.ts +99 -0
- package/lib/admin/RolloutCoordinator.js +321 -0
- package/lib/admin/ServerEvents.d.ts +57 -0
- package/lib/admin/ServerEvents.js +89 -0
- package/lib/admin/SessionRoutes.d.ts +113 -0
- package/lib/admin/SessionRoutes.js +386 -0
- package/lib/admin/UserRoutes.d.ts +8 -0
- package/lib/admin/UserRoutes.js +214 -0
- package/lib/cli.d.ts +2 -0
- package/lib/cli.js +221 -0
- package/lib/console/assets/index-CI5N7c-T.js +21 -0
- package/lib/console/assets/index-DPitvzLp.css +1 -0
- package/lib/console/favicon.svg +1 -0
- package/lib/console/index.html +16 -0
- package/lib/discord/DiscordOAuth.d.ts +57 -0
- package/lib/discord/DiscordOAuth.js +127 -0
- package/lib/discord/DiscordSessions.d.ts +79 -0
- package/lib/discord/DiscordSessions.js +135 -0
- package/lib/discord/DiscordSignInConstants.d.ts +54 -0
- package/lib/discord/DiscordSignInConstants.js +55 -0
- package/lib/discord/DiscordSignInRoutes.d.ts +51 -0
- package/lib/discord/DiscordSignInRoutes.js +359 -0
- package/lib/index.d.ts +24 -0
- package/lib/index.js +18 -0
- package/lib/leaderboards/FileLeaderboardRankStore.d.ts +293 -0
- package/lib/leaderboards/FileLeaderboardRankStore.js +808 -0
- package/lib/leaderboards/LeaderboardConstants.d.ts +63 -0
- package/lib/leaderboards/LeaderboardConstants.js +70 -0
- package/lib/leaderboards/LeaderboardError.d.ts +15 -0
- package/lib/leaderboards/LeaderboardError.js +20 -0
- package/lib/leaderboards/LeaderboardIdentity.d.ts +44 -0
- package/lib/leaderboards/LeaderboardIdentity.js +72 -0
- package/lib/leaderboards/LeaderboardPublicApi.d.ts +17 -0
- package/lib/leaderboards/LeaderboardPublicApi.js +389 -0
- package/lib/leaderboards/LeaderboardRankStore.d.ts +296 -0
- package/lib/leaderboards/LeaderboardRankStore.js +18 -0
- package/lib/leaderboards/LeaderboardRules.d.ts +122 -0
- package/lib/leaderboards/LeaderboardRules.js +472 -0
- package/lib/leaderboards/LeaderboardScheduler.d.ts +43 -0
- package/lib/leaderboards/LeaderboardScheduler.js +103 -0
- package/lib/leaderboards/LeaderboardService.d.ts +669 -0
- package/lib/leaderboards/LeaderboardService.js +1667 -0
- package/lib/leaderboards/LeaderboardTransfer.d.ts +35 -0
- package/lib/leaderboards/LeaderboardTransfer.js +580 -0
- package/lib/leaderboards/RedisLeaderboardRankStore.d.ts +282 -0
- package/lib/leaderboards/RedisLeaderboardRankStore.js +691 -0
- package/package.json +64 -4
- package/templates/init/Caddyfile +6 -0
- package/templates/init/DEPLOY.md +233 -0
- package/templates/init/Dockerfile +12 -0
- package/templates/init/app.json +21 -0
- package/templates/init/aws/README.md +118 -0
- package/templates/init/aws/ecs-fargate-alb.yml +207 -0
- package/templates/init/aws/ecs-fargate-nlb-quic.yml +210 -0
- package/templates/init/babylon-game-server.config.mjs +158 -0
- package/templates/init/colyseus-cloud/README.md +133 -0
- package/templates/init/colyseus-cloud/babylon-game-server.config.mjs +36 -0
- package/templates/init/colyseus-cloud/ecosystem.config.cjs +35 -0
- package/templates/init/colyseus-cloud/gitignore +6 -0
- package/templates/init/colyseus-cloud/package.json +15 -0
- package/templates/init/digitalocean-app.yaml +30 -0
- package/templates/init/docker-compose.full.yml +45 -0
- package/templates/init/docker-compose.websocket.yml +41 -0
- package/templates/init/fly.toml +56 -0
- package/templates/init/heroku.yml +11 -0
- package/templates/init/kubernetes/deployment.yaml +71 -0
- package/templates/init/kubernetes/redis.yaml +73 -0
- package/templates/init/kubernetes/service.yaml +24 -0
- package/templates/init/monitoring/grafana-dashboard.json +539 -0
- package/templates/init/monitoring/prometheus-alerts.yml +51 -0
- package/templates/init/monitoring/prometheus-scrape.yml +17 -0
- package/templates/init/railway.json +13 -0
- package/templates/init/render.yaml +19 -0
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
// The fleet as one instance sees it (game-server-admin-console D16, DR §6.5, §13 R15): each instance publishes a
|
|
2
|
+
// heartbeat to a presence hash, and instances ask each other to run fixed, typed commands over presence topics. Without
|
|
3
|
+
// Redis the presence is local, so the fleet is this instance alone and every command runs in-process.
|
|
4
|
+
import { matchMaker } from "colyseus";
|
|
5
|
+
import { randomUUID, X509Certificate } from "node:crypto";
|
|
6
|
+
import { hostname } from "node:os";
|
|
7
|
+
import { monitorEventLoopDelay } from "node:perf_hooks";
|
|
8
|
+
import { getLogger } from "../GameServerLogger.js";
|
|
9
|
+
import { getToolkitRuntime } from "../HeadlessRuntime.js";
|
|
10
|
+
import { readinessReasons, SERVER_VERSION } from "../ServerEndpoints.js";
|
|
11
|
+
import { AdminRequestError } from "./AdminApiRouter.js";
|
|
12
|
+
import { ADMIN_CHANGED_TOPIC, EVENT_LOOP_MONITOR_RESOLUTION_MS, FLEET_COMMAND_TIMEOUT_MS, FLEET_COMMAND_TOPIC_PREFIX, FLEET_FORGET_AFTER_MS, FLEET_INSTANCES_KEY, FLEET_LOST_AFTER_MS, FLEET_REPLY_TOPIC_PREFIX } from "./AdminConstants.js";
|
|
13
|
+
/** HTTP status of an unknown or lost instance. */
|
|
14
|
+
const HTTP_NOT_FOUND = 404;
|
|
15
|
+
/** HTTP status of a command no handler is registered for. */
|
|
16
|
+
const HTTP_NOT_IMPLEMENTED = 501;
|
|
17
|
+
/** HTTP status of a command that failed on another instance without a status of its own. */
|
|
18
|
+
const HTTP_BAD_GATEWAY = 502;
|
|
19
|
+
/** HTTP status of a command that got no reply in time. */
|
|
20
|
+
const HTTP_GATEWAY_TIMEOUT = 504;
|
|
21
|
+
/** Least time between two "heartbeat failed" warnings, in milliseconds (DR §9: once per minute). */
|
|
22
|
+
const HEARTBEAT_WARNING_INTERVAL_MS = 60000;
|
|
23
|
+
/** Milliseconds per day, for certificate expiry. */
|
|
24
|
+
const MILLISECONDS_PER_DAY = 86400000;
|
|
25
|
+
/** Nanoseconds per millisecond (the event-loop histogram reports nanoseconds). */
|
|
26
|
+
const NANOSECONDS_PER_MILLISECOND = 1e6;
|
|
27
|
+
/** Nanoseconds per microsecond (process.cpuUsage reports microseconds). */
|
|
28
|
+
const NANOSECONDS_PER_MICROSECOND = 1000n;
|
|
29
|
+
/** Percent of one fully used CPU core. */
|
|
30
|
+
const FULL_CORE_PERCENT = 100;
|
|
31
|
+
/** Heartbeats, the command bus and change announcements of this instance. */
|
|
32
|
+
export class FleetDirectory {
|
|
33
|
+
/** The shared core services. */
|
|
34
|
+
services;
|
|
35
|
+
/** Command handlers by name (each name once). */
|
|
36
|
+
handlers = new Map();
|
|
37
|
+
/** Listeners of change announcements. */
|
|
38
|
+
changeListeners = [];
|
|
39
|
+
/** The event-loop delay histogram, while started. */
|
|
40
|
+
eventLoopMonitor = null;
|
|
41
|
+
/** The heartbeat timer, while started. */
|
|
42
|
+
heartbeatTimer = null;
|
|
43
|
+
/** True while a heartbeat is being written, so a slow store never stacks them. */
|
|
44
|
+
isWritingHeartbeat = false;
|
|
45
|
+
/** The newest heartbeat this instance wrote, or null. */
|
|
46
|
+
lastHeartbeat = null;
|
|
47
|
+
/** The heartbeat the counter rates are measured from, or null. */
|
|
48
|
+
rateBaseHeartbeat = null;
|
|
49
|
+
/** The counter snapshot of the newest heartbeat (the metrics cache hands the same object until it renews). */
|
|
50
|
+
lastSnapshot = null;
|
|
51
|
+
/** CPU time used up to the last heartbeat. */
|
|
52
|
+
lastCpuUsage = process.cpuUsage();
|
|
53
|
+
/** High-resolution time of the last CPU sample, in nanoseconds. */
|
|
54
|
+
lastCpuSampleAt = process.hrtime.bigint();
|
|
55
|
+
/** Real time of the last "heartbeat failed" warning, in milliseconds. */
|
|
56
|
+
lastHeartbeatWarningAt = 0;
|
|
57
|
+
/** The subscribed command listener, while started. */
|
|
58
|
+
commandListener = (command) => void this.receiveCommand(command);
|
|
59
|
+
/** The subscribed change listener, while started. */
|
|
60
|
+
changeListener = (announcement) => this.notifyChange(announcement.collection);
|
|
61
|
+
/**
|
|
62
|
+
* Prepares the directory; nothing is published until start().
|
|
63
|
+
* @param services The shared core services.
|
|
64
|
+
*/
|
|
65
|
+
constructor(services) {
|
|
66
|
+
this.services = services;
|
|
67
|
+
}
|
|
68
|
+
/** This instance's process id (matchMaker.processId, R5). */
|
|
69
|
+
get localProcessId() {
|
|
70
|
+
return this.services.processId();
|
|
71
|
+
}
|
|
72
|
+
/** True when Redis connects the fleet; false for a single instance. */
|
|
73
|
+
get isScaledOut() {
|
|
74
|
+
return this.services.config.redis.url != null;
|
|
75
|
+
}
|
|
76
|
+
/** Starts heartbeats, command handling and change announcements; the first heartbeat is written at once. */
|
|
77
|
+
async start() {
|
|
78
|
+
this.eventLoopMonitor = monitorEventLoopDelay({ resolution: EVENT_LOOP_MONITOR_RESOLUTION_MS });
|
|
79
|
+
this.eventLoopMonitor.enable();
|
|
80
|
+
await this.presence().subscribe(FLEET_COMMAND_TOPIC_PREFIX + this.localProcessId, this.commandListener);
|
|
81
|
+
await this.presence().subscribe(ADMIN_CHANGED_TOPIC, this.changeListener);
|
|
82
|
+
await this.writeHeartbeat();
|
|
83
|
+
this.heartbeatTimer = this.services.clock.setInterval(() => void this.writeHeartbeat(), this.services.heartbeatIntervalMs);
|
|
84
|
+
}
|
|
85
|
+
/** Stops heartbeats and command handling and removes this instance's heartbeat (R4). */
|
|
86
|
+
async stop() {
|
|
87
|
+
if (this.heartbeatTimer != null)
|
|
88
|
+
this.services.clock.clearInterval(this.heartbeatTimer);
|
|
89
|
+
this.heartbeatTimer = null;
|
|
90
|
+
this.eventLoopMonitor?.disable();
|
|
91
|
+
this.eventLoopMonitor = null;
|
|
92
|
+
try {
|
|
93
|
+
this.presence().unsubscribe(FLEET_COMMAND_TOPIC_PREFIX + this.localProcessId, this.commandListener);
|
|
94
|
+
this.presence().unsubscribe(ADMIN_CHANGED_TOPIC, this.changeListener);
|
|
95
|
+
await this.presence().hdel(FLEET_INSTANCES_KEY, this.localProcessId);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
getLogger().warn("the fleet heartbeat could not be removed", { error: String(error) });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Every known instance with its status, newest heartbeat first.
|
|
103
|
+
* @returns The instance rows.
|
|
104
|
+
*/
|
|
105
|
+
async listInstances() {
|
|
106
|
+
const records = await this.listInstanceRecords();
|
|
107
|
+
return records.map((record) => record.view);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Every known instance with the heartbeat its rates are measured from, newest heartbeat first. Instances silent
|
|
111
|
+
* for FLEET_LOST_AFTER_MS are "lost"; those silent for FLEET_FORGET_AFTER_MS are removed from the hash.
|
|
112
|
+
* @returns The records.
|
|
113
|
+
*/
|
|
114
|
+
async listInstanceRecords() {
|
|
115
|
+
const entries = await this.presence().hgetall(FLEET_INSTANCES_KEY);
|
|
116
|
+
const now = this.services.clock.now();
|
|
117
|
+
const records = [];
|
|
118
|
+
for (const [processId, text] of Object.entries(entries ?? {})) {
|
|
119
|
+
const stored = parseStoredHeartbeat(text);
|
|
120
|
+
if (stored == null)
|
|
121
|
+
continue;
|
|
122
|
+
const silentFor = now - Date.parse(stored.heartbeat.sentAt);
|
|
123
|
+
if (silentFor > FLEET_FORGET_AFTER_MS) {
|
|
124
|
+
void this.forgetInstance(processId);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const status = (silentFor > FLEET_LOST_AFTER_MS) ? "lost" : "up";
|
|
128
|
+
const view = Object.assign({}, stored.heartbeat, { status: status, isThisInstance: processId === this.localProcessId });
|
|
129
|
+
records.push({ view: view, previous: stored.previous });
|
|
130
|
+
}
|
|
131
|
+
records.sort((left, right) => right.view.sentAt.localeCompare(left.view.sentAt));
|
|
132
|
+
return this.withoutRestartedInstances(records);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Drops (and forgets) each lost instance whose public address a newer live instance now serves: that instance was
|
|
136
|
+
* restarted in its place, so it is no longer lost (T14: restarting a lost instance resolves its alert).
|
|
137
|
+
* @param records The instances, newest heartbeat first.
|
|
138
|
+
* @returns The records without the restarted instances.
|
|
139
|
+
*/
|
|
140
|
+
withoutRestartedInstances(records) {
|
|
141
|
+
const upRecords = records.filter((record) => record.view.status === "up");
|
|
142
|
+
const isRestarted = (record) => record.view.status === "lost" && record.view.publicAddress != null
|
|
143
|
+
&& upRecords.some((upRecord) => upRecord.view.publicAddress === record.view.publicAddress && upRecord.view.startedAt > record.view.startedAt);
|
|
144
|
+
for (const record of records) {
|
|
145
|
+
if (isRestarted(record))
|
|
146
|
+
void this.forgetInstance(record.view.processId);
|
|
147
|
+
}
|
|
148
|
+
return records.filter((record) => !isRestarted(record));
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Runs a command on one instance: in-process when it is this one, else over the bus.
|
|
152
|
+
* @param processId The instance.
|
|
153
|
+
* @param name The command.
|
|
154
|
+
* @param commandArguments Its arguments.
|
|
155
|
+
* @returns The command's result.
|
|
156
|
+
* @throws AdminRequestError 404 for an unknown or lost instance, 504 when no reply came in time, or the status the
|
|
157
|
+
* handler refused with.
|
|
158
|
+
*/
|
|
159
|
+
async runOn(processId, name, commandArguments) {
|
|
160
|
+
if (processId === this.localProcessId)
|
|
161
|
+
return this.runLocally(name, commandArguments);
|
|
162
|
+
const instances = await this.listInstances();
|
|
163
|
+
const target = instances.find((instance) => instance.processId === processId);
|
|
164
|
+
if (target == null || target.status === "lost")
|
|
165
|
+
throw new AdminRequestError(HTTP_NOT_FOUND, "no such instance");
|
|
166
|
+
return this.sendCommand(processId, name, commandArguments);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Runs a command on every live instance (this one always included).
|
|
170
|
+
* @param name The command.
|
|
171
|
+
* @param commandArguments Its arguments.
|
|
172
|
+
* @returns Each instance's result, and the instances that failed.
|
|
173
|
+
*/
|
|
174
|
+
async runOnAll(name, commandArguments) {
|
|
175
|
+
const instances = await this.listInstances();
|
|
176
|
+
const processIds = new Set([this.localProcessId]);
|
|
177
|
+
for (const instance of instances)
|
|
178
|
+
if (instance.status === "up")
|
|
179
|
+
processIds.add(instance.processId);
|
|
180
|
+
const outcome = { results: [], failures: [] };
|
|
181
|
+
await Promise.all(Array.from(processIds).map(async (processId) => {
|
|
182
|
+
try {
|
|
183
|
+
const result = await this.runOn(processId, name, commandArguments);
|
|
184
|
+
outcome.results.push({ processId: processId, result: result });
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
outcome.failures.push({ processId: processId, error: errorMessageOf(error) });
|
|
188
|
+
}
|
|
189
|
+
}));
|
|
190
|
+
return outcome;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Registers this instance's handler of a command.
|
|
194
|
+
* @param name The command.
|
|
195
|
+
* @param handler Runs it; its arguments and result are copies (structured clones).
|
|
196
|
+
* @throws Error when the command already has a handler.
|
|
197
|
+
*/
|
|
198
|
+
handle(name, handler) {
|
|
199
|
+
if (this.handlers.has(name))
|
|
200
|
+
throw new Error("the fleet command " + name + " already has a handler");
|
|
201
|
+
this.handlers.set(name, handler);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Publishes that a store collection changed, so every instance (this one included) reloads it.
|
|
205
|
+
* @param collection The collection.
|
|
206
|
+
*/
|
|
207
|
+
async announceChange(collection) {
|
|
208
|
+
const announcement = { collection: collection };
|
|
209
|
+
await this.presence().publish(ADMIN_CHANGED_TOPIC, announcement);
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Calls back on every announced change (any instance, this one included).
|
|
213
|
+
* @param listener The listener.
|
|
214
|
+
*/
|
|
215
|
+
onChange(listener) {
|
|
216
|
+
this.changeListeners.push(listener);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The presence every instance shares (Redis when scaled out). Colyseus sets it when the server listens.
|
|
220
|
+
* @returns The presence.
|
|
221
|
+
*/
|
|
222
|
+
presence() {
|
|
223
|
+
return matchMaker.presence;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Runs a registered handler on this instance with copies of the arguments and the result, since local presence
|
|
227
|
+
* hands objects over by reference.
|
|
228
|
+
* @param name The command.
|
|
229
|
+
* @param commandArguments Its arguments.
|
|
230
|
+
* @returns A copy of the result.
|
|
231
|
+
* @throws AdminRequestError 501 when no handler is registered.
|
|
232
|
+
*/
|
|
233
|
+
async runLocally(name, commandArguments) {
|
|
234
|
+
const handler = this.handlers.get(name);
|
|
235
|
+
if (handler == null)
|
|
236
|
+
throw new AdminRequestError(HTTP_NOT_IMPLEMENTED, "this instance cannot run " + name);
|
|
237
|
+
const result = await handler(structuredClone(commandArguments));
|
|
238
|
+
return structuredClone(result ?? null);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Sends a command to another instance and waits for its reply on a per-command topic.
|
|
242
|
+
* @param processId The instance.
|
|
243
|
+
* @param name The command.
|
|
244
|
+
* @param commandArguments Its arguments.
|
|
245
|
+
* @returns The result.
|
|
246
|
+
* @throws AdminRequestError on a failed reply or after FLEET_COMMAND_TIMEOUT_MS.
|
|
247
|
+
*/
|
|
248
|
+
async sendCommand(processId, name, commandArguments) {
|
|
249
|
+
const commandId = randomUUID();
|
|
250
|
+
const command = { commandId: commandId, name: name, arguments: commandArguments, replyTopic: FLEET_REPLY_TOPIC_PREFIX + commandId, fromProcessId: this.localProcessId };
|
|
251
|
+
const reply = await this.awaitReply(command, processId);
|
|
252
|
+
if (reply.ok)
|
|
253
|
+
return reply.result;
|
|
254
|
+
throw new AdminRequestError(reply.status ?? HTTP_BAD_GATEWAY, reply.error ?? "the instance could not run " + name);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Subscribes to a command's reply topic, publishes the command and resolves with the reply.
|
|
258
|
+
* @param command The command.
|
|
259
|
+
* @param processId The instance it goes to.
|
|
260
|
+
* @returns The reply.
|
|
261
|
+
* @throws AdminRequestError 504 after FLEET_COMMAND_TIMEOUT_MS.
|
|
262
|
+
*/
|
|
263
|
+
awaitReply(command, processId) {
|
|
264
|
+
return new Promise((resolve, reject) => {
|
|
265
|
+
const onReply = (reply) => {
|
|
266
|
+
finish();
|
|
267
|
+
resolve(reply);
|
|
268
|
+
};
|
|
269
|
+
const finish = () => {
|
|
270
|
+
this.services.clock.clearTimeout(timeout);
|
|
271
|
+
this.presence().unsubscribe(command.replyTopic, onReply);
|
|
272
|
+
};
|
|
273
|
+
const timeout = this.services.clock.setTimeout(() => {
|
|
274
|
+
finish();
|
|
275
|
+
reject(new AdminRequestError(HTTP_GATEWAY_TIMEOUT, "the instance " + processId + " did not answer"));
|
|
276
|
+
}, FLEET_COMMAND_TIMEOUT_MS);
|
|
277
|
+
const publish = async () => {
|
|
278
|
+
await this.presence().subscribe(command.replyTopic, onReply);
|
|
279
|
+
await this.presence().publish(FLEET_COMMAND_TOPIC_PREFIX + processId, command);
|
|
280
|
+
};
|
|
281
|
+
publish().catch((error) => {
|
|
282
|
+
finish();
|
|
283
|
+
reject(error);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Runs a command another instance sent and publishes the reply.
|
|
289
|
+
* @param command The command.
|
|
290
|
+
*/
|
|
291
|
+
async receiveCommand(command) {
|
|
292
|
+
const reply = await this.answerCommand(command);
|
|
293
|
+
try {
|
|
294
|
+
await this.presence().publish(command.replyTopic, reply);
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
getLogger().warn("a fleet command reply could not be sent", { command: command.name, error: String(error) });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Runs a received command and turns its outcome into a reply.
|
|
302
|
+
* @param command The command.
|
|
303
|
+
* @returns The reply.
|
|
304
|
+
*/
|
|
305
|
+
async answerCommand(command) {
|
|
306
|
+
const reply = { commandId: command.commandId, processId: this.localProcessId, ok: true, result: null, error: null, status: null };
|
|
307
|
+
try {
|
|
308
|
+
reply.result = await this.runLocally(command.name, command.arguments);
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
reply.ok = false;
|
|
312
|
+
reply.error = errorMessageOf(error);
|
|
313
|
+
reply.status = (error instanceof AdminRequestError) ? error.status : null;
|
|
314
|
+
}
|
|
315
|
+
return reply;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Calls every change listener; a listener that throws is logged and the others still run.
|
|
319
|
+
* @param collection The changed collection.
|
|
320
|
+
*/
|
|
321
|
+
notifyChange(collection) {
|
|
322
|
+
for (const listener of this.changeListeners) {
|
|
323
|
+
try {
|
|
324
|
+
listener(collection);
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
getLogger().error("an admin change listener failed", { collection: collection, error: String(error) });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Removes a long-lost instance from the heartbeat hash.
|
|
333
|
+
* @param processId The instance.
|
|
334
|
+
*/
|
|
335
|
+
async forgetInstance(processId) {
|
|
336
|
+
try {
|
|
337
|
+
await this.presence().hdel(FLEET_INSTANCES_KEY, processId);
|
|
338
|
+
}
|
|
339
|
+
catch (error) {
|
|
340
|
+
getLogger().warn("a lost instance could not be forgotten", { processId: processId, error: String(error) });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/** Builds and writes this instance's heartbeat; a failure is logged at warn once per minute (DR §9). */
|
|
344
|
+
async writeHeartbeat() {
|
|
345
|
+
if (this.isWritingHeartbeat)
|
|
346
|
+
return;
|
|
347
|
+
this.isWritingHeartbeat = true;
|
|
348
|
+
try {
|
|
349
|
+
const stored = await this.nextStoredHeartbeat();
|
|
350
|
+
await this.presence().hset(FLEET_INSTANCES_KEY, this.localProcessId, JSON.stringify(stored));
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
this.warnHeartbeatFailure(error);
|
|
354
|
+
}
|
|
355
|
+
finally {
|
|
356
|
+
this.isWritingHeartbeat = false;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Builds the next heartbeat entry, moving the rate base forward whenever the counters were renewed.
|
|
361
|
+
* @returns The entry to store.
|
|
362
|
+
*/
|
|
363
|
+
async nextStoredHeartbeat() {
|
|
364
|
+
const metrics = this.services.routeContext.metrics();
|
|
365
|
+
const snapshot = (metrics != null) ? await metrics.snapshot() : null;
|
|
366
|
+
const heartbeat = await this.buildHeartbeat(snapshot);
|
|
367
|
+
if (snapshot !== this.lastSnapshot)
|
|
368
|
+
this.rateBaseHeartbeat = this.lastHeartbeat;
|
|
369
|
+
this.lastSnapshot = snapshot;
|
|
370
|
+
this.lastHeartbeat = heartbeat;
|
|
371
|
+
return { heartbeat: heartbeat, previous: this.rateBaseHeartbeat };
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* This instance's heartbeat now (DR §6.5).
|
|
375
|
+
* @param snapshot The metrics counters, or null when metrics are off (the counters are then 0).
|
|
376
|
+
* @returns The heartbeat.
|
|
377
|
+
*/
|
|
378
|
+
async buildHeartbeat(snapshot) {
|
|
379
|
+
const state = this.services.state;
|
|
380
|
+
const reasons = await readinessReasons(this.services.routeContext);
|
|
381
|
+
const rooms = Array.from(state.sessionRooms);
|
|
382
|
+
const memory = process.memoryUsage();
|
|
383
|
+
return {
|
|
384
|
+
processId: this.localProcessId,
|
|
385
|
+
publicAddress: state.config.publicAddress,
|
|
386
|
+
hostName: hostname(),
|
|
387
|
+
instanceLabel: state.config.admin.instanceLabel,
|
|
388
|
+
serverVersion: SERVER_VERSION,
|
|
389
|
+
protocolVersion: getToolkitRuntime().TOOLKIT.NETWORK_PROTOCOL_VERSION,
|
|
390
|
+
startedAt: state.startedAt,
|
|
391
|
+
sentAt: new Date(this.services.clock.now()).toISOString(),
|
|
392
|
+
ready: reasons.length === 0,
|
|
393
|
+
readinessReasons: reasons,
|
|
394
|
+
draining: state.draining,
|
|
395
|
+
drainDeadline: this.services.routeContext.drainDeadline(),
|
|
396
|
+
maintenance: state.maintenance,
|
|
397
|
+
players: rooms.reduce((total, room) => total + room.playerCount, 0),
|
|
398
|
+
sessions: rooms.length,
|
|
399
|
+
dedicatedSessions: rooms.filter((room) => room.isDedicated).length,
|
|
400
|
+
cpuPercent: this.sampleCpuPercent(),
|
|
401
|
+
residentMemoryBytes: memory.rss,
|
|
402
|
+
heapUsedBytes: memory.heapUsed,
|
|
403
|
+
eventLoopLagMilliseconds: this.sampleEventLoopLag(),
|
|
404
|
+
certificateDaysLeft: certificateDaysLeft(state.tlsMaterial, this.services.clock.now()),
|
|
405
|
+
logLevel: getLogger().currentLevel,
|
|
406
|
+
joinsAccepted: snapshot?.joinsAccepted ?? 0,
|
|
407
|
+
joinsRefused: snapshot?.joinsRefused ?? 0,
|
|
408
|
+
refusalsByReason: Object.assign({}, snapshot?.refusalsByReason ?? {}),
|
|
409
|
+
authFailures: snapshot?.authFailures ?? 0,
|
|
410
|
+
framesWebsocket: snapshot?.framesByTransport.websocket ?? 0,
|
|
411
|
+
framesWebtransport: snapshot?.framesByTransport.webtransport ?? 0
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* CPU use since the last heartbeat, as a percent of one core (user plus system time over elapsed time).
|
|
416
|
+
* @returns The percent.
|
|
417
|
+
*/
|
|
418
|
+
sampleCpuPercent() {
|
|
419
|
+
const now = process.hrtime.bigint();
|
|
420
|
+
const used = process.cpuUsage(this.lastCpuUsage);
|
|
421
|
+
const elapsedMicroseconds = Number((now - this.lastCpuSampleAt) / NANOSECONDS_PER_MICROSECOND);
|
|
422
|
+
this.lastCpuUsage = process.cpuUsage();
|
|
423
|
+
this.lastCpuSampleAt = now;
|
|
424
|
+
if (elapsedMicroseconds <= 0)
|
|
425
|
+
return 0;
|
|
426
|
+
return ((used.user + used.system) / elapsedMicroseconds) * FULL_CORE_PERCENT;
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* The mean event-loop delay since the last heartbeat; the histogram restarts afterwards.
|
|
430
|
+
* @returns The delay, in milliseconds (0 before any sample).
|
|
431
|
+
*/
|
|
432
|
+
sampleEventLoopLag() {
|
|
433
|
+
if (this.eventLoopMonitor == null)
|
|
434
|
+
return 0;
|
|
435
|
+
const meanNanoseconds = this.eventLoopMonitor.mean;
|
|
436
|
+
this.eventLoopMonitor.reset();
|
|
437
|
+
return Number.isFinite(meanNanoseconds) ? meanNanoseconds / NANOSECONDS_PER_MILLISECOND : 0;
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Logs a failed heartbeat at warn, at most once per HEARTBEAT_WARNING_INTERVAL_MS.
|
|
441
|
+
* @param error What failed.
|
|
442
|
+
*/
|
|
443
|
+
warnHeartbeatFailure(error) {
|
|
444
|
+
const now = Date.now();
|
|
445
|
+
if (now - this.lastHeartbeatWarningAt < HEARTBEAT_WARNING_INTERVAL_MS)
|
|
446
|
+
return;
|
|
447
|
+
this.lastHeartbeatWarningAt = now;
|
|
448
|
+
getLogger().warn("the fleet heartbeat failed", { error: String(error) });
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Days until the served certificate expires (DR §8), rounded down.
|
|
453
|
+
* @param material The TLS material served now, or null without TLS.
|
|
454
|
+
* @param nowMilliseconds The current time.
|
|
455
|
+
* @returns The days left (negative once expired), or null without TLS or for an unreadable certificate.
|
|
456
|
+
*/
|
|
457
|
+
export function certificateDaysLeft(material, nowMilliseconds) {
|
|
458
|
+
if (material == null)
|
|
459
|
+
return null;
|
|
460
|
+
try {
|
|
461
|
+
const validTo = new Date(new X509Certificate(material.cert).validTo).getTime();
|
|
462
|
+
return Math.floor((validTo - nowMilliseconds) / MILLISECONDS_PER_DAY);
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Parses one heartbeat hash entry.
|
|
470
|
+
* @param text The stored JSON.
|
|
471
|
+
* @returns The entry, or null when it is not a heartbeat entry.
|
|
472
|
+
*/
|
|
473
|
+
function parseStoredHeartbeat(text) {
|
|
474
|
+
try {
|
|
475
|
+
const parsed = JSON.parse(text);
|
|
476
|
+
return (parsed != null && parsed.heartbeat != null && typeof parsed.heartbeat.sentAt === "string") ? parsed : null;
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* The readable message of an error.
|
|
484
|
+
* @param error What was thrown.
|
|
485
|
+
* @returns Its message.
|
|
486
|
+
*/
|
|
487
|
+
function errorMessageOf(error) {
|
|
488
|
+
return (error instanceof Error) ? error.message : String(error);
|
|
489
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { AdminCoreServices } from "./AdminServices.js";
|
|
2
|
+
/** One piece of leader work, run on every leader tick. */
|
|
3
|
+
export type LeaderTickHandler = () => Promise<void>;
|
|
4
|
+
/** Takes and renews the leader lease and runs the tick handlers while it holds it. */
|
|
5
|
+
export declare class FleetLeader {
|
|
6
|
+
/** The shared core services. */
|
|
7
|
+
private readonly services;
|
|
8
|
+
/** The work run on every tick, in registration order. */
|
|
9
|
+
private readonly tickHandlers;
|
|
10
|
+
/** True while this instance holds the lease. */
|
|
11
|
+
private holdsLease;
|
|
12
|
+
/** The lease renewal timer, while started. */
|
|
13
|
+
private renewTimer;
|
|
14
|
+
/** The tick timer, while started. */
|
|
15
|
+
private tickTimer;
|
|
16
|
+
/** True while a tick runs, so a slow tick never overlaps the next one. */
|
|
17
|
+
private isTicking;
|
|
18
|
+
/**
|
|
19
|
+
* Prepares the leader; nothing runs until start().
|
|
20
|
+
* @param services The shared core services.
|
|
21
|
+
*/
|
|
22
|
+
constructor(services: AdminCoreServices);
|
|
23
|
+
/** True while this instance holds the leader lease. */
|
|
24
|
+
get isLeader(): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Adds work to every leader tick. Handlers run one after another; a failing handler is logged and the others run.
|
|
27
|
+
* @param handler The work.
|
|
28
|
+
*/
|
|
29
|
+
addTickHandler(handler: LeaderTickHandler): void;
|
|
30
|
+
/** Takes the lease if it is free, then renews it every LEADER_RENEW_INTERVAL_MS and ticks every leaderTickMs. */
|
|
31
|
+
start(): Promise<void>;
|
|
32
|
+
/** Stops renewing and ticking; the lease runs out by itself after LEADER_LEASE_TTL_MS. */
|
|
33
|
+
stop(): void;
|
|
34
|
+
/** Runs every tick handler once, when this instance is the leader and no tick is running. */
|
|
35
|
+
tick(): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* Runs one tick handler; a throw is logged.
|
|
38
|
+
* @param handler The handler.
|
|
39
|
+
*/
|
|
40
|
+
private runHandler;
|
|
41
|
+
/** Takes or renews the lease; a store failure counts as not holding it. */
|
|
42
|
+
private renewLease;
|
|
43
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// The fleet leader (game-server-admin-console D24): the instance holding the store lease "fleet-leader" evaluates
|
|
2
|
+
// alert rules, runs maintenance windows and drives rolling updates. Without Redis the file store grants the lease to
|
|
3
|
+
// its one instance, so a single server is always the leader.
|
|
4
|
+
import { getLogger } from "../GameServerLogger.js";
|
|
5
|
+
import { LEADER_LEASE_NAME, LEADER_LEASE_TTL_MS, LEADER_RENEW_INTERVAL_MS } from "./AdminConstants.js";
|
|
6
|
+
/** Takes and renews the leader lease and runs the tick handlers while it holds it. */
|
|
7
|
+
export class FleetLeader {
|
|
8
|
+
/** The shared core services. */
|
|
9
|
+
services;
|
|
10
|
+
/** The work run on every tick, in registration order. */
|
|
11
|
+
tickHandlers = [];
|
|
12
|
+
/** True while this instance holds the lease. */
|
|
13
|
+
holdsLease = false;
|
|
14
|
+
/** The lease renewal timer, while started. */
|
|
15
|
+
renewTimer = null;
|
|
16
|
+
/** The tick timer, while started. */
|
|
17
|
+
tickTimer = null;
|
|
18
|
+
/** True while a tick runs, so a slow tick never overlaps the next one. */
|
|
19
|
+
isTicking = false;
|
|
20
|
+
/**
|
|
21
|
+
* Prepares the leader; nothing runs until start().
|
|
22
|
+
* @param services The shared core services.
|
|
23
|
+
*/
|
|
24
|
+
constructor(services) {
|
|
25
|
+
this.services = services;
|
|
26
|
+
}
|
|
27
|
+
/** True while this instance holds the leader lease. */
|
|
28
|
+
get isLeader() {
|
|
29
|
+
return this.holdsLease;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Adds work to every leader tick. Handlers run one after another; a failing handler is logged and the others run.
|
|
33
|
+
* @param handler The work.
|
|
34
|
+
*/
|
|
35
|
+
addTickHandler(handler) {
|
|
36
|
+
this.tickHandlers.push(handler);
|
|
37
|
+
}
|
|
38
|
+
/** Takes the lease if it is free, then renews it every LEADER_RENEW_INTERVAL_MS and ticks every leaderTickMs. */
|
|
39
|
+
async start() {
|
|
40
|
+
await this.renewLease();
|
|
41
|
+
this.renewTimer = this.services.clock.setInterval(() => void this.renewLease(), LEADER_RENEW_INTERVAL_MS);
|
|
42
|
+
this.tickTimer = this.services.clock.setInterval(() => void this.tick(), this.services.leaderTickMs);
|
|
43
|
+
}
|
|
44
|
+
/** Stops renewing and ticking; the lease runs out by itself after LEADER_LEASE_TTL_MS. */
|
|
45
|
+
stop() {
|
|
46
|
+
if (this.renewTimer != null)
|
|
47
|
+
this.services.clock.clearInterval(this.renewTimer);
|
|
48
|
+
if (this.tickTimer != null)
|
|
49
|
+
this.services.clock.clearInterval(this.tickTimer);
|
|
50
|
+
this.renewTimer = null;
|
|
51
|
+
this.tickTimer = null;
|
|
52
|
+
this.holdsLease = false;
|
|
53
|
+
}
|
|
54
|
+
/** Runs every tick handler once, when this instance is the leader and no tick is running. */
|
|
55
|
+
async tick() {
|
|
56
|
+
if (!this.holdsLease || this.isTicking)
|
|
57
|
+
return;
|
|
58
|
+
this.isTicking = true;
|
|
59
|
+
try {
|
|
60
|
+
for (const handler of this.tickHandlers)
|
|
61
|
+
await this.runHandler(handler);
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
this.isTicking = false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Runs one tick handler; a throw is logged.
|
|
69
|
+
* @param handler The handler.
|
|
70
|
+
*/
|
|
71
|
+
async runHandler(handler) {
|
|
72
|
+
try {
|
|
73
|
+
await handler();
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
getLogger().warn("a fleet leader task failed", { error: String(error) });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Takes or renews the lease; a store failure counts as not holding it. */
|
|
80
|
+
async renewLease() {
|
|
81
|
+
try {
|
|
82
|
+
this.holdsLease = await this.services.store.tryAcquireLease(LEADER_LEASE_NAME, this.services.processId(), LEADER_LEASE_TTL_MS);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
this.holdsLease = false;
|
|
86
|
+
getLogger().warn("the fleet leader lease could not be renewed", { error: String(error) });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { GameServerStateValues } from "../GameServerState.js";
|
|
2
|
+
import { type AdminApiRouter } from "./AdminApiRouter.js";
|
|
3
|
+
import type { AdminNoticePayload, AdminNoticeSeverity } from "./AdminApiTypes.js";
|
|
4
|
+
import type { AdminCoreServices, AdminServices } from "./AdminServices.js";
|
|
5
|
+
/**
|
|
6
|
+
* Registers the overview, instances/* and maintenance routes.
|
|
7
|
+
* @param router The admin API router.
|
|
8
|
+
* @param services The shared services.
|
|
9
|
+
*/
|
|
10
|
+
export declare function registerFleetRoutes(router: AdminApiRouter, services: AdminServices): void;
|
|
11
|
+
/**
|
|
12
|
+
* Registers this instance's handlers of the setMaintenance, clearMaintenance and drain fleet commands (R15).
|
|
13
|
+
* @param services The shared services.
|
|
14
|
+
*/
|
|
15
|
+
export declare function registerFleetCommands(services: AdminServices): void;
|
|
16
|
+
/**
|
|
17
|
+
* A notice to players (DR §6.9) with a fresh id and the server's time.
|
|
18
|
+
* @param services The shared services (the clock).
|
|
19
|
+
* @param text The text (at most NOTICE_TEXT_MAX_LENGTH characters).
|
|
20
|
+
* @param severity How prominent it is.
|
|
21
|
+
* @param countdownSeconds Seconds until the announced event, or null.
|
|
22
|
+
* @returns The notice.
|
|
23
|
+
*/
|
|
24
|
+
export declare function createNoticePayload(services: Pick<AdminCoreServices, "clock">, text: string, severity: AdminNoticeSeverity, countdownSeconds: number | null): AdminNoticePayload;
|
|
25
|
+
/**
|
|
26
|
+
* Sends a notice to the sessions of this instance.
|
|
27
|
+
* @param state The server state.
|
|
28
|
+
* @param roomId One room, or null for every room of this instance.
|
|
29
|
+
* @param playerId One player of that room, or 0 for everyone.
|
|
30
|
+
* @param notice The notice.
|
|
31
|
+
* @returns How many players were targeted.
|
|
32
|
+
*/
|
|
33
|
+
export declare function sendLocalNotice(state: GameServerStateValues, roomId: string | null, playerId: number, notice: AdminNoticePayload): number;
|
|
34
|
+
/**
|
|
35
|
+
* Refuses a fleet-only action on a single instance (DR §9, R33).
|
|
36
|
+
* @param services The shared services.
|
|
37
|
+
* @throws AdminRequestError 409 without Redis.
|
|
38
|
+
*/
|
|
39
|
+
export declare function requireScaledOut(services: AdminServices): void;
|
|
40
|
+
/**
|
|
41
|
+
* An optional notice or maintenance text, at most NOTICE_TEXT_MAX_LENGTH characters.
|
|
42
|
+
* @param body The body.
|
|
43
|
+
* @param name The field.
|
|
44
|
+
* @returns The text, or null.
|
|
45
|
+
* @throws AdminRequestError 400 when it is not text or too long.
|
|
46
|
+
*/
|
|
47
|
+
export declare function noticeTextField(body: Record<string, unknown>, name: string): string | null;
|
|
48
|
+
/**
|
|
49
|
+
* A number field that may not be negative.
|
|
50
|
+
* @param body The body.
|
|
51
|
+
* @param name The field.
|
|
52
|
+
* @returns The number.
|
|
53
|
+
* @throws AdminRequestError 400 when it is not a number or negative.
|
|
54
|
+
*/
|
|
55
|
+
export declare function nonNegativeNumberField(body: Record<string, unknown>, name: string): number;
|