@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/dist/capacitor-shell-starter.d.ts +21 -0
- package/dist/capacitor-shell-starter.js +121 -1
- package/dist/native-build-info.json +2 -2
- package/dist/native-build-target.js +381 -292
- package/package.json +12 -12
- package/templates/phaser-game/README.md +7 -0
- package/templates/phaser-game/apps/target-devvit/README.md +12 -0
- package/templates/phaser-game/apps/target-devvit/src/server/bridge.ts +513 -0
- package/templates/phaser-game/apps/target-devvit/src/server/index.ts +8 -291
- package/templates/phaser-game/vite.shared.ts +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/cli",
|
|
3
|
-
"version": "0.36.
|
|
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.
|
|
53
|
+
"sharp": "0.35.5",
|
|
54
54
|
"typia": "13.0.2",
|
|
55
|
-
"undici": "^7.
|
|
55
|
+
"undici": "^7.29.1",
|
|
56
56
|
"vite": "8.1.3",
|
|
57
57
|
"@mpgd/catalog": "0.7.6",
|
|
58
|
-
"@mpgd/
|
|
59
|
-
"@mpgd/
|
|
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.
|
|
67
|
-
"@mpgd/adapter-browser": "0.7.
|
|
68
|
-
"@mpgd/adapter-capacitor": "0.6.
|
|
69
|
-
"@mpgd/adapter-
|
|
70
|
-
"@mpgd/adapter-
|
|
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/
|
|
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
|
+
}
|