@mpgd/game-services 0.24.1 → 0.25.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,443 @@
1
+ import { normalizeNotificationTimestamp, } from '@mpgd/platform/notifications';
2
+ export function notificationRowOutcome(row) {
3
+ switch (row.state.status) {
4
+ case 'delivered':
5
+ return { status: 'delivered', alreadyProcessed: false };
6
+ case 'in-flight':
7
+ return { status: 'in-flight' };
8
+ case 'deferred':
9
+ return { status: 'deferred', reason: row.state.reason, until: row.state.until };
10
+ }
11
+ }
12
+ /**
13
+ * How far a recipient's answer got, for a send's result across recipients:
14
+ * delivered, in flight, waiting, a provider's refusal, a policy refusal, and
15
+ * last a recipient that did not resolve.
16
+ */
17
+ function outcomeRank(outcome) {
18
+ switch (outcome.status) {
19
+ case 'delivered':
20
+ return 5;
21
+ case 'in-flight':
22
+ return 4;
23
+ case 'deferred':
24
+ return 3;
25
+ case 'unavailable':
26
+ return outcome.reason === 'no-endpoint' || outcome.reason === 'link-missing' ? 0 : 2;
27
+ case 'suppressed':
28
+ return 1;
29
+ }
30
+ }
31
+ /** The highest-ranked outcome, the earliest among deferred ones, the first among equals. */
32
+ export function bestNotificationOutcome(outcomes) {
33
+ let best;
34
+ for (const outcome of outcomes) {
35
+ if (best === undefined
36
+ || outcomeRank(outcome) > outcomeRank(best)
37
+ || (outcome.status === 'deferred'
38
+ && best.status === 'deferred'
39
+ && Date.parse(outcome.until) < Date.parse(best.until))) {
40
+ best = outcome;
41
+ }
42
+ }
43
+ return best;
44
+ }
45
+ /** A send's stored outcome: its best row, or `in-flight` while its first evaluation holds the claim. */
46
+ export function notificationSendOutcome(send) {
47
+ const stored = bestNotificationOutcome(send.rows.map(notificationRowOutcome));
48
+ return stored ?? (send.claim === undefined ? undefined : { status: 'in-flight' });
49
+ }
50
+ /** How long an ambiguous delivery is replayed before it is left in flight. */
51
+ export const notificationAmbiguousReplayHours = 24;
52
+ const ambiguousReplayMs = notificationAmbiguousReplayHours * 3_600_000;
53
+ /** When the scheduler may first take the row, or `undefined` when it never will. */
54
+ function rowDueAt(row) {
55
+ if (row.state.status === 'deferred') {
56
+ return Date.parse(row.state.until);
57
+ }
58
+ if (row.state.status !== 'in-flight') {
59
+ return undefined;
60
+ }
61
+ const lease = Date.parse(row.state.leaseUntil);
62
+ if (!row.state.attempted || row.firstAttemptedAt === undefined) {
63
+ return lease;
64
+ }
65
+ // The provider may have sent it every time: replays stop after a day, and
66
+ // the row stays in flight until its key's retention ends.
67
+ return isNotificationRowAbandoned(row, lease) ? undefined : lease;
68
+ }
69
+ /**
70
+ * Whether an attempted row is past its replays: its lease would end a day
71
+ * or more after its first attempt.
72
+ */
73
+ export function isNotificationRowAbandoned(row, leaseUntil) {
74
+ return row.firstAttemptedAt !== undefined
75
+ && leaseUntil - Date.parse(row.firstAttemptedAt) >= ambiguousReplayMs;
76
+ }
77
+ /**
78
+ * Whether the scheduler may take a row at `at`: a deferred row once `until`
79
+ * passed, and an in-flight row once its lease expired. That covers a queued
80
+ * delivery whose message was lost, a claim whose holder failed before the
81
+ * provider call, and an ambiguous answer, for a device endpoint, a scheduled
82
+ * send, or a direct platform send whose caller never retries. Ambiguous
83
+ * replays stop 24 hours after the first attempt.
84
+ */
85
+ export function isNotificationRowDue(row, at) {
86
+ const due = rowDueAt(row);
87
+ return due !== undefined && due <= at;
88
+ }
89
+ /** When the scheduler may first take one of the send's rows, or `undefined` when none waits for it. */
90
+ export function notificationSendDueAt(send) {
91
+ let due;
92
+ for (const row of send.rows) {
93
+ const at = rowDueAt(row);
94
+ if (at !== undefined && (due === undefined || at < due)) {
95
+ due = at;
96
+ }
97
+ }
98
+ return due === undefined ? undefined : formatNotificationInstant(due);
99
+ }
100
+ /** Epoch milliseconds of a stored time; one that does not parse is a caller error, as in the D1 store. */
101
+ function instantOf(at) {
102
+ const millis = Date.parse(at);
103
+ if (!Number.isFinite(millis)) {
104
+ throw new TypeError(`Not a timestamp: ${JSON.stringify(at)}`);
105
+ }
106
+ return millis;
107
+ }
108
+ function dueOf(entry) {
109
+ return entry.dueAt === undefined ? undefined : instantOf(entry.dueAt);
110
+ }
111
+ /** The entry as a JSON store keeps it: under its key's player and id, without an undefined `dueAt`. */
112
+ function storedEntry(entry, player, id) {
113
+ const { dueAt, ...rest } = structuredClone(entry);
114
+ return { ...rest, player, id, ...(dueAt === undefined ? {} : { dueAt }) };
115
+ }
116
+ /** Refuses an entry whose `dueAt` does not parse before anything is written. */
117
+ function assertDue(entry) {
118
+ dueOf(entry);
119
+ }
120
+ /** Code-point order, which is UTF-8 byte order: the order of SQLite's BINARY collation. */
121
+ function compareCodePoints(left, right) {
122
+ if (left === right) {
123
+ return 0;
124
+ }
125
+ const a = Array.from(left);
126
+ const b = Array.from(right);
127
+ for (let index = 0; index < Math.min(a.length, b.length); index += 1) {
128
+ const difference = (a[index]?.codePointAt(0) ?? 0) - (b[index]?.codePointAt(0) ?? 0);
129
+ if (difference !== 0) {
130
+ return difference;
131
+ }
132
+ }
133
+ return a.length - b.length;
134
+ }
135
+ export function formatNotificationInstant(millis) {
136
+ return normalizeNotificationTimestamp(new Date(millis).toISOString());
137
+ }
138
+ /**
139
+ * A process-local store for tests, conformance runs, and local development.
140
+ * Every method finishes its change before its first await, so concurrent
141
+ * callers see each change whole. Production deployments use a durable store.
142
+ */
143
+ export function createInMemoryNotificationStore() {
144
+ const consent = [];
145
+ const endpoints = [];
146
+ const platformRegistrations = new Map();
147
+ const noticesGiven = [];
148
+ const sends = new Map();
149
+ const history = [];
150
+ const schedule = new Map();
151
+ const scheduleKey = (player, id) => JSON.stringify([player, id]);
152
+ let versions = 0;
153
+ const nextVersion = () => {
154
+ versions += 1;
155
+ return `v${versions}`;
156
+ };
157
+ const copy = (value) => structuredClone(value);
158
+ const noticeName = (notice) => JSON.stringify([
159
+ notice.player,
160
+ notice.kind,
161
+ notice.record.kind,
162
+ Date.parse(notice.record.at),
163
+ notice.dueBy,
164
+ ]);
165
+ return {
166
+ inspect() {
167
+ return copy({
168
+ consent: consent.map((row) => ({ ...row.record, player: row.player })),
169
+ endpoints,
170
+ platformRegistrations: [...platformRegistrations.values()],
171
+ noticesGiven,
172
+ sends: [...sends.values()].map((entry) => entry.send),
173
+ history,
174
+ schedule: [...schedule.values()].map((held) => held.entry),
175
+ });
176
+ },
177
+ async appendConsent({ player, record, at }) {
178
+ let millis = Date.parse(at);
179
+ for (let index = consent.length - 1; index >= 0; index -= 1) {
180
+ const row = consent[index];
181
+ if (row !== undefined && row.player === player) {
182
+ millis = Math.max(millis, Date.parse(row.record.at) + 1);
183
+ break;
184
+ }
185
+ }
186
+ const stored = { ...copy(record), at: formatNotificationInstant(millis) };
187
+ consent.push({ player, record: stored });
188
+ return copy(stored);
189
+ },
190
+ async listConsent(player) {
191
+ return copy(consent.filter((row) => row.player === player).map((row) => row.record));
192
+ },
193
+ async pruneConsent({ player, at }) {
194
+ const instants = new Set(at.map(instantOf));
195
+ let removed = 0;
196
+ for (let index = consent.length - 1; index >= 0; index -= 1) {
197
+ const row = consent[index];
198
+ if (row !== undefined && row.player === player && instants.has(Date.parse(row.record.at))) {
199
+ consent.splice(index, 1);
200
+ removed += 1;
201
+ }
202
+ }
203
+ for (let index = noticesGiven.length - 1; index >= 0; index -= 1) {
204
+ const given = noticesGiven[index];
205
+ if (given !== undefined && given.player === player && instants.has(Date.parse(given.record.at))) {
206
+ noticesGiven.splice(index, 1);
207
+ }
208
+ }
209
+ return removed;
210
+ },
211
+ async putEndpoint(write) {
212
+ for (let index = endpoints.length - 1; index >= 0; index -= 1) {
213
+ const other = endpoints[index];
214
+ if (other !== undefined
215
+ && other.provider === write.provider
216
+ && other.address.value === write.address.value
217
+ && other.installationId !== write.installationId) {
218
+ endpoints.splice(index, 1);
219
+ }
220
+ }
221
+ const index = endpoints.findIndex((endpoint) => endpoint.provider === write.provider && endpoint.installationId === write.installationId);
222
+ const existing = index === -1 ? undefined : endpoints[index];
223
+ const dead = existing?.status === 'invalid' && existing.address.value === write.address.value;
224
+ const stored = {
225
+ ...copy(write),
226
+ status: dead ? 'invalid' : 'valid',
227
+ ...(dead && existing.invalidatedAt !== undefined ? { invalidatedAt: existing.invalidatedAt } : {}),
228
+ };
229
+ if (index === -1) {
230
+ endpoints.push(stored);
231
+ }
232
+ else {
233
+ endpoints[index] = stored;
234
+ }
235
+ return copy(stored);
236
+ },
237
+ async listEndpoints({ player, provider }) {
238
+ return copy(endpoints.filter((endpoint) => endpoint.player === player && endpoint.provider === provider));
239
+ },
240
+ async getEndpoint({ provider, installationId }) {
241
+ const endpoint = endpoints.find((candidate) => candidate.provider === provider && candidate.installationId === installationId);
242
+ return endpoint === undefined ? undefined : copy(endpoint);
243
+ },
244
+ async invalidateEndpoint({ provider, installationId, value, at }) {
245
+ const index = endpoints.findIndex((endpoint) => endpoint.provider === provider
246
+ && endpoint.installationId === installationId
247
+ && endpoint.address.value === value);
248
+ const endpoint = endpoints[index];
249
+ if (endpoint !== undefined && endpoint.status === 'valid') {
250
+ endpoints[index] = { ...endpoint, status: 'invalid', invalidatedAt: at };
251
+ }
252
+ },
253
+ async removeEndpoints({ installationId, player, provider }) {
254
+ for (let index = endpoints.length - 1; index >= 0; index -= 1) {
255
+ const endpoint = endpoints[index];
256
+ if (endpoint !== undefined
257
+ && endpoint.installationId === installationId
258
+ && endpoint.player === player
259
+ && (provider === undefined || endpoint.provider === provider)) {
260
+ endpoints.splice(index, 1);
261
+ }
262
+ }
263
+ },
264
+ async removeSessionEndpoints({ sessionId }) {
265
+ let removed = 0;
266
+ for (let index = endpoints.length - 1; index >= 0; index -= 1) {
267
+ if (endpoints[index]?.sessionId === sessionId) {
268
+ endpoints.splice(index, 1);
269
+ removed += 1;
270
+ }
271
+ }
272
+ return removed;
273
+ },
274
+ async extendSessionEndpoints({ sessionId, expiresAt }) {
275
+ let changed = 0;
276
+ for (let index = 0; index < endpoints.length; index += 1) {
277
+ const endpoint = endpoints[index];
278
+ if (endpoint === undefined
279
+ || endpoint.sessionId !== sessionId
280
+ || endpoint.sessionExpiresAt === undefined
281
+ || (expiresAt !== undefined && Date.parse(endpoint.sessionExpiresAt) >= Date.parse(expiresAt))) {
282
+ continue;
283
+ }
284
+ const { sessionExpiresAt: _earlier, ...kept } = endpoint;
285
+ endpoints[index] = expiresAt === undefined ? kept : { ...kept, sessionExpiresAt: expiresAt };
286
+ changed += 1;
287
+ }
288
+ return changed;
289
+ },
290
+ async putPlatformRegistration(registration) {
291
+ platformRegistrations.set(JSON.stringify([registration.player, registration.provider]), copy(registration));
292
+ },
293
+ async getPlatformRegistration({ player, provider }) {
294
+ const registration = platformRegistrations.get(JSON.stringify([player, provider]));
295
+ return registration === undefined ? undefined : copy(registration);
296
+ },
297
+ async addNoticeGiven(notice) {
298
+ const name = noticeName(notice);
299
+ if (!noticesGiven.some((given) => noticeName(given) === name)) {
300
+ noticesGiven.push(copy(notice));
301
+ }
302
+ },
303
+ async listNoticesGiven(player) {
304
+ return copy(noticesGiven.filter((given) => given.player === player));
305
+ },
306
+ async getSend(key) {
307
+ const entry = sends.get(key);
308
+ return entry === undefined ? undefined : copy(entry);
309
+ },
310
+ async createSend(send) {
311
+ if (sends.has(send.key)) {
312
+ return undefined;
313
+ }
314
+ const version = nextVersion();
315
+ sends.set(send.key, { send: copy(send), version });
316
+ return version;
317
+ },
318
+ async updateSend({ key, version, send }) {
319
+ const entry = sends.get(key);
320
+ if (entry === undefined || entry.version !== version) {
321
+ return false;
322
+ }
323
+ if (send === undefined) {
324
+ sends.delete(key);
325
+ }
326
+ else {
327
+ sends.set(key, { send: copy(send), version: nextVersion() });
328
+ }
329
+ return true;
330
+ },
331
+ async listInFlightSends(player) {
332
+ return copy([...sends.values()]
333
+ .map((entry) => entry.send)
334
+ .filter((send) => send.request.player === player && send.rows.some((row) => row.state.status === 'in-flight')));
335
+ },
336
+ async listDueSends({ at, limit }) {
337
+ const now = instantOf(at);
338
+ return [...sends.values()]
339
+ .flatMap(({ send }) => {
340
+ const due = notificationSendDueAt(send);
341
+ return due !== undefined && Date.parse(due) <= now ? [{ key: send.key, due: Date.parse(due) }] : [];
342
+ })
343
+ .sort((left, right) => left.due - right.due)
344
+ .slice(0, Math.max(0, limit))
345
+ .map((entry) => entry.key);
346
+ },
347
+ async addHistory(entry) {
348
+ instantOf(entry.at);
349
+ if (!history.some((sent) => sent.sendKey === entry.sendKey && sent.provider === entry.provider)) {
350
+ history.push(copy(entry));
351
+ history.sort((left, right) => Date.parse(left.at) - Date.parse(right.at));
352
+ }
353
+ },
354
+ async listHistory({ player, since }) {
355
+ const from = instantOf(since);
356
+ return copy(history.filter((sent) => sent.player === player && Date.parse(sent.at) >= from));
357
+ },
358
+ async countHistory({ provider, since, until }) {
359
+ const from = instantOf(since);
360
+ const to = instantOf(until);
361
+ return history.filter((sent) => sent.provider === provider && Date.parse(sent.at) >= from && Date.parse(sent.at) <= to).length;
362
+ },
363
+ async getScheduleEntry({ player, id }) {
364
+ const held = schedule.get(scheduleKey(player, id));
365
+ return held === undefined ? undefined : copy(held);
366
+ },
367
+ async createScheduleEntry(entry) {
368
+ assertDue(entry);
369
+ const key = scheduleKey(entry.player, entry.id);
370
+ if (schedule.has(key)) {
371
+ return undefined;
372
+ }
373
+ const version = nextVersion();
374
+ schedule.set(key, { entry: storedEntry(entry, entry.player, entry.id), version });
375
+ return version;
376
+ },
377
+ async updateScheduleEntry({ player, id, version, entry }) {
378
+ if (entry !== undefined) {
379
+ assertDue(entry);
380
+ }
381
+ const key = scheduleKey(player, id);
382
+ const held = schedule.get(key);
383
+ if (held === undefined || held.version !== version) {
384
+ return false;
385
+ }
386
+ if (entry === undefined) {
387
+ schedule.delete(key);
388
+ }
389
+ else {
390
+ schedule.set(key, { entry: storedEntry(entry, player, id), version: nextVersion() });
391
+ }
392
+ return true;
393
+ },
394
+ async countPendingReminders(player) {
395
+ return [...schedule.values()].filter(({ entry }) => entry.player === player
396
+ && entry.id.startsWith('reminder:')
397
+ && entry.item.kind === 'reminder'
398
+ && entry.item.firedAt === undefined).length;
399
+ },
400
+ async listDueScheduleEntries({ at, limit }) {
401
+ const now = instantOf(at);
402
+ return [...schedule.values()]
403
+ .flatMap(({ entry }) => {
404
+ const due = dueOf(entry);
405
+ return due !== undefined && due <= now ? [{ player: entry.player, id: entry.id, due }] : [];
406
+ })
407
+ .sort((left, right) => left.due - right.due || compareCodePoints(left.player, right.player) || compareCodePoints(left.id, right.id))
408
+ .slice(0, Math.max(0, limit))
409
+ .map(({ player, id }) => ({ player, id }));
410
+ },
411
+ async purge(cutoffs) {
412
+ for (const cutoff of Object.values(cutoffs)) {
413
+ instantOf(cutoff);
414
+ }
415
+ const before = (at, cutoff) => at !== undefined && Date.parse(at) < Date.parse(cutoff);
416
+ let sendCount = 0;
417
+ for (const [key, entry] of [...sends]) {
418
+ if (before(entry.send.changedAt, cutoffs.sendsChangedBefore)) {
419
+ sends.delete(key);
420
+ sendCount += 1;
421
+ }
422
+ }
423
+ const historyBefore = history.length;
424
+ for (let index = history.length - 1; index >= 0; index -= 1) {
425
+ if (before(history[index]?.at, cutoffs.historyBefore)) {
426
+ history.splice(index, 1);
427
+ }
428
+ }
429
+ let endpointCount = 0;
430
+ for (let index = endpoints.length - 1; index >= 0; index -= 1) {
431
+ const endpoint = endpoints[index];
432
+ if (endpoint !== undefined
433
+ && ((endpoint.status === 'invalid' && before(endpoint.invalidatedAt, cutoffs.invalidEndpointsBefore))
434
+ || before(endpoint.lastRegisteredAt, cutoffs.staleEndpointsBefore)
435
+ || before(endpoint.sessionExpiresAt, cutoffs.sessionEndpointsBefore))) {
436
+ endpoints.splice(index, 1);
437
+ endpointCount += 1;
438
+ }
439
+ }
440
+ return { sends: sendCount, history: historyBefore - history.length, endpoints: endpointCount };
441
+ },
442
+ };
443
+ }
@@ -0,0 +1,2 @@
1
+ /** An error's message for another error's text; never the error's other fields. */
2
+ export declare function describeError(error: unknown): string;
@@ -0,0 +1,4 @@
1
+ /** An error's message for another error's text; never the error's other fields. */
2
+ export function describeError(error) {
3
+ return error instanceof Error ? error.message : String(error);
4
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/game-services",
3
- "version": "0.24.1",
3
+ "version": "0.25.1",
4
4
  "description": "Client, contract, server, store, and test helpers for authoritative mpgd game services.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -129,6 +129,18 @@
129
129
  "types": "./dist/notification-delivery.d.ts",
130
130
  "default": "./dist/notification-delivery.js"
131
131
  },
132
+ "./notifications": {
133
+ "types": "./dist/notifications/index.d.ts",
134
+ "default": "./dist/notifications/index.js"
135
+ },
136
+ "./notifications/store-conformance": {
137
+ "types": "./dist/notifications/store-conformance.d.ts",
138
+ "default": "./dist/notifications/store-conformance.js"
139
+ },
140
+ "./notifications/ait-functional-delivery": {
141
+ "types": "./dist/notifications/ait-functional-delivery.d.ts",
142
+ "default": "./dist/notifications/ait-functional-delivery.js"
143
+ },
132
144
  "./platform-order": {
133
145
  "types": "./dist/platform-order.d.ts",
134
146
  "default": "./dist/platform-order.js"
@@ -203,9 +215,9 @@
203
215
  "@orpc/client": "2.0.0-beta.14",
204
216
  "@orpc/contract": "2.0.0-beta.14",
205
217
  "@orpc/server": "2.0.0-beta.14",
206
- "@mpgd/catalog": "0.10.1",
207
- "@mpgd/analytics": "0.4.10",
208
- "@mpgd/platform": "0.23.1"
218
+ "@mpgd/platform": "0.25.0",
219
+ "@mpgd/analytics": "0.4.12",
220
+ "@mpgd/catalog": "0.10.3"
209
221
  },
210
222
  "peerDependencies": {
211
223
  "@apple/app-store-server-library": "^3.1.0"
@@ -234,7 +246,7 @@
234
246
  "fix": "ttsc fix",
235
247
  "build": "cd ../.. && node tools/run-ttsx.mjs tools/package/build-packages.ts @mpgd/game-services",
236
248
  "test:packed-transport": "pnpm build && node test/runtime-transport-package-import.mjs && node test/reward-evidence-import.mjs",
237
- "test:unit": "cd ../.. && node tools/run-ttsx.mjs packages/game-services/src/client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/client-progress.test.ts && node tools/run-ttsx.mjs packages/game-services/src/runtime.test.ts && node tools/run-ttsx.mjs packages/game-services/src/types.test.ts && node tools/run-ttsx.mjs packages/game-services/src/server.test.ts && node tools/run-ttsx.mjs packages/game-services/src/google-play-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/google-play-publisher-client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/microsoft-store-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/microsoft-store-identity.test.ts && node tools/run-ttsx.mjs packages/game-services/src/platform-order.test.ts && node tools/run-ttsx.mjs packages/game-services/src/progress-link.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notification-delivery.test.ts && node tools/run-ttsx.mjs packages/game-services/src/admob-ssv.test.ts && node tools/run-ttsx.mjs packages/game-services/src/app-store-verifier.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-evidence-verification-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-partner-api.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-durability-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-transport.test.ts && node tools/run-ttsx.mjs packages/game-services/src/guest-session.test.ts && node tools/run-ttsx.mjs packages/game-services/src/client-reward-evidence.test.ts && node tools/run-ttsx.mjs packages/game-services/src/ad-reward-verifier-registry.test.ts && node tools/run-ttsx.mjs packages/game-services/src/oneplay-reward.test.ts && node tools/run-ttsx.mjs packages/game-services/src/oneplay-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-registry-routing.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-registry-server.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-ledger-revocation.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-anonymous-auth.test.ts",
249
+ "test:unit": "cd ../.. && node tools/run-ttsx.mjs packages/game-services/src/client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/client-progress.test.ts && node tools/run-ttsx.mjs packages/game-services/src/runtime.test.ts && node tools/run-ttsx.mjs packages/game-services/src/types.test.ts && node tools/run-ttsx.mjs packages/game-services/src/server.test.ts && node tools/run-ttsx.mjs packages/game-services/src/google-play-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/google-play-publisher-client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/microsoft-store-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/microsoft-store-identity.test.ts && node tools/run-ttsx.mjs packages/game-services/src/platform-order.test.ts && node tools/run-ttsx.mjs packages/game-services/src/progress-link.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notification-delivery.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/service.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/service-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/scheduler.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/fcm.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/store-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notifications/ait-functional-delivery.test.ts && node tools/run-ttsx.mjs packages/game-services/src/admob-ssv.test.ts && node tools/run-ttsx.mjs packages/game-services/src/app-store-verifier.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-evidence-verification-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-partner-api.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-durability-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-transport.test.ts && node tools/run-ttsx.mjs packages/game-services/src/guest-session.test.ts && node tools/run-ttsx.mjs packages/game-services/src/client-reward-evidence.test.ts && node tools/run-ttsx.mjs packages/game-services/src/ad-reward-verifier-registry.test.ts && node tools/run-ttsx.mjs packages/game-services/src/oneplay-reward.test.ts && node tools/run-ttsx.mjs packages/game-services/src/oneplay-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-registry-routing.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-registry-server.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-ledger-revocation.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-anonymous-auth.test.ts",
238
250
  "test:recovery": "cd ../.. && node tools/run-ttsx.mjs packages/game-services/src/monetization-recovery.test.ts && node tools/run-ttsx.mjs packages/game-services/src/app-store-recovery.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-reconciler.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-reconciler-conformance.test.ts",
239
251
  "test": "pnpm test:unit && pnpm test:app-store-server && pnpm test:recovery && pnpm test:packed-transport",
240
252
  "test:app-store-server": "vitest run"