@mpgd/game-services 0.4.0 → 0.6.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 +3 -0
- package/dist/index.js +3 -0
- package/dist/runtime.d.ts +31 -0
- package/dist/runtime.js +198 -0
- package/dist/verified-leaderboard-conformance.d.ts +29 -0
- package/dist/verified-leaderboard-conformance.js +630 -0
- package/dist/verified-leaderboard-transport.d.ts +24 -0
- package/dist/verified-leaderboard-transport.js +169 -0
- package/dist/verified-leaderboard.d.ts +112 -0
- package/dist/verified-leaderboard.js +539 -0
- package/package.json +23 -7
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { assertGetVerifiedLeaderboardSnapshotRequest, assertVerifiedLeaderboardSnapshot, VerifiedLeaderboardCursorError, } from './verified-leaderboard.js';
|
|
2
|
+
export const verifiedLeaderboardSnapshotPath = '/game-services/verified-leaderboard/snapshot';
|
|
3
|
+
export function createVerifiedLeaderboardSnapshotFetchHandler(input) {
|
|
4
|
+
const path = input.path ?? verifiedLeaderboardSnapshotPath;
|
|
5
|
+
return async (request) => {
|
|
6
|
+
const requestUrl = new URL(request.url);
|
|
7
|
+
if (requestUrl.pathname !== path) {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
if (request.method === 'OPTIONS') {
|
|
11
|
+
return emptyResponse(204, input.corsHeaders);
|
|
12
|
+
}
|
|
13
|
+
if (request.method !== 'GET') {
|
|
14
|
+
return jsonResponse({ error: 'METHOD_NOT_ALLOWED' }, 405, input.corsHeaders, {
|
|
15
|
+
Allow: 'GET, OPTIONS',
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
let principal;
|
|
19
|
+
try {
|
|
20
|
+
principal = await input.authenticate(request);
|
|
21
|
+
if (principal !== undefined) {
|
|
22
|
+
assertPrincipal(principal);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return jsonResponse({ error: 'AUTHENTICATION_FAILED' }, 500, input.corsHeaders);
|
|
27
|
+
}
|
|
28
|
+
if (principal === undefined) {
|
|
29
|
+
return jsonResponse({ error: 'UNAUTHORIZED' }, 401, input.corsHeaders);
|
|
30
|
+
}
|
|
31
|
+
let snapshotRequest;
|
|
32
|
+
try {
|
|
33
|
+
snapshotRequest = readSnapshotRequest(requestUrl);
|
|
34
|
+
assertGetVerifiedLeaderboardSnapshotRequest(snapshotRequest);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error instanceof VerifiedLeaderboardCursorError) {
|
|
38
|
+
return jsonResponse({ error: 'INVALID_CURSOR' }, 400, input.corsHeaders);
|
|
39
|
+
}
|
|
40
|
+
return jsonResponse({ error: error instanceof Error ? error.message : 'BAD_REQUEST' }, 400, input.corsHeaders);
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const snapshot = await input.reader.getSnapshot({
|
|
44
|
+
...snapshotRequest,
|
|
45
|
+
participantId: principal.participantId,
|
|
46
|
+
});
|
|
47
|
+
if (snapshot === undefined) {
|
|
48
|
+
return jsonResponse({ error: 'LEADERBOARD_NOT_FOUND' }, 404, input.corsHeaders);
|
|
49
|
+
}
|
|
50
|
+
assertVerifiedLeaderboardSnapshot(snapshot);
|
|
51
|
+
return jsonResponse(snapshot, 200, input.corsHeaders);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
return error instanceof VerifiedLeaderboardCursorError
|
|
55
|
+
? jsonResponse({ error: 'INVALID_CURSOR' }, 400, input.corsHeaders)
|
|
56
|
+
: jsonResponse({ error: 'INTERNAL_ERROR' }, 500, input.corsHeaders);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function createVerifiedLeaderboardSnapshotFetchClient(input) {
|
|
61
|
+
const fetcher = input.fetch ?? globalThis.fetch;
|
|
62
|
+
if (fetcher === undefined) {
|
|
63
|
+
throw new Error('A fetch implementation is required for leaderboard snapshot reads.');
|
|
64
|
+
}
|
|
65
|
+
const endpoint = createEndpointUrl(input.baseUrl, input.path ?? verifiedLeaderboardSnapshotPath);
|
|
66
|
+
return {
|
|
67
|
+
async getSnapshot(requestInput) {
|
|
68
|
+
assertGetVerifiedLeaderboardSnapshotRequest(requestInput);
|
|
69
|
+
const authorization = await input.authorization();
|
|
70
|
+
if (typeof authorization !== 'string' || authorization.length === 0) {
|
|
71
|
+
throw new Error('authorization must be a non-empty string.');
|
|
72
|
+
}
|
|
73
|
+
const url = new URL(endpoint);
|
|
74
|
+
url.searchParams.set('leaderboardId', requestInput.leaderboardId);
|
|
75
|
+
if (requestInput.limit !== undefined) {
|
|
76
|
+
url.searchParams.set('limit', String(requestInput.limit));
|
|
77
|
+
}
|
|
78
|
+
if (requestInput.cursor !== undefined) {
|
|
79
|
+
url.searchParams.set('cursor', requestInput.cursor);
|
|
80
|
+
}
|
|
81
|
+
const response = await fetcher(url, {
|
|
82
|
+
method: 'GET',
|
|
83
|
+
headers: {
|
|
84
|
+
Authorization: authorization,
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
let body;
|
|
88
|
+
try {
|
|
89
|
+
body = await response.json();
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
throw new Error(`Verified leaderboard snapshot response was not JSON (status ${response.status}).`);
|
|
93
|
+
}
|
|
94
|
+
if (response.status === 404 && readErrorCode(body) === 'LEADERBOARD_NOT_FOUND') {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
const error = readErrorCode(body);
|
|
99
|
+
throw new Error(`Verified leaderboard snapshot request failed: ${error}.`);
|
|
100
|
+
}
|
|
101
|
+
assertVerifiedLeaderboardSnapshot(body);
|
|
102
|
+
return body;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function readSnapshotRequest(url) {
|
|
107
|
+
const allowedParameters = new Set(['leaderboardId', 'limit', 'cursor']);
|
|
108
|
+
for (const parameter of url.searchParams.keys()) {
|
|
109
|
+
if (!allowedParameters.has(parameter)) {
|
|
110
|
+
throw new Error(`Unsupported snapshot query parameter: ${parameter}.`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
for (const parameter of allowedParameters) {
|
|
114
|
+
if (url.searchParams.getAll(parameter).length > 1) {
|
|
115
|
+
throw new Error(`Snapshot query parameter must not be repeated: ${parameter}.`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const leaderboardId = url.searchParams.get('leaderboardId') ?? '';
|
|
119
|
+
const limitValue = url.searchParams.get('limit');
|
|
120
|
+
const cursor = url.searchParams.get('cursor') ?? undefined;
|
|
121
|
+
const limit = limitValue === null ? undefined : Number(limitValue);
|
|
122
|
+
return {
|
|
123
|
+
leaderboardId,
|
|
124
|
+
...(limit === undefined ? {} : { limit }),
|
|
125
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function assertPrincipal(input) {
|
|
129
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
|
|
130
|
+
throw new Error('Verified leaderboard principal must be an object.');
|
|
131
|
+
}
|
|
132
|
+
const participantId = input.participantId;
|
|
133
|
+
if (typeof participantId !== 'string' || participantId.length === 0) {
|
|
134
|
+
throw new Error('Verified leaderboard principal participantId must be non-empty.');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function readErrorCode(input) {
|
|
138
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
|
|
139
|
+
return 'UNKNOWN_ERROR';
|
|
140
|
+
}
|
|
141
|
+
const error = input.error;
|
|
142
|
+
return typeof error === 'string' && error.length > 0 ? error : 'UNKNOWN_ERROR';
|
|
143
|
+
}
|
|
144
|
+
function createEndpointUrl(baseUrl, path) {
|
|
145
|
+
const normalizedBaseUrl = new URL(baseUrl);
|
|
146
|
+
normalizedBaseUrl.search = '';
|
|
147
|
+
normalizedBaseUrl.hash = '';
|
|
148
|
+
if (!normalizedBaseUrl.pathname.endsWith('/')) {
|
|
149
|
+
normalizedBaseUrl.pathname = `${normalizedBaseUrl.pathname}/`;
|
|
150
|
+
}
|
|
151
|
+
return new URL(path.replace(/^\/+/u, ''), normalizedBaseUrl);
|
|
152
|
+
}
|
|
153
|
+
function jsonResponse(body, status, corsHeaders, additionalHeaders) {
|
|
154
|
+
return new Response(JSON.stringify(body), {
|
|
155
|
+
status,
|
|
156
|
+
headers: {
|
|
157
|
+
...corsHeaders,
|
|
158
|
+
...additionalHeaders,
|
|
159
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
160
|
+
'Cache-Control': 'private, no-store',
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
function emptyResponse(status, corsHeaders) {
|
|
165
|
+
return new Response(null, {
|
|
166
|
+
status,
|
|
167
|
+
...(corsHeaders === undefined ? {} : { headers: corsHeaders }),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
export type VerifiedLeaderboardScoreOrder = 'ascending' | 'descending';
|
|
2
|
+
export type VerifiedLeaderboardAttemptSelection = 'first' | 'best';
|
|
3
|
+
export declare const verifiedLeaderboardIdentifierMaximumLength = 512;
|
|
4
|
+
export interface VerifiedLeaderboardDefinition {
|
|
5
|
+
readonly leaderboardId: string;
|
|
6
|
+
readonly scoreOrder: VerifiedLeaderboardScoreOrder;
|
|
7
|
+
readonly attemptSelection: VerifiedLeaderboardAttemptSelection;
|
|
8
|
+
}
|
|
9
|
+
export interface LeaderboardVerificationEvidence {
|
|
10
|
+
readonly authorityId: string;
|
|
11
|
+
readonly evidenceId: string;
|
|
12
|
+
readonly verifiedAt: string;
|
|
13
|
+
}
|
|
14
|
+
export interface VerifiedLeaderboardAttempt {
|
|
15
|
+
readonly participantId: string;
|
|
16
|
+
readonly participantLabel?: string;
|
|
17
|
+
readonly attemptId: string;
|
|
18
|
+
readonly score: number;
|
|
19
|
+
readonly completedAt: string;
|
|
20
|
+
readonly verification: LeaderboardVerificationEvidence;
|
|
21
|
+
}
|
|
22
|
+
export interface RecordVerifiedLeaderboardAttemptRequest {
|
|
23
|
+
readonly definition: VerifiedLeaderboardDefinition;
|
|
24
|
+
readonly attempt: VerifiedLeaderboardAttempt;
|
|
25
|
+
}
|
|
26
|
+
export interface LeaderboardRankedEntry {
|
|
27
|
+
readonly rank: number;
|
|
28
|
+
readonly participantId: string;
|
|
29
|
+
readonly participantLabel?: string;
|
|
30
|
+
readonly attemptId: string;
|
|
31
|
+
readonly score: number;
|
|
32
|
+
readonly completedAt: string;
|
|
33
|
+
}
|
|
34
|
+
export interface RecordVerifiedLeaderboardAttemptResponse {
|
|
35
|
+
readonly recorded: true;
|
|
36
|
+
readonly alreadyProcessed: boolean;
|
|
37
|
+
readonly retained: boolean;
|
|
38
|
+
readonly entry: LeaderboardRankedEntry;
|
|
39
|
+
readonly reason?: 'ATTEMPT_NOT_RETAINED';
|
|
40
|
+
}
|
|
41
|
+
export interface GetVerifiedLeaderboardSnapshotRequest {
|
|
42
|
+
readonly leaderboardId: string;
|
|
43
|
+
readonly participantId?: string;
|
|
44
|
+
readonly limit?: number;
|
|
45
|
+
readonly cursor?: string;
|
|
46
|
+
}
|
|
47
|
+
export interface VerifiedLeaderboardSnapshot {
|
|
48
|
+
readonly definition: VerifiedLeaderboardDefinition;
|
|
49
|
+
readonly entries: readonly LeaderboardRankedEntry[];
|
|
50
|
+
readonly participantEntry?: LeaderboardRankedEntry;
|
|
51
|
+
readonly totalParticipants: number;
|
|
52
|
+
readonly generatedAt: string;
|
|
53
|
+
readonly nextCursor?: string;
|
|
54
|
+
}
|
|
55
|
+
export interface VerifiedLeaderboardCursorPosition {
|
|
56
|
+
readonly score: number;
|
|
57
|
+
readonly completedAtMs: number;
|
|
58
|
+
readonly attemptId: string;
|
|
59
|
+
}
|
|
60
|
+
export declare class VerifiedLeaderboardCursorError extends Error {
|
|
61
|
+
readonly name = "VerifiedLeaderboardCursorError";
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Safe to expose through a game-facing read transport after the caller supplies
|
|
65
|
+
* any required authentication and participant scoping.
|
|
66
|
+
*/
|
|
67
|
+
export interface VerifiedLeaderboardReader {
|
|
68
|
+
getSnapshot(input: GetVerifiedLeaderboardSnapshotRequest): Promise<VerifiedLeaderboardSnapshot | undefined>;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Server-only write boundary. Call this only after an authoritative attempt
|
|
72
|
+
* coordinator has verified the completion. Never expose it as a client score
|
|
73
|
+
* submission endpoint.
|
|
74
|
+
*/
|
|
75
|
+
export interface TrustedLeaderboardRecorder {
|
|
76
|
+
recordVerifiedAttempt(input: RecordVerifiedLeaderboardAttemptRequest): Promise<RecordVerifiedLeaderboardAttemptResponse>;
|
|
77
|
+
}
|
|
78
|
+
export interface VerifiedLeaderboardService extends VerifiedLeaderboardReader, TrustedLeaderboardRecorder {
|
|
79
|
+
}
|
|
80
|
+
export interface CreateInMemoryVerifiedLeaderboardServiceInput {
|
|
81
|
+
readonly now?: () => string;
|
|
82
|
+
}
|
|
83
|
+
export declare function createInMemoryVerifiedLeaderboardService(input?: CreateInMemoryVerifiedLeaderboardServiceInput): VerifiedLeaderboardService;
|
|
84
|
+
/**
|
|
85
|
+
* Process-local reference adapter for tests and development. It keeps every
|
|
86
|
+
* definition and processed attempt for its full lifetime and fully sorts the
|
|
87
|
+
* retained attempts for each write and read. Use a durable adapter with
|
|
88
|
+
* eviction and an indexed ranking strategy for long-lived or large boards.
|
|
89
|
+
*/
|
|
90
|
+
export declare class InMemoryVerifiedLeaderboardService implements VerifiedLeaderboardService {
|
|
91
|
+
private readonly now;
|
|
92
|
+
private readonly definitions;
|
|
93
|
+
private readonly attemptsById;
|
|
94
|
+
private readonly retainedByLeaderboard;
|
|
95
|
+
constructor(now?: () => string);
|
|
96
|
+
recordVerifiedAttempt(input: RecordVerifiedLeaderboardAttemptRequest): Promise<RecordVerifiedLeaderboardAttemptResponse>;
|
|
97
|
+
getSnapshot(input: GetVerifiedLeaderboardSnapshotRequest): Promise<VerifiedLeaderboardSnapshot | undefined>;
|
|
98
|
+
private createRecordResponse;
|
|
99
|
+
private ensureDefinition;
|
|
100
|
+
private getRetainedAttempts;
|
|
101
|
+
private createRankedEntries;
|
|
102
|
+
}
|
|
103
|
+
export declare function assertVerifiedLeaderboardDefinition(input: unknown): asserts input is VerifiedLeaderboardDefinition;
|
|
104
|
+
export declare function assertLeaderboardVerificationEvidence(input: unknown): asserts input is LeaderboardVerificationEvidence;
|
|
105
|
+
export declare function assertVerifiedLeaderboardAttempt(input: unknown): asserts input is VerifiedLeaderboardAttempt;
|
|
106
|
+
export declare function assertRecordVerifiedLeaderboardAttemptRequest(input: unknown): asserts input is RecordVerifiedLeaderboardAttemptRequest;
|
|
107
|
+
export declare function assertRecordVerifiedLeaderboardAttemptResponse(input: unknown): asserts input is RecordVerifiedLeaderboardAttemptResponse;
|
|
108
|
+
export declare function assertGetVerifiedLeaderboardSnapshotRequest(input: unknown): asserts input is GetVerifiedLeaderboardSnapshotRequest;
|
|
109
|
+
export declare function assertLeaderboardRankedEntry(input: unknown): asserts input is LeaderboardRankedEntry;
|
|
110
|
+
export declare function assertVerifiedLeaderboardSnapshot(input: unknown): asserts input is VerifiedLeaderboardSnapshot;
|
|
111
|
+
export declare function createVerifiedLeaderboardCursor(definition: VerifiedLeaderboardDefinition, entry: LeaderboardRankedEntry): string;
|
|
112
|
+
export declare function parseVerifiedLeaderboardCursor(cursor: string, definition: VerifiedLeaderboardDefinition): VerifiedLeaderboardCursorPosition;
|