@babylonjs-toolkit/server 0.0.0-stage → 9.35.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CUSTOM.md +1690 -0
- package/DEPLOY.md +2012 -0
- package/README.md +1081 -2
- package/lib/AbuseLimits.d.ts +64 -0
- package/lib/AbuseLimits.js +121 -0
- package/lib/Authentication.d.ts +86 -0
- package/lib/Authentication.js +272 -0
- package/lib/BootSessionOwner.d.ts +86 -0
- package/lib/BootSessionOwner.js +116 -0
- package/lib/ColyseusLoggerAdapter.d.ts +32 -0
- package/lib/ColyseusLoggerAdapter.js +73 -0
- package/lib/DedicatedSceneRegistry.d.ts +52 -0
- package/lib/DedicatedSceneRegistry.js +74 -0
- package/lib/DedicatedSession.d.ts +152 -0
- package/lib/DedicatedSession.js +173 -0
- package/lib/DualTransport.d.ts +112 -0
- package/lib/DualTransport.js +197 -0
- package/lib/GameServer.d.ts +64 -0
- package/lib/GameServer.js +647 -0
- package/lib/GameServerConfig.d.ts +387 -0
- package/lib/GameServerConfig.js +1059 -0
- package/lib/GameServerLobbyRoom.d.ts +33 -0
- package/lib/GameServerLobbyRoom.js +56 -0
- package/lib/GameServerLogger.d.ts +111 -0
- package/lib/GameServerLogger.js +190 -0
- package/lib/GameServerMetrics.d.ts +158 -0
- package/lib/GameServerMetrics.js +276 -0
- package/lib/GameServerState.d.ts +164 -0
- package/lib/GameServerState.js +26 -0
- package/lib/HeadlessRuntime.d.ts +316 -0
- package/lib/HeadlessRuntime.js +380 -0
- package/lib/JoinGate.d.ts +34 -0
- package/lib/JoinGate.js +221 -0
- package/lib/NetworkAddresses.d.ts +33 -0
- package/lib/NetworkAddresses.js +90 -0
- package/lib/PlayerTokens.d.ts +17 -0
- package/lib/PlayerTokens.js +24 -0
- package/lib/RoomServerTransport.d.ts +73 -0
- package/lib/RoomServerTransport.js +90 -0
- package/lib/ServerEndpoints.d.ts +133 -0
- package/lib/ServerEndpoints.js +247 -0
- package/lib/ServerInit.d.ts +28 -0
- package/lib/ServerInit.js +113 -0
- package/lib/SessionRoom.d.ts +391 -0
- package/lib/SessionRoom.js +930 -0
- package/lib/TlsCertificates.d.ts +35 -0
- package/lib/TlsCertificates.js +130 -0
- package/lib/admin/AccountRoutes.d.ts +8 -0
- package/lib/admin/AccountRoutes.js +502 -0
- package/lib/admin/AdminAccounts.d.ts +329 -0
- package/lib/admin/AdminAccounts.js +607 -0
- package/lib/admin/AdminApiRouter.d.ts +202 -0
- package/lib/admin/AdminApiRouter.js +465 -0
- package/lib/admin/AdminApiTypes.d.ts +1782 -0
- package/lib/admin/AdminApiTypes.js +4 -0
- package/lib/admin/AdminAudit.d.ts +113 -0
- package/lib/admin/AdminAudit.js +266 -0
- package/lib/admin/AdminCli.d.ts +7 -0
- package/lib/admin/AdminCli.js +351 -0
- package/lib/admin/AdminClock.d.ts +51 -0
- package/lib/admin/AdminClock.js +10 -0
- package/lib/admin/AdminConsole.d.ts +39 -0
- package/lib/admin/AdminConsole.js +325 -0
- package/lib/admin/AdminConstants.d.ts +147 -0
- package/lib/admin/AdminConstants.js +167 -0
- package/lib/admin/AdminCrypto.d.ts +19 -0
- package/lib/admin/AdminCrypto.js +29 -0
- package/lib/admin/AdminExtensionsHost.d.ts +176 -0
- package/lib/admin/AdminExtensionsHost.js +307 -0
- package/lib/admin/AdminPasswords.d.ts +33 -0
- package/lib/admin/AdminPasswords.js +92 -0
- package/lib/admin/AdminPermissions.d.ts +28 -0
- package/lib/admin/AdminPermissions.js +74 -0
- package/lib/admin/AdminRecords.d.ts +196 -0
- package/lib/admin/AdminRecords.js +3 -0
- package/lib/admin/AdminRequestBodies.d.ts +47 -0
- package/lib/admin/AdminRequestBodies.js +84 -0
- package/lib/admin/AdminRequestGate.d.ts +38 -0
- package/lib/admin/AdminRequestGate.js +59 -0
- package/lib/admin/AdminResponses.d.ts +103 -0
- package/lib/admin/AdminResponses.js +164 -0
- package/lib/admin/AdminServices.d.ts +99 -0
- package/lib/admin/AdminServices.js +4 -0
- package/lib/admin/AdminStaticFiles.d.ts +29 -0
- package/lib/admin/AdminStaticFiles.js +97 -0
- package/lib/admin/AdminStore.d.ts +152 -0
- package/lib/admin/AdminStore.js +46 -0
- package/lib/admin/AlertChannels.d.ts +43 -0
- package/lib/admin/AlertChannels.js +120 -0
- package/lib/admin/AlertEngine.d.ts +231 -0
- package/lib/admin/AlertEngine.js +681 -0
- package/lib/admin/AlertRoutes.d.ts +8 -0
- package/lib/admin/AlertRoutes.js +180 -0
- package/lib/admin/ApiKeyRoutes.d.ts +8 -0
- package/lib/admin/ApiKeyRoutes.js +53 -0
- package/lib/admin/ApiKeys.d.ts +63 -0
- package/lib/admin/ApiKeys.js +167 -0
- package/lib/admin/AuditRoutes.d.ts +8 -0
- package/lib/admin/AuditRoutes.js +106 -0
- package/lib/admin/AuthToolRoutes.d.ts +13 -0
- package/lib/admin/AuthToolRoutes.js +164 -0
- package/lib/admin/BanList.d.ts +87 -0
- package/lib/admin/BanList.js +152 -0
- package/lib/admin/CborDecoder.d.ts +18 -0
- package/lib/admin/CborDecoder.js +201 -0
- package/lib/admin/ColyseusCloudClient.d.ts +313 -0
- package/lib/admin/ColyseusCloudClient.js +272 -0
- package/lib/admin/ColyseusCloudRoutes.d.ts +8 -0
- package/lib/admin/ColyseusCloudRoutes.js +91 -0
- package/lib/admin/ColyseusCloudService.d.ts +180 -0
- package/lib/admin/ColyseusCloudService.js +476 -0
- package/lib/admin/ColyseusCloudSettings.d.ts +16 -0
- package/lib/admin/ColyseusCloudSettings.js +29 -0
- package/lib/admin/ColyseusCloudViews.d.ts +105 -0
- package/lib/admin/ColyseusCloudViews.js +371 -0
- package/lib/admin/CommonPasswords.d.ts +6 -0
- package/lib/admin/CommonPasswords.js +19 -0
- package/lib/admin/ConfigOverrides.d.ts +174 -0
- package/lib/admin/ConfigOverrides.js +406 -0
- package/lib/admin/ConfigRoutes.d.ts +8 -0
- package/lib/admin/ConfigRoutes.js +91 -0
- package/lib/admin/ConsoleSessions.d.ts +112 -0
- package/lib/admin/ConsoleSessions.js +201 -0
- package/lib/admin/DedicatedRoutes.d.ts +21 -0
- package/lib/admin/DedicatedRoutes.js +254 -0
- package/lib/admin/ExtensionRoutes.d.ts +8 -0
- package/lib/admin/ExtensionRoutes.js +145 -0
- package/lib/admin/FileAdminStore.d.ts +157 -0
- package/lib/admin/FileAdminStore.js +349 -0
- package/lib/admin/FleetDirectory.d.ts +323 -0
- package/lib/admin/FleetDirectory.js +489 -0
- package/lib/admin/FleetLeader.d.ts +43 -0
- package/lib/admin/FleetLeader.js +89 -0
- package/lib/admin/FleetRoutes.d.ts +55 -0
- package/lib/admin/FleetRoutes.js +465 -0
- package/lib/admin/HealthWarnings.d.ts +10 -0
- package/lib/admin/HealthWarnings.js +95 -0
- package/lib/admin/LeaderboardRoutes.d.ts +8 -0
- package/lib/admin/LeaderboardRoutes.js +569 -0
- package/lib/admin/LogBuffer.d.ts +60 -0
- package/lib/admin/LogBuffer.js +92 -0
- package/lib/admin/LogRedaction.d.ts +15 -0
- package/lib/admin/LogRedaction.js +72 -0
- package/lib/admin/LogRoutes.d.ts +45 -0
- package/lib/admin/LogRoutes.js +307 -0
- package/lib/admin/MaintenanceMode.d.ts +41 -0
- package/lib/admin/MaintenanceMode.js +74 -0
- package/lib/admin/MaintenanceWindows.d.ts +91 -0
- package/lib/admin/MaintenanceWindows.js +207 -0
- package/lib/admin/MetricsRoutes.d.ts +8 -0
- package/lib/admin/MetricsRoutes.js +209 -0
- package/lib/admin/MetricsSampler.d.ts +124 -0
- package/lib/admin/MetricsSampler.js +211 -0
- package/lib/admin/OneTimeCodes.d.ts +60 -0
- package/lib/admin/OneTimeCodes.js +162 -0
- package/lib/admin/OpenIdConnect.d.ts +146 -0
- package/lib/admin/OpenIdConnect.js +335 -0
- package/lib/admin/Passkeys.d.ts +130 -0
- package/lib/admin/Passkeys.js +432 -0
- package/lib/admin/PlayerRoutes.d.ts +20 -0
- package/lib/admin/PlayerRoutes.js +676 -0
- package/lib/admin/RedisAdminStore.d.ts +114 -0
- package/lib/admin/RedisAdminStore.js +258 -0
- package/lib/admin/RolloutCoordinator.d.ts +99 -0
- package/lib/admin/RolloutCoordinator.js +321 -0
- package/lib/admin/ServerEvents.d.ts +57 -0
- package/lib/admin/ServerEvents.js +89 -0
- package/lib/admin/SessionRoutes.d.ts +113 -0
- package/lib/admin/SessionRoutes.js +386 -0
- package/lib/admin/UserRoutes.d.ts +8 -0
- package/lib/admin/UserRoutes.js +214 -0
- package/lib/cli.d.ts +2 -0
- package/lib/cli.js +221 -0
- package/lib/console/assets/index-CI5N7c-T.js +21 -0
- package/lib/console/assets/index-DPitvzLp.css +1 -0
- package/lib/console/favicon.svg +1 -0
- package/lib/console/index.html +16 -0
- package/lib/discord/DiscordOAuth.d.ts +57 -0
- package/lib/discord/DiscordOAuth.js +127 -0
- package/lib/discord/DiscordSessions.d.ts +79 -0
- package/lib/discord/DiscordSessions.js +135 -0
- package/lib/discord/DiscordSignInConstants.d.ts +54 -0
- package/lib/discord/DiscordSignInConstants.js +55 -0
- package/lib/discord/DiscordSignInRoutes.d.ts +51 -0
- package/lib/discord/DiscordSignInRoutes.js +359 -0
- package/lib/index.d.ts +24 -0
- package/lib/index.js +18 -0
- package/lib/leaderboards/FileLeaderboardRankStore.d.ts +293 -0
- package/lib/leaderboards/FileLeaderboardRankStore.js +808 -0
- package/lib/leaderboards/LeaderboardConstants.d.ts +63 -0
- package/lib/leaderboards/LeaderboardConstants.js +70 -0
- package/lib/leaderboards/LeaderboardError.d.ts +15 -0
- package/lib/leaderboards/LeaderboardError.js +20 -0
- package/lib/leaderboards/LeaderboardIdentity.d.ts +44 -0
- package/lib/leaderboards/LeaderboardIdentity.js +72 -0
- package/lib/leaderboards/LeaderboardPublicApi.d.ts +17 -0
- package/lib/leaderboards/LeaderboardPublicApi.js +389 -0
- package/lib/leaderboards/LeaderboardRankStore.d.ts +296 -0
- package/lib/leaderboards/LeaderboardRankStore.js +18 -0
- package/lib/leaderboards/LeaderboardRules.d.ts +122 -0
- package/lib/leaderboards/LeaderboardRules.js +472 -0
- package/lib/leaderboards/LeaderboardScheduler.d.ts +43 -0
- package/lib/leaderboards/LeaderboardScheduler.js +103 -0
- package/lib/leaderboards/LeaderboardService.d.ts +669 -0
- package/lib/leaderboards/LeaderboardService.js +1667 -0
- package/lib/leaderboards/LeaderboardTransfer.d.ts +35 -0
- package/lib/leaderboards/LeaderboardTransfer.js +580 -0
- package/lib/leaderboards/RedisLeaderboardRankStore.d.ts +282 -0
- package/lib/leaderboards/RedisLeaderboardRankStore.js +691 -0
- package/package.json +64 -4
- package/templates/init/Caddyfile +6 -0
- package/templates/init/DEPLOY.md +233 -0
- package/templates/init/Dockerfile +12 -0
- package/templates/init/app.json +21 -0
- package/templates/init/aws/README.md +118 -0
- package/templates/init/aws/ecs-fargate-alb.yml +207 -0
- package/templates/init/aws/ecs-fargate-nlb-quic.yml +210 -0
- package/templates/init/babylon-game-server.config.mjs +158 -0
- package/templates/init/colyseus-cloud/README.md +133 -0
- package/templates/init/colyseus-cloud/babylon-game-server.config.mjs +36 -0
- package/templates/init/colyseus-cloud/ecosystem.config.cjs +35 -0
- package/templates/init/colyseus-cloud/gitignore +6 -0
- package/templates/init/colyseus-cloud/package.json +15 -0
- package/templates/init/digitalocean-app.yaml +30 -0
- package/templates/init/docker-compose.full.yml +45 -0
- package/templates/init/docker-compose.websocket.yml +41 -0
- package/templates/init/fly.toml +56 -0
- package/templates/init/heroku.yml +11 -0
- package/templates/init/kubernetes/deployment.yaml +71 -0
- package/templates/init/kubernetes/redis.yaml +73 -0
- package/templates/init/kubernetes/service.yaml +24 -0
- package/templates/init/monitoring/grafana-dashboard.json +539 -0
- package/templates/init/monitoring/prometheus-alerts.yml +51 -0
- package/templates/init/monitoring/prometheus-scrape.yml +17 -0
- package/templates/init/railway.json +13 -0
- package/templates/init/render.yaml +19 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// The rank store of the leaderboard service (game-server-leaderboards D1, DR §5): rankings, players, rate counters and
|
|
2
|
+
// session presence, in the server's own Redis or in files under admin.dataDir. General purpose (D2).
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { LEADERBOARD_DATA_FOLDER } from "./LeaderboardConstants.js";
|
|
5
|
+
import { FileLeaderboardRankStore } from "./FileLeaderboardRankStore.js";
|
|
6
|
+
import { RedisLeaderboardRankStore } from "./RedisLeaderboardRankStore.js";
|
|
7
|
+
/**
|
|
8
|
+
* Opens the rank store on the same Redis client the admin store uses, or on files under admin.dataDir.
|
|
9
|
+
* @param dataDir The resolved admin data folder (used without Redis).
|
|
10
|
+
* @param redisClient The shared ioredis client, or null.
|
|
11
|
+
* @param isFolderPersistent The admin store's isPersistent (the file store's durability).
|
|
12
|
+
* @returns The store.
|
|
13
|
+
*/
|
|
14
|
+
export async function openLeaderboardRankStore(dataDir, redisClient, isFolderPersistent) {
|
|
15
|
+
if (redisClient != null)
|
|
16
|
+
return new RedisLeaderboardRankStore(redisClient);
|
|
17
|
+
return await FileLeaderboardRankStore.open(path.join(dataDir, LEADERBOARD_DATA_FOLDER), isFolderPersistent);
|
|
18
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { LeaderboardBoardDefinition, LeaderboardKeepPolicy, LeaderboardMetadata, LeaderboardRefusalReason, LeaderboardResetSchedule, LeaderboardRules, LeaderboardSortOrder, LeaderboardWriteOutcome } from "../admin/AdminApiTypes.js";
|
|
2
|
+
import type { LeaderboardRankKey, StoredLeaderboardEntry } from "./LeaderboardRankStore.js";
|
|
3
|
+
/**
|
|
4
|
+
* Validates a board definition (DR §10): every field present with its type, the id pattern, name and description
|
|
5
|
+
* lengths, enums, score limits, trust levels and dimensions.
|
|
6
|
+
* @param value The definition to check (any JSON).
|
|
7
|
+
* @returns The definition (null when there are problems) and one sentence per problem.
|
|
8
|
+
*/
|
|
9
|
+
export declare function validateBoardDefinition(value: unknown): {
|
|
10
|
+
definition: LeaderboardBoardDefinition | null;
|
|
11
|
+
problems: string[];
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* The canonical variant key of a submission's variant (D16): `name=value` pairs in declared order joined with `&`, or
|
|
15
|
+
* "all" for a board without dimensions.
|
|
16
|
+
* @param definition The board.
|
|
17
|
+
* @param variant The submission's variant (null or {} for a board without dimensions).
|
|
18
|
+
* @returns The key, or a problem sentence naming the bad key or value.
|
|
19
|
+
*/
|
|
20
|
+
export declare function variantKeyOf(definition: LeaderboardBoardDefinition, variant: Record<string, string> | null): {
|
|
21
|
+
variantKey: string | null;
|
|
22
|
+
problem: string | null;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* The dimension values of a variant key (the inverse of variantKeyOf).
|
|
26
|
+
* @param variantKey The canonical key.
|
|
27
|
+
* @returns Name → value; {} for "all".
|
|
28
|
+
*/
|
|
29
|
+
export declare function variantValuesOf(variantKey: string): Record<string, string>;
|
|
30
|
+
/**
|
|
31
|
+
* The value a ranking sorts ascending by (D13).
|
|
32
|
+
* @param sortOrder The board's sort order.
|
|
33
|
+
* @param score The score.
|
|
34
|
+
* @returns The score for lowerIsBetter, minus the score for higherIsBetter.
|
|
35
|
+
*/
|
|
36
|
+
export declare function sortValueOf(sortOrder: LeaderboardSortOrder, score: number): number;
|
|
37
|
+
/**
|
|
38
|
+
* Orders two entries of one ranking (D13): sort value ascending, then the earlier achievedAtMs, then playerId.
|
|
39
|
+
* @param sortOrder The board's sort order.
|
|
40
|
+
* @param first One entry.
|
|
41
|
+
* @param second The other.
|
|
42
|
+
* @returns Negative when first ranks higher, positive when second does, 0 for the same player and time.
|
|
43
|
+
*/
|
|
44
|
+
export declare function compareRankedEntries(sortOrder: LeaderboardSortOrder, first: StoredLeaderboardEntry, second: StoredLeaderboardEntry): number;
|
|
45
|
+
/**
|
|
46
|
+
* Applies a keep policy (D14) exactly as the Redis submit script does.
|
|
47
|
+
* @param keepPolicy The board's keep policy.
|
|
48
|
+
* @param sortOrder The board's sort order (for best).
|
|
49
|
+
* @param current The player's stored entry, or null.
|
|
50
|
+
* @param incoming The new entry (submissionCount 1, the submitted score).
|
|
51
|
+
* @returns The outcome and the entry to store (current itself when kept).
|
|
52
|
+
*/
|
|
53
|
+
export declare function applyKeepPolicy(keepPolicy: LeaderboardKeepPolicy, sortOrder: LeaderboardSortOrder, current: StoredLeaderboardEntry | null, incoming: StoredLeaderboardEntry): {
|
|
54
|
+
outcome: LeaderboardWriteOutcome;
|
|
55
|
+
stored: StoredLeaderboardEntry;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Checks one submitted score (D12), in order: integer, absolute bound, board limits, run duration.
|
|
59
|
+
* @param definition The board.
|
|
60
|
+
* @param score The submitted score (any JSON).
|
|
61
|
+
* @param durationMs The run's duration, or null.
|
|
62
|
+
* @returns The refusal, or null when the score is acceptable.
|
|
63
|
+
*/
|
|
64
|
+
export declare function checkScore(definition: LeaderboardBoardDefinition, score: unknown, durationMs: number | null): LeaderboardRefusalReason | null;
|
|
65
|
+
/**
|
|
66
|
+
* Checks entry metadata: a plain object of JSON values, nested at most four levels, whose UTF-8 JSON fits the limit.
|
|
67
|
+
* @param metadata The submitted metadata (any JSON).
|
|
68
|
+
* @param maximumBytes The rules' metadataMaximumBytes.
|
|
69
|
+
* @returns The metadata (null when absent or refused) and the refusal, or null.
|
|
70
|
+
*/
|
|
71
|
+
export declare function checkMetadata(metadata: unknown, maximumBytes: number): {
|
|
72
|
+
metadata: LeaderboardMetadata | null;
|
|
73
|
+
refusal: LeaderboardRefusalReason | null;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Cleans a display name: trims, collapses whitespace runs to one space, refuses control characters, lengths outside
|
|
77
|
+
* 1–24 characters and names containing a blocked word (case-insensitive).
|
|
78
|
+
* @param value The name (any JSON).
|
|
79
|
+
* @param blockedWords The rules' blocked words.
|
|
80
|
+
* @returns The clean name, or null with the refusal.
|
|
81
|
+
*/
|
|
82
|
+
export declare function cleanDisplayName(value: unknown, blockedWords: readonly string[]): {
|
|
83
|
+
displayName: string | null;
|
|
84
|
+
refusal: "nameInvalid" | "nameBlocked" | null;
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* The next scheduled reset after a time (D17, UTC): daily the next 00:00, weekly the next Monday 00:00, monthly the 1st
|
|
88
|
+
* of next month 00:00.
|
|
89
|
+
* @param schedule The board's reset schedule.
|
|
90
|
+
* @param fromMs The time to count from, in epoch milliseconds.
|
|
91
|
+
* @returns ISO time, or null for "never" and "manual".
|
|
92
|
+
*/
|
|
93
|
+
export declare function nextResetAt(schedule: LeaderboardResetSchedule, fromMs: number): string | null;
|
|
94
|
+
/**
|
|
95
|
+
* The string form of a rank key: `boardId/seasonId/variantKey` (the variant key is URI-encoded, so it has no `/`).
|
|
96
|
+
* @param key The rank key.
|
|
97
|
+
* @returns The text.
|
|
98
|
+
*/
|
|
99
|
+
export declare function rankKeyText(key: LeaderboardRankKey): string;
|
|
100
|
+
/**
|
|
101
|
+
* Parses rankKeyText's output, splitting on the first two `/`.
|
|
102
|
+
* @param text The text.
|
|
103
|
+
* @returns The rank key.
|
|
104
|
+
*/
|
|
105
|
+
export declare function parseRankKeyText(text: string): LeaderboardRankKey;
|
|
106
|
+
/**
|
|
107
|
+
* Validates console-edited rules (D19): every limit an integer in range, blocked words a list of short strings (kept
|
|
108
|
+
* trimmed and lower-case).
|
|
109
|
+
* @param value The rules (any JSON).
|
|
110
|
+
* @returns The rules (null when there are problems) and one sentence per problem.
|
|
111
|
+
*/
|
|
112
|
+
export declare function validateRules(value: unknown): {
|
|
113
|
+
rules: LeaderboardRules | null;
|
|
114
|
+
problems: string[];
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* The advice shown with a durability that is not "durable" (D20).
|
|
118
|
+
* @param durability The measured durability.
|
|
119
|
+
* @param storage Where the rankings live.
|
|
120
|
+
* @returns The advice sentence, or null.
|
|
121
|
+
*/
|
|
122
|
+
export declare function durabilityAdvice(durability: "durable" | "notDurable" | "unknown", storage: "redis" | "file"): string | null;
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
// Pure rules of the leaderboard service (game-server-leaderboards DR §10): board and rules validation, variant keys,
|
|
2
|
+
// ranking order, keep policies, score, metadata and name checks, reset schedules and durability advice. Synchronous and
|
|
3
|
+
// free of side effects. General purpose: nothing here knows any game (D2).
|
|
4
|
+
import { BOARD_ID_PATTERN, DIMENSION_NAME_PATTERN, LEADERBOARD_ALL_VARIANT_KEY, LEADERBOARD_DURATION_TOLERANCE_MS, LEADERBOARD_MAX_BODY_BYTES, LEADERBOARD_MAXIMUM_ABSOLUTE_SCORE, LEADERBOARD_MAXIMUM_DIMENSION_VALUES, LEADERBOARD_MAXIMUM_DIMENSIONS, LEADERBOARD_MAXIMUM_NAME_LENGTH, LEADERBOARD_MAXIMUM_VALUE_LENGTH } from "./LeaderboardConstants.js";
|
|
5
|
+
/** Sort orders a board may use. */
|
|
6
|
+
const SORT_ORDERS = ["higherIsBetter", "lowerIsBetter"];
|
|
7
|
+
/** Score formats a board may use. */
|
|
8
|
+
const SCORE_FORMATS = ["integer", "timeMilliseconds", "points"];
|
|
9
|
+
/** Keep policies a board may use. */
|
|
10
|
+
const KEEP_POLICIES = ["best", "latest", "sum"];
|
|
11
|
+
/** Trust levels a board may accept. */
|
|
12
|
+
const TRUST_LEVELS = ["client", "session", "server"];
|
|
13
|
+
/** Visibilities a board may have. */
|
|
14
|
+
const VISIBILITIES = ["public", "hidden"];
|
|
15
|
+
/** Reset schedules a board may have. */
|
|
16
|
+
const RESET_SCHEDULES = ["never", "manual", "daily", "weekly", "monthly"];
|
|
17
|
+
/** Longest board display name. */
|
|
18
|
+
const MAXIMUM_BOARD_NAME_LENGTH = 80;
|
|
19
|
+
/** Longest board description. */
|
|
20
|
+
const MAXIMUM_DESCRIPTION_LENGTH = 500;
|
|
21
|
+
/** Longest dimension label. */
|
|
22
|
+
const MAXIMUM_LABEL_LENGTH = 40;
|
|
23
|
+
/** Deepest metadata nesting (the top-level object is level 1). */
|
|
24
|
+
const MAXIMUM_METADATA_DEPTH = 4;
|
|
25
|
+
/** Largest value of a per-minute or per-hour rules limit. */
|
|
26
|
+
const MAXIMUM_RATE_LIMIT = 1000000;
|
|
27
|
+
/** Most scores one submission may carry, whatever the rules say. */
|
|
28
|
+
const MAXIMUM_SCORES_PER_SUBMISSION = 100;
|
|
29
|
+
/** Most blocked words. */
|
|
30
|
+
const MAXIMUM_BLOCKED_WORDS = 1000;
|
|
31
|
+
/** Longest blocked word. */
|
|
32
|
+
const MAXIMUM_BLOCKED_WORD_LENGTH = 64;
|
|
33
|
+
/** getUTCDay() of Monday. */
|
|
34
|
+
const MONDAY = 1;
|
|
35
|
+
/** Days in a week. */
|
|
36
|
+
const DAYS_PER_WEEK = 7;
|
|
37
|
+
/** C0 and C1 control characters (and DEL), refused in display names. */
|
|
38
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/;
|
|
39
|
+
/** Runs of whitespace, collapsed to one space in display names. */
|
|
40
|
+
const WHITESPACE_RUNS = /\s+/g;
|
|
41
|
+
/** Fields of a board definition, each required. */
|
|
42
|
+
const DEFINITION_FIELDS = ["boardId", "displayName", "description", "sortOrder", "scoreFormat", "keepPolicy", "minimumScore", "maximumScore", "acceptedTrustLevels", "visibility", "resetSchedule", "dimensions"];
|
|
43
|
+
/** Integer fields of the rules with their smallest and largest values. */
|
|
44
|
+
const RULE_LIMITS = [
|
|
45
|
+
{ field: "submissionsPerMinutePerPlayer", minimum: 1, maximum: MAXIMUM_RATE_LIMIT },
|
|
46
|
+
{ field: "submissionsPerMinutePerAddress", minimum: 1, maximum: MAXIMUM_RATE_LIMIT },
|
|
47
|
+
{ field: "readsPerMinutePerAddress", minimum: 1, maximum: MAXIMUM_RATE_LIMIT },
|
|
48
|
+
{ field: "identitiesPerHourPerAddress", minimum: 1, maximum: MAXIMUM_RATE_LIMIT },
|
|
49
|
+
{ field: "maximumScoresPerSubmission", minimum: 1, maximum: MAXIMUM_SCORES_PER_SUBMISSION },
|
|
50
|
+
{ field: "metadataMaximumBytes", minimum: 0, maximum: LEADERBOARD_MAX_BODY_BYTES }
|
|
51
|
+
];
|
|
52
|
+
/**
|
|
53
|
+
* Validates a board definition (DR §10): every field present with its type, the id pattern, name and description
|
|
54
|
+
* lengths, enums, score limits, trust levels and dimensions.
|
|
55
|
+
* @param value The definition to check (any JSON).
|
|
56
|
+
* @returns The definition (null when there are problems) and one sentence per problem.
|
|
57
|
+
*/
|
|
58
|
+
export function validateBoardDefinition(value) {
|
|
59
|
+
if (!isPlainObject(value))
|
|
60
|
+
return { definition: null, problems: ["a board definition must be an object"] };
|
|
61
|
+
const missing = DEFINITION_FIELDS.filter((field) => !(field in value));
|
|
62
|
+
if (missing.length > 0)
|
|
63
|
+
return { definition: null, problems: missing.map((field) => field + " is missing") };
|
|
64
|
+
const problems = [
|
|
65
|
+
...identityProblems(value),
|
|
66
|
+
...enumProblems(value),
|
|
67
|
+
...scoreLimitProblems(value.minimumScore, value.maximumScore),
|
|
68
|
+
...trustLevelProblems(value.acceptedTrustLevels),
|
|
69
|
+
...dimensionListProblems(value.dimensions)
|
|
70
|
+
];
|
|
71
|
+
if (problems.length > 0)
|
|
72
|
+
return { definition: null, problems: problems };
|
|
73
|
+
const definition = {
|
|
74
|
+
boardId: value.boardId,
|
|
75
|
+
displayName: value.displayName.trim(),
|
|
76
|
+
description: value.description,
|
|
77
|
+
sortOrder: value.sortOrder,
|
|
78
|
+
scoreFormat: value.scoreFormat,
|
|
79
|
+
keepPolicy: value.keepPolicy,
|
|
80
|
+
minimumScore: value.minimumScore,
|
|
81
|
+
maximumScore: value.maximumScore,
|
|
82
|
+
acceptedTrustLevels: [...value.acceptedTrustLevels],
|
|
83
|
+
visibility: value.visibility,
|
|
84
|
+
resetSchedule: value.resetSchedule,
|
|
85
|
+
dimensions: value.dimensions.map((dimension) => ({ name: dimension.name, label: dimension.label, values: [...dimension.values] }))
|
|
86
|
+
};
|
|
87
|
+
return { definition: definition, problems: [] };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The canonical variant key of a submission's variant (D16): `name=value` pairs in declared order joined with `&`, or
|
|
91
|
+
* "all" for a board without dimensions.
|
|
92
|
+
* @param definition The board.
|
|
93
|
+
* @param variant The submission's variant (null or {} for a board without dimensions).
|
|
94
|
+
* @returns The key, or a problem sentence naming the bad key or value.
|
|
95
|
+
*/
|
|
96
|
+
export function variantKeyOf(definition, variant) {
|
|
97
|
+
const values = (variant != null) ? variant : {};
|
|
98
|
+
if (!isPlainObject(values))
|
|
99
|
+
return { variantKey: null, problem: "variant must be an object" };
|
|
100
|
+
const declaredNames = new Set(definition.dimensions.map((dimension) => dimension.name));
|
|
101
|
+
const extraName = Object.keys(values).find((name) => !declaredNames.has(name));
|
|
102
|
+
if (extraName != null)
|
|
103
|
+
return { variantKey: null, problem: "variant key " + extraName + " is not a dimension of " + definition.boardId };
|
|
104
|
+
if (definition.dimensions.length === 0)
|
|
105
|
+
return { variantKey: LEADERBOARD_ALL_VARIANT_KEY, problem: null };
|
|
106
|
+
for (const dimension of definition.dimensions) {
|
|
107
|
+
const dimensionValue = values[dimension.name];
|
|
108
|
+
if (dimensionValue == null)
|
|
109
|
+
return { variantKey: null, problem: "variant must name " + dimension.name };
|
|
110
|
+
if (typeof dimensionValue !== "string" || !dimension.values.includes(dimensionValue)) {
|
|
111
|
+
return { variantKey: null, problem: "variant value " + String(dimensionValue) + " is not allowed for " + dimension.name };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const pairs = definition.dimensions.map((dimension) => encodeURIComponent(dimension.name) + "=" + encodeURIComponent(values[dimension.name]));
|
|
115
|
+
return { variantKey: pairs.join("&"), problem: null };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The dimension values of a variant key (the inverse of variantKeyOf).
|
|
119
|
+
* @param variantKey The canonical key.
|
|
120
|
+
* @returns Name → value; {} for "all".
|
|
121
|
+
*/
|
|
122
|
+
export function variantValuesOf(variantKey) {
|
|
123
|
+
const values = {};
|
|
124
|
+
if (variantKey === LEADERBOARD_ALL_VARIANT_KEY || variantKey === "")
|
|
125
|
+
return values;
|
|
126
|
+
for (const pair of variantKey.split("&")) {
|
|
127
|
+
const separatorIndex = pair.indexOf("=");
|
|
128
|
+
if (separatorIndex < 0)
|
|
129
|
+
continue;
|
|
130
|
+
values[decodeURIComponent(pair.slice(0, separatorIndex))] = decodeURIComponent(pair.slice(separatorIndex + 1));
|
|
131
|
+
}
|
|
132
|
+
return values;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The value a ranking sorts ascending by (D13).
|
|
136
|
+
* @param sortOrder The board's sort order.
|
|
137
|
+
* @param score The score.
|
|
138
|
+
* @returns The score for lowerIsBetter, minus the score for higherIsBetter.
|
|
139
|
+
*/
|
|
140
|
+
export function sortValueOf(sortOrder, score) {
|
|
141
|
+
return (sortOrder === "higherIsBetter") ? -score : score;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Orders two entries of one ranking (D13): sort value ascending, then the earlier achievedAtMs, then playerId.
|
|
145
|
+
* @param sortOrder The board's sort order.
|
|
146
|
+
* @param first One entry.
|
|
147
|
+
* @param second The other.
|
|
148
|
+
* @returns Negative when first ranks higher, positive when second does, 0 for the same player and time.
|
|
149
|
+
*/
|
|
150
|
+
export function compareRankedEntries(sortOrder, first, second) {
|
|
151
|
+
const sortDifference = sortValueOf(sortOrder, first.score) - sortValueOf(sortOrder, second.score);
|
|
152
|
+
if (sortDifference !== 0)
|
|
153
|
+
return sortDifference;
|
|
154
|
+
const timeDifference = first.achievedAtMs - second.achievedAtMs;
|
|
155
|
+
if (timeDifference !== 0)
|
|
156
|
+
return timeDifference;
|
|
157
|
+
return first.playerId.localeCompare(second.playerId, "en");
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Applies a keep policy (D14) exactly as the Redis submit script does.
|
|
161
|
+
* @param keepPolicy The board's keep policy.
|
|
162
|
+
* @param sortOrder The board's sort order (for best).
|
|
163
|
+
* @param current The player's stored entry, or null.
|
|
164
|
+
* @param incoming The new entry (submissionCount 1, the submitted score).
|
|
165
|
+
* @returns The outcome and the entry to store (current itself when kept).
|
|
166
|
+
*/
|
|
167
|
+
export function applyKeepPolicy(keepPolicy, sortOrder, current, incoming) {
|
|
168
|
+
if (current == null)
|
|
169
|
+
return { outcome: "created", stored: incoming };
|
|
170
|
+
const nextCount = current.submissionCount + 1;
|
|
171
|
+
if (keepPolicy === "sum")
|
|
172
|
+
return { outcome: "added", stored: { ...incoming, score: current.score + incoming.score, submissionCount: nextCount } };
|
|
173
|
+
if (keepPolicy === "latest")
|
|
174
|
+
return { outcome: "replaced", stored: { ...incoming, submissionCount: nextCount } };
|
|
175
|
+
const isBetter = (sortOrder === "lowerIsBetter") ? incoming.score < current.score : incoming.score > current.score;
|
|
176
|
+
if (!isBetter)
|
|
177
|
+
return { outcome: "kept", stored: current };
|
|
178
|
+
return { outcome: "improved", stored: { ...incoming, submissionCount: nextCount } };
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Checks one submitted score (D12), in order: integer, absolute bound, board limits, run duration.
|
|
182
|
+
* @param definition The board.
|
|
183
|
+
* @param score The submitted score (any JSON).
|
|
184
|
+
* @param durationMs The run's duration, or null.
|
|
185
|
+
* @returns The refusal, or null when the score is acceptable.
|
|
186
|
+
*/
|
|
187
|
+
export function checkScore(definition, score, durationMs) {
|
|
188
|
+
if (typeof score !== "number" || !Number.isSafeInteger(score))
|
|
189
|
+
return "scoreNotInteger";
|
|
190
|
+
if (Math.abs(score) > LEADERBOARD_MAXIMUM_ABSOLUTE_SCORE)
|
|
191
|
+
return "scoreOutOfRange";
|
|
192
|
+
if (definition.minimumScore != null && score < definition.minimumScore)
|
|
193
|
+
return "scoreOutOfRange";
|
|
194
|
+
if (definition.maximumScore != null && score > definition.maximumScore)
|
|
195
|
+
return "scoreOutOfRange";
|
|
196
|
+
const isTimeBoard = definition.scoreFormat === "timeMilliseconds";
|
|
197
|
+
if (isTimeBoard && durationMs != null && score > durationMs + LEADERBOARD_DURATION_TOLERANCE_MS)
|
|
198
|
+
return "implausibleDuration";
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Checks entry metadata: a plain object of JSON values, nested at most four levels, whose UTF-8 JSON fits the limit.
|
|
203
|
+
* @param metadata The submitted metadata (any JSON).
|
|
204
|
+
* @param maximumBytes The rules' metadataMaximumBytes.
|
|
205
|
+
* @returns The metadata (null when absent or refused) and the refusal, or null.
|
|
206
|
+
*/
|
|
207
|
+
export function checkMetadata(metadata, maximumBytes) {
|
|
208
|
+
if (metadata == null)
|
|
209
|
+
return { metadata: null, refusal: null };
|
|
210
|
+
if (!isPlainObject(metadata) || !isJsonValue(metadata, 1))
|
|
211
|
+
return { metadata: null, refusal: "metadataTooLarge" };
|
|
212
|
+
const byteLength = new TextEncoder().encode(JSON.stringify(metadata)).length;
|
|
213
|
+
if (byteLength > maximumBytes)
|
|
214
|
+
return { metadata: null, refusal: "metadataTooLarge" };
|
|
215
|
+
return { metadata: metadata, refusal: null };
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Cleans a display name: trims, collapses whitespace runs to one space, refuses control characters, lengths outside
|
|
219
|
+
* 1–24 characters and names containing a blocked word (case-insensitive).
|
|
220
|
+
* @param value The name (any JSON).
|
|
221
|
+
* @param blockedWords The rules' blocked words.
|
|
222
|
+
* @returns The clean name, or null with the refusal.
|
|
223
|
+
*/
|
|
224
|
+
export function cleanDisplayName(value, blockedWords) {
|
|
225
|
+
if (typeof value !== "string")
|
|
226
|
+
return { displayName: null, refusal: "nameInvalid" };
|
|
227
|
+
const collapsed = value.replace(WHITESPACE_RUNS, " ").trim();
|
|
228
|
+
if (CONTROL_CHARACTERS.test(collapsed))
|
|
229
|
+
return { displayName: null, refusal: "nameInvalid" };
|
|
230
|
+
const characterCount = Array.from(collapsed).length;
|
|
231
|
+
if (characterCount < 1 || characterCount > LEADERBOARD_MAXIMUM_NAME_LENGTH)
|
|
232
|
+
return { displayName: null, refusal: "nameInvalid" };
|
|
233
|
+
const lowerName = collapsed.toLowerCase();
|
|
234
|
+
const isBlocked = blockedWords.some((word) => word.length > 0 && lowerName.includes(word.toLowerCase()));
|
|
235
|
+
if (isBlocked)
|
|
236
|
+
return { displayName: null, refusal: "nameBlocked" };
|
|
237
|
+
return { displayName: collapsed, refusal: null };
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* The next scheduled reset after a time (D17, UTC): daily the next 00:00, weekly the next Monday 00:00, monthly the 1st
|
|
241
|
+
* of next month 00:00.
|
|
242
|
+
* @param schedule The board's reset schedule.
|
|
243
|
+
* @param fromMs The time to count from, in epoch milliseconds.
|
|
244
|
+
* @returns ISO time, or null for "never" and "manual".
|
|
245
|
+
*/
|
|
246
|
+
export function nextResetAt(schedule, fromMs) {
|
|
247
|
+
const from = new Date(fromMs);
|
|
248
|
+
const year = from.getUTCFullYear();
|
|
249
|
+
const month = from.getUTCMonth();
|
|
250
|
+
const day = from.getUTCDate();
|
|
251
|
+
if (schedule === "daily")
|
|
252
|
+
return new Date(Date.UTC(year, month, day + 1)).toISOString();
|
|
253
|
+
if (schedule === "monthly")
|
|
254
|
+
return new Date(Date.UTC(year, month + 1, 1)).toISOString();
|
|
255
|
+
if (schedule !== "weekly")
|
|
256
|
+
return null;
|
|
257
|
+
const daysUntilMonday = ((MONDAY - from.getUTCDay() + DAYS_PER_WEEK) % DAYS_PER_WEEK) || DAYS_PER_WEEK;
|
|
258
|
+
return new Date(Date.UTC(year, month, day + daysUntilMonday)).toISOString();
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* The string form of a rank key: `boardId/seasonId/variantKey` (the variant key is URI-encoded, so it has no `/`).
|
|
262
|
+
* @param key The rank key.
|
|
263
|
+
* @returns The text.
|
|
264
|
+
*/
|
|
265
|
+
export function rankKeyText(key) {
|
|
266
|
+
return key.boardId + "/" + key.seasonId + "/" + key.variantKey;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Parses rankKeyText's output, splitting on the first two `/`.
|
|
270
|
+
* @param text The text.
|
|
271
|
+
* @returns The rank key.
|
|
272
|
+
*/
|
|
273
|
+
export function parseRankKeyText(text) {
|
|
274
|
+
const firstSlash = text.indexOf("/");
|
|
275
|
+
const secondSlash = text.indexOf("/", firstSlash + 1);
|
|
276
|
+
return { boardId: text.slice(0, firstSlash), seasonId: Number(text.slice(firstSlash + 1, secondSlash)), variantKey: text.slice(secondSlash + 1) };
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Validates console-edited rules (D19): every limit an integer in range, blocked words a list of short strings (kept
|
|
280
|
+
* trimmed and lower-case).
|
|
281
|
+
* @param value The rules (any JSON).
|
|
282
|
+
* @returns The rules (null when there are problems) and one sentence per problem.
|
|
283
|
+
*/
|
|
284
|
+
export function validateRules(value) {
|
|
285
|
+
if (!isPlainObject(value))
|
|
286
|
+
return { rules: null, problems: ["rules must be an object"] };
|
|
287
|
+
const problems = [];
|
|
288
|
+
for (const limit of RULE_LIMITS) {
|
|
289
|
+
const limitValue = value[limit.field];
|
|
290
|
+
const isValid = typeof limitValue === "number" && Number.isSafeInteger(limitValue) && limitValue >= limit.minimum && limitValue <= limit.maximum;
|
|
291
|
+
if (!isValid)
|
|
292
|
+
problems.push(limit.field + " must be an integer from " + limit.minimum + " to " + limit.maximum);
|
|
293
|
+
}
|
|
294
|
+
const blockedWords = value.blockedWords;
|
|
295
|
+
const areWordsValid = Array.isArray(blockedWords) && blockedWords.length <= MAXIMUM_BLOCKED_WORDS
|
|
296
|
+
&& blockedWords.every((word) => typeof word === "string" && word.trim().length > 0 && word.length <= MAXIMUM_BLOCKED_WORD_LENGTH);
|
|
297
|
+
if (!areWordsValid)
|
|
298
|
+
problems.push("blockedWords must be a list of at most " + MAXIMUM_BLOCKED_WORDS + " words of 1–" + MAXIMUM_BLOCKED_WORD_LENGTH + " characters");
|
|
299
|
+
if (problems.length > 0)
|
|
300
|
+
return { rules: null, problems: problems };
|
|
301
|
+
const rules = {
|
|
302
|
+
submissionsPerMinutePerPlayer: value.submissionsPerMinutePerPlayer,
|
|
303
|
+
submissionsPerMinutePerAddress: value.submissionsPerMinutePerAddress,
|
|
304
|
+
readsPerMinutePerAddress: value.readsPerMinutePerAddress,
|
|
305
|
+
identitiesPerHourPerAddress: value.identitiesPerHourPerAddress,
|
|
306
|
+
maximumScoresPerSubmission: value.maximumScoresPerSubmission,
|
|
307
|
+
metadataMaximumBytes: value.metadataMaximumBytes,
|
|
308
|
+
blockedWords: blockedWords.map((word) => word.trim().toLowerCase())
|
|
309
|
+
};
|
|
310
|
+
return { rules: rules, problems: [] };
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* The advice shown with a durability that is not "durable" (D20).
|
|
314
|
+
* @param durability The measured durability.
|
|
315
|
+
* @param storage Where the rankings live.
|
|
316
|
+
* @returns The advice sentence, or null.
|
|
317
|
+
*/
|
|
318
|
+
export function durabilityAdvice(durability, storage) {
|
|
319
|
+
if (durability === "notDurable" && storage === "file") {
|
|
320
|
+
return "Leaderboards are kept in files on this machine's own disk and are lost when it is replaced or redeployed. Set GAME_SERVER_REDIS_URL (on Colyseus Cloud, REDIS_URI) to a Redis with persistence, or mount a volume at GAME_SERVER_ADMIN_DATA_DIR, and export a backup now.";
|
|
321
|
+
}
|
|
322
|
+
if (durability === "notDurable" && storage === "redis") {
|
|
323
|
+
return "This Redis keeps no snapshot or append-only file, so a restart loses every leaderboard. Turn on appendonly (or RDB saves), or point GAME_SERVER_REDIS_URL at a managed Redis with persistence.";
|
|
324
|
+
}
|
|
325
|
+
if (durability === "unknown" && storage === "redis") {
|
|
326
|
+
return "This Redis does not say whether it persists data (INFO or CONFIG is restricted). Check your Redis host keeps snapshots, and export backups from the console.";
|
|
327
|
+
}
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Whether a value is a plain object (not null, not an array).
|
|
332
|
+
* @param value Any value.
|
|
333
|
+
* @returns True for a plain object.
|
|
334
|
+
*/
|
|
335
|
+
function isPlainObject(value) {
|
|
336
|
+
return typeof value === "object" && value != null && !Array.isArray(value);
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Whether a value is JSON (strings, finite numbers, booleans, null, arrays, objects) nested within the metadata limit.
|
|
340
|
+
* @param value Any value.
|
|
341
|
+
* @param depth The nesting level of this value (the top-level object is 1).
|
|
342
|
+
* @returns True when it is JSON within the depth.
|
|
343
|
+
*/
|
|
344
|
+
function isJsonValue(value, depth) {
|
|
345
|
+
if (value == null || typeof value === "string" || typeof value === "boolean")
|
|
346
|
+
return true;
|
|
347
|
+
if (typeof value === "number")
|
|
348
|
+
return Number.isFinite(value);
|
|
349
|
+
if (typeof value !== "object" || depth > MAXIMUM_METADATA_DEPTH)
|
|
350
|
+
return false;
|
|
351
|
+
const children = Array.isArray(value) ? value : Object.values(value);
|
|
352
|
+
return children.every((child) => isJsonValue(child, depth + 1));
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Problems of the board id, display name and description.
|
|
356
|
+
* @param value The definition.
|
|
357
|
+
* @returns The problems.
|
|
358
|
+
*/
|
|
359
|
+
function identityProblems(value) {
|
|
360
|
+
const problems = [];
|
|
361
|
+
if (typeof value.boardId !== "string" || !BOARD_ID_PATTERN.test(value.boardId)) {
|
|
362
|
+
problems.push("boardId must be 1–64 of a-z, 0-9 and -, starting with a letter or digit");
|
|
363
|
+
}
|
|
364
|
+
const nameLength = (typeof value.displayName === "string") ? value.displayName.trim().length : 0;
|
|
365
|
+
if (nameLength < 1 || nameLength > MAXIMUM_BOARD_NAME_LENGTH)
|
|
366
|
+
problems.push("displayName must be 1–" + MAXIMUM_BOARD_NAME_LENGTH + " characters");
|
|
367
|
+
if (typeof value.description !== "string" || value.description.length > MAXIMUM_DESCRIPTION_LENGTH) {
|
|
368
|
+
problems.push("description must be text of at most " + MAXIMUM_DESCRIPTION_LENGTH + " characters");
|
|
369
|
+
}
|
|
370
|
+
return problems;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Problems of the enum fields.
|
|
374
|
+
* @param value The definition.
|
|
375
|
+
* @returns The problems.
|
|
376
|
+
*/
|
|
377
|
+
function enumProblems(value) {
|
|
378
|
+
const enumFields = [
|
|
379
|
+
{ field: "sortOrder", allowed: SORT_ORDERS },
|
|
380
|
+
{ field: "scoreFormat", allowed: SCORE_FORMATS },
|
|
381
|
+
{ field: "keepPolicy", allowed: KEEP_POLICIES },
|
|
382
|
+
{ field: "visibility", allowed: VISIBILITIES },
|
|
383
|
+
{ field: "resetSchedule", allowed: RESET_SCHEDULES }
|
|
384
|
+
];
|
|
385
|
+
return enumFields
|
|
386
|
+
.filter((enumField) => !enumField.allowed.includes(value[enumField.field]))
|
|
387
|
+
.map((enumField) => enumField.field + " must be one of " + enumField.allowed.join(", "));
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Problems of the score limits: null or integers within the absolute bound, minimum ≤ maximum.
|
|
391
|
+
* @param minimum The minimumScore.
|
|
392
|
+
* @param maximum The maximumScore.
|
|
393
|
+
* @returns The problems.
|
|
394
|
+
*/
|
|
395
|
+
function scoreLimitProblems(minimum, maximum) {
|
|
396
|
+
const problems = [];
|
|
397
|
+
if (!isScoreLimit(minimum))
|
|
398
|
+
problems.push("minimumScore must be null or an integer within ±" + LEADERBOARD_MAXIMUM_ABSOLUTE_SCORE);
|
|
399
|
+
if (!isScoreLimit(maximum))
|
|
400
|
+
problems.push("maximumScore must be null or an integer within ±" + LEADERBOARD_MAXIMUM_ABSOLUTE_SCORE);
|
|
401
|
+
if (problems.length === 0 && minimum != null && maximum != null && minimum > maximum) {
|
|
402
|
+
problems.push("minimumScore must not be greater than maximumScore");
|
|
403
|
+
}
|
|
404
|
+
return problems;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Whether a score limit is null or an integer within the absolute bound.
|
|
408
|
+
* @param value The limit.
|
|
409
|
+
* @returns True when valid.
|
|
410
|
+
*/
|
|
411
|
+
function isScoreLimit(value) {
|
|
412
|
+
if (value === null)
|
|
413
|
+
return true;
|
|
414
|
+
return typeof value === "number" && Number.isSafeInteger(value) && Math.abs(value) <= LEADERBOARD_MAXIMUM_ABSOLUTE_SCORE;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Problems of the accepted trust levels: a non-empty list of known levels without duplicates.
|
|
418
|
+
* @param value The acceptedTrustLevels.
|
|
419
|
+
* @returns The problems.
|
|
420
|
+
*/
|
|
421
|
+
function trustLevelProblems(value) {
|
|
422
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
423
|
+
return ["acceptedTrustLevels must list at least one trust level"];
|
|
424
|
+
const areKnown = value.every((level) => typeof level === "string" && TRUST_LEVELS.includes(level));
|
|
425
|
+
if (!areKnown)
|
|
426
|
+
return ["acceptedTrustLevels may hold only " + TRUST_LEVELS.join(", ")];
|
|
427
|
+
if (new Set(value).size !== value.length)
|
|
428
|
+
return ["acceptedTrustLevels must not repeat a level"];
|
|
429
|
+
return [];
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Problems of the dimension list: at most four, unique names, each dimension valid.
|
|
433
|
+
* @param value The dimensions.
|
|
434
|
+
* @returns The problems.
|
|
435
|
+
*/
|
|
436
|
+
function dimensionListProblems(value) {
|
|
437
|
+
if (!Array.isArray(value))
|
|
438
|
+
return ["dimensions must be a list"];
|
|
439
|
+
if (value.length > LEADERBOARD_MAXIMUM_DIMENSIONS)
|
|
440
|
+
return ["a board may declare at most " + LEADERBOARD_MAXIMUM_DIMENSIONS + " dimensions"];
|
|
441
|
+
const problems = value.flatMap((dimension, index) => dimensionProblems(dimension, index));
|
|
442
|
+
const names = value.map((dimension) => isPlainObject(dimension) ? dimension.name : null);
|
|
443
|
+
if (problems.length === 0 && new Set(names).size !== names.length)
|
|
444
|
+
problems.push("dimension names must be unique");
|
|
445
|
+
return problems;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Problems of one dimension: name pattern, label length, 1–100 unique values of 1–64 characters.
|
|
449
|
+
* @param dimension The dimension.
|
|
450
|
+
* @param index Its position (for the sentence).
|
|
451
|
+
* @returns The problems.
|
|
452
|
+
*/
|
|
453
|
+
function dimensionProblems(dimension, index) {
|
|
454
|
+
const label = "dimension " + (index + 1);
|
|
455
|
+
if (!isPlainObject(dimension))
|
|
456
|
+
return [label + " must be an object"];
|
|
457
|
+
const problems = [];
|
|
458
|
+
if (typeof dimension.name !== "string" || !DIMENSION_NAME_PATTERN.test(dimension.name)) {
|
|
459
|
+
problems.push(label + " name must be 1–32 of letters, digits, _ and -, starting with a letter");
|
|
460
|
+
}
|
|
461
|
+
const labelLength = (typeof dimension.label === "string") ? dimension.label.trim().length : 0;
|
|
462
|
+
if (labelLength < 1 || labelLength > MAXIMUM_LABEL_LENGTH)
|
|
463
|
+
problems.push(label + " label must be 1–" + MAXIMUM_LABEL_LENGTH + " characters");
|
|
464
|
+
const values = dimension.values;
|
|
465
|
+
const areValuesValid = Array.isArray(values) && values.length >= 1 && values.length <= LEADERBOARD_MAXIMUM_DIMENSION_VALUES
|
|
466
|
+
&& values.every((entry) => typeof entry === "string" && entry.length >= 1 && entry.length <= LEADERBOARD_MAXIMUM_VALUE_LENGTH)
|
|
467
|
+
&& new Set(values).size === values.length;
|
|
468
|
+
if (!areValuesValid) {
|
|
469
|
+
problems.push(label + " values must be 1–" + LEADERBOARD_MAXIMUM_DIMENSION_VALUES + " unique texts of 1–" + LEADERBOARD_MAXIMUM_VALUE_LENGTH + " characters");
|
|
470
|
+
}
|
|
471
|
+
return problems;
|
|
472
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { AdminClock } from "../admin/AdminClock.js";
|
|
2
|
+
import type { AdminStore } from "../admin/AdminStore.js";
|
|
3
|
+
import type { LeaderboardService } from "./LeaderboardService.js";
|
|
4
|
+
/** Scheduled season resets and activity trimming, run on the lease holder (D17). */
|
|
5
|
+
export declare class LeaderboardScheduler {
|
|
6
|
+
/** The service whose boards are reset. */
|
|
7
|
+
private readonly service;
|
|
8
|
+
/** The admin store (lease and activity). */
|
|
9
|
+
private readonly adminStore;
|
|
10
|
+
/** The service clock (timers and "now"). */
|
|
11
|
+
private readonly clock;
|
|
12
|
+
/** This process's id, read lazily (matchMaker.processId). */
|
|
13
|
+
private readonly processId;
|
|
14
|
+
/** Milliseconds between ticks. */
|
|
15
|
+
private readonly tickMs;
|
|
16
|
+
/** The interval handle while started, or null. */
|
|
17
|
+
private intervalHandle;
|
|
18
|
+
/** The tick in progress, or null; a tick that comes due meanwhile is skipped. */
|
|
19
|
+
private runningTick;
|
|
20
|
+
/**
|
|
21
|
+
* Creates the scheduler (nothing runs until start()).
|
|
22
|
+
* @param service The leaderboard service.
|
|
23
|
+
* @param adminStore The admin store.
|
|
24
|
+
* @param clock The service clock.
|
|
25
|
+
* @param processId This process's id.
|
|
26
|
+
* @param tickMs Milliseconds between ticks.
|
|
27
|
+
*/
|
|
28
|
+
constructor(service: LeaderboardService, adminStore: AdminStore, clock: AdminClock, processId: () => string, tickMs: number);
|
|
29
|
+
/** Starts ticking every tickMs (a second call does nothing). */
|
|
30
|
+
start(): void;
|
|
31
|
+
/** Stops ticking and waits for a tick in progress. */
|
|
32
|
+
stop(): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* One tick: when this process holds the lease, resets every due board and trims the activity stream. Never throws;
|
|
35
|
+
* failures are logged at warn.
|
|
36
|
+
* @returns Resolves when the tick is done (the running tick when one is in progress).
|
|
37
|
+
*/
|
|
38
|
+
tick(): Promise<void>;
|
|
39
|
+
/** The work of one tick. */
|
|
40
|
+
private runOnce;
|
|
41
|
+
/** Resets every board whose nextResetAt has come; a failing board is logged and the others still run. */
|
|
42
|
+
private resetDueBoards;
|
|
43
|
+
}
|