@mpgd/cli 0.64.0 → 0.66.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/native-build-info.json +2 -2
- package/dist/native-build-target.js +104 -82
- package/dist/production-target-readiness.js +9 -2
- package/package.json +20 -20
- package/templates/lite-overlay/README.md +4 -0
- package/templates/lite-overlay/apps/target-devvit/README.md +10 -0
- package/templates/notifications-overlay/common/src/platform/notificationBackend.ts +5 -1
- package/templates/phaser-game/README.md +32 -3
- package/templates/phaser-game/apps/target-ait/package.json +2 -2
- package/templates/phaser-game/apps/target-cloudflare-pages/package.json +2 -2
- package/templates/phaser-game/apps/target-devvit/README.md +10 -0
- package/templates/phaser-game/apps/target-devvit/package.json +2 -2
- package/templates/phaser-game/package.json +2 -2
- package/templates/phaser-game/src/i18n/messages.ts +3 -0
- package/templates/phaser-game/src/main.ts +42 -0
- package/templates/phaser-game/src/platform/buildGateways/capacitorAndroid.ts +5 -0
- package/templates/phaser-game/src/platform/buildGateways/capacitorIos.ts +5 -0
- package/templates/phaser-game/src/platform/commerceAccountBinding.ts +81 -1
- package/templates/phaser-game/src/platform/deleteAccount.ts +6 -1
- package/templates/phaser-game/src/platform/gameServices.ts +94 -7
- package/templates/phaser-game/src/platform/guestIdentity.ts +4 -3
- package/templates/phaser-game/src/platform/monetizationJournal.ts +127 -5
- package/templates/phaser-game/src/platform/sessionGatedServices.ts +149 -21
- package/templates/phaser-game/src/styles.css +12 -0
|
@@ -4,6 +4,7 @@ import productCatalogJson from '@mpgd/catalog/catalog.json';
|
|
|
4
4
|
import {
|
|
5
5
|
createGameServicesRuntime,
|
|
6
6
|
resolveGameServicesAuthorityMode,
|
|
7
|
+
resolveGameServicesBaseUrl,
|
|
7
8
|
resolveGameServicesTransport,
|
|
8
9
|
type GameServicesRuntime,
|
|
9
10
|
type GameServicesRuntimeMode,
|
|
@@ -13,11 +14,17 @@ import type { PlatformGateway } from '@mpgd/platform';
|
|
|
13
14
|
import type { GameActionReconciliationPort } from '@mpgd/game-runtime/actions';
|
|
14
15
|
import type { PurchasePresentationEvent } from '@mpgd/game-runtime/ads';
|
|
15
16
|
|
|
17
|
+
import {
|
|
18
|
+
pinStarterCommercePlayer,
|
|
19
|
+
resolveStarterStoreAccountBindingFor,
|
|
20
|
+
starterStoreAccountBindingKind,
|
|
21
|
+
} from './commerceAccountBinding';
|
|
16
22
|
import { resolveStarterCommerceProducts } from './commerceProducts';
|
|
17
23
|
import type { StarterGuestSignIn } from './guestIdentity';
|
|
18
24
|
import { createStorageMonetizationOperationStore } from './monetizationJournal';
|
|
19
25
|
import { resolveStarterServerSession } from './serverSession';
|
|
20
26
|
import {
|
|
27
|
+
bindStarterServicesToPlayer,
|
|
21
28
|
createSessionGatedGameServices,
|
|
22
29
|
type StarterSessionGatedServices,
|
|
23
30
|
} from './sessionGatedServices';
|
|
@@ -52,6 +59,20 @@ interface StarterGameServicesInput {
|
|
|
52
59
|
readonly operationStore?: MonetizationOperationStore;
|
|
53
60
|
readonly analytics?: AnalyticsSink;
|
|
54
61
|
readonly analyticsSessionId?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Called at once when the server session names another player than the
|
|
64
|
+
* services' (an account switch within the run): the services refuse
|
|
65
|
+
* everything from now on; block input or show a switching screen
|
|
66
|
+
* (`main.ts` does both). `onPlayerChanged` follows.
|
|
67
|
+
*/
|
|
68
|
+
readonly onPlayerChanging?: (playerId: string) => void;
|
|
69
|
+
/**
|
|
70
|
+
* Called once after `onPlayerChanging`, when the closed services have
|
|
71
|
+
* stopped (an open store sheet first journals its answer, up to
|
|
72
|
+
* `gameServicesDisposeWaitMs`): restart the game so it boots for that
|
|
73
|
+
* player (`main.ts` reloads).
|
|
74
|
+
*/
|
|
75
|
+
readonly onPlayerChanged?: (playerId: string) => void;
|
|
55
76
|
}
|
|
56
77
|
|
|
57
78
|
/**
|
|
@@ -60,9 +81,42 @@ interface StarterGameServicesInput {
|
|
|
60
81
|
* over the platform storage adapter unless the game supplies its own) and
|
|
61
82
|
* verified through the backend ledger, with the catalog's kinds and SKUs for
|
|
62
83
|
* the gateway target resolved without a store read.
|
|
84
|
+
*
|
|
85
|
+
* With a server session (`setStarterServerSession`), the services belong to
|
|
86
|
+
* `playerId` for their whole life and fail closed for anyone else: while the
|
|
87
|
+
* session names no player, purchases and backend requests are refused
|
|
88
|
+
* (retryable); once it names another player (an account switch), the
|
|
89
|
+
* services close for good before that player's headers or store account
|
|
90
|
+
* binding reach this player's journal (purchases answer `failed` with the
|
|
91
|
+
* retryable diagnostic `GAME_SERVICES_PLAYER_CHANGED`), `onPlayerChanging`
|
|
92
|
+
* tells the host at once, and `onPlayerChanged` asks it to boot again for
|
|
93
|
+
* the new player once they have stopped. Pass the
|
|
94
|
+
* session's player as `playerId`, never the platform's local placeholder:
|
|
95
|
+
* services created for another player than the session's stay closed.
|
|
63
96
|
*/
|
|
64
97
|
export function createStarterGameServices(input: StarterGameServicesInput): StarterGameServices {
|
|
65
|
-
|
|
98
|
+
const session = resolveStarterServerSession({ once: true });
|
|
99
|
+
if (session === undefined) {
|
|
100
|
+
return createRuntime(input, session);
|
|
101
|
+
}
|
|
102
|
+
// The store account binding follows the same player as the journal, until
|
|
103
|
+
// these services were disposed; throws while another player's are open.
|
|
104
|
+
const releasePin = pinStarterCommercePlayer(input.playerId);
|
|
105
|
+
let services: GameServicesRuntime;
|
|
106
|
+
try {
|
|
107
|
+
services = createRuntime(input, session);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
releasePin();
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
return bindStarterServicesToPlayer({
|
|
113
|
+
services,
|
|
114
|
+
playerId: input.playerId,
|
|
115
|
+
session,
|
|
116
|
+
onDisposed: releasePin,
|
|
117
|
+
...(input.onPlayerChanging === undefined ? {} : { onPlayerChanging: input.onPlayerChanging }),
|
|
118
|
+
...(input.onPlayerChanged === undefined ? {} : { onPlayerChanged: input.onPlayerChanged }),
|
|
119
|
+
});
|
|
66
120
|
}
|
|
67
121
|
|
|
68
122
|
/**
|
|
@@ -79,14 +133,30 @@ export function createStarterSessionGatedGameServices(
|
|
|
79
133
|
readonly onActivated?: () => void;
|
|
80
134
|
},
|
|
81
135
|
): StarterGameServices {
|
|
82
|
-
const { signIn, onActivated, ...rest } = input;
|
|
136
|
+
const { signIn, onActivated, onPlayerChanging, onPlayerChanged, ...rest } = input;
|
|
137
|
+
// The inner services (a session switch) and the gate (a later sign-in) may
|
|
138
|
+
// both see the same change; the host hears each step once.
|
|
139
|
+
const once = (callback: (playerId: string) => void): ((playerId: string) => void) => {
|
|
140
|
+
let called = false;
|
|
141
|
+
return (playerId) => {
|
|
142
|
+
if (!called) {
|
|
143
|
+
called = true;
|
|
144
|
+
callback(playerId);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
const hooks = {
|
|
149
|
+
...(onPlayerChanging === undefined ? {} : { onPlayerChanging: once(onPlayerChanging) }),
|
|
150
|
+
...(onPlayerChanged === undefined ? {} : { onPlayerChanged: once(onPlayerChanged) }),
|
|
151
|
+
};
|
|
83
152
|
return createSessionGatedGameServices({
|
|
84
153
|
// Configuration only (mode, target, whether a client exists); never sends a request.
|
|
85
154
|
probe: createRuntime({ ...rest, playerId: 'session-pending' }, undefined),
|
|
86
155
|
ensurePlayer: async () => (await signIn.ensure())?.playerId,
|
|
87
156
|
onPlayer: (listener) => signIn.onSignedIn((signedIn) => listener(signedIn.playerId)),
|
|
88
|
-
create: (playerId) => createStarterGameServices({ ...rest, playerId }),
|
|
157
|
+
create: (playerId) => createStarterGameServices({ ...rest, playerId, ...hooks }),
|
|
89
158
|
...(onActivated === undefined ? {} : { onActivated }),
|
|
159
|
+
...hooks,
|
|
90
160
|
});
|
|
91
161
|
}
|
|
92
162
|
|
|
@@ -94,6 +164,10 @@ function createRuntime(
|
|
|
94
164
|
input: StarterGameServicesInput,
|
|
95
165
|
session: ReturnType<typeof resolveStarterServerSession>,
|
|
96
166
|
): GameServicesRuntime {
|
|
167
|
+
const baseUrl = resolveGameServicesBaseUrl(import.meta.env.VITE_MPGD_GAME_SERVICES_URL, {
|
|
168
|
+
target: input.gateway.target,
|
|
169
|
+
...(globalThis.location?.origin === undefined ? {} : { pageOrigin: globalThis.location.origin }),
|
|
170
|
+
});
|
|
97
171
|
return createGameServicesRuntime({
|
|
98
172
|
gateway: input.gateway,
|
|
99
173
|
playerId: input.playerId,
|
|
@@ -107,10 +181,23 @@ function createRuntime(
|
|
|
107
181
|
authorityMode: resolveGameServicesAuthorityMode(import.meta.env.MODE),
|
|
108
182
|
target: import.meta.env.VITE_MPGD_GAME_SERVICES_TARGET ?? input.gateway.target,
|
|
109
183
|
deploymentTarget: input.configTarget,
|
|
110
|
-
...(
|
|
111
|
-
? {}
|
|
112
|
-
: { baseUrl: import.meta.env.VITE_MPGD_GAME_SERVICES_URL }),
|
|
184
|
+
...(baseUrl === undefined ? {} : { baseUrl }),
|
|
113
185
|
transport: resolveGameServicesTransport(import.meta.env.VITE_MPGD_GAME_SERVICES_TRANSPORT),
|
|
114
|
-
|
|
186
|
+
// A store purchase bound to another player's account is left for that player.
|
|
187
|
+
// Only where the template binds purchases: elsewhere (Microsoft Store) nothing is compared.
|
|
188
|
+
...(starterStoreAccountBindingKind(input.gateway.target) === undefined
|
|
189
|
+
? {}
|
|
190
|
+
: {
|
|
191
|
+
commerceAccountBinding: () =>
|
|
192
|
+
resolveStarterStoreAccountBindingFor(input.gateway.target, input.playerId),
|
|
193
|
+
}),
|
|
194
|
+
...(session === undefined
|
|
195
|
+
? {}
|
|
196
|
+
: {
|
|
197
|
+
getHeaders: () => session.getHeaders(),
|
|
198
|
+
// Requests, the store account binding, and the journal follow this
|
|
199
|
+
// session's player; the services refuse while it is not theirs.
|
|
200
|
+
sessionPlayerId: () => session.current()?.playerId,
|
|
201
|
+
}),
|
|
115
202
|
});
|
|
116
203
|
}
|
|
@@ -59,9 +59,10 @@ export type StarterGuestIdentity =
|
|
|
59
59
|
* place: that session cannot come back, and without a linked account its
|
|
60
60
|
* player cannot either. Any other failure (offline, an outage) leaves the
|
|
61
61
|
* stored token alone for the next attempt (`createStarterGuestSignIn`
|
|
62
|
-
* retries), and until one succeeds purchases are refused
|
|
63
|
-
*
|
|
64
|
-
*
|
|
62
|
+
* retries), and until one succeeds purchases are refused before the store
|
|
63
|
+
* opens with a `failed` result (`ACCOUNT_BINDING_REQUIRED`, store code
|
|
64
|
+
* `PLAY_BILLING_ACCOUNT_LOOKUP_FAILED` or `STOREKIT_ACCOUNT_LOOKUP_FAILED`
|
|
65
|
+
* when the lookup throws). One attempt: the game calls it through
|
|
65
66
|
* `createStarterGuestSignIn`.
|
|
66
67
|
*/
|
|
67
68
|
export async function startStarterGuestIdentity(input: {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
|
+
isCommercePurchaseOperationRecord,
|
|
2
3
|
isMonetizationOperationGranted,
|
|
3
4
|
isMonetizationOperationRecoverable,
|
|
5
|
+
isParkedForAnotherAccount,
|
|
4
6
|
type MonetizationOperationRecord,
|
|
5
7
|
type MonetizationOperationStore,
|
|
6
8
|
} from '@mpgd/game-services';
|
|
@@ -21,17 +23,55 @@ export const defaultMonetizationJournalRetentionMs = 30 * 24 * 60 * 60 * 1000;
|
|
|
21
23
|
* age, or by the byte budget (`defaultMonetizationJournalMaxDocumentBytes`)
|
|
22
24
|
* when the document would not fit, so the revocation pass and
|
|
23
25
|
* `findByTransaction` keep them for the window whenever storage allows.
|
|
26
|
+
* Records parked as another account's purchase are the one kind of open
|
|
27
|
+
* record that leaves (`defaultMonetizationJournalParkedRetentionMs`).
|
|
24
28
|
*/
|
|
25
29
|
export const defaultMonetizationJournalMaxTerminalRecords = 100;
|
|
30
|
+
/**
|
|
31
|
+
* Records parked as another account's purchase (RFC "Another account's
|
|
32
|
+
* purchase") leave this long after their last write (30 days): the parking,
|
|
33
|
+
* or the player's last Restore Purchases resend. They are open records, but
|
|
34
|
+
* nothing of their player is in progress: the backend's answer granted and
|
|
35
|
+
* recorded nothing, and the store still holds the purchase for the account it
|
|
36
|
+
* is bound to, which recovers it under its own key. Dropping one cannot lose
|
|
37
|
+
* a grant: the backend ledger decides every grant, and a later verify of the
|
|
38
|
+
* same purchase finds a grant whose answer was lost. It costs one more verify
|
|
39
|
+
* that parks it again, if the store still lists the purchase and the client
|
|
40
|
+
* cannot tell its binding is another account's.
|
|
41
|
+
*/
|
|
42
|
+
export const defaultMonetizationJournalParkedRetentionMs = 30 * 24 * 60 * 60 * 1000;
|
|
43
|
+
/**
|
|
44
|
+
* At most this many parked records are kept across every player of the
|
|
45
|
+
* install, the most recently written first. A purchase dropped by this bound
|
|
46
|
+
* that the store keeps listing is verified again by the next pass that meets
|
|
47
|
+
* it, as an older client resends every such purchase. The byte budget may
|
|
48
|
+
* drop parked records before granted purchases. Neither drops a record in
|
|
49
|
+
* the in-flight window (`parkedInFlightMs`).
|
|
50
|
+
*/
|
|
51
|
+
export const defaultMonetizationJournalMaxParkedRecords = 20;
|
|
52
|
+
/**
|
|
53
|
+
* The default in-flight window (10 minutes, the reconciler's
|
|
54
|
+
* `defaultInFlightTimeoutMs`): a parked record written this recently, or
|
|
55
|
+
* later than now (a clock moved back), is kept through every bound. A
|
|
56
|
+
* Restore Purchases resend rewrites its record before it is sent, in this
|
|
57
|
+
* context or another tab's, and the record must still be there when the
|
|
58
|
+
* backend answers. The parked record this journal wrote last is kept too. A
|
|
59
|
+
* parked record without a readable time is never in flight.
|
|
60
|
+
*/
|
|
61
|
+
export const defaultMonetizationJournalParkedInFlightMs = 10 * 60 * 1000;
|
|
26
62
|
/**
|
|
27
63
|
* Upper bound of the serialized document (192 KiB, below the Devvit bridge's
|
|
28
64
|
* 256 KiB per value). When the records in the window would exceed it, the
|
|
29
65
|
* oldest terminal records leave first: ungranted ones without a store
|
|
30
|
-
* transaction, then ungranted ones that still name a store transaction
|
|
31
|
-
*
|
|
66
|
+
* transaction, then ungranted ones that still name a store transaction and
|
|
67
|
+
* parked records (except one that may be in flight), then granted purchases.
|
|
68
|
+
* Other open records are never dropped.
|
|
32
69
|
*/
|
|
33
70
|
export const defaultMonetizationJournalMaxDocumentBytes = 192 * 1024;
|
|
34
71
|
|
|
72
|
+
/** The time of a parked record without a readable one: newest in sort order, never in flight. */
|
|
73
|
+
const unreadableParkedTime = Number.MAX_SAFE_INTEGER;
|
|
74
|
+
|
|
35
75
|
export interface CreateStorageMonetizationOperationStoreInput {
|
|
36
76
|
/** The platform storage adapter (`gateway.storage`); the host owns its durability. */
|
|
37
77
|
readonly storage: StorageAdapter;
|
|
@@ -43,6 +83,18 @@ export interface CreateStorageMonetizationOperationStoreInput {
|
|
|
43
83
|
readonly maxTerminalRecords?: number;
|
|
44
84
|
/** Defaults to 192 KiB of serialized JSON; see `defaultMonetizationJournalMaxDocumentBytes`. */
|
|
45
85
|
readonly maxDocumentBytes?: number;
|
|
86
|
+
/** Defaults to 30 days; see `defaultMonetizationJournalParkedRetentionMs`. */
|
|
87
|
+
readonly parkedRetentionMs?: number;
|
|
88
|
+
/** Defaults to 20 parked records; see `defaultMonetizationJournalMaxParkedRecords`. */
|
|
89
|
+
readonly maxParkedRecords?: number;
|
|
90
|
+
/**
|
|
91
|
+
* Defaults to 10 minutes; see `defaultMonetizationJournalParkedInFlightMs`.
|
|
92
|
+
* A game that changes the reconciler's `inFlightTimeoutMs`
|
|
93
|
+
* (`createCommerceReconciler`) passes the same value here, so a parked
|
|
94
|
+
* record stays in the journal for as long as its Restore Purchases answer
|
|
95
|
+
* may be awaited.
|
|
96
|
+
*/
|
|
97
|
+
readonly parkedInFlightMs?: number;
|
|
46
98
|
readonly now?: () => number;
|
|
47
99
|
/**
|
|
48
100
|
* Cross-context lock for the read-modify-write of the document; defaults to
|
|
@@ -67,7 +119,8 @@ interface JournalDocument {
|
|
|
67
119
|
* The reference durable `MonetizationOperationStore` of the starter: one JSON
|
|
68
120
|
* document in the platform storage adapter, rewritten whole on every change
|
|
69
121
|
* 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
|
|
122
|
+
* age (and, without a ledger grant, by count; open records are kept, except
|
|
123
|
+
* records parked as another account's purchase, bounded by age and count),
|
|
71
124
|
* with every operation
|
|
72
125
|
* serialized through one promise chain so the compare-and-swap of `replace`
|
|
73
126
|
* holds inside the WebView. It is crash recovery, not grant authority: the
|
|
@@ -87,10 +140,26 @@ export function createStorageMonetizationOperationStore(
|
|
|
87
140
|
const maxTerminalRecords = input.maxTerminalRecords
|
|
88
141
|
?? defaultMonetizationJournalMaxTerminalRecords;
|
|
89
142
|
const maxDocumentBytes = input.maxDocumentBytes ?? defaultMonetizationJournalMaxDocumentBytes;
|
|
143
|
+
const parkedRetentionMs = input.parkedRetentionMs ?? defaultMonetizationJournalParkedRetentionMs;
|
|
144
|
+
const parkedLimit = input.maxParkedRecords ?? defaultMonetizationJournalMaxParkedRecords;
|
|
145
|
+
const maxParkedRecords = parkedLimit >= 0 ? parkedLimit : 0;
|
|
146
|
+
const parkedInFlightMs = input.parkedInFlightMs ?? defaultMonetizationJournalParkedInFlightMs;
|
|
90
147
|
const now = input.now ?? (() => Date.now());
|
|
91
148
|
const locks = input.locks === undefined ? readHostLockManager() : (input.locks ?? undefined);
|
|
92
149
|
const lockName = `mpgd-monetization-journal:${key}`;
|
|
93
150
|
let chain: Promise<unknown> = Promise.resolve();
|
|
151
|
+
/**
|
|
152
|
+
* The parked record this journal wrote last: a Restore Purchases resend
|
|
153
|
+
* rewrites its record before it is sent, so this is the one whose answer
|
|
154
|
+
* may be awaited. No bound drops it, whatever its time says.
|
|
155
|
+
*/
|
|
156
|
+
let lastWrittenParked: string | undefined;
|
|
157
|
+
const noteWrite = (record: MonetizationOperationRecord): void => {
|
|
158
|
+
const writtenAt = parkedWrittenAt(record);
|
|
159
|
+
if (writtenAt !== undefined && writtenAt !== unreadableParkedTime) {
|
|
160
|
+
lastWrittenParked = record.key;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
94
163
|
|
|
95
164
|
const serialize = <T>(operation: () => Promise<T>): Promise<T> => {
|
|
96
165
|
const run = locks === undefined ? operation : () => locks.request(lockName, () => operation());
|
|
@@ -113,11 +182,34 @@ export function createStorageMonetizationOperationStore(
|
|
|
113
182
|
|
|
114
183
|
const save = async (records: Map<string, MonetizationOperationRecord>): Promise<void> => {
|
|
115
184
|
const cutoff = now() - retentionMs;
|
|
185
|
+
const parkedCutoff = now() - parkedRetentionMs;
|
|
116
186
|
const terminal: [string, number][] = [];
|
|
187
|
+
const parked: [string, number][] = [];
|
|
117
188
|
const kept: Record<string, MonetizationOperationRecord> = {};
|
|
118
|
-
/**
|
|
189
|
+
/** Records the byte budget may drop, with their drop tier (lower leaves first). */
|
|
119
190
|
const droppable: [string, number, number][] = [];
|
|
191
|
+
const inFlightSince = now() - parkedInFlightMs;
|
|
192
|
+
let inFlight = 0;
|
|
120
193
|
for (const [recordKey, record] of records) {
|
|
194
|
+
const parkedAt = parkedWrittenAt(record);
|
|
195
|
+
if (
|
|
196
|
+
parkedAt !== undefined
|
|
197
|
+
&& parkedAt !== unreadableParkedTime
|
|
198
|
+
&& (recordKey === lastWrittenParked || parkedAt >= inFlightSince)
|
|
199
|
+
) {
|
|
200
|
+
// Possibly awaiting a Restore Purchases answer: no bound drops it.
|
|
201
|
+
kept[recordKey] = record;
|
|
202
|
+
inFlight += 1;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (parkedAt !== undefined) {
|
|
206
|
+
// Another account's purchase: nothing of this player is in progress,
|
|
207
|
+
// and the purchase's own account recovers it under its own key.
|
|
208
|
+
if (parkedAt >= parkedCutoff) {
|
|
209
|
+
parked.push([recordKey, parkedAt]);
|
|
210
|
+
}
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
121
213
|
const settledAt = terminalSettledAt(record);
|
|
122
214
|
if (settledAt === undefined) {
|
|
123
215
|
kept[recordKey] = record;
|
|
@@ -135,6 +227,18 @@ export function createStorageMonetizationOperationStore(
|
|
|
135
227
|
terminal.push([recordKey, settledAt]);
|
|
136
228
|
}
|
|
137
229
|
}
|
|
230
|
+
// Newest first; the records kept as in flight count against the bound.
|
|
231
|
+
parked.sort((left, right) => right[1] - left[1]);
|
|
232
|
+
for (const [recordKey, writtenAt] of parked.slice(
|
|
233
|
+
0,
|
|
234
|
+
Math.max(0, maxParkedRecords - inFlight),
|
|
235
|
+
)) {
|
|
236
|
+
const record = records.get(recordKey);
|
|
237
|
+
if (record !== undefined) {
|
|
238
|
+
kept[recordKey] = record;
|
|
239
|
+
droppable.push([recordKey, writtenAt, 1]);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
138
242
|
// Newest first; the oldest terminal records beyond the bound are dropped.
|
|
139
243
|
terminal.sort((left, right) => right[1] - left[1]);
|
|
140
244
|
for (const [recordKey, settledAt] of terminal.slice(0, Math.max(0, maxTerminalRecords))) {
|
|
@@ -146,7 +250,7 @@ export function createStorageMonetizationOperationStore(
|
|
|
146
250
|
}
|
|
147
251
|
// The document must fit the host's per-value limit, or every later write
|
|
148
252
|
// fails and monetization stops: the oldest record of the lowest tier leaves
|
|
149
|
-
// until it fits. Open records are never dropped.
|
|
253
|
+
// until it fits. Open records other than parked ones are never dropped.
|
|
150
254
|
droppable.sort((left, right) => left[2] - right[2] || left[1] - right[1]);
|
|
151
255
|
let size = documentBytes(kept);
|
|
152
256
|
for (const [recordKey] of droppable) {
|
|
@@ -169,6 +273,7 @@ export function createStorageMonetizationOperationStore(
|
|
|
169
273
|
return { created: false, record: clone(existing) };
|
|
170
274
|
}
|
|
171
275
|
records.set(record.key, clone(record));
|
|
276
|
+
noteWrite(record);
|
|
172
277
|
await save(records);
|
|
173
278
|
return { created: true, record: clone(record) };
|
|
174
279
|
});
|
|
@@ -195,6 +300,7 @@ export function createStorageMonetizationOperationStore(
|
|
|
195
300
|
throw new Error('Stale or conflicting monetization journal write.');
|
|
196
301
|
}
|
|
197
302
|
records.set(record.key, clone(record));
|
|
303
|
+
noteWrite(record);
|
|
198
304
|
await save(records);
|
|
199
305
|
});
|
|
200
306
|
},
|
|
@@ -245,6 +351,22 @@ function terminalSettledAt(record: MonetizationOperationRecord): number | undefi
|
|
|
245
351
|
return Number.isFinite(time) ? time : Number.MAX_SAFE_INTEGER;
|
|
246
352
|
}
|
|
247
353
|
|
|
354
|
+
/**
|
|
355
|
+
* When a record parked as another account's purchase was last written (its
|
|
356
|
+
* parking, or a Restore Purchases resend), or `undefined` for any other
|
|
357
|
+
* record. A parked record without a readable time is never in flight and
|
|
358
|
+
* sorts as the newest: only the count bound and the byte budget drop it.
|
|
359
|
+
*/
|
|
360
|
+
function parkedWrittenAt(record: MonetizationOperationRecord): number | undefined {
|
|
361
|
+
if (!isCommercePurchaseOperationRecord(record) || !isParkedForAnotherAccount(record)) {
|
|
362
|
+
return undefined;
|
|
363
|
+
}
|
|
364
|
+
const times = [record.updatedAt, record.anotherAccount?.reportedAt]
|
|
365
|
+
.map((time) => (time === undefined ? Number.NaN : Date.parse(time)))
|
|
366
|
+
.filter((time) => Number.isFinite(time));
|
|
367
|
+
return times.length === 0 ? unreadableParkedTime : Math.max(...times);
|
|
368
|
+
}
|
|
369
|
+
|
|
248
370
|
/** A commerce purchase record that names a store transaction (the store may still list it). */
|
|
249
371
|
function holdsStoreTransaction(record: MonetizationOperationRecord): boolean {
|
|
250
372
|
return record.kind === 'commerce-purchase' && record.purchase?.transactionId !== undefined;
|
|
@@ -1,20 +1,22 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import {
|
|
2
|
+
gameServicesPlayerChangedCode,
|
|
3
|
+
gameServicesRuntimeDisposedCode,
|
|
4
|
+
type GameServicesClient,
|
|
5
|
+
type GameServicesPurchaseResult,
|
|
6
|
+
type GameServicesRewardedAdResult,
|
|
7
|
+
type GameServicesRuntime,
|
|
6
8
|
} from '@mpgd/game-services';
|
|
7
9
|
|
|
8
10
|
/** The diagnostic of a purchase refused because the run is not signed in yet (retryable). */
|
|
9
11
|
export const starterSessionRequiredCode = 'STARTER_SESSION_REQUIRED';
|
|
10
12
|
|
|
11
|
-
function
|
|
13
|
+
function refusedPurchase(code: string): GameServicesPurchaseResult {
|
|
12
14
|
return {
|
|
13
15
|
status: 'failed',
|
|
14
16
|
purchase: {
|
|
15
17
|
status: 'failed',
|
|
16
18
|
entitlementIds: [],
|
|
17
|
-
diagnostic: { code
|
|
19
|
+
diagnostic: { code, retryable: true },
|
|
18
20
|
},
|
|
19
21
|
};
|
|
20
22
|
}
|
|
@@ -25,11 +27,16 @@ function sessionRequiredReward(): GameServicesRewardedAdResult {
|
|
|
25
27
|
|
|
26
28
|
/** A purchase, restore, or reward claim was asked for before the guest sign-in succeeded. */
|
|
27
29
|
export class StarterSessionRequiredError extends Error {
|
|
28
|
-
readonly code
|
|
30
|
+
readonly code: string;
|
|
29
31
|
|
|
30
|
-
constructor() {
|
|
31
|
-
super(
|
|
32
|
+
constructor(code: string = starterSessionRequiredCode) {
|
|
33
|
+
super(
|
|
34
|
+
code === starterSessionRequiredCode
|
|
35
|
+
? 'The game is not signed in to its identity service yet; try again when it is online.'
|
|
36
|
+
: 'Another player signed in; the game restarts for that player.',
|
|
37
|
+
);
|
|
32
38
|
this.name = 'StarterSessionRequiredError';
|
|
39
|
+
this.code = code;
|
|
33
40
|
}
|
|
34
41
|
}
|
|
35
42
|
|
|
@@ -69,8 +76,14 @@ export type StarterSessionGatedServices = GameServicesRuntime & {
|
|
|
69
76
|
* runtime binds its late reward evidence recovery at boot.
|
|
70
77
|
* - When the player becomes known, the services are created once; the
|
|
71
78
|
* caller runs the game runtime's `reconcile()` from `onActivated` (the
|
|
72
|
-
* startup recovery, with the action coordinator's). A later sign-in naming
|
|
73
|
-
* (not expected within one run)
|
|
79
|
+
* startup recovery, with the action coordinator's). A later sign-in naming
|
|
80
|
+
* another player (not expected within one run) closes them for good, as
|
|
81
|
+
* the services' own session check does: purchases answer `failed` with the
|
|
82
|
+
* retryable diagnostic `GAME_SERVICES_PLAYER_CHANGED`, recovery and Delete
|
|
83
|
+
* account go away, and `onPlayerChanged` asks the host to boot again for
|
|
84
|
+
* that player once the old services stopped. They are never re-created
|
|
85
|
+
* for the new player inside this run: the scenes, the action coordinator,
|
|
86
|
+
* and the notifications still hold the boot player.
|
|
74
87
|
* - `accountDeletion` is `undefined` until then, and the services' after
|
|
75
88
|
* (read live); `onReady` tells Delete account to appear then.
|
|
76
89
|
* - `ensureSession` is present only when the configuration has a client
|
|
@@ -90,19 +103,39 @@ export function createSessionGatedGameServices(input: {
|
|
|
90
103
|
* one a purchase or restore started): run the game runtime's `reconcile()`.
|
|
91
104
|
*/
|
|
92
105
|
readonly onActivated?: () => void;
|
|
106
|
+
/** Called at once when a later sign-in names another player: block input or show a switching screen. */
|
|
107
|
+
readonly onPlayerChanging?: (playerId: string) => void;
|
|
108
|
+
/** Called once, after a sign-in named another player closed the services: boot again for it. */
|
|
109
|
+
readonly onPlayerChanged?: (playerId: string) => void;
|
|
93
110
|
readonly warn?: (message: string, error?: unknown) => void;
|
|
94
111
|
}): StarterSessionGatedServices {
|
|
95
112
|
const warn = input.warn ?? ((message: string, error?: unknown) => console.warn(message, error ?? ''));
|
|
96
113
|
let inner: GameServicesRuntime | undefined;
|
|
97
114
|
let innerPlayer: string | undefined;
|
|
115
|
+
/** Set once another player signed in, or the services were disposed: nothing comes up again. */
|
|
116
|
+
let closedCode: string | undefined;
|
|
98
117
|
const readyListeners = new Set<() => void>();
|
|
118
|
+
/** The services the scenes may use now: none once closed. */
|
|
119
|
+
const live = (): GameServicesRuntime | undefined => (closedCode === undefined
|
|
120
|
+
? inner
|
|
121
|
+
: undefined);
|
|
99
122
|
|
|
100
123
|
const activate = (playerId: string): void => {
|
|
124
|
+
if (closedCode !== undefined) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
101
127
|
if (inner !== undefined) {
|
|
102
128
|
if (playerId !== innerPlayer) {
|
|
129
|
+
closedCode = gameServicesPlayerChangedCode;
|
|
130
|
+
readyListeners.clear();
|
|
103
131
|
warn(
|
|
104
|
-
'[game-services] a later sign-in named another player; the services
|
|
132
|
+
'[game-services] a later sign-in named another player; the services closed until restart.',
|
|
105
133
|
);
|
|
134
|
+
notifyChanging(input.onPlayerChanging, playerId, warn);
|
|
135
|
+
// Fail closed first; the host restarts once the old reconciler has stopped.
|
|
136
|
+
void inner.dispose().catch((error: unknown) => {
|
|
137
|
+
warn('[game-services] the services could not be closed cleanly', error);
|
|
138
|
+
}).finally(() => input.onPlayerChanged?.(playerId));
|
|
106
139
|
}
|
|
107
140
|
return;
|
|
108
141
|
}
|
|
@@ -132,16 +165,17 @@ export function createSessionGatedGameServices(input: {
|
|
|
132
165
|
input.onPlayer(activate);
|
|
133
166
|
|
|
134
167
|
const ready = async (): Promise<GameServicesRuntime> => {
|
|
135
|
-
if (inner === undefined) {
|
|
168
|
+
if (inner === undefined && closedCode === undefined) {
|
|
136
169
|
const playerId = await input.ensurePlayer();
|
|
137
170
|
if (playerId !== undefined) {
|
|
138
171
|
activate(playerId);
|
|
139
172
|
}
|
|
140
173
|
}
|
|
141
|
-
|
|
142
|
-
|
|
174
|
+
const services = live();
|
|
175
|
+
if (services === undefined) {
|
|
176
|
+
throw new StarterSessionRequiredError(closedCode);
|
|
143
177
|
}
|
|
144
|
-
return
|
|
178
|
+
return services;
|
|
145
179
|
};
|
|
146
180
|
const readyClientOrUndefined = async (): Promise<GameServicesClient | undefined> => {
|
|
147
181
|
try {
|
|
@@ -166,7 +200,9 @@ export function createSessionGatedGameServices(input: {
|
|
|
166
200
|
client = {
|
|
167
201
|
purchase: async (purchase, options) => {
|
|
168
202
|
const ready = await readyClientOrUndefined();
|
|
169
|
-
return ready === undefined
|
|
203
|
+
return ready === undefined
|
|
204
|
+
? refusedPurchase(closedCode ?? starterSessionRequiredCode)
|
|
205
|
+
: ready.purchase(purchase, options);
|
|
170
206
|
},
|
|
171
207
|
claimRewardedAd: async (reward, options) => {
|
|
172
208
|
const ready = await readyClientOrUndefined();
|
|
@@ -177,7 +213,7 @@ export function createSessionGatedGameServices(input: {
|
|
|
177
213
|
// Read live: the reconciler of the services created after sign-in.
|
|
178
214
|
Object.defineProperty(client, 'commerceReconciler', {
|
|
179
215
|
enumerable: true,
|
|
180
|
-
get: () =>
|
|
216
|
+
get: () => live()?.client?.commerceReconciler,
|
|
181
217
|
});
|
|
182
218
|
}
|
|
183
219
|
|
|
@@ -205,13 +241,105 @@ export function createSessionGatedGameServices(input: {
|
|
|
205
241
|
}
|
|
206
242
|
},
|
|
207
243
|
}),
|
|
244
|
+
async dispose() {
|
|
245
|
+
closedCode ??= gameServicesRuntimeDisposedCode;
|
|
246
|
+
readyListeners.clear();
|
|
247
|
+
await inner?.dispose();
|
|
248
|
+
},
|
|
208
249
|
} as StarterSessionGatedServices;
|
|
209
250
|
// Read live: `undefined` until the player is known, so rewarded ads stay off until then,
|
|
210
251
|
// and Delete account appears once there is an account (the lobby's next visit).
|
|
211
252
|
Object.defineProperties(services, {
|
|
212
253
|
mode: { enumerable: true, get: () => inner?.mode ?? input.probe.mode },
|
|
213
|
-
monetizationRecovery: { enumerable: true, get: () =>
|
|
214
|
-
accountDeletion: { enumerable: true, get: () =>
|
|
254
|
+
monetizationRecovery: { enumerable: true, get: () => live()?.monetizationRecovery },
|
|
255
|
+
accountDeletion: { enumerable: true, get: () => live()?.accountDeletion },
|
|
215
256
|
});
|
|
216
257
|
return services;
|
|
217
258
|
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Closes `services` once the server session names another player than
|
|
262
|
+
* `playerId` (an account switch within the run): `onPlayerChanging` hears it
|
|
263
|
+
* at once (the runtime's own `sessionPlayerId` check already refuses every
|
|
264
|
+
* request from that moment; block input or show a switching screen), then
|
|
265
|
+
* the services are disposed (an open store sheet first journals its answer)
|
|
266
|
+
* and `onPlayerChanged` asks the host to restart for that player. Services
|
|
267
|
+
* created for another player than the session's current one stay closed,
|
|
268
|
+
* which `warn` reports. `onDisposed` runs once, after the services were
|
|
269
|
+
* disposed either way (the store binding pin is released there).
|
|
270
|
+
* `dispose()` also stops watching.
|
|
271
|
+
*/
|
|
272
|
+
export function bindStarterServicesToPlayer(input: {
|
|
273
|
+
readonly services: GameServicesRuntime;
|
|
274
|
+
readonly playerId: string;
|
|
275
|
+
readonly session: {
|
|
276
|
+
current(): { readonly playerId: string } | undefined;
|
|
277
|
+
onAuthenticated(listener: (session: { readonly playerId: string }) => void): () => void;
|
|
278
|
+
};
|
|
279
|
+
readonly onPlayerChanging?: (playerId: string) => void;
|
|
280
|
+
readonly onPlayerChanged?: (playerId: string) => void;
|
|
281
|
+
readonly onDisposed?: () => void;
|
|
282
|
+
readonly warn?: (message: string, error?: unknown) => void;
|
|
283
|
+
}): GameServicesRuntime {
|
|
284
|
+
const warn = input.warn ?? ((message: string, error?: unknown) => console.warn(message, error ?? ''));
|
|
285
|
+
const { services } = input;
|
|
286
|
+
const current = input.session.current()?.playerId;
|
|
287
|
+
const createdForAnother = current !== undefined && current !== input.playerId;
|
|
288
|
+
let closing: Promise<void> | undefined;
|
|
289
|
+
const close = (): Promise<void> => {
|
|
290
|
+
closing ??= services.dispose().catch((error: unknown) => {
|
|
291
|
+
warn('[game-services] the services could not be closed cleanly', error);
|
|
292
|
+
}).finally(() => input.onDisposed?.());
|
|
293
|
+
return closing;
|
|
294
|
+
};
|
|
295
|
+
let changed = false;
|
|
296
|
+
/** The unsubscribe, once `onAuthenticated` returned it. */
|
|
297
|
+
const watching: { stop?: () => void } = {};
|
|
298
|
+
const onSession = (next: { readonly playerId: string }): void => {
|
|
299
|
+
if (changed || next.playerId === input.playerId) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
changed = true;
|
|
303
|
+
watching.stop?.();
|
|
304
|
+
notifyChanging(input.onPlayerChanging, next.playerId, warn);
|
|
305
|
+
void close().finally(() => input.onPlayerChanged?.(next.playerId));
|
|
306
|
+
};
|
|
307
|
+
if (createdForAnother) {
|
|
308
|
+
// Created for another player than the session's (a local placeholder):
|
|
309
|
+
// closed at once and the pin released, so services for the session's
|
|
310
|
+
// player can be created. No restart: the host that created these would
|
|
311
|
+
// create them the same way again.
|
|
312
|
+
changed = true;
|
|
313
|
+
warn(
|
|
314
|
+
"[game-services] created for a player that is not the server session's; they are closed. "
|
|
315
|
+
+ "Create them with the session's player.",
|
|
316
|
+
);
|
|
317
|
+
void close();
|
|
318
|
+
} else {
|
|
319
|
+
watching.stop = input.session.onAuthenticated(onSession);
|
|
320
|
+
if (changed) {
|
|
321
|
+
// The session announced another player while subscribing.
|
|
322
|
+
watching.stop();
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
...services,
|
|
327
|
+
async dispose() {
|
|
328
|
+
watching.stop?.();
|
|
329
|
+
await close();
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Tells the host at once that another player signed in; a throwing callback is only reported. */
|
|
335
|
+
function notifyChanging(
|
|
336
|
+
callback: ((playerId: string) => void) | undefined,
|
|
337
|
+
playerId: string,
|
|
338
|
+
warn: (message: string, error?: unknown) => void,
|
|
339
|
+
): void {
|
|
340
|
+
try {
|
|
341
|
+
callback?.(playerId);
|
|
342
|
+
} catch (error) {
|
|
343
|
+
warn('[game-services] the player change callback failed', error);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
@@ -61,3 +61,15 @@ canvas {
|
|
|
61
61
|
font-size: 16px;
|
|
62
62
|
line-height: 1.5;
|
|
63
63
|
}
|
|
64
|
+
|
|
65
|
+
.player-switch {
|
|
66
|
+
position: fixed;
|
|
67
|
+
inset: 0;
|
|
68
|
+
z-index: 1000;
|
|
69
|
+
display: flex;
|
|
70
|
+
align-items: center;
|
|
71
|
+
justify-content: center;
|
|
72
|
+
background: rgb(0 0 0 / 70%);
|
|
73
|
+
color: #f8fafc;
|
|
74
|
+
font-size: 18px;
|
|
75
|
+
}
|