@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.
Files changed (236) hide show
  1. package/CUSTOM.md +1690 -0
  2. package/DEPLOY.md +2012 -0
  3. package/README.md +1081 -2
  4. package/lib/AbuseLimits.d.ts +64 -0
  5. package/lib/AbuseLimits.js +121 -0
  6. package/lib/Authentication.d.ts +86 -0
  7. package/lib/Authentication.js +272 -0
  8. package/lib/BootSessionOwner.d.ts +86 -0
  9. package/lib/BootSessionOwner.js +116 -0
  10. package/lib/ColyseusLoggerAdapter.d.ts +32 -0
  11. package/lib/ColyseusLoggerAdapter.js +73 -0
  12. package/lib/DedicatedSceneRegistry.d.ts +52 -0
  13. package/lib/DedicatedSceneRegistry.js +74 -0
  14. package/lib/DedicatedSession.d.ts +152 -0
  15. package/lib/DedicatedSession.js +173 -0
  16. package/lib/DualTransport.d.ts +112 -0
  17. package/lib/DualTransport.js +197 -0
  18. package/lib/GameServer.d.ts +64 -0
  19. package/lib/GameServer.js +647 -0
  20. package/lib/GameServerConfig.d.ts +387 -0
  21. package/lib/GameServerConfig.js +1059 -0
  22. package/lib/GameServerLobbyRoom.d.ts +33 -0
  23. package/lib/GameServerLobbyRoom.js +56 -0
  24. package/lib/GameServerLogger.d.ts +111 -0
  25. package/lib/GameServerLogger.js +190 -0
  26. package/lib/GameServerMetrics.d.ts +158 -0
  27. package/lib/GameServerMetrics.js +276 -0
  28. package/lib/GameServerState.d.ts +164 -0
  29. package/lib/GameServerState.js +26 -0
  30. package/lib/HeadlessRuntime.d.ts +316 -0
  31. package/lib/HeadlessRuntime.js +380 -0
  32. package/lib/JoinGate.d.ts +34 -0
  33. package/lib/JoinGate.js +221 -0
  34. package/lib/NetworkAddresses.d.ts +33 -0
  35. package/lib/NetworkAddresses.js +90 -0
  36. package/lib/PlayerTokens.d.ts +17 -0
  37. package/lib/PlayerTokens.js +24 -0
  38. package/lib/RoomServerTransport.d.ts +73 -0
  39. package/lib/RoomServerTransport.js +90 -0
  40. package/lib/ServerEndpoints.d.ts +133 -0
  41. package/lib/ServerEndpoints.js +247 -0
  42. package/lib/ServerInit.d.ts +28 -0
  43. package/lib/ServerInit.js +113 -0
  44. package/lib/SessionRoom.d.ts +391 -0
  45. package/lib/SessionRoom.js +930 -0
  46. package/lib/TlsCertificates.d.ts +35 -0
  47. package/lib/TlsCertificates.js +130 -0
  48. package/lib/admin/AccountRoutes.d.ts +8 -0
  49. package/lib/admin/AccountRoutes.js +502 -0
  50. package/lib/admin/AdminAccounts.d.ts +329 -0
  51. package/lib/admin/AdminAccounts.js +607 -0
  52. package/lib/admin/AdminApiRouter.d.ts +202 -0
  53. package/lib/admin/AdminApiRouter.js +465 -0
  54. package/lib/admin/AdminApiTypes.d.ts +1782 -0
  55. package/lib/admin/AdminApiTypes.js +4 -0
  56. package/lib/admin/AdminAudit.d.ts +113 -0
  57. package/lib/admin/AdminAudit.js +266 -0
  58. package/lib/admin/AdminCli.d.ts +7 -0
  59. package/lib/admin/AdminCli.js +351 -0
  60. package/lib/admin/AdminClock.d.ts +51 -0
  61. package/lib/admin/AdminClock.js +10 -0
  62. package/lib/admin/AdminConsole.d.ts +39 -0
  63. package/lib/admin/AdminConsole.js +325 -0
  64. package/lib/admin/AdminConstants.d.ts +147 -0
  65. package/lib/admin/AdminConstants.js +167 -0
  66. package/lib/admin/AdminCrypto.d.ts +19 -0
  67. package/lib/admin/AdminCrypto.js +29 -0
  68. package/lib/admin/AdminExtensionsHost.d.ts +176 -0
  69. package/lib/admin/AdminExtensionsHost.js +307 -0
  70. package/lib/admin/AdminPasswords.d.ts +33 -0
  71. package/lib/admin/AdminPasswords.js +92 -0
  72. package/lib/admin/AdminPermissions.d.ts +28 -0
  73. package/lib/admin/AdminPermissions.js +74 -0
  74. package/lib/admin/AdminRecords.d.ts +196 -0
  75. package/lib/admin/AdminRecords.js +3 -0
  76. package/lib/admin/AdminRequestBodies.d.ts +47 -0
  77. package/lib/admin/AdminRequestBodies.js +84 -0
  78. package/lib/admin/AdminRequestGate.d.ts +38 -0
  79. package/lib/admin/AdminRequestGate.js +59 -0
  80. package/lib/admin/AdminResponses.d.ts +103 -0
  81. package/lib/admin/AdminResponses.js +164 -0
  82. package/lib/admin/AdminServices.d.ts +99 -0
  83. package/lib/admin/AdminServices.js +4 -0
  84. package/lib/admin/AdminStaticFiles.d.ts +29 -0
  85. package/lib/admin/AdminStaticFiles.js +97 -0
  86. package/lib/admin/AdminStore.d.ts +152 -0
  87. package/lib/admin/AdminStore.js +46 -0
  88. package/lib/admin/AlertChannels.d.ts +43 -0
  89. package/lib/admin/AlertChannels.js +120 -0
  90. package/lib/admin/AlertEngine.d.ts +231 -0
  91. package/lib/admin/AlertEngine.js +681 -0
  92. package/lib/admin/AlertRoutes.d.ts +8 -0
  93. package/lib/admin/AlertRoutes.js +180 -0
  94. package/lib/admin/ApiKeyRoutes.d.ts +8 -0
  95. package/lib/admin/ApiKeyRoutes.js +53 -0
  96. package/lib/admin/ApiKeys.d.ts +63 -0
  97. package/lib/admin/ApiKeys.js +167 -0
  98. package/lib/admin/AuditRoutes.d.ts +8 -0
  99. package/lib/admin/AuditRoutes.js +106 -0
  100. package/lib/admin/AuthToolRoutes.d.ts +13 -0
  101. package/lib/admin/AuthToolRoutes.js +164 -0
  102. package/lib/admin/BanList.d.ts +87 -0
  103. package/lib/admin/BanList.js +152 -0
  104. package/lib/admin/CborDecoder.d.ts +18 -0
  105. package/lib/admin/CborDecoder.js +201 -0
  106. package/lib/admin/ColyseusCloudClient.d.ts +313 -0
  107. package/lib/admin/ColyseusCloudClient.js +272 -0
  108. package/lib/admin/ColyseusCloudRoutes.d.ts +8 -0
  109. package/lib/admin/ColyseusCloudRoutes.js +91 -0
  110. package/lib/admin/ColyseusCloudService.d.ts +180 -0
  111. package/lib/admin/ColyseusCloudService.js +476 -0
  112. package/lib/admin/ColyseusCloudSettings.d.ts +16 -0
  113. package/lib/admin/ColyseusCloudSettings.js +29 -0
  114. package/lib/admin/ColyseusCloudViews.d.ts +105 -0
  115. package/lib/admin/ColyseusCloudViews.js +371 -0
  116. package/lib/admin/CommonPasswords.d.ts +6 -0
  117. package/lib/admin/CommonPasswords.js +19 -0
  118. package/lib/admin/ConfigOverrides.d.ts +174 -0
  119. package/lib/admin/ConfigOverrides.js +406 -0
  120. package/lib/admin/ConfigRoutes.d.ts +8 -0
  121. package/lib/admin/ConfigRoutes.js +91 -0
  122. package/lib/admin/ConsoleSessions.d.ts +112 -0
  123. package/lib/admin/ConsoleSessions.js +201 -0
  124. package/lib/admin/DedicatedRoutes.d.ts +21 -0
  125. package/lib/admin/DedicatedRoutes.js +254 -0
  126. package/lib/admin/ExtensionRoutes.d.ts +8 -0
  127. package/lib/admin/ExtensionRoutes.js +145 -0
  128. package/lib/admin/FileAdminStore.d.ts +157 -0
  129. package/lib/admin/FileAdminStore.js +349 -0
  130. package/lib/admin/FleetDirectory.d.ts +323 -0
  131. package/lib/admin/FleetDirectory.js +489 -0
  132. package/lib/admin/FleetLeader.d.ts +43 -0
  133. package/lib/admin/FleetLeader.js +89 -0
  134. package/lib/admin/FleetRoutes.d.ts +55 -0
  135. package/lib/admin/FleetRoutes.js +465 -0
  136. package/lib/admin/HealthWarnings.d.ts +10 -0
  137. package/lib/admin/HealthWarnings.js +95 -0
  138. package/lib/admin/LeaderboardRoutes.d.ts +8 -0
  139. package/lib/admin/LeaderboardRoutes.js +569 -0
  140. package/lib/admin/LogBuffer.d.ts +60 -0
  141. package/lib/admin/LogBuffer.js +92 -0
  142. package/lib/admin/LogRedaction.d.ts +15 -0
  143. package/lib/admin/LogRedaction.js +72 -0
  144. package/lib/admin/LogRoutes.d.ts +45 -0
  145. package/lib/admin/LogRoutes.js +307 -0
  146. package/lib/admin/MaintenanceMode.d.ts +41 -0
  147. package/lib/admin/MaintenanceMode.js +74 -0
  148. package/lib/admin/MaintenanceWindows.d.ts +91 -0
  149. package/lib/admin/MaintenanceWindows.js +207 -0
  150. package/lib/admin/MetricsRoutes.d.ts +8 -0
  151. package/lib/admin/MetricsRoutes.js +209 -0
  152. package/lib/admin/MetricsSampler.d.ts +124 -0
  153. package/lib/admin/MetricsSampler.js +211 -0
  154. package/lib/admin/OneTimeCodes.d.ts +60 -0
  155. package/lib/admin/OneTimeCodes.js +162 -0
  156. package/lib/admin/OpenIdConnect.d.ts +146 -0
  157. package/lib/admin/OpenIdConnect.js +335 -0
  158. package/lib/admin/Passkeys.d.ts +130 -0
  159. package/lib/admin/Passkeys.js +432 -0
  160. package/lib/admin/PlayerRoutes.d.ts +20 -0
  161. package/lib/admin/PlayerRoutes.js +676 -0
  162. package/lib/admin/RedisAdminStore.d.ts +114 -0
  163. package/lib/admin/RedisAdminStore.js +258 -0
  164. package/lib/admin/RolloutCoordinator.d.ts +99 -0
  165. package/lib/admin/RolloutCoordinator.js +321 -0
  166. package/lib/admin/ServerEvents.d.ts +57 -0
  167. package/lib/admin/ServerEvents.js +89 -0
  168. package/lib/admin/SessionRoutes.d.ts +113 -0
  169. package/lib/admin/SessionRoutes.js +386 -0
  170. package/lib/admin/UserRoutes.d.ts +8 -0
  171. package/lib/admin/UserRoutes.js +214 -0
  172. package/lib/cli.d.ts +2 -0
  173. package/lib/cli.js +221 -0
  174. package/lib/console/assets/index-CI5N7c-T.js +21 -0
  175. package/lib/console/assets/index-DPitvzLp.css +1 -0
  176. package/lib/console/favicon.svg +1 -0
  177. package/lib/console/index.html +16 -0
  178. package/lib/discord/DiscordOAuth.d.ts +57 -0
  179. package/lib/discord/DiscordOAuth.js +127 -0
  180. package/lib/discord/DiscordSessions.d.ts +79 -0
  181. package/lib/discord/DiscordSessions.js +135 -0
  182. package/lib/discord/DiscordSignInConstants.d.ts +54 -0
  183. package/lib/discord/DiscordSignInConstants.js +55 -0
  184. package/lib/discord/DiscordSignInRoutes.d.ts +51 -0
  185. package/lib/discord/DiscordSignInRoutes.js +359 -0
  186. package/lib/index.d.ts +24 -0
  187. package/lib/index.js +18 -0
  188. package/lib/leaderboards/FileLeaderboardRankStore.d.ts +293 -0
  189. package/lib/leaderboards/FileLeaderboardRankStore.js +808 -0
  190. package/lib/leaderboards/LeaderboardConstants.d.ts +63 -0
  191. package/lib/leaderboards/LeaderboardConstants.js +70 -0
  192. package/lib/leaderboards/LeaderboardError.d.ts +15 -0
  193. package/lib/leaderboards/LeaderboardError.js +20 -0
  194. package/lib/leaderboards/LeaderboardIdentity.d.ts +44 -0
  195. package/lib/leaderboards/LeaderboardIdentity.js +72 -0
  196. package/lib/leaderboards/LeaderboardPublicApi.d.ts +17 -0
  197. package/lib/leaderboards/LeaderboardPublicApi.js +389 -0
  198. package/lib/leaderboards/LeaderboardRankStore.d.ts +296 -0
  199. package/lib/leaderboards/LeaderboardRankStore.js +18 -0
  200. package/lib/leaderboards/LeaderboardRules.d.ts +122 -0
  201. package/lib/leaderboards/LeaderboardRules.js +472 -0
  202. package/lib/leaderboards/LeaderboardScheduler.d.ts +43 -0
  203. package/lib/leaderboards/LeaderboardScheduler.js +103 -0
  204. package/lib/leaderboards/LeaderboardService.d.ts +669 -0
  205. package/lib/leaderboards/LeaderboardService.js +1667 -0
  206. package/lib/leaderboards/LeaderboardTransfer.d.ts +35 -0
  207. package/lib/leaderboards/LeaderboardTransfer.js +580 -0
  208. package/lib/leaderboards/RedisLeaderboardRankStore.d.ts +282 -0
  209. package/lib/leaderboards/RedisLeaderboardRankStore.js +691 -0
  210. package/package.json +64 -4
  211. package/templates/init/Caddyfile +6 -0
  212. package/templates/init/DEPLOY.md +233 -0
  213. package/templates/init/Dockerfile +12 -0
  214. package/templates/init/app.json +21 -0
  215. package/templates/init/aws/README.md +118 -0
  216. package/templates/init/aws/ecs-fargate-alb.yml +207 -0
  217. package/templates/init/aws/ecs-fargate-nlb-quic.yml +210 -0
  218. package/templates/init/babylon-game-server.config.mjs +158 -0
  219. package/templates/init/colyseus-cloud/README.md +133 -0
  220. package/templates/init/colyseus-cloud/babylon-game-server.config.mjs +36 -0
  221. package/templates/init/colyseus-cloud/ecosystem.config.cjs +35 -0
  222. package/templates/init/colyseus-cloud/gitignore +6 -0
  223. package/templates/init/colyseus-cloud/package.json +15 -0
  224. package/templates/init/digitalocean-app.yaml +30 -0
  225. package/templates/init/docker-compose.full.yml +45 -0
  226. package/templates/init/docker-compose.websocket.yml +41 -0
  227. package/templates/init/fly.toml +56 -0
  228. package/templates/init/heroku.yml +11 -0
  229. package/templates/init/kubernetes/deployment.yaml +71 -0
  230. package/templates/init/kubernetes/redis.yaml +73 -0
  231. package/templates/init/kubernetes/service.yaml +24 -0
  232. package/templates/init/monitoring/grafana-dashboard.json +539 -0
  233. package/templates/init/monitoring/prometheus-alerts.yml +51 -0
  234. package/templates/init/monitoring/prometheus-scrape.yml +17 -0
  235. package/templates/init/railway.json +13 -0
  236. package/templates/init/render.yaml +19 -0
@@ -0,0 +1,808 @@
1
+ // The file rank store of the leaderboard service (game-server-leaderboards D1, DR §8): one JSON file per board season
2
+ // plus players, player keys and dedupe files under <admin.dataDir>/leaderboards. It serves one process by definition, so
3
+ // rate counters and session presence stay in memory. Writes go through the same keep-policy rule as the Redis script.
4
+ import { mkdir, readFile, readdir, rename, rm, unlink, writeFile } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { LEADERBOARD_DEDUPE_TTL_MS, LEADERBOARD_FILE_FLUSH_DELAY_MS, LEADERBOARD_MAXIMUM_ABSOLUTE_SCORE } from "./LeaderboardConstants.js";
7
+ import { applyKeepPolicy, compareRankedEntries, durabilityAdvice, parseRankKeyText, rankKeyText } from "./LeaderboardRules.js";
8
+ /** Folder of the board season files. */
9
+ const BOARDS_FOLDER = "boards";
10
+ /** File of the player records. */
11
+ const PLAYERS_FILE = "players.json";
12
+ /** File of each player's rank keys. */
13
+ const PLAYER_KEYS_FILE = "player-keys.json";
14
+ /** File of the remembered submission results. */
15
+ const DEDUPE_FILE = "dedupe.json";
16
+ /** Prefix of a season file name. */
17
+ const SEASON_FILE_PREFIX = "season-";
18
+ /** Extension of every file. */
19
+ const JSON_EXTENSION = ".json";
20
+ /** Node's error code of a missing file. */
21
+ const FILE_MISSING_CODE = "ENOENT";
22
+ /** Rate and presence records kept in memory before expired ones are pruned. */
23
+ const MEMORY_PRUNE_THRESHOLD = 10000;
24
+ /** Rankings in files (D1, DR §8). */
25
+ export class FileLeaderboardRankStore {
26
+ /** Always "file". */
27
+ kind = "file";
28
+ /** The store's folder (<admin.dataDir>/leaderboards). */
29
+ folder;
30
+ /** Whether the folder survives a redeploy (D20). */
31
+ isFolderPersistent;
32
+ /** Loaded season files by file key (`<boardId>/<seasonId>`). */
33
+ seasons = new Map();
34
+ /** Season loads in flight, so two callers share one read. */
35
+ seasonLoads = new Map();
36
+ /** Player records. */
37
+ players = new Map();
38
+ /** Player id → rank key texts. */
39
+ playerKeys = new Map();
40
+ /** Dedupe id → remembered result. */
41
+ dedupe = new Map();
42
+ /** Rate bucket → counter (memory only). */
43
+ rates = new Map();
44
+ /** `<sessionName>\n<subject>` → presence (memory only). */
45
+ presence = new Map();
46
+ /** File keys changed since the last flush. */
47
+ dirtyFiles = new Set();
48
+ /** The pending flush timer, or null. */
49
+ flushTimer = null;
50
+ /** Pending writes per file key, so a file's writes never interleave. */
51
+ writeChains = new Map();
52
+ /** The latest caller time seen (prunes expired dedupe records on flush). */
53
+ latestNowMs = 0;
54
+ /**
55
+ * Creates the store; open() loads the shared files.
56
+ * @param folder The store's folder.
57
+ * @param isFolderPersistent Whether the folder survives a redeploy.
58
+ */
59
+ constructor(folder, isFolderPersistent) {
60
+ this.folder = folder;
61
+ this.isFolderPersistent = isFolderPersistent;
62
+ }
63
+ /**
64
+ * Opens the store, loading players, player keys and dedupe records (season files load on first use).
65
+ * @param folder The store's folder (created on the first write).
66
+ * @param isFolderPersistent Whether the folder survives a redeploy (the admin store's isPersistent).
67
+ * @returns The store.
68
+ */
69
+ static async open(folder, isFolderPersistent) {
70
+ const store = new FileLeaderboardRankStore(folder, isFolderPersistent);
71
+ await store.loadSharedFiles();
72
+ return store;
73
+ }
74
+ /**
75
+ * Writes one score: dedupe, keep policy (applyKeepPolicy, the Lua's semantics), overflow, ranking and indexes.
76
+ * @param request The write.
77
+ * @returns What the write did.
78
+ */
79
+ async write(request) {
80
+ const season = await this.loadSeason(request.key.boardId, request.key.seasonId);
81
+ this.latestNowMs = Math.max(this.latestNowMs, request.nowMs);
82
+ const remembered = (request.dedupeId != null) ? this.dedupe.get(request.dedupeId) : undefined;
83
+ if (remembered != null && remembered.expiresAtMs > request.nowMs)
84
+ return { ...remembered.result, status: "duplicate" };
85
+ const result = this.applyWrite(season, request);
86
+ if (request.dedupeId != null && result.status !== "overflow") {
87
+ this.dedupe.set(request.dedupeId, { expiresAtMs: request.nowMs + LEADERBOARD_DEDUPE_TTL_MS, result: result });
88
+ this.markDirty(DEDUPE_FILE);
89
+ }
90
+ return result;
91
+ }
92
+ /**
93
+ * Reads a page of a ranking.
94
+ * @param key The ranking.
95
+ * @param offset Zero-based index of the first entry.
96
+ * @param limit Most entries.
97
+ * @returns The ranked entries.
98
+ */
99
+ async readRange(key, offset, limit) {
100
+ const ranking = await this.findRanking(key);
101
+ if (ranking == null || limit <= 0)
102
+ return [];
103
+ return ranking.entries.slice(offset, offset + limit).map((entry, index) => ({ ...entry, rank: offset + index + 1 }));
104
+ }
105
+ /**
106
+ * Counts the live entries.
107
+ * @param key The ranking.
108
+ * @returns The count.
109
+ */
110
+ async count(key) {
111
+ const ranking = await this.findRanking(key);
112
+ return (ranking != null) ? ranking.entries.length : 0;
113
+ }
114
+ /**
115
+ * Reads one player's live entry and rank.
116
+ * @param key The ranking.
117
+ * @param playerId The player.
118
+ * @returns The entry, or null.
119
+ */
120
+ async readEntry(key, playerId) {
121
+ const ranking = await this.findRanking(key);
122
+ if (ranking == null)
123
+ return null;
124
+ const index = indexOfPlayer(ranking, playerId);
125
+ return (index != null) ? { ...ranking.entries[index], rank: index + 1 } : null;
126
+ }
127
+ /**
128
+ * Reads the window around a player's entry, clamped to the ranking.
129
+ * @param key The ranking.
130
+ * @param playerId The player.
131
+ * @param window Entries above and below.
132
+ * @returns The entries, or [] when the player has none.
133
+ */
134
+ async readAround(key, playerId, window) {
135
+ const ranking = await this.findRanking(key);
136
+ const index = (ranking != null) ? indexOfPlayer(ranking, playerId) : undefined;
137
+ if (ranking == null || index == null)
138
+ return [];
139
+ const firstIndex = Math.max(0, index - window);
140
+ const lastIndex = Math.min(ranking.entries.length - 1, index + window);
141
+ return await this.readRange(key, firstIndex, lastIndex - firstIndex + 1);
142
+ }
143
+ /**
144
+ * Finds entries by display name or player id (case-insensitive), in rank order.
145
+ * @param key The ranking.
146
+ * @param text The text.
147
+ * @param limit Most entries.
148
+ * @returns The matches.
149
+ */
150
+ async searchEntries(key, text, limit) {
151
+ const ranking = await this.findRanking(key);
152
+ if (ranking == null)
153
+ return [];
154
+ const needle = text.toLowerCase();
155
+ const matches = [];
156
+ for (let i = 0; i < ranking.entries.length && matches.length < limit; i++) {
157
+ const entry = ranking.entries[i];
158
+ if (matchesText(entry.displayName, entry.playerId, needle))
159
+ matches.push({ ...entry, rank: i + 1 });
160
+ }
161
+ return matches;
162
+ }
163
+ /**
164
+ * Reads the removed entries, newest removal first.
165
+ * @param key The ranking.
166
+ * @returns The removed entries.
167
+ */
168
+ async readRemoved(key) {
169
+ const ranking = await this.findRanking(key);
170
+ if (ranking == null)
171
+ return [];
172
+ return Array.from(ranking.removed.values()).sort((first, second) => second.removedAt.localeCompare(first.removedAt));
173
+ }
174
+ /**
175
+ * Moves a live entry to the removed set.
176
+ * @param key The ranking.
177
+ * @param playerId The player.
178
+ * @param reason Why.
179
+ * @param removedBy Who.
180
+ * @param removedAt ISO time.
181
+ * @returns True when there was a live entry.
182
+ */
183
+ async removeEntry(key, playerId, reason, removedBy, removedAt) {
184
+ const ranking = await this.findRanking(key);
185
+ const entry = (ranking != null) ? takeEntry(ranking, playerId) : null;
186
+ if (ranking == null || entry == null)
187
+ return false;
188
+ ranking.removed.set(playerId, { entry: entry, reason: reason, removedBy: removedBy, removedAt: removedAt });
189
+ this.markDirty(seasonFileKey(key.boardId, key.seasonId));
190
+ return true;
191
+ }
192
+ /**
193
+ * Moves a removed entry back into the ranking.
194
+ * @param key The ranking.
195
+ * @param playerId The player.
196
+ * @param sortOrder The board's sort order.
197
+ * @returns "restored", "missing" or "liveEntryExists".
198
+ */
199
+ async restoreEntry(key, playerId, sortOrder) {
200
+ const ranking = await this.findRanking(key);
201
+ const removed = ranking?.removed.get(playerId);
202
+ if (ranking == null || removed == null)
203
+ return "missing";
204
+ if (ranking.entryByPlayer.has(playerId))
205
+ return "liveEntryExists";
206
+ ranking.removed.delete(playerId);
207
+ insertEntry(ranking, removed.entry, sortOrder);
208
+ this.addPlayerKey(playerId, rankKeyText(key));
209
+ this.markDirty(seasonFileKey(key.boardId, key.seasonId));
210
+ return "restored";
211
+ }
212
+ /**
213
+ * Lists the variant keys of a season, sorted.
214
+ * @param boardId The board.
215
+ * @param seasonId The season.
216
+ * @returns The variant keys.
217
+ */
218
+ async listVariantKeys(boardId, seasonId) {
219
+ const season = await this.loadSeason(boardId, seasonId);
220
+ return Array.from(season.variants.keys()).sort();
221
+ }
222
+ /**
223
+ * Reads a board's last accepted write time across its season files.
224
+ * @param boardId The board.
225
+ * @returns ISO time, or null.
226
+ */
227
+ async readLastSubmissionAt(boardId) {
228
+ let latest = null;
229
+ for (const seasonId of await this.listSeasonIds(boardId)) {
230
+ const season = await this.loadSeason(boardId, seasonId);
231
+ if (season.lastSubmissionAt != null && (latest == null || season.lastSubmissionAt > latest))
232
+ latest = season.lastSubmissionAt;
233
+ }
234
+ return latest;
235
+ }
236
+ /**
237
+ * Empties a season (its file keeps only the last submission time) and unlinks it from its players.
238
+ * @param boardId The board.
239
+ * @param seasonId The season.
240
+ * @returns How many entries were deleted.
241
+ */
242
+ async deleteSeason(boardId, seasonId) {
243
+ const season = await this.loadSeason(boardId, seasonId);
244
+ let deletedCount = 0;
245
+ for (const [variantKey, ranking] of season.variants) {
246
+ const keyText = rankKeyText({ boardId: boardId, seasonId: seasonId, variantKey: variantKey });
247
+ const playerIds = new Set([...ranking.entryByPlayer.keys(), ...ranking.removed.keys()]);
248
+ for (const playerId of playerIds)
249
+ this.removePlayerKey(playerId, keyText);
250
+ deletedCount += ranking.entries.length + ranking.removed.size;
251
+ }
252
+ season.variants.clear();
253
+ this.markDirty(seasonFileKey(boardId, seasonId));
254
+ return deletedCount;
255
+ }
256
+ /**
257
+ * Deletes a board's folder after emptying its seasons.
258
+ * @param boardId The board.
259
+ * @param seasonIds Its seasons.
260
+ */
261
+ async deleteBoard(boardId, seasonIds) {
262
+ const knownSeasonIds = new Set([...seasonIds, ...await this.listSeasonIds(boardId)]);
263
+ for (const seasonId of knownSeasonIds) {
264
+ await this.deleteSeason(boardId, seasonId);
265
+ const fileKey = seasonFileKey(boardId, seasonId);
266
+ this.dirtyFiles.delete(fileKey);
267
+ await this.writeChains.get(fileKey);
268
+ this.seasons.delete(fileKey);
269
+ this.seasonLoads.delete(fileKey);
270
+ }
271
+ await rm(path.join(this.folder, BOARDS_FOLDER, boardId), { recursive: true, force: true });
272
+ }
273
+ /**
274
+ * Lists the rankings a player has an entry in.
275
+ * @param playerId The player.
276
+ * @returns The rank keys, sorted by their text.
277
+ */
278
+ async listPlayerRankKeys(playerId) {
279
+ const texts = this.playerKeys.get(playerId) ?? new Set();
280
+ return Array.from(texts).sort().map(parseRankKeyText);
281
+ }
282
+ /**
283
+ * Renames every entry of a player, live and removed.
284
+ * @param playerId The player.
285
+ * @param displayName The new name.
286
+ * @returns How many entries changed.
287
+ */
288
+ async renamePlayerEntries(playerId, displayName) {
289
+ let renamedCount = 0;
290
+ for (const key of await this.listPlayerRankKeys(playerId)) {
291
+ const ranking = await this.findRanking(key);
292
+ if (ranking == null)
293
+ continue;
294
+ const live = ranking.entryByPlayer.get(playerId);
295
+ if (live != null) {
296
+ live.displayName = displayName;
297
+ renamedCount++;
298
+ }
299
+ const removed = ranking.removed.get(playerId);
300
+ if (removed != null) {
301
+ removed.entry.displayName = displayName;
302
+ renamedCount++;
303
+ }
304
+ this.markDirty(seasonFileKey(key.boardId, key.seasonId));
305
+ }
306
+ return renamedCount;
307
+ }
308
+ /**
309
+ * Deletes every entry of a player, live and removed, and the player's rank keys.
310
+ * @param playerId The player.
311
+ * @returns How many entries were deleted.
312
+ */
313
+ async deletePlayerEntries(playerId) {
314
+ let deletedCount = 0;
315
+ for (const key of await this.listPlayerRankKeys(playerId)) {
316
+ const ranking = await this.findRanking(key);
317
+ if (ranking == null)
318
+ continue;
319
+ if (takeEntry(ranking, playerId) != null)
320
+ deletedCount++;
321
+ if (ranking.removed.delete(playerId))
322
+ deletedCount++;
323
+ this.markDirty(seasonFileKey(key.boardId, key.seasonId));
324
+ }
325
+ if (this.playerKeys.delete(playerId))
326
+ this.markDirty(PLAYER_KEYS_FILE);
327
+ return deletedCount;
328
+ }
329
+ /**
330
+ * Reads a player.
331
+ * @param playerId The player.
332
+ * @returns A copy of the record, or null.
333
+ */
334
+ async getPlayer(playerId) {
335
+ const record = this.players.get(playerId);
336
+ return (record != null) ? { ...record } : null;
337
+ }
338
+ /**
339
+ * Writes a player.
340
+ * @param record The record.
341
+ */
342
+ async putPlayer(record) {
343
+ this.players.set(record.playerId, { ...record });
344
+ this.markDirty(PLAYERS_FILE);
345
+ }
346
+ /**
347
+ * Deletes a player record.
348
+ * @param playerId The player.
349
+ * @returns True when it existed.
350
+ */
351
+ async deletePlayer(playerId) {
352
+ const existed = this.players.delete(playerId);
353
+ if (existed)
354
+ this.markDirty(PLAYERS_FILE);
355
+ return existed;
356
+ }
357
+ /**
358
+ * Finds players by display name or id (case-insensitive).
359
+ * @param text The text ("" matches any).
360
+ * @param limit Most players.
361
+ * @returns Copies of the matching records.
362
+ */
363
+ async searchPlayers(text, limit) {
364
+ const needle = text.toLowerCase();
365
+ const matches = [];
366
+ for (const record of this.players.values()) {
367
+ if (matches.length >= limit)
368
+ break;
369
+ if (matchesText(record.displayName, record.playerId, needle))
370
+ matches.push({ ...record });
371
+ }
372
+ return matches;
373
+ }
374
+ /**
375
+ * Counts every player.
376
+ * @returns The count.
377
+ */
378
+ async countPlayers() {
379
+ return this.players.size;
380
+ }
381
+ /**
382
+ * Counts one use in a fixed window, in memory.
383
+ * @param bucket What is limited.
384
+ * @param limit Most uses per window.
385
+ * @param windowMs The window, in milliseconds.
386
+ * @param nowMs The caller's clock.
387
+ * @returns True while within the limit.
388
+ */
389
+ async consumeRate(bucket, limit, windowMs, nowMs) {
390
+ if (this.rates.size >= MEMORY_PRUNE_THRESHOLD)
391
+ pruneExpired(this.rates, nowMs);
392
+ const windowStartMs = Math.floor(nowMs / windowMs) * windowMs;
393
+ const existing = this.rates.get(bucket);
394
+ const counter = (existing != null && existing.windowStartMs === windowStartMs)
395
+ ? existing
396
+ : { windowStartMs: windowStartMs, expiresAtMs: windowStartMs + windowMs, usedCount: 0 };
397
+ counter.usedCount++;
398
+ this.rates.set(bucket, counter);
399
+ return counter.usedCount <= limit;
400
+ }
401
+ /**
402
+ * Records a session join per subject, in memory; an earlier join that still counts wins.
403
+ * @param sessionName The session.
404
+ * @param subjects The subjects.
405
+ * @param joinedAtMs The join time.
406
+ * @param ttlMs How long it counts.
407
+ */
408
+ async recordSessionPresence(sessionName, subjects, joinedAtMs, ttlMs) {
409
+ if (this.presence.size >= MEMORY_PRUNE_THRESHOLD)
410
+ pruneExpired(this.presence, joinedAtMs);
411
+ for (const subject of subjects) {
412
+ const presenceKey = sessionName + "\n" + subject;
413
+ const existing = this.presence.get(presenceKey);
414
+ if (existing != null && existing.expiresAtMs > joinedAtMs)
415
+ continue;
416
+ this.presence.set(presenceKey, { joinedAtMs: joinedAtMs, expiresAtMs: joinedAtMs + ttlMs });
417
+ }
418
+ }
419
+ /**
420
+ * Reads a subject's join time, if it still counts.
421
+ * @param sessionName The session.
422
+ * @param subject The subject.
423
+ * @param nowMs The caller's clock.
424
+ * @returns The join time, or null.
425
+ */
426
+ async readSessionPresence(sessionName, subject, nowMs) {
427
+ const record = this.presence.get(sessionName + "\n" + subject);
428
+ return (record != null && record.expiresAtMs > nowMs) ? record.joinedAtMs : null;
429
+ }
430
+ /**
431
+ * Durability of the folder (D20): durable when the admin store says it persists.
432
+ * @returns The durability and advice.
433
+ */
434
+ async measureDurability() {
435
+ const durability = this.isFolderPersistent ? "durable" : "notDurable";
436
+ return { durability: durability, advice: durabilityAdvice(durability, "file") };
437
+ }
438
+ /** Writes every changed file and waits for the writes. */
439
+ async close() {
440
+ if (this.flushTimer != null)
441
+ clearTimeout(this.flushTimer);
442
+ this.flushTimer = null;
443
+ await this.flush();
444
+ await Promise.all(Array.from(this.writeChains.values()));
445
+ }
446
+ /**
447
+ * Applies one write to a loaded season (no dedupe).
448
+ * @param season The season.
449
+ * @param request The write.
450
+ * @returns What the write did.
451
+ */
452
+ applyWrite(season, request) {
453
+ const playerId = request.entry.playerId;
454
+ const existingRanking = season.variants.get(request.key.variantKey);
455
+ const current = existingRanking?.entryByPlayer.get(playerId) ?? null;
456
+ const total = (existingRanking != null) ? existingRanking.entries.length : 0;
457
+ const previousScore = (current != null) ? current.score : null;
458
+ const { outcome, stored } = applyKeepPolicy(request.keepPolicy, request.sortOrder, current, { ...request.entry });
459
+ if (Math.abs(stored.score) > LEADERBOARD_MAXIMUM_ABSOLUTE_SCORE) {
460
+ return { status: "overflow", outcome: null, entry: null, previousScore: previousScore, rank: null, total: total };
461
+ }
462
+ if (outcome === "kept" && existingRanking != null) {
463
+ const keptIndex = indexOfPlayer(existingRanking, playerId) ?? 0;
464
+ return { status: "kept", outcome: outcome, entry: { ...stored }, previousScore: previousScore, rank: keptIndex + 1, total: total };
465
+ }
466
+ const ranking = existingRanking ?? createRanking();
467
+ season.variants.set(request.key.variantKey, ranking);
468
+ takeEntry(ranking, playerId, request.sortOrder);
469
+ const index = insertEntry(ranking, stored, request.sortOrder);
470
+ season.lastSubmissionAt = stored.submittedAt;
471
+ this.addPlayerKey(playerId, rankKeyText(request.key));
472
+ this.markDirty(seasonFileKey(request.key.boardId, request.key.seasonId));
473
+ return { status: "written", outcome: outcome, entry: { ...stored }, previousScore: previousScore, rank: index + 1, total: ranking.entries.length };
474
+ }
475
+ /**
476
+ * Finds a loaded ranking without creating it.
477
+ * @param key The ranking.
478
+ * @returns The ranking, or null.
479
+ */
480
+ async findRanking(key) {
481
+ const season = await this.loadSeason(key.boardId, key.seasonId);
482
+ return season.variants.get(key.variantKey) ?? null;
483
+ }
484
+ /**
485
+ * Loads a season file once (an empty season when it does not exist).
486
+ * @param boardId The board.
487
+ * @param seasonId The season.
488
+ * @returns The season held in memory.
489
+ */
490
+ loadSeason(boardId, seasonId) {
491
+ const fileKey = seasonFileKey(boardId, seasonId);
492
+ const loaded = this.seasons.get(fileKey);
493
+ if (loaded != null)
494
+ return Promise.resolve(loaded);
495
+ const pending = this.seasonLoads.get(fileKey);
496
+ if (pending != null)
497
+ return pending;
498
+ const load = this.readSeasonFile(fileKey).then((season) => {
499
+ this.seasons.set(fileKey, season);
500
+ this.seasonLoads.delete(fileKey);
501
+ return season;
502
+ });
503
+ this.seasonLoads.set(fileKey, load);
504
+ return load;
505
+ }
506
+ /**
507
+ * Reads a season file into memory form.
508
+ * @param fileKey The season's file key.
509
+ * @returns The season.
510
+ */
511
+ async readSeasonFile(fileKey) {
512
+ const file = await readJsonIfPresent(this.filePath(fileKey));
513
+ const season = { variants: new Map(), lastSubmissionAt: (file != null) ? file.lastSubmissionAt : null };
514
+ if (file == null)
515
+ return season;
516
+ for (const [variantKey, variant] of Object.entries(file.variants)) {
517
+ const ranking = createRanking();
518
+ ranking.entries = variant.entries;
519
+ for (const entry of variant.entries)
520
+ ranking.entryByPlayer.set(entry.playerId, entry);
521
+ for (const removed of variant.removed)
522
+ ranking.removed.set(removed.entry.playerId, removed);
523
+ season.variants.set(variantKey, ranking);
524
+ }
525
+ return season;
526
+ }
527
+ /**
528
+ * Lists the season ids that have a file for a board.
529
+ * @param boardId The board.
530
+ * @returns The season ids, plus those loaded in memory.
531
+ */
532
+ async listSeasonIds(boardId) {
533
+ const seasonIds = new Set();
534
+ const boardPrefix = boardId + "/";
535
+ for (const fileKey of this.seasons.keys()) {
536
+ if (fileKey.startsWith(boardPrefix))
537
+ seasonIds.add(Number(fileKey.slice(boardPrefix.length)));
538
+ }
539
+ let fileNames = [];
540
+ try {
541
+ fileNames = await readdir(path.join(this.folder, BOARDS_FOLDER, boardId));
542
+ }
543
+ catch (error) {
544
+ if (error.code !== FILE_MISSING_CODE)
545
+ throw error;
546
+ }
547
+ for (const fileName of fileNames) {
548
+ if (fileName.startsWith(SEASON_FILE_PREFIX) && fileName.endsWith(JSON_EXTENSION)) {
549
+ seasonIds.add(Number(fileName.slice(SEASON_FILE_PREFIX.length, -JSON_EXTENSION.length)));
550
+ }
551
+ }
552
+ return Array.from(seasonIds).filter((seasonId) => Number.isSafeInteger(seasonId)).sort((first, second) => first - second);
553
+ }
554
+ /** Loads the players, player keys and dedupe files. */
555
+ async loadSharedFiles() {
556
+ const players = await readJsonIfPresent(this.filePath(PLAYERS_FILE));
557
+ const playerKeys = await readJsonIfPresent(this.filePath(PLAYER_KEYS_FILE));
558
+ const dedupe = await readJsonIfPresent(this.filePath(DEDUPE_FILE));
559
+ this.players = new Map(Object.entries(players ?? {}));
560
+ this.playerKeys = new Map(Object.entries(playerKeys ?? {}).map(([playerId, texts]) => [playerId, new Set(texts)]));
561
+ this.dedupe = new Map(Object.entries(dedupe ?? {}));
562
+ }
563
+ /**
564
+ * Adds a rank key to a player's index.
565
+ * @param playerId The player.
566
+ * @param keyText The rank key text.
567
+ */
568
+ addPlayerKey(playerId, keyText) {
569
+ const texts = this.playerKeys.get(playerId) ?? new Set();
570
+ if (texts.has(keyText))
571
+ return;
572
+ texts.add(keyText);
573
+ this.playerKeys.set(playerId, texts);
574
+ this.markDirty(PLAYER_KEYS_FILE);
575
+ }
576
+ /**
577
+ * Removes a rank key from a player's index.
578
+ * @param playerId The player.
579
+ * @param keyText The rank key text.
580
+ */
581
+ removePlayerKey(playerId, keyText) {
582
+ const texts = this.playerKeys.get(playerId);
583
+ if (texts == null || !texts.delete(keyText))
584
+ return;
585
+ if (texts.size === 0)
586
+ this.playerKeys.delete(playerId);
587
+ this.markDirty(PLAYER_KEYS_FILE);
588
+ }
589
+ /**
590
+ * Marks a file changed and schedules a flush after LEADERBOARD_FILE_FLUSH_DELAY_MS.
591
+ * @param fileKey The file key (a season key or a shared file name).
592
+ */
593
+ markDirty(fileKey) {
594
+ this.dirtyFiles.add(fileKey);
595
+ if (this.flushTimer != null)
596
+ return;
597
+ this.flushTimer = setTimeout(() => {
598
+ this.flushTimer = null;
599
+ void this.flush();
600
+ }, LEADERBOARD_FILE_FLUSH_DELAY_MS);
601
+ this.flushTimer.unref();
602
+ }
603
+ /**
604
+ * Writes every changed file, each after its own pending write.
605
+ * @returns Resolves once the writes are queued and done.
606
+ */
607
+ async flush() {
608
+ const fileKeys = Array.from(this.dirtyFiles);
609
+ this.dirtyFiles.clear();
610
+ await Promise.all(fileKeys.map((fileKey) => this.serialize(fileKey, async () => await this.writeFileOf(fileKey))));
611
+ }
612
+ /**
613
+ * Writes one file from memory (an empty, never-submitted season file is deleted instead).
614
+ * @param fileKey The file key.
615
+ */
616
+ async writeFileOf(fileKey) {
617
+ const filePath = this.filePath(fileKey);
618
+ if (fileKey === PLAYERS_FILE)
619
+ return await writeFileAtomically(filePath, JSON.stringify(Object.fromEntries(this.players)));
620
+ if (fileKey === PLAYER_KEYS_FILE)
621
+ return await writeFileAtomically(filePath, JSON.stringify(Object.fromEntries(Array.from(this.playerKeys, ([playerId, texts]) => [playerId, Array.from(texts)]))));
622
+ if (fileKey === DEDUPE_FILE) {
623
+ pruneExpired(this.dedupe, this.latestNowMs);
624
+ return await writeFileAtomically(filePath, JSON.stringify(Object.fromEntries(this.dedupe)));
625
+ }
626
+ const season = this.seasons.get(fileKey);
627
+ if (season == null)
628
+ return;
629
+ if (season.variants.size === 0 && season.lastSubmissionAt == null)
630
+ return await deleteFileIfPresent(filePath);
631
+ await writeFileAtomically(filePath, JSON.stringify(toSeasonFile(season)));
632
+ }
633
+ /**
634
+ * Runs a task after the pending writes of one file.
635
+ * @param fileKey The file.
636
+ * @param task The task.
637
+ * @returns The task's result.
638
+ */
639
+ serialize(fileKey, task) {
640
+ const previous = this.writeChains.get(fileKey) ?? Promise.resolve();
641
+ const next = previous.then(task, task);
642
+ this.writeChains.set(fileKey, next.catch(() => undefined));
643
+ return next;
644
+ }
645
+ /**
646
+ * The path of a file key.
647
+ * @param fileKey A season key (`<boardId>/<seasonId>`) or a shared file name.
648
+ * @returns The absolute path.
649
+ */
650
+ filePath(fileKey) {
651
+ if (fileKey === PLAYERS_FILE || fileKey === PLAYER_KEYS_FILE || fileKey === DEDUPE_FILE)
652
+ return path.join(this.folder, fileKey);
653
+ const [boardId, seasonId] = fileKey.split("/");
654
+ return path.join(this.folder, BOARDS_FOLDER, boardId, SEASON_FILE_PREFIX + seasonId + JSON_EXTENSION);
655
+ }
656
+ }
657
+ /**
658
+ * The file key of a season.
659
+ * @param boardId The board.
660
+ * @param seasonId The season.
661
+ * @returns `<boardId>/<seasonId>`.
662
+ */
663
+ function seasonFileKey(boardId, seasonId) {
664
+ return boardId + "/" + seasonId;
665
+ }
666
+ /**
667
+ * An empty ranking.
668
+ * @returns The ranking.
669
+ */
670
+ function createRanking() {
671
+ return { entries: [], entryByPlayer: new Map(), indexByPlayer: null, removed: new Map() };
672
+ }
673
+ /**
674
+ * A player's index in a ranking (the index map is rebuilt once after changes).
675
+ * @param ranking The ranking.
676
+ * @param playerId The player.
677
+ * @returns The index, or undefined.
678
+ */
679
+ function indexOfPlayer(ranking, playerId) {
680
+ if (ranking.indexByPlayer == null) {
681
+ ranking.indexByPlayer = new Map(ranking.entries.map((entry, index) => [entry.playerId, index]));
682
+ }
683
+ return ranking.indexByPlayer.get(playerId);
684
+ }
685
+ /**
686
+ * Removes a player's live entry from a ranking.
687
+ * @param ranking The ranking.
688
+ * @param playerId The player.
689
+ * @param sortOrder The board's sort order, which finds the entry by binary search; without it the index map is used.
690
+ * @returns The removed entry, or null.
691
+ */
692
+ function takeEntry(ranking, playerId, sortOrder = null) {
693
+ const entry = ranking.entryByPlayer.get(playerId);
694
+ if (entry == null)
695
+ return null;
696
+ const searchedIndex = (sortOrder != null) ? sortedPositionOf(ranking, entry, sortOrder) : -1;
697
+ const index = (ranking.entries[searchedIndex] === entry) ? searchedIndex : indexOfPlayer(ranking, playerId);
698
+ if (index != null)
699
+ ranking.entries.splice(index, 1);
700
+ ranking.entryByPlayer.delete(playerId);
701
+ ranking.indexByPlayer = null;
702
+ return entry;
703
+ }
704
+ /**
705
+ * The first index whose entry does not rank above the given one (binary search with compareRankedEntries).
706
+ * @param ranking The ranking.
707
+ * @param entry The entry.
708
+ * @param sortOrder The board's sort order.
709
+ * @returns The index (entries.length when it ranks below every entry).
710
+ */
711
+ function sortedPositionOf(ranking, entry, sortOrder) {
712
+ let low = 0;
713
+ let high = ranking.entries.length;
714
+ while (low < high) {
715
+ const middle = (low + high) >>> 1;
716
+ if (compareRankedEntries(sortOrder, ranking.entries[middle], entry) < 0)
717
+ low = middle + 1;
718
+ else
719
+ high = middle;
720
+ }
721
+ return low;
722
+ }
723
+ /**
724
+ * Inserts an entry at its sorted position (binary search with compareRankedEntries).
725
+ * @param ranking The ranking.
726
+ * @param entry The entry.
727
+ * @param sortOrder The board's sort order.
728
+ * @returns The entry's index.
729
+ */
730
+ function insertEntry(ranking, entry, sortOrder) {
731
+ const index = sortedPositionOf(ranking, entry, sortOrder);
732
+ ranking.entries.splice(index, 0, entry);
733
+ ranking.entryByPlayer.set(entry.playerId, entry);
734
+ ranking.indexByPlayer = null;
735
+ return index;
736
+ }
737
+ /**
738
+ * A season in its file form.
739
+ * @param season The season.
740
+ * @returns The file.
741
+ */
742
+ function toSeasonFile(season) {
743
+ const variants = {};
744
+ for (const [variantKey, ranking] of season.variants) {
745
+ variants[variantKey] = { entries: ranking.entries, removed: Array.from(ranking.removed.values()) };
746
+ }
747
+ return { variants: variants, lastSubmissionAt: season.lastSubmissionAt };
748
+ }
749
+ /**
750
+ * Deletes map records whose expiresAtMs has passed.
751
+ * @param records The records.
752
+ * @param nowMs The time to compare with.
753
+ */
754
+ function pruneExpired(records, nowMs) {
755
+ for (const [recordKey, record] of records) {
756
+ if (record.expiresAtMs <= nowMs)
757
+ records.delete(recordKey);
758
+ }
759
+ }
760
+ /**
761
+ * Whether a name or id contains a lower-case needle.
762
+ * @param displayName The name.
763
+ * @param playerId The id.
764
+ * @param needle The lower-case text ("" matches any).
765
+ * @returns True on a match.
766
+ */
767
+ function matchesText(displayName, playerId, needle) {
768
+ return displayName.toLowerCase().includes(needle) || playerId.toLowerCase().includes(needle);
769
+ }
770
+ /**
771
+ * Reads a JSON file, or null when it does not exist.
772
+ * @param filePath The file.
773
+ * @returns The parsed value, or null.
774
+ */
775
+ async function readJsonIfPresent(filePath) {
776
+ try {
777
+ return JSON.parse(await readFile(filePath, "utf8"));
778
+ }
779
+ catch (error) {
780
+ if (error.code === FILE_MISSING_CODE)
781
+ return null;
782
+ throw error;
783
+ }
784
+ }
785
+ /**
786
+ * Deletes a file that may not exist.
787
+ * @param filePath The file.
788
+ */
789
+ async function deleteFileIfPresent(filePath) {
790
+ try {
791
+ await unlink(filePath);
792
+ }
793
+ catch (error) {
794
+ if (error.code !== FILE_MISSING_CODE)
795
+ throw error;
796
+ }
797
+ }
798
+ /**
799
+ * Writes a file through a temporary file and a rename, so a crash never leaves half a file.
800
+ * @param filePath The file.
801
+ * @param text Its new text.
802
+ */
803
+ async function writeFileAtomically(filePath, text) {
804
+ await mkdir(path.dirname(filePath), { recursive: true });
805
+ const temporaryPath = filePath + ".tmp-" + process.pid;
806
+ await writeFile(temporaryPath, text, "utf8");
807
+ await rename(temporaryPath, filePath);
808
+ }