@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,691 @@
|
|
|
1
|
+
// The Redis rank store of the leaderboard service (game-server-leaderboards D1, DR §7), shared by every process of a
|
|
2
|
+
// fleet. Rankings are sorted sets beside entry hashes; one Lua script does a whole submit atomically. Multi-key admin
|
|
3
|
+
// operations run WATCH/MULTI on a connection of their own, never on the shared client.
|
|
4
|
+
import { LEADERBOARD_DEDUPE_TTL_MS, LEADERBOARD_MAXIMUM_ABSOLUTE_SCORE, LEADERBOARD_REDIS_PREFIX } from "./LeaderboardConstants.js";
|
|
5
|
+
import { durabilityAdvice, parseRankKeyText, rankKeyText, sortValueOf } from "./LeaderboardRules.js";
|
|
6
|
+
/**
|
|
7
|
+
* Atomic submit (D13–D15): dedupe, keep policy, ranking member, indexes and rank, in one round trip.
|
|
8
|
+
* KEYS: 1 rank zset, 2 entry hash, 3 player-keys set, 4 variants set, 5 dedupe key, 6 last-activity key.
|
|
9
|
+
* ARGV: 1 playerId, 2 keepPolicy, 3 sortOrder, 4 incoming entry JSON, 5 rankKeyText, 6 variantKey,
|
|
10
|
+
* 7 dedupe TTL ms ("" = no dedupe), 8 maximum absolute score.
|
|
11
|
+
* Returns {status, resultJson}.
|
|
12
|
+
*/
|
|
13
|
+
const SUBMIT_SCRIPT = `
|
|
14
|
+
if ARGV[7] ~= '' then
|
|
15
|
+
local cached = redis.call('GET', KEYS[5])
|
|
16
|
+
if cached then return {'duplicate', cached} end
|
|
17
|
+
end
|
|
18
|
+
local incoming = cjson.decode(ARGV[4])
|
|
19
|
+
local currentText = redis.call('HGET', KEYS[2], ARGV[1])
|
|
20
|
+
local current = nil
|
|
21
|
+
if currentText then current = cjson.decode(currentText) end
|
|
22
|
+
local outcome = 'created'
|
|
23
|
+
local stored = incoming
|
|
24
|
+
local previousScore = cjson.null
|
|
25
|
+
if current then
|
|
26
|
+
previousScore = current.score
|
|
27
|
+
if ARGV[2] == 'sum' then
|
|
28
|
+
stored.score = current.score + incoming.score
|
|
29
|
+
stored.submissionCount = current.submissionCount + 1
|
|
30
|
+
outcome = 'added'
|
|
31
|
+
elseif ARGV[2] == 'latest' then
|
|
32
|
+
stored.submissionCount = current.submissionCount + 1
|
|
33
|
+
outcome = 'replaced'
|
|
34
|
+
else
|
|
35
|
+
local isBetter = false
|
|
36
|
+
if ARGV[3] == 'lowerIsBetter' then isBetter = incoming.score < current.score else isBetter = incoming.score > current.score end
|
|
37
|
+
if isBetter then
|
|
38
|
+
stored.submissionCount = current.submissionCount + 1
|
|
39
|
+
outcome = 'improved'
|
|
40
|
+
else
|
|
41
|
+
outcome = 'kept'
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
if math.abs(stored.score) > tonumber(ARGV[8]) then
|
|
46
|
+
return {'overflow', cjson.encode({status = 'overflow', outcome = cjson.null, entry = cjson.null, previousScore = previousScore, rank = cjson.null, total = redis.call('ZCARD', KEYS[1])})}
|
|
47
|
+
end
|
|
48
|
+
local status = 'written'
|
|
49
|
+
if outcome == 'kept' then
|
|
50
|
+
status = 'kept'
|
|
51
|
+
stored = current
|
|
52
|
+
else
|
|
53
|
+
if current then redis.call('ZREM', KEYS[1], current.member) end
|
|
54
|
+
local sortValue = stored.score
|
|
55
|
+
if ARGV[3] == 'higherIsBetter' then sortValue = -stored.score end
|
|
56
|
+
stored.member = string.format('%013d', stored.achievedAtMs) .. '|' .. ARGV[1]
|
|
57
|
+
redis.call('ZADD', KEYS[1], sortValue, stored.member)
|
|
58
|
+
redis.call('HSET', KEYS[2], ARGV[1], cjson.encode(stored))
|
|
59
|
+
redis.call('SADD', KEYS[3], ARGV[5])
|
|
60
|
+
redis.call('SADD', KEYS[4], ARGV[6])
|
|
61
|
+
redis.call('SET', KEYS[6], stored.submittedAt)
|
|
62
|
+
end
|
|
63
|
+
local rank = redis.call('ZRANK', KEYS[1], stored.member) + 1
|
|
64
|
+
local result = cjson.encode({status = status, outcome = outcome, entry = stored, previousScore = previousScore, rank = rank, total = redis.call('ZCARD', KEYS[1])})
|
|
65
|
+
if ARGV[7] ~= '' then redis.call('SET', KEYS[5], result, 'PX', tonumber(ARGV[7])) end
|
|
66
|
+
return {status, result}
|
|
67
|
+
`;
|
|
68
|
+
/** Number of keys SUBMIT_SCRIPT takes. */
|
|
69
|
+
const SUBMIT_SCRIPT_KEY_COUNT = 6;
|
|
70
|
+
/** Digits of the zero-padded achievedAtMs in a ranking member (D13). */
|
|
71
|
+
const MEMBER_TIME_DIGITS = 13;
|
|
72
|
+
/** Separator between the time and the player id in a ranking member. */
|
|
73
|
+
const MEMBER_SEPARATOR = "|";
|
|
74
|
+
/** HSCAN batch size of searches. */
|
|
75
|
+
const SCAN_BATCH_SIZE = 500;
|
|
76
|
+
/** Most hash fields one search scans. */
|
|
77
|
+
const MAXIMUM_SEARCH_SCAN = 100000;
|
|
78
|
+
/** Attempts of a watched transaction before it gives up. */
|
|
79
|
+
const TRANSACTION_ATTEMPTS = 10;
|
|
80
|
+
/** The cursor that starts and ends an HSCAN. */
|
|
81
|
+
const SCAN_START_CURSOR = "0";
|
|
82
|
+
/** The INFO line that says the append-only file is on. */
|
|
83
|
+
const AOF_ENABLED_PATTERN = /^aof_enabled:1\s*$/m;
|
|
84
|
+
/** The INFO line of the last RDB save time. */
|
|
85
|
+
const RDB_LAST_SAVE_PATTERN = /^rdb_last_save_time:(\d+)\s*$/m;
|
|
86
|
+
/** Rankings in Redis (D1, DR §7). */
|
|
87
|
+
export class RedisLeaderboardRankStore {
|
|
88
|
+
/** Always "redis". */
|
|
89
|
+
kind = "redis";
|
|
90
|
+
/** The shared client (RedisPresence quits it). */
|
|
91
|
+
redis;
|
|
92
|
+
/** A connection of its own for WATCH/MULTI work, quit by close(). */
|
|
93
|
+
transactions;
|
|
94
|
+
/** The pending transaction, so this process runs one WATCH at a time on `transactions`. */
|
|
95
|
+
transactionQueue = Promise.resolve();
|
|
96
|
+
/**
|
|
97
|
+
* Wraps the shared client and opens the transaction connection.
|
|
98
|
+
* @param redis The ioredis client the admin store also uses.
|
|
99
|
+
*/
|
|
100
|
+
constructor(redis) {
|
|
101
|
+
this.redis = redis;
|
|
102
|
+
this.transactions = redis.duplicate();
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Writes one score through SUBMIT_SCRIPT.
|
|
106
|
+
* @param request The write.
|
|
107
|
+
* @returns What the write did.
|
|
108
|
+
*/
|
|
109
|
+
async write(request) {
|
|
110
|
+
const keyText = rankKeyText(request.key);
|
|
111
|
+
const playerId = request.entry.playerId;
|
|
112
|
+
const dedupeTtl = (request.dedupeId != null) ? String(LEADERBOARD_DEDUPE_TTL_MS) : "";
|
|
113
|
+
const incoming = toRedisEntry(request.entry, "");
|
|
114
|
+
const answer = await this.redis.eval(SUBMIT_SCRIPT, SUBMIT_SCRIPT_KEY_COUNT, this.rankKey(request.key), this.entryKey(request.key), this.playerKeysKey(playerId), this.variantsKey(request.key.boardId, request.key.seasonId), this.dedupeKey(request.dedupeId ?? ""), this.lastKey(request.key.boardId), playerId, request.keepPolicy, request.sortOrder, JSON.stringify(incoming), keyText, request.key.variantKey, dedupeTtl, String(LEADERBOARD_MAXIMUM_ABSOLUTE_SCORE));
|
|
115
|
+
const [scriptStatus, resultJson] = answer;
|
|
116
|
+
const raw = JSON.parse(resultJson);
|
|
117
|
+
const status = (scriptStatus === "duplicate") ? "duplicate" : raw.status;
|
|
118
|
+
return {
|
|
119
|
+
status: status,
|
|
120
|
+
outcome: raw.outcome ?? null,
|
|
121
|
+
entry: (raw.entry != null) ? fromRedisEntry(raw.entry) : null,
|
|
122
|
+
previousScore: raw.previousScore ?? null,
|
|
123
|
+
rank: raw.rank ?? null,
|
|
124
|
+
total: raw.total
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Reads a page of a ranking (ZRANGE then HMGET).
|
|
129
|
+
* @param key The ranking.
|
|
130
|
+
* @param offset Zero-based index of the first entry.
|
|
131
|
+
* @param limit Most entries.
|
|
132
|
+
* @returns The ranked entries.
|
|
133
|
+
*/
|
|
134
|
+
async readRange(key, offset, limit) {
|
|
135
|
+
if (limit <= 0)
|
|
136
|
+
return [];
|
|
137
|
+
const members = await this.redis.zrange(this.rankKey(key), offset, offset + limit - 1);
|
|
138
|
+
if (members.length === 0)
|
|
139
|
+
return [];
|
|
140
|
+
const playerIds = members.map(playerIdOfMember);
|
|
141
|
+
const texts = await this.redis.hmget(this.entryKey(key), ...playerIds);
|
|
142
|
+
const ranked = [];
|
|
143
|
+
texts.forEach((text, index) => {
|
|
144
|
+
if (text != null)
|
|
145
|
+
ranked.push({ ...fromRedisEntry(JSON.parse(text)), rank: offset + index + 1 });
|
|
146
|
+
});
|
|
147
|
+
return ranked;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Counts the live entries (ZCARD).
|
|
151
|
+
* @param key The ranking.
|
|
152
|
+
* @returns The count.
|
|
153
|
+
*/
|
|
154
|
+
async count(key) {
|
|
155
|
+
return await this.redis.zcard(this.rankKey(key));
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Reads one player's live entry and its rank.
|
|
159
|
+
* @param key The ranking.
|
|
160
|
+
* @param playerId The player.
|
|
161
|
+
* @returns The entry, or null.
|
|
162
|
+
*/
|
|
163
|
+
async readEntry(key, playerId) {
|
|
164
|
+
const text = await this.redis.hget(this.entryKey(key), playerId);
|
|
165
|
+
if (text == null)
|
|
166
|
+
return null;
|
|
167
|
+
const stored = JSON.parse(text);
|
|
168
|
+
const index = await this.redis.zrank(this.rankKey(key), stored.member);
|
|
169
|
+
if (index == null)
|
|
170
|
+
return null;
|
|
171
|
+
return { ...fromRedisEntry(stored), rank: index + 1 };
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Reads the window around a player's entry, clamped to the ranking.
|
|
175
|
+
* @param key The ranking.
|
|
176
|
+
* @param playerId The player.
|
|
177
|
+
* @param window Entries above and below.
|
|
178
|
+
* @returns The entries, or [] when the player has none.
|
|
179
|
+
*/
|
|
180
|
+
async readAround(key, playerId, window) {
|
|
181
|
+
const entry = await this.readEntry(key, playerId);
|
|
182
|
+
if (entry == null)
|
|
183
|
+
return [];
|
|
184
|
+
const total = await this.count(key);
|
|
185
|
+
const firstIndex = Math.max(0, entry.rank - 1 - window);
|
|
186
|
+
const lastIndex = Math.min(total - 1, entry.rank - 1 + window);
|
|
187
|
+
return await this.readRange(key, firstIndex, lastIndex - firstIndex + 1);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Finds entries by display name or player id (HSCAN, case-insensitive), sorted by rank.
|
|
191
|
+
* @param key The ranking.
|
|
192
|
+
* @param text The text.
|
|
193
|
+
* @param limit Most entries.
|
|
194
|
+
* @returns The matches.
|
|
195
|
+
*/
|
|
196
|
+
async searchEntries(key, text, limit) {
|
|
197
|
+
const needle = text.toLowerCase();
|
|
198
|
+
const matches = await this.scanHash(this.entryKey(key), limit, (stored) => matchesText(stored.displayName, stored.playerId, needle));
|
|
199
|
+
const ranked = [];
|
|
200
|
+
for (const stored of matches) {
|
|
201
|
+
const index = await this.redis.zrank(this.rankKey(key), stored.member);
|
|
202
|
+
if (index != null)
|
|
203
|
+
ranked.push({ ...fromRedisEntry(stored), rank: index + 1 });
|
|
204
|
+
}
|
|
205
|
+
return ranked.sort((first, second) => first.rank - second.rank);
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Reads the removed entries, newest removal first.
|
|
209
|
+
* @param key The ranking.
|
|
210
|
+
* @returns The removed entries.
|
|
211
|
+
*/
|
|
212
|
+
async readRemoved(key) {
|
|
213
|
+
const texts = await this.redis.hvals(this.removedKey(key));
|
|
214
|
+
const removed = texts.map((text) => JSON.parse(text));
|
|
215
|
+
return removed.sort((first, second) => second.removedAt.localeCompare(first.removedAt));
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Moves a live entry to the removed hash (watched MULTI).
|
|
219
|
+
* @param key The ranking.
|
|
220
|
+
* @param playerId The player.
|
|
221
|
+
* @param reason Why.
|
|
222
|
+
* @param removedBy Who.
|
|
223
|
+
* @param removedAt ISO time.
|
|
224
|
+
* @returns True when there was a live entry.
|
|
225
|
+
*/
|
|
226
|
+
async removeEntry(key, playerId, reason, removedBy, removedAt) {
|
|
227
|
+
return await this.runTransaction([this.entryKey(key)], async (client) => {
|
|
228
|
+
const text = await client.hget(this.entryKey(key), playerId);
|
|
229
|
+
if (text == null)
|
|
230
|
+
return { commands: null, result: false };
|
|
231
|
+
const stored = JSON.parse(text);
|
|
232
|
+
const removed = { entry: fromRedisEntry(stored), reason: reason, removedBy: removedBy, removedAt: removedAt };
|
|
233
|
+
const commands = client.multi()
|
|
234
|
+
.zrem(this.rankKey(key), stored.member)
|
|
235
|
+
.hdel(this.entryKey(key), playerId)
|
|
236
|
+
.hset(this.removedKey(key), playerId, JSON.stringify(removed));
|
|
237
|
+
return { commands: commands, result: true };
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Moves a removed entry back into the ranking (watched MULTI).
|
|
242
|
+
* @param key The ranking.
|
|
243
|
+
* @param playerId The player.
|
|
244
|
+
* @param sortOrder The board's sort order.
|
|
245
|
+
* @returns "restored", "missing" or "liveEntryExists".
|
|
246
|
+
*/
|
|
247
|
+
async restoreEntry(key, playerId, sortOrder) {
|
|
248
|
+
return await this.runTransaction([this.entryKey(key), this.removedKey(key)], async (client) => {
|
|
249
|
+
const removedText = await client.hget(this.removedKey(key), playerId);
|
|
250
|
+
if (removedText == null)
|
|
251
|
+
return { commands: null, result: "missing" };
|
|
252
|
+
if ((await client.hexists(this.entryKey(key), playerId)) === 1)
|
|
253
|
+
return { commands: null, result: "liveEntryExists" };
|
|
254
|
+
const removed = JSON.parse(removedText);
|
|
255
|
+
const member = memberOf(removed.entry);
|
|
256
|
+
const commands = client.multi()
|
|
257
|
+
.hdel(this.removedKey(key), playerId)
|
|
258
|
+
.hset(this.entryKey(key), playerId, JSON.stringify(toRedisEntry(removed.entry, member)))
|
|
259
|
+
.zadd(this.rankKey(key), sortValueOf(sortOrder, removed.entry.score), member)
|
|
260
|
+
.sadd(this.playerKeysKey(playerId), rankKeyText(key))
|
|
261
|
+
.sadd(this.variantsKey(key.boardId, key.seasonId), key.variantKey);
|
|
262
|
+
return { commands: commands, result: "restored" };
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Lists the variant keys written in a season (SMEMBERS), sorted.
|
|
267
|
+
* @param boardId The board.
|
|
268
|
+
* @param seasonId The season.
|
|
269
|
+
* @returns The variant keys.
|
|
270
|
+
*/
|
|
271
|
+
async listVariantKeys(boardId, seasonId) {
|
|
272
|
+
const variantKeys = await this.redis.smembers(this.variantsKey(boardId, seasonId));
|
|
273
|
+
return variantKeys.sort();
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Reads a board's last accepted write time.
|
|
277
|
+
* @param boardId The board.
|
|
278
|
+
* @returns ISO time, or null.
|
|
279
|
+
*/
|
|
280
|
+
async readLastSubmissionAt(boardId) {
|
|
281
|
+
return await this.redis.get(this.lastKey(boardId));
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Deletes every ranking of a season and unlinks it from its players.
|
|
285
|
+
* @param boardId The board.
|
|
286
|
+
* @param seasonId The season.
|
|
287
|
+
* @returns How many entries were deleted.
|
|
288
|
+
*/
|
|
289
|
+
async deleteSeason(boardId, seasonId) {
|
|
290
|
+
const variantKeys = await this.redis.smembers(this.variantsKey(boardId, seasonId));
|
|
291
|
+
let deletedCount = 0;
|
|
292
|
+
for (const variantKey of variantKeys) {
|
|
293
|
+
deletedCount += await this.deleteRanking({ boardId: boardId, seasonId: seasonId, variantKey: variantKey });
|
|
294
|
+
}
|
|
295
|
+
await this.redis.del(this.variantsKey(boardId, seasonId));
|
|
296
|
+
return deletedCount;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Deletes every ranking of a board and its last-activity key.
|
|
300
|
+
* @param boardId The board.
|
|
301
|
+
* @param seasonIds Its seasons.
|
|
302
|
+
*/
|
|
303
|
+
async deleteBoard(boardId, seasonIds) {
|
|
304
|
+
for (const seasonId of seasonIds)
|
|
305
|
+
await this.deleteSeason(boardId, seasonId);
|
|
306
|
+
await this.redis.del(this.lastKey(boardId));
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Lists the rankings a player has an entry in.
|
|
310
|
+
* @param playerId The player.
|
|
311
|
+
* @returns The rank keys, sorted by their text.
|
|
312
|
+
*/
|
|
313
|
+
async listPlayerRankKeys(playerId) {
|
|
314
|
+
const texts = await this.redis.smembers(this.playerKeysKey(playerId));
|
|
315
|
+
return texts.sort().map(parseRankKeyText);
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Renames every entry of a player, live and removed.
|
|
319
|
+
* @param playerId The player.
|
|
320
|
+
* @param displayName The new name.
|
|
321
|
+
* @returns How many entries changed.
|
|
322
|
+
*/
|
|
323
|
+
async renamePlayerEntries(playerId, displayName) {
|
|
324
|
+
let renamedCount = 0;
|
|
325
|
+
for (const key of await this.listPlayerRankKeys(playerId)) {
|
|
326
|
+
renamedCount += await this.runTransaction([this.entryKey(key), this.removedKey(key)], async (client) => {
|
|
327
|
+
const entryText = await client.hget(this.entryKey(key), playerId);
|
|
328
|
+
const removedText = await client.hget(this.removedKey(key), playerId);
|
|
329
|
+
if (entryText == null && removedText == null)
|
|
330
|
+
return { commands: null, result: 0 };
|
|
331
|
+
const commands = client.multi();
|
|
332
|
+
if (entryText != null) {
|
|
333
|
+
const stored = JSON.parse(entryText);
|
|
334
|
+
commands.hset(this.entryKey(key), playerId, JSON.stringify({ ...stored, displayName: displayName }));
|
|
335
|
+
}
|
|
336
|
+
if (removedText != null) {
|
|
337
|
+
const removed = JSON.parse(removedText);
|
|
338
|
+
commands.hset(this.removedKey(key), playerId, JSON.stringify({ ...removed, entry: { ...removed.entry, displayName: displayName } }));
|
|
339
|
+
}
|
|
340
|
+
const changedCount = ((entryText != null) ? 1 : 0) + ((removedText != null) ? 1 : 0);
|
|
341
|
+
return { commands: commands, result: changedCount };
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
return renamedCount;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Deletes every entry of a player, live and removed, and the player's ranking index.
|
|
348
|
+
* @param playerId The player.
|
|
349
|
+
* @returns How many entries were deleted.
|
|
350
|
+
*/
|
|
351
|
+
async deletePlayerEntries(playerId) {
|
|
352
|
+
let deletedCount = 0;
|
|
353
|
+
for (const key of await this.listPlayerRankKeys(playerId)) {
|
|
354
|
+
deletedCount += await this.runTransaction([this.entryKey(key), this.removedKey(key)], async (client) => {
|
|
355
|
+
const entryText = await client.hget(this.entryKey(key), playerId);
|
|
356
|
+
const hasRemoved = (await client.hexists(this.removedKey(key), playerId)) === 1;
|
|
357
|
+
const commands = client.multi().hdel(this.entryKey(key), playerId).hdel(this.removedKey(key), playerId);
|
|
358
|
+
if (entryText != null)
|
|
359
|
+
commands.zrem(this.rankKey(key), JSON.parse(entryText).member);
|
|
360
|
+
const removedCount = ((entryText != null) ? 1 : 0) + (hasRemoved ? 1 : 0);
|
|
361
|
+
return { commands: commands, result: removedCount };
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
await this.redis.del(this.playerKeysKey(playerId));
|
|
365
|
+
return deletedCount;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Reads a player.
|
|
369
|
+
* @param playerId The player.
|
|
370
|
+
* @returns The record, or null.
|
|
371
|
+
*/
|
|
372
|
+
async getPlayer(playerId) {
|
|
373
|
+
const text = await this.redis.hget(this.playersKey(), playerId);
|
|
374
|
+
return (text != null) ? JSON.parse(text) : null;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Writes a player.
|
|
378
|
+
* @param record The record.
|
|
379
|
+
*/
|
|
380
|
+
async putPlayer(record) {
|
|
381
|
+
await this.redis.hset(this.playersKey(), record.playerId, JSON.stringify(record));
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Deletes a player record.
|
|
385
|
+
* @param playerId The player.
|
|
386
|
+
* @returns True when it existed.
|
|
387
|
+
*/
|
|
388
|
+
async deletePlayer(playerId) {
|
|
389
|
+
return (await this.redis.hdel(this.playersKey(), playerId)) > 0;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Finds players by display name or id (HSCAN, case-insensitive).
|
|
393
|
+
* @param text The text ("" matches any).
|
|
394
|
+
* @param limit Most players.
|
|
395
|
+
* @returns The players.
|
|
396
|
+
*/
|
|
397
|
+
async searchPlayers(text, limit) {
|
|
398
|
+
const needle = text.toLowerCase();
|
|
399
|
+
return await this.scanHash(this.playersKey(), limit, (record) => matchesText(record.displayName, record.playerId, needle));
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Counts every player (HLEN).
|
|
403
|
+
* @returns The count.
|
|
404
|
+
*/
|
|
405
|
+
async countPlayers() {
|
|
406
|
+
return await this.redis.hlen(this.playersKey());
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Counts one use in a fixed window (INCR, PEXPIRE on the first use).
|
|
410
|
+
* @param bucket What is limited.
|
|
411
|
+
* @param limit Most uses per window.
|
|
412
|
+
* @param windowMs The window, in milliseconds.
|
|
413
|
+
* @param nowMs The caller's clock.
|
|
414
|
+
* @returns True while within the limit.
|
|
415
|
+
*/
|
|
416
|
+
async consumeRate(bucket, limit, windowMs, nowMs) {
|
|
417
|
+
const windowStartMs = Math.floor(nowMs / windowMs) * windowMs;
|
|
418
|
+
const key = this.rateKey(bucket, windowStartMs);
|
|
419
|
+
const usedCount = await this.redis.incr(key);
|
|
420
|
+
if (usedCount === 1)
|
|
421
|
+
await this.redis.pexpire(key, windowMs);
|
|
422
|
+
return usedCount <= limit;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Records a session join per subject (SET NX PX: the earliest join wins).
|
|
426
|
+
* @param sessionName The session.
|
|
427
|
+
* @param subjects The subjects.
|
|
428
|
+
* @param joinedAtMs The join time.
|
|
429
|
+
* @param ttlMs How long it counts.
|
|
430
|
+
*/
|
|
431
|
+
async recordSessionPresence(sessionName, subjects, joinedAtMs, ttlMs) {
|
|
432
|
+
const record = { joinedAtMs: joinedAtMs, expiresAtMs: joinedAtMs + ttlMs };
|
|
433
|
+
for (const subject of subjects) {
|
|
434
|
+
await this.redis.set(this.presenceKey(sessionName, subject), JSON.stringify(record), "PX", ttlMs, "NX");
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Reads a subject's join time, if it still counts at the caller's time.
|
|
439
|
+
* @param sessionName The session.
|
|
440
|
+
* @param subject The subject.
|
|
441
|
+
* @param nowMs The caller's clock.
|
|
442
|
+
* @returns The join time, or null.
|
|
443
|
+
*/
|
|
444
|
+
async readSessionPresence(sessionName, subject, nowMs) {
|
|
445
|
+
const text = await this.redis.get(this.presenceKey(sessionName, subject));
|
|
446
|
+
if (text == null)
|
|
447
|
+
return null;
|
|
448
|
+
const record = JSON.parse(text);
|
|
449
|
+
return (record.expiresAtMs > nowMs) ? record.joinedAtMs : null;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Measures durability from INFO persistence and CONFIG GET save (D20).
|
|
453
|
+
* @returns The durability and advice.
|
|
454
|
+
*/
|
|
455
|
+
async measureDurability() {
|
|
456
|
+
try {
|
|
457
|
+
const info = await this.redis.info("persistence");
|
|
458
|
+
const saveAnswer = await this.redis.config("GET", "save");
|
|
459
|
+
const saveRule = (Array.isArray(saveAnswer) && typeof saveAnswer[1] === "string") ? saveAnswer[1] : "";
|
|
460
|
+
const lastSaveMatch = info.match(RDB_LAST_SAVE_PATTERN);
|
|
461
|
+
const lastSaveTime = (lastSaveMatch != null) ? Number(lastSaveMatch[1]) : 0;
|
|
462
|
+
const isDurable = AOF_ENABLED_PATTERN.test(info) || (lastSaveTime > 0 && saveRule.trim() !== "");
|
|
463
|
+
const durability = isDurable ? "durable" : "notDurable";
|
|
464
|
+
return { durability: durability, advice: durabilityAdvice(durability, "redis") };
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
return { durability: "unknown", advice: durabilityAdvice("unknown", "redis") };
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
/** Quits the transaction connection (the shared client is left to RedisPresence). */
|
|
471
|
+
async close() {
|
|
472
|
+
await this.transactionQueue;
|
|
473
|
+
try {
|
|
474
|
+
await this.transactions.quit();
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
this.transactions.disconnect();
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Deletes one ranking (its zset, entries and removed entries) and unlinks it from its players.
|
|
482
|
+
* @param key The ranking.
|
|
483
|
+
* @returns How many entries were deleted.
|
|
484
|
+
*/
|
|
485
|
+
async deleteRanking(key) {
|
|
486
|
+
const keyText = rankKeyText(key);
|
|
487
|
+
const livePlayers = await this.redis.hkeys(this.entryKey(key));
|
|
488
|
+
const removedPlayers = await this.redis.hkeys(this.removedKey(key));
|
|
489
|
+
const commands = this.transactions.multi();
|
|
490
|
+
for (const playerId of new Set([...livePlayers, ...removedPlayers]))
|
|
491
|
+
commands.srem(this.playerKeysKey(playerId), keyText);
|
|
492
|
+
commands.del(this.rankKey(key), this.entryKey(key), this.removedKey(key));
|
|
493
|
+
await this.queued(async () => await commands.exec());
|
|
494
|
+
return livePlayers.length + removedPlayers.length;
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Runs a watched read-then-MULTI on the transaction connection, retried while another writer changes a watched key.
|
|
498
|
+
* @param watchKeys The keys to WATCH.
|
|
499
|
+
* @param plan Reads (through the given client) and returns the MULTI to commit, or null for none.
|
|
500
|
+
* @returns The plan's result.
|
|
501
|
+
* @throws Error when another writer changed a watched key on every attempt.
|
|
502
|
+
*/
|
|
503
|
+
runTransaction(watchKeys, plan) {
|
|
504
|
+
return this.queued(async () => {
|
|
505
|
+
for (let attempt = 0; attempt < TRANSACTION_ATTEMPTS; attempt++) {
|
|
506
|
+
await this.transactions.watch(...watchKeys);
|
|
507
|
+
const decided = await plan(this.transactions);
|
|
508
|
+
if (decided.commands == null) {
|
|
509
|
+
await this.transactions.unwatch();
|
|
510
|
+
return decided.result;
|
|
511
|
+
}
|
|
512
|
+
const committed = await decided.commands.exec();
|
|
513
|
+
if (committed != null)
|
|
514
|
+
return decided.result;
|
|
515
|
+
}
|
|
516
|
+
throw new Error("leaderboard transaction on " + watchKeys.join(", ") + " kept conflicting after " + TRANSACTION_ATTEMPTS + " attempts");
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Runs a task after the pending one on the transaction connection.
|
|
521
|
+
* @param task The task.
|
|
522
|
+
* @returns The task's result.
|
|
523
|
+
*/
|
|
524
|
+
queued(task) {
|
|
525
|
+
const next = this.transactionQueue.then(task, task);
|
|
526
|
+
this.transactionQueue = next.catch(() => undefined);
|
|
527
|
+
return next;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Scans a hash of JSON values with HSCAN, keeping the matches.
|
|
531
|
+
* @param hashKey The hash.
|
|
532
|
+
* @param limit Most matches.
|
|
533
|
+
* @param accept Whether a value matches.
|
|
534
|
+
* @returns The matching values.
|
|
535
|
+
*/
|
|
536
|
+
async scanHash(hashKey, limit, accept) {
|
|
537
|
+
const matches = [];
|
|
538
|
+
let cursor = SCAN_START_CURSOR;
|
|
539
|
+
let scannedCount = 0;
|
|
540
|
+
do {
|
|
541
|
+
const [nextCursor, fields] = await this.redis.hscan(hashKey, cursor, "COUNT", SCAN_BATCH_SIZE);
|
|
542
|
+
for (let i = 1; i < fields.length && matches.length < limit; i += 2) {
|
|
543
|
+
const value = JSON.parse(fields[i]);
|
|
544
|
+
if (accept(value))
|
|
545
|
+
matches.push(value);
|
|
546
|
+
}
|
|
547
|
+
scannedCount += fields.length / 2;
|
|
548
|
+
cursor = nextCursor;
|
|
549
|
+
} while (cursor !== SCAN_START_CURSOR && matches.length < limit && scannedCount < MAXIMUM_SEARCH_SCAN);
|
|
550
|
+
return matches;
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* The sorted set of a ranking.
|
|
554
|
+
* @param key The ranking.
|
|
555
|
+
* @returns The Redis key.
|
|
556
|
+
*/
|
|
557
|
+
rankKey(key) {
|
|
558
|
+
return LEADERBOARD_REDIS_PREFIX + "rank:" + rankKeyText(key);
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* The entry hash of a ranking.
|
|
562
|
+
* @param key The ranking.
|
|
563
|
+
* @returns The Redis key.
|
|
564
|
+
*/
|
|
565
|
+
entryKey(key) {
|
|
566
|
+
return LEADERBOARD_REDIS_PREFIX + "entry:" + rankKeyText(key);
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* The removed-entry hash of a ranking.
|
|
570
|
+
* @param key The ranking.
|
|
571
|
+
* @returns The Redis key.
|
|
572
|
+
*/
|
|
573
|
+
removedKey(key) {
|
|
574
|
+
return LEADERBOARD_REDIS_PREFIX + "removed:" + rankKeyText(key);
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* The set of variant keys of a season.
|
|
578
|
+
* @param boardId The board.
|
|
579
|
+
* @param seasonId The season.
|
|
580
|
+
* @returns The Redis key.
|
|
581
|
+
*/
|
|
582
|
+
variantsKey(boardId, seasonId) {
|
|
583
|
+
return LEADERBOARD_REDIS_PREFIX + "variants:" + boardId + ":" + seasonId;
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* The last-activity key of a board.
|
|
587
|
+
* @param boardId The board.
|
|
588
|
+
* @returns The Redis key.
|
|
589
|
+
*/
|
|
590
|
+
lastKey(boardId) {
|
|
591
|
+
return LEADERBOARD_REDIS_PREFIX + "last:" + boardId;
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* The players hash.
|
|
595
|
+
* @returns The Redis key.
|
|
596
|
+
*/
|
|
597
|
+
playersKey() {
|
|
598
|
+
return LEADERBOARD_REDIS_PREFIX + "players";
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* The set of a player's rank keys.
|
|
602
|
+
* @param playerId The player.
|
|
603
|
+
* @returns The Redis key.
|
|
604
|
+
*/
|
|
605
|
+
playerKeysKey(playerId) {
|
|
606
|
+
return LEADERBOARD_REDIS_PREFIX + "player-keys:" + playerId;
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* The cached result of one submission.
|
|
610
|
+
* @param dedupeId `<submissionId>|<boardId>|<variantKey>`.
|
|
611
|
+
* @returns The Redis key.
|
|
612
|
+
*/
|
|
613
|
+
dedupeKey(dedupeId) {
|
|
614
|
+
return LEADERBOARD_REDIS_PREFIX + "dedupe:" + dedupeId;
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* The counter of one rate window.
|
|
618
|
+
* @param bucket What is limited.
|
|
619
|
+
* @param windowStartMs The window's start.
|
|
620
|
+
* @returns The Redis key.
|
|
621
|
+
*/
|
|
622
|
+
rateKey(bucket, windowStartMs) {
|
|
623
|
+
return LEADERBOARD_REDIS_PREFIX + "rate:" + bucket + ":" + windowStartMs;
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* The presence record of a subject in a session.
|
|
627
|
+
* @param sessionName The session.
|
|
628
|
+
* @param subject The subject.
|
|
629
|
+
* @returns The Redis key.
|
|
630
|
+
*/
|
|
631
|
+
presenceKey(sessionName, subject) {
|
|
632
|
+
return LEADERBOARD_REDIS_PREFIX + "presence:" + sessionName + ":" + subject;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* An entry in its Redis form: metadata as text, plus its ranking member.
|
|
637
|
+
* @param entry The entry.
|
|
638
|
+
* @param member The ranking member ("" for an incoming entry; the script sets it).
|
|
639
|
+
* @returns The Redis form.
|
|
640
|
+
*/
|
|
641
|
+
function toRedisEntry(entry, member) {
|
|
642
|
+
const { metadata, ...rest } = entry;
|
|
643
|
+
return { ...rest, metadataText: (metadata != null) ? JSON.stringify(metadata) : null, member: member };
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* An entry from its Redis form, without the member.
|
|
647
|
+
* @param record The Redis form.
|
|
648
|
+
* @returns The entry.
|
|
649
|
+
*/
|
|
650
|
+
function fromRedisEntry(record) {
|
|
651
|
+
return {
|
|
652
|
+
playerId: record.playerId,
|
|
653
|
+
displayName: record.displayName,
|
|
654
|
+
score: record.score,
|
|
655
|
+
metadata: (record.metadataText != null) ? JSON.parse(record.metadataText) : null,
|
|
656
|
+
trustLevel: record.trustLevel,
|
|
657
|
+
sessionName: record.sessionName,
|
|
658
|
+
gameMode: record.gameMode,
|
|
659
|
+
buildVersion: record.buildVersion,
|
|
660
|
+
submittedAt: record.submittedAt,
|
|
661
|
+
achievedAt: record.achievedAt,
|
|
662
|
+
achievedAtMs: record.achievedAtMs,
|
|
663
|
+
submissionCount: record.submissionCount
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* The ranking member of an entry: zero-padded achievedAtMs, `|`, playerId (D13).
|
|
668
|
+
* @param entry The entry.
|
|
669
|
+
* @returns The member.
|
|
670
|
+
*/
|
|
671
|
+
function memberOf(entry) {
|
|
672
|
+
return String(entry.achievedAtMs).padStart(MEMBER_TIME_DIGITS, "0") + MEMBER_SEPARATOR + entry.playerId;
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* The player id of a ranking member (everything after the first `|`).
|
|
676
|
+
* @param member The member.
|
|
677
|
+
* @returns The player id.
|
|
678
|
+
*/
|
|
679
|
+
function playerIdOfMember(member) {
|
|
680
|
+
return member.slice(member.indexOf(MEMBER_SEPARATOR) + 1);
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Whether a name or id contains a lower-case needle.
|
|
684
|
+
* @param displayName The name.
|
|
685
|
+
* @param playerId The id.
|
|
686
|
+
* @param needle The lower-case text ("" matches any).
|
|
687
|
+
* @returns True on a match.
|
|
688
|
+
*/
|
|
689
|
+
function matchesText(displayName, playerId, needle) {
|
|
690
|
+
return displayName.toLowerCase().includes(needle) || playerId.toLowerCase().includes(needle);
|
|
691
|
+
}
|