@mpgd/adapter-devvit 0.5.0 → 0.7.0
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/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { type BridgeRequest, type BridgeResponse } from '@mpgd/bridge';
|
|
2
2
|
import { type BridgeRpcClient, type BridgeRpcEndpoint } from '@mpgd/bridge/orpc';
|
|
3
3
|
import type { PlatformGateway } from '@mpgd/platform';
|
|
4
|
-
export declare const defaultDevvitBridgeEndpoint = "/api/mpgd/bridge";
|
|
5
4
|
export declare const defaultDevvitRpcEndpoint = "/api/mpgd/rpc";
|
|
6
5
|
type BridgeErrorResponse = Extract<BridgeResponse, {
|
|
7
6
|
readonly ok: false;
|
|
@@ -20,7 +19,6 @@ export interface DevvitPlatformGatewayOptions {
|
|
|
20
19
|
readonly buildId: string;
|
|
21
20
|
readonly bridge?: DevvitBridge;
|
|
22
21
|
readonly fallbackBridge?: DevvitBridge;
|
|
23
|
-
readonly endpoint?: string;
|
|
24
22
|
readonly rpcEndpoint?: BridgeRpcEndpoint;
|
|
25
23
|
}
|
|
26
24
|
export declare function createDevvitPlatformGateway(input: DevvitPlatformGatewayOptions): PlatformGateway;
|
|
@@ -29,9 +27,5 @@ export declare function createDevvitOrpcBridge(input?: {
|
|
|
29
27
|
readonly fetch?: typeof fetch | undefined;
|
|
30
28
|
readonly client?: BridgeRpcClient | undefined;
|
|
31
29
|
}): DevvitBridge;
|
|
32
|
-
export declare function createDevvitFetchBridge(input?: {
|
|
33
|
-
readonly endpoint?: string | undefined;
|
|
34
|
-
readonly fetch?: typeof fetch | undefined;
|
|
35
|
-
}): DevvitBridge;
|
|
36
30
|
export declare function createDevvitSandboxBridge(): DevvitBridge;
|
|
37
31
|
export {};
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createBridgeError, } from '@mpgd/bridge';
|
|
2
2
|
import { createBridgeOrpcClient, defaultBridgeRpcEndpoint, } from '@mpgd/bridge/orpc';
|
|
3
|
-
export const defaultDevvitBridgeEndpoint = '/api/mpgd/bridge';
|
|
4
3
|
export const defaultDevvitRpcEndpoint = defaultBridgeRpcEndpoint;
|
|
5
4
|
const devvitFallbackStoragePrefix = 'mpgd:devvit:fallback:';
|
|
6
5
|
export class DevvitBridgeError extends Error {
|
|
@@ -21,9 +20,7 @@ export function createDevvitPlatformGateway(input) {
|
|
|
21
20
|
const bridge = input.bridge ??
|
|
22
21
|
getBridge() ??
|
|
23
22
|
input.fallbackBridge ??
|
|
24
|
-
(input.
|
|
25
|
-
? createDevvitOrpcBridge({ endpoint: input.rpcEndpoint })
|
|
26
|
-
: createDevvitFetchBridge({ endpoint: input.endpoint }));
|
|
23
|
+
createDevvitOrpcBridge({ endpoint: input.rpcEndpoint });
|
|
27
24
|
const response = await bridge.request({
|
|
28
25
|
id: generateRequestId(),
|
|
29
26
|
method,
|
|
@@ -204,40 +201,6 @@ export function createDevvitOrpcBridge(input = {}) {
|
|
|
204
201
|
},
|
|
205
202
|
};
|
|
206
203
|
}
|
|
207
|
-
export function createDevvitFetchBridge(input = {}) {
|
|
208
|
-
const endpoint = input.endpoint ?? defaultDevvitBridgeEndpoint;
|
|
209
|
-
return {
|
|
210
|
-
async request(bridgeRequest) {
|
|
211
|
-
const fetchImpl = input.fetch ?? globalThis.fetch;
|
|
212
|
-
if (typeof fetchImpl !== 'function') {
|
|
213
|
-
return createBridgeError(bridgeRequest.id, 'DEVVIT_FETCH_UNAVAILABLE', 'Devvit bridge fetch is not available.', false);
|
|
214
|
-
}
|
|
215
|
-
let response;
|
|
216
|
-
try {
|
|
217
|
-
response = await fetchImpl(endpoint, {
|
|
218
|
-
method: 'POST',
|
|
219
|
-
headers: {
|
|
220
|
-
'content-type': 'application/json',
|
|
221
|
-
},
|
|
222
|
-
body: JSON.stringify(bridgeRequest),
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
catch (error) {
|
|
226
|
-
return createBridgeError(bridgeRequest.id, 'DEVVIT_BRIDGE_NETWORK_ERROR', `Devvit bridge network request failed: ${errorMessage(error)}`, true);
|
|
227
|
-
}
|
|
228
|
-
if (!response.ok) {
|
|
229
|
-
return createBridgeError(bridgeRequest.id, 'DEVVIT_BRIDGE_HTTP_ERROR', `Devvit bridge request failed with HTTP ${response.status}.`, response.status >= 500);
|
|
230
|
-
}
|
|
231
|
-
try {
|
|
232
|
-
const body = await response.json();
|
|
233
|
-
return assertBridgeResponse(body);
|
|
234
|
-
}
|
|
235
|
-
catch (error) {
|
|
236
|
-
return createBridgeError(bridgeRequest.id, 'DEVVIT_BRIDGE_PARSE_ERROR', `Devvit bridge response was not valid JSON: ${errorMessage(error)}`, false);
|
|
237
|
-
}
|
|
238
|
-
},
|
|
239
|
-
};
|
|
240
|
-
}
|
|
241
204
|
function getBridge() {
|
|
242
205
|
const globalBridgeHost = globalThis;
|
|
243
206
|
return globalBridgeHost.__DEVVIT_GAME_PLATFORM_BRIDGE__ ?? globalBridgeHost.__GAME_PLATFORM_BRIDGE__;
|
|
@@ -411,7 +374,6 @@ function removeDevvitStorageFallback(key, namespace) {
|
|
|
411
374
|
if (namespace !== undefined) {
|
|
412
375
|
storage.removeItem(devvitStorageFallbackKey(key, namespace));
|
|
413
376
|
}
|
|
414
|
-
storage.removeItem(legacyDevvitStorageFallbackKey(key));
|
|
415
377
|
}
|
|
416
378
|
}
|
|
417
379
|
function hasDevvitStorageFallbackCandidate(key, appVersion) {
|
|
@@ -435,9 +397,6 @@ function devvitStorageFallbackNamespace(appVersion, playerId) {
|
|
|
435
397
|
function devvitStorageFallbackKey(key, namespace) {
|
|
436
398
|
return `${devvitFallbackStoragePrefix}${namespace}:${encodeURIComponent(key)}`;
|
|
437
399
|
}
|
|
438
|
-
function legacyDevvitStorageFallbackKey(key) {
|
|
439
|
-
return `${devvitFallbackStoragePrefix}${encodeURIComponent(key)}`;
|
|
440
|
-
}
|
|
441
400
|
function browserLocalStorage() {
|
|
442
401
|
try {
|
|
443
402
|
return globalThis.localStorage;
|
package/dist/server/index.d.ts
CHANGED
package/dist/server/index.js
CHANGED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type VerifiedLeaderboardService } from '@mpgd/game-services/verified-leaderboard';
|
|
2
|
+
export interface DevvitVerifiedLeaderboardRedisMember {
|
|
3
|
+
readonly member: string;
|
|
4
|
+
readonly score: number;
|
|
5
|
+
}
|
|
6
|
+
export interface DevvitVerifiedLeaderboardRedisTransactionLike {
|
|
7
|
+
multi(): Promise<void>;
|
|
8
|
+
discard(): Promise<unknown>;
|
|
9
|
+
set(key: string, value: string): Promise<unknown>;
|
|
10
|
+
hSet(key: string, fieldValues: Record<string, string>): Promise<unknown>;
|
|
11
|
+
hDel(key: string, fields: string[]): Promise<unknown>;
|
|
12
|
+
zAdd(key: string, ...members: DevvitVerifiedLeaderboardRedisMember[]): Promise<unknown>;
|
|
13
|
+
zRem(key: string, members: string[]): Promise<unknown>;
|
|
14
|
+
exec(): Promise<readonly unknown[] | null>;
|
|
15
|
+
unwatch(): Promise<unknown>;
|
|
16
|
+
}
|
|
17
|
+
export interface DevvitVerifiedLeaderboardRedisLike {
|
|
18
|
+
get(key: string): Promise<string | undefined>;
|
|
19
|
+
hGet(key: string, field: string): Promise<string | undefined>;
|
|
20
|
+
zRange(key: string, start: number, stop: number): Promise<DevvitVerifiedLeaderboardRedisMember[]>;
|
|
21
|
+
watch(...keys: string[]): Promise<DevvitVerifiedLeaderboardRedisTransactionLike>;
|
|
22
|
+
}
|
|
23
|
+
export interface DevvitRedisVerifiedLeaderboardServiceOptions {
|
|
24
|
+
/** Redis-safe namespace shared by every board created by this provider. */
|
|
25
|
+
readonly keyPrefix?: string;
|
|
26
|
+
/** Maximum optimistic transaction attempts after WATCH contention. */
|
|
27
|
+
readonly transactionAttempts?: number;
|
|
28
|
+
/** Clock used for snapshot generation timestamps. */
|
|
29
|
+
readonly now?: () => string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Durable Devvit Redis provider for modest verified leaderboards. Writes are
|
|
33
|
+
* fenced with WATCH/MULTI/EXEC and keep the original idempotency response.
|
|
34
|
+
* Reads and writes load retained entries to reproduce the contract's exact
|
|
35
|
+
* JavaScript ordering, including UTF-16 tie breakers and cursor traversal.
|
|
36
|
+
*/
|
|
37
|
+
export declare function createDevvitRedisVerifiedLeaderboardService(redis: DevvitVerifiedLeaderboardRedisLike, options?: DevvitRedisVerifiedLeaderboardServiceOptions): VerifiedLeaderboardService;
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import { areVerifiedLeaderboardMetricsEqual, assertGetVerifiedLeaderboardSnapshotRequest, assertRecordVerifiedLeaderboardAttemptRequest, assertRecordVerifiedLeaderboardAttemptResponse, assertVerifiedLeaderboardAttempt, assertVerifiedLeaderboardDefinition, assertVerifiedLeaderboardSnapshot, createVerifiedLeaderboardCursor, normalizeVerifiedLeaderboardMetrics, parseVerifiedLeaderboardCursor, } from '@mpgd/game-services/verified-leaderboard';
|
|
2
|
+
const defaultKeyPrefix = 'mpgd:verified-leaderboard:v1';
|
|
3
|
+
const defaultSnapshotLimit = 10;
|
|
4
|
+
const defaultTransactionAttempts = 3;
|
|
5
|
+
const maximumTransactionAttempts = 32;
|
|
6
|
+
const keyPrefixPattern = /^[A-Za-z0-9:_-]{1,128}$/;
|
|
7
|
+
/**
|
|
8
|
+
* Durable Devvit Redis provider for modest verified leaderboards. Writes are
|
|
9
|
+
* fenced with WATCH/MULTI/EXEC and keep the original idempotency response.
|
|
10
|
+
* Reads and writes load retained entries to reproduce the contract's exact
|
|
11
|
+
* JavaScript ordering, including UTF-16 tie breakers and cursor traversal.
|
|
12
|
+
*/
|
|
13
|
+
export function createDevvitRedisVerifiedLeaderboardService(redis, options = {}) {
|
|
14
|
+
const keyPrefix = normalizeKeyPrefix(options.keyPrefix);
|
|
15
|
+
const transactionAttempts = normalizeTransactionAttempts(options.transactionAttempts);
|
|
16
|
+
const now = options.now ?? (() => new Date().toISOString());
|
|
17
|
+
return {
|
|
18
|
+
async recordVerifiedAttempt(input) {
|
|
19
|
+
assertRecordVerifiedLeaderboardAttemptRequest(input);
|
|
20
|
+
const request = cloneRecordRequest(input);
|
|
21
|
+
const keys = await createBoardKeys(keyPrefix, request.definition.leaderboardId);
|
|
22
|
+
const attemptMember = await createDigest(request.attempt.attemptId);
|
|
23
|
+
for (let transactionAttempt = 0; transactionAttempt < transactionAttempts; transactionAttempt += 1) {
|
|
24
|
+
const transaction = await redis.watch(...keys.watched);
|
|
25
|
+
let multiStarted = false;
|
|
26
|
+
try {
|
|
27
|
+
const storedDefinition = await readDefinition(redis, keys);
|
|
28
|
+
const definition = ensureDefinition(storedDefinition, request.definition);
|
|
29
|
+
const existingRaw = await redis.hGet(keys.attempts, attemptMember);
|
|
30
|
+
if (existingRaw !== undefined) {
|
|
31
|
+
const existing = parseStoredAttemptDecision(existingRaw, keys.attempts);
|
|
32
|
+
assertSameAttempt(existing.attempt, request.attempt);
|
|
33
|
+
await transaction.unwatch();
|
|
34
|
+
return cloneRecordResponse(existing.response, true);
|
|
35
|
+
}
|
|
36
|
+
const rankedAttempts = await readRankedAttempts(redis, keys, definition, transactionAttempts);
|
|
37
|
+
assertAvailableAttemptMember(rankedAttempts, attemptMember, request.attempt);
|
|
38
|
+
const retainedAttempt = rankedAttempts.find((candidate) => candidate.attempt.participantId === request.attempt.participantId);
|
|
39
|
+
const shouldRetain = retainedAttempt === undefined
|
|
40
|
+
|| shouldReplaceRetainedAttempt(definition, retainedAttempt.attempt, request.attempt);
|
|
41
|
+
const nextRankedAttempts = shouldRetain
|
|
42
|
+
? replaceRetainedAttempt(rankedAttempts, retainedAttempt, {
|
|
43
|
+
member: attemptMember,
|
|
44
|
+
attempt: request.attempt,
|
|
45
|
+
})
|
|
46
|
+
: rankedAttempts;
|
|
47
|
+
const response = createRecordResponse(definition, request.attempt, nextRankedAttempts);
|
|
48
|
+
await transaction.multi();
|
|
49
|
+
multiStarted = true;
|
|
50
|
+
if (storedDefinition === undefined) {
|
|
51
|
+
await transaction.set(keys.definition, JSON.stringify(definition));
|
|
52
|
+
}
|
|
53
|
+
if (shouldRetain) {
|
|
54
|
+
if (retainedAttempt !== undefined) {
|
|
55
|
+
await transaction.zRem(keys.ranking, [retainedAttempt.member]);
|
|
56
|
+
await transaction.hDel(keys.entries, [retainedAttempt.member]);
|
|
57
|
+
}
|
|
58
|
+
await transaction.zAdd(keys.ranking, {
|
|
59
|
+
member: attemptMember,
|
|
60
|
+
score: redisRankingScore(definition.scoreOrder, request.attempt.score),
|
|
61
|
+
});
|
|
62
|
+
await transaction.hSet(keys.entries, {
|
|
63
|
+
[attemptMember]: serializeRetainedAttempt(request.attempt),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
await transaction.hSet(keys.attempts, {
|
|
67
|
+
[attemptMember]: serializeAttemptDecision(request.attempt, response),
|
|
68
|
+
});
|
|
69
|
+
const results = await transaction.exec();
|
|
70
|
+
if (results === null || (Array.isArray(results) && results.length === 0)) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (!Array.isArray(results)) {
|
|
74
|
+
throw new Error('Devvit Redis transaction returned an unsupported response.');
|
|
75
|
+
}
|
|
76
|
+
return response;
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
await bestEffortReset(transaction, multiStarted);
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
throw new Error('Devvit Redis verified leaderboard transaction contention exceeded '
|
|
84
|
+
+ `${String(transactionAttempts)} attempts for board: `
|
|
85
|
+
+ request.definition.leaderboardId);
|
|
86
|
+
},
|
|
87
|
+
async getSnapshot(input) {
|
|
88
|
+
assertGetVerifiedLeaderboardSnapshotRequest(input);
|
|
89
|
+
const request = {
|
|
90
|
+
leaderboardId: input.leaderboardId,
|
|
91
|
+
...(input.participantId === undefined ? {} : { participantId: input.participantId }),
|
|
92
|
+
...(input.limit === undefined ? {} : { limit: input.limit }),
|
|
93
|
+
...(input.cursor === undefined ? {} : { cursor: input.cursor }),
|
|
94
|
+
};
|
|
95
|
+
const keys = await createBoardKeys(keyPrefix, request.leaderboardId);
|
|
96
|
+
const definition = await readDefinition(redis, keys);
|
|
97
|
+
if (definition === undefined) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
const rankedAttempts = await readRankedAttempts(redis, keys, definition, transactionAttempts);
|
|
101
|
+
const rankedEntries = toRankedEntries(rankedAttempts);
|
|
102
|
+
const cursorPosition = request.cursor === undefined
|
|
103
|
+
? undefined
|
|
104
|
+
: parseVerifiedLeaderboardCursor(request.cursor, definition);
|
|
105
|
+
const firstPageIndex = cursorPosition === undefined
|
|
106
|
+
? 0
|
|
107
|
+
: rankedEntries.findIndex((entry) => compareEntryToCursor(definition.scoreOrder, entry, cursorPosition) > 0);
|
|
108
|
+
const pageStart = firstPageIndex < 0 ? rankedEntries.length : firstPageIndex;
|
|
109
|
+
const pageEnd = pageStart + (request.limit ?? defaultSnapshotLimit);
|
|
110
|
+
const entries = rankedEntries.slice(pageStart, pageEnd);
|
|
111
|
+
const participantEntry = request.participantId === undefined
|
|
112
|
+
? undefined
|
|
113
|
+
: rankedEntries.find((entry) => entry.participantId === request.participantId);
|
|
114
|
+
const lastEntry = entries.at(-1);
|
|
115
|
+
const nextCursor = lastEntry !== undefined && pageEnd < rankedEntries.length
|
|
116
|
+
? createVerifiedLeaderboardCursor(definition, lastEntry)
|
|
117
|
+
: undefined;
|
|
118
|
+
const snapshot = {
|
|
119
|
+
definition: cloneDefinition(definition),
|
|
120
|
+
entries,
|
|
121
|
+
...(participantEntry === undefined ? {} : { participantEntry }),
|
|
122
|
+
totalParticipants: rankedEntries.length,
|
|
123
|
+
generatedAt: now(),
|
|
124
|
+
...(nextCursor === undefined ? {} : { nextCursor }),
|
|
125
|
+
};
|
|
126
|
+
assertVerifiedLeaderboardSnapshot(snapshot);
|
|
127
|
+
return snapshot;
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
async function createBoardKeys(keyPrefix, leaderboardId) {
|
|
132
|
+
const boardDigest = await createDigest(leaderboardId);
|
|
133
|
+
const base = `${keyPrefix}:${boardDigest}`;
|
|
134
|
+
const keys = {
|
|
135
|
+
definition: `${base}:definition`,
|
|
136
|
+
attempts: `${base}:attempts`,
|
|
137
|
+
entries: `${base}:entries`,
|
|
138
|
+
ranking: `${base}:ranking`,
|
|
139
|
+
};
|
|
140
|
+
return {
|
|
141
|
+
...keys,
|
|
142
|
+
watched: [keys.definition, keys.attempts, keys.entries, keys.ranking],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
async function createDigest(input) {
|
|
146
|
+
const subtle = globalThis.crypto?.subtle;
|
|
147
|
+
if (subtle === undefined) {
|
|
148
|
+
throw new Error('Web Crypto is required for Devvit Redis leaderboard keys.');
|
|
149
|
+
}
|
|
150
|
+
const bytes = new Uint8Array(input.length * 2);
|
|
151
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
152
|
+
const codeUnit = input.charCodeAt(index);
|
|
153
|
+
bytes[index * 2] = codeUnit >>> 8;
|
|
154
|
+
bytes[index * 2 + 1] = codeUnit & 0xff;
|
|
155
|
+
}
|
|
156
|
+
const digest = new Uint8Array(await subtle.digest('SHA-256', bytes));
|
|
157
|
+
return [...digest]
|
|
158
|
+
.map((value) => value.toString(16).padStart(2, '0'))
|
|
159
|
+
.join('');
|
|
160
|
+
}
|
|
161
|
+
async function readDefinition(redis, keys) {
|
|
162
|
+
const raw = await redis.get(keys.definition);
|
|
163
|
+
if (raw === undefined) {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
const parsed = parseJson(raw, keys.definition);
|
|
167
|
+
assertVerifiedLeaderboardDefinition(parsed);
|
|
168
|
+
return cloneDefinition(parsed);
|
|
169
|
+
}
|
|
170
|
+
async function readRankedAttempts(redis, keys, definition, readAttempts) {
|
|
171
|
+
for (let attempt = 0; attempt < readAttempts; attempt += 1) {
|
|
172
|
+
const rankedAttempts = await tryReadRankedAttempts(redis, keys, definition);
|
|
173
|
+
if (rankedAttempts !== undefined) {
|
|
174
|
+
return rankedAttempts;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
throw new Error('Devvit Redis retained entries changed during '
|
|
178
|
+
+ `${String(readAttempts)} consecutive reads for ${keys.ranking}.`);
|
|
179
|
+
}
|
|
180
|
+
async function tryReadRankedAttempts(redis, keys, definition) {
|
|
181
|
+
const members = await redis.zRange(keys.ranking, 0, -1);
|
|
182
|
+
if (members.length === 0) {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
const values = await Promise.all(members.map(({ member }) => redis.hGet(keys.entries, member)));
|
|
186
|
+
const rankedAttempts = [];
|
|
187
|
+
for (const [index, member] of members.entries()) {
|
|
188
|
+
const raw = values[index];
|
|
189
|
+
if (raw === undefined) {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
const stored = parseStoredRetainedAttempt(raw, keys.entries);
|
|
193
|
+
const expectedScore = redisRankingScore(definition.scoreOrder, stored.attempt.score);
|
|
194
|
+
if (member.score !== expectedScore) {
|
|
195
|
+
throw new Error(`Devvit Redis retained entry score is inconsistent for ${keys.ranking}.`);
|
|
196
|
+
}
|
|
197
|
+
rankedAttempts.push({ member: member.member, attempt: stored.attempt });
|
|
198
|
+
}
|
|
199
|
+
assertUniqueRetainedParticipants(rankedAttempts, keys.entries);
|
|
200
|
+
return rankedAttempts.sort((left, right) => compareAttempts(definition.scoreOrder, left.attempt, right.attempt));
|
|
201
|
+
}
|
|
202
|
+
function ensureDefinition(existing, input) {
|
|
203
|
+
if (existing === undefined) {
|
|
204
|
+
return cloneDefinition(input);
|
|
205
|
+
}
|
|
206
|
+
if (existing.leaderboardId !== input.leaderboardId) {
|
|
207
|
+
throw new Error('Devvit Redis leaderboard key digest collision detected.');
|
|
208
|
+
}
|
|
209
|
+
if (existing.scoreOrder !== input.scoreOrder
|
|
210
|
+
|| existing.attemptSelection !== input.attemptSelection) {
|
|
211
|
+
throw new Error(`Leaderboard definition conflict for ${JSON.stringify(input.leaderboardId)}.`);
|
|
212
|
+
}
|
|
213
|
+
return existing;
|
|
214
|
+
}
|
|
215
|
+
function assertAvailableAttemptMember(rankedAttempts, member, attempt) {
|
|
216
|
+
const existing = rankedAttempts.find((candidate) => candidate.member === member);
|
|
217
|
+
if (existing !== undefined && existing.attempt.attemptId !== attempt.attemptId) {
|
|
218
|
+
throw new Error('Devvit Redis attempt key digest collision detected.');
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function assertUniqueRetainedParticipants(rankedAttempts, entriesKey) {
|
|
222
|
+
const participantIds = new Set();
|
|
223
|
+
for (const { attempt } of rankedAttempts) {
|
|
224
|
+
if (participantIds.has(attempt.participantId)) {
|
|
225
|
+
throw new Error(`Devvit Redis contains duplicate retained participants for ${entriesKey}.`);
|
|
226
|
+
}
|
|
227
|
+
participantIds.add(attempt.participantId);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function replaceRetainedAttempt(rankedAttempts, retainedAttempt, candidate) {
|
|
231
|
+
const remainingAttempts = rankedAttempts.filter((item) => item !== retainedAttempt);
|
|
232
|
+
return [...remainingAttempts, candidate];
|
|
233
|
+
}
|
|
234
|
+
function createRecordResponse(definition, attempted, rankedAttempts) {
|
|
235
|
+
const entry = toRankedEntries([...rankedAttempts].sort((left, right) => compareAttempts(definition.scoreOrder, left.attempt, right.attempt))).find((candidate) => candidate.participantId === attempted.participantId);
|
|
236
|
+
if (entry === undefined) {
|
|
237
|
+
throw new Error('Retained leaderboard entry was not found after recording an attempt.');
|
|
238
|
+
}
|
|
239
|
+
const retained = entry.attemptId === attempted.attemptId;
|
|
240
|
+
const response = {
|
|
241
|
+
recorded: true,
|
|
242
|
+
alreadyProcessed: false,
|
|
243
|
+
retained,
|
|
244
|
+
entry,
|
|
245
|
+
...(retained ? {} : { reason: 'ATTEMPT_NOT_RETAINED' }),
|
|
246
|
+
};
|
|
247
|
+
assertRecordVerifiedLeaderboardAttemptResponse(response);
|
|
248
|
+
return response;
|
|
249
|
+
}
|
|
250
|
+
function toRankedEntries(rankedAttempts) {
|
|
251
|
+
return rankedAttempts.map(({ attempt }, index) => ({
|
|
252
|
+
rank: index + 1,
|
|
253
|
+
participantId: attempt.participantId,
|
|
254
|
+
...(attempt.participantLabel === undefined
|
|
255
|
+
? {}
|
|
256
|
+
: { participantLabel: attempt.participantLabel }),
|
|
257
|
+
attemptId: attempt.attemptId,
|
|
258
|
+
score: attempt.score,
|
|
259
|
+
...(attempt.metrics === undefined
|
|
260
|
+
? {}
|
|
261
|
+
: { metrics: normalizeVerifiedLeaderboardMetrics(attempt.metrics) }),
|
|
262
|
+
completedAt: attempt.completedAt,
|
|
263
|
+
}));
|
|
264
|
+
}
|
|
265
|
+
function shouldReplaceRetainedAttempt(definition, retained, candidate) {
|
|
266
|
+
if (definition.attemptSelection === 'first') {
|
|
267
|
+
return compareAttemptChronology(candidate, retained) < 0;
|
|
268
|
+
}
|
|
269
|
+
return compareAttempts(definition.scoreOrder, candidate, retained) < 0;
|
|
270
|
+
}
|
|
271
|
+
function compareAttemptChronology(left, right) {
|
|
272
|
+
const completedAtComparison = Date.parse(left.completedAt) - Date.parse(right.completedAt);
|
|
273
|
+
if (completedAtComparison !== 0) {
|
|
274
|
+
return completedAtComparison;
|
|
275
|
+
}
|
|
276
|
+
return compareOrdinal(left.attemptId, right.attemptId);
|
|
277
|
+
}
|
|
278
|
+
function compareAttempts(scoreOrder, left, right) {
|
|
279
|
+
if (left.score !== right.score) {
|
|
280
|
+
return scoreOrder === 'ascending' ? left.score - right.score : right.score - left.score;
|
|
281
|
+
}
|
|
282
|
+
return compareAttemptChronology(left, right);
|
|
283
|
+
}
|
|
284
|
+
function compareEntryToCursor(scoreOrder, entry, cursor) {
|
|
285
|
+
if (entry.score !== cursor.score) {
|
|
286
|
+
const comparison = entry.score - cursor.score;
|
|
287
|
+
return scoreOrder === 'ascending' ? comparison : -comparison;
|
|
288
|
+
}
|
|
289
|
+
const completedAtComparison = Date.parse(entry.completedAt) - cursor.completedAtMs;
|
|
290
|
+
if (completedAtComparison !== 0) {
|
|
291
|
+
return completedAtComparison;
|
|
292
|
+
}
|
|
293
|
+
return compareOrdinal(entry.attemptId, cursor.attemptId);
|
|
294
|
+
}
|
|
295
|
+
function compareOrdinal(left, right) {
|
|
296
|
+
if (left < right) {
|
|
297
|
+
return -1;
|
|
298
|
+
}
|
|
299
|
+
if (left > right) {
|
|
300
|
+
return 1;
|
|
301
|
+
}
|
|
302
|
+
return 0;
|
|
303
|
+
}
|
|
304
|
+
function redisRankingScore(scoreOrder, score) {
|
|
305
|
+
return scoreOrder === 'ascending' ? score : -score;
|
|
306
|
+
}
|
|
307
|
+
function assertSameAttempt(existing, candidate) {
|
|
308
|
+
if (existing.participantId !== candidate.participantId
|
|
309
|
+
|| existing.attemptId !== candidate.attemptId
|
|
310
|
+
|| existing.score !== candidate.score
|
|
311
|
+
|| !areVerifiedLeaderboardMetricsEqual(existing.metrics, candidate.metrics)
|
|
312
|
+
|| Date.parse(existing.completedAt) !== Date.parse(candidate.completedAt)
|
|
313
|
+
|| existing.verification.authorityId !== candidate.verification.authorityId
|
|
314
|
+
|| existing.verification.evidenceId !== candidate.verification.evidenceId
|
|
315
|
+
|| Date.parse(existing.verification.verifiedAt)
|
|
316
|
+
!== Date.parse(candidate.verification.verifiedAt)) {
|
|
317
|
+
throw new Error(`Attempt id conflict for ${JSON.stringify(candidate.attemptId)}.`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
function serializeAttemptDecision(attempt, response) {
|
|
321
|
+
return JSON.stringify({ version: 1, attempt, response });
|
|
322
|
+
}
|
|
323
|
+
function serializeRetainedAttempt(attempt) {
|
|
324
|
+
return JSON.stringify({ version: 1, attempt });
|
|
325
|
+
}
|
|
326
|
+
function parseStoredAttemptDecision(raw, key) {
|
|
327
|
+
const parsed = parseJson(raw, key);
|
|
328
|
+
assertStoredRecord(parsed, key);
|
|
329
|
+
if (parsed.version !== 1) {
|
|
330
|
+
throw new Error(`Unsupported Devvit Redis record version for ${key}.`);
|
|
331
|
+
}
|
|
332
|
+
assertVerifiedLeaderboardAttempt(parsed.attempt);
|
|
333
|
+
assertRecordVerifiedLeaderboardAttemptResponse(parsed.response);
|
|
334
|
+
return {
|
|
335
|
+
version: 1,
|
|
336
|
+
attempt: cloneAttempt(parsed.attempt),
|
|
337
|
+
response: cloneRecordResponse(parsed.response, parsed.response.alreadyProcessed),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
function parseStoredRetainedAttempt(raw, key) {
|
|
341
|
+
const parsed = parseJson(raw, key);
|
|
342
|
+
assertStoredRecord(parsed, key);
|
|
343
|
+
if (parsed.version !== 1) {
|
|
344
|
+
throw new Error(`Unsupported Devvit Redis record version for ${key}.`);
|
|
345
|
+
}
|
|
346
|
+
assertVerifiedLeaderboardAttempt(parsed.attempt);
|
|
347
|
+
return { version: 1, attempt: cloneAttempt(parsed.attempt) };
|
|
348
|
+
}
|
|
349
|
+
function parseJson(raw, key) {
|
|
350
|
+
try {
|
|
351
|
+
return JSON.parse(raw);
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
throw new Error(`Invalid Devvit Redis JSON record for ${key}.`, { cause: error });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
function assertStoredRecord(input, key) {
|
|
358
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
|
|
359
|
+
throw new Error(`Invalid Devvit Redis record for ${key}.`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
function cloneRecordRequest(input) {
|
|
363
|
+
return {
|
|
364
|
+
definition: cloneDefinition(input.definition),
|
|
365
|
+
attempt: cloneAttempt(input.attempt),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
function cloneDefinition(input) {
|
|
369
|
+
return {
|
|
370
|
+
leaderboardId: input.leaderboardId,
|
|
371
|
+
scoreOrder: input.scoreOrder,
|
|
372
|
+
attemptSelection: input.attemptSelection,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
function cloneAttempt(input) {
|
|
376
|
+
return {
|
|
377
|
+
participantId: input.participantId,
|
|
378
|
+
...(input.participantLabel === undefined
|
|
379
|
+
? {}
|
|
380
|
+
: { participantLabel: input.participantLabel }),
|
|
381
|
+
attemptId: input.attemptId,
|
|
382
|
+
score: input.score,
|
|
383
|
+
...(input.metrics === undefined
|
|
384
|
+
? {}
|
|
385
|
+
: { metrics: normalizeVerifiedLeaderboardMetrics(input.metrics) }),
|
|
386
|
+
completedAt: input.completedAt,
|
|
387
|
+
verification: {
|
|
388
|
+
authorityId: input.verification.authorityId,
|
|
389
|
+
evidenceId: input.verification.evidenceId,
|
|
390
|
+
verifiedAt: input.verification.verifiedAt,
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
function cloneRecordResponse(input, alreadyProcessed) {
|
|
395
|
+
const response = {
|
|
396
|
+
recorded: true,
|
|
397
|
+
alreadyProcessed,
|
|
398
|
+
retained: input.retained,
|
|
399
|
+
entry: {
|
|
400
|
+
rank: input.entry.rank,
|
|
401
|
+
participantId: input.entry.participantId,
|
|
402
|
+
...(input.entry.participantLabel === undefined
|
|
403
|
+
? {}
|
|
404
|
+
: { participantLabel: input.entry.participantLabel }),
|
|
405
|
+
attemptId: input.entry.attemptId,
|
|
406
|
+
score: input.entry.score,
|
|
407
|
+
...(input.entry.metrics === undefined
|
|
408
|
+
? {}
|
|
409
|
+
: { metrics: normalizeVerifiedLeaderboardMetrics(input.entry.metrics) }),
|
|
410
|
+
completedAt: input.entry.completedAt,
|
|
411
|
+
},
|
|
412
|
+
...(input.reason === undefined ? {} : { reason: input.reason }),
|
|
413
|
+
};
|
|
414
|
+
assertRecordVerifiedLeaderboardAttemptResponse(response);
|
|
415
|
+
return response;
|
|
416
|
+
}
|
|
417
|
+
async function bestEffortReset(transaction, multiStarted) {
|
|
418
|
+
try {
|
|
419
|
+
if (multiStarted) {
|
|
420
|
+
await transaction.discard();
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
await transaction.unwatch();
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
// Preserve the original Redis failure; this cleanup is best-effort.
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
function normalizeKeyPrefix(value) {
|
|
431
|
+
const keyPrefix = value ?? defaultKeyPrefix;
|
|
432
|
+
if (!keyPrefixPattern.test(keyPrefix)) {
|
|
433
|
+
throw new TypeError('keyPrefix must contain 1 to 128 ASCII letters, digits, colons, underscores, or hyphens.');
|
|
434
|
+
}
|
|
435
|
+
return keyPrefix;
|
|
436
|
+
}
|
|
437
|
+
function normalizeTransactionAttempts(value) {
|
|
438
|
+
const transactionAttempts = value ?? defaultTransactionAttempts;
|
|
439
|
+
if (!Number.isSafeInteger(transactionAttempts)
|
|
440
|
+
|| transactionAttempts < 1
|
|
441
|
+
|| transactionAttempts > maximumTransactionAttempts) {
|
|
442
|
+
throw new TypeError(`transactionAttempts must be a safe integer from 1 to ${String(maximumTransactionAttempts)}.`);
|
|
443
|
+
}
|
|
444
|
+
return transactionAttempts;
|
|
445
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/adapter-devvit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Reddit Devvit Web platform adapter for mpgd games.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -46,13 +46,14 @@
|
|
|
46
46
|
"dist"
|
|
47
47
|
],
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@devvit/web": "0.13.
|
|
50
|
-
"@mpgd/
|
|
51
|
-
"@mpgd/
|
|
49
|
+
"@devvit/web": "0.13.8",
|
|
50
|
+
"@mpgd/bridge": "0.6.0",
|
|
51
|
+
"@mpgd/game-services": "0.7.0",
|
|
52
|
+
"@mpgd/platform": "0.5.1"
|
|
52
53
|
},
|
|
53
54
|
"devDependencies": {
|
|
54
|
-
"ttsc": "0.
|
|
55
|
-
"typescript": "7.0.
|
|
55
|
+
"ttsc": "0.18.4",
|
|
56
|
+
"typescript": "7.0.2",
|
|
56
57
|
"vitest": "^4.0.0"
|
|
57
58
|
},
|
|
58
59
|
"main": "./dist/index.js",
|