@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,35 @@
|
|
|
1
|
+
import type { LeaderboardExportFile, LeaderboardImportRequest, LeaderboardImportResult } from "../admin/AdminApiTypes.js";
|
|
2
|
+
import { type AdminClock } from "../admin/AdminClock.js";
|
|
3
|
+
import type { LeaderboardService } from "./LeaderboardService.js";
|
|
4
|
+
/**
|
|
5
|
+
* Builds a JSON backup: every board or one, entries live and removed, every season or one, and every player those entries
|
|
6
|
+
* name (key hashes included, so anonymous players keep their keys after a restore).
|
|
7
|
+
* @param service The leaderboard service.
|
|
8
|
+
* @param boardId One board, or null for all.
|
|
9
|
+
* @param seasonId One season's entries, or null for every season's.
|
|
10
|
+
* @param clock The clock of the export time (the service's clock; default the system clock).
|
|
11
|
+
* @returns The export file.
|
|
12
|
+
* @throws LeaderboardError 404 for an unknown board, or a season that one named board does not keep.
|
|
13
|
+
*/
|
|
14
|
+
export declare function exportLeaderboards(service: LeaderboardService, boardId: string | null, seasonId: number | null, clock?: AdminClock): Promise<LeaderboardExportFile>;
|
|
15
|
+
/**
|
|
16
|
+
* Builds the CSV of one ranking: the header and one RFC 4180 line per live entry in rank order.
|
|
17
|
+
* @param service The leaderboard service.
|
|
18
|
+
* @param boardId The board.
|
|
19
|
+
* @param seasonId The season.
|
|
20
|
+
* @param variantKey The variant key.
|
|
21
|
+
* @returns The CSV text.
|
|
22
|
+
* @throws LeaderboardError 404 for an unknown board or a season the board does not keep.
|
|
23
|
+
*/
|
|
24
|
+
export declare function exportRankingCsv(service: LeaderboardService, boardId: string, seasonId: number, variantKey: string): Promise<string>;
|
|
25
|
+
/**
|
|
26
|
+
* Imports a backup or a definitions file. Every board is validated before anything is written; a dry run computes the
|
|
27
|
+
* same result without writing. "skip" leaves existing boards (and existing players) alone; "replace" deletes an existing
|
|
28
|
+
* board first and overwrites the file's players.
|
|
29
|
+
* @param service The leaderboard service.
|
|
30
|
+
* @param request The file, the conflict policy and the dry-run flag.
|
|
31
|
+
* @param clock The clock of the writes (the service's clock; default the system clock).
|
|
32
|
+
* @returns What was (or would be) created, replaced, skipped and written.
|
|
33
|
+
* @throws LeaderboardError 400 invalidRequest naming every problem of the file.
|
|
34
|
+
*/
|
|
35
|
+
export declare function importLeaderboards(service: LeaderboardService, request: LeaderboardImportRequest, clock?: AdminClock): Promise<LeaderboardImportResult>;
|
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
// Leaderboard backups (game-server-leaderboards T6, DR §6 export/import rows, §15 imports): the JSON export with every
|
|
2
|
+
// live and removed entry and the players they name, the CSV ranking export, and the import that restores a backup or
|
|
3
|
+
// creates the boards of a definitions file. General purpose: nothing here knows any game (D2).
|
|
4
|
+
import { SYSTEM_CLOCK } from "../admin/AdminClock.js";
|
|
5
|
+
import { LeaderboardError } from "./LeaderboardError.js";
|
|
6
|
+
import { validateBoardDefinition } from "./LeaderboardRules.js";
|
|
7
|
+
/** The format marker of an export file. */
|
|
8
|
+
const EXPORT_FORMAT = "babylon-game-server-leaderboards";
|
|
9
|
+
/** The format marker of a definitions file (D11). */
|
|
10
|
+
const DEFINITIONS_FORMAT = "babylon-game-server-leaderboard-definitions";
|
|
11
|
+
/** The only file version this server reads and writes. */
|
|
12
|
+
const FILE_VERSION = 1;
|
|
13
|
+
/** Admin store collection of the boards (the service's own collection). */
|
|
14
|
+
const LEADERBOARD_BOARDS_COLLECTION = "leaderboards";
|
|
15
|
+
/** Entries read per page while exporting a ranking. */
|
|
16
|
+
const EXPORT_PAGE_SIZE = 1000;
|
|
17
|
+
/** The CSV ranking header (DR §6). */
|
|
18
|
+
const CSV_HEADER = "rank,playerId,displayName,score,variantKey,seasonId,trustLevel,submittedAt";
|
|
19
|
+
/** The CSV line ending (RFC 4180). */
|
|
20
|
+
const CSV_LINE_END = "\r\n";
|
|
21
|
+
/** Characters that make a CSV field need quotes (RFC 4180). */
|
|
22
|
+
const CSV_QUOTED_CHARACTERS = /[",\r\n]/;
|
|
23
|
+
/** Every trust level an exported entry may carry. */
|
|
24
|
+
const TRUST_LEVELS = new Set(["client", "session", "server"]);
|
|
25
|
+
/** Every kind an exported player may have. */
|
|
26
|
+
const PLAYER_KINDS = new Set(["anonymous", "user", "manual"]);
|
|
27
|
+
/** The conflict policies of an import. */
|
|
28
|
+
const CONFLICT_POLICIES = new Set(["skip", "replace"]);
|
|
29
|
+
/** Keep policy of every imported write: the entry as exported, never merged with a better score. */
|
|
30
|
+
const IMPORT_KEEP_POLICY = "latest";
|
|
31
|
+
// ---- Export ---------------------------------------------------------------------------------------------------------
|
|
32
|
+
/**
|
|
33
|
+
* Builds a JSON backup: every board or one, entries live and removed, every season or one, and every player those entries
|
|
34
|
+
* name (key hashes included, so anonymous players keep their keys after a restore).
|
|
35
|
+
* @param service The leaderboard service.
|
|
36
|
+
* @param boardId One board, or null for all.
|
|
37
|
+
* @param seasonId One season's entries, or null for every season's.
|
|
38
|
+
* @param clock The clock of the export time (the service's clock; default the system clock).
|
|
39
|
+
* @returns The export file.
|
|
40
|
+
* @throws LeaderboardError 404 for an unknown board, or a season that one named board does not keep.
|
|
41
|
+
*/
|
|
42
|
+
export async function exportLeaderboards(service, boardId, seasonId, clock = SYSTEM_CLOCK) {
|
|
43
|
+
const records = (boardId != null) ? [await service.getBoardRecord(boardId)] : await service.listBoardRecords();
|
|
44
|
+
if (boardId != null && seasonId != null)
|
|
45
|
+
requireKeptSeason(records[0].value, seasonId);
|
|
46
|
+
const rankStore = service.stores.rankStore;
|
|
47
|
+
const boards = [];
|
|
48
|
+
const playerIds = new Set();
|
|
49
|
+
for (const stored of records) {
|
|
50
|
+
const board = await exportBoard(rankStore, stored.value, seasonId);
|
|
51
|
+
for (const entry of board.entries)
|
|
52
|
+
playerIds.add(entry.playerId);
|
|
53
|
+
boards.push(board);
|
|
54
|
+
}
|
|
55
|
+
return { format: EXPORT_FORMAT, version: FILE_VERSION, exportedAt: new Date(clock.now()).toISOString(), boards: boards, players: await exportPlayers(rankStore, playerIds) };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Builds the CSV of one ranking: the header and one RFC 4180 line per live entry in rank order.
|
|
59
|
+
* @param service The leaderboard service.
|
|
60
|
+
* @param boardId The board.
|
|
61
|
+
* @param seasonId The season.
|
|
62
|
+
* @param variantKey The variant key.
|
|
63
|
+
* @returns The CSV text.
|
|
64
|
+
* @throws LeaderboardError 404 for an unknown board or a season the board does not keep.
|
|
65
|
+
*/
|
|
66
|
+
export async function exportRankingCsv(service, boardId, seasonId, variantKey) {
|
|
67
|
+
const record = (await service.getBoardRecord(boardId)).value;
|
|
68
|
+
requireKeptSeason(record, seasonId);
|
|
69
|
+
const entries = await readWholeRanking(service.stores.rankStore, { boardId: boardId, seasonId: seasonId, variantKey: variantKey });
|
|
70
|
+
const lines = [CSV_HEADER];
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
const fields = [entry.rank, entry.playerId, entry.displayName, entry.score, variantKey, seasonId, entry.trustLevel, entry.submittedAt];
|
|
73
|
+
lines.push(fields.map(csvField).join(","));
|
|
74
|
+
}
|
|
75
|
+
return lines.join(CSV_LINE_END) + CSV_LINE_END;
|
|
76
|
+
}
|
|
77
|
+
// ---- Import ---------------------------------------------------------------------------------------------------------
|
|
78
|
+
/**
|
|
79
|
+
* Imports a backup or a definitions file. Every board is validated before anything is written; a dry run computes the
|
|
80
|
+
* same result without writing. "skip" leaves existing boards (and existing players) alone; "replace" deletes an existing
|
|
81
|
+
* board first and overwrites the file's players.
|
|
82
|
+
* @param service The leaderboard service.
|
|
83
|
+
* @param request The file, the conflict policy and the dry-run flag.
|
|
84
|
+
* @param clock The clock of the writes (the service's clock; default the system clock).
|
|
85
|
+
* @returns What was (or would be) created, replaced, skipped and written.
|
|
86
|
+
* @throws LeaderboardError 400 invalidRequest naming every problem of the file.
|
|
87
|
+
*/
|
|
88
|
+
export async function importLeaderboards(service, request, clock = SYSTEM_CLOCK) {
|
|
89
|
+
if (!CONFLICT_POLICIES.has(request.conflictPolicy))
|
|
90
|
+
throw new LeaderboardError(400, "invalidRequest", "conflictPolicy must be skip or replace");
|
|
91
|
+
const plan = parseImportFile(request.file);
|
|
92
|
+
const isReplacing = request.conflictPolicy === "replace";
|
|
93
|
+
const actions = [];
|
|
94
|
+
for (const board of plan.boards)
|
|
95
|
+
actions.push(await boardActionOf(service, board.definition.boardId, isReplacing));
|
|
96
|
+
const players = isReplacing ? plan.players : await missingPlayers(service.stores.rankStore, plan.players);
|
|
97
|
+
const writtenBoards = plan.boards.filter((board, index) => actions[index] !== "skip");
|
|
98
|
+
const result = {
|
|
99
|
+
dryRun: request.dryRun,
|
|
100
|
+
boardsCreated: boardIdsWith(plan.boards, actions, "create"),
|
|
101
|
+
boardsReplaced: boardIdsWith(plan.boards, actions, "replace"),
|
|
102
|
+
boardsSkipped: boardIdsWith(plan.boards, actions, "skip"),
|
|
103
|
+
entriesWritten: writtenBoards.reduce((total, board) => total + board.entries.length, 0),
|
|
104
|
+
playersWritten: players.length
|
|
105
|
+
};
|
|
106
|
+
if (request.dryRun)
|
|
107
|
+
return result;
|
|
108
|
+
for (const player of players)
|
|
109
|
+
await service.stores.rankStore.putPlayer(toPlayerRecord(player));
|
|
110
|
+
for (let i = 0; i < plan.boards.length; i++) {
|
|
111
|
+
if (actions[i] === "skip")
|
|
112
|
+
continue;
|
|
113
|
+
if (actions[i] === "replace")
|
|
114
|
+
await service.deleteBoard(plan.boards[i].definition.boardId);
|
|
115
|
+
await writeBoard(service, plan.boards[i], clock);
|
|
116
|
+
}
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
// ---- Export helpers -------------------------------------------------------------------------------------------------
|
|
120
|
+
/**
|
|
121
|
+
* One board of an export.
|
|
122
|
+
* @param rankStore The rank store.
|
|
123
|
+
* @param record The board.
|
|
124
|
+
* @param seasonId One season (no entries when the board does not keep it), or null for every season the board keeps.
|
|
125
|
+
* @returns The exported board.
|
|
126
|
+
*/
|
|
127
|
+
async function exportBoard(rankStore, record, seasonId) {
|
|
128
|
+
const keptSeasonIds = record.seasons.map((season) => season.seasonId);
|
|
129
|
+
const seasonIds = (seasonId != null) ? keptSeasonIds.filter((keptSeasonId) => keptSeasonId === seasonId) : keptSeasonIds;
|
|
130
|
+
const entries = [];
|
|
131
|
+
for (const exportedSeasonId of seasonIds) {
|
|
132
|
+
for (const variantKey of await rankStore.listVariantKeys(record.definition.boardId, exportedSeasonId)) {
|
|
133
|
+
const key = { boardId: record.definition.boardId, seasonId: exportedSeasonId, variantKey: variantKey };
|
|
134
|
+
for (const entry of await readWholeRanking(rankStore, key))
|
|
135
|
+
entries.push(toExportEntry(key, entry, null));
|
|
136
|
+
for (const removed of await rankStore.readRemoved(key))
|
|
137
|
+
entries.push(toExportEntry(key, removed.entry, removed));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { definition: record.definition, currentSeasonId: record.currentSeasonId, seasons: record.seasons, entries: entries };
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Every live entry of a ranking, best first, read in pages.
|
|
144
|
+
* @param rankStore The rank store.
|
|
145
|
+
* @param key The ranking.
|
|
146
|
+
* @returns The entries with their ranks.
|
|
147
|
+
*/
|
|
148
|
+
async function readWholeRanking(rankStore, key) {
|
|
149
|
+
const total = await rankStore.count(key);
|
|
150
|
+
const entries = [];
|
|
151
|
+
for (let offset = 0; offset < total; offset += EXPORT_PAGE_SIZE)
|
|
152
|
+
entries.push(...await rankStore.readRange(key, offset, EXPORT_PAGE_SIZE));
|
|
153
|
+
return entries;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* An entry as exported.
|
|
157
|
+
* @param key Its ranking.
|
|
158
|
+
* @param entry The stored entry.
|
|
159
|
+
* @param removed The removal record of a removed entry, or null for a live one.
|
|
160
|
+
* @returns The exported entry.
|
|
161
|
+
*/
|
|
162
|
+
function toExportEntry(key, entry, removed) {
|
|
163
|
+
return {
|
|
164
|
+
seasonId: key.seasonId,
|
|
165
|
+
variantKey: key.variantKey,
|
|
166
|
+
playerId: entry.playerId,
|
|
167
|
+
displayName: entry.displayName,
|
|
168
|
+
score: entry.score,
|
|
169
|
+
metadata: entry.metadata,
|
|
170
|
+
trustLevel: entry.trustLevel,
|
|
171
|
+
sessionName: entry.sessionName,
|
|
172
|
+
submittedAt: entry.submittedAt,
|
|
173
|
+
achievedAt: entry.achievedAt,
|
|
174
|
+
submissionCount: entry.submissionCount,
|
|
175
|
+
removedReason: (removed != null) ? removed.reason : null,
|
|
176
|
+
removedBy: (removed != null) ? removed.removedBy : null,
|
|
177
|
+
removedAt: (removed != null) ? removed.removedAt : null
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The player records an export names (players without a record are left out).
|
|
182
|
+
* @param rankStore The rank store.
|
|
183
|
+
* @param playerIds The players.
|
|
184
|
+
* @returns The exported players, sorted by id.
|
|
185
|
+
*/
|
|
186
|
+
async function exportPlayers(rankStore, playerIds) {
|
|
187
|
+
const players = [];
|
|
188
|
+
for (const playerId of Array.from(playerIds).sort()) {
|
|
189
|
+
const record = await rankStore.getPlayer(playerId);
|
|
190
|
+
if (record == null)
|
|
191
|
+
continue;
|
|
192
|
+
players.push({ playerId: record.playerId, displayName: record.displayName, kind: record.kind, keyHash: record.keyHash, hidden: record.hidden, createdAt: record.createdAt });
|
|
193
|
+
}
|
|
194
|
+
return players;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* One CSV field, quoted when it holds a quote, a comma or a line break (RFC 4180).
|
|
198
|
+
* @param value The value.
|
|
199
|
+
* @returns The field.
|
|
200
|
+
*/
|
|
201
|
+
function csvField(value) {
|
|
202
|
+
const text = String(value);
|
|
203
|
+
if (!CSV_QUOTED_CHARACTERS.test(text))
|
|
204
|
+
return text;
|
|
205
|
+
return "\"" + text.replace(/"/g, "\"\"") + "\"";
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Checks that a board keeps a season.
|
|
209
|
+
* @param record The board.
|
|
210
|
+
* @param seasonId The season.
|
|
211
|
+
* @throws LeaderboardError 404 invalidRequest when it does not.
|
|
212
|
+
*/
|
|
213
|
+
function requireKeptSeason(record, seasonId) {
|
|
214
|
+
const isKept = record.seasons.some((season) => season.seasonId === seasonId);
|
|
215
|
+
if (!isKept)
|
|
216
|
+
throw new LeaderboardError(404, "invalidRequest", "board " + record.definition.boardId + " has no season " + seasonId);
|
|
217
|
+
}
|
|
218
|
+
// ---- Import helpers -------------------------------------------------------------------------------------------------
|
|
219
|
+
/**
|
|
220
|
+
* Validates an import file completely.
|
|
221
|
+
* @param file The file (any JSON).
|
|
222
|
+
* @returns The plan.
|
|
223
|
+
* @throws LeaderboardError 400 invalidRequest with every problem joined.
|
|
224
|
+
*/
|
|
225
|
+
function parseImportFile(file) {
|
|
226
|
+
if (!isPlainObject(file))
|
|
227
|
+
throw new LeaderboardError(400, "invalidRequest", "file must be an export or definitions file object");
|
|
228
|
+
if (file.version !== FILE_VERSION)
|
|
229
|
+
throw new LeaderboardError(400, "invalidRequest", "file.version must be 1");
|
|
230
|
+
if (!Array.isArray(file.boards))
|
|
231
|
+
throw new LeaderboardError(400, "invalidRequest", "file.boards must be a list");
|
|
232
|
+
const problems = [];
|
|
233
|
+
let plan;
|
|
234
|
+
if (file.format === DEFINITIONS_FORMAT)
|
|
235
|
+
plan = { boards: parseDefinitionBoards(file.boards, problems), players: [] };
|
|
236
|
+
else if (file.format === EXPORT_FORMAT)
|
|
237
|
+
plan = { boards: parseExportBoards(file.boards, problems), players: parsePlayers(file.players, problems) };
|
|
238
|
+
else
|
|
239
|
+
throw new LeaderboardError(400, "invalidRequest", "file.format must be " + EXPORT_FORMAT + " or " + DEFINITIONS_FORMAT);
|
|
240
|
+
problems.push(...duplicateBoardProblems(plan.boards));
|
|
241
|
+
if (problems.length > 0)
|
|
242
|
+
throw new LeaderboardError(400, "invalidRequest", problems.join("; "));
|
|
243
|
+
return plan;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* The boards of a definitions file.
|
|
247
|
+
* @param values The file's boards.
|
|
248
|
+
* @param problems Collects one sentence per problem.
|
|
249
|
+
* @returns The valid boards.
|
|
250
|
+
*/
|
|
251
|
+
function parseDefinitionBoards(values, problems) {
|
|
252
|
+
const boards = [];
|
|
253
|
+
values.forEach((value, index) => {
|
|
254
|
+
const definition = checkedDefinition(value, "boards[" + index + "]", problems);
|
|
255
|
+
if (definition != null)
|
|
256
|
+
boards.push({ definition: definition, currentSeasonId: null, seasons: null, entries: [] });
|
|
257
|
+
});
|
|
258
|
+
return boards;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* The boards of an export file: definition, seasons and entries.
|
|
262
|
+
* @param values The file's boards.
|
|
263
|
+
* @param problems Collects one sentence per problem.
|
|
264
|
+
* @returns The valid boards.
|
|
265
|
+
*/
|
|
266
|
+
function parseExportBoards(values, problems) {
|
|
267
|
+
const boards = [];
|
|
268
|
+
values.forEach((value, index) => {
|
|
269
|
+
const label = "boards[" + index + "]";
|
|
270
|
+
if (!isPlainObject(value)) {
|
|
271
|
+
problems.push(label + " must be an object");
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const definition = checkedDefinition(value.definition, label, problems);
|
|
275
|
+
const seasons = checkedSeasons(value, label, problems);
|
|
276
|
+
const entries = checkedEntries(value.entries, label, seasons, problems);
|
|
277
|
+
if (definition == null || seasons == null)
|
|
278
|
+
return;
|
|
279
|
+
boards.push({ definition: definition, currentSeasonId: value.currentSeasonId, seasons: seasons, entries: entries });
|
|
280
|
+
});
|
|
281
|
+
return boards;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* A board definition, or null with its problems recorded.
|
|
285
|
+
* @param value The definition (any JSON).
|
|
286
|
+
* @param label Where it is in the file.
|
|
287
|
+
* @param problems Collects one sentence per problem.
|
|
288
|
+
* @returns The valid definition, or null.
|
|
289
|
+
*/
|
|
290
|
+
function checkedDefinition(value, label, problems) {
|
|
291
|
+
const checked = validateBoardDefinition(value);
|
|
292
|
+
if (checked.definition == null)
|
|
293
|
+
problems.push(label + ": " + checked.problems.join(", "));
|
|
294
|
+
return checked.definition;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* An exported board's seasons and current season, or null with its problems recorded.
|
|
298
|
+
* @param board The exported board.
|
|
299
|
+
* @param label Where it is in the file.
|
|
300
|
+
* @param problems Collects one sentence per problem.
|
|
301
|
+
* @returns The seasons, or null.
|
|
302
|
+
*/
|
|
303
|
+
function checkedSeasons(board, label, problems) {
|
|
304
|
+
const currentSeasonId = board.currentSeasonId;
|
|
305
|
+
if (!isPositiveInteger(currentSeasonId)) {
|
|
306
|
+
problems.push(label + ".currentSeasonId must be a whole number from 1");
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
const seasons = board.seasons;
|
|
310
|
+
const isSeasonList = Array.isArray(seasons) && seasons.every(isSeasonView);
|
|
311
|
+
if (!isSeasonList) {
|
|
312
|
+
problems.push(label + ".seasons must be a list of seasons");
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
const seasonList = seasons;
|
|
316
|
+
if (!seasonList.some((season) => season.seasonId === currentSeasonId)) {
|
|
317
|
+
problems.push(label + ".seasons must include the current season " + currentSeasonId);
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
return seasonList;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* An exported board's entries, with each bad entry's problem recorded.
|
|
324
|
+
* @param value The entries (any JSON).
|
|
325
|
+
* @param label Where the board is in the file.
|
|
326
|
+
* @param seasons The board's seasons, or null when they were invalid.
|
|
327
|
+
* @param problems Collects one sentence per problem.
|
|
328
|
+
* @returns The entries (meaningful only when no problem was recorded).
|
|
329
|
+
*/
|
|
330
|
+
function checkedEntries(value, label, seasons, problems) {
|
|
331
|
+
if (!Array.isArray(value)) {
|
|
332
|
+
problems.push(label + ".entries must be a list");
|
|
333
|
+
return [];
|
|
334
|
+
}
|
|
335
|
+
const seasonIds = new Set((seasons ?? []).map((season) => season.seasonId));
|
|
336
|
+
value.forEach((entry, index) => {
|
|
337
|
+
const problem = entryProblem(entry, seasonIds);
|
|
338
|
+
if (problem != null)
|
|
339
|
+
problems.push(label + ".entries[" + index + "] " + problem);
|
|
340
|
+
});
|
|
341
|
+
return value;
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* What is wrong with one exported entry.
|
|
345
|
+
* @param value The entry (any JSON).
|
|
346
|
+
* @param seasonIds The board's seasons.
|
|
347
|
+
* @returns A problem sentence, or null.
|
|
348
|
+
*/
|
|
349
|
+
function entryProblem(value, seasonIds) {
|
|
350
|
+
if (!isPlainObject(value))
|
|
351
|
+
return "must be an object";
|
|
352
|
+
if (!isPositiveInteger(value.seasonId) || !seasonIds.has(value.seasonId))
|
|
353
|
+
return "names a season the board does not keep";
|
|
354
|
+
if (!isNonEmptyText(value.variantKey) || !isNonEmptyText(value.playerId))
|
|
355
|
+
return "needs a variantKey and a playerId";
|
|
356
|
+
if (typeof value.displayName !== "string")
|
|
357
|
+
return "displayName must be text";
|
|
358
|
+
if (!Number.isSafeInteger(value.score))
|
|
359
|
+
return "score must be a whole number";
|
|
360
|
+
if (!TRUST_LEVELS.has(value.trustLevel))
|
|
361
|
+
return "trustLevel must be client, session or server";
|
|
362
|
+
if (!isIsoTime(value.submittedAt) || !isIsoTime(value.achievedAt))
|
|
363
|
+
return "submittedAt and achievedAt must be ISO times";
|
|
364
|
+
if (!isPositiveInteger(value.submissionCount))
|
|
365
|
+
return "submissionCount must be a whole number from 1";
|
|
366
|
+
if (value.metadata != null && !isPlainObject(value.metadata))
|
|
367
|
+
return "metadata must be an object or null";
|
|
368
|
+
if (value.sessionName != null && typeof value.sessionName !== "string")
|
|
369
|
+
return "sessionName must be text or null";
|
|
370
|
+
return removalProblem(value);
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* What is wrong with an exported entry's removal fields: all null (live) or reason, removedBy and an ISO removedAt.
|
|
374
|
+
* @param entry The entry.
|
|
375
|
+
* @returns A problem sentence, or null.
|
|
376
|
+
*/
|
|
377
|
+
function removalProblem(entry) {
|
|
378
|
+
if (entry.removedReason == null)
|
|
379
|
+
return (entry.removedBy == null && entry.removedAt == null) ? null : "has removedBy or removedAt without a removedReason";
|
|
380
|
+
const isRemoval = typeof entry.removedReason === "string" && typeof entry.removedBy === "string" && isIsoTime(entry.removedAt);
|
|
381
|
+
return isRemoval ? null : "a removed entry needs text removedReason and removedBy and an ISO removedAt";
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* The players of an export file.
|
|
385
|
+
* @param value The players (any JSON).
|
|
386
|
+
* @param problems Collects one sentence per problem.
|
|
387
|
+
* @returns The players (meaningful only when no problem was recorded).
|
|
388
|
+
*/
|
|
389
|
+
function parsePlayers(value, problems) {
|
|
390
|
+
if (!Array.isArray(value)) {
|
|
391
|
+
problems.push("file.players must be a list");
|
|
392
|
+
return [];
|
|
393
|
+
}
|
|
394
|
+
value.forEach((player, index) => {
|
|
395
|
+
if (!isExportPlayer(player))
|
|
396
|
+
problems.push("players[" + index + "] needs playerId, displayName, kind, keyHash, hidden and an ISO createdAt");
|
|
397
|
+
});
|
|
398
|
+
return value;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* One sentence per board id that appears more than once.
|
|
402
|
+
* @param boards The boards.
|
|
403
|
+
* @returns The problems.
|
|
404
|
+
*/
|
|
405
|
+
function duplicateBoardProblems(boards) {
|
|
406
|
+
const seen = new Set();
|
|
407
|
+
const problems = [];
|
|
408
|
+
for (const board of boards) {
|
|
409
|
+
const boardId = board.definition.boardId;
|
|
410
|
+
if (seen.has(boardId))
|
|
411
|
+
problems.push("board " + boardId + " appears more than once");
|
|
412
|
+
seen.add(boardId);
|
|
413
|
+
}
|
|
414
|
+
return problems;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* What an import does to one board.
|
|
418
|
+
* @param service The leaderboard service.
|
|
419
|
+
* @param boardId The board.
|
|
420
|
+
* @param isReplacing True for the "replace" policy.
|
|
421
|
+
* @returns "create" for a new board, else "replace" or "skip" by the policy.
|
|
422
|
+
*/
|
|
423
|
+
async function boardActionOf(service, boardId, isReplacing) {
|
|
424
|
+
if (!await boardExists(service, boardId))
|
|
425
|
+
return "create";
|
|
426
|
+
return isReplacing ? "replace" : "skip";
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Whether a board exists (read fresh from the admin store).
|
|
430
|
+
* @param service The leaderboard service.
|
|
431
|
+
* @param boardId The board.
|
|
432
|
+
* @returns True when it exists.
|
|
433
|
+
*/
|
|
434
|
+
async function boardExists(service, boardId) {
|
|
435
|
+
try {
|
|
436
|
+
await service.getBoardRecord(boardId);
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
if (error instanceof LeaderboardError && error.reason === "unknownBoard")
|
|
441
|
+
return false;
|
|
442
|
+
throw error;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* The players of a file that have no record yet ("skip" never overwrites a player).
|
|
447
|
+
* @param rankStore The rank store.
|
|
448
|
+
* @param players The file's players.
|
|
449
|
+
* @returns The missing ones.
|
|
450
|
+
*/
|
|
451
|
+
async function missingPlayers(rankStore, players) {
|
|
452
|
+
const missing = [];
|
|
453
|
+
for (const player of players) {
|
|
454
|
+
if (await rankStore.getPlayer(player.playerId) == null)
|
|
455
|
+
missing.push(player);
|
|
456
|
+
}
|
|
457
|
+
return missing;
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* The ids of the boards an import treats one way.
|
|
461
|
+
* @param boards The boards.
|
|
462
|
+
* @param actions Each board's action, by index.
|
|
463
|
+
* @param action The action.
|
|
464
|
+
* @returns The board ids.
|
|
465
|
+
*/
|
|
466
|
+
function boardIdsWith(boards, actions, action) {
|
|
467
|
+
return boards.filter((board, index) => actions[index] === action).map((board) => board.definition.boardId);
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Creates one board and writes its seasons and entries: removed entries first (written, then removed again with their
|
|
471
|
+
* reason, removedBy and removedAt), then live entries, so a player with both keeps both.
|
|
472
|
+
* @param service The leaderboard service.
|
|
473
|
+
* @param board The board.
|
|
474
|
+
* @param clock The clock of the writes.
|
|
475
|
+
*/
|
|
476
|
+
async function writeBoard(service, board, clock) {
|
|
477
|
+
await service.createBoard(board.definition);
|
|
478
|
+
if (board.currentSeasonId == null || board.seasons == null)
|
|
479
|
+
return;
|
|
480
|
+
const { adminStore, rankStore } = service.stores;
|
|
481
|
+
const created = await service.getBoardRecord(board.definition.boardId);
|
|
482
|
+
await adminStore.putDocument(LEADERBOARD_BOARDS_COLLECTION, board.definition.boardId, { ...created.value, currentSeasonId: board.currentSeasonId, seasons: board.seasons }, created.revision);
|
|
483
|
+
const removedEntries = board.entries.filter((entry) => entry.removedReason != null);
|
|
484
|
+
const liveEntries = board.entries.filter((entry) => entry.removedReason == null);
|
|
485
|
+
for (const entry of removedEntries) {
|
|
486
|
+
const key = await writeEntry(rankStore, board.definition, entry, clock);
|
|
487
|
+
await rankStore.removeEntry(key, entry.playerId, entry.removedReason, entry.removedBy, entry.removedAt);
|
|
488
|
+
}
|
|
489
|
+
for (const entry of liveEntries)
|
|
490
|
+
await writeEntry(rankStore, board.definition, entry, clock);
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Writes one exported entry as it was (keep policy latest, its own achieved time, no dedupe).
|
|
494
|
+
* @param rankStore The rank store.
|
|
495
|
+
* @param definition The board.
|
|
496
|
+
* @param entry The exported entry.
|
|
497
|
+
* @param clock The clock of the write.
|
|
498
|
+
* @returns The entry's ranking.
|
|
499
|
+
*/
|
|
500
|
+
async function writeEntry(rankStore, definition, entry, clock) {
|
|
501
|
+
const key = { boardId: definition.boardId, seasonId: entry.seasonId, variantKey: entry.variantKey };
|
|
502
|
+
const stored = {
|
|
503
|
+
playerId: entry.playerId,
|
|
504
|
+
displayName: entry.displayName,
|
|
505
|
+
score: entry.score,
|
|
506
|
+
metadata: entry.metadata ?? null,
|
|
507
|
+
trustLevel: entry.trustLevel,
|
|
508
|
+
sessionName: entry.sessionName ?? null,
|
|
509
|
+
gameMode: null,
|
|
510
|
+
buildVersion: null,
|
|
511
|
+
submittedAt: entry.submittedAt,
|
|
512
|
+
achievedAt: entry.achievedAt,
|
|
513
|
+
achievedAtMs: Date.parse(entry.achievedAt),
|
|
514
|
+
submissionCount: entry.submissionCount
|
|
515
|
+
};
|
|
516
|
+
await rankStore.write({ key: key, keepPolicy: IMPORT_KEEP_POLICY, sortOrder: definition.sortOrder, entry: stored, dedupeId: null, nowMs: clock.now() });
|
|
517
|
+
return key;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* The stored record of an exported player (its last-seen time is its creation time).
|
|
521
|
+
* @param player The exported player.
|
|
522
|
+
* @returns The record.
|
|
523
|
+
*/
|
|
524
|
+
function toPlayerRecord(player) {
|
|
525
|
+
return { playerId: player.playerId, kind: player.kind, keyHash: player.keyHash, displayName: player.displayName, hidden: player.hidden, createdAt: player.createdAt, lastSeenAt: player.createdAt };
|
|
526
|
+
}
|
|
527
|
+
// ---- Shape checks ---------------------------------------------------------------------------------------------------
|
|
528
|
+
/**
|
|
529
|
+
* Whether a value is a plain JSON object.
|
|
530
|
+
* @param value The value.
|
|
531
|
+
* @returns True for a non-null, non-array object.
|
|
532
|
+
*/
|
|
533
|
+
function isPlainObject(value) {
|
|
534
|
+
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Whether a value is a whole number from 1.
|
|
538
|
+
* @param value The value.
|
|
539
|
+
* @returns True for 1, 2, 3 …
|
|
540
|
+
*/
|
|
541
|
+
function isPositiveInteger(value) {
|
|
542
|
+
return Number.isSafeInteger(value) && value >= 1;
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Whether a value is non-empty text.
|
|
546
|
+
* @param value The value.
|
|
547
|
+
* @returns True for a string with at least one character.
|
|
548
|
+
*/
|
|
549
|
+
function isNonEmptyText(value) {
|
|
550
|
+
return typeof value === "string" && value.length > 0;
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Whether a value is a time text Date can read.
|
|
554
|
+
* @param value The value.
|
|
555
|
+
* @returns True for a parseable time string.
|
|
556
|
+
*/
|
|
557
|
+
function isIsoTime(value) {
|
|
558
|
+
return typeof value === "string" && !Number.isNaN(Date.parse(value));
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Whether a value is a season view.
|
|
562
|
+
* @param value The value.
|
|
563
|
+
* @returns True for { seasonId, startedAt, endedAt, isCurrent } with the right types.
|
|
564
|
+
*/
|
|
565
|
+
function isSeasonView(value) {
|
|
566
|
+
if (!isPlainObject(value))
|
|
567
|
+
return false;
|
|
568
|
+
return isPositiveInteger(value.seasonId) && isIsoTime(value.startedAt) && (value.endedAt == null || isIsoTime(value.endedAt)) && typeof value.isCurrent === "boolean";
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Whether a value is an exported player.
|
|
572
|
+
* @param value The value.
|
|
573
|
+
* @returns True for a player with every field of the right type.
|
|
574
|
+
*/
|
|
575
|
+
function isExportPlayer(value) {
|
|
576
|
+
if (!isPlainObject(value))
|
|
577
|
+
return false;
|
|
578
|
+
const hasKeyHash = value.keyHash === null || typeof value.keyHash === "string";
|
|
579
|
+
return isNonEmptyText(value.playerId) && typeof value.displayName === "string" && PLAYER_KINDS.has(value.kind) && hasKeyHash && typeof value.hidden === "boolean" && isIsoTime(value.createdAt);
|
|
580
|
+
}
|