@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,1667 @@
1
+ // The leaderboard service (game-server-leaderboards D4, DR §5): boards and seasons in the admin store, rankings and
2
+ // players in the rank store, the submission pipeline, reads, activity, status and session presence. Created by
3
+ // GameServer on every server, independent of the admin console. General purpose: nothing here knows any game (D2).
4
+ import { randomUUID } from "node:crypto";
5
+ import { readFile } from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { AdminConflictError } from "../admin/AdminStore.js";
8
+ import { getLogger } from "../GameServerLogger.js";
9
+ import { DEFAULT_LEADERBOARD_RULES, LEADERBOARD_DEFINITION_CACHE_MS, LEADERBOARD_DURATION_TOLERANCE_MS, LEADERBOARD_READ_CACHE_MS, LEADERBOARD_RULES_DOCUMENT_ID, LEADERBOARD_SESSION_WINDOW_MS, MILLISECONDS_PER_HOUR, MILLISECONDS_PER_MINUTE, SUBMISSION_ID_PATTERN } from "./LeaderboardConstants.js";
10
+ import { LeaderboardError } from "./LeaderboardError.js";
11
+ import { ANONYMOUS_PLAYER_PREFIX, createAnonymousKey, defaultDisplayName, isPlayerKeyValid, MANUAL_PLAYER_PREFIX, parsePlayerKey, USER_PLAYER_PREFIX, userPlayerId } from "./LeaderboardIdentity.js";
12
+ import { LeaderboardScheduler } from "./LeaderboardScheduler.js";
13
+ import { checkMetadata, checkScore, cleanDisplayName, nextResetAt, rankKeyText, validateBoardDefinition, validateRules, variantKeyOf, variantValuesOf } from "./LeaderboardRules.js";
14
+ /** Admin store collection of the boards. */
15
+ const BOARDS_COLLECTION = "leaderboards";
16
+ /** Admin store collection of the rules. */
17
+ const SETTINGS_COLLECTION = "leaderboardSettings";
18
+ /** Admin store stream of the activity (D21). */
19
+ const ACTIVITY_STREAM = "leaderboardActivity";
20
+ /** The format marker of a definitions file (D11). */
21
+ const DEFINITIONS_FORMAT = "babylon-game-server-leaderboard-definitions";
22
+ /** The format marker of an export file. */
23
+ const EXPORT_FORMAT = "babylon-game-server-leaderboards";
24
+ /** Retries of a season reset after a compare-and-set conflict. */
25
+ const RESET_RETRY_LIMIT = 3;
26
+ /** Records read per page of an activity scan. */
27
+ const ACTIVITY_PAGE_SIZE = 500;
28
+ /** Most records one activity read scans. */
29
+ const ACTIVITY_SCAN_LIMIT = 5000;
30
+ /** Longest gameMode and buildVersion. */
31
+ const MAXIMUM_LABEL_TEXT_LENGTH = 64;
32
+ /** Longest session name in a submission. */
33
+ const MAXIMUM_SESSION_NAME_LENGTH = 128;
34
+ /** Most players one search returns. */
35
+ const PLAYER_SEARCH_LIMIT = 50;
36
+ /** Rank shown on a removed entry (removed entries are not ranked). */
37
+ const UNRANKED = 0;
38
+ /** Most matches of an entry search. */
39
+ const ENTRY_SEARCH_LIMIT = 50;
40
+ /** Top-N read cache entries kept before the cache is emptied. */
41
+ const READ_CACHE_CAPACITY = 1000;
42
+ /** Who removes a hidden player's entries. */
43
+ const SYSTEM_ACTOR = "system";
44
+ /** Removal reason of a hidden player's entries (D18). */
45
+ const PLAYER_HIDDEN_REASON = "playerHidden";
46
+ /** Removal reason of an anonymous player's entry swept on start (leaderboard-signed-in-only D4). */
47
+ const ANONYMOUS_PLAYER_REMOVAL_REASON = "anonymousPlayer";
48
+ /** Who removes anonymous players' entries on start (leaderboard-signed-in-only D4). */
49
+ const SERVER_REMOVER_NAME = "server";
50
+ /** Outcomes that count as a personal best. */
51
+ const PERSONAL_BEST_OUTCOMES = ["created", "improved", "replaced", "added"];
52
+ /** Boards, seasons, rules, players, submissions, reads, activity, status and presence (D4). */
53
+ export class LeaderboardService {
54
+ /** The options it was opened with. */
55
+ options;
56
+ /** The cached boards, or null. */
57
+ boardCache = null;
58
+ /** The cached rules, or null. */
59
+ rulesCache = null;
60
+ /** Cached top-N pages by `rankKeyText|offset|limit`. */
61
+ readCache = new Map();
62
+ /** The definitions-file problem found by the last start, or null. */
63
+ definitionsProblem = null;
64
+ /** The scheduler while started, or null. */
65
+ scheduler = null;
66
+ /**
67
+ * Creates the service; open() is the public way.
68
+ * @param options The options.
69
+ */
70
+ constructor(options) {
71
+ this.options = options;
72
+ }
73
+ /**
74
+ * Opens the service (nothing runs until start()).
75
+ * @param options The stores, state, clock and scheduler settings.
76
+ * @returns The service.
77
+ */
78
+ static async open(options) {
79
+ return new LeaderboardService(options);
80
+ }
81
+ /** The admin store and rank store, for LeaderboardTransfer (T6). */
82
+ get stores() {
83
+ return { adminStore: this.options.adminStore, rankStore: this.options.rankStore };
84
+ }
85
+ /**
86
+ * Imports the definitions file, hides anonymous entries when only signed-in players may submit, starts listening for
87
+ * session presence and starts the scheduler.
88
+ */
89
+ async start() {
90
+ await this.importDefinitionsFile();
91
+ if (this.options.config.leaderboards.signedInPlayersOnly)
92
+ await this.hideAnonymousEntries();
93
+ this.options.state.onLeaderboardSessionPresence = (event) => this.recordPresence(event);
94
+ this.scheduler ??= new LeaderboardScheduler(this, this.options.adminStore, this.options.clock, this.options.processId, this.options.schedulerTickMs);
95
+ this.scheduler.start();
96
+ }
97
+ /** Stops the scheduler, stops listening for session presence and closes the rank store. */
98
+ async stop() {
99
+ await this.scheduler?.stop();
100
+ this.scheduler = null;
101
+ this.options.state.onLeaderboardSessionPresence = null;
102
+ await this.options.rankStore.close();
103
+ }
104
+ // ---- Boards and seasons -------------------------------------------------------------------------------------------
105
+ /**
106
+ * Lists every board document (cached for LEADERBOARD_DEFINITION_CACHE_MS), sorted by board id.
107
+ * @returns The documents.
108
+ */
109
+ async listBoardRecords() {
110
+ const boards = await this.cachedBoards();
111
+ return Array.from(boards.values()).sort((first, second) => first.id.localeCompare(second.id));
112
+ }
113
+ /**
114
+ * Reads one board document fresh from the admin store.
115
+ * @param boardId The board.
116
+ * @returns The document.
117
+ * @throws LeaderboardError 404 unknownBoard when it does not exist.
118
+ */
119
+ async getBoardRecord(boardId) {
120
+ const stored = await this.options.adminStore.getDocument(BOARDS_COLLECTION, boardId);
121
+ if (stored == null)
122
+ throw new LeaderboardError(404, "unknownBoard", "there is no leaderboard " + boardId);
123
+ return stored;
124
+ }
125
+ /**
126
+ * The public boards as game clients see them.
127
+ * @returns The boards, sorted by board id.
128
+ */
129
+ async listPublicBoards() {
130
+ const records = await this.listBoardRecords();
131
+ return records.filter((stored) => stored.value.definition.visibility === "public").map((stored) => toPublicBoardView(stored.value));
132
+ }
133
+ /**
134
+ * A board's console summary: definition, seasons, live entries of the current season and the last submission.
135
+ * @param stored The board document.
136
+ * @returns The summary.
137
+ */
138
+ async summarizeBoard(stored) {
139
+ const record = stored.value;
140
+ const variantKeys = await this.options.rankStore.listVariantKeys(record.definition.boardId, record.currentSeasonId);
141
+ let entryCount = 0;
142
+ for (const variantKey of variantKeys)
143
+ entryCount += await this.options.rankStore.count({ boardId: record.definition.boardId, seasonId: record.currentSeasonId, variantKey: variantKey });
144
+ return {
145
+ definition: record.definition,
146
+ revision: stored.revision,
147
+ currentSeasonId: record.currentSeasonId,
148
+ seasons: record.seasons,
149
+ nextResetAt: record.nextResetAt,
150
+ entryCount: entryCount,
151
+ lastSubmissionAt: await this.options.rankStore.readLastSubmissionAt(record.definition.boardId)
152
+ };
153
+ }
154
+ /**
155
+ * A board's detail view: its summary and every variant of one season with its top entry.
156
+ * @param boardId The board.
157
+ * @param seasonId The season, or null for the current one.
158
+ * @returns The detail view.
159
+ */
160
+ async describeBoard(boardId, seasonId) {
161
+ const stored = await this.getBoardRecord(boardId);
162
+ const resolvedSeasonId = resolveSeasonId(stored.value, seasonId);
163
+ const variants = [];
164
+ for (const variantKey of await this.options.rankStore.listVariantKeys(boardId, resolvedSeasonId)) {
165
+ const key = { boardId: boardId, seasonId: resolvedSeasonId, variantKey: variantKey };
166
+ const [topEntry] = await this.options.rankStore.readRange(key, 0, 1);
167
+ variants.push({ variantKey: variantKey, values: variantValuesOf(variantKey), entryCount: await this.options.rankStore.count(key), topEntry: (topEntry != null) ? toEntryView(topEntry) : null });
168
+ }
169
+ return { summary: await this.summarizeBoard(stored), seasonId: resolvedSeasonId, variants: variants };
170
+ }
171
+ /**
172
+ * Creates a board with season 1.
173
+ * @param definition The definition (any JSON; validated).
174
+ * @returns The new board's summary.
175
+ * @throws LeaderboardError 400 invalidRequest for an invalid definition; AdminConflictError when the id exists.
176
+ */
177
+ async createBoard(definition) {
178
+ const valid = requireValidDefinition(definition);
179
+ const stored = await this.options.adminStore.putDocument(BOARDS_COLLECTION, valid.boardId, this.newBoardRecord(valid), 0);
180
+ this.boardCache = null;
181
+ return await this.summarizeBoard(stored);
182
+ }
183
+ /**
184
+ * Updates a board's definition. Changing sortOrder, scoreFormat or keepPolicy while the current season has entries
185
+ * needs startNewSeason (D17); startNewSeason always starts the next season in the same write.
186
+ * @param boardId The board.
187
+ * @param definition The new definition (its boardId must match).
188
+ * @param revision The revision the editor read.
189
+ * @param startNewSeason True to start the next season.
190
+ * @returns The updated summary.
191
+ * @throws LeaderboardError 400 for an invalid definition, 409 for a ranking change that needs a new season;
192
+ * AdminConflictError for a stale revision.
193
+ */
194
+ async updateBoard(boardId, definition, revision, startNewSeason) {
195
+ const valid = requireValidDefinition(definition);
196
+ if (valid.boardId !== boardId)
197
+ throw new LeaderboardError(400, "invalidRequest", "boardId cannot be changed; duplicate the board instead");
198
+ const stored = await this.getBoardRecord(boardId);
199
+ const current = stored.value.definition;
200
+ const changesRanking = valid.sortOrder !== current.sortOrder || valid.scoreFormat !== current.scoreFormat || valid.keepPolicy !== current.keepPolicy;
201
+ if (changesRanking && !startNewSeason && (await this.summarizeBoard(stored)).entryCount > 0) {
202
+ throw new LeaderboardError(409, "invalidRequest", "changing sortOrder, scoreFormat or keepPolicy needs a new season while the current season has entries; set startNewSeason");
203
+ }
204
+ const nowMs = this.options.clock.now();
205
+ const scheduleChanged = valid.resetSchedule !== current.resetSchedule;
206
+ const base = startNewSeason ? advanceSeason(stored.value, nowMs) : stored.value;
207
+ const updated = { ...base, definition: valid, nextResetAt: (scheduleChanged || startNewSeason) ? nextResetAt(valid.resetSchedule, nowMs) : base.nextResetAt };
208
+ const written = await this.options.adminStore.putDocument(BOARDS_COLLECTION, boardId, updated, revision);
209
+ this.boardCache = null;
210
+ return await this.summarizeBoard(written);
211
+ }
212
+ /**
213
+ * Creates a copy of a board's definition under a new id (no entries).
214
+ * @param boardId The source board.
215
+ * @param newBoardId The new id.
216
+ * @param displayName The new name.
217
+ * @returns The new board's summary.
218
+ */
219
+ async duplicateBoard(boardId, newBoardId, displayName) {
220
+ const stored = await this.getBoardRecord(boardId);
221
+ return await this.createBoard({ ...stored.value.definition, boardId: newBoardId, displayName: displayName });
222
+ }
223
+ /**
224
+ * Deletes a board and every ranking of every season (activity records stay, DR §15).
225
+ * @param boardId The board.
226
+ */
227
+ async deleteBoard(boardId) {
228
+ const stored = await this.getBoardRecord(boardId);
229
+ const seasonIds = stored.value.seasons.map((season) => season.seasonId);
230
+ await this.options.rankStore.deleteBoard(boardId, seasonIds);
231
+ await this.options.adminStore.deleteDocument(BOARDS_COLLECTION, boardId);
232
+ this.boardCache = null;
233
+ this.readCache.clear();
234
+ }
235
+ /**
236
+ * Ends the current season and starts the next (compare-and-set, retried on conflict). A scheduled reset that finds
237
+ * the board already reset by another writer returns without resetting again.
238
+ * @param boardId The board.
239
+ * @param trigger "admin" from the console, "schedule" from the scheduler.
240
+ * @returns The board's summary.
241
+ * @throws LeaderboardError 409 for a board that never resets.
242
+ */
243
+ async resetSeason(boardId, trigger) {
244
+ let lastConflict = null;
245
+ for (let attempt = 0; attempt <= RESET_RETRY_LIMIT; attempt++) {
246
+ const stored = await this.getBoardRecord(boardId);
247
+ if (stored.value.definition.resetSchedule === "never")
248
+ throw new LeaderboardError(409, "invalidRequest", "this board never resets; change its schedule first");
249
+ const nowMs = this.options.clock.now();
250
+ if (trigger === "schedule" && !isResetDue(stored.value, nowMs))
251
+ return await this.summarizeBoard(stored);
252
+ const advanced = { ...advanceSeason(stored.value, nowMs), nextResetAt: nextResetAt(stored.value.definition.resetSchedule, nowMs) };
253
+ try {
254
+ const written = await this.options.adminStore.putDocument(BOARDS_COLLECTION, boardId, advanced, stored.revision);
255
+ this.boardCache = null;
256
+ return await this.summarizeBoard(written);
257
+ }
258
+ catch (error) {
259
+ if (!(error instanceof AdminConflictError))
260
+ throw error;
261
+ lastConflict = error;
262
+ }
263
+ }
264
+ throw lastConflict;
265
+ }
266
+ /**
267
+ * Deletes an archived season's rankings and drops it from the board's season list.
268
+ * @param boardId The board.
269
+ * @param seasonId The season.
270
+ * @returns How many entries were deleted.
271
+ * @throws LeaderboardError 409 for the current season, 404 for a season the board does not keep.
272
+ */
273
+ async deleteSeason(boardId, seasonId) {
274
+ const stored = await this.getBoardRecord(boardId);
275
+ if (seasonId === stored.value.currentSeasonId)
276
+ throw new LeaderboardError(409, "invalidRequest", "the current season cannot be deleted; reset the board first");
277
+ resolveSeasonId(stored.value, seasonId);
278
+ const deletedCount = await this.options.rankStore.deleteSeason(boardId, seasonId);
279
+ const seasons = stored.value.seasons.filter((season) => season.seasonId !== seasonId);
280
+ await this.options.adminStore.putDocument(BOARDS_COLLECTION, boardId, { ...stored.value, seasons: seasons }, stored.revision);
281
+ this.boardCache = null;
282
+ this.readCache.clear();
283
+ return deletedCount;
284
+ }
285
+ /**
286
+ * A board's seasons, oldest first.
287
+ * @param boardId The board.
288
+ * @param audience "public" hides hidden boards (404).
289
+ * @returns The seasons.
290
+ */
291
+ async listSeasons(boardId, audience) {
292
+ const record = await this.boardFor(boardId, audience);
293
+ return record.seasons;
294
+ }
295
+ // ---- Rules --------------------------------------------------------------------------------------------------------
296
+ /**
297
+ * The rules in force, the defaults and the stored revision (0 while the defaults apply).
298
+ * @returns The rules view.
299
+ */
300
+ async getRulesView() {
301
+ const nowMs = this.options.clock.now();
302
+ if (this.rulesCache != null && this.rulesCache.expiresAtMs > nowMs)
303
+ return this.rulesCache.view;
304
+ const stored = await this.options.adminStore.getDocument(SETTINGS_COLLECTION, LEADERBOARD_RULES_DOCUMENT_ID);
305
+ const view = {
306
+ rules: (stored != null) ? { ...DEFAULT_LEADERBOARD_RULES, ...stored.value } : { ...DEFAULT_LEADERBOARD_RULES },
307
+ defaults: { ...DEFAULT_LEADERBOARD_RULES },
308
+ revision: (stored != null) ? stored.revision : 0
309
+ };
310
+ this.rulesCache = { view: view, expiresAtMs: nowMs + LEADERBOARD_DEFINITION_CACHE_MS };
311
+ return view;
312
+ }
313
+ /**
314
+ * Saves new rules.
315
+ * @param rules The rules (any JSON; validated).
316
+ * @param revision The revision the editor read (0 while the defaults apply).
317
+ * @returns The new rules view.
318
+ * @throws LeaderboardError 400 for invalid rules; AdminConflictError for a stale revision.
319
+ */
320
+ async updateRules(rules, revision) {
321
+ const checked = validateRules(rules);
322
+ if (checked.rules == null)
323
+ throw new LeaderboardError(400, "invalidRequest", checked.problems.join("; "));
324
+ await this.options.adminStore.putDocument(SETTINGS_COLLECTION, LEADERBOARD_RULES_DOCUMENT_ID, checked.rules, revision);
325
+ this.rulesCache = null;
326
+ return await this.getRulesView();
327
+ }
328
+ // ---- Players and identity -----------------------------------------------------------------------------------------
329
+ /**
330
+ * Opens a player session (POST players): the auth user's player (created on first use), the player of a valid key,
331
+ * or a new anonymous player with its one-time key.
332
+ * @param caller The caller.
333
+ * @returns The player and, only for a new anonymous player, its key.
334
+ * @throws LeaderboardError 401 invalidPlayer for a wrong key, 429 rateLimited over identitiesPerHourPerAddress.
335
+ */
336
+ async openPlayerSession(caller) {
337
+ if (caller.userId != null || caller.playerKey != null) {
338
+ const player = await this.resolvePlayer(caller);
339
+ return { player: toPlayerView(player), playerKey: null };
340
+ }
341
+ const rules = (await this.getRulesView()).rules;
342
+ const nowMs = this.options.clock.now();
343
+ const isAllowed = await this.options.rankStore.consumeRate("identity-address:" + caller.clientAddress, rules.identitiesPerHourPerAddress, MILLISECONDS_PER_HOUR, nowMs);
344
+ if (!isAllowed)
345
+ throw new LeaderboardError(429, "rateLimited", "too many new players from this address; try again later");
346
+ const identity = createAnonymousKey();
347
+ const nowIso = new Date(nowMs).toISOString();
348
+ const record = { playerId: identity.playerId, kind: "anonymous", keyHash: identity.keyHash, displayName: defaultDisplayName(identity.playerId), hidden: false, createdAt: nowIso, lastSeenAt: nowIso };
349
+ await this.options.rankStore.putPlayer(record);
350
+ return { player: toPlayerView(record), playerKey: identity.playerKey };
351
+ }
352
+ /**
353
+ * Resolves the caller's player without creating an anonymous one: the auth user's player (its record is created on
354
+ * first use, D8) or the player of a valid key.
355
+ * @param caller The caller.
356
+ * @returns The player record.
357
+ * @throws LeaderboardError 401 invalidPlayer when there is no auth user and no valid key.
358
+ */
359
+ async resolvePlayer(caller) {
360
+ if (caller.userId != null)
361
+ return await this.userPlayer(caller.userId, caller.userDisplayName, caller.userAvatar);
362
+ const parsed = (caller.playerKey != null) ? parsePlayerKey(caller.playerKey) : null;
363
+ const record = (parsed != null) ? await this.options.rankStore.getPlayer(parsed.playerId) : null;
364
+ if (parsed == null || record == null || !isPlayerKeyValid(record, parsed.secret)) {
365
+ throw new LeaderboardError(401, "invalidPlayer", "the player key is missing or not valid");
366
+ }
367
+ return record;
368
+ }
369
+ /**
370
+ * The caller's own player (POST players/me).
371
+ * @param caller The caller.
372
+ * @returns The player.
373
+ * @throws LeaderboardError 401 invalidPlayer.
374
+ */
375
+ async getOwnPlayer(caller) {
376
+ return toPlayerView(await this.resolvePlayer(caller));
377
+ }
378
+ /**
379
+ * Erases the caller's own player and every entry of theirs (POST players/me/delete).
380
+ * @param caller The caller.
381
+ * @returns How many entries were deleted.
382
+ * @throws LeaderboardError 401 invalidPlayer.
383
+ */
384
+ async eraseOwnPlayer(caller) {
385
+ const player = await this.resolvePlayer(caller);
386
+ return await this.erasePlayer(player.playerId);
387
+ }
388
+ /**
389
+ * Gives a signed-in user their player at sign-in (D16): an existing player gets its name and avatar refreshed and
390
+ * nothing is merged; a new one is created and, when a valid anonymous key is sent, that anonymous player's history
391
+ * is merged into it (the anonymous player is erased by the merge).
392
+ * @param claim The user and the browser's anonymous key.
393
+ * @returns The player id and whether a merge happened.
394
+ */
395
+ async claimUserPlayer(claim) {
396
+ const playerId = userPlayerId(claim.userId);
397
+ const existing = await this.options.rankStore.getPlayer(playerId);
398
+ if (existing != null) {
399
+ await this.refreshUserProfile(existing, claim.displayName, claim.avatar);
400
+ return { playerId: playerId, merged: false };
401
+ }
402
+ const created = await this.userPlayer(claim.userId, claim.displayName, claim.avatar);
403
+ const anonymous = await this.anonymousPlayerOf(claim.anonymousPlayerKey);
404
+ if (anonymous == null)
405
+ return { playerId: created.playerId, merged: false };
406
+ await this.mergePlayers(anonymous.playerId, created.playerId);
407
+ return { playerId: created.playerId, merged: true };
408
+ }
409
+ /**
410
+ * Fills each entry's avatar from its player's record (only user: players can have one).
411
+ * @param entries The entries of one public read.
412
+ * @returns New entry objects with avatar set (null when the player has none).
413
+ */
414
+ async attachAvatars(entries) {
415
+ const avatarsByPlayer = new Map();
416
+ for (const entry of entries) {
417
+ if (!entry.playerId.startsWith(USER_PLAYER_PREFIX) || avatarsByPlayer.has(entry.playerId))
418
+ continue;
419
+ const record = await this.options.rankStore.getPlayer(entry.playerId);
420
+ avatarsByPlayer.set(entry.playerId, record?.avatar ?? null);
421
+ }
422
+ return entries.map((entry) => ({ ...entry, avatar: avatarsByPlayer.get(entry.playerId) ?? null }));
423
+ }
424
+ /**
425
+ * Consumes one public read of an address against readsPerMinutePerAddress (D19 read limit scope).
426
+ * @param clientAddress The caller's address.
427
+ * @throws LeaderboardError 429 rateLimited.
428
+ */
429
+ async consumeReadRate(clientAddress) {
430
+ const rules = (await this.getRulesView()).rules;
431
+ const isAllowed = await this.options.rankStore.consumeRate("read-address:" + clientAddress, rules.readsPerMinutePerAddress, MILLISECONDS_PER_MINUTE, this.options.clock.now());
432
+ if (!isAllowed)
433
+ throw new LeaderboardError(429, "rateLimited", "too many leaderboard reads; try again in a minute");
434
+ }
435
+ /**
436
+ * Renames the caller's own player (POST players/me/name) with the current rules' blocked words.
437
+ * @param caller The caller.
438
+ * @param displayName The new name (any JSON; cleaned).
439
+ * @returns The player.
440
+ * @throws LeaderboardError 403 nameManaged for a signed-in caller whose session owns the name, 400 nameInvalid or
441
+ * nameBlocked, 401 invalidPlayer.
442
+ */
443
+ async renameOwnPlayer(caller, displayName) {
444
+ if (caller.isNameManaged)
445
+ throw new LeaderboardError(403, "nameManaged", "this player's name comes from their sign-in and cannot be changed here");
446
+ const player = await this.resolvePlayer(caller);
447
+ const cleanName = await this.requireCleanName(displayName);
448
+ const renamed = { ...player, displayName: cleanName, lastSeenAt: this.nowIso() };
449
+ await this.options.rankStore.putPlayer(renamed);
450
+ await this.options.rankStore.renamePlayerEntries(player.playerId, cleanName);
451
+ this.readCache.clear();
452
+ return toPlayerView(renamed);
453
+ }
454
+ // ---- Submissions and reads ----------------------------------------------------------------------------------------
455
+ /**
456
+ * Runs the submission pipeline (DR §10) in its binding order: request shape, score count, player, rate limits, trust,
457
+ * then per score board, variant, score, trust, metadata and write, each recorded in activity.
458
+ * @param caller The caller.
459
+ * @param request The envelope (a LeaderboardServerSubmissionRequest for source "server").
460
+ * @param source "public" from the public API, "server" from the admin route (server trust).
461
+ * @returns The per-score results.
462
+ * @throws LeaderboardError 400 invalidRequest or tooManyScores, 401 invalidPlayer, 403 signInRequired (a public
463
+ * caller with no auth user id while leaderboards.signedInPlayersOnly is on), 429 rateLimited.
464
+ */
465
+ async submit(caller, request, source) {
466
+ requireValidEnvelope(request);
467
+ if (source === "public" && this.options.config.leaderboards.signedInPlayersOnly && caller.userId == null) {
468
+ throw new LeaderboardError(403, "signInRequired", "this server's leaderboards accept only signed-in players");
469
+ }
470
+ const rules = (await this.getRulesView()).rules;
471
+ if (request.scores.length > rules.maximumScoresPerSubmission) {
472
+ throw new LeaderboardError(400, "tooManyScores", "a submission may carry at most " + rules.maximumScoresPerSubmission + " scores");
473
+ }
474
+ const player = (source === "server") ? await this.serverPlayer(request) : await this.resolvePlayer(caller);
475
+ const nowMs = this.options.clock.now();
476
+ const context = { caller: caller, request: request, source: source, player: player, rules: rules, nowMs: nowMs, baseTrust: baseTrustOf(source, request) };
477
+ if (source === "public")
478
+ await this.consumeSubmissionRates(context);
479
+ const results = [];
480
+ for (const score of request.scores)
481
+ results.push(await this.submitScore(context, score));
482
+ await this.options.rankStore.putPlayer({ ...player, lastSeenAt: new Date(nowMs).toISOString() });
483
+ return { submissionId: request.submissionId, playerId: player.playerId, results: results };
484
+ }
485
+ /**
486
+ * Reads a page of one ranking (top-N reads are cached for LEADERBOARD_READ_CACHE_MS).
487
+ * @param boardId The board.
488
+ * @param variant The variant (null or {} without dimensions).
489
+ * @param seasonId The season, or null for the current one.
490
+ * @param offset Zero-based index of the first entry.
491
+ * @param limit Most entries.
492
+ * @param audience "public" hides hidden boards and checks variant values; "admin" also reads retired values.
493
+ * @returns The page.
494
+ */
495
+ async readEntries(boardId, variant, seasonId, offset, limit, audience) {
496
+ const key = await this.rankKeyFor(boardId, variant, seasonId, audience);
497
+ const cacheKey = rankKeyText(key) + "|" + offset + "|" + limit;
498
+ const nowMs = this.options.clock.now();
499
+ const cached = this.readCache.get(cacheKey);
500
+ if (cached != null && cached.expiresAtMs > nowMs)
501
+ return cached.page;
502
+ const entries = await this.options.rankStore.readRange(key, offset, limit);
503
+ const page = { ...pageHeader(key, await this.options.rankStore.count(key)), offset: offset, entries: entries.map(toEntryView) };
504
+ if (this.readCache.size >= READ_CACHE_CAPACITY)
505
+ this.readCache.clear();
506
+ this.readCache.set(cacheKey, { page: page, expiresAtMs: nowMs + LEADERBOARD_READ_CACHE_MS });
507
+ return page;
508
+ }
509
+ /**
510
+ * Finds entries of one ranking by name or player id (console search).
511
+ * @param boardId The board.
512
+ * @param variantKey The variant key.
513
+ * @param seasonId The season.
514
+ * @param text The text.
515
+ * @returns The matches as a page.
516
+ */
517
+ async searchEntries(boardId, variantKey, seasonId, text) {
518
+ await this.getBoardRecord(boardId);
519
+ const key = { boardId: boardId, seasonId: seasonId, variantKey: variantKey };
520
+ const entries = await this.options.rankStore.searchEntries(key, text, ENTRY_SEARCH_LIMIT);
521
+ return { ...pageHeader(key, await this.options.rankStore.count(key)), offset: 0, entries: entries.map(toEntryView) };
522
+ }
523
+ /**
524
+ * Reads the window around a player's entry.
525
+ * @param boardId The board.
526
+ * @param variant The variant.
527
+ * @param seasonId The season, or null for the current one.
528
+ * @param playerId The player.
529
+ * @param window Entries above and below.
530
+ * @returns The page with the player's rank (null without an entry).
531
+ */
532
+ async readAround(boardId, variant, seasonId, playerId, window) {
533
+ const key = await this.rankKeyFor(boardId, variant, seasonId, "public");
534
+ const entries = await this.options.rankStore.readAround(key, playerId, window);
535
+ const own = entries.find((entry) => entry.playerId === playerId);
536
+ const offset = (entries.length > 0) ? entries[0].rank - 1 : 0;
537
+ return { ...pageHeader(key, await this.options.rankStore.count(key)), offset: offset, entries: entries.map(toEntryView), playerRank: (own != null) ? own.rank : null };
538
+ }
539
+ /**
540
+ * Reads a player's entry in each of several rankings.
541
+ * @param playerId The player.
542
+ * @param targets The rankings.
543
+ * @returns One answer per target, in order.
544
+ */
545
+ async readTargets(playerId, targets) {
546
+ const answers = [];
547
+ for (const target of targets) {
548
+ const key = await this.rankKeyFor(target.boardId, target.variant, target.seasonId ?? null, "public");
549
+ const entry = await this.options.rankStore.readEntry(key, playerId);
550
+ answers.push({ boardId: key.boardId, variantKey: key.variantKey, seasonId: key.seasonId, total: await this.options.rankStore.count(key), entry: (entry != null) ? toEntryView(entry) : null });
551
+ }
552
+ return answers;
553
+ }
554
+ /**
555
+ * Reads the entries of named players in one ranking (friends lists).
556
+ * @param boardId The board.
557
+ * @param variant The variant.
558
+ * @param seasonId The season, or null for the current one.
559
+ * @param playerIds The players.
560
+ * @returns The entries they have, by rank.
561
+ */
562
+ async readPlayersEntries(boardId, variant, seasonId, playerIds) {
563
+ const key = await this.rankKeyFor(boardId, variant, seasonId, "public");
564
+ const entries = [];
565
+ for (const playerId of new Set(playerIds)) {
566
+ const entry = await this.options.rankStore.readEntry(key, playerId);
567
+ if (entry != null)
568
+ entries.push(toEntryView(entry));
569
+ }
570
+ return entries.sort((first, second) => first.rank - second.rank);
571
+ }
572
+ // ---- Moderation ---------------------------------------------------------------------------------------------------
573
+ /**
574
+ * The removed entries of one ranking, newest removal first.
575
+ * @param boardId The board.
576
+ * @param variantKey The variant key.
577
+ * @param seasonId The season.
578
+ * @returns The removed entries (rank 0: they are not ranked).
579
+ */
580
+ async readRemoved(boardId, variantKey, seasonId) {
581
+ const key = await this.adminRankKey(boardId, variantKey, seasonId);
582
+ const removed = await this.options.rankStore.readRemoved(key);
583
+ return removed.map(toRemovedEntryView);
584
+ }
585
+ /**
586
+ * Removes a live entry from its ranking (D18).
587
+ * @param boardId The board.
588
+ * @param variantKey The variant key.
589
+ * @param seasonId The season.
590
+ * @param playerId The player.
591
+ * @param reason Why (shown in the console).
592
+ * @param actorName Who removed it.
593
+ * @throws LeaderboardError 404 when the player has no live entry.
594
+ */
595
+ async removeEntry(boardId, variantKey, seasonId, playerId, reason, actorName) {
596
+ const key = await this.adminRankKey(boardId, variantKey, seasonId);
597
+ const wasRemoved = await this.options.rankStore.removeEntry(key, playerId, reason, actorName, this.nowIso());
598
+ if (!wasRemoved)
599
+ throw new LeaderboardError(404, "invalidRequest", "player " + playerId + " has no live entry in this ranking");
600
+ this.forgetCachedReads(key);
601
+ }
602
+ /**
603
+ * Puts a removed entry back into its ranking.
604
+ * @param boardId The board.
605
+ * @param variantKey The variant key.
606
+ * @param seasonId The season.
607
+ * @param playerId The player.
608
+ * @throws LeaderboardError 409 while the player has a live entry, 404 when nothing was removed.
609
+ */
610
+ async restoreEntry(boardId, variantKey, seasonId, playerId) {
611
+ const record = (await this.getBoardRecord(boardId)).value;
612
+ const key = { boardId: boardId, seasonId: resolveSeasonId(record, seasonId), variantKey: variantKey };
613
+ const restored = await this.options.rankStore.restoreEntry(key, playerId, record.definition.sortOrder);
614
+ if (restored === "liveEntryExists")
615
+ throw new LeaderboardError(409, "invalidRequest", "this player already has a live entry in this ranking; remove it first");
616
+ if (restored === "missing")
617
+ throw new LeaderboardError(404, "invalidRequest", "player " + playerId + " has no removed entry in this ranking");
618
+ this.forgetCachedReads(key);
619
+ }
620
+ /**
621
+ * Corrects a live entry's score (keep policy latest, a fresh achieved time).
622
+ * @param boardId The board.
623
+ * @param variantKey The variant key.
624
+ * @param seasonId The season.
625
+ * @param playerId The player.
626
+ * @param score The corrected score.
627
+ * @returns The corrected entry with its new rank.
628
+ * @throws LeaderboardError 400 for a score the board refuses, 404 without a live entry.
629
+ */
630
+ async correctEntry(boardId, variantKey, seasonId, playerId, score) {
631
+ const record = (await this.getBoardRecord(boardId)).value;
632
+ requireAcceptableScore(record.definition, score);
633
+ const key = { boardId: boardId, seasonId: resolveSeasonId(record, seasonId), variantKey: variantKey };
634
+ const current = await this.options.rankStore.readEntry(key, playerId);
635
+ if (current == null)
636
+ throw new LeaderboardError(404, "invalidRequest", "player " + playerId + " has no live entry in this ranking");
637
+ const nowMs = this.options.clock.now();
638
+ const nowIso = new Date(nowMs).toISOString();
639
+ const corrected = { ...withoutRank(current), score: score, submittedAt: nowIso, achievedAt: nowIso, achievedAtMs: nowMs, submissionCount: 1 };
640
+ return await this.writeAdminEntry(key, record, corrected);
641
+ }
642
+ /**
643
+ * Adds an entry by hand at server trust: for a new manual player when playerId is null, else for that player.
644
+ * @param boardId The board.
645
+ * @param variant The variant.
646
+ * @param playerId The player, or null to create a manual player.
647
+ * @param displayName The new manual player's name (cleaned).
648
+ * @param score The score.
649
+ * @returns The entry with its rank.
650
+ * @throws LeaderboardError 400 for a bad variant, score or name, 404 for an unknown player.
651
+ */
652
+ async addEntry(boardId, variant, playerId, displayName, score) {
653
+ const record = (await this.getBoardRecord(boardId)).value;
654
+ const variantKey = requireVariantKey(record.definition, variant);
655
+ requireAcceptableScore(record.definition, score);
656
+ const player = (playerId == null) ? await this.createManualPlayer(displayName) : await this.requirePlayer(playerId);
657
+ const nowMs = this.options.clock.now();
658
+ const nowIso = new Date(nowMs).toISOString();
659
+ const entry = {
660
+ playerId: player.playerId,
661
+ displayName: player.displayName,
662
+ score: score,
663
+ metadata: null,
664
+ trustLevel: "server",
665
+ sessionName: null,
666
+ gameMode: null,
667
+ buildVersion: null,
668
+ submittedAt: nowIso,
669
+ achievedAt: nowIso,
670
+ achievedAtMs: nowMs,
671
+ submissionCount: 1
672
+ };
673
+ return await this.writeAdminEntry({ boardId: boardId, seasonId: record.currentSeasonId, variantKey: variantKey }, record, entry);
674
+ }
675
+ // ---- Players ------------------------------------------------------------------------------------------------------
676
+ /**
677
+ * Renames a player and every entry of theirs.
678
+ * @param playerId The player.
679
+ * @param displayName The new name (cleaned with the blocked words).
680
+ * @returns The player.
681
+ * @throws LeaderboardError 400 nameInvalid or nameBlocked, 404 for an unknown player.
682
+ */
683
+ async renamePlayer(playerId, displayName) {
684
+ const cleanName = await this.requireCleanName(displayName);
685
+ const player = await this.requirePlayer(playerId);
686
+ const renamed = { ...player, displayName: cleanName };
687
+ await this.options.rankStore.putPlayer(renamed);
688
+ await this.options.rankStore.renamePlayerEntries(playerId, cleanName);
689
+ this.readCache.clear();
690
+ return toPlayerAdminView(renamed);
691
+ }
692
+ /**
693
+ * Hides a player (every live entry moves to removed with reason playerHidden, D18) or unhides them (those entries
694
+ * come back).
695
+ * @param playerId The player.
696
+ * @param hidden True to hide.
697
+ * @param actorName Who did it.
698
+ * @returns The player.
699
+ * @throws LeaderboardError 404 for an unknown player.
700
+ */
701
+ async hidePlayer(playerId, hidden, actorName) {
702
+ const player = await this.requirePlayer(playerId);
703
+ const updated = { ...player, hidden: hidden };
704
+ await this.options.rankStore.putPlayer(updated);
705
+ const nowIso = this.nowIso();
706
+ for (const key of await this.options.rankStore.listPlayerRankKeys(playerId)) {
707
+ if (hidden)
708
+ await this.options.rankStore.removeEntry(key, playerId, PLAYER_HIDDEN_REASON, actorName, nowIso);
709
+ else
710
+ await this.restoreHiddenEntry(key, playerId);
711
+ }
712
+ this.readCache.clear();
713
+ return toPlayerAdminView(updated);
714
+ }
715
+ /**
716
+ * Merges one player into another (DR §15): per ranking of the source, the target board's keep policy decides — best
717
+ * keeps the better, latest the later, sum adds — then the source is erased.
718
+ * @param sourcePlayerId The player merged away.
719
+ * @param targetPlayerId The player kept.
720
+ * @returns The target's detail.
721
+ * @throws LeaderboardError 400 for the same player, 404 for an unknown one.
722
+ */
723
+ async mergePlayers(sourcePlayerId, targetPlayerId) {
724
+ if (sourcePlayerId === targetPlayerId)
725
+ throw new LeaderboardError(400, "invalidRequest", "a player cannot be merged into itself");
726
+ await this.requirePlayer(sourcePlayerId);
727
+ const target = await this.requirePlayer(targetPlayerId);
728
+ const boards = await this.freshBoards();
729
+ for (const key of await this.options.rankStore.listPlayerRankKeys(sourcePlayerId)) {
730
+ const board = boards.get(key.boardId);
731
+ const sourceEntry = await this.options.rankStore.readEntry(key, sourcePlayerId);
732
+ if (board == null || sourceEntry == null)
733
+ continue;
734
+ await this.mergeEntry(key, board.value.definition, withoutRank(sourceEntry), target);
735
+ }
736
+ await this.erasePlayer(sourcePlayerId);
737
+ return await this.getPlayerDetail(targetPlayerId);
738
+ }
739
+ /**
740
+ * Erases a player: every entry, live and removed, and the player record.
741
+ * @param playerId The player.
742
+ * @returns How many entries were deleted.
743
+ * @throws LeaderboardError 404 for a player with neither a record nor entries.
744
+ */
745
+ async erasePlayer(playerId) {
746
+ const erasedCount = await this.options.rankStore.deletePlayerEntries(playerId);
747
+ const hadRecord = await this.options.rankStore.deletePlayer(playerId);
748
+ if (!hadRecord && erasedCount === 0)
749
+ throw new LeaderboardError(404, "invalidPlayer", "there is no player " + playerId);
750
+ this.readCache.clear();
751
+ return erasedCount;
752
+ }
753
+ /**
754
+ * A player and every entry of theirs across boards and seasons, live or removed.
755
+ * @param playerId The player.
756
+ * @returns The detail.
757
+ * @throws LeaderboardError 404 for an unknown player.
758
+ */
759
+ async getPlayerDetail(playerId) {
760
+ const player = await this.requirePlayer(playerId);
761
+ const boards = await this.cachedBoards();
762
+ const entries = [];
763
+ for (const key of await this.options.rankStore.listPlayerRankKeys(playerId)) {
764
+ const entry = await this.playerEntryView(key, playerId, boards.get(key.boardId)?.value ?? null);
765
+ if (entry != null)
766
+ entries.push(entry);
767
+ }
768
+ return { player: toPlayerAdminView(player), entries: entries };
769
+ }
770
+ /**
771
+ * Finds players by name or id.
772
+ * @param text The text ("" lists any).
773
+ * @returns Up to 50 players.
774
+ */
775
+ async searchPlayers(text) {
776
+ const players = await this.options.rankStore.searchPlayers(text, PLAYER_SEARCH_LIMIT);
777
+ return players.map(toPlayerAdminView);
778
+ }
779
+ // ---- Activity, status and presence --------------------------------------------------------------------------------
780
+ /**
781
+ * Reads activity newest first, filtered by kind and board, scanning at most ACTIVITY_SCAN_LIMIT records (it may
782
+ * return fewer than limit; the console pages with the oldest sequence shown).
783
+ * @param kind The kind, or null for all.
784
+ * @param boardId The board, or null for all.
785
+ * @param beforeSequence Only records older than this sequence, or null.
786
+ * @param limit Most records.
787
+ * @param includeAddresses True to keep client addresses (players.viewAddresses).
788
+ * @returns The records.
789
+ */
790
+ async readActivity(kind, boardId, beforeSequence, limit, includeAddresses) {
791
+ const matches = [];
792
+ let cursor = beforeSequence;
793
+ let scannedCount = 0;
794
+ while (matches.length < limit && scannedCount < ACTIVITY_SCAN_LIMIT) {
795
+ const page = await this.options.adminStore.readRecords(ACTIVITY_STREAM, { newestFirst: true, limit: ACTIVITY_PAGE_SIZE, beforeSequence: cursor ?? undefined });
796
+ for (const record of page) {
797
+ const isMatch = (kind == null || record.value.kind === kind) && (boardId == null || record.value.boardId === boardId);
798
+ if (isMatch && matches.length < limit)
799
+ matches.push(toActivityView(record, includeAddresses));
800
+ }
801
+ scannedCount += page.length;
802
+ if (page.length < ACTIVITY_PAGE_SIZE)
803
+ break;
804
+ cursor = page[page.length - 1].sequence;
805
+ }
806
+ return matches;
807
+ }
808
+ /**
809
+ * The status view: storage, durability, the definitions problem, boards and fleet-wide per-minute counts (D20, D21).
810
+ * @returns The status.
811
+ */
812
+ async status() {
813
+ const nowMs = this.options.clock.now();
814
+ const records = await this.options.adminStore.readRecords(ACTIVITY_STREAM, { newestFirst: true, limit: ACTIVITY_SCAN_LIMIT });
815
+ const recent = records.filter((record) => Date.parse(record.at) >= nowMs - MILLISECONDS_PER_MINUTE);
816
+ const durability = await this.options.rankStore.measureDurability();
817
+ return {
818
+ storage: (this.options.rankStore.kind === "redis") ? "redis" : "files",
819
+ durability: durability.durability,
820
+ durabilityAdvice: durability.advice,
821
+ definitionsProblem: this.definitionsProblem,
822
+ boardCount: (await this.cachedBoards()).size,
823
+ acceptedPerMinute: recent.filter((record) => record.value.kind === "accepted").length,
824
+ refusedPerMinute: recent.filter((record) => record.value.kind === "refused").length
825
+ };
826
+ }
827
+ /**
828
+ * Records a session join for session-level trust (D10). Fire-and-forget: errors are logged at warn, never thrown.
829
+ * @param event The join or leave (leaves are ignored).
830
+ */
831
+ recordPresence(event) {
832
+ if (event.kind !== "joined")
833
+ return;
834
+ const subjects = ["address:" + event.clientAddress];
835
+ if (event.userId != null)
836
+ subjects.push("user:" + event.userId);
837
+ this.options.rankStore.recordSessionPresence(event.sessionName, subjects, this.options.clock.now(), LEADERBOARD_SESSION_WINDOW_MS).catch((error) => {
838
+ getLogger().warn("leaderboard session presence was not recorded", { sessionName: event.sessionName, error: errorText(error) });
839
+ });
840
+ }
841
+ // ---- Private helpers ----------------------------------------------------------------------------------------------
842
+ /**
843
+ * The board documents, cached for LEADERBOARD_DEFINITION_CACHE_MS.
844
+ * @returns Board id → document.
845
+ */
846
+ async cachedBoards() {
847
+ const nowMs = this.options.clock.now();
848
+ if (this.boardCache != null && this.boardCache.expiresAtMs > nowMs)
849
+ return this.boardCache.boards;
850
+ const documents = await this.options.adminStore.listDocuments(BOARDS_COLLECTION);
851
+ const boards = new Map(documents.map((stored) => [stored.id, stored]));
852
+ this.boardCache = { boards: boards, expiresAtMs: nowMs + LEADERBOARD_DEFINITION_CACHE_MS };
853
+ return boards;
854
+ }
855
+ /**
856
+ * A board for a read, from the cache: public reads see only public boards.
857
+ * @param boardId The board.
858
+ * @param audience "public" or "admin".
859
+ * @returns The board record.
860
+ * @throws LeaderboardError 404 unknownBoard.
861
+ */
862
+ async boardFor(boardId, audience) {
863
+ const stored = (await this.cachedBoards()).get(boardId);
864
+ const isVisible = stored != null && (audience === "admin" || stored.value.definition.visibility === "public");
865
+ if (stored == null || !isVisible)
866
+ throw new LeaderboardError(404, "unknownBoard", "there is no leaderboard " + boardId);
867
+ return stored.value;
868
+ }
869
+ /**
870
+ * The rank key of a read: the board, its variant key and the season.
871
+ * @param boardId The board.
872
+ * @param variant The variant.
873
+ * @param seasonId The season, or null for the current one.
874
+ * @param audience "public" checks variant values; "admin" also accepts values a board no longer allows (DR §15).
875
+ * @returns The rank key.
876
+ * @throws LeaderboardError 404 unknownBoard or for an unknown season, 400 variantNotAllowed.
877
+ */
878
+ async rankKeyFor(boardId, variant, seasonId, audience) {
879
+ const record = await this.boardFor(boardId, audience);
880
+ const variantKey = (audience === "admin") ? adminVariantKeyOf(record.definition, variant) : requireVariantKey(record.definition, variant);
881
+ return { boardId: boardId, seasonId: resolveSeasonId(record, seasonId), variantKey: variantKey };
882
+ }
883
+ /**
884
+ * A new board record with season 1 starting now.
885
+ * @param definition The valid definition.
886
+ * @returns The record.
887
+ */
888
+ newBoardRecord(definition) {
889
+ const nowMs = this.options.clock.now();
890
+ const nowIso = new Date(nowMs).toISOString();
891
+ return {
892
+ definition: definition,
893
+ currentSeasonId: 1,
894
+ seasons: [{ seasonId: 1, startedAt: nowIso, endedAt: null, isCurrent: true }],
895
+ nextResetAt: nextResetAt(definition.resetSchedule, nowMs),
896
+ createdAt: nowIso
897
+ };
898
+ }
899
+ /**
900
+ * Imports the definitions file (D11): creates each valid board that does not exist yet and records any problem.
901
+ */
902
+ async importDefinitionsFile() {
903
+ const definitionsFile = this.options.config.leaderboards.definitionsFile;
904
+ this.definitionsProblem = null;
905
+ if (definitionsFile == null)
906
+ return;
907
+ const filePath = path.resolve(definitionsFile);
908
+ const problems = [];
909
+ const definitions = await readDefinitions(filePath, problems);
910
+ for (const value of definitions) {
911
+ const checked = validateBoardDefinition(value);
912
+ if (checked.definition == null) {
913
+ problems.push(describeBoardValue(value) + ": " + checked.problems.join(", "));
914
+ continue;
915
+ }
916
+ await this.createBoardIfMissing(checked.definition);
917
+ }
918
+ this.boardCache = null;
919
+ if (problems.length === 0)
920
+ return;
921
+ this.definitionsProblem = problems.join("; ");
922
+ getLogger().error("leaderboard definitions file " + filePath + " has problems: " + this.definitionsProblem);
923
+ }
924
+ /**
925
+ * Moves every anonymous player's live entry, in every kept season of every board, to its ranking's removed list
926
+ * (leaderboard-signed-in-only D4). Entries stay restorable from the console and player records are untouched.
927
+ * Idempotent: a second run finds nothing left to move.
928
+ * @returns How many entries were moved.
929
+ */
930
+ async hideAnonymousEntries() {
931
+ const nowIso = this.nowIso();
932
+ let hiddenCount = 0;
933
+ for (const stored of await this.listBoardRecords()) {
934
+ const boardId = stored.value.definition.boardId;
935
+ for (const season of stored.value.seasons) {
936
+ for (const variantKey of await this.options.rankStore.listVariantKeys(boardId, season.seasonId)) {
937
+ const key = { boardId: boardId, seasonId: season.seasonId, variantKey: variantKey };
938
+ hiddenCount += await this.hideAnonymousEntriesOf(key, nowIso);
939
+ }
940
+ }
941
+ }
942
+ this.readCache.clear();
943
+ if (hiddenCount > 0)
944
+ getLogger().info("leaderboards: moved " + hiddenCount + " anonymous entries to the removed lists (signedInPlayersOnly)");
945
+ return hiddenCount;
946
+ }
947
+ /**
948
+ * Moves the anonymous players' live entries of one ranking to its removed list.
949
+ * @param key The ranking.
950
+ * @param nowIso The removal time (ISO).
951
+ * @returns How many entries were moved.
952
+ */
953
+ async hideAnonymousEntriesOf(key, nowIso) {
954
+ const entryCount = await this.options.rankStore.count(key);
955
+ const entries = await this.options.rankStore.readRange(key, 0, entryCount);
956
+ const anonymousEntries = entries.filter((entry) => entry.playerId.startsWith(ANONYMOUS_PLAYER_PREFIX));
957
+ let hiddenCount = 0;
958
+ for (const entry of anonymousEntries) {
959
+ const wasRemoved = await this.options.rankStore.removeEntry(key, entry.playerId, ANONYMOUS_PLAYER_REMOVAL_REASON, SERVER_REMOVER_NAME, nowIso);
960
+ if (wasRemoved)
961
+ hiddenCount += 1;
962
+ }
963
+ return hiddenCount;
964
+ }
965
+ /**
966
+ * Creates a board unless another process or the console already has it.
967
+ * @param definition The valid definition.
968
+ */
969
+ async createBoardIfMissing(definition) {
970
+ try {
971
+ await this.options.adminStore.putDocument(BOARDS_COLLECTION, definition.boardId, this.newBoardRecord(definition), 0);
972
+ }
973
+ catch (error) {
974
+ if (!(error instanceof AdminConflictError))
975
+ throw error;
976
+ }
977
+ }
978
+ /**
979
+ * The auth user's player record, created with the claim name (or a default) on first use; an existing record keeps
980
+ * its own name.
981
+ * @param userId The auth user id.
982
+ * @param userDisplayName The name claim, or null.
983
+ * @param userAvatar The avatar claim, or null (stored only when the record is created).
984
+ * @returns The record.
985
+ */
986
+ async userPlayer(userId, userDisplayName, userAvatar) {
987
+ const playerId = userPlayerId(userId);
988
+ const existing = await this.options.rankStore.getPlayer(playerId);
989
+ if (existing != null)
990
+ return existing;
991
+ const rules = (await this.getRulesView()).rules;
992
+ const nowIso = this.nowIso();
993
+ const displayName = cleanDisplayName(userDisplayName, rules.blockedWords).displayName ?? defaultDisplayName(playerId);
994
+ const record = { playerId: playerId, kind: "user", keyHash: null, displayName: displayName, avatar: userAvatar, hidden: false, createdAt: nowIso, lastSeenAt: nowIso };
995
+ await this.options.rankStore.putPlayer(record);
996
+ return record;
997
+ }
998
+ /**
999
+ * Refreshes an existing user player's name and avatar from a new sign-in (D16); its entries take the new name.
1000
+ * @param record The stored player.
1001
+ * @param displayName The sign-in's display name (cleaned; a refused name keeps the stored one).
1002
+ * @param avatar The sign-in's avatar claim, or null.
1003
+ */
1004
+ async refreshUserProfile(record, displayName, avatar) {
1005
+ const rules = (await this.getRulesView()).rules;
1006
+ const cleanName = cleanDisplayName(displayName, rules.blockedWords).displayName ?? record.displayName;
1007
+ const isNameChanged = cleanName !== record.displayName;
1008
+ if (!isNameChanged && (record.avatar ?? null) === avatar)
1009
+ return;
1010
+ await this.options.rankStore.putPlayer({ ...record, displayName: cleanName, avatar: avatar, lastSeenAt: this.nowIso() });
1011
+ if (isNameChanged)
1012
+ await this.options.rankStore.renamePlayerEntries(record.playerId, cleanName);
1013
+ this.readCache.clear();
1014
+ }
1015
+ /**
1016
+ * The anonymous player a browser's key names, when the key is valid.
1017
+ * @param playerKey The anonymous player key, or null.
1018
+ * @returns The anonymous record, or null when the key is missing, malformed, unknown, not anonymous or not valid.
1019
+ */
1020
+ async anonymousPlayerOf(playerKey) {
1021
+ if (playerKey == null)
1022
+ return null;
1023
+ const parsed = parsePlayerKey(playerKey);
1024
+ if (parsed == null)
1025
+ return null;
1026
+ const record = await this.options.rankStore.getPlayer(parsed.playerId);
1027
+ if (record == null || record.kind !== "anonymous" || !isPlayerKeyValid(record, parsed.secret))
1028
+ return null;
1029
+ return record;
1030
+ }
1031
+ /**
1032
+ * The player of a server-trust submission: the named player, or one created when unknown and a name is given (DR §6):
1033
+ * kind user for a user: id (discord-sign-in F1), else manual.
1034
+ * @param request The server submission.
1035
+ * @returns The record.
1036
+ * @throws LeaderboardError 400 invalidPlayer for an unknown player without a name.
1037
+ */
1038
+ async serverPlayer(request) {
1039
+ if (typeof request.playerId !== "string" || request.playerId === "")
1040
+ throw new LeaderboardError(400, "invalidRequest", "playerId must be a player id");
1041
+ const existing = await this.options.rankStore.getPlayer(request.playerId);
1042
+ if (existing != null)
1043
+ return existing;
1044
+ if (request.displayName == null)
1045
+ throw new LeaderboardError(400, "invalidPlayer", "there is no player " + request.playerId + "; give a displayName to create one");
1046
+ const displayName = await this.requireCleanName(request.displayName);
1047
+ const nowIso = this.nowIso();
1048
+ const kind = request.playerId.startsWith(USER_PLAYER_PREFIX) ? "user" : "manual";
1049
+ const record = { playerId: request.playerId, kind: kind, keyHash: null, displayName: displayName, hidden: false, createdAt: nowIso, lastSeenAt: nowIso };
1050
+ await this.options.rankStore.putPlayer(record);
1051
+ return record;
1052
+ }
1053
+ /**
1054
+ * Cleans a display name with the current rules' blocked words.
1055
+ * @param displayName The name (any JSON).
1056
+ * @returns The clean name.
1057
+ * @throws LeaderboardError 400 nameInvalid or nameBlocked.
1058
+ */
1059
+ async requireCleanName(displayName) {
1060
+ const rules = (await this.getRulesView()).rules;
1061
+ const cleaned = cleanDisplayName(displayName, rules.blockedWords);
1062
+ if (cleaned.displayName == null) {
1063
+ const sentence = (cleaned.refusal === "nameBlocked") ? "that name contains a blocked word" : "a name must be 1–24 characters without control characters";
1064
+ throw new LeaderboardError(400, cleaned.refusal ?? "nameInvalid", sentence);
1065
+ }
1066
+ return cleaned.displayName;
1067
+ }
1068
+ /**
1069
+ * Consumes the per-player and per-address submission limits; a refusal is recorded in activity and thrown.
1070
+ * @param context The submission.
1071
+ * @throws LeaderboardError 429 rateLimited.
1072
+ */
1073
+ async consumeSubmissionRates(context) {
1074
+ const rankStore = this.options.rankStore;
1075
+ const isPlayerAllowed = await rankStore.consumeRate("submit-player:" + context.player.playerId, context.rules.submissionsPerMinutePerPlayer, MILLISECONDS_PER_MINUTE, context.nowMs);
1076
+ const isAddressAllowed = isPlayerAllowed && await rankStore.consumeRate("submit-address:" + context.caller.clientAddress, context.rules.submissionsPerMinutePerAddress, MILLISECONDS_PER_MINUTE, context.nowMs);
1077
+ if (isAddressAllowed)
1078
+ return;
1079
+ await this.appendActivity(context, { boardId: null, variantKey: null, score: null, outcome: null, reason: "rateLimited", trustLevel: null, kind: "refused" });
1080
+ throw new LeaderboardError(429, "rateLimited", "too many submissions; try again in a minute");
1081
+ }
1082
+ /**
1083
+ * Checks and writes one score (DR §10 step 6), recording activity and the metric.
1084
+ * @param context The submission.
1085
+ * @param submission The score.
1086
+ * @returns The score's result.
1087
+ */
1088
+ async submitScore(context, submission) {
1089
+ const boardId = (submission != null && typeof submission.boardId === "string") ? submission.boardId : "";
1090
+ const stored = (await this.cachedBoards()).get(boardId);
1091
+ const isBoardOpen = stored != null && (context.source === "server" || stored.value.definition.visibility === "public");
1092
+ if (stored == null || !isBoardOpen)
1093
+ return await this.refuseScore(context, boardId, null, "unknownBoard");
1094
+ const record = stored.value;
1095
+ const variant = isVariantShape(submission.variant) ? variantKeyOf(record.definition, submission.variant) : { variantKey: null, problem: "variant must be an object" };
1096
+ if (variant.variantKey == null)
1097
+ return await this.refuseScore(context, boardId, null, "variantNotAllowed");
1098
+ const scoreRefusal = checkScore(record.definition, submission.score, context.request.durationMs);
1099
+ if (scoreRefusal != null)
1100
+ return await this.refuseScore(context, boardId, variant.variantKey, scoreRefusal);
1101
+ const trustLevel = await this.trustLevelOf(context, record.definition, submission.score);
1102
+ if (!record.definition.acceptedTrustLevels.includes(trustLevel))
1103
+ return await this.refuseScore(context, boardId, variant.variantKey, "trustLevelNotAccepted");
1104
+ const metadata = checkMetadata(submission.metadata, context.rules.metadataMaximumBytes);
1105
+ if (metadata.refusal != null)
1106
+ return await this.refuseScore(context, boardId, variant.variantKey, metadata.refusal);
1107
+ const key = { boardId: boardId, seasonId: record.currentSeasonId, variantKey: variant.variantKey };
1108
+ if (context.player.hidden)
1109
+ return await this.discardScore(context, key, submission.score, trustLevel);
1110
+ const written = await this.options.rankStore.write({
1111
+ key: key,
1112
+ keepPolicy: record.definition.keepPolicy,
1113
+ sortOrder: record.definition.sortOrder,
1114
+ entry: this.incomingEntry(context, submission.score, metadata.metadata, trustLevel),
1115
+ dedupeId: context.request.submissionId + "|" + boardId + "|" + variant.variantKey,
1116
+ nowMs: context.nowMs
1117
+ });
1118
+ if (written.status === "overflow")
1119
+ return await this.refuseScore(context, boardId, variant.variantKey, "scoreOutOfRange");
1120
+ return await this.acceptScore(context, key, written, trustLevel);
1121
+ }
1122
+ /**
1123
+ * The trust level of one score: server, session when presence passes for its duration (D10), else client.
1124
+ * @param context The submission.
1125
+ * @param definition The board.
1126
+ * @param score The score (a time score must fit between the join and now).
1127
+ * @returns The trust level.
1128
+ */
1129
+ async trustLevelOf(context, definition, score) {
1130
+ if (context.baseTrust !== "session")
1131
+ return context.baseTrust;
1132
+ const sessionName = context.request.sessionName;
1133
+ const runMs = (definition.scoreFormat === "timeMilliseconds") ? score : 0;
1134
+ const latestJoinMs = context.nowMs - runMs + LEADERBOARD_DURATION_TOLERANCE_MS;
1135
+ const subjects = ["address:" + context.caller.clientAddress];
1136
+ if (context.caller.userId != null)
1137
+ subjects.push("user:" + context.caller.userId);
1138
+ for (const subject of subjects) {
1139
+ const joinedAtMs = await this.options.rankStore.readSessionPresence(sessionName, subject, context.nowMs);
1140
+ if (joinedAtMs != null && joinedAtMs <= latestJoinMs)
1141
+ return "session";
1142
+ }
1143
+ return "client";
1144
+ }
1145
+ /**
1146
+ * The incoming entry of a write: submissionCount 1, the service clock's times, the player's current name.
1147
+ * @param context The submission.
1148
+ * @param score The score.
1149
+ * @param metadata The checked metadata.
1150
+ * @param trustLevel The trust level.
1151
+ * @returns The entry.
1152
+ */
1153
+ incomingEntry(context, score, metadata, trustLevel) {
1154
+ const nowIso = new Date(context.nowMs).toISOString();
1155
+ return {
1156
+ playerId: context.player.playerId,
1157
+ displayName: context.player.displayName,
1158
+ score: score,
1159
+ metadata: metadata,
1160
+ trustLevel: trustLevel,
1161
+ sessionName: context.request.sessionName,
1162
+ gameMode: context.request.gameMode,
1163
+ buildVersion: context.request.buildVersion,
1164
+ submittedAt: nowIso,
1165
+ achievedAt: nowIso,
1166
+ achievedAtMs: context.nowMs,
1167
+ submissionCount: 1
1168
+ };
1169
+ }
1170
+ /**
1171
+ * Records and returns an accepted or duplicate score.
1172
+ * @param context The submission.
1173
+ * @param key The ranking written.
1174
+ * @param written The store's result.
1175
+ * @param trustLevel The trust level.
1176
+ * @returns The result.
1177
+ */
1178
+ async acceptScore(context, key, written, trustLevel) {
1179
+ const status = (written.status === "duplicate") ? "duplicate" : "accepted";
1180
+ const outcome = written.outcome;
1181
+ const score = (written.entry != null) ? written.entry.score : null;
1182
+ if (written.status === "written")
1183
+ this.forgetCachedReads(key);
1184
+ await this.appendActivity(context, { kind: status, boardId: key.boardId, variantKey: key.variantKey, score: score, outcome: outcome, reason: null, trustLevel: trustLevel });
1185
+ this.options.state.metrics?.countLeaderboardScore?.(status, null);
1186
+ return {
1187
+ boardId: key.boardId,
1188
+ variantKey: key.variantKey,
1189
+ status: status,
1190
+ outcome: outcome,
1191
+ score: score,
1192
+ previousScore: written.previousScore,
1193
+ isPersonalBest: outcome != null && PERSONAL_BEST_OUTCOMES.includes(outcome),
1194
+ rank: written.rank,
1195
+ total: written.total,
1196
+ seasonId: key.seasonId,
1197
+ trustLevel: trustLevel,
1198
+ refusal: null
1199
+ };
1200
+ }
1201
+ /**
1202
+ * Discards a hidden player's score (D18 shadow ban): accepted, outcome kept, no rank, nothing written.
1203
+ * @param context The submission.
1204
+ * @param key The ranking it would have gone to.
1205
+ * @param score The score.
1206
+ * @param trustLevel The trust level.
1207
+ * @returns The result.
1208
+ */
1209
+ async discardScore(context, key, score, trustLevel) {
1210
+ await this.appendActivity(context, { kind: "accepted", boardId: key.boardId, variantKey: key.variantKey, score: score, outcome: "kept", reason: null, trustLevel: trustLevel });
1211
+ this.options.state.metrics?.countLeaderboardScore?.("accepted", null);
1212
+ return { boardId: key.boardId, variantKey: key.variantKey, status: "accepted", outcome: "kept", score: score, previousScore: null, isPersonalBest: false, rank: null, total: null, seasonId: key.seasonId, trustLevel: trustLevel, refusal: null };
1213
+ }
1214
+ /**
1215
+ * Records and returns a refused score.
1216
+ * @param context The submission.
1217
+ * @param boardId The board named.
1218
+ * @param variantKey The variant key, or null when it was not valid.
1219
+ * @param reason Why.
1220
+ * @returns The result.
1221
+ */
1222
+ async refuseScore(context, boardId, variantKey, reason) {
1223
+ await this.appendActivity(context, { kind: "refused", boardId: (boardId !== "") ? boardId : null, variantKey: variantKey, score: null, outcome: null, reason: reason, trustLevel: null });
1224
+ this.options.state.metrics?.countLeaderboardScore?.("refused", reason);
1225
+ return { boardId: boardId, variantKey: variantKey, status: "refused", outcome: null, score: null, previousScore: null, isPersonalBest: false, rank: null, total: null, seasonId: null, trustLevel: null, refusal: reason };
1226
+ }
1227
+ /**
1228
+ * Appends one activity record (D21); a failing append is logged at warn and never fails the score.
1229
+ * @param context The submission.
1230
+ * @param fields The score's fields.
1231
+ */
1232
+ async appendActivity(context, fields) {
1233
+ const record = {
1234
+ ...fields,
1235
+ playerId: context.player.playerId,
1236
+ displayName: context.player.displayName,
1237
+ gameMode: context.request.gameMode,
1238
+ clientAddress: context.caller.clientAddress
1239
+ };
1240
+ try {
1241
+ await this.options.adminStore.appendRecord(ACTIVITY_STREAM, record);
1242
+ }
1243
+ catch (error) {
1244
+ getLogger().warn("leaderboard activity was not recorded", { error: errorText(error) });
1245
+ }
1246
+ }
1247
+ /**
1248
+ * Drops the cached pages of one ranking after a local write.
1249
+ * @param key The ranking.
1250
+ */
1251
+ forgetCachedReads(key) {
1252
+ const prefix = rankKeyText(key) + "|";
1253
+ for (const cacheKey of this.readCache.keys()) {
1254
+ if (cacheKey.startsWith(prefix))
1255
+ this.readCache.delete(cacheKey);
1256
+ }
1257
+ }
1258
+ /**
1259
+ * The rank key of an admin moderation call (the board must exist; the season must be kept).
1260
+ * @param boardId The board.
1261
+ * @param variantKey The variant key.
1262
+ * @param seasonId The season.
1263
+ * @returns The rank key.
1264
+ */
1265
+ async adminRankKey(boardId, variantKey, seasonId) {
1266
+ const record = (await this.getBoardRecord(boardId)).value;
1267
+ return { boardId: boardId, seasonId: resolveSeasonId(record, seasonId), variantKey: variantKey };
1268
+ }
1269
+ /**
1270
+ * Writes an admin entry with keep policy latest and no dedupe.
1271
+ * @param key The ranking.
1272
+ * @param record The board.
1273
+ * @param entry The entry.
1274
+ * @returns The entry's view with its rank.
1275
+ */
1276
+ async writeAdminEntry(key, record, entry) {
1277
+ const written = await this.options.rankStore.write({ key: key, keepPolicy: "latest", sortOrder: record.definition.sortOrder, entry: entry, dedupeId: null, nowMs: this.options.clock.now() });
1278
+ if (written.status === "overflow" || written.entry == null || written.rank == null)
1279
+ throw new LeaderboardError(400, "scoreOutOfRange", "the score is outside the allowed range");
1280
+ this.forgetCachedReads(key);
1281
+ return toEntryView({ ...written.entry, rank: written.rank });
1282
+ }
1283
+ /**
1284
+ * Restores a hidden player's entry in one ranking, if it was removed for playerHidden.
1285
+ * @param key The ranking.
1286
+ * @param playerId The player.
1287
+ */
1288
+ async restoreHiddenEntry(key, playerId) {
1289
+ const removed = await this.options.rankStore.readRemoved(key);
1290
+ const hiddenEntry = removed.find((item) => item.entry.playerId === playerId && item.reason === PLAYER_HIDDEN_REASON);
1291
+ const board = (await this.freshBoards()).get(key.boardId);
1292
+ if (hiddenEntry == null || board == null)
1293
+ return;
1294
+ await this.options.rankStore.restoreEntry(key, playerId, board.value.definition.sortOrder);
1295
+ }
1296
+ /**
1297
+ * Merges one source entry into the target's entry of the same ranking by the board's keep policy.
1298
+ * @param key The ranking.
1299
+ * @param definition The board.
1300
+ * @param sourceEntry The source player's live entry.
1301
+ * @param target The target player.
1302
+ */
1303
+ async mergeEntry(key, definition, sourceEntry, target) {
1304
+ const targetEntry = await this.options.rankStore.readEntry(key, target.playerId);
1305
+ const isOlderLatest = definition.keepPolicy === "latest" && targetEntry != null && targetEntry.achievedAtMs >= sourceEntry.achievedAtMs;
1306
+ if (isOlderLatest)
1307
+ return;
1308
+ const incoming = { ...sourceEntry, playerId: target.playerId, displayName: target.displayName, submissionCount: 1 };
1309
+ const written = await this.options.rankStore.write({ key: key, keepPolicy: definition.keepPolicy, sortOrder: definition.sortOrder, entry: incoming, dedupeId: null, nowMs: this.options.clock.now() });
1310
+ if (written.status === "overflow")
1311
+ getLogger().warn("leaderboard merge left a sum unchanged: it would pass the score bound", { boardId: key.boardId, leaderboardPlayerId: target.playerId });
1312
+ this.forgetCachedReads(key);
1313
+ }
1314
+ /**
1315
+ * One entry of a player's detail: live with its rank, or removed with its reason.
1316
+ * @param key The ranking.
1317
+ * @param playerId The player.
1318
+ * @param record The board, or null when it was deleted.
1319
+ * @returns The entry view, or null when the player has no entry there.
1320
+ */
1321
+ async playerEntryView(key, playerId, record) {
1322
+ const live = await this.options.rankStore.readEntry(key, playerId);
1323
+ const removed = (live == null) ? (await this.options.rankStore.readRemoved(key)).find((item) => item.entry.playerId === playerId) : undefined;
1324
+ if (live == null && removed == null)
1325
+ return null;
1326
+ return {
1327
+ boardId: key.boardId,
1328
+ boardName: (record != null) ? record.definition.displayName : key.boardId,
1329
+ scoreFormat: (record != null) ? record.definition.scoreFormat : "integer",
1330
+ seasonId: key.seasonId,
1331
+ isCurrentSeason: record != null && record.currentSeasonId === key.seasonId,
1332
+ variantKey: key.variantKey,
1333
+ rank: (live != null) ? live.rank : null,
1334
+ total: await this.options.rankStore.count(key),
1335
+ score: (live != null) ? live.score : removed.entry.score,
1336
+ removedReason: (removed != null) ? removed.reason : null
1337
+ };
1338
+ }
1339
+ /**
1340
+ * Reads a player record.
1341
+ * @param playerId The player.
1342
+ * @returns The record.
1343
+ * @throws LeaderboardError 404 invalidPlayer when it does not exist.
1344
+ */
1345
+ async requirePlayer(playerId) {
1346
+ const record = await this.options.rankStore.getPlayer(playerId);
1347
+ if (record == null)
1348
+ throw new LeaderboardError(404, "invalidPlayer", "there is no player " + playerId);
1349
+ return record;
1350
+ }
1351
+ /**
1352
+ * Creates a manual player (D8) with a cleaned name.
1353
+ * @param displayName The name.
1354
+ * @returns The record.
1355
+ */
1356
+ async createManualPlayer(displayName) {
1357
+ const cleanName = await this.requireCleanName(displayName);
1358
+ const nowIso = this.nowIso();
1359
+ const record = { playerId: MANUAL_PLAYER_PREFIX + randomUUID(), kind: "manual", keyHash: null, displayName: cleanName, hidden: false, createdAt: nowIso, lastSeenAt: nowIso };
1360
+ await this.options.rankStore.putPlayer(record);
1361
+ return record;
1362
+ }
1363
+ /**
1364
+ * The board documents read fresh (moderation must not act on a stale cache).
1365
+ * @returns Board id → document.
1366
+ */
1367
+ async freshBoards() {
1368
+ this.boardCache = null;
1369
+ return await this.cachedBoards();
1370
+ }
1371
+ /**
1372
+ * The service clock's time as ISO text.
1373
+ * @returns ISO time.
1374
+ */
1375
+ nowIso() {
1376
+ return new Date(this.options.clock.now()).toISOString();
1377
+ }
1378
+ }
1379
+ /**
1380
+ * Checks a submission envelope's shape (DR §10 step 1) and fills absent optional fields with null.
1381
+ * @param request The envelope (any JSON).
1382
+ * @throws LeaderboardError 400 invalidRequest naming the field.
1383
+ */
1384
+ function requireValidEnvelope(request) {
1385
+ if (request == null || typeof request !== "object")
1386
+ throw invalidRequest("the submission must be an object");
1387
+ if (typeof request.submissionId !== "string" || !SUBMISSION_ID_PATTERN.test(request.submissionId))
1388
+ throw invalidRequest("submissionId must be 8–64 of A-Z, a-z, 0-9 and -");
1389
+ if (!isOptionalText(request.gameMode, 0, MAXIMUM_LABEL_TEXT_LENGTH))
1390
+ throw invalidRequest("gameMode must be null or text of at most " + MAXIMUM_LABEL_TEXT_LENGTH + " characters");
1391
+ if (!isOptionalText(request.buildVersion, 0, MAXIMUM_LABEL_TEXT_LENGTH))
1392
+ throw invalidRequest("buildVersion must be null or text of at most " + MAXIMUM_LABEL_TEXT_LENGTH + " characters");
1393
+ if (!isOptionalText(request.sessionName, 1, MAXIMUM_SESSION_NAME_LENGTH))
1394
+ throw invalidRequest("sessionName must be null or text of 1–" + MAXIMUM_SESSION_NAME_LENGTH + " characters");
1395
+ const durationMs = request.durationMs;
1396
+ const isDurationValid = durationMs == null || (typeof durationMs === "number" && Number.isSafeInteger(durationMs) && durationMs >= 0);
1397
+ if (!isDurationValid)
1398
+ throw invalidRequest("durationMs must be null or a whole number of milliseconds");
1399
+ if (!Array.isArray(request.scores) || request.scores.length === 0)
1400
+ throw invalidRequest("scores must be a list of at least one score");
1401
+ request.gameMode ??= null;
1402
+ request.buildVersion ??= null;
1403
+ request.sessionName ??= null;
1404
+ request.durationMs ??= null;
1405
+ }
1406
+ /**
1407
+ * A 400 invalidRequest error.
1408
+ * @param sentence What is wrong, naming the field.
1409
+ * @returns The error to throw.
1410
+ */
1411
+ function invalidRequest(sentence) {
1412
+ return new LeaderboardError(400, "invalidRequest", sentence);
1413
+ }
1414
+ /**
1415
+ * Whether a value is null/undefined or text within a length range.
1416
+ * @param value The value.
1417
+ * @param minimumLength Shortest text.
1418
+ * @param maximumLength Longest text.
1419
+ * @returns True when valid.
1420
+ */
1421
+ function isOptionalText(value, minimumLength, maximumLength) {
1422
+ if (value == null)
1423
+ return true;
1424
+ return typeof value === "string" && value.length >= minimumLength && value.length <= maximumLength;
1425
+ }
1426
+ /**
1427
+ * Whether a submission's variant is null or an object of strings.
1428
+ * @param variant The variant (any JSON).
1429
+ * @returns True when it has the right shape.
1430
+ */
1431
+ function isVariantShape(variant) {
1432
+ if (variant == null)
1433
+ return true;
1434
+ if (typeof variant !== "object" || Array.isArray(variant))
1435
+ return false;
1436
+ return Object.values(variant).every((value) => typeof value === "string");
1437
+ }
1438
+ /**
1439
+ * The trust level before the per-score presence check (DR §10 step 5).
1440
+ * @param source The submission's source.
1441
+ * @param request The envelope.
1442
+ * @returns "server", "session" when presence may pass, else "client".
1443
+ */
1444
+ function baseTrustOf(source, request) {
1445
+ if (source === "server")
1446
+ return "server";
1447
+ return (request.sessionName != null) ? "session" : "client";
1448
+ }
1449
+ /**
1450
+ * Validates a board definition or throws.
1451
+ * @param definition The definition (any JSON).
1452
+ * @returns The valid definition.
1453
+ * @throws LeaderboardError 400 invalidRequest with the problems.
1454
+ */
1455
+ function requireValidDefinition(definition) {
1456
+ const checked = validateBoardDefinition(definition);
1457
+ if (checked.definition == null)
1458
+ throw new LeaderboardError(400, "invalidRequest", checked.problems.join("; "));
1459
+ return checked.definition;
1460
+ }
1461
+ /**
1462
+ * The variant key of a public read or throws.
1463
+ * @param definition The board.
1464
+ * @param variant The variant.
1465
+ * @returns The variant key.
1466
+ * @throws LeaderboardError 400 variantNotAllowed.
1467
+ */
1468
+ function requireVariantKey(definition, variant) {
1469
+ const checked = isVariantShape(variant) ? variantKeyOf(definition, variant) : { variantKey: null, problem: "variant must be an object of texts" };
1470
+ if (checked.variantKey == null)
1471
+ throw new LeaderboardError(400, "variantNotAllowed", checked.problem ?? "variant not allowed");
1472
+ return checked.variantKey;
1473
+ }
1474
+ /**
1475
+ * The variant key of an admin read: the declared dimensions in order, accepting values the board no longer allows.
1476
+ * @param definition The board.
1477
+ * @param variant The variant.
1478
+ * @returns The variant key.
1479
+ * @throws LeaderboardError 400 variantNotAllowed when a dimension is missing or a key is extra.
1480
+ */
1481
+ function adminVariantKeyOf(definition, variant) {
1482
+ const relaxed = {
1483
+ ...definition,
1484
+ dimensions: definition.dimensions.map((dimension) => {
1485
+ const value = (variant != null) ? variant[dimension.name] : undefined;
1486
+ const values = (typeof value === "string" && !dimension.values.includes(value)) ? [...dimension.values, value] : dimension.values;
1487
+ return { ...dimension, values: values };
1488
+ })
1489
+ };
1490
+ return requireVariantKey(relaxed, variant);
1491
+ }
1492
+ /**
1493
+ * The season a read or delete names, or the current one.
1494
+ * @param record The board.
1495
+ * @param seasonId The season, or null for the current one.
1496
+ * @returns The season id.
1497
+ * @throws LeaderboardError 404 invalidRequest for a season the board does not keep.
1498
+ */
1499
+ function resolveSeasonId(record, seasonId) {
1500
+ if (seasonId == null)
1501
+ return record.currentSeasonId;
1502
+ const isKept = record.seasons.some((season) => season.seasonId === seasonId);
1503
+ if (!isKept)
1504
+ throw new LeaderboardError(404, "invalidRequest", "board " + record.definition.boardId + " has no season " + seasonId);
1505
+ return seasonId;
1506
+ }
1507
+ /**
1508
+ * A board record with its current season ended and the next one started.
1509
+ * @param record The board.
1510
+ * @param nowMs The reset time.
1511
+ * @returns The new record (nextResetAt unchanged).
1512
+ */
1513
+ function advanceSeason(record, nowMs) {
1514
+ const nowIso = new Date(nowMs).toISOString();
1515
+ const nextSeasonId = record.currentSeasonId + 1;
1516
+ const ended = record.seasons.map((season) => (season.seasonId === record.currentSeasonId) ? { ...season, endedAt: nowIso, isCurrent: false } : season);
1517
+ return { ...record, currentSeasonId: nextSeasonId, seasons: [...ended, { seasonId: nextSeasonId, startedAt: nowIso, endedAt: null, isCurrent: true }] };
1518
+ }
1519
+ /**
1520
+ * Whether a board's scheduled reset is due.
1521
+ * @param record The board.
1522
+ * @param nowMs The time.
1523
+ * @returns True when nextResetAt is set and not in the future.
1524
+ */
1525
+ function isResetDue(record, nowMs) {
1526
+ return record.nextResetAt != null && Date.parse(record.nextResetAt) <= nowMs;
1527
+ }
1528
+ /**
1529
+ * The page fields of a ranking.
1530
+ * @param key The ranking.
1531
+ * @param total Its live entries.
1532
+ * @returns boardId, seasonId, variantKey and total.
1533
+ */
1534
+ function pageHeader(key, total) {
1535
+ return { boardId: key.boardId, seasonId: key.seasonId, variantKey: key.variantKey, total: total };
1536
+ }
1537
+ /**
1538
+ * The public view of a board.
1539
+ * @param record The board.
1540
+ * @returns The view.
1541
+ */
1542
+ function toPublicBoardView(record) {
1543
+ const definition = record.definition;
1544
+ const currentSeason = record.seasons.find((season) => season.seasonId === record.currentSeasonId);
1545
+ return {
1546
+ boardId: definition.boardId,
1547
+ displayName: definition.displayName,
1548
+ description: definition.description,
1549
+ sortOrder: definition.sortOrder,
1550
+ scoreFormat: definition.scoreFormat,
1551
+ keepPolicy: definition.keepPolicy,
1552
+ dimensions: definition.dimensions,
1553
+ currentSeasonId: record.currentSeasonId,
1554
+ currentSeasonStartedAt: (currentSeason != null) ? currentSeason.startedAt : record.createdAt,
1555
+ nextResetAt: record.nextResetAt
1556
+ };
1557
+ }
1558
+ /**
1559
+ * The view of a ranked entry.
1560
+ * @param entry The entry.
1561
+ * @returns The view.
1562
+ */
1563
+ function toEntryView(entry) {
1564
+ return { rank: entry.rank, playerId: entry.playerId, displayName: entry.displayName, score: entry.score, metadata: entry.metadata, trustLevel: entry.trustLevel, submittedAt: entry.submittedAt, achievedAt: entry.achievedAt };
1565
+ }
1566
+ /**
1567
+ * The public view of a player.
1568
+ * @param record The player.
1569
+ * @returns The view.
1570
+ */
1571
+ function toPlayerView(record) {
1572
+ return { playerId: record.playerId, displayName: record.displayName, kind: record.kind, createdAt: record.createdAt };
1573
+ }
1574
+ /**
1575
+ * The view of an activity record.
1576
+ * @param record The stored record.
1577
+ * @param includeAddresses True to keep the client address.
1578
+ * @returns The view.
1579
+ */
1580
+ function toActivityView(record, includeAddresses) {
1581
+ return { ...record.value, sequence: record.sequence, at: record.at, clientAddress: includeAddresses ? record.value.clientAddress : "" };
1582
+ }
1583
+ /**
1584
+ * The view of a removed entry.
1585
+ * @param removed The removed entry.
1586
+ * @returns The view (rank 0).
1587
+ */
1588
+ function toRemovedEntryView(removed) {
1589
+ return { entry: toEntryView({ ...removed.entry, rank: UNRANKED }), reason: removed.reason, removedBy: removed.removedBy, removedAt: removed.removedAt };
1590
+ }
1591
+ /**
1592
+ * The console view of a player.
1593
+ * @param record The player.
1594
+ * @returns The view.
1595
+ */
1596
+ function toPlayerAdminView(record) {
1597
+ return { playerId: record.playerId, displayName: record.displayName, kind: record.kind, hidden: record.hidden, createdAt: record.createdAt, lastSeenAt: record.lastSeenAt };
1598
+ }
1599
+ /**
1600
+ * A ranked entry without its rank.
1601
+ * @param entry The ranked entry.
1602
+ * @returns The stored entry.
1603
+ */
1604
+ function withoutRank(entry) {
1605
+ const stored = { ...entry };
1606
+ delete stored.rank;
1607
+ return stored;
1608
+ }
1609
+ /**
1610
+ * Checks an admin score against the board or throws.
1611
+ * @param definition The board.
1612
+ * @param score The score.
1613
+ * @throws LeaderboardError 400 with the refusal reason.
1614
+ */
1615
+ function requireAcceptableScore(definition, score) {
1616
+ const refusal = checkScore(definition, score, null);
1617
+ if (refusal != null)
1618
+ throw new LeaderboardError(400, refusal, "the score must be a whole number within the board's limits");
1619
+ }
1620
+ /**
1621
+ * Reads the board definitions of a definitions file or an export file, adding a problem for anything unreadable.
1622
+ * @param filePath The resolved file path.
1623
+ * @param problems Receives the problems.
1624
+ * @returns The definitions found (unvalidated).
1625
+ */
1626
+ async function readDefinitions(filePath, problems) {
1627
+ let text;
1628
+ try {
1629
+ text = await readFile(filePath, "utf8");
1630
+ }
1631
+ catch (error) {
1632
+ problems.push("cannot read " + filePath + " (" + errorText(error) + ")");
1633
+ return [];
1634
+ }
1635
+ let parsed;
1636
+ try {
1637
+ parsed = JSON.parse(text);
1638
+ }
1639
+ catch {
1640
+ problems.push(filePath + " is not valid JSON");
1641
+ return [];
1642
+ }
1643
+ const file = (typeof parsed === "object" && parsed != null) ? parsed : {};
1644
+ if (file.format === DEFINITIONS_FORMAT && Array.isArray(file.boards))
1645
+ return file.boards;
1646
+ if (file.format === EXPORT_FORMAT && Array.isArray(file.boards))
1647
+ return file.boards.map((board) => (typeof board === "object" && board != null) ? board.definition : board);
1648
+ problems.push(filePath + " is not a leaderboard definitions file (format must be " + DEFINITIONS_FORMAT + ")");
1649
+ return [];
1650
+ }
1651
+ /**
1652
+ * A short label of a board value for a problem sentence.
1653
+ * @param value The value.
1654
+ * @returns "board <id>" or "a board".
1655
+ */
1656
+ function describeBoardValue(value) {
1657
+ const boardId = (typeof value === "object" && value != null) ? value.boardId : null;
1658
+ return (typeof boardId === "string") ? "board " + boardId : "a board";
1659
+ }
1660
+ /**
1661
+ * The message of an error.
1662
+ * @param error Anything thrown.
1663
+ * @returns Its message.
1664
+ */
1665
+ function errorText(error) {
1666
+ return (error instanceof Error) ? error.message : String(error);
1667
+ }