@mpgd/cli 0.36.0 → 0.36.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/cli",
3
- "version": "0.36.0",
3
+ "version": "0.36.1",
4
4
  "description": "Gunshi CLI for mpgd-kit starter generation and target workflow orchestration.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -50,27 +50,27 @@
50
50
  "esbuild": "^0.28.1",
51
51
  "gunshi": "^0.35.1",
52
52
  "semver": "^7.8.5",
53
- "sharp": "0.35.3",
53
+ "sharp": "0.35.5",
54
54
  "typia": "13.0.2",
55
- "undici": "^7.28.0",
55
+ "undici": "^7.29.1",
56
56
  "vite": "8.1.3",
57
57
  "@mpgd/catalog": "0.7.6",
58
- "@mpgd/phaser-assets": "0.7.0",
59
- "@mpgd/target-config": "0.17.0"
58
+ "@mpgd/target-config": "0.17.0",
59
+ "@mpgd/phaser-assets": "0.7.0"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@types/node": "^24.0.0",
63
63
  "@types/semver": "^7.8.0",
64
64
  "ttsc": "0.30.4",
65
65
  "typescript": "7.0.2",
66
- "@mpgd/adapter-ait": "0.12.6",
67
- "@mpgd/adapter-browser": "0.7.7",
68
- "@mpgd/adapter-capacitor": "0.6.0",
69
- "@mpgd/adapter-devvit": "0.9.12",
70
- "@mpgd/adapter-verse8": "0.3.10",
71
- "@mpgd/analytics": "0.4.0",
66
+ "@mpgd/adapter-ait": "0.12.7",
67
+ "@mpgd/adapter-browser": "0.7.8",
68
+ "@mpgd/adapter-capacitor": "0.6.1",
69
+ "@mpgd/adapter-verse8": "0.3.11",
70
+ "@mpgd/adapter-devvit": "0.9.13",
72
71
  "@mpgd/bridge": "0.10.0",
73
- "@mpgd/game-services": "0.17.0",
72
+ "@mpgd/analytics": "0.4.0",
73
+ "@mpgd/game-services": "0.17.1",
74
74
  "@mpgd/i18n": "0.6.5",
75
75
  "@mpgd/platform": "0.14.0"
76
76
  },
@@ -295,6 +295,13 @@ pnpm ait:wrapper:dev:plain
295
295
  pnpm ait:wrapper:dev:sandbox
296
296
  ```
297
297
 
298
+ `pnpm dev:ait` sets `BUILD_ID=ait-sandbox`, and only a non-production build with
299
+ that exact build id selects the `aitSandbox` gateway, whose in-memory bridge
300
+ self-completes purchases and rewarded ads without any backend verification. Any
301
+ other AIT build, including other debug builds, uses the production `ait` gateway
302
+ so the real Apps in Toss SDK and server authority stay in the loop. The same rule
303
+ gates the Devvit sandbox gateway behind `BUILD_ID=devvit-sandbox`.
304
+
298
305
  `ait:wrapper:dev` loads the last game bundle copied by `pnpm build:ait` from the
299
306
  wrapper's `public/game` directory, so run `pnpm build:ait` again after game
300
307
  changes before opening it. The local mock deliberately keeps ads, promotions,
@@ -51,6 +51,18 @@ Games that intentionally support inline gameplay can opt in through
51
51
  An opt-in inline surface must remain tap/click based and preserve Reddit-native
52
52
  gestures across its complete gameplay flow.
53
53
 
54
+ The bridge method handlers live in `src/server/bridge.ts`; `src/server/index.ts`
55
+ only wires the Devvit `context`, `reddit`, and `redis` clients into them. Cloud
56
+ saves are bound to the authenticated Reddit user from the request context and
57
+ stored under `<game-name>:save:<user>:<key>`. Each value is capped at
58
+ `maxStorageValueBytes` and each user may hold at most `maxStorageKeysPerPlayer`
59
+ distinct keys, tracked in the `<game-name>:save-keys:<user>` hash and enforced
60
+ inside a WATCH/MULTI/EXEC transaction so concurrent saves cannot overshoot the
61
+ cap; saves past it fail with `DEVVIT_STORAGE_KEY_LIMIT`. Saves do not expire by
62
+ default; set `storageEntryTtlSeconds` to let idle saves age out. Keep
63
+ `bridge.ts` aligned with the kit's `apps/target-devvit/src/server/bridge.ts`
64
+ when upgrading so generated games inherit storage protections.
65
+
54
66
  The generated bridge does not advertise or accept a generic platform
55
67
  leaderboard. Devvit ranking should be owned by a server completion handler that
56
68
  validates the game-specific attempt, records it through the verified leaderboard
@@ -0,0 +1,513 @@
1
+ import {
2
+ bridgeStorageLoadProtocol,
3
+ createBridgeError,
4
+ type BridgeRequest,
5
+ type BridgeResponse,
6
+ type BridgeStorageLoadData,
7
+ } from '@mpgd/bridge';
8
+
9
+ export const maxStorageKeyLength = 128;
10
+ export const maxEncodedStorageKeyLength = 384;
11
+ export const maxStorageValueBytes = 262_144;
12
+ /**
13
+ * Hard cap on distinct `storage.save` keys per authenticated player. Combined with
14
+ * `maxStorageValueBytes` this bounds the Redis footprint one Reddit account can
15
+ * create inside the installation's shared quota.
16
+ */
17
+ export const maxStorageKeysPerPlayer = 32;
18
+ /**
19
+ * Optional expiry applied to every saved value and to the per-player key index on
20
+ * each save. Cloud saves are expected to persist for the lifetime of the player,
21
+ * so the default is no expiry. Set a positive number of seconds to let idle saves
22
+ * fall out of Redis; the index is refreshed on every save, so stale index fields
23
+ * for expired values still count toward `maxStorageKeysPerPlayer` until the
24
+ * player writes to those keys again.
25
+ */
26
+ export const storageEntryTtlSeconds: number | undefined = undefined;
27
+ /** Redis key namespace used when `storageKeyNamespace` is not injected. */
28
+ export const defaultStorageKeyNamespace = 'mpgd';
29
+ const storageIndexTransactionAttempts = 3;
30
+
31
+ export interface DevvitBridgeRedisTransactionLike {
32
+ multi(): Promise<void>;
33
+ discard(): Promise<unknown>;
34
+ set(key: string, value: string): Promise<unknown>;
35
+ hSet(key: string, fieldValues: Readonly<Record<string, string>>): Promise<unknown>;
36
+ expire(key: string, seconds: number): Promise<unknown>;
37
+ exec(): Promise<readonly unknown[] | null>;
38
+ unwatch(): Promise<unknown>;
39
+ }
40
+
41
+ /** Subset of the Devvit server Redis client used by the bridge storage handlers. */
42
+ export interface DevvitBridgeRedisLike {
43
+ get(key: string): Promise<string | undefined>;
44
+ exists(...keys: readonly string[]): Promise<number>;
45
+ hGet(key: string, field: string): Promise<string | undefined>;
46
+ hLen(key: string): Promise<number>;
47
+ watch(...keys: readonly string[]): Promise<DevvitBridgeRedisTransactionLike>;
48
+ }
49
+
50
+ export interface DevvitBridgeHandlerDependencies {
51
+ readonly redis: DevvitBridgeRedisLike;
52
+ /** Authenticated Reddit user ID from the Devvit request context, never from the payload. */
53
+ readonly currentPlayerId: () => string | undefined;
54
+ readonly currentDisplayName: (playerId: string) => Promise<string>;
55
+ /**
56
+ * Prefix for the per-player value and index keys. Generated games pass their
57
+ * game name so saves stay namespaced per game inside the shared Redis.
58
+ */
59
+ readonly storageKeyNamespace?: string | undefined;
60
+ readonly warn?: ((message: string) => void) | undefined;
61
+ }
62
+
63
+ export type DevvitBridgeHandler = (input: BridgeRequest) => Promise<BridgeResponse>;
64
+
65
+ export function createDevvitBridgeHandler(
66
+ dependencies: DevvitBridgeHandlerDependencies,
67
+ ): DevvitBridgeHandler {
68
+ const warn = dependencies.warn ?? ((message) => {
69
+ console.warn(message);
70
+ });
71
+ const storageKeyNamespace = dependencies.storageKeyNamespace ?? defaultStorageKeyNamespace;
72
+
73
+ if (storageKeyNamespace.length === 0) {
74
+ throw new TypeError('Devvit bridge storageKeyNamespace must not be empty.');
75
+ }
76
+
77
+ return async function handleBridgeRequest(input: BridgeRequest): Promise<BridgeResponse> {
78
+ switch (input.method) {
79
+ case 'runtime.getCapabilities':
80
+ return ok(input, {
81
+ nativeIap: false,
82
+ nativeAds: false,
83
+ rewardedAds: false,
84
+ interstitialAds: false,
85
+ nativeLeaderboard: false,
86
+ remoteLeaderboard: false,
87
+ achievements: false,
88
+ cloudSave: true,
89
+ socialShare: false,
90
+ haptics: false,
91
+ localizedContent: true,
92
+ });
93
+
94
+ case 'identity.getPlayer': {
95
+ const playerId = dependencies.currentPlayerId();
96
+
97
+ if (playerId === undefined) {
98
+ return ok(input, null);
99
+ }
100
+
101
+ return ok(input, {
102
+ playerId,
103
+ displayName: await dependencies.currentDisplayName(playerId),
104
+ });
105
+ }
106
+
107
+ case 'identity.getSession': {
108
+ const playerId = dependencies.currentPlayerId();
109
+
110
+ return ok(
111
+ input,
112
+ playerId === undefined
113
+ ? {
114
+ identityLevel: 'guest',
115
+ trustLevel: 'local',
116
+ }
117
+ : {
118
+ identityLevel: 'authenticated',
119
+ playerId,
120
+ trustLevel: 'server-verified',
121
+ },
122
+ );
123
+ }
124
+
125
+ case 'identity.requestUpgrade': {
126
+ const authenticated = dependencies.currentPlayerId() !== undefined;
127
+
128
+ return ok(input, {
129
+ status: authenticated ? 'completed' : 'unavailable',
130
+ reloadExpected: false,
131
+ });
132
+ }
133
+
134
+ case 'presentation.getLaunchIntent':
135
+ return ok(input, { entry: 'home' });
136
+
137
+ case 'presentation.requestGameSurface':
138
+ return ok(input, 'unavailable');
139
+
140
+ case 'share.share':
141
+ return ok(input, { status: 'unavailable' });
142
+
143
+ case 'share.readInboundShare':
144
+ return ok(input, null);
145
+
146
+ case 'notifications.getStatus':
147
+ return ok(input, 'approval-required');
148
+
149
+ case 'notifications.requestSubscription':
150
+ return ok(input, 'unavailable');
151
+
152
+ case 'commerce.getProducts':
153
+ case 'commerce.getEntitlements':
154
+ return ok(input, []);
155
+
156
+ case 'commerce.purchase':
157
+ return ok(input, {
158
+ status: 'cancelled',
159
+ entitlementIds: [],
160
+ });
161
+
162
+ case 'commerce.restore':
163
+ return ok(input, {
164
+ restoredEntitlements: [],
165
+ });
166
+
167
+ case 'ads.preload':
168
+ return ok(input, {});
169
+
170
+ case 'leaderboard.open':
171
+ return createBridgeError(
172
+ input.id,
173
+ 'DEVVIT_LEADERBOARD_OPEN_UNAVAILABLE',
174
+ 'Devvit leaderboard display is not implemented yet.',
175
+ );
176
+
177
+ case 'ads.showRewarded':
178
+ return ok(input, {
179
+ status: 'unavailable',
180
+ rewardGranted: false,
181
+ });
182
+
183
+ case 'ads.showInterstitial':
184
+ return ok(input, {
185
+ status: 'unavailable',
186
+ });
187
+
188
+ case 'leaderboard.submitScore':
189
+ return ok(input, {
190
+ submitted: false,
191
+ });
192
+
193
+ case 'storage.load':
194
+ return loadStorage(input, dependencies, storageKeyNamespace, warn);
195
+
196
+ case 'storage.save':
197
+ return saveStorage(input, dependencies, storageKeyNamespace, warn);
198
+
199
+ default:
200
+ return createBridgeError(
201
+ input.id,
202
+ 'UNSUPPORTED_METHOD',
203
+ `Unsupported Devvit bridge method: ${input.method}`,
204
+ );
205
+ }
206
+ };
207
+ }
208
+
209
+ async function loadStorage(
210
+ input: BridgeRequest,
211
+ dependencies: DevvitBridgeHandlerDependencies,
212
+ storageKeyNamespace: string,
213
+ warn: (message: string) => void,
214
+ ): Promise<BridgeResponse> {
215
+ const playerId = dependencies.currentPlayerId();
216
+
217
+ if (playerId === undefined) {
218
+ return createBridgeError(
219
+ input.id,
220
+ 'DEVVIT_STORAGE_IDENTITY_REQUIRED',
221
+ 'A current Reddit player is required to load storage.',
222
+ );
223
+ }
224
+
225
+ const location = storageLocation(input, playerId, storageKeyNamespace);
226
+
227
+ if (!('valueKey' in location)) {
228
+ return location;
229
+ }
230
+
231
+ let stored: string | null | undefined;
232
+
233
+ try {
234
+ stored = await dependencies.redis.get(location.valueKey);
235
+ } catch (error) {
236
+ warn(`devvit storage load failed: ${errorMessage(error)}`);
237
+ return createBridgeError(
238
+ input.id,
239
+ 'DEVVIT_STORAGE_LOAD_FAILED',
240
+ 'Devvit storage could not be loaded.',
241
+ true,
242
+ );
243
+ }
244
+
245
+ if (stored === undefined || stored === null) {
246
+ return ok(input, {
247
+ __mpgdBridgeProtocol: bridgeStorageLoadProtocol,
248
+ found: false,
249
+ } satisfies BridgeStorageLoadData);
250
+ }
251
+
252
+ try {
253
+ return ok(
254
+ input,
255
+ {
256
+ __mpgdBridgeProtocol: bridgeStorageLoadProtocol,
257
+ found: true,
258
+ value: JSON.parse(stored),
259
+ } satisfies BridgeStorageLoadData,
260
+ );
261
+ } catch {
262
+ return createBridgeError(input.id, 'CORRUPTED_STORAGE_VALUE', 'Stored data is not valid JSON.');
263
+ }
264
+ }
265
+
266
+ async function saveStorage(
267
+ input: BridgeRequest,
268
+ dependencies: DevvitBridgeHandlerDependencies,
269
+ storageKeyNamespace: string,
270
+ warn: (message: string) => void,
271
+ ): Promise<BridgeResponse> {
272
+ const playerId = dependencies.currentPlayerId();
273
+
274
+ if (playerId === undefined) {
275
+ return createBridgeError(
276
+ input.id,
277
+ 'DEVVIT_STORAGE_IDENTITY_REQUIRED',
278
+ 'A current Reddit player is required to save storage.',
279
+ );
280
+ }
281
+
282
+ const location = storageLocation(input, playerId, storageKeyNamespace);
283
+
284
+ if (!('valueKey' in location)) {
285
+ return location;
286
+ }
287
+
288
+ const payload = optionalObjectPayload(input.payload) as { readonly value?: unknown };
289
+ let serialized: string;
290
+
291
+ try {
292
+ const candidate = JSON.stringify(payload.value);
293
+
294
+ if (typeof candidate !== 'string') {
295
+ throw new Error('JSON serialization did not produce a string.');
296
+ }
297
+
298
+ serialized = candidate;
299
+ } catch {
300
+ return createBridgeError(
301
+ input.id,
302
+ 'INVALID_STORAGE_VALUE',
303
+ 'Storage values must be JSON serializable.',
304
+ );
305
+ }
306
+
307
+ if (new TextEncoder().encode(serialized).length > maxStorageValueBytes) {
308
+ return createBridgeError(
309
+ input.id,
310
+ 'DEVVIT_STORAGE_QUOTA_EXCEEDED',
311
+ `Storage values must not exceed ${String(maxStorageValueBytes)} UTF-8 bytes.`,
312
+ );
313
+ }
314
+
315
+ let persisted: boolean;
316
+
317
+ try {
318
+ persisted = await writeIndexedStorageValue(dependencies.redis, location, serialized);
319
+ } catch (error) {
320
+ warn(`devvit storage save was not persisted: ${errorMessage(error)}`);
321
+ return createBridgeError(
322
+ input.id,
323
+ 'DEVVIT_STORAGE_SAVE_FAILED',
324
+ 'Devvit storage could not be saved.',
325
+ true,
326
+ );
327
+ }
328
+
329
+ if (!persisted) {
330
+ return createBridgeError(
331
+ input.id,
332
+ 'DEVVIT_STORAGE_KEY_LIMIT',
333
+ `Players may store at most ${String(maxStorageKeysPerPlayer)} distinct storage keys.`,
334
+ );
335
+ }
336
+
337
+ return ok(input, {
338
+ saved: true,
339
+ playerId,
340
+ });
341
+ }
342
+
343
+ /**
344
+ * Writes the value and registers its key in the per-player index inside one
345
+ * WATCH/MULTI/EXEC transaction. The cap check reads the index while it is
346
+ * watched, so a concurrent save that changes the index aborts EXEC and the
347
+ * attempt is retried with fresh counts instead of overshooting the cap.
348
+ *
349
+ * The Devvit Redis client (`@devvit/redis` `TxClient.exec()`) never resolves
350
+ * `null` for an aborted transaction; it maps the server reply to an array, so a
351
+ * WATCH abort surfaces as an empty array. An EXEC result with fewer entries than
352
+ * the commands queued after MULTI is therefore treated as a conflict, and `null`
353
+ * is kept as a conflict too for clients that follow the classic Redis contract.
354
+ *
355
+ * Returns `false` when the key is new and the player already holds
356
+ * `maxStorageKeysPerPlayer` keys.
357
+ */
358
+ async function writeIndexedStorageValue(
359
+ redis: DevvitBridgeRedisLike,
360
+ location: StorageLocation,
361
+ serialized: string,
362
+ ): Promise<boolean> {
363
+ for (let attempt = 0; attempt < storageIndexTransactionAttempts; attempt += 1) {
364
+ const transaction = await redis.watch(location.indexKey, location.valueKey);
365
+ let multiStarted = false;
366
+
367
+ try {
368
+ if (!(await playerOwnsStorageKey(redis, location))) {
369
+ const indexedKeyCount = await redis.hLen(location.indexKey);
370
+
371
+ if (indexedKeyCount >= maxStorageKeysPerPlayer) {
372
+ await transaction.unwatch();
373
+ return false;
374
+ }
375
+ }
376
+
377
+ await transaction.multi();
378
+ multiStarted = true;
379
+ let queuedCommandCount = 0;
380
+ await transaction.set(location.valueKey, serialized);
381
+ queuedCommandCount += 1;
382
+ await transaction.hSet(location.indexKey, { [location.indexField]: '1' });
383
+ queuedCommandCount += 1;
384
+
385
+ if (storageEntryTtlSeconds !== undefined) {
386
+ await transaction.expire(location.valueKey, storageEntryTtlSeconds);
387
+ queuedCommandCount += 1;
388
+ await transaction.expire(location.indexKey, storageEntryTtlSeconds);
389
+ queuedCommandCount += 1;
390
+ }
391
+
392
+ const results = await transaction.exec();
393
+
394
+ if (results === null) {
395
+ continue;
396
+ }
397
+
398
+ if (!Array.isArray(results)) {
399
+ throw new Error('Devvit Redis transaction returned an unsupported response.');
400
+ }
401
+
402
+ if (results.length < queuedCommandCount) {
403
+ continue;
404
+ }
405
+
406
+ return true;
407
+ } catch (error) {
408
+ await bestEffortReset(transaction, multiStarted);
409
+ throw error;
410
+ }
411
+ }
412
+
413
+ throw new Error(
414
+ `Devvit Redis transaction contention exceeded ${String(storageIndexTransactionAttempts)} attempts for key: ${location.valueKey}`,
415
+ );
416
+ }
417
+
418
+ async function playerOwnsStorageKey(
419
+ redis: DevvitBridgeRedisLike,
420
+ location: StorageLocation,
421
+ ): Promise<boolean> {
422
+ if ((await redis.hGet(location.indexKey, location.indexField)) !== undefined) {
423
+ return true;
424
+ }
425
+
426
+ // Values written before the index existed are still owned by the player; they
427
+ // are backfilled into the index on their next save instead of counting as new.
428
+ return (await redis.exists(location.valueKey)) > 0;
429
+ }
430
+
431
+ async function bestEffortReset(
432
+ transaction: DevvitBridgeRedisTransactionLike,
433
+ multiStarted: boolean,
434
+ ): Promise<void> {
435
+ try {
436
+ if (multiStarted) {
437
+ await transaction.discard();
438
+ } else {
439
+ await transaction.unwatch();
440
+ }
441
+ } catch {
442
+ // Preserve the original Redis failure; this cleanup is best-effort.
443
+ }
444
+ }
445
+
446
+ function ok(input: BridgeRequest, data: unknown): BridgeResponse {
447
+ return {
448
+ id: input.id,
449
+ ok: true,
450
+ data,
451
+ };
452
+ }
453
+
454
+ interface StorageLocation {
455
+ readonly valueKey: string;
456
+ readonly indexKey: string;
457
+ readonly indexField: string;
458
+ }
459
+
460
+ export function storageValueKey(
461
+ playerId: string,
462
+ clientKey: string,
463
+ namespace: string = defaultStorageKeyNamespace,
464
+ ): string {
465
+ return `${namespace}:save:${encodeURIComponent(playerId)}:${encodeURIComponent(clientKey)}`;
466
+ }
467
+
468
+ export function storageIndexKey(
469
+ playerId: string,
470
+ namespace: string = defaultStorageKeyNamespace,
471
+ ): string {
472
+ return `${namespace}:save-keys:${encodeURIComponent(playerId)}`;
473
+ }
474
+
475
+ function storageLocation(
476
+ input: BridgeRequest,
477
+ playerId: string,
478
+ namespace: string,
479
+ ): StorageLocation | BridgeResponse {
480
+ const payload = optionalObjectPayload(input.payload);
481
+
482
+ if (typeof payload.key !== 'string' || payload.key.length === 0) {
483
+ return createBridgeError(input.id, 'INVALID_STORAGE_KEY', 'Storage key is required.');
484
+ }
485
+
486
+ if (payload.key.length > maxStorageKeyLength) {
487
+ return createBridgeError(input.id, 'INVALID_STORAGE_KEY', 'Storage key is too long.');
488
+ }
489
+
490
+ const encodedKey = encodeURIComponent(payload.key);
491
+
492
+ if (encodedKey.length > maxEncodedStorageKeyLength) {
493
+ return createBridgeError(input.id, 'INVALID_STORAGE_KEY', 'Encoded storage key is too long.');
494
+ }
495
+
496
+ return {
497
+ valueKey: storageValueKey(playerId, payload.key, namespace),
498
+ indexKey: storageIndexKey(playerId, namespace),
499
+ indexField: encodedKey,
500
+ };
501
+ }
502
+
503
+ function errorMessage(error: unknown): string {
504
+ return error instanceof Error ? error.message : String(error);
505
+ }
506
+
507
+ function optionalObjectPayload(payload: unknown): Record<string, unknown> {
508
+ if (typeof payload !== 'object' || payload === null) {
509
+ return {};
510
+ }
511
+
512
+ return payload as Record<string, unknown>;
513
+ }