@mpgd/game-services 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 imjlk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,122 @@
1
+ import { type AnalyticsSink } from '@mpgd/analytics';
2
+ import type { LeaderboardScoreInput, LogicalAdPlacementId, LogicalProductId, PlatformGateway, PlatformTarget, PurchaseResult, RewardedAdResult } from '@mpgd/platform';
3
+ import type { GameServicesContractClient } from './contract';
4
+ import type { ClaimAdRewardRequest, ClaimAdRewardResponse, GameServicesStoreTarget, RecordLeaderboardScoreRequest, RecordLeaderboardScoreResponse, VerifyPurchaseRequest, VerifyPurchaseResponse } from './types';
5
+ export interface PurchaseVerificationApi {
6
+ verifyPurchase(input: VerifyPurchaseRequest): Promise<VerifyPurchaseResponse>;
7
+ }
8
+ export interface AdRewardClaimApi {
9
+ claimAdReward(input: ClaimAdRewardRequest): Promise<ClaimAdRewardResponse>;
10
+ }
11
+ export interface LeaderboardRecordApi {
12
+ recordScore(input: RecordLeaderboardScoreRequest): Promise<RecordLeaderboardScoreResponse>;
13
+ }
14
+ export interface GameServicesBackendApi {
15
+ readonly version?: string;
16
+ readonly purchases: PurchaseVerificationApi;
17
+ readonly adRewards: AdRewardClaimApi;
18
+ readonly leaderboard: LeaderboardRecordApi;
19
+ }
20
+ export declare const gameServicesBackendEndpoints: {
21
+ readonly verifyPurchase: '/game-services/purchases/verify';
22
+ readonly claimAdReward: '/game-services/ad-rewards/claim';
23
+ readonly recordLeaderboardScore: '/game-services/leaderboard/record';
24
+ };
25
+ export type GameServicesBackendEndpoint = (typeof gameServicesBackendEndpoints)[keyof typeof gameServicesBackendEndpoints];
26
+ export interface GameServicesBackendTransportRequest<TBody = unknown> {
27
+ readonly method: 'POST';
28
+ readonly endpoint: GameServicesBackendEndpoint;
29
+ readonly body: TBody;
30
+ }
31
+ export interface GameServicesBackendTransportResponse<TBody = unknown> {
32
+ readonly status: number;
33
+ readonly body: TBody;
34
+ }
35
+ export interface GameServicesBackendTransport {
36
+ send(request: GameServicesBackendTransportRequest): Promise<GameServicesBackendTransportResponse>;
37
+ }
38
+ export interface CreateGameServicesHttpBackendApiInput {
39
+ readonly transport: GameServicesBackendTransport;
40
+ }
41
+ export interface CreateGameServicesFetchBackendTransportInput {
42
+ readonly baseUrl: string;
43
+ readonly fetch?: GameServicesFetch;
44
+ readonly headers?: Record<string, string>;
45
+ }
46
+ export interface CreateGameServicesOrpcClientInput {
47
+ readonly url: string;
48
+ readonly fetch?: typeof fetch;
49
+ readonly headers?: Record<string, string>;
50
+ }
51
+ export type GameServicesFetch = (url: string, init: {
52
+ readonly method: 'POST';
53
+ readonly headers: Record<string, string>;
54
+ readonly body: string;
55
+ }) => Promise<GameServicesFetchResponse>;
56
+ export interface GameServicesFetchResponse {
57
+ readonly ok: boolean;
58
+ readonly status: number;
59
+ text(): Promise<string>;
60
+ }
61
+ export declare class GameServicesBackendError extends Error {
62
+ readonly endpoint: GameServicesBackendEndpoint;
63
+ readonly status: number;
64
+ readonly body: unknown;
65
+ constructor(endpoint: GameServicesBackendEndpoint, status: number, body: unknown);
66
+ }
67
+ export interface GameServicesClient {
68
+ purchase(input: GameServicesPurchaseInput): Promise<GameServicesPurchaseResult>;
69
+ claimRewardedAd(input: GameServicesRewardedAdInput): Promise<GameServicesRewardedAdResult>;
70
+ submitLeaderboardScore(input: GameServicesLeaderboardInput): Promise<GameServicesLeaderboardResult>;
71
+ }
72
+ export interface CreateGameServicesClientInput {
73
+ readonly gateway: PlatformGateway;
74
+ readonly backend: GameServicesBackendApi;
75
+ readonly playerId: string;
76
+ readonly target: GameServicesStoreTarget;
77
+ readonly analytics?: AnalyticsSink;
78
+ readonly analyticsSessionId?: string;
79
+ readonly now?: () => string;
80
+ }
81
+ export interface GameServicesPurchaseInput {
82
+ readonly productId: LogicalProductId;
83
+ readonly source: 'shop' | 'stage_fail' | 'result' | 'event';
84
+ readonly idempotencyKey: string;
85
+ }
86
+ export interface GameServicesPurchaseResult {
87
+ readonly status: 'granted' | 'cancelled' | 'pending' | 'failed' | 'rejected';
88
+ readonly purchase: PurchaseResult;
89
+ readonly verification?: VerifyPurchaseResponse;
90
+ readonly ledgerEntryId?: string;
91
+ }
92
+ export interface GameServicesRewardedAdInput {
93
+ readonly placementId: LogicalAdPlacementId;
94
+ readonly idempotencyKey: string;
95
+ }
96
+ export interface GameServicesRewardedAdResult {
97
+ readonly status: 'granted' | 'skipped' | 'unavailable' | 'failed' | 'rejected';
98
+ readonly reward: RewardedAdResult;
99
+ readonly claim?: ClaimAdRewardResponse;
100
+ readonly ledgerEntryId?: string;
101
+ }
102
+ export interface GameServicesLeaderboardInput extends LeaderboardScoreInput {
103
+ }
104
+ export interface GameServicesLeaderboardResult {
105
+ readonly submitted: boolean;
106
+ readonly platformSubmitted: boolean;
107
+ readonly rank?: number;
108
+ readonly ledgerEntryId?: string;
109
+ readonly alreadyProcessed: boolean;
110
+ }
111
+ export declare function createGameServicesClient(input: CreateGameServicesClientInput): GameServicesClient;
112
+ export declare function createGameServicesHttpBackendApi(input: CreateGameServicesHttpBackendApiInput): GameServicesBackendApi;
113
+ export declare function createGameServicesFetchBackendTransport(input: CreateGameServicesFetchBackendTransportInput): GameServicesBackendTransport;
114
+ export declare function createGameServicesOrpcClient(input: CreateGameServicesOrpcClientInput): GameServicesContractClient;
115
+ export declare function createGameServicesOrpcBackendApi(client: GameServicesContractClient): GameServicesBackendApi;
116
+ export declare function createGameServicesIdempotencyKey(input: {
117
+ readonly target: PlatformTarget;
118
+ readonly playerId: string;
119
+ readonly action: 'purchase' | 'rewarded-ad' | 'leaderboard';
120
+ readonly subjectId: string;
121
+ readonly runId: string;
122
+ }): string;
package/dist/client.js ADDED
@@ -0,0 +1,270 @@
1
+ import { createORPCClient } from '@orpc/client';
2
+ import { RPCLink } from '@orpc/client/fetch';
3
+ import { createAnalyticsReporter } from '@mpgd/analytics';
4
+ export const gameServicesBackendEndpoints = {
5
+ verifyPurchase: '/game-services/purchases/verify',
6
+ claimAdReward: '/game-services/ad-rewards/claim',
7
+ recordLeaderboardScore: '/game-services/leaderboard/record',
8
+ };
9
+ export class GameServicesBackendError extends Error {
10
+ endpoint;
11
+ status;
12
+ body;
13
+ constructor(endpoint, status, body) {
14
+ super(`GameServices backend request failed: ${endpoint} ${status}`);
15
+ this.name = 'GameServicesBackendError';
16
+ this.endpoint = endpoint;
17
+ this.status = status;
18
+ this.body = body;
19
+ }
20
+ }
21
+ export function createGameServicesClient(input) {
22
+ const now = input.now ?? (() => new Date().toISOString());
23
+ const analytics = createAnalyticsReporter({
24
+ target: input.target,
25
+ sessionId: input.analyticsSessionId ?? input.playerId,
26
+ now,
27
+ ...(input.analytics === undefined ? {} : { sink: input.analytics }),
28
+ });
29
+ return {
30
+ async purchase(purchaseInput) {
31
+ const purchase = await input.gateway.commerce.purchase(purchaseInput);
32
+ if (purchase.status !== 'completed' || purchase.transactionId === undefined) {
33
+ await analytics.track({
34
+ name: 'purchase_rejected',
35
+ properties: {
36
+ productId: purchaseInput.productId,
37
+ status: purchase.status,
38
+ reason: purchase.transactionId === undefined ? 'missing_transaction_id' : undefined,
39
+ },
40
+ });
41
+ return {
42
+ status: purchase.status === 'completed' ? 'rejected' : purchase.status,
43
+ purchase,
44
+ };
45
+ }
46
+ const verification = await input.backend.purchases.verifyPurchase({
47
+ target: input.target,
48
+ playerId: input.playerId,
49
+ productId: purchaseInput.productId,
50
+ platformTransactionId: purchase.transactionId,
51
+ idempotencyKey: purchaseInput.idempotencyKey,
52
+ purchasedAt: now(),
53
+ });
54
+ const result = {
55
+ status: verification.verified ? 'granted' : 'rejected',
56
+ purchase,
57
+ verification,
58
+ ...(verification.ledgerEntryId === undefined
59
+ ? {}
60
+ : { ledgerEntryId: verification.ledgerEntryId }),
61
+ };
62
+ await analytics.track({
63
+ name: verification.verified ? 'purchase_granted' : 'purchase_rejected',
64
+ properties: {
65
+ productId: purchaseInput.productId,
66
+ status: result.status,
67
+ ledgerEntryId: verification.ledgerEntryId,
68
+ alreadyProcessed: verification.alreadyProcessed,
69
+ reason: verification.reason,
70
+ },
71
+ });
72
+ return result;
73
+ },
74
+ async claimRewardedAd(rewardInput) {
75
+ const reward = await input.gateway.ads.showRewarded(rewardInput);
76
+ if (reward.status !== 'completed' || !reward.rewardGranted) {
77
+ await analytics.track({
78
+ name: 'rewarded_ad_rejected',
79
+ properties: {
80
+ placementId: rewardInput.placementId,
81
+ status: reward.status,
82
+ rewardGranted: reward.rewardGranted,
83
+ },
84
+ });
85
+ return {
86
+ status: reward.status === 'completed' ? 'rejected' : reward.status,
87
+ reward,
88
+ };
89
+ }
90
+ const claim = await input.backend.adRewards.claimAdReward({
91
+ target: input.target,
92
+ playerId: input.playerId,
93
+ placementId: rewardInput.placementId,
94
+ ...(reward.ledgerEntryId === undefined
95
+ ? {}
96
+ : { platformImpressionId: reward.ledgerEntryId }),
97
+ idempotencyKey: rewardInput.idempotencyKey,
98
+ completedAt: now(),
99
+ });
100
+ const result = {
101
+ status: claim.granted ? 'granted' : 'rejected',
102
+ reward,
103
+ claim,
104
+ ...(claim.ledgerEntryId === undefined ? {} : { ledgerEntryId: claim.ledgerEntryId }),
105
+ };
106
+ await analytics.track({
107
+ name: claim.granted ? 'rewarded_ad_granted' : 'rewarded_ad_rejected',
108
+ properties: {
109
+ placementId: rewardInput.placementId,
110
+ status: result.status,
111
+ ledgerEntryId: claim.ledgerEntryId,
112
+ alreadyProcessed: claim.alreadyProcessed,
113
+ reason: claim.reason,
114
+ },
115
+ });
116
+ return result;
117
+ },
118
+ async submitLeaderboardScore(scoreInput) {
119
+ const platformResult = await input.gateway.leaderboard.submitScore(scoreInput);
120
+ if (!platformResult.submitted) {
121
+ await analytics.track({
122
+ name: 'leaderboard_submitted',
123
+ properties: {
124
+ leaderboardId: scoreInput.leaderboardId,
125
+ score: scoreInput.score,
126
+ submitted: false,
127
+ },
128
+ });
129
+ return {
130
+ submitted: false,
131
+ platformSubmitted: false,
132
+ alreadyProcessed: false,
133
+ };
134
+ }
135
+ const record = await input.backend.leaderboard.recordScore({
136
+ target: input.target,
137
+ playerId: input.playerId,
138
+ ...scoreInput,
139
+ });
140
+ const result = {
141
+ submitted: record.submitted,
142
+ platformSubmitted: true,
143
+ rank: record.rank,
144
+ ledgerEntryId: record.ledgerEntryId,
145
+ alreadyProcessed: record.alreadyProcessed,
146
+ };
147
+ await analytics.track({
148
+ name: 'leaderboard_recorded',
149
+ properties: {
150
+ leaderboardId: scoreInput.leaderboardId,
151
+ score: scoreInput.score,
152
+ submitted: result.submitted,
153
+ rank: result.rank,
154
+ ledgerEntryId: result.ledgerEntryId,
155
+ alreadyProcessed: result.alreadyProcessed,
156
+ },
157
+ });
158
+ return result;
159
+ },
160
+ };
161
+ }
162
+ export function createGameServicesHttpBackendApi(input) {
163
+ return {
164
+ purchases: {
165
+ async verifyPurchase(body) {
166
+ return sendGameServicesBackendRequest(input.transport, gameServicesBackendEndpoints.verifyPurchase, body);
167
+ },
168
+ },
169
+ adRewards: {
170
+ async claimAdReward(body) {
171
+ return sendGameServicesBackendRequest(input.transport, gameServicesBackendEndpoints.claimAdReward, body);
172
+ },
173
+ },
174
+ leaderboard: {
175
+ async recordScore(body) {
176
+ return sendGameServicesBackendRequest(input.transport, gameServicesBackendEndpoints.recordLeaderboardScore, body);
177
+ },
178
+ },
179
+ };
180
+ }
181
+ export function createGameServicesFetchBackendTransport(input) {
182
+ const fetcher = input.fetch ?? readGlobalFetch();
183
+ return {
184
+ async send(request) {
185
+ const response = await fetcher(joinUrl(input.baseUrl, request.endpoint), {
186
+ method: request.method,
187
+ headers: {
188
+ ...(input.headers ?? {}),
189
+ 'content-type': 'application/json',
190
+ },
191
+ body: JSON.stringify(request.body),
192
+ });
193
+ return {
194
+ status: response.status,
195
+ body: await readFetchResponseBody(response),
196
+ };
197
+ },
198
+ };
199
+ }
200
+ export function createGameServicesOrpcClient(input) {
201
+ const link = new RPCLink({
202
+ origin: input.url,
203
+ ...(input.fetch === undefined ? {} : { fetch: input.fetch }),
204
+ ...(input.headers === undefined
205
+ ? {}
206
+ : {
207
+ headers: () => input.headers,
208
+ }),
209
+ });
210
+ return createORPCClient(link);
211
+ }
212
+ export function createGameServicesOrpcBackendApi(client) {
213
+ return {
214
+ purchases: {
215
+ verifyPurchase(input) {
216
+ return client.commerce.verifyPurchase(input);
217
+ },
218
+ },
219
+ adRewards: {
220
+ claimAdReward(input) {
221
+ return client.ads.claimReward(input);
222
+ },
223
+ },
224
+ leaderboard: {
225
+ recordScore(input) {
226
+ return client.leaderboard.recordScore(input);
227
+ },
228
+ },
229
+ };
230
+ }
231
+ export function createGameServicesIdempotencyKey(input) {
232
+ return [
233
+ input.action,
234
+ normalizeSegment(input.target),
235
+ normalizeSegment(input.playerId),
236
+ normalizeSegment(input.subjectId),
237
+ normalizeSegment(input.runId),
238
+ ].join(':');
239
+ }
240
+ function normalizeSegment(value) {
241
+ return value.replaceAll(/[^a-zA-Z0-9_-]+/g, '-').replaceAll(/^-|-$/g, '').slice(0, 64);
242
+ }
243
+ async function sendGameServicesBackendRequest(transport, endpoint, body) {
244
+ const response = await transport.send({
245
+ method: 'POST',
246
+ endpoint,
247
+ body,
248
+ });
249
+ if (response.status < 200 || response.status >= 300) {
250
+ throw new GameServicesBackendError(endpoint, response.status, response.body);
251
+ }
252
+ return response.body;
253
+ }
254
+ function readGlobalFetch() {
255
+ const fetcher = globalThis.fetch;
256
+ if (fetcher === undefined) {
257
+ throw new Error('globalThis.fetch is unavailable. Provide a GameServices fetch implementation.');
258
+ }
259
+ return fetcher;
260
+ }
261
+ async function readFetchResponseBody(response) {
262
+ const text = await response.text();
263
+ if (text.length === 0) {
264
+ return null;
265
+ }
266
+ return JSON.parse(text);
267
+ }
268
+ function joinUrl(baseUrl, endpoint) {
269
+ return `${baseUrl.replace(/\/+$/g, '')}${endpoint}`;
270
+ }
@@ -0,0 +1,21 @@
1
+ import { type RouterContractClient } from '@orpc/contract';
2
+ import type { ClaimAdRewardRequest, ClaimAdRewardResponse, RecordLeaderboardScoreRequest, RecordLeaderboardScoreResponse, VerifyPurchaseRequest, VerifyPurchaseResponse } from './types';
3
+ export interface GameServicesHealthResponse {
4
+ readonly ok: true;
5
+ readonly service: 'game-services';
6
+ readonly version: string;
7
+ }
8
+ export declare const gameServicesContract: {
9
+ health: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<void, void>, import("@orpc/contract").Schema<GameServicesHealthResponse, GameServicesHealthResponse>, object>;
10
+ commerce: {
11
+ verifyPurchase: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<VerifyPurchaseRequest, VerifyPurchaseRequest>, import("@orpc/contract").Schema<VerifyPurchaseResponse, VerifyPurchaseResponse>, object>;
12
+ };
13
+ ads: {
14
+ claimReward: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<ClaimAdRewardRequest, ClaimAdRewardRequest>, import("@orpc/contract").Schema<ClaimAdRewardResponse, ClaimAdRewardResponse>, object>;
15
+ };
16
+ leaderboard: {
17
+ recordScore: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<RecordLeaderboardScoreRequest, RecordLeaderboardScoreRequest>, import("@orpc/contract").Schema<RecordLeaderboardScoreResponse, RecordLeaderboardScoreResponse>, object>;
18
+ };
19
+ };
20
+ export type GameServicesContract = typeof gameServicesContract;
21
+ export type GameServicesContractClient = RouterContractClient<GameServicesContract>;
@@ -0,0 +1,21 @@
1
+ import { oc, type as orpcType } from '@orpc/contract';
2
+ export const gameServicesContract = oc.router({
3
+ health: oc
4
+ .input(orpcType())
5
+ .output(orpcType()),
6
+ commerce: oc.router({
7
+ verifyPurchase: oc
8
+ .input(orpcType())
9
+ .output(orpcType()),
10
+ }),
11
+ ads: oc.router({
12
+ claimReward: oc
13
+ .input(orpcType())
14
+ .output(orpcType()),
15
+ }),
16
+ leaderboard: oc.router({
17
+ recordScore: oc
18
+ .input(orpcType())
19
+ .output(orpcType()),
20
+ }),
21
+ });
@@ -0,0 +1,4 @@
1
+ export * from './client';
2
+ export * from './contract';
3
+ export * from './server';
4
+ export * from './types';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export * from './client';
2
+ export * from './contract';
3
+ export * from './server';
4
+ export * from './types';
@@ -0,0 +1,78 @@
1
+ import { type AnalyticsSink } from '@mpgd/analytics';
2
+ import type { AdPlacements, ProductCatalog } from '@mpgd/catalog';
3
+ import { type GameServicesBackendApi, type GameServicesBackendTransport, type GameServicesBackendTransportRequest, type GameServicesBackendTransportResponse } from './client';
4
+ import { type GameServicesHealthResponse } from './contract';
5
+ import { type ClaimAdRewardRequest, type ClaimAdRewardResponse, type EntitlementLedgerGrant, type EntitlementLedgerResult, type LeaderboardScoreTransaction, type ProductGrantTransaction, type RecordLeaderboardScoreRequest, type RecordLeaderboardScoreResponse, type VerifyPurchaseRequest, type VerifyPurchaseResponse } from './types';
6
+ type CorsHeaders = Record<string, string>;
7
+ export interface CreateGameServicesBackendInput {
8
+ readonly catalog: ProductCatalog;
9
+ readonly placements: AdPlacements;
10
+ readonly store?: GameServicesStore;
11
+ readonly analytics?: AnalyticsSink;
12
+ readonly analyticsSessionId?: string;
13
+ readonly now?: () => string;
14
+ readonly version?: string;
15
+ }
16
+ export interface GameServicesBackendApiHandler {
17
+ readonly version?: string;
18
+ handle(request: GameServicesBackendTransportRequest): Promise<GameServicesBackendTransportResponse>;
19
+ }
20
+ export interface GameServicesBackendErrorResponse {
21
+ readonly error: string;
22
+ }
23
+ export interface GameServicesStore {
24
+ recordEntitlementGrant(input: EntitlementLedgerGrant): Promise<EntitlementLedgerResult>;
25
+ getEntitlementTransaction(ledgerEntryId: string): Promise<ProductGrantTransaction | undefined>;
26
+ listEntitlementTransactions(): Promise<readonly ProductGrantTransaction[]>;
27
+ recordLeaderboardScore(input: RecordLeaderboardScoreRequest, options?: {
28
+ readonly recordedAt?: string;
29
+ }): Promise<RecordLeaderboardScoreResponse>;
30
+ getLeaderboardTransaction(ledgerEntryId: string): Promise<LeaderboardScoreTransaction | undefined>;
31
+ listLeaderboardTransactions(): Promise<readonly LeaderboardScoreTransaction[]>;
32
+ }
33
+ export interface CreateGameServicesFetchHandlerOptions {
34
+ readonly corsHeaders?: CorsHeaders;
35
+ readonly healthPath?: string;
36
+ readonly version?: string;
37
+ }
38
+ export interface CreateGameServicesRpcFetchHandlerOptions extends CreateGameServicesFetchHandlerOptions {
39
+ readonly prefix?: string;
40
+ }
41
+ export interface GameServicesOrpcContext {
42
+ readonly request?: Request;
43
+ }
44
+ export declare class InMemoryGameServicesStore implements GameServicesStore {
45
+ private readonly entitlementTransactionsByKey;
46
+ private readonly entitlementTransactionsById;
47
+ private readonly leaderboardTransactionsByRun;
48
+ private readonly leaderboardTransactionsById;
49
+ recordEntitlementGrant(input: EntitlementLedgerGrant): Promise<EntitlementLedgerResult>;
50
+ getEntitlementTransaction(ledgerEntryId: string): Promise<ProductGrantTransaction | undefined>;
51
+ listEntitlementTransactions(): Promise<readonly ProductGrantTransaction[]>;
52
+ recordLeaderboardScore(input: RecordLeaderboardScoreRequest, options?: {
53
+ readonly recordedAt?: string;
54
+ }): Promise<RecordLeaderboardScoreResponse>;
55
+ getLeaderboardTransaction(ledgerEntryId: string): Promise<LeaderboardScoreTransaction | undefined>;
56
+ listLeaderboardTransactions(): Promise<readonly LeaderboardScoreTransaction[]>;
57
+ private rankFor;
58
+ private sortedLeaderboardTransactions;
59
+ }
60
+ export declare function createInMemoryGameServicesStore(): InMemoryGameServicesStore;
61
+ export declare function createGameServicesBackend(input: CreateGameServicesBackendInput): GameServicesBackendApi;
62
+ export declare function createGameServicesBackendApiHandler(input: CreateGameServicesBackendInput): GameServicesBackendApiHandler;
63
+ export declare function createInProcessGameServicesBackendTransport(handler: GameServicesBackendApiHandler): GameServicesBackendTransport;
64
+ export declare function createGameServicesRouter(backend: GameServicesBackendApi): {
65
+ health: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<void, void>, import("@orpc/contract").Schema<GameServicesHealthResponse, GameServicesHealthResponse>, object>;
66
+ commerce: {
67
+ verifyPurchase: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<VerifyPurchaseRequest, VerifyPurchaseRequest>, import("@orpc/contract").Schema<VerifyPurchaseResponse, VerifyPurchaseResponse>, object>;
68
+ };
69
+ ads: {
70
+ claimReward: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<ClaimAdRewardRequest, ClaimAdRewardRequest>, import("@orpc/contract").Schema<ClaimAdRewardResponse, ClaimAdRewardResponse>, object>;
71
+ };
72
+ leaderboard: {
73
+ recordScore: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<RecordLeaderboardScoreRequest, RecordLeaderboardScoreRequest>, import("@orpc/contract").Schema<RecordLeaderboardScoreResponse, RecordLeaderboardScoreResponse>, object>;
74
+ };
75
+ };
76
+ export declare function createGameServicesRpcFetchHandler(router: ReturnType<typeof createGameServicesRouter>, options?: CreateGameServicesRpcFetchHandlerOptions): (request: Request) => Promise<Response>;
77
+ export declare function createGameServicesHttpFetchHandler(handler: GameServicesBackendApiHandler, options?: CreateGameServicesFetchHandlerOptions): (request: Request) => Promise<Response>;
78
+ export {};
package/dist/server.js ADDED
@@ -0,0 +1,474 @@
1
+ import { implement } from '@orpc/server';
2
+ import { RPCHandler } from '@orpc/server/fetch';
3
+ import { createAnalyticsReporter } from '@mpgd/analytics';
4
+ import { gameServicesBackendEndpoints, } from './client';
5
+ import { gameServicesContract } from './contract';
6
+ import { assertClaimAdRewardRequest, assertClaimAdRewardResponse, assertEntitlementLedgerGrant, assertEntitlementLedgerResult, assertLeaderboardScoreTransaction, assertProductGrantTransaction, assertRecordLeaderboardScoreRequest, assertRecordLeaderboardScoreResponse, assertVerifyPurchaseRequest, assertVerifyPurchaseResponse, } from './types';
7
+ export class InMemoryGameServicesStore {
8
+ entitlementTransactionsByKey = new Map();
9
+ entitlementTransactionsById = new Map();
10
+ leaderboardTransactionsByRun = new Map();
11
+ leaderboardTransactionsById = new Map();
12
+ async recordEntitlementGrant(input) {
13
+ const grant = assertEntitlementLedgerGrant(input);
14
+ const key = createEntitlementIdempotencyKey(grant);
15
+ const existing = this.entitlementTransactionsByKey.get(key);
16
+ if (existing !== undefined) {
17
+ return assertEntitlementLedgerResult({
18
+ ledgerEntryId: existing.ledgerEntryId,
19
+ alreadyProcessed: true,
20
+ });
21
+ }
22
+ const transaction = createEntitlementTransaction(grant);
23
+ this.entitlementTransactionsByKey.set(key, transaction);
24
+ this.entitlementTransactionsById.set(transaction.ledgerEntryId, transaction);
25
+ return assertEntitlementLedgerResult({
26
+ ledgerEntryId: transaction.ledgerEntryId,
27
+ alreadyProcessed: false,
28
+ });
29
+ }
30
+ async getEntitlementTransaction(ledgerEntryId) {
31
+ return this.entitlementTransactionsById.get(ledgerEntryId);
32
+ }
33
+ async listEntitlementTransactions() {
34
+ return [...this.entitlementTransactionsById.values()];
35
+ }
36
+ async recordLeaderboardScore(input, options = {}) {
37
+ const request = assertRecordLeaderboardScoreRequest(input);
38
+ const runKey = createLeaderboardRunKey(request);
39
+ const existing = this.leaderboardTransactionsByRun.get(runKey);
40
+ if (existing !== undefined) {
41
+ return assertRecordLeaderboardScoreResponse({
42
+ submitted: true,
43
+ ledgerEntryId: existing.ledgerEntryId,
44
+ alreadyProcessed: true,
45
+ rank: this.rankFor(existing),
46
+ });
47
+ }
48
+ const transaction = assertLeaderboardScoreTransaction({
49
+ ...request,
50
+ ledgerEntryId: createLeaderboardLedgerEntryId(request),
51
+ recordedAt: options.recordedAt ?? new Date().toISOString(),
52
+ });
53
+ this.leaderboardTransactionsByRun.set(runKey, transaction);
54
+ this.leaderboardTransactionsById.set(transaction.ledgerEntryId, transaction);
55
+ return assertRecordLeaderboardScoreResponse({
56
+ submitted: true,
57
+ ledgerEntryId: transaction.ledgerEntryId,
58
+ alreadyProcessed: false,
59
+ rank: this.rankFor(transaction),
60
+ });
61
+ }
62
+ async getLeaderboardTransaction(ledgerEntryId) {
63
+ return this.leaderboardTransactionsById.get(ledgerEntryId);
64
+ }
65
+ async listLeaderboardTransactions() {
66
+ return this.sortedLeaderboardTransactions();
67
+ }
68
+ rankFor(transaction) {
69
+ return (this.sortedLeaderboardTransactions()
70
+ .filter((candidate) => candidate.leaderboardId === transaction.leaderboardId)
71
+ .findIndex((candidate) => candidate.ledgerEntryId === transaction.ledgerEntryId) + 1);
72
+ }
73
+ sortedLeaderboardTransactions() {
74
+ return [...this.leaderboardTransactionsById.values()].sort((left, right) => {
75
+ if (right.score !== left.score) {
76
+ return right.score - left.score;
77
+ }
78
+ return left.submittedAt.localeCompare(right.submittedAt);
79
+ });
80
+ }
81
+ }
82
+ export function createInMemoryGameServicesStore() {
83
+ return new InMemoryGameServicesStore();
84
+ }
85
+ export function createGameServicesBackend(input) {
86
+ const store = input.store ?? createInMemoryGameServicesStore();
87
+ const now = input.now ?? (() => new Date().toISOString());
88
+ const version = input.version ?? '0.0.0';
89
+ const analytics = createAnalyticsReporter({
90
+ target: 'server',
91
+ sessionId: input.analyticsSessionId ?? 'game-services',
92
+ now,
93
+ ...(input.analytics === undefined ? {} : { sink: input.analytics }),
94
+ });
95
+ return {
96
+ version,
97
+ purchases: {
98
+ async verifyPurchase(request) {
99
+ const verification = await verifyPurchaseWithStore(request, {
100
+ catalog: input.catalog,
101
+ store,
102
+ now,
103
+ });
104
+ await analytics.track({
105
+ name: verification.verified ? 'purchase_granted' : 'purchase_rejected',
106
+ properties: {
107
+ target: request.target,
108
+ playerId: request.playerId,
109
+ productId: request.productId,
110
+ ledgerEntryId: verification.ledgerEntryId,
111
+ alreadyProcessed: verification.alreadyProcessed,
112
+ reason: verification.reason,
113
+ },
114
+ });
115
+ return verification;
116
+ },
117
+ },
118
+ adRewards: {
119
+ async claimAdReward(request) {
120
+ const claim = await claimAdRewardWithStore(request, {
121
+ placements: input.placements,
122
+ store,
123
+ now,
124
+ });
125
+ await analytics.track({
126
+ name: claim.granted ? 'rewarded_ad_granted' : 'rewarded_ad_rejected',
127
+ properties: {
128
+ target: request.target,
129
+ playerId: request.playerId,
130
+ placementId: request.placementId,
131
+ ledgerEntryId: claim.ledgerEntryId,
132
+ alreadyProcessed: claim.alreadyProcessed,
133
+ reason: claim.reason,
134
+ },
135
+ });
136
+ return claim;
137
+ },
138
+ },
139
+ leaderboard: {
140
+ async recordScore(request) {
141
+ const record = await recordLeaderboardScoreWithStore(request, {
142
+ store,
143
+ now,
144
+ });
145
+ await analytics.track({
146
+ name: 'leaderboard_recorded',
147
+ properties: {
148
+ target: request.target,
149
+ playerId: request.playerId,
150
+ leaderboardId: request.leaderboardId,
151
+ score: request.score,
152
+ ledgerEntryId: record.ledgerEntryId,
153
+ alreadyProcessed: record.alreadyProcessed,
154
+ rank: record.rank,
155
+ },
156
+ });
157
+ return record;
158
+ },
159
+ },
160
+ };
161
+ }
162
+ export function createGameServicesBackendApiHandler(input) {
163
+ const backend = createGameServicesBackend(input);
164
+ return {
165
+ ...(backend.version === undefined ? {} : { version: backend.version }),
166
+ async handle(request) {
167
+ if (request.method !== 'POST') {
168
+ return errorResponse(405, 'METHOD_NOT_ALLOWED');
169
+ }
170
+ try {
171
+ return await routeGameServicesRequest(request.endpoint, request.body, backend);
172
+ }
173
+ catch (error) {
174
+ return errorResponse(400, error instanceof Error ? error.message : 'BAD_REQUEST');
175
+ }
176
+ },
177
+ };
178
+ }
179
+ export function createInProcessGameServicesBackendTransport(handler) {
180
+ return {
181
+ async send(request) {
182
+ return handler.handle(request);
183
+ },
184
+ };
185
+ }
186
+ export function createGameServicesRouter(backend) {
187
+ const contract = implement(gameServicesContract).$context();
188
+ return contract.router({
189
+ health: contract.health.handler(() => createHealthResponse(backend.version)),
190
+ commerce: contract.commerce.router({
191
+ verifyPurchase: contract.commerce.verifyPurchase.handler(({ input }) => {
192
+ return backend.purchases.verifyPurchase(input);
193
+ }),
194
+ }),
195
+ ads: contract.ads.router({
196
+ claimReward: contract.ads.claimReward.handler(({ input }) => {
197
+ return backend.adRewards.claimAdReward(input);
198
+ }),
199
+ }),
200
+ leaderboard: contract.leaderboard.router({
201
+ recordScore: contract.leaderboard.recordScore.handler(({ input }) => {
202
+ return backend.leaderboard.recordScore(input);
203
+ }),
204
+ }),
205
+ });
206
+ }
207
+ export function createGameServicesRpcFetchHandler(router, options = {}) {
208
+ const rpcHandler = new RPCHandler(router);
209
+ const prefix = options.prefix ?? '/rpc';
210
+ const healthPath = options.healthPath ?? '/health';
211
+ return async (request) => {
212
+ const requestUrl = new URL(request.url);
213
+ if (requestUrl.pathname === healthPath) {
214
+ return jsonResponse(createHealthResponse(options.version), 200, options.corsHeaders);
215
+ }
216
+ if (request.method === 'OPTIONS') {
217
+ return emptyResponse(204, options.corsHeaders);
218
+ }
219
+ const result = await rpcHandler.handle(request, {
220
+ prefix,
221
+ context: {
222
+ request,
223
+ },
224
+ });
225
+ if (!result.matched) {
226
+ return textResponse('Not Found', 404, options.corsHeaders);
227
+ }
228
+ return withCors(result.response, options.corsHeaders);
229
+ };
230
+ }
231
+ export function createGameServicesHttpFetchHandler(handler, options = {}) {
232
+ const healthPath = options.healthPath ?? '/health';
233
+ return async (request) => {
234
+ const requestUrl = new URL(request.url);
235
+ if (requestUrl.pathname === healthPath) {
236
+ return jsonResponse(createHealthResponse(options.version ?? handler.version), 200, options.corsHeaders);
237
+ }
238
+ if (request.method === 'OPTIONS') {
239
+ return emptyResponse(204, options.corsHeaders);
240
+ }
241
+ if (request.method !== 'POST') {
242
+ return jsonResponse({ error: 'METHOD_NOT_ALLOWED' }, 405, options.corsHeaders);
243
+ }
244
+ if (!isGameServicesBackendEndpoint(requestUrl.pathname)) {
245
+ return jsonResponse({ error: 'UNKNOWN_ENDPOINT' }, 404, options.corsHeaders);
246
+ }
247
+ try {
248
+ const response = await handler.handle({
249
+ method: 'POST',
250
+ endpoint: requestUrl.pathname,
251
+ body: await readRequestJson(request),
252
+ });
253
+ return jsonResponse(response.body, response.status, options.corsHeaders);
254
+ }
255
+ catch (error) {
256
+ return jsonResponse({ error: error instanceof Error ? error.message : 'BAD_REQUEST' }, 400, options.corsHeaders);
257
+ }
258
+ };
259
+ }
260
+ async function verifyPurchaseWithStore(input, context) {
261
+ const request = assertVerifyPurchaseRequest(input);
262
+ const product = context.catalog.products.find((entry) => entry.id === request.productId);
263
+ if (product === undefined) {
264
+ return assertVerifyPurchaseResponse({
265
+ verified: false,
266
+ alreadyProcessed: false,
267
+ reason: 'UNKNOWN_PRODUCT',
268
+ });
269
+ }
270
+ const platformProductId = product.platformProductIds[request.target];
271
+ if (platformProductId === undefined) {
272
+ return assertVerifyPurchaseResponse({
273
+ verified: false,
274
+ alreadyProcessed: false,
275
+ reason: 'PRODUCT_NOT_AVAILABLE_ON_TARGET',
276
+ });
277
+ }
278
+ const grant = await context.store.recordEntitlementGrant({
279
+ playerId: request.playerId,
280
+ grantId: product.id,
281
+ source: 'purchase',
282
+ idempotencyKey: request.idempotencyKey,
283
+ grantedAt: context.now(),
284
+ grant: product.grant,
285
+ payload: {
286
+ target: request.target,
287
+ productId: request.productId,
288
+ platformProductId,
289
+ platformTransactionId: request.platformTransactionId,
290
+ purchasedAt: request.purchasedAt,
291
+ },
292
+ });
293
+ return assertVerifyPurchaseResponse({
294
+ verified: true,
295
+ ledgerEntryId: grant.ledgerEntryId,
296
+ alreadyProcessed: grant.alreadyProcessed,
297
+ });
298
+ }
299
+ async function claimAdRewardWithStore(input, context) {
300
+ const request = assertClaimAdRewardRequest(input);
301
+ const placement = context.placements.placements.find((entry) => entry.id === request.placementId);
302
+ if (placement === undefined || placement.type !== 'rewarded' || placement.reward === undefined) {
303
+ return assertClaimAdRewardResponse({
304
+ granted: false,
305
+ alreadyProcessed: false,
306
+ reason: placement === undefined ? 'UNKNOWN_PLACEMENT' : 'NOT_REWARDED_PLACEMENT',
307
+ });
308
+ }
309
+ const payload = {
310
+ target: request.target,
311
+ placementId: request.placementId,
312
+ rewardType: placement.reward.type,
313
+ amount: placement.reward.amount,
314
+ completedAt: request.completedAt,
315
+ };
316
+ if (placement.reward.type === 'currency') {
317
+ payload.currency = placement.reward.currency;
318
+ }
319
+ if (request.platformImpressionId !== undefined) {
320
+ payload.platformImpressionId = request.platformImpressionId;
321
+ }
322
+ const result = await context.store.recordEntitlementGrant({
323
+ playerId: request.playerId,
324
+ grantId: request.placementId,
325
+ source: 'ad_reward',
326
+ idempotencyKey: request.idempotencyKey,
327
+ grantedAt: context.now(),
328
+ payload,
329
+ });
330
+ return assertClaimAdRewardResponse({
331
+ granted: true,
332
+ ledgerEntryId: result.ledgerEntryId,
333
+ alreadyProcessed: result.alreadyProcessed,
334
+ });
335
+ }
336
+ async function recordLeaderboardScoreWithStore(input, context) {
337
+ return context.store.recordLeaderboardScore(assertRecordLeaderboardScoreRequest(input), {
338
+ recordedAt: context.now(),
339
+ });
340
+ }
341
+ async function routeGameServicesRequest(endpoint, body, backend) {
342
+ switch (endpoint) {
343
+ case gameServicesBackendEndpoints.verifyPurchase:
344
+ return okResponse(await backend.purchases.verifyPurchase(body));
345
+ case gameServicesBackendEndpoints.claimAdReward:
346
+ return okResponse(await backend.adRewards.claimAdReward(body));
347
+ case gameServicesBackendEndpoints.recordLeaderboardScore:
348
+ return okResponse(await backend.leaderboard.recordScore(body));
349
+ }
350
+ return errorResponse(404, 'UNKNOWN_ENDPOINT');
351
+ }
352
+ function okResponse(body) {
353
+ return {
354
+ status: 200,
355
+ body,
356
+ };
357
+ }
358
+ function errorResponse(status, error) {
359
+ return {
360
+ status,
361
+ body: {
362
+ error,
363
+ },
364
+ };
365
+ }
366
+ function createHealthResponse(version = '0.0.0') {
367
+ return {
368
+ ok: true,
369
+ service: 'game-services',
370
+ version,
371
+ };
372
+ }
373
+ function createEntitlementTransaction(grant) {
374
+ const baseTransaction = {
375
+ ledgerEntryId: createEntitlementLedgerEntryId(grant),
376
+ playerId: grant.playerId,
377
+ grantId: grant.grantId,
378
+ source: grant.source,
379
+ idempotencyKey: grant.idempotencyKey,
380
+ grantedAt: grant.grantedAt,
381
+ payload: grant.payload,
382
+ };
383
+ return assertProductGrantTransaction(grant.grant === undefined ? baseTransaction : { ...baseTransaction, grant: grant.grant });
384
+ }
385
+ function createEntitlementIdempotencyKey(grant) {
386
+ return createCompositeKey([grant.source, grant.playerId, grant.idempotencyKey]);
387
+ }
388
+ function createEntitlementLedgerEntryId(grant) {
389
+ return [
390
+ 'ledger',
391
+ encodeIdSegment(grant.source),
392
+ encodeIdSegment(grant.playerId),
393
+ encodeIdSegment(grant.idempotencyKey),
394
+ ].join('_');
395
+ }
396
+ function createLeaderboardRunKey(request) {
397
+ return createCompositeKey([
398
+ request.target,
399
+ request.leaderboardId,
400
+ request.playerId,
401
+ request.runId,
402
+ ]);
403
+ }
404
+ function createLeaderboardLedgerEntryId(request) {
405
+ return [
406
+ 'leaderboard',
407
+ encodeIdSegment(request.target),
408
+ encodeIdSegment(request.leaderboardId),
409
+ encodeIdSegment(request.playerId),
410
+ encodeIdSegment(request.runId),
411
+ ].join('_');
412
+ }
413
+ function createCompositeKey(parts) {
414
+ return JSON.stringify(parts);
415
+ }
416
+ function encodeIdSegment(value) {
417
+ return `${value.length}:${encodeURIComponent(value)}`;
418
+ }
419
+ function isGameServicesBackendEndpoint(pathname) {
420
+ return Object.values(gameServicesBackendEndpoints).includes(pathname);
421
+ }
422
+ async function readRequestJson(request) {
423
+ const text = await request.text();
424
+ if (text.length === 0) {
425
+ return {};
426
+ }
427
+ return JSON.parse(text);
428
+ }
429
+ function jsonResponse(body, status, corsHeaders) {
430
+ const headers = new Headers({
431
+ 'content-type': 'application/json; charset=utf-8',
432
+ });
433
+ applyCorsHeaders(headers, corsHeaders);
434
+ return new Response(JSON.stringify(body), {
435
+ status,
436
+ headers,
437
+ });
438
+ }
439
+ function textResponse(body, status, corsHeaders) {
440
+ const headers = new Headers();
441
+ applyCorsHeaders(headers, corsHeaders);
442
+ return new Response(body, {
443
+ status,
444
+ headers,
445
+ });
446
+ }
447
+ function emptyResponse(status, corsHeaders) {
448
+ const headers = new Headers();
449
+ applyCorsHeaders(headers, corsHeaders);
450
+ return new Response(null, {
451
+ status,
452
+ headers,
453
+ });
454
+ }
455
+ function withCors(response, corsHeaders) {
456
+ if (corsHeaders === undefined) {
457
+ return response;
458
+ }
459
+ const headers = new Headers(response.headers);
460
+ applyCorsHeaders(headers, corsHeaders);
461
+ return new Response(response.body, {
462
+ status: response.status,
463
+ statusText: response.statusText,
464
+ headers,
465
+ });
466
+ }
467
+ function applyCorsHeaders(headers, corsHeaders) {
468
+ if (corsHeaders === undefined) {
469
+ return;
470
+ }
471
+ for (const [key, value] of Object.entries(corsHeaders)) {
472
+ headers.set(key, value);
473
+ }
474
+ }
@@ -0,0 +1,85 @@
1
+ import type { ProductGrant } from '@mpgd/catalog';
2
+ import type { LeaderboardScoreInput, LogicalAdPlacementId, LogicalProductId, PlatformTarget } from '@mpgd/platform';
3
+ export type GameServicesStoreTarget = Extract<PlatformTarget, 'android' | 'ios' | 'ait'>;
4
+ export type GameServicesLedgerTarget = Extract<PlatformTarget, 'browser' | 'android' | 'ios' | 'ait'>;
5
+ export type PurchaseIdempotencyKey = string;
6
+ export type AdRewardIdempotencyKey = string;
7
+ export type EntitlementIdempotencyKey = PurchaseIdempotencyKey | AdRewardIdempotencyKey;
8
+ export type EntitlementLedgerSource = 'purchase' | 'ad_reward' | 'admin';
9
+ export type EntitlementLedgerPayload = Record<string, string | number | boolean>;
10
+ export interface VerifyPurchaseRequest {
11
+ readonly target: GameServicesStoreTarget;
12
+ readonly playerId: string;
13
+ readonly productId: LogicalProductId;
14
+ readonly platformTransactionId: string;
15
+ readonly idempotencyKey: PurchaseIdempotencyKey;
16
+ readonly purchasedAt: string;
17
+ }
18
+ export interface VerifyPurchaseResponse {
19
+ readonly verified: boolean;
20
+ readonly ledgerEntryId?: string;
21
+ readonly alreadyProcessed: boolean;
22
+ readonly reason?: string;
23
+ }
24
+ export interface ClaimAdRewardRequest {
25
+ readonly target: GameServicesStoreTarget;
26
+ readonly playerId: string;
27
+ readonly placementId: LogicalAdPlacementId;
28
+ readonly platformImpressionId?: string;
29
+ readonly idempotencyKey: AdRewardIdempotencyKey;
30
+ readonly completedAt: string;
31
+ }
32
+ export interface ClaimAdRewardResponse {
33
+ readonly granted: boolean;
34
+ readonly ledgerEntryId?: string;
35
+ readonly alreadyProcessed: boolean;
36
+ readonly reason?: 'UNKNOWN_PLACEMENT' | 'NOT_REWARDED_PLACEMENT';
37
+ }
38
+ export interface RecordLeaderboardScoreRequest extends LeaderboardScoreInput {
39
+ readonly target: GameServicesLedgerTarget;
40
+ readonly playerId: string;
41
+ readonly platformSubmissionId?: string;
42
+ }
43
+ export interface RecordLeaderboardScoreResponse {
44
+ readonly submitted: boolean;
45
+ readonly ledgerEntryId: string;
46
+ readonly alreadyProcessed: boolean;
47
+ readonly rank: number;
48
+ }
49
+ export interface EntitlementLedgerGrant {
50
+ readonly playerId: string;
51
+ readonly grantId: string;
52
+ readonly source: EntitlementLedgerSource;
53
+ readonly idempotencyKey: EntitlementIdempotencyKey;
54
+ readonly grantedAt: string;
55
+ readonly grant?: ProductGrant;
56
+ readonly payload: EntitlementLedgerPayload;
57
+ }
58
+ export interface EntitlementLedgerResult {
59
+ readonly ledgerEntryId: string;
60
+ readonly alreadyProcessed: boolean;
61
+ }
62
+ export interface ProductGrantTransaction {
63
+ readonly ledgerEntryId: string;
64
+ readonly playerId: string;
65
+ readonly grantId: string;
66
+ readonly source: EntitlementLedgerSource;
67
+ readonly idempotencyKey: EntitlementIdempotencyKey;
68
+ readonly grantedAt: string;
69
+ readonly grant?: ProductGrant;
70
+ readonly payload: EntitlementLedgerPayload;
71
+ }
72
+ export interface LeaderboardScoreTransaction extends RecordLeaderboardScoreRequest {
73
+ readonly ledgerEntryId: string;
74
+ readonly recordedAt: string;
75
+ }
76
+ export declare function assertVerifyPurchaseRequest(input: VerifyPurchaseRequest): VerifyPurchaseRequest;
77
+ export declare function assertVerifyPurchaseResponse(input: VerifyPurchaseResponse): VerifyPurchaseResponse;
78
+ export declare function assertClaimAdRewardRequest(input: ClaimAdRewardRequest): ClaimAdRewardRequest;
79
+ export declare function assertClaimAdRewardResponse(input: ClaimAdRewardResponse): ClaimAdRewardResponse;
80
+ export declare function assertRecordLeaderboardScoreRequest(input: RecordLeaderboardScoreRequest): RecordLeaderboardScoreRequest;
81
+ export declare function assertRecordLeaderboardScoreResponse(input: RecordLeaderboardScoreResponse): RecordLeaderboardScoreResponse;
82
+ export declare function assertEntitlementLedgerGrant(input: EntitlementLedgerGrant): EntitlementLedgerGrant;
83
+ export declare function assertEntitlementLedgerResult(input: EntitlementLedgerResult): EntitlementLedgerResult;
84
+ export declare function assertProductGrantTransaction(input: ProductGrantTransaction): ProductGrantTransaction;
85
+ export declare function assertLeaderboardScoreTransaction(input: LeaderboardScoreTransaction): LeaderboardScoreTransaction;
package/dist/types.js ADDED
@@ -0,0 +1,165 @@
1
+ export function assertVerifyPurchaseRequest(input) {
2
+ assertRecord(input, 'VerifyPurchaseRequest');
3
+ assertStoreTarget(input.target);
4
+ assertNonEmptyString(input.playerId, 'playerId');
5
+ assertNonEmptyString(input.productId, 'productId');
6
+ assertNonEmptyString(input.platformTransactionId, 'platformTransactionId');
7
+ assertNonEmptyString(input.idempotencyKey, 'idempotencyKey');
8
+ assertNonEmptyString(input.purchasedAt, 'purchasedAt');
9
+ return input;
10
+ }
11
+ export function assertVerifyPurchaseResponse(input) {
12
+ assertRecord(input, 'VerifyPurchaseResponse');
13
+ assertBoolean(input.verified, 'verified');
14
+ assertOptionalNonEmptyString(input.ledgerEntryId, 'ledgerEntryId');
15
+ assertBoolean(input.alreadyProcessed, 'alreadyProcessed');
16
+ assertOptionalNonEmptyString(input.reason, 'reason');
17
+ return input;
18
+ }
19
+ export function assertClaimAdRewardRequest(input) {
20
+ assertRecord(input, 'ClaimAdRewardRequest');
21
+ assertStoreTarget(input.target);
22
+ assertNonEmptyString(input.playerId, 'playerId');
23
+ assertNonEmptyString(input.placementId, 'placementId');
24
+ assertOptionalNonEmptyString(input.platformImpressionId, 'platformImpressionId');
25
+ assertNonEmptyString(input.idempotencyKey, 'idempotencyKey');
26
+ assertNonEmptyString(input.completedAt, 'completedAt');
27
+ return input;
28
+ }
29
+ export function assertClaimAdRewardResponse(input) {
30
+ assertRecord(input, 'ClaimAdRewardResponse');
31
+ assertBoolean(input.granted, 'granted');
32
+ assertOptionalNonEmptyString(input.ledgerEntryId, 'ledgerEntryId');
33
+ assertBoolean(input.alreadyProcessed, 'alreadyProcessed');
34
+ assertOptionalAdRewardReason(input.reason, 'reason');
35
+ return input;
36
+ }
37
+ export function assertRecordLeaderboardScoreRequest(input) {
38
+ assertRecord(input, 'RecordLeaderboardScoreRequest');
39
+ assertLeaderboardTarget(input.target);
40
+ assertNonEmptyString(input.playerId, 'playerId');
41
+ assertNonEmptyString(input.leaderboardId, 'leaderboardId');
42
+ assertFiniteNumber(input.score, 'score');
43
+ assertNonEmptyString(input.runId, 'runId');
44
+ assertNonEmptyString(input.submittedAt, 'submittedAt');
45
+ assertOptionalNonEmptyString(input.platformSubmissionId, 'platformSubmissionId');
46
+ return input;
47
+ }
48
+ export function assertRecordLeaderboardScoreResponse(input) {
49
+ assertRecord(input, 'RecordLeaderboardScoreResponse');
50
+ assertBoolean(input.submitted, 'submitted');
51
+ assertNonEmptyString(input.ledgerEntryId, 'ledgerEntryId');
52
+ assertBoolean(input.alreadyProcessed, 'alreadyProcessed');
53
+ assertFiniteNumber(input.rank, 'rank');
54
+ return input;
55
+ }
56
+ export function assertEntitlementLedgerGrant(input) {
57
+ assertRecord(input, 'EntitlementLedgerGrant');
58
+ assertNonEmptyString(input.playerId, 'playerId');
59
+ assertNonEmptyString(input.grantId, 'grantId');
60
+ assertLedgerSource(input.source);
61
+ assertNonEmptyString(input.idempotencyKey, 'idempotencyKey');
62
+ assertNonEmptyString(input.grantedAt, 'grantedAt');
63
+ if (input.grant !== undefined) {
64
+ assertProductGrant(input.grant);
65
+ }
66
+ assertPayload(input.payload);
67
+ return input;
68
+ }
69
+ export function assertEntitlementLedgerResult(input) {
70
+ assertRecord(input, 'EntitlementLedgerResult');
71
+ assertNonEmptyString(input.ledgerEntryId, 'ledgerEntryId');
72
+ assertBoolean(input.alreadyProcessed, 'alreadyProcessed');
73
+ return input;
74
+ }
75
+ export function assertProductGrantTransaction(input) {
76
+ assertRecord(input, 'ProductGrantTransaction');
77
+ assertNonEmptyString(input.ledgerEntryId, 'ledgerEntryId');
78
+ assertNonEmptyString(input.playerId, 'playerId');
79
+ assertNonEmptyString(input.grantId, 'grantId');
80
+ assertLedgerSource(input.source);
81
+ assertNonEmptyString(input.idempotencyKey, 'idempotencyKey');
82
+ assertNonEmptyString(input.grantedAt, 'grantedAt');
83
+ if (input.grant !== undefined) {
84
+ assertProductGrant(input.grant);
85
+ }
86
+ assertPayload(input.payload);
87
+ return input;
88
+ }
89
+ export function assertLeaderboardScoreTransaction(input) {
90
+ assertRecordLeaderboardScoreRequest(input);
91
+ assertNonEmptyString(input.ledgerEntryId, 'ledgerEntryId');
92
+ assertNonEmptyString(input.recordedAt, 'recordedAt');
93
+ return input;
94
+ }
95
+ function assertRecord(input, label) {
96
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
97
+ throw new Error(`${label} must be an object.`);
98
+ }
99
+ }
100
+ function assertStoreTarget(input) {
101
+ if (input !== 'android' && input !== 'ios' && input !== 'ait') {
102
+ throw new Error('target must be android, ios, or ait.');
103
+ }
104
+ }
105
+ function assertLeaderboardTarget(input) {
106
+ if (input !== 'browser' && input !== 'android' && input !== 'ios' && input !== 'ait') {
107
+ throw new Error('target must be browser, android, ios, or ait.');
108
+ }
109
+ }
110
+ function assertNonEmptyString(input, label) {
111
+ if (typeof input !== 'string' || input.length === 0) {
112
+ throw new Error(`${label} must be a non-empty string.`);
113
+ }
114
+ }
115
+ function assertOptionalNonEmptyString(input, label) {
116
+ if (input !== undefined) {
117
+ assertNonEmptyString(input, label);
118
+ }
119
+ }
120
+ function assertFiniteNumber(input, label) {
121
+ if (typeof input !== 'number' || !Number.isFinite(input)) {
122
+ throw new Error(`${label} must be a finite number.`);
123
+ }
124
+ }
125
+ function assertBoolean(input, label) {
126
+ if (typeof input !== 'boolean') {
127
+ throw new Error(`${label} must be a boolean.`);
128
+ }
129
+ }
130
+ function assertLedgerSource(input) {
131
+ if (input !== 'purchase' && input !== 'ad_reward' && input !== 'admin') {
132
+ throw new Error('source must be purchase, ad_reward, or admin.');
133
+ }
134
+ }
135
+ function assertProductGrant(input) {
136
+ assertRecord(input, 'grant');
137
+ if (input.type === 'currency') {
138
+ if (input.currency !== 'coin' && input.currency !== 'gem') {
139
+ throw new Error('grant.currency must be coin or gem.');
140
+ }
141
+ assertFiniteNumber(input.amount, 'grant.amount');
142
+ return;
143
+ }
144
+ if (input.type === 'entitlement') {
145
+ assertNonEmptyString(input.entitlement, 'grant.entitlement');
146
+ return;
147
+ }
148
+ throw new Error('grant.type must be currency or entitlement.');
149
+ }
150
+ function assertOptionalAdRewardReason(input, label) {
151
+ if (input !== undefined
152
+ && input !== 'UNKNOWN_PLACEMENT'
153
+ && input !== 'NOT_REWARDED_PLACEMENT') {
154
+ throw new Error(`${label} must be UNKNOWN_PLACEMENT or NOT_REWARDED_PLACEMENT.`);
155
+ }
156
+ }
157
+ function assertPayload(input) {
158
+ assertRecord(input, 'payload');
159
+ for (const [key, value] of Object.entries(input)) {
160
+ const valueType = typeof value;
161
+ if (valueType !== 'string' && valueType !== 'number' && valueType !== 'boolean') {
162
+ throw new Error(`payload.${key} must be a string, number, or boolean.`);
163
+ }
164
+ }
165
+ }
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "@mpgd/game-services",
3
+ "version": "0.1.0",
4
+ "description": "Client, contract, server, store, and test helpers for authoritative mpgd game services.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/imjlk/mpgd-kit.git",
9
+ "directory": "packages/game-services"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/imjlk/mpgd-kit/issues"
13
+ },
14
+ "homepage": "https://github.com/imjlk/mpgd-kit#readme",
15
+ "keywords": [
16
+ "mpgd",
17
+ "phaser",
18
+ "vite",
19
+ "typescript",
20
+ "capacitor",
21
+ "apps-in-toss",
22
+ "game-development",
23
+ "game-distribution"
24
+ ],
25
+ "type": "module",
26
+ "sideEffects": false,
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "default": "./dist/index.js"
31
+ },
32
+ "./client": {
33
+ "types": "./dist/client.d.ts",
34
+ "default": "./dist/client.js"
35
+ },
36
+ "./contract": {
37
+ "types": "./dist/contract.d.ts",
38
+ "default": "./dist/contract.js"
39
+ },
40
+ "./server": {
41
+ "types": "./dist/server.d.ts",
42
+ "default": "./dist/server.js"
43
+ },
44
+ "./types": {
45
+ "types": "./dist/types.d.ts",
46
+ "default": "./dist/types.js"
47
+ }
48
+ },
49
+ "files": [
50
+ "dist"
51
+ ],
52
+ "dependencies": {
53
+ "@orpc/client": "2.0.0-beta.14",
54
+ "@orpc/contract": "2.0.0-beta.14",
55
+ "@orpc/server": "2.0.0-beta.14",
56
+ "@mpgd/analytics": "0.1.0",
57
+ "@mpgd/catalog": "0.1.0",
58
+ "@mpgd/platform": "0.1.0"
59
+ },
60
+ "devDependencies": {
61
+ "ttsc": "0.16.9",
62
+ "typescript": "7.0.1-rc"
63
+ },
64
+ "main": "./dist/index.js",
65
+ "types": "./dist/index.d.ts",
66
+ "publishConfig": {
67
+ "access": "public"
68
+ },
69
+ "scripts": {
70
+ "check": "ttsc --noEmit",
71
+ "lint": "ttsc --noEmit",
72
+ "format": "ttsc format",
73
+ "fix": "ttsc fix",
74
+ "test": "cd ../.. && node tools/run-ttsx.mjs packages/game-services/src/client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/server.test.ts"
75
+ }
76
+ }