@mpgd/cli 0.46.1 → 0.47.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/capacitor-shell-starter.d.ts +57 -1
- package/dist/capacitor-shell-starter.js +311 -34
- package/dist/index.js +23 -5
- package/dist/native-build-info.json +2 -2
- package/dist/native-build-target.js +613 -316
- package/package.json +11 -9
- package/templates/phaser-game/apps/target-ait/src/main.ts +17 -0
- package/templates/phaser-game/apps/target-ait/src/runtime-config.types.ts +2 -0
- package/templates/phaser-game/apps/target-ait/vite.config.ts +27 -0
- package/templates/phaser-game/apps/target-devvit/package.json +2 -1
- package/templates/phaser-game/apps/target-devvit/src/server/index.ts +35 -1
- package/templates/phaser-game/package.json +2 -0
- package/templates/phaser-game/src/main.ts +8 -1
- package/templates/phaser-game/src/platform/buildGateways/ait.ts +9 -0
- package/templates/phaser-game/src/platform/buildGateways/aitSandbox.ts +7 -0
- package/templates/phaser-game/src/platform/buildGateways/capacitorAndroid.ts +51 -2
- package/templates/phaser-game/src/platform/buildGateways/capacitorIos.ts +51 -2
- package/templates/phaser-game/src/platform/buildGateways/microsoftStore.ts +62 -26
- package/templates/phaser-game/src/platform/buildGateways/reddit.ts +36 -1
- package/templates/phaser-game/src/platform/buildGateways/verse8.ts +40 -2
- package/templates/phaser-game/src/platform/commerceAccountBinding.ts +79 -0
- package/templates/phaser-game/src/platform/commerceProducts.ts +69 -0
- package/templates/phaser-game/src/platform/gameServices.ts +22 -2
- package/templates/phaser-game/src/platform/monetizationJournal.ts +286 -0
- package/templates/phaser-game/src/platform/verse8Agent8Client.ts +23 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { IdentitySession, PlatformGateway } from '@mpgd/platform';
|
|
2
|
+
import type { StorePurchaseAccountBinding } from '@mpgd/platform/commerce';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reference account binding of the native store providers. Google Play's
|
|
6
|
+
* obfuscated account id and StoreKit's app account token must be stable,
|
|
7
|
+
* non-identifying, and derived the same way by the game backend
|
|
8
|
+
* (`resolveObfuscatedAccountId` of the Play verifier,
|
|
9
|
+
* `resolveAppAccountToken` of the App Store verifier), which is how the
|
|
10
|
+
* backend ties a store purchase to the player before it grants. This module
|
|
11
|
+
* derives both from a SHA-256 digest of the authenticated player id; a game
|
|
12
|
+
* with its own account scheme replaces these two functions and the backend
|
|
13
|
+
* resolvers together. Nothing here grants anything.
|
|
14
|
+
*/
|
|
15
|
+
export const starterAccountBindingDigestPrefix = 'mpgd-commerce-account:';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The authenticated player id a purchase is bound to. Only an
|
|
19
|
+
* `authenticated` session whose trust is not `local` binds: a guest, a
|
|
20
|
+
* platform-anonymous session (the native shell's default
|
|
21
|
+
* `android-local-player` / `ios-local-player`), or a locally asserted id
|
|
22
|
+
* would give every install the same binding, and the backend binds the
|
|
23
|
+
* purchase to its own authenticated player id anyway.
|
|
24
|
+
*/
|
|
25
|
+
export async function resolveStarterCommercePlayerId(
|
|
26
|
+
gateway: Pick<PlatformGateway, 'identity'>,
|
|
27
|
+
): Promise<string | undefined> {
|
|
28
|
+
let session: IdentitySession | undefined;
|
|
29
|
+
try {
|
|
30
|
+
session = await gateway.identity.getSession?.();
|
|
31
|
+
} catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
if (session?.identityLevel !== 'authenticated' || session.trustLevel === 'local') {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
const playerId = session.playerId?.trim();
|
|
38
|
+
return playerId === undefined || playerId.length === 0 ? undefined : playerId;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Google Play `obfuscatedAccountId`: 64 hex characters, within Play's 64-character limit. */
|
|
42
|
+
export async function resolvePlayBillingAccountBinding(
|
|
43
|
+
gateway: Pick<PlatformGateway, 'identity'>,
|
|
44
|
+
): Promise<StorePurchaseAccountBinding | undefined> {
|
|
45
|
+
const playerId = await resolveStarterCommercePlayerId(gateway);
|
|
46
|
+
if (playerId === undefined) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
return { kind: 'obfuscated-account-id', value: toHex(await digestPlayerId(playerId)) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** StoreKit `appAccountToken`: a non-nil UUID derived from the same digest. */
|
|
53
|
+
export async function resolveStoreKitAppAccountToken(
|
|
54
|
+
gateway: Pick<PlatformGateway, 'identity'>,
|
|
55
|
+
): Promise<string> {
|
|
56
|
+
const playerId = await resolveStarterCommercePlayerId(gateway);
|
|
57
|
+
if (playerId === undefined) {
|
|
58
|
+
throw new Error('A StoreKit purchase requires an authenticated player session.');
|
|
59
|
+
}
|
|
60
|
+
return toUuid(await digestPlayerId(playerId));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function digestPlayerId(playerId: string): Promise<Uint8Array> {
|
|
64
|
+
const bytes = new TextEncoder().encode(`${starterAccountBindingDigestPrefix}${playerId}`);
|
|
65
|
+
return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function toHex(bytes: Uint8Array): string {
|
|
69
|
+
return [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** RFC 4122 layout (version 5 nibble, variant bits) over the first 16 digest bytes. */
|
|
73
|
+
function toUuid(bytes: Uint8Array): string {
|
|
74
|
+
const uuid = bytes.slice(0, 16);
|
|
75
|
+
uuid[6] = ((uuid[6] ?? 0) & 0x0f) | 0x50;
|
|
76
|
+
uuid[8] = ((uuid[8] ?? 0) & 0x3f) | 0x80;
|
|
77
|
+
const hex = toHex(uuid);
|
|
78
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
79
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveProductPlatformId,
|
|
3
|
+
type ProductCatalog,
|
|
4
|
+
type ProductCatalogEntry,
|
|
5
|
+
} from '@mpgd/catalog';
|
|
6
|
+
import type { CommerceReconcilerProduct } from '@mpgd/game-services';
|
|
7
|
+
import type { PurchaseKind } from '@mpgd/platform/commerce';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One catalog product mapped to the store SKU of a target, in the shape the
|
|
11
|
+
* Capacitor store providers take (`StoreCommerceProductMapping`): the catalog
|
|
12
|
+
* `platformProductIds[target]` entry is the single source of every SKU.
|
|
13
|
+
*/
|
|
14
|
+
export interface StarterStoreProductMapping {
|
|
15
|
+
readonly logicalId: ProductCatalogEntry['id'];
|
|
16
|
+
readonly storeSku: string;
|
|
17
|
+
readonly kind: PurchaseKind;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Commerce v2 purchase kind of a catalog product type. */
|
|
21
|
+
export function purchaseKindOf(type: ProductCatalogEntry['type']): PurchaseKind {
|
|
22
|
+
switch (type) {
|
|
23
|
+
case 'consumable':
|
|
24
|
+
return 'consumable';
|
|
25
|
+
case 'non_consumable':
|
|
26
|
+
return 'durable';
|
|
27
|
+
case 'subscription':
|
|
28
|
+
return 'subscription';
|
|
29
|
+
default:
|
|
30
|
+
throw new TypeError(`Unknown catalog product type: ${String(type)}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The products sold on `target`: every catalog entry that names a platform
|
|
36
|
+
* product id for it. A product without one is not sold on that target, so a
|
|
37
|
+
* store provider never offers it.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveStarterStoreProducts(
|
|
40
|
+
catalog: ProductCatalog,
|
|
41
|
+
target: string,
|
|
42
|
+
): readonly StarterStoreProductMapping[] {
|
|
43
|
+
return catalog.products.flatMap((product) => {
|
|
44
|
+
const storeSku = resolveProductPlatformId(product, target);
|
|
45
|
+
return storeSku === undefined
|
|
46
|
+
? []
|
|
47
|
+
: [{ logicalId: product.id, storeSku, kind: purchaseKindOf(product.type) }];
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The catalog kinds and SKUs the commerce v2 reconciler resolves without a
|
|
53
|
+
* store read (`createGameServicesRuntime({ commerceProducts })`). Every
|
|
54
|
+
* product is listed so a purchase for an unsold product fails on the store
|
|
55
|
+
* answer, not on a missing kind.
|
|
56
|
+
*/
|
|
57
|
+
export function resolveStarterCommerceProducts(
|
|
58
|
+
catalog: ProductCatalog,
|
|
59
|
+
target: string,
|
|
60
|
+
): readonly CommerceReconcilerProduct[] {
|
|
61
|
+
return catalog.products.map((product) => {
|
|
62
|
+
const storeSku = resolveProductPlatformId(product, target);
|
|
63
|
+
return {
|
|
64
|
+
logicalId: product.id,
|
|
65
|
+
kind: purchaseKindOf(product.type),
|
|
66
|
+
...(storeSku === undefined ? {} : { storeSku }),
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { AnalyticsSink } from '@mpgd/analytics';
|
|
2
|
+
import type { ProductCatalog } from '@mpgd/catalog';
|
|
3
|
+
import productCatalogJson from '@mpgd/catalog/catalog.json';
|
|
2
4
|
import {
|
|
3
5
|
createGameServicesRuntime,
|
|
4
6
|
resolveGameServicesAuthorityMode,
|
|
@@ -11,7 +13,16 @@ import type { PlatformGateway } from '@mpgd/platform';
|
|
|
11
13
|
import type { GameActionReconciliationPort } from '@mpgd/game-runtime/actions';
|
|
12
14
|
import type { PurchasePresentationEvent } from '@mpgd/game-runtime/ads';
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
import { resolveStarterCommerceProducts } from './commerceProducts';
|
|
17
|
+
import { createStorageMonetizationOperationStore } from './monetizationJournal';
|
|
18
|
+
|
|
19
|
+
const productCatalog = productCatalogJson as ProductCatalog;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Game-owned monetization ports. `operationStore` replaces the reference
|
|
23
|
+
* journal over platform storage with an encrypted, access-controlled one;
|
|
24
|
+
* `reconciliation` and `purchasePresentation` supply trusted native facts.
|
|
25
|
+
*/
|
|
15
26
|
export interface StarterMonetizationPorts {
|
|
16
27
|
readonly operationStore: MonetizationOperationStore;
|
|
17
28
|
readonly reconciliation?: GameActionReconciliationPort;
|
|
@@ -21,6 +32,13 @@ export interface StarterMonetizationPorts {
|
|
|
21
32
|
export type StarterBackendMode = GameServicesRuntimeMode;
|
|
22
33
|
export type StarterGameServices = GameServicesRuntime;
|
|
23
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Game-services runtime over the commerce v2 reconciler: every purchase is
|
|
37
|
+
* journaled in a durable `MonetizationOperationStore` (the reference journal
|
|
38
|
+
* over the platform storage adapter unless the game supplies its own) and
|
|
39
|
+
* verified through the backend ledger, with the catalog's kinds and SKUs for
|
|
40
|
+
* the gateway target resolved without a store read.
|
|
41
|
+
*/
|
|
24
42
|
export function createStarterGameServices(input: {
|
|
25
43
|
readonly gateway: PlatformGateway;
|
|
26
44
|
readonly playerId: string;
|
|
@@ -32,7 +50,9 @@ export function createStarterGameServices(input: {
|
|
|
32
50
|
return createGameServicesRuntime({
|
|
33
51
|
gateway: input.gateway,
|
|
34
52
|
playerId: input.playerId,
|
|
35
|
-
|
|
53
|
+
operationStore: input.operationStore
|
|
54
|
+
?? createStorageMonetizationOperationStore({ storage: input.gateway.storage }),
|
|
55
|
+
commerceProducts: resolveStarterCommerceProducts(productCatalog, input.configTarget),
|
|
36
56
|
...(input.analytics === undefined ? {} : { analytics: input.analytics }),
|
|
37
57
|
...(input.analyticsSessionId === undefined
|
|
38
58
|
? {}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isMonetizationOperationGranted,
|
|
3
|
+
isMonetizationOperationRecoverable,
|
|
4
|
+
type MonetizationOperationRecord,
|
|
5
|
+
type MonetizationOperationStore,
|
|
6
|
+
} from '@mpgd/game-services';
|
|
7
|
+
import type { StorageAdapter } from '@mpgd/platform';
|
|
8
|
+
|
|
9
|
+
/** The storage key the journal document lives under. */
|
|
10
|
+
export const defaultMonetizationJournalKey = 'mpgd:monetization-journal';
|
|
11
|
+
/** Terminal records older than this are dropped on the next write (30 days). */
|
|
12
|
+
export const defaultMonetizationJournalRetentionMs = 30 * 24 * 60 * 60 * 1000;
|
|
13
|
+
/**
|
|
14
|
+
* At most this many terminal records without a ledger grant and without a
|
|
15
|
+
* store transaction are kept (the most recently settled): finished rewarded
|
|
16
|
+
* ads and cancelled or failed attempts the store never answered. Platform
|
|
17
|
+
* storage bounds one value (the Devvit bridge allows 256 KiB), so the
|
|
18
|
+
* document must not grow with every one of them. Open records are never
|
|
19
|
+
* dropped; granted purchases (`granted`, `finishing`, `finished` with a
|
|
20
|
+
* ledger entry) and purchases that still name a store transaction leave by
|
|
21
|
+
* age, or by the byte budget (`defaultMonetizationJournalMaxDocumentBytes`)
|
|
22
|
+
* when the document would not fit, so the revocation pass and
|
|
23
|
+
* `findByTransaction` keep them for the window whenever storage allows.
|
|
24
|
+
*/
|
|
25
|
+
export const defaultMonetizationJournalMaxTerminalRecords = 100;
|
|
26
|
+
/**
|
|
27
|
+
* Upper bound of the serialized document (192 KiB, below the Devvit bridge's
|
|
28
|
+
* 256 KiB per value). When the records in the window would exceed it, the
|
|
29
|
+
* oldest terminal records leave first: ungranted ones without a store
|
|
30
|
+
* transaction, then ungranted ones that still name a store transaction, then
|
|
31
|
+
* granted purchases. Open records are never dropped.
|
|
32
|
+
*/
|
|
33
|
+
export const defaultMonetizationJournalMaxDocumentBytes = 192 * 1024;
|
|
34
|
+
|
|
35
|
+
export interface CreateStorageMonetizationOperationStoreInput {
|
|
36
|
+
/** The platform storage adapter (`gateway.storage`); the host owns its durability. */
|
|
37
|
+
readonly storage: StorageAdapter;
|
|
38
|
+
/** Defaults to `mpgd:monetization-journal`. */
|
|
39
|
+
readonly key?: string;
|
|
40
|
+
/** Defaults to 30 days; `Infinity` keeps every terminal record within the count bound. */
|
|
41
|
+
readonly retentionMs?: number;
|
|
42
|
+
/** Defaults to 100 ungranted terminal records; `Infinity` keeps them all within the window. */
|
|
43
|
+
readonly maxTerminalRecords?: number;
|
|
44
|
+
/** Defaults to 192 KiB of serialized JSON; see `defaultMonetizationJournalMaxDocumentBytes`. */
|
|
45
|
+
readonly maxDocumentBytes?: number;
|
|
46
|
+
readonly now?: () => number;
|
|
47
|
+
/**
|
|
48
|
+
* Cross-context lock for the read-modify-write of the document; defaults to
|
|
49
|
+
* the Web Locks API (`navigator.locks`) when the host has it, so two tabs or
|
|
50
|
+
* PWA windows over the same storage cannot lose each other's writes. `null`
|
|
51
|
+
* keeps only the in-context queue.
|
|
52
|
+
*/
|
|
53
|
+
readonly locks?: JournalLockManager | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The part of the Web Locks API (`LockManager`) the journal uses. */
|
|
57
|
+
export interface JournalLockManager {
|
|
58
|
+
request<T>(name: string, callback: (lock: unknown) => Promise<T>): Promise<T>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface JournalDocument {
|
|
62
|
+
readonly version: 1;
|
|
63
|
+
readonly records: Readonly<Record<string, MonetizationOperationRecord>>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The reference durable `MonetizationOperationStore` of the starter: one JSON
|
|
68
|
+
* document in the platform storage adapter, rewritten whole on every change
|
|
69
|
+
* so a record and its index can never disagree, terminal records bounded by
|
|
70
|
+
* age (and, without a ledger grant, by count; open records are always kept),
|
|
71
|
+
* with every operation
|
|
72
|
+
* serialized through one promise chain so the compare-and-swap of `replace`
|
|
73
|
+
* holds inside the WebView. It is crash recovery, not grant authority: the
|
|
74
|
+
* backend ledger still decides every grant. The platform storage is the
|
|
75
|
+
* host's; a game whose host cannot keep that storage private supplies an
|
|
76
|
+
* encrypted, access-controlled implementation through
|
|
77
|
+
* `bootstrapStarter({ monetization: { operationStore } })` instead.
|
|
78
|
+
*
|
|
79
|
+
* A document that cannot be parsed fails every call instead of being
|
|
80
|
+
* replaced: silently starting over would let a purchase be verified twice.
|
|
81
|
+
*/
|
|
82
|
+
export function createStorageMonetizationOperationStore(
|
|
83
|
+
input: CreateStorageMonetizationOperationStoreInput,
|
|
84
|
+
): MonetizationOperationStore {
|
|
85
|
+
const key = input.key ?? defaultMonetizationJournalKey;
|
|
86
|
+
const retentionMs = input.retentionMs ?? defaultMonetizationJournalRetentionMs;
|
|
87
|
+
const maxTerminalRecords = input.maxTerminalRecords
|
|
88
|
+
?? defaultMonetizationJournalMaxTerminalRecords;
|
|
89
|
+
const maxDocumentBytes = input.maxDocumentBytes ?? defaultMonetizationJournalMaxDocumentBytes;
|
|
90
|
+
const now = input.now ?? (() => Date.now());
|
|
91
|
+
const locks = input.locks === undefined ? readHostLockManager() : (input.locks ?? undefined);
|
|
92
|
+
const lockName = `mpgd-monetization-journal:${key}`;
|
|
93
|
+
let chain: Promise<unknown> = Promise.resolve();
|
|
94
|
+
|
|
95
|
+
const serialize = <T>(operation: () => Promise<T>): Promise<T> => {
|
|
96
|
+
const run = locks === undefined ? operation : () => locks.request(lockName, () => operation());
|
|
97
|
+
const next = chain.then(run, run);
|
|
98
|
+
chain = next.catch(() => undefined);
|
|
99
|
+
return next;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const load = async (): Promise<Map<string, MonetizationOperationRecord>> => {
|
|
103
|
+
const loaded = await input.storage.load({ key });
|
|
104
|
+
if (loaded === null || loaded.value === undefined || loaded.value === null) {
|
|
105
|
+
return new Map();
|
|
106
|
+
}
|
|
107
|
+
const document = loaded.value;
|
|
108
|
+
if (!isJournalDocument(document)) {
|
|
109
|
+
throw new Error(`Monetization journal at ${key} is unreadable; it was not replaced.`);
|
|
110
|
+
}
|
|
111
|
+
return new Map(Object.entries(document.records));
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const save = async (records: Map<string, MonetizationOperationRecord>): Promise<void> => {
|
|
115
|
+
const cutoff = now() - retentionMs;
|
|
116
|
+
const terminal: [string, number][] = [];
|
|
117
|
+
const kept: Record<string, MonetizationOperationRecord> = {};
|
|
118
|
+
/** Terminal records the byte budget may drop, with their drop tier (lower leaves first). */
|
|
119
|
+
const droppable: [string, number, number][] = [];
|
|
120
|
+
for (const [recordKey, record] of records) {
|
|
121
|
+
const settledAt = terminalSettledAt(record);
|
|
122
|
+
if (settledAt === undefined) {
|
|
123
|
+
kept[recordKey] = record;
|
|
124
|
+
} else if (settledAt < cutoff) {
|
|
125
|
+
continue;
|
|
126
|
+
} else if (isMonetizationOperationGranted(record)) {
|
|
127
|
+
kept[recordKey] = record;
|
|
128
|
+
droppable.push([recordKey, settledAt, 2]);
|
|
129
|
+
} else if (holdsStoreTransaction(record)) {
|
|
130
|
+
// A rejected or failed purchase the store still lists: the reconciler's
|
|
131
|
+
// findByTransaction needs it, so the count bound does not drop it.
|
|
132
|
+
kept[recordKey] = record;
|
|
133
|
+
droppable.push([recordKey, settledAt, 1]);
|
|
134
|
+
} else {
|
|
135
|
+
terminal.push([recordKey, settledAt]);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Newest first; the oldest terminal records beyond the bound are dropped.
|
|
139
|
+
terminal.sort((left, right) => right[1] - left[1]);
|
|
140
|
+
for (const [recordKey, settledAt] of terminal.slice(0, Math.max(0, maxTerminalRecords))) {
|
|
141
|
+
const record = records.get(recordKey);
|
|
142
|
+
if (record !== undefined) {
|
|
143
|
+
kept[recordKey] = record;
|
|
144
|
+
droppable.push([recordKey, settledAt, 0]);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// The document must fit the host's per-value limit, or every later write
|
|
148
|
+
// fails and monetization stops: the oldest record of the lowest tier leaves
|
|
149
|
+
// until it fits. Open records are never dropped.
|
|
150
|
+
droppable.sort((left, right) => left[2] - right[2] || left[1] - right[1]);
|
|
151
|
+
let size = documentBytes(kept);
|
|
152
|
+
for (const [recordKey] of droppable) {
|
|
153
|
+
if (size <= maxDocumentBytes) {
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
delete kept[recordKey];
|
|
157
|
+
size = documentBytes(kept);
|
|
158
|
+
}
|
|
159
|
+
const document: JournalDocument = { version: 1, records: kept };
|
|
160
|
+
await input.storage.save({ key, value: document });
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
reserve(record) {
|
|
165
|
+
return serialize(async () => {
|
|
166
|
+
const records = await load();
|
|
167
|
+
const existing = records.get(record.key);
|
|
168
|
+
if (existing !== undefined) {
|
|
169
|
+
return { created: false, record: clone(existing) };
|
|
170
|
+
}
|
|
171
|
+
records.set(record.key, clone(record));
|
|
172
|
+
await save(records);
|
|
173
|
+
return { created: true, record: clone(record) };
|
|
174
|
+
});
|
|
175
|
+
},
|
|
176
|
+
read(recordKey) {
|
|
177
|
+
return serialize(async () => {
|
|
178
|
+
const record = (await load()).get(recordKey);
|
|
179
|
+
return record === undefined ? undefined : clone(record);
|
|
180
|
+
});
|
|
181
|
+
},
|
|
182
|
+
replace(expectedRevision, record) {
|
|
183
|
+
return serialize(async () => {
|
|
184
|
+
const records = await load();
|
|
185
|
+
const existing = records.get(record.key);
|
|
186
|
+
if (
|
|
187
|
+
existing === undefined
|
|
188
|
+
|| existing.revision !== expectedRevision
|
|
189
|
+
|| record.revision !== expectedRevision + 1
|
|
190
|
+
|| existing.playerId !== record.playerId
|
|
191
|
+
|| existing.target !== record.target
|
|
192
|
+
|| existing.deploymentTarget !== record.deploymentTarget
|
|
193
|
+
|| existing.kind !== record.kind
|
|
194
|
+
) {
|
|
195
|
+
throw new Error('Stale or conflicting monetization journal write.');
|
|
196
|
+
}
|
|
197
|
+
records.set(record.key, clone(record));
|
|
198
|
+
await save(records);
|
|
199
|
+
});
|
|
200
|
+
},
|
|
201
|
+
listRecoverable(playerId) {
|
|
202
|
+
return serialize(async () =>
|
|
203
|
+
[...(await load()).values()]
|
|
204
|
+
.filter((record) => record.playerId === playerId
|
|
205
|
+
&& isMonetizationOperationRecoverable(record))
|
|
206
|
+
.map(clone));
|
|
207
|
+
},
|
|
208
|
+
listGranted(playerId) {
|
|
209
|
+
return serialize(async () =>
|
|
210
|
+
[...(await load()).values()]
|
|
211
|
+
.filter((record) => record.playerId === playerId
|
|
212
|
+
&& isMonetizationOperationGranted(record))
|
|
213
|
+
.map(clone));
|
|
214
|
+
},
|
|
215
|
+
findByTransaction(lookup) {
|
|
216
|
+
return serialize(async () => {
|
|
217
|
+
const found = [...(await load()).values()].find(
|
|
218
|
+
(record) =>
|
|
219
|
+
record.kind === 'commerce-purchase'
|
|
220
|
+
&& record.playerId === lookup.playerId
|
|
221
|
+
&& record.target === lookup.target
|
|
222
|
+
&& record.deploymentTarget === lookup.deploymentTarget
|
|
223
|
+
&& record.store === lookup.store
|
|
224
|
+
&& record.purchase?.transactionId === lookup.transactionId,
|
|
225
|
+
);
|
|
226
|
+
return found === undefined ? undefined : clone(found);
|
|
227
|
+
});
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* When a terminal record settled, or `undefined` for an open record (never
|
|
234
|
+
* dropped). A terminal record without a readable time sorts as the newest:
|
|
235
|
+
* only the count bound drops it.
|
|
236
|
+
*/
|
|
237
|
+
function terminalSettledAt(record: MonetizationOperationRecord): number | undefined {
|
|
238
|
+
if (isMonetizationOperationRecoverable(record)) {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
const settledAt = record.kind === 'commerce-purchase'
|
|
242
|
+
? record.updatedAt
|
|
243
|
+
: record.platformCompletedAt;
|
|
244
|
+
const time = settledAt === undefined ? Number.NaN : Date.parse(settledAt);
|
|
245
|
+
return Number.isFinite(time) ? time : Number.MAX_SAFE_INTEGER;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** A commerce purchase record that names a store transaction (the store may still list it). */
|
|
249
|
+
function holdsStoreTransaction(record: MonetizationOperationRecord): boolean {
|
|
250
|
+
return record.kind === 'commerce-purchase' && record.purchase?.transactionId !== undefined;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** UTF-8 size of the document the records serialize to. */
|
|
254
|
+
function documentBytes(records: Readonly<Record<string, MonetizationOperationRecord>>): number {
|
|
255
|
+
return new TextEncoder().encode(JSON.stringify({ version: 1, records })).length;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function readHostLockManager(): JournalLockManager | undefined {
|
|
259
|
+
const candidate = (globalThis as { readonly navigator?: { readonly locks?: unknown } })
|
|
260
|
+
.navigator?.locks;
|
|
261
|
+
return typeof candidate === 'object'
|
|
262
|
+
&& candidate !== null
|
|
263
|
+
&& typeof (candidate as { readonly request?: unknown }).request === 'function'
|
|
264
|
+
? candidate as JournalLockManager
|
|
265
|
+
: undefined;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function isJournalDocument(value: unknown): value is JournalDocument {
|
|
269
|
+
if (typeof value !== 'object' || value === null) {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
const document = value as { readonly version?: unknown; readonly records?: unknown };
|
|
273
|
+
return document.version === 1
|
|
274
|
+
&& typeof document.records === 'object'
|
|
275
|
+
&& document.records !== null
|
|
276
|
+
&& !Array.isArray(document.records)
|
|
277
|
+
&& Object.values(document.records).every(
|
|
278
|
+
(record) => typeof record === 'object' && record !== null
|
|
279
|
+
&& typeof (record as { readonly key?: unknown }).key === 'string'
|
|
280
|
+
&& typeof (record as { readonly revision?: unknown }).revision === 'number',
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function clone(record: MonetizationOperationRecord): MonetizationOperationRecord {
|
|
285
|
+
return structuredClone(record);
|
|
286
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Verse8RecordedPurchase } from '@mpgd/adapter-verse8';
|
|
2
|
+
import type { Entitlement } from '@mpgd/platform';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The game-owned Agent8 remote client the Verse8 gateway reads commerce state
|
|
6
|
+
* through: two authenticated server methods exposed for the sender's own
|
|
7
|
+
* account (see docs/VERSE8_COMMERCE.md, "Iframe client boundary"). The
|
|
8
|
+
* starter installs no Agent8 browser SDK, so `resolveStarterVerse8Agent8Client`
|
|
9
|
+
* answers `undefined` until the game provides one; VXShop then stays off and
|
|
10
|
+
* the gateway offers no `commerceV2`, exactly as before. The client only
|
|
11
|
+
* reads: `$onItemPurchased` on the Agent8 server is the sole grant path.
|
|
12
|
+
*/
|
|
13
|
+
export interface StarterVerse8Agent8Client {
|
|
14
|
+
/** Server-owned balances and entitlements (`getSnapshot` of the Agent8 commerce service). */
|
|
15
|
+
getCommerceSnapshot(): Promise<{ readonly entitlements: readonly Entitlement[] }>;
|
|
16
|
+
/** The purchases `$onItemPurchased` recorded (`listPurchases` of the Agent8 commerce service). */
|
|
17
|
+
listPurchases(): Promise<readonly Verse8RecordedPurchase[]>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Return the game's authenticated Agent8 client here to enable VXShop and commerce v2 on Verse8. */
|
|
21
|
+
export function resolveStarterVerse8Agent8Client(): StarterVerse8Agent8Client | undefined {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|