@mpgd/adapter-verse8 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,37 @@
1
+ import type { ProductCatalog } from '@mpgd/catalog';
2
+ import type { Entitlement, LogicalProductId } from '@mpgd/platform';
3
+ export * from './agent8-services.js';
4
+ export interface Verse8Agent8PurchaseEvent {
5
+ readonly account: string;
6
+ readonly purchaseId: number;
7
+ readonly productId: string;
8
+ readonly quantity: number;
9
+ readonly metadata?: unknown;
10
+ }
11
+ export interface Verse8Agent8Context {
12
+ getUserState(account: string): Promise<Readonly<Record<string, unknown>>>;
13
+ updateUserState(account: string, state: Readonly<Record<string, unknown>>): Promise<Readonly<Record<string, unknown>>>;
14
+ lock<T>(key: string, callback: () => T | Promise<T>): Promise<T>;
15
+ }
16
+ export interface Verse8Agent8CommerceOptions {
17
+ readonly catalog: ProductCatalog;
18
+ readonly stateNamespace?: string;
19
+ readonly now?: () => string;
20
+ }
21
+ export interface Verse8Agent8PurchaseResult {
22
+ readonly success: true;
23
+ readonly alreadyProcessed: boolean;
24
+ readonly purchaseId: string;
25
+ readonly logicalProductId: LogicalProductId;
26
+ readonly entitlementIds: readonly string[];
27
+ }
28
+ export interface Verse8Agent8CommerceSnapshot {
29
+ readonly balances: Readonly<Record<'coin' | 'gem', number>>;
30
+ readonly entitlements: readonly Entitlement[];
31
+ }
32
+ export interface Verse8Agent8CommerceService {
33
+ handleItemPurchased(event: Verse8Agent8PurchaseEvent, context: Verse8Agent8Context): Promise<Verse8Agent8PurchaseResult>;
34
+ getEntitlements(account: string, context: Pick<Verse8Agent8Context, 'getUserState'>): Promise<readonly Entitlement[]>;
35
+ getSnapshot(account: string, context: Pick<Verse8Agent8Context, 'getUserState'>): Promise<Verse8Agent8CommerceSnapshot>;
36
+ }
37
+ export declare function createVerse8Agent8CommerceService(options: Verse8Agent8CommerceOptions): Verse8Agent8CommerceService;
package/dist/agent8.js ADDED
@@ -0,0 +1,276 @@
1
+ export * from './agent8-services.js';
2
+ const defaultStateNamespace = 'mpgdVerse8Commerce';
3
+ export function createVerse8Agent8CommerceService(options) {
4
+ const namespace = normalizeNamespace(options.stateNamespace ?? defaultStateNamespace);
5
+ const now = options.now ?? (() => new Date().toISOString());
6
+ const products = createPlatformProductMap(options.catalog);
7
+ return {
8
+ async handleItemPurchased(event, context) {
9
+ const normalized = assertPurchaseEvent(event);
10
+ const product = products.get(normalized.productId);
11
+ const purchaseId = String(normalized.purchaseId);
12
+ const lockAccount = normalizeLockAccount(normalized.account);
13
+ return context.lock(`mpgd:vxshop:${lockAccount}`, async () => {
14
+ const userState = await context.getUserState(normalized.account);
15
+ const current = readCommerceState(userState[namespace]);
16
+ const existing = current.purchasesById[purchaseId];
17
+ if (existing !== undefined) {
18
+ assertSamePurchase(existing, normalized, product?.id);
19
+ return {
20
+ success: true,
21
+ alreadyProcessed: true,
22
+ purchaseId,
23
+ logicalProductId: existing.logicalProductId,
24
+ entitlementIds: existing.entitlementIds,
25
+ };
26
+ }
27
+ if (product === undefined) {
28
+ throw new Error('Unknown Verse8 VXShop product.');
29
+ }
30
+ const grantedAt = now();
31
+ assertNonEmptyString(grantedAt, 'now() result');
32
+ const applied = applyGrant(current, product.grant, normalized.quantity, grantedAt);
33
+ const purchase = {
34
+ purchaseId,
35
+ platformProductId: normalized.productId,
36
+ logicalProductId: product.id,
37
+ quantity: normalized.quantity,
38
+ grantedAt,
39
+ entitlementIds: applied.entitlementIds,
40
+ };
41
+ const next = {
42
+ ...applied.state,
43
+ purchasesById: {
44
+ ...applied.state.purchasesById,
45
+ [purchaseId]: purchase,
46
+ },
47
+ };
48
+ // The grant and its consume-once marker share one Agent8 user-state
49
+ // update so a retry cannot observe one without the other.
50
+ await context.updateUserState(normalized.account, { [namespace]: next });
51
+ return {
52
+ success: true,
53
+ alreadyProcessed: false,
54
+ purchaseId,
55
+ logicalProductId: product.id,
56
+ entitlementIds: applied.entitlementIds,
57
+ };
58
+ });
59
+ },
60
+ async getEntitlements(account, context) {
61
+ return (await readSnapshot(account, context, namespace)).entitlements;
62
+ },
63
+ async getSnapshot(account, context) {
64
+ return readSnapshot(account, context, namespace);
65
+ },
66
+ };
67
+ }
68
+ async function readSnapshot(account, context, namespace) {
69
+ assertNonEmptyString(account, 'account');
70
+ const userState = await context.getUserState(account);
71
+ const state = readCommerceState(userState[namespace]);
72
+ return {
73
+ balances: { ...state.balances },
74
+ entitlements: [...state.entitlements],
75
+ };
76
+ }
77
+ function createPlatformProductMap(catalog) {
78
+ const products = new Map();
79
+ for (const product of catalog.products) {
80
+ const platformProductId = product.platformProductIds.verse8?.trim();
81
+ if (platformProductId === undefined || platformProductId.length === 0) {
82
+ continue;
83
+ }
84
+ if (products.has(platformProductId)) {
85
+ throw new Error(`Duplicate Verse8 platform product ID: ${platformProductId}`);
86
+ }
87
+ products.set(platformProductId, product);
88
+ }
89
+ return products;
90
+ }
91
+ function assertPurchaseEvent(event) {
92
+ assertNonEmptyString(event.account, 'account');
93
+ assertNonEmptyString(event.productId, 'productId');
94
+ if (!Number.isSafeInteger(event.purchaseId) || event.purchaseId < 0) {
95
+ throw new Error('purchaseId must be a non-negative safe integer.');
96
+ }
97
+ if (!Number.isSafeInteger(event.quantity) || event.quantity <= 0) {
98
+ throw new Error('quantity must be a positive safe integer.');
99
+ }
100
+ return event;
101
+ }
102
+ function assertSamePurchase(existing, event, logicalProductId) {
103
+ if (existing.platformProductId !== event.productId
104
+ || existing.quantity !== event.quantity
105
+ || (logicalProductId !== undefined
106
+ && existing.logicalProductId !== logicalProductId)) {
107
+ throw new Error('Verse8 purchase ID was reused with different purchase data.');
108
+ }
109
+ }
110
+ function applyGrant(state, grant, quantity, grantedAt) {
111
+ if (grant.type === 'currency') {
112
+ const amount = grant.amount * quantity;
113
+ if (!Number.isSafeInteger(amount) || amount <= 0) {
114
+ throw new Error('Verse8 currency grant exceeds the safe integer range.');
115
+ }
116
+ const balance = state.balances[grant.currency] + amount;
117
+ if (!Number.isSafeInteger(balance)) {
118
+ throw new Error('Verse8 currency balance exceeds the safe integer range.');
119
+ }
120
+ return {
121
+ state: {
122
+ ...state,
123
+ balances: {
124
+ ...state.balances,
125
+ [grant.currency]: balance,
126
+ },
127
+ },
128
+ entitlementIds: [],
129
+ };
130
+ }
131
+ if (grant.type === 'resource') {
132
+ throw new Error('Verse8 resource grants are not supported.');
133
+ }
134
+ if (quantity !== 1) {
135
+ throw new Error('Verse8 entitlement purchases must have quantity 1.');
136
+ }
137
+ const existing = state.entitlements.find((entitlement) => entitlement.id === grant.entitlement);
138
+ const entitlements = existing === undefined
139
+ ? [
140
+ ...state.entitlements,
141
+ {
142
+ id: grant.entitlement,
143
+ source: 'purchase',
144
+ grantedAt,
145
+ },
146
+ ]
147
+ : state.entitlements;
148
+ return {
149
+ state: {
150
+ ...state,
151
+ entitlements,
152
+ },
153
+ entitlementIds: [grant.entitlement],
154
+ };
155
+ }
156
+ function readCommerceState(value) {
157
+ if (value === undefined) {
158
+ return emptyCommerceState();
159
+ }
160
+ if (!isRecord(value) || value.version !== 1) {
161
+ throw invalidStoredState();
162
+ }
163
+ return {
164
+ version: 1,
165
+ balances: readBalances(value.balances),
166
+ entitlements: readEntitlements(value.entitlements),
167
+ purchasesById: readPurchaseIndex(value.purchasesById),
168
+ };
169
+ }
170
+ function emptyCommerceState() {
171
+ return {
172
+ version: 1,
173
+ balances: { coin: 0, gem: 0 },
174
+ entitlements: [],
175
+ purchasesById: {},
176
+ };
177
+ }
178
+ function readBalances(value) {
179
+ if (!isRecord(value)) {
180
+ throw invalidStoredState();
181
+ }
182
+ return {
183
+ coin: assertSafeNonNegativeInteger(value.coin),
184
+ gem: assertSafeNonNegativeInteger(value.gem),
185
+ };
186
+ }
187
+ function readEntitlements(value) {
188
+ if (!Array.isArray(value)) {
189
+ throw invalidStoredState();
190
+ }
191
+ const entitlementIds = new Set();
192
+ return value.map((entry) => {
193
+ if (!isRecord(entry)
194
+ || typeof entry.id !== 'string'
195
+ || entry.id.length === 0
196
+ || entry.source !== 'purchase'
197
+ || typeof entry.grantedAt !== 'string'
198
+ || entry.grantedAt.length === 0
199
+ || entitlementIds.has(entry.id)) {
200
+ throw invalidStoredState();
201
+ }
202
+ entitlementIds.add(entry.id);
203
+ return {
204
+ id: entry.id,
205
+ source: 'purchase',
206
+ grantedAt: entry.grantedAt,
207
+ };
208
+ });
209
+ }
210
+ function readPurchaseIndex(value) {
211
+ if (!isRecord(value)) {
212
+ throw invalidStoredState();
213
+ }
214
+ const purchasesById = {};
215
+ for (const [purchaseId, entry] of Object.entries(value)) {
216
+ if (!isCanonicalPurchaseId(purchaseId)
217
+ || !isRecord(entry)
218
+ || entry.purchaseId !== purchaseId
219
+ || typeof entry.platformProductId !== 'string'
220
+ || entry.platformProductId.length === 0
221
+ || typeof entry.logicalProductId !== 'string'
222
+ || entry.logicalProductId.length === 0
223
+ || !Number.isSafeInteger(entry.quantity)
224
+ || entry.quantity <= 0
225
+ || typeof entry.grantedAt !== 'string'
226
+ || entry.grantedAt.length === 0
227
+ || !Array.isArray(entry.entitlementIds)
228
+ || !entry.entitlementIds.every((id) => typeof id === 'string' && id.length > 0)) {
229
+ throw invalidStoredState();
230
+ }
231
+ purchasesById[purchaseId] = {
232
+ purchaseId,
233
+ platformProductId: entry.platformProductId,
234
+ logicalProductId: entry.logicalProductId,
235
+ quantity: entry.quantity,
236
+ grantedAt: entry.grantedAt,
237
+ entitlementIds: entry.entitlementIds,
238
+ };
239
+ }
240
+ return purchasesById;
241
+ }
242
+ function isCanonicalPurchaseId(value) {
243
+ const parsed = Number(value);
244
+ return Number.isSafeInteger(parsed) && parsed >= 0 && String(parsed) === value;
245
+ }
246
+ function assertSafeNonNegativeInteger(value) {
247
+ if (!Number.isSafeInteger(value) || value < 0) {
248
+ throw invalidStoredState();
249
+ }
250
+ return value;
251
+ }
252
+ function normalizeNamespace(value) {
253
+ const normalized = value.trim();
254
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/.test(normalized)) {
255
+ throw new Error('stateNamespace must be a safe non-empty state key.');
256
+ }
257
+ return normalized;
258
+ }
259
+ function normalizeLockAccount(value) {
260
+ const normalized = value.trim();
261
+ if (normalized !== value || normalized.length > 256) {
262
+ throw new Error('account must be a canonical bounded string.');
263
+ }
264
+ return encodeURIComponent(normalized);
265
+ }
266
+ function invalidStoredState() {
267
+ return new Error('Stored Verse8 commerce state is invalid.');
268
+ }
269
+ function assertNonEmptyString(value, name) {
270
+ if (typeof value !== 'string' || value.trim().length === 0) {
271
+ throw new Error(`${name} must be a non-empty string.`);
272
+ }
273
+ }
274
+ function isRecord(value) {
275
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
276
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,7 @@
1
- import { type PlatformGateway } from '@mpgd/platform';
1
+ import { type InterstitialAdResult as Verse8InterstitialAdResult, type RewardedAdResult as Verse8RewardedAdResult } from '@verse8/ads';
2
+ import type { ProductCatalog } from '@mpgd/catalog';
3
+ import { type Entitlement, type LogicalAdPlacementId, type LogicalProductId, type PlatformGateway, type ProductType, type StorageAdapter } from '@mpgd/platform';
4
+ export { verse8AdsRewardEvidenceSchema } from './ads-contract.js';
2
5
  export interface Verse8Credential {
3
6
  readonly account: `0x${string}`;
4
7
  readonly verse: string;
@@ -13,13 +16,77 @@ export interface Verse8Storage {
13
16
  getItem(key: string): string | null;
14
17
  setItem(key: string, value: string): void;
15
18
  }
19
+ /**
20
+ * Game-owned Agent8 RPC wrapper. The adapter deliberately does not import the
21
+ * React-based Agent8 browser SDK.
22
+ */
23
+ export interface Verse8Agent8StorageClient extends StorageAdapter {
24
+ }
16
25
  export interface Verse8VisibilitySource {
17
26
  readonly hidden: boolean;
18
27
  addEventListener(type: 'visibilitychange', callback: () => void): void;
19
28
  removeEventListener(type: 'visibilitychange', callback: () => void): void;
20
29
  }
30
+ export interface Verse8AdsClient {
31
+ showRewarded(input: {
32
+ readonly placementId: string;
33
+ readonly timeoutMs?: number;
34
+ readonly meta?: Record<string, unknown>;
35
+ }): Promise<Verse8RewardedAdResult>;
36
+ showInterstitial(input: {
37
+ readonly placementId: string;
38
+ readonly timeoutMs?: number;
39
+ readonly meta?: Record<string, unknown>;
40
+ }): Promise<Verse8InterstitialAdResult>;
41
+ }
42
+ export interface Verse8VXShopItem {
43
+ readonly productId: string;
44
+ readonly name: string;
45
+ readonly description: string;
46
+ readonly price: number;
47
+ readonly purchasable: boolean;
48
+ readonly purchaseLimitReached: boolean;
49
+ }
50
+ export interface Verse8VXShopClient {
51
+ init(options?: {
52
+ readonly verseId?: string;
53
+ readonly account?: string;
54
+ readonly autoRefresh?: boolean;
55
+ }): void;
56
+ getItem(productId: string): Verse8VXShopItem | undefined;
57
+ getItems(): readonly Verse8VXShopItem[];
58
+ buyItem(productId: string): void;
59
+ refresh(): Promise<void>;
60
+ }
61
+ export interface Verse8CommerceProduct {
62
+ readonly id: LogicalProductId;
63
+ readonly type: ProductType;
64
+ readonly platformProductId: string;
65
+ }
66
+ export interface Verse8CommerceOptions {
67
+ /**
68
+ * Explicitly acknowledges that grants are handled only by Verse8's reserved
69
+ * Agent8 `$onItemPurchased` server event, never by the client dialog callback.
70
+ */
71
+ readonly purchaseEventAuthority: 'agent8-server';
72
+ readonly products: readonly Verse8CommerceProduct[];
73
+ readonly loadEntitlements: () => Promise<readonly Entitlement[]>;
74
+ readonly client?: Verse8VXShopClient;
75
+ readonly init?: {
76
+ readonly verseId?: string;
77
+ readonly account?: string;
78
+ readonly autoRefresh?: boolean;
79
+ };
80
+ readonly canOpenShop?: () => boolean;
81
+ }
82
+ export declare function createVerse8CommerceProducts(catalog: ProductCatalog): readonly Verse8CommerceProduct[];
21
83
  export interface Verse8PlatformGatewayOptions {
22
84
  readonly authClient?: Verse8AuthClient;
85
+ readonly adsClient?: Verse8AdsClient;
86
+ readonly adsTimeoutMs?: number;
87
+ readonly resolveAdPlacementId?: (placementId: LogicalAdPlacementId) => string | undefined;
88
+ readonly vxShop?: Verse8CommerceOptions;
89
+ readonly agent8Storage?: Verse8Agent8StorageClient;
23
90
  readonly storage?: Verse8Storage;
24
91
  readonly visibility?: Verse8VisibilitySource;
25
92
  }