@mpgd/cli 0.8.0 → 0.10.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/game-acceptance.d.ts +14 -0
- package/dist/game-acceptance.js +318 -5
- package/dist/gameplay-e2e.d.ts +121 -0
- package/dist/gameplay-e2e.js +684 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +57 -3
- package/package.json +11 -11
- package/templates/phaser-game/README.md +14 -9
- package/templates/phaser-game/agent/acceptance.md +13 -7
- package/templates/phaser-game/agent/game-manifest.json +1 -1
- package/templates/phaser-game/apps/target-devvit/README.md +21 -8
- package/templates/phaser-game/apps/target-devvit/package.json +3 -3
- package/templates/phaser-game/apps/target-devvit/src/server/index.ts +5 -352
- package/templates/phaser-game/apps/target-devvit/src/server/postOperationStore.ts +2 -2
- package/templates/phaser-game/mpgd.game.json +42 -0
- package/templates/phaser-game/mpgd.targets.json +0 -1
- package/templates/phaser-game/src/main.ts +2 -0
- package/templates/phaser-game/src/platform/devvitEntrypoint.ts +104 -29
- package/templates/phaser-game/src/platform/devvitInlineMode.css +133 -0
- package/templates/phaser-game/src/runtime/createGame.ts +6 -0
- package/templates/phaser-game/vite.shared.ts +6 -11
- package/templates/phaser-game/src/platform/devvitInlinePreview.css +0 -75
|
@@ -3,7 +3,6 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
|
3
3
|
import { createServer, context, getServerPort, reddit, redis } from '@devvit/web/server';
|
|
4
4
|
import type { UiResponse } from '@devvit/web/shared';
|
|
5
5
|
import {
|
|
6
|
-
assertBridgeRequest,
|
|
7
6
|
createBridgeError,
|
|
8
7
|
type BridgeRequest,
|
|
9
8
|
type BridgeResponse,
|
|
@@ -14,16 +13,9 @@ import {
|
|
|
14
13
|
} from '@mpgd/bridge/orpc';
|
|
15
14
|
import { createBridgeRpcNodeHandler } from '@mpgd/bridge/orpc/node';
|
|
16
15
|
|
|
17
|
-
const redisKeyComponentPattern = /^[A-Za-z0-9:_-]{1,128}$/;
|
|
18
16
|
const maxStorageKeyLength = 128;
|
|
19
17
|
const maxEncodedStorageKeyLength = 384;
|
|
20
|
-
const maxRequestBodySize =
|
|
21
|
-
const legacyBridgeEndpoint = '/api/mpgd/bridge';
|
|
22
|
-
const leaderboardUpdateMaxAttempts = 3;
|
|
23
|
-
const leaderboardBackoffBaseMs = 25;
|
|
24
|
-
const leaderboardLockTtlMs = 2_000;
|
|
25
|
-
const leaderboardLockTtlSeconds = Math.ceil(leaderboardLockTtlMs / 1_000);
|
|
26
|
-
const leaderboardLockRetryBudgetMs = leaderboardLockTtlSeconds * 1_000;
|
|
18
|
+
const maxRequestBodySize = 1_048_576;
|
|
27
19
|
const gameName = '__GAME_NAME__';
|
|
28
20
|
const gameTitle = __GAME_TITLE_TS_LITERAL__;
|
|
29
21
|
const bridgeRpcHandler = createBridgeRpcNodeHandler(
|
|
@@ -59,11 +51,6 @@ async function handleHttpRequest(
|
|
|
59
51
|
return;
|
|
60
52
|
}
|
|
61
53
|
|
|
62
|
-
if (request.method === 'POST' && requestPathname(request) === legacyBridgeEndpoint) {
|
|
63
|
-
await handleLegacyBridgeRequest(request, response);
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
54
|
if (await bridgeRpcHandler(request, response)) {
|
|
68
55
|
return;
|
|
69
56
|
}
|
|
@@ -73,73 +60,6 @@ async function handleHttpRequest(
|
|
|
73
60
|
});
|
|
74
61
|
}
|
|
75
62
|
|
|
76
|
-
async function handleLegacyBridgeRequest(
|
|
77
|
-
request: IncomingMessage,
|
|
78
|
-
response: ServerResponse,
|
|
79
|
-
): Promise<void> {
|
|
80
|
-
let body: unknown;
|
|
81
|
-
|
|
82
|
-
try {
|
|
83
|
-
body = await readJsonRequestBody(request, maxRequestBodySize);
|
|
84
|
-
} catch (error) {
|
|
85
|
-
const responseError = describeLegacyBridgeBodyError(error);
|
|
86
|
-
|
|
87
|
-
if (error instanceof RequestBodyTooLargeError) {
|
|
88
|
-
discardOversizedRequestBody(request, response);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
if (responseError.retryable) {
|
|
92
|
-
console.error(`devvit legacy bridge body read failed: ${errorMessage(error)}`, error);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
sendJson(
|
|
96
|
-
response,
|
|
97
|
-
responseError.status,
|
|
98
|
-
createBridgeError(
|
|
99
|
-
'unknown',
|
|
100
|
-
responseError.code,
|
|
101
|
-
responseError.message,
|
|
102
|
-
responseError.retryable,
|
|
103
|
-
),
|
|
104
|
-
);
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
let bridgeRequest: BridgeRequest;
|
|
109
|
-
|
|
110
|
-
try {
|
|
111
|
-
bridgeRequest = assertBridgeRequest(body);
|
|
112
|
-
} catch (error) {
|
|
113
|
-
console.warn(`devvit legacy bridge validation failed: ${errorMessage(error)}`);
|
|
114
|
-
sendJson(
|
|
115
|
-
response,
|
|
116
|
-
400,
|
|
117
|
-
createBridgeError(
|
|
118
|
-
requestIdFromBody(body),
|
|
119
|
-
'INVALID_BRIDGE_REQUEST',
|
|
120
|
-
'Invalid bridge request.',
|
|
121
|
-
),
|
|
122
|
-
);
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
try {
|
|
127
|
-
sendJson(response, 200, await handleBridgeRequest(bridgeRequest));
|
|
128
|
-
} catch (error) {
|
|
129
|
-
console.error(`devvit legacy bridge request failed: ${errorMessage(error)}`, error);
|
|
130
|
-
sendJson(
|
|
131
|
-
response,
|
|
132
|
-
500,
|
|
133
|
-
createBridgeError(
|
|
134
|
-
bridgeRequest.id,
|
|
135
|
-
'DEVVIT_BRIDGE_INTERNAL_ERROR',
|
|
136
|
-
'Devvit bridge request failed.',
|
|
137
|
-
true,
|
|
138
|
-
),
|
|
139
|
-
);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
63
|
async function handleCreatePostMenu(response: ServerResponse): Promise<void> {
|
|
144
64
|
const subredditName = currentSubredditName();
|
|
145
65
|
|
|
@@ -217,7 +137,7 @@ async function handleBridgeRequest(input: BridgeRequest): Promise<BridgeResponse
|
|
|
217
137
|
nativeAds: false,
|
|
218
138
|
rewardedAds: false,
|
|
219
139
|
interstitialAds: false,
|
|
220
|
-
nativeLeaderboard:
|
|
140
|
+
nativeLeaderboard: false,
|
|
221
141
|
achievements: false,
|
|
222
142
|
cloudSave: true,
|
|
223
143
|
socialShare: false,
|
|
@@ -320,7 +240,9 @@ async function handleBridgeRequest(input: BridgeRequest): Promise<BridgeResponse
|
|
|
320
240
|
});
|
|
321
241
|
|
|
322
242
|
case 'leaderboard.submitScore':
|
|
323
|
-
return
|
|
243
|
+
return ok(input, {
|
|
244
|
+
submitted: false,
|
|
245
|
+
});
|
|
324
246
|
|
|
325
247
|
case 'storage.load':
|
|
326
248
|
return loadStorage(input);
|
|
@@ -337,48 +259,6 @@ async function handleBridgeRequest(input: BridgeRequest): Promise<BridgeResponse
|
|
|
337
259
|
}
|
|
338
260
|
}
|
|
339
261
|
|
|
340
|
-
async function submitLeaderboardScore(input: BridgeRequest): Promise<BridgeResponse> {
|
|
341
|
-
const payload = optionalObjectPayload(input.payload) as {
|
|
342
|
-
readonly leaderboardId?: unknown;
|
|
343
|
-
readonly score?: unknown;
|
|
344
|
-
};
|
|
345
|
-
const leaderboardId = leaderboardIdFromPayload(input, payload.leaderboardId);
|
|
346
|
-
const playerId = currentPlayerId();
|
|
347
|
-
|
|
348
|
-
if (typeof leaderboardId !== 'string') {
|
|
349
|
-
return leaderboardId;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
if (typeof payload.score !== 'number' || !Number.isFinite(payload.score)) {
|
|
353
|
-
return createBridgeError(
|
|
354
|
-
input.id,
|
|
355
|
-
'INVALID_LEADERBOARD_SCORE',
|
|
356
|
-
'Finite leaderboard score is required.',
|
|
357
|
-
);
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
if (playerId === undefined) {
|
|
361
|
-
return ok(input, {
|
|
362
|
-
submitted: false,
|
|
363
|
-
});
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
const redisKey = leaderboardKey(leaderboardId);
|
|
367
|
-
let scoreUpdate: 'updated' | 'unchanged' | 'failed';
|
|
368
|
-
|
|
369
|
-
try {
|
|
370
|
-
scoreUpdate = await submitMaxLeaderboardScore(redisKey, playerId, payload.score);
|
|
371
|
-
} catch (error) {
|
|
372
|
-
console.warn(`devvit leaderboard score submission failed: ${errorMessage(error)}`);
|
|
373
|
-
scoreUpdate = 'failed';
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
return ok(input, {
|
|
377
|
-
submitted: scoreUpdate !== 'failed',
|
|
378
|
-
highScoreUpdated: scoreUpdate === 'updated',
|
|
379
|
-
});
|
|
380
|
-
}
|
|
381
|
-
|
|
382
262
|
async function loadStorage(input: BridgeRequest): Promise<BridgeResponse> {
|
|
383
263
|
const playerId = currentPlayerId();
|
|
384
264
|
|
|
@@ -467,179 +347,6 @@ function storageKey(input: BridgeRequest, playerId: string): string | BridgeResp
|
|
|
467
347
|
return `${gameName}:save:${encodeURIComponent(playerId)}:${encodedKey}`;
|
|
468
348
|
}
|
|
469
349
|
|
|
470
|
-
function leaderboardIdFromPayload(
|
|
471
|
-
input: BridgeRequest,
|
|
472
|
-
value: unknown,
|
|
473
|
-
): string | BridgeResponse {
|
|
474
|
-
if (value === undefined) {
|
|
475
|
-
return 'default';
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
if (typeof value !== 'string' || !isValidRedisKeyComponent(value)) {
|
|
479
|
-
return createBridgeError(
|
|
480
|
-
input.id,
|
|
481
|
-
'INVALID_LEADERBOARD_ID',
|
|
482
|
-
'Leaderboard id format is invalid.',
|
|
483
|
-
);
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
return value;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
function leaderboardKey(leaderboardId: string): string {
|
|
490
|
-
return `${gameName}:leaderboard:${encodeURIComponent(leaderboardId)}`;
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
async function submitMaxLeaderboardScore(
|
|
494
|
-
redisKey: string,
|
|
495
|
-
playerId: string,
|
|
496
|
-
score: number,
|
|
497
|
-
): Promise<'updated' | 'unchanged' | 'failed'> {
|
|
498
|
-
const lockKey = leaderboardLockKey(redisKey, playerId);
|
|
499
|
-
const startedAt = Date.now();
|
|
500
|
-
let attempt = 0;
|
|
501
|
-
|
|
502
|
-
while (Date.now() - startedAt <= leaderboardLockRetryBudgetMs) {
|
|
503
|
-
const lockToken = createLockToken();
|
|
504
|
-
const acquiredLock = await acquireLeaderboardLock(lockKey, lockToken);
|
|
505
|
-
|
|
506
|
-
if (!acquiredLock) {
|
|
507
|
-
await delay(nextLeaderboardRetryDelay(attempt, startedAt));
|
|
508
|
-
attempt += 1;
|
|
509
|
-
continue;
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
try {
|
|
513
|
-
const currentScore = await redis.zScore(redisKey, playerId);
|
|
514
|
-
|
|
515
|
-
if (currentScore !== undefined && score <= currentScore) {
|
|
516
|
-
return 'unchanged';
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
if (await writeLeaderboardScoreIfLockHeld(lockKey, lockToken, redisKey, playerId, score)) {
|
|
520
|
-
return 'updated';
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
await delay(nextLeaderboardRetryDelay(attempt, startedAt));
|
|
524
|
-
attempt += 1;
|
|
525
|
-
} finally {
|
|
526
|
-
try {
|
|
527
|
-
await releaseLeaderboardLock(lockKey, lockToken);
|
|
528
|
-
} catch (error) {
|
|
529
|
-
console.warn(`devvit leaderboard lock release failed: ${errorMessage(error)}`);
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
console.warn(`devvit leaderboard lock contention exceeded retry budget for key: ${lockKey}`);
|
|
535
|
-
|
|
536
|
-
return 'failed';
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
function leaderboardLockKey(redisKey: string, playerId: string): string {
|
|
540
|
-
return `${redisKey}:lock:${encodeURIComponent(playerId)}`;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
function createLockToken(): string {
|
|
544
|
-
const cryptoImpl = globalThis.crypto;
|
|
545
|
-
|
|
546
|
-
if (typeof cryptoImpl?.randomUUID === 'function') {
|
|
547
|
-
return cryptoImpl.randomUUID();
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
if (typeof cryptoImpl?.getRandomValues === 'function') {
|
|
551
|
-
const values = new Uint32Array(4);
|
|
552
|
-
cryptoImpl.getRandomValues(values);
|
|
553
|
-
|
|
554
|
-
return Array.from(values, (value) => value.toString(36).padStart(7, '0')).join('');
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
throw new Error('Web Crypto is required to create a Devvit leaderboard lock token.');
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
async function acquireLeaderboardLock(lockKey: string, lockToken: string): Promise<boolean> {
|
|
561
|
-
const result = await redis.set(lockKey, lockToken, {
|
|
562
|
-
nx: true,
|
|
563
|
-
expiration: leaderboardLockExpirationDate(),
|
|
564
|
-
});
|
|
565
|
-
|
|
566
|
-
return result === 'OK';
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
function leaderboardLockExpirationDate(): Date {
|
|
570
|
-
// Devvit Redis SetOptions expects a Date and converts it to Redis EX seconds internally.
|
|
571
|
-
return new Date(Date.now() + leaderboardLockTtlMs);
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
async function writeLeaderboardScoreIfLockHeld(
|
|
575
|
-
lockKey: string,
|
|
576
|
-
lockToken: string,
|
|
577
|
-
redisKey: string,
|
|
578
|
-
playerId: string,
|
|
579
|
-
score: number,
|
|
580
|
-
): Promise<boolean> {
|
|
581
|
-
const transaction = await redis.watch(lockKey);
|
|
582
|
-
const currentToken = await redis.get(lockKey);
|
|
583
|
-
|
|
584
|
-
if (currentToken !== lockToken) {
|
|
585
|
-
await transaction.unwatch();
|
|
586
|
-
return false;
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
await transaction.multi();
|
|
590
|
-
await transaction.zAdd(redisKey, {
|
|
591
|
-
member: playerId,
|
|
592
|
-
score,
|
|
593
|
-
});
|
|
594
|
-
|
|
595
|
-
const results = await transaction.exec();
|
|
596
|
-
|
|
597
|
-
return Array.isArray(results) && results.length > 0;
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
async function releaseLeaderboardLock(lockKey: string, lockToken: string): Promise<void> {
|
|
601
|
-
for (let attempt = 0; attempt < leaderboardUpdateMaxAttempts; attempt += 1) {
|
|
602
|
-
const transaction = await redis.watch(lockKey);
|
|
603
|
-
const currentToken = await redis.get(lockKey);
|
|
604
|
-
|
|
605
|
-
if (currentToken !== lockToken) {
|
|
606
|
-
await transaction.unwatch();
|
|
607
|
-
return;
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
await transaction.multi();
|
|
611
|
-
await transaction.del(lockKey);
|
|
612
|
-
|
|
613
|
-
const results = await transaction.exec();
|
|
614
|
-
|
|
615
|
-
if (Array.isArray(results) && results.length > 0) {
|
|
616
|
-
return;
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
await delay(leaderboardBackoffBaseMs * (attempt + 1));
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
console.warn(`devvit leaderboard lock release exhausted retries for key: ${lockKey}`);
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
function delay(ms: number): Promise<void> {
|
|
626
|
-
return new Promise((resolve) => {
|
|
627
|
-
setTimeout(resolve, ms);
|
|
628
|
-
});
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
function nextLeaderboardRetryDelay(attempt: number, startedAt: number): number {
|
|
632
|
-
const elapsed = Date.now() - startedAt;
|
|
633
|
-
const remaining = Math.max(0, leaderboardLockRetryBudgetMs - elapsed);
|
|
634
|
-
const backoff = leaderboardBackoffBaseMs * (attempt + 1);
|
|
635
|
-
|
|
636
|
-
return Math.min(backoff, remaining);
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
function isValidRedisKeyComponent(value: string): boolean {
|
|
640
|
-
return redisKeyComponentPattern.test(value);
|
|
641
|
-
}
|
|
642
|
-
|
|
643
350
|
function errorMessage(error: unknown): string {
|
|
644
351
|
return error instanceof Error ? error.message : String(error);
|
|
645
352
|
}
|
|
@@ -690,38 +397,6 @@ function requestPathname(request: IncomingMessage): string {
|
|
|
690
397
|
|
|
691
398
|
class RequestBodyTooLargeError extends Error {}
|
|
692
399
|
|
|
693
|
-
function describeLegacyBridgeBodyError(error: unknown): {
|
|
694
|
-
readonly status: number;
|
|
695
|
-
readonly code: string;
|
|
696
|
-
readonly message: string;
|
|
697
|
-
readonly retryable: boolean;
|
|
698
|
-
} {
|
|
699
|
-
if (error instanceof RequestBodyTooLargeError) {
|
|
700
|
-
return {
|
|
701
|
-
status: 413,
|
|
702
|
-
code: 'BRIDGE_REQUEST_TOO_LARGE',
|
|
703
|
-
message: 'Bridge request body is too large.',
|
|
704
|
-
retryable: false,
|
|
705
|
-
};
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
if (error instanceof SyntaxError) {
|
|
709
|
-
return {
|
|
710
|
-
status: 400,
|
|
711
|
-
code: 'INVALID_BRIDGE_REQUEST',
|
|
712
|
-
message: 'Bridge request body is not valid JSON.',
|
|
713
|
-
retryable: false,
|
|
714
|
-
};
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
return {
|
|
718
|
-
status: 500,
|
|
719
|
-
code: 'DEVVIT_BRIDGE_INTERNAL_ERROR',
|
|
720
|
-
message: 'Bridge request body could not be read.',
|
|
721
|
-
retryable: true,
|
|
722
|
-
};
|
|
723
|
-
}
|
|
724
|
-
|
|
725
400
|
async function* readRequestBodyChunks(
|
|
726
401
|
request: IncomingMessage,
|
|
727
402
|
maxBodySize: number,
|
|
@@ -751,19 +426,6 @@ async function drainRequestBody(
|
|
|
751
426
|
}
|
|
752
427
|
}
|
|
753
428
|
|
|
754
|
-
async function readJsonRequestBody(
|
|
755
|
-
request: IncomingMessage,
|
|
756
|
-
maxBodySize: number,
|
|
757
|
-
): Promise<unknown> {
|
|
758
|
-
const chunks: Buffer[] = [];
|
|
759
|
-
|
|
760
|
-
for await (const chunk of readRequestBodyChunks(request, maxBodySize)) {
|
|
761
|
-
chunks.push(chunk);
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
765
|
-
}
|
|
766
|
-
|
|
767
429
|
function assertContentLengthWithinLimit(
|
|
768
430
|
request: IncomingMessage,
|
|
769
431
|
maxBodySize: number,
|
|
@@ -787,15 +449,6 @@ function discardOversizedRequestBody(
|
|
|
787
449
|
request.resume();
|
|
788
450
|
}
|
|
789
451
|
|
|
790
|
-
function requestIdFromBody(input: unknown): string {
|
|
791
|
-
if (typeof input !== 'object' || input === null || !('id' in input)) {
|
|
792
|
-
return 'unknown';
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
const id = (input as { readonly id?: unknown }).id;
|
|
796
|
-
return typeof id === 'string' && id.length > 0 ? id : 'unknown';
|
|
797
|
-
}
|
|
798
|
-
|
|
799
452
|
function setResponseSecurityHeaders(response: ServerResponse): void {
|
|
800
453
|
response.setHeader('cache-control', 'no-store');
|
|
801
454
|
response.setHeader('content-security-policy', "default-src 'none'; frame-ancestors 'none'");
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { redis } from '@devvit/web/server';
|
|
2
2
|
import {
|
|
3
3
|
createDevvitRedisPostOperationStore,
|
|
4
|
-
type
|
|
4
|
+
type DevvitIndexedDurableOperationStore,
|
|
5
5
|
} from '@mpgd/adapter-devvit/server';
|
|
6
6
|
|
|
7
|
-
export function createPostOperationStore():
|
|
7
|
+
export function createPostOperationStore(): DevvitIndexedDurableOperationStore {
|
|
8
8
|
return createDevvitRedisPostOperationStore(redis);
|
|
9
9
|
}
|
|
@@ -4,5 +4,47 @@
|
|
|
4
4
|
"source": "public/icon.svg",
|
|
5
5
|
"backgroundColor": "#0f172a"
|
|
6
6
|
}
|
|
7
|
+
},
|
|
8
|
+
"acceptance": {
|
|
9
|
+
"gameplay": {
|
|
10
|
+
"schemaVersion": 1,
|
|
11
|
+
"states": [
|
|
12
|
+
{
|
|
13
|
+
"id": "launch-ready",
|
|
14
|
+
"label": "Launch ready",
|
|
15
|
+
"expectation": "The lobby is rendered and ready for primary input.",
|
|
16
|
+
"actions": [
|
|
17
|
+
{
|
|
18
|
+
"type": "wait",
|
|
19
|
+
"durationMs": 500
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"id": "primary-input",
|
|
25
|
+
"label": "Primary input",
|
|
26
|
+
"expectation": "The game enters its playable state after primary input.",
|
|
27
|
+
"actions": [
|
|
28
|
+
{
|
|
29
|
+
"type": "tap",
|
|
30
|
+
"x": 0.5,
|
|
31
|
+
"y": 0.72
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"id": "resume-session",
|
|
37
|
+
"label": "Resume session",
|
|
38
|
+
"expectation": "The active game session remains healthy after pause and resume.",
|
|
39
|
+
"actions": [
|
|
40
|
+
{
|
|
41
|
+
"type": "pause-resume",
|
|
42
|
+
"backgroundMs": 1000,
|
|
43
|
+
"expectSameSession": true
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
}
|
|
7
49
|
}
|
|
8
50
|
}
|
|
@@ -1,57 +1,132 @@
|
|
|
1
1
|
import {
|
|
2
2
|
requestDevvitExpandedMode,
|
|
3
|
-
|
|
3
|
+
startDevvitWebView,
|
|
4
|
+
type DevvitInlineModeContext,
|
|
4
5
|
} from '@mpgd/adapter-devvit/web';
|
|
5
6
|
|
|
6
|
-
await
|
|
7
|
-
async
|
|
8
|
-
await import('./
|
|
9
|
-
|
|
7
|
+
await startDevvitWebView({
|
|
8
|
+
async mountInlineMode(context) {
|
|
9
|
+
await import('./devvitInlineMode.css');
|
|
10
|
+
renderInlineLaunchScreen(context);
|
|
10
11
|
},
|
|
11
|
-
async
|
|
12
|
+
async loadGameplay() {
|
|
12
13
|
await import('../main');
|
|
13
14
|
},
|
|
14
15
|
onModeUnavailable(error) {
|
|
15
16
|
if (!(error instanceof ReferenceError)) {
|
|
16
|
-
console.warn('[devvit] web view mode unavailable; loading
|
|
17
|
+
console.warn('[devvit] web view mode unavailable; loading gameplay.', error);
|
|
17
18
|
}
|
|
18
19
|
},
|
|
19
20
|
});
|
|
20
21
|
|
|
21
|
-
function
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
function renderInlineLaunchScreen(
|
|
23
|
+
context: DevvitInlineModeContext,
|
|
24
|
+
errorMessage?: string,
|
|
25
|
+
): void {
|
|
26
|
+
const launchScreen = document.createElement('main');
|
|
27
|
+
launchScreen.className = 'devvit-launch-screen';
|
|
24
28
|
|
|
25
29
|
const eyebrow = document.createElement('p');
|
|
26
|
-
eyebrow.className = 'devvit-
|
|
27
|
-
eyebrow.textContent = '
|
|
30
|
+
eyebrow.className = 'devvit-launch-screen__eyebrow';
|
|
31
|
+
eyebrow.textContent = 'Inline mode';
|
|
28
32
|
|
|
29
33
|
const title = document.createElement('h1');
|
|
30
34
|
title.textContent = '__GAME_TITLE__';
|
|
31
35
|
|
|
32
36
|
const description = document.createElement('p');
|
|
33
|
-
description.className = 'devvit-
|
|
34
|
-
description.textContent = '
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
37
|
+
description.className = 'devvit-launch-screen__description';
|
|
38
|
+
description.textContent = 'Play directly in the post or open expanded mode.';
|
|
39
|
+
|
|
40
|
+
const actions = document.createElement('div');
|
|
41
|
+
actions.className = 'devvit-launch-screen__actions';
|
|
42
|
+
|
|
43
|
+
const playInlineButton = document.createElement('button');
|
|
44
|
+
playInlineButton.className = 'devvit-launch-screen__button';
|
|
45
|
+
playInlineButton.type = 'button';
|
|
46
|
+
playInlineButton.textContent = 'Play here';
|
|
47
|
+
|
|
48
|
+
const expandButton = document.createElement('button');
|
|
49
|
+
expandButton.className =
|
|
50
|
+
'devvit-launch-screen__button devvit-launch-screen__button--secondary';
|
|
51
|
+
expandButton.type = 'button';
|
|
52
|
+
expandButton.textContent = 'Open expanded mode';
|
|
53
|
+
|
|
54
|
+
const status = document.createElement('p');
|
|
55
|
+
status.className = 'devvit-launch-screen__status';
|
|
56
|
+
status.setAttribute('aria-live', 'polite');
|
|
57
|
+
status.textContent = errorMessage ?? '';
|
|
58
|
+
|
|
59
|
+
playInlineButton.addEventListener('click', () => {
|
|
60
|
+
setBusy(true, 'Loading gameplay…');
|
|
61
|
+
const loading = mountGameplayDocument();
|
|
62
|
+
|
|
63
|
+
void context.startGameplay()
|
|
64
|
+
.then(() => {
|
|
65
|
+
loading.remove();
|
|
66
|
+
})
|
|
67
|
+
.catch((error: unknown) => {
|
|
68
|
+
console.error('[devvit] inline mode gameplay failed to load.', error);
|
|
69
|
+
renderInlineLaunchScreen(context, 'Gameplay could not start. Try again.');
|
|
70
|
+
});
|
|
46
71
|
});
|
|
72
|
+
expandButton.addEventListener('click', (event) => {
|
|
73
|
+
setBusy(true, 'Opening expanded mode…');
|
|
74
|
+
|
|
75
|
+
void requestDevvitExpandedMode(event, 'game')
|
|
76
|
+
.then(() => {
|
|
77
|
+
setBusy(false, '');
|
|
78
|
+
})
|
|
79
|
+
.catch((error: unknown) => {
|
|
80
|
+
console.error('[devvit] expanded mode request failed.', error);
|
|
81
|
+
setBusy(false, 'Expanded mode is unavailable. Try again.');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
actions.append(playInlineButton, expandButton);
|
|
86
|
+
launchScreen.append(eyebrow, title, description, actions, status);
|
|
87
|
+
const body = requireDocumentBody();
|
|
88
|
+
|
|
89
|
+
body.classList.remove('devvit-inline-mode-gameplay');
|
|
90
|
+
body.classList.add('devvit-inline-mode-host');
|
|
91
|
+
delete body.dataset.mpgdPreserveBrowserTouchGestures;
|
|
92
|
+
body.replaceChildren(launchScreen);
|
|
93
|
+
|
|
94
|
+
function setBusy(busy: boolean, message: string): void {
|
|
95
|
+
playInlineButton.disabled = busy;
|
|
96
|
+
expandButton.disabled = busy;
|
|
97
|
+
status.textContent = message;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function mountGameplayDocument(): HTMLElement {
|
|
102
|
+
const body = requireDocumentBody();
|
|
103
|
+
const app = document.createElement('main');
|
|
104
|
+
const game = document.createElement('div');
|
|
105
|
+
const loading = document.createElement('p');
|
|
106
|
+
|
|
107
|
+
app.id = 'app';
|
|
108
|
+
game.id = 'game';
|
|
109
|
+
loading.className = 'devvit-inline-gameplay-loading';
|
|
110
|
+
loading.setAttribute('aria-live', 'polite');
|
|
111
|
+
loading.setAttribute('role', 'status');
|
|
112
|
+
loading.textContent = 'Loading gameplay…';
|
|
113
|
+
game.append(loading);
|
|
114
|
+
app.append(game);
|
|
115
|
+
|
|
116
|
+
body.classList.remove('devvit-inline-mode-host');
|
|
117
|
+
body.classList.add('devvit-inline-mode-gameplay');
|
|
118
|
+
body.dataset.mpgdPreserveBrowserTouchGestures = 'true';
|
|
119
|
+
body.replaceChildren(app);
|
|
120
|
+
|
|
121
|
+
return loading;
|
|
122
|
+
}
|
|
47
123
|
|
|
48
|
-
|
|
124
|
+
function requireDocumentBody(): HTMLElement {
|
|
49
125
|
const body = document.body;
|
|
50
126
|
|
|
51
127
|
if (body === null) {
|
|
52
|
-
throw new Error('Devvit inline
|
|
128
|
+
throw new Error('Devvit inline mode requires a document body.');
|
|
53
129
|
}
|
|
54
130
|
|
|
55
|
-
body
|
|
56
|
-
body.replaceChildren(preview);
|
|
131
|
+
return body;
|
|
57
132
|
}
|