@playdotfun/game-sdk 1.0.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.
@@ -0,0 +1,1488 @@
1
+ import { ApiClient } from '@/http/client';
2
+ import {
3
+ PaginatedFetchOpts,
4
+ RecursivePartial,
5
+ SDKOpts,
6
+ SDKState,
7
+ Token,
8
+ Game,
9
+ SDKActions,
10
+ SDKEvents,
11
+ Theme,
12
+ SDKMessages,
13
+ Context,
14
+ WidgetScreen,
15
+ ListUserRewardsResponse,
16
+ ClaimRewardsResponse,
17
+ FlushState,
18
+ FlushResponse,
19
+ CommitResponse,
20
+ SessionStateResponse,
21
+ } from '@/types';
22
+ import { decodePointsObf, encodePointsObf } from './obfuscation';
23
+ import { hmacSha256, generateUUID } from './crypto';
24
+ import messaging from './messaging';
25
+ import { WidgetBridge } from '@/widget/bridge';
26
+ import { handleWidgetMessage } from '@/widget/messages';
27
+ import config, { isDevOrigin } from '@/config';
28
+ import { handleCarouselMessages } from '@/carousel/messages';
29
+ import {
30
+ DashboardActions,
31
+ DashboardMessages,
32
+ DashboardScreens,
33
+ UpwardsDashboardActions,
34
+ } from '@play-fun/types/sdk';
35
+ import { DashboardBridge } from '@/dashboard/bridge';
36
+
37
+ interface ReplicateAuthData {
38
+ gameId: string;
39
+ playerId: string;
40
+ identityToken?: string;
41
+ privyAccessToken?: string;
42
+ }
43
+
44
+ export default class OpenGameSDK {
45
+ private api: ApiClient;
46
+ private baseUrl: string = config.apiURL;
47
+
48
+ private state: SDKState;
49
+
50
+ private _widget: WidgetBridge | null = null;
51
+
52
+ private eventHandler: EventTarget = new EventTarget();
53
+
54
+ private messaging = messaging(this.eventHandler);
55
+
56
+ private _pendingLoginCallback: ((args?: any) => void) | null = null;
57
+
58
+ private _isInitializing: boolean = false;
59
+ private _pendingReplicateAuth: ReplicateAuthData | null = null;
60
+ private _replicateAuthResolver: ((data: ReplicateAuthData) => void) | null = null;
61
+
62
+ // Debug: unique instance ID to track if SDK is being recreated
63
+ private _instanceId = Math.random().toString(36).substring(2, 8);
64
+
65
+ // Rate limiting: track last savePoints call time
66
+ private _lastSavePointsTime: number = 0;
67
+
68
+ // Flush protocol state
69
+ private _flushState: FlushState = {
70
+ seq: 0,
71
+ currentHash: '',
72
+ stepKey: '',
73
+ totalPoints: 0,
74
+ initialized: false,
75
+ };
76
+ private _pointsBuffer: number = 0;
77
+ private _localTotal: number = 0;
78
+ private _flushTimer: ReturnType<typeof setTimeout> | null = null;
79
+ private _clientInstanceId: string = this._getOrCreateClientInstanceId();
80
+ private _flushInProgress: boolean = false;
81
+
82
+ // Privy token refresh state
83
+ private _privyTokenResolvers: Map<string, (token: string) => void> = new Map();
84
+
85
+ /**
86
+ * Get or create a client instance ID that persists across page refreshes within the same tab.
87
+ * Uses sessionStorage so:
88
+ * - Same tab, page refresh → same ID (legitimate)
89
+ * - New tab → new ID (detected as multi-tab if same session)
90
+ */
91
+ private _getOrCreateClientInstanceId(): string {
92
+ const STORAGE_KEY = 'ogp_client_instance_id';
93
+ try {
94
+ const existing = sessionStorage.getItem(STORAGE_KEY);
95
+ if (existing) {
96
+ return existing;
97
+ }
98
+ const newId = generateUUID();
99
+ sessionStorage.setItem(STORAGE_KEY, newId);
100
+ return newId;
101
+ } catch {
102
+ // sessionStorage not available (e.g., private browsing in some browsers)
103
+ return generateUUID();
104
+ }
105
+ }
106
+
107
+ emit = this.messaging.emit;
108
+ on = this.messaging.on;
109
+ off = this.messaging.off;
110
+ onAll = this.messaging.onAll;
111
+
112
+ constructor(private readonly opts: SDKOpts) {
113
+ console.log(`[OpenGameSDK:${this._instanceId}] Creating new SDK instance`);
114
+ const {
115
+ apiKey = this._deriveApiKey(),
116
+ gameId = this._deriveGameId(),
117
+ playerId,
118
+ ui = { theme: 'system', usePointsWidget: true, useCustomUi: false },
119
+ isDashboard = false,
120
+ } = opts;
121
+
122
+ this.baseUrl = (opts.baseUrl || this.baseUrl).replace(/\/+$/, '');
123
+
124
+ const detectedDashboard = this._detectDashboardMode();
125
+ console.log(`[OpenGameSDK:${this._instanceId}] Detected dashboard mode:`, detectedDashboard);
126
+ const effectiveIsDashboard = isDashboard || detectedDashboard;
127
+
128
+ if (detectedDashboard && !isDashboard) {
129
+ console.log('[OpenGameSDK] Auto-detected dashboard mode from parent window');
130
+ }
131
+
132
+ this.api = new ApiClient({
133
+ baseUrl: this.baseUrl,
134
+ });
135
+
136
+ this.state = {
137
+ isReady: false,
138
+ apiKey,
139
+ gameId,
140
+ playerId,
141
+ ui,
142
+ isDashboard: effectiveIsDashboard,
143
+ dashboardUrl: config.dashboardURL,
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Detect if the SDK is running inside the dashboard by checking parent window.
149
+ * Uses document.referrer since we can't access cross-origin parent location directly.
150
+ */
151
+ private _detectDashboardMode(): boolean {
152
+ if (window.parent === window) {
153
+ return false;
154
+ }
155
+ const params = new URLSearchParams(window.location.search);
156
+
157
+ const inCarousel = params.get('inCarousel') === 'true';
158
+ const inDashboard = params.get('inDashboard') === 'true';
159
+
160
+ if (inCarousel) {
161
+ return false;
162
+ }
163
+
164
+ if (inDashboard) {
165
+ return true;
166
+ }
167
+
168
+ try {
169
+ const referrer = document.referrer;
170
+ if (referrer) {
171
+ const referrerOrigin = new URL(referrer).origin;
172
+ const dashboardOrigin = new URL(config.dashboardURL).origin;
173
+ if (referrerOrigin === dashboardOrigin) {
174
+ return true;
175
+ }
176
+ }
177
+
178
+ try {
179
+ const parentOrigin = window.parent.location.origin;
180
+ const dashboardOrigin = new URL(config.dashboardURL).origin;
181
+ if (parentOrigin === dashboardOrigin) {
182
+ return true;
183
+ }
184
+ } catch {}
185
+ } catch (e) {
186
+ console.warn('[OpenGameSDK] Error detecting dashboard mode:', e);
187
+ }
188
+
189
+ return false;
190
+ }
191
+
192
+ private async fetchTokens(tokenIds: string[]) {
193
+ if (tokenIds.length === 0) {
194
+ return [];
195
+ }
196
+ try {
197
+ return await this.getTokens(tokenIds);
198
+ } catch (error) {
199
+ console.warn('[OpenGameSDK] Failed to fetch token data, returning empty array:', error);
200
+ return [];
201
+ }
202
+ }
203
+
204
+ async init(args?: { gameId?: string }) {
205
+ console.log('[OpenGameSDK] Initializing SDK...');
206
+ const { gameId } = args ?? {};
207
+ this._isInitializing = true;
208
+
209
+ if (gameId !== undefined && gameId !== this.state.gameId) {
210
+ this.state.gameId = gameId;
211
+ this.state.game = undefined;
212
+ }
213
+
214
+ try {
215
+ if (this.state.isDashboard && window.parent !== window) {
216
+ this._setupDashboardBridge();
217
+
218
+ console.debug(
219
+ '[OpenGameSDK] Dashboard mode: signaling ready and waiting for ReplicateAuth...',
220
+ );
221
+
222
+ if (this.opts.ui?.usePointsWidget || !this.opts.ui?.useCustomUI) {
223
+ console.log(`[OpenGameSDK] Initializing SDK Widget Bridge...`);
224
+ this._widget = new WidgetBridge((msg) => handleWidgetMessage(this, msg));
225
+
226
+ const url = this.getWidgetUrl();
227
+ await this._widget.init(document.body, url);
228
+ console.log('[OpenGameSDK] Widget Bridge Initialized Successfully!');
229
+ }
230
+ this._sendReadyToDashboard();
231
+ const authData = await this._waitForReplicateAuth(30_000);
232
+
233
+ if (authData) {
234
+ this.state.gameId = authData.gameId;
235
+ this.state.playerId = authData.playerId;
236
+ if (authData.identityToken) {
237
+ this.state.identityToken = authData.identityToken;
238
+ }
239
+ if (authData.privyAccessToken) {
240
+ this.state.privyAccessToken = authData.privyAccessToken;
241
+ }
242
+ }
243
+ } else if (window.parent !== window) {
244
+ console.log(`[OpenGameSDK] Initializing SDK Widget Bridge (embed mode)...`);
245
+ this._widget = new WidgetBridge((msg) => handleWidgetMessage(this, msg));
246
+
247
+ const url = this.getWidgetUrl();
248
+ await this._widget.init(document.body, url);
249
+ console.log('[OpenGameSDK] Widget Bridge Initialized Successfully!');
250
+ } else {
251
+ if (this.opts.ui?.usePointsWidget || !this.opts.ui?.useCustomUI) {
252
+ console.log(`[OpenGameSDK] Initializing SDK Widget Bridge...`);
253
+ this._widget = new WidgetBridge((msg) => handleWidgetMessage(this, msg));
254
+
255
+ const url = this.getWidgetUrl();
256
+ await this._widget.init(document.body, url);
257
+ console.log('[OpenGameSDK] Widget Bridge Initialized Successfully!');
258
+ }
259
+ }
260
+
261
+ let points = 0;
262
+ let tokens: Token[] = this.state.pointsDisplay?.tokens ?? [];
263
+ let activeMultiplier = 1;
264
+ let game: Game | undefined;
265
+
266
+ if (this.state.gameId) {
267
+ const hasPrivyAuth = !!this.state.privyAccessToken;
268
+ const [_game, sessionToken] = await Promise.all([
269
+ this.getGame(this.state.gameId),
270
+ hasPrivyAuth
271
+ ? this.getSessionToken(this.state.apiKey, this.state.gameId)
272
+ : Promise.resolve(null),
273
+ ]);
274
+
275
+ if (_game) {
276
+ game = _game;
277
+ }
278
+
279
+ if (sessionToken) {
280
+ this.state.session = { token: sessionToken, updatedAt: Date.now() };
281
+ this.updateAndReplicate({
282
+ session: { token: sessionToken, updatedAt: Date.now() },
283
+ });
284
+ }
285
+
286
+ // Determine which tokens to fetch
287
+ const tokenAddresses = Object.keys(_game?.tokenSplits ?? {});
288
+ const filteredTokens = tokenAddresses.filter((t) => t !== '__GAMECOIN_PLACEHOLDER__');
289
+ const needsTokenFetch = filteredTokens.length > 0;
290
+
291
+ // Parallel fetch: tokens and points (now that we have session and game data)
292
+ const [fetchedTokens, pointsData] = await Promise.all([
293
+ needsTokenFetch ? this.getTokens(filteredTokens) : Promise.resolve([]),
294
+ hasPrivyAuth ? this.getPoints() : Promise.resolve({ points: '0', activeMultiplier: 1 }),
295
+ ]);
296
+
297
+ tokens = fetchedTokens;
298
+ if (pointsData.points) {
299
+ points = parseInt(pointsData.points);
300
+ }
301
+ if (
302
+ pointsData.activeMultiplier &&
303
+ typeof pointsData.activeMultiplier === 'number' &&
304
+ pointsData.activeMultiplier > 0
305
+ ) {
306
+ activeMultiplier = pointsData.activeMultiplier;
307
+ }
308
+ }
309
+
310
+ if (!gameId || !this.state.gameId || !this.state.playerId) {
311
+ console.warn(
312
+ '[OpenGameSDK] No gameId or playerId found on initialization. Please pass a gameId and playerId to the SDK constructor to initialize with a valid session. Or call loadGame() instead of init() to load a new game session.',
313
+ );
314
+ }
315
+
316
+ this._setupCarouselBridge();
317
+
318
+ this.updateAndReplicate({
319
+ isReady: true,
320
+ game,
321
+ pointsDisplay: {
322
+ show: true,
323
+ points,
324
+ activeMultiplier,
325
+ tokens,
326
+ },
327
+ });
328
+
329
+ this.emit(SDKEvents.OnReady);
330
+ console.log('[OpenGameSDK] SDK Initialized Successfully');
331
+ } finally {
332
+ this._isInitializing = false;
333
+ // Clear any pending auth since we've completed init
334
+ this._pendingReplicateAuth = null;
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Wait for ReplicateAuth message with a timeout.
340
+ * Returns the auth data if received, or null if timeout.
341
+ */
342
+ private async _waitForReplicateAuth(timeoutMs: number): Promise<ReplicateAuthData | null> {
343
+ // Check if we already have pending auth data
344
+ if (this._pendingReplicateAuth) {
345
+ console.log('[OpenGameSDK] Using already received auth data');
346
+ const data = this._pendingReplicateAuth;
347
+ this._pendingReplicateAuth = null;
348
+ return data;
349
+ }
350
+
351
+ // Wait for auth data with timeout
352
+ return new Promise<ReplicateAuthData | null>((resolve) => {
353
+ const timeout = setTimeout(() => {
354
+ console.log('[OpenGameSDK] Timeout waiting for ReplicateAuth');
355
+ this._replicateAuthResolver = null;
356
+ resolve(null);
357
+ }, timeoutMs);
358
+
359
+ this._replicateAuthResolver = (data: ReplicateAuthData) => {
360
+ clearTimeout(timeout);
361
+ resolve(data);
362
+ };
363
+
364
+ // Check again in case data arrived while setting up
365
+ if (this._pendingReplicateAuth) {
366
+ clearTimeout(timeout);
367
+ this._replicateAuthResolver = null;
368
+ const data = this._pendingReplicateAuth;
369
+ this._pendingReplicateAuth = null;
370
+ resolve(data);
371
+ }
372
+ });
373
+ }
374
+
375
+ /** Public Helper Methods */
376
+ async loadGame(gameId: string, playerId: string, privyAccessToken?: string) {
377
+ // Only skip if we have matching IDs AND a valid session token
378
+ const hasValidSession = !!this.state.session?.token;
379
+ if (this.state.gameId === gameId && this.state.playerId === playerId && hasValidSession) {
380
+ return;
381
+ }
382
+
383
+ // Set privyAccessToken if provided (needed for auth header in getSessionToken)
384
+ if (privyAccessToken) {
385
+ this.state.privyAccessToken = privyAccessToken;
386
+ }
387
+
388
+ // Parallel fetch: session token and game data (both only need gameId)
389
+ // Note: playerId parameter is deprecated - server returns canonical playerId from auth
390
+ const needsGameFetch = this.state.game?.id !== gameId;
391
+ const [token, game] = await Promise.all([
392
+ this.getSessionToken(this.state.apiKey, gameId),
393
+ needsGameFetch ? this.getGame(gameId) : Promise.resolve(this.state.game),
394
+ ]);
395
+
396
+ if (!game) {
397
+ throw new Error(`Game ${gameId} not found`);
398
+ }
399
+
400
+ this.state.session = { token, updatedAt: Date.now() };
401
+
402
+ const tokenAddresses = Object.keys(game?.tokenSplits ?? {});
403
+ const filteredTokens = tokenAddresses.filter((t) => t !== '__GAMECOIN_PLACEHOLDER__');
404
+ const needsTokenFetch = filteredTokens.length > 0;
405
+
406
+ const [_tokens, pointsData] = await Promise.all([
407
+ needsTokenFetch ? this.getTokens(filteredTokens) : Promise.resolve([]),
408
+ this.getPoints(),
409
+ ]);
410
+
411
+ const { points, activeMultiplier } = pointsData;
412
+
413
+ this.updateAndReplicate({
414
+ gameId,
415
+ game,
416
+ // playerId is set by getSessionToken from server response
417
+ session: { token, updatedAt: Date.now() },
418
+ pointsDisplay: {
419
+ show: this.ui?.usePointsWidget ?? true,
420
+ points: parseInt(points),
421
+ activeMultiplier,
422
+ tokens: _tokens,
423
+ },
424
+ });
425
+
426
+ if (this._pendingLoginCallback) {
427
+ this.widget?.sendMessage({
428
+ action: SDKActions.UI_ShowScreen,
429
+ data: {
430
+ screen: WidgetScreen.Loading,
431
+ },
432
+ });
433
+ const callback = this._pendingLoginCallback;
434
+ this._pendingLoginCallback = null;
435
+ callback();
436
+ }
437
+ }
438
+
439
+ async addPoints(points: number) {
440
+ this._checkReady();
441
+ if (!this.state.gameId) {
442
+ throw new Error(
443
+ 'Must have an active game ID to add points. Please call init or loadGame first.',
444
+ );
445
+ }
446
+
447
+ // Initialize flush state if needed
448
+ if (!this._flushState.initialized && this.hasSession) {
449
+ await this._initFlushState();
450
+ }
451
+
452
+ // Add to local buffer
453
+ this._pointsBuffer += points;
454
+ this._localTotal += points;
455
+
456
+ // Update UI immediately (optimistic)
457
+ const activeMultiplier = this.state.pointsDisplay?.activeMultiplier ?? 1;
458
+ const multipliedPoints = Math.floor(points * activeMultiplier);
459
+ const newDisplayPoints = (this.state.pointsDisplay?.points ?? 0) + multipliedPoints;
460
+
461
+ // Also update legacy sessionPoints for backwards compatibility
462
+ const sessionPoints = this.state.sessionPoints ? decodePointsObf(this.state.sessionPoints) : 0;
463
+ const newSessionPoints = encodePointsObf(sessionPoints + points);
464
+
465
+ this.updateAndReplicate({
466
+ sessionPoints: newSessionPoints,
467
+ pointsDisplay: {
468
+ ...this.state.pointsDisplay,
469
+ points: newDisplayPoints,
470
+ },
471
+ });
472
+
473
+ // Schedule flush
474
+ this._scheduleFlush();
475
+ }
476
+
477
+ async decrPoints(points: number) {
478
+ this._checkReady();
479
+ if (!this.state.gameId) {
480
+ throw new Error(
481
+ 'Must have an active game ID to decrease points. Please call init or loadGame first.',
482
+ );
483
+ }
484
+
485
+ // Decrease from buffer (can go negative for deductions)
486
+ this._pointsBuffer = Math.max(0, this._pointsBuffer - points);
487
+ this._localTotal = Math.max(0, this._localTotal - points);
488
+
489
+ const sessionPoints = this.state.sessionPoints ? decodePointsObf(this.state.sessionPoints) : 0;
490
+ const newSessionPoints = Math.max(0, sessionPoints - points);
491
+ const encodedSessionPoints = encodePointsObf(newSessionPoints);
492
+
493
+ const activeMultiplier = this.state.pointsDisplay?.activeMultiplier ?? 1;
494
+ const multipliedPoints = Math.floor(points * activeMultiplier);
495
+ const newDisplayPoints = Math.max(
496
+ 0,
497
+ (this.state.pointsDisplay?.points ?? 0) - multipliedPoints,
498
+ );
499
+
500
+ this.updateAndReplicate({
501
+ sessionPoints: encodedSessionPoints,
502
+ pointsDisplay: {
503
+ ...this.state.pointsDisplay,
504
+ points: newDisplayPoints,
505
+ },
506
+ });
507
+ }
508
+
509
+ /**
510
+ * Initialize flush state by fetching from server.
511
+ */
512
+ private async _initFlushState(): Promise<void> {
513
+ if (this._flushState.initialized) return;
514
+
515
+ try {
516
+ const response = await this.fetch<SessionStateResponse>(
517
+ '/play/session-state',
518
+ { method: 'GET' },
519
+ true,
520
+ );
521
+
522
+ this._flushState = {
523
+ seq: response.lastSeq,
524
+ currentHash: response.lastHash,
525
+ stepKey: response.stepKey,
526
+ totalPoints: response.totalPoints,
527
+ initialized: true,
528
+ };
529
+
530
+ console.log(`[OpenGameSDK:${this._instanceId}] Flush state initialized:`, {
531
+ seq: this._flushState.seq,
532
+ totalPoints: this._flushState.totalPoints,
533
+ });
534
+ } catch (error) {
535
+ console.error('[OpenGameSDK] Failed to initialize flush state:', error);
536
+ throw error;
537
+ }
538
+ }
539
+
540
+ /**
541
+ * Schedule a flush to happen after a delay.
542
+ */
543
+ private _scheduleFlush(): void {
544
+ if (this._flushTimer) return;
545
+ this._flushTimer = setTimeout(() => this._flush(), 5000); // 5 second delay
546
+ }
547
+
548
+ /**
549
+ * Flush buffered points to server.
550
+ */
551
+ private async _flush(): Promise<void> {
552
+ this._flushTimer = null;
553
+ if (this._pointsBuffer <= 0 || this._flushInProgress) return;
554
+ if (!this.hasSession) return;
555
+
556
+ // Initialize flush state if needed
557
+ if (!this._flushState.initialized) {
558
+ try {
559
+ await this._initFlushState();
560
+ } catch (error) {
561
+ console.error('[OpenGameSDK] Failed to init flush state during flush:', error);
562
+ this._scheduleFlush(); // Retry later
563
+ return;
564
+ }
565
+ }
566
+
567
+ this._flushInProgress = true;
568
+ const pointsToFlush = this._pointsBuffer;
569
+ this._pointsBuffer = 0;
570
+ const nextSeq = this._flushState.seq + 1;
571
+
572
+ try {
573
+ // Compute proof with current step key
574
+ const sessionKey = `${this.state.playerId}:${this.state.gameId}`;
575
+ const proofData = `${sessionKey}|${nextSeq}|${pointsToFlush}|${this._flushState.currentHash}`;
576
+ const proof = await hmacSha256(this._flushState.stepKey, proofData);
577
+
578
+ const result = await this.fetch<FlushResponse>(
579
+ '/play/flush',
580
+ {
581
+ method: 'POST',
582
+ body: JSON.stringify({
583
+ seq: nextSeq,
584
+ points: pointsToFlush,
585
+ prevHash: this._flushState.currentHash,
586
+ proof,
587
+ clientInstanceId: this._clientInstanceId,
588
+ }),
589
+ },
590
+ true,
591
+ );
592
+
593
+ // Update state with server response
594
+ this._flushState.seq = result.seq;
595
+ this._flushState.currentHash = result.newHash;
596
+ this._flushState.stepKey = result.nextStepKey;
597
+ this._flushState.totalPoints = result.total;
598
+
599
+ console.log(`[OpenGameSDK:${this._instanceId}] Flush successful:`, {
600
+ seq: result.seq,
601
+ added: result.added,
602
+ total: result.total,
603
+ });
604
+
605
+ // Reconcile if server total differs significantly
606
+ if (Math.abs(result.total - this._localTotal) > pointsToFlush) {
607
+ console.warn(
608
+ `[OpenGameSDK] Points reconciliation needed: local=${this._localTotal}, server=${result.total}`,
609
+ );
610
+ this._localTotal = result.total;
611
+ }
612
+ } catch (error: any) {
613
+ console.error('[OpenGameSDK] Flush failed:', error);
614
+ // Re-add to buffer on failure
615
+ this._pointsBuffer += pointsToFlush;
616
+ // Retry after delay
617
+ this._scheduleFlush();
618
+ } finally {
619
+ this._flushInProgress = false;
620
+ }
621
+ }
622
+
623
+ async savePoints() {
624
+ this._checkReady();
625
+
626
+ // Rate limit: only allow savePoints once every 5 seconds
627
+ const now = Date.now();
628
+ const timeSinceLastSave = now - this._lastSavePointsTime;
629
+ if (timeSinceLastSave < 5000) {
630
+ const remainingTime = Math.ceil((5000 - timeSinceLastSave) / 1000);
631
+ throw new Error(
632
+ `Rate limit exceeded. Please wait ${remainingTime} more second(s) before saving points again.`,
633
+ );
634
+ }
635
+ this._lastSavePointsTime = now;
636
+
637
+ console.log(
638
+ `[OpenGameSDK:${this._instanceId}] savePoints called, hasSession:`,
639
+ this.hasSession,
640
+ );
641
+ if (!this.hasSession) {
642
+ console.log(
643
+ `[OpenGameSDK:${this._instanceId}] No session, setting _pendingLoginCallback and triggering login`,
644
+ );
645
+ this._pendingLoginCallback = () => this.savePoints();
646
+ await this.login();
647
+ console.log(
648
+ `[OpenGameSDK:${this._instanceId}] login() returned, _pendingLoginCallback is:`,
649
+ !!this._pendingLoginCallback,
650
+ );
651
+ return;
652
+ }
653
+
654
+ // Check if we have points to save (either in buffer, flush state, or legacy sessionPoints)
655
+ const hasLegacyPoints = this.state.sessionPoints && decodePointsObf(this.state.sessionPoints) > 0;
656
+ const hasBufferedPoints = this._pointsBuffer > 0 || this._flushState.totalPoints > 0;
657
+
658
+ if (!hasLegacyPoints && !hasBufferedPoints) {
659
+ throw new Error(
660
+ 'No session points to save. Use addPoints() to accumulate points before saving.',
661
+ );
662
+ }
663
+
664
+ const callbackId = Math.random().toString(36).substring(2, 15);
665
+
666
+ if (!this.isDashboard && this.widget) {
667
+ this.widget?.sendMessage({
668
+ action: SDKActions.UI_ShowScreen,
669
+ data: {
670
+ screen: WidgetScreen.Loading,
671
+ },
672
+ });
673
+ } else if (this.isDashboard) {
674
+ DashboardBridge.sendMessage({
675
+ action: UpwardsDashboardActions.TriggerNotification,
676
+ data: {
677
+ title: 'Saving Points...',
678
+ callbackId,
679
+ type: 'promise',
680
+ },
681
+ });
682
+ }
683
+
684
+ try {
685
+ // Flush any remaining buffer first
686
+ if (this._pointsBuffer > 0) {
687
+ // Cancel scheduled flush and flush immediately
688
+ if (this._flushTimer) {
689
+ clearTimeout(this._flushTimer);
690
+ this._flushTimer = null;
691
+ }
692
+ await this._flush();
693
+ }
694
+
695
+ // Wait for any in-progress flush to complete
696
+ while (this._flushInProgress) {
697
+ await new Promise((resolve) => setTimeout(resolve, 100));
698
+ }
699
+
700
+ const pointsBeforeCommit = this._flushState.totalPoints || this._localTotal;
701
+
702
+ // Commit the accumulated points
703
+ const result = await this.fetch<CommitResponse>(
704
+ '/play/commit',
705
+ {
706
+ method: 'POST',
707
+ body: JSON.stringify({
708
+ seq: this._flushState.seq,
709
+ prevHash: this._flushState.currentHash,
710
+ referrer: this.state.referral?.referrerId,
711
+ distributors: this.state.distributorKeys,
712
+ }),
713
+ },
714
+ true,
715
+ );
716
+
717
+ console.log(`[OpenGameSDK:${this._instanceId}] Commit successful:`, result);
718
+
719
+ // Reset flush state for next session
720
+ this._flushState = {
721
+ seq: 0,
722
+ currentHash: '',
723
+ stepKey: '',
724
+ totalPoints: 0,
725
+ initialized: false,
726
+ };
727
+ this._pointsBuffer = 0;
728
+ this._localTotal = 0;
729
+
730
+ // Get updated points from server
731
+ const { points } = await this.getPoints();
732
+ this.updateAndReplicate({
733
+ sessionPoints: undefined,
734
+ pointsDisplay: {
735
+ ...this.state.pointsDisplay,
736
+ points: parseInt(points),
737
+ },
738
+ });
739
+
740
+ if (!this.state.ui?.useCustomUI) {
741
+ if (!this.isDashboard && this.widget) {
742
+ return this._widget?.sendMessage({
743
+ action: SDKActions.UI_ShowScreen,
744
+ data: {
745
+ screen: WidgetScreen.BoostPoints,
746
+ opts: {
747
+ points: pointsBeforeCommit,
748
+ },
749
+ },
750
+ });
751
+ } else if (this.isDashboard) {
752
+ DashboardBridge.sendMessage({
753
+ action: UpwardsDashboardActions.NotificationPromiseResult,
754
+ data: {
755
+ callbackId,
756
+ result: true,
757
+ title: 'Points saved successfully!',
758
+ },
759
+ });
760
+ return DashboardBridge.sendMessage({
761
+ action: UpwardsDashboardActions.ShowScreen,
762
+ data: {
763
+ screen: DashboardScreens.BoostPoints,
764
+ opts: {
765
+ points: pointsBeforeCommit,
766
+ },
767
+ },
768
+ });
769
+ }
770
+ }
771
+
772
+ return result;
773
+ } catch (e) {
774
+ console.error('[OpenGameSDK] Error saving points:', e);
775
+ const { points } = await this.getPoints();
776
+ if (points) {
777
+ this.updateAndReplicate({
778
+ pointsDisplay: {
779
+ ...this.state.pointsDisplay,
780
+ points: parseInt(points),
781
+ },
782
+ });
783
+ }
784
+ if (!this.state.ui?.useCustomUI) {
785
+ if (!this.isDashboard && this.widget) {
786
+ return this._widget?.sendMessage({
787
+ action: SDKActions.UI_ShowScreen,
788
+ data: {
789
+ screen: WidgetScreen.Error,
790
+ opts: {
791
+ error: e,
792
+ },
793
+ },
794
+ });
795
+ } else if (this.isDashboard) {
796
+ DashboardBridge.sendMessage({
797
+ action: UpwardsDashboardActions.NotificationPromiseResult,
798
+ data: {
799
+ callbackId,
800
+ result: false,
801
+ title: 'An error occurred while saving points',
802
+ },
803
+ });
804
+ }
805
+ }
806
+ }
807
+ }
808
+
809
+ async showClaim() {
810
+ this._checkReady();
811
+ if (!this.state.isEmbedSession && !this.isDashboard && this._widget) {
812
+ return this._widget.sendMessage({
813
+ action: SDKActions.UI_ShowScreen,
814
+ data: {
815
+ screen: WidgetScreen.Claim,
816
+ opts: {},
817
+ },
818
+ });
819
+ } else if (this.isDashboard) {
820
+ return DashboardBridge.sendMessage({
821
+ action: UpwardsDashboardActions.ShowScreen,
822
+ data: {
823
+ screen: DashboardScreens.Claim,
824
+ opts: {},
825
+ },
826
+ });
827
+ } else {
828
+ throw new Error('Cannot show claim screen outside of widget or dashboard mode.');
829
+ }
830
+ }
831
+
832
+ async login(callback?: string) {
833
+ this._checkReady();
834
+ if (!this.state.isEmbedSession && !this.isDashboard && this._widget) {
835
+ return this._widget.sendMessage({
836
+ action: SDKActions.UI_ShowScreen,
837
+ data: {
838
+ screen: WidgetScreen.Login,
839
+ opts: {
840
+ callback,
841
+ },
842
+ },
843
+ });
844
+ } else if (this.isDashboard) {
845
+ return DashboardBridge.sendMessage({
846
+ action: UpwardsDashboardActions.TriggerLogin,
847
+ data: undefined,
848
+ });
849
+ }
850
+ return this._sendMessageToCarousel({
851
+ action: SDKActions.TriggerPrivyLogin,
852
+ data: undefined,
853
+ });
854
+ }
855
+
856
+ /** API Methods */
857
+ async getGame(gameId: string) {
858
+ return await this.fetch<Game>(`/games/id/${gameId}?include_extra=true`);
859
+ }
860
+
861
+ async getGames({ ...opts }: PaginatedFetchOpts) {
862
+ return await this.paginatedFetch<Game>('/games', opts);
863
+ }
864
+
865
+ async getToken(tokenId: string) {
866
+ return await this.fetch<Token>(`/tokens/${tokenId}`);
867
+ }
868
+
869
+ async getTokens(tokenIds: string[]) {
870
+ return await this.fetch<Token[]>(`/tokens/?query=${tokenIds.join(',')}`);
871
+ }
872
+
873
+ async getPoints() {
874
+ return await this.fetch<{ points: string; activeMultiplier: number }>(
875
+ '/play/get-points',
876
+ { method: 'GET' },
877
+ true,
878
+ );
879
+ }
880
+
881
+ async listUserRewards() {
882
+ return await this.fetch<ListUserRewardsResponse>('/play/rewards', { method: 'GET' }, true);
883
+ }
884
+
885
+ async claimRewards(addresses: string[]) {
886
+ return await this.fetch<ClaimRewardsResponse>(
887
+ `/play/claim-rewards`,
888
+ {
889
+ method: 'POST',
890
+ body: JSON.stringify({ addresses }),
891
+ },
892
+ true,
893
+ );
894
+ }
895
+
896
+ setIdentityToken(token: string) {
897
+ this.updateAndReplicate({
898
+ identityToken: token,
899
+ });
900
+ }
901
+
902
+ setReferrer(referrerId: string, refferredGameId: string) {
903
+ this.updateAndReplicate({
904
+ referral: { referrerId, referredGameId: refferredGameId },
905
+ });
906
+ }
907
+
908
+ setEmbedId(embedId: string) {
909
+ this.updateAndReplicate({
910
+ embedId,
911
+ });
912
+ }
913
+
914
+ setDistributorKeys(distributorKeys: Record<string, string>) {
915
+ this.updateAndReplicate({
916
+ distributorKeys,
917
+ });
918
+ }
919
+
920
+ setContext(context: Context | undefined) {
921
+ this.updateAndReplicate({
922
+ context,
923
+ });
924
+ }
925
+
926
+ /** UI Methods */
927
+
928
+ showPoints() {
929
+ if (this.state.ui?.usePointsWidget) {
930
+ return;
931
+ }
932
+ this.updateAndReplicate({
933
+ pointsDisplay: {
934
+ ...this.state.pointsDisplay,
935
+ show: true,
936
+ },
937
+ });
938
+ this.widget?.show({
939
+ maxHeight: 'auto',
940
+ height: 'auto',
941
+ });
942
+ }
943
+
944
+ hidePoints() {
945
+ this.updateAndReplicate({
946
+ pointsDisplay: {
947
+ ...this.state.pointsDisplay,
948
+ show: false,
949
+ },
950
+ });
951
+ this.widget?.hide();
952
+ }
953
+
954
+ setTheme(theme: Theme) {
955
+ this.widget?.setTheme(theme);
956
+ }
957
+
958
+ /** Internal Methods */
959
+
960
+ private _checkReady() {
961
+ if (!this.state.isReady) {
962
+ throw new Error('SDK not initialized. Call init() before using the SDK.');
963
+ }
964
+ }
965
+
966
+ private async fetch<T>(endpoint: string, options?: RequestInit, authed: boolean = false) {
967
+ let bearerToken: string | undefined;
968
+ if (authed) {
969
+ const { token, updatedAt } = this.state.session ?? {};
970
+
971
+ if (!token || (updatedAt ?? 0) + 1000 * 60 * 30 < Date.now()) {
972
+ const gameId = this.state.gameId;
973
+ const apiKey = this.state.apiKey;
974
+ if (!apiKey) {
975
+ throw new Error(
976
+ 'No API key found. Please pass an API key to the SDK constructor or include an ogp-key meta tag in your HTML.',
977
+ );
978
+ }
979
+ if (!gameId) {
980
+ throw new Error('No Game ID found. Failed to fetch session token.');
981
+ }
982
+
983
+ // Get fresh Privy token from widget if needed
984
+ if (!this.state.privyAccessToken && this._widget) {
985
+ console.info('[OpenGameSDK] Requesting fresh Privy token from widget');
986
+ await this.requestPrivyToken();
987
+ }
988
+ if (!this.state.privyAccessToken) {
989
+ throw new Error('No Privy access token found. User must be authenticated.');
990
+ }
991
+
992
+ console.info('[OpenGameSDK] Updating session token for game:', gameId);
993
+ const token = await this.getSessionToken(apiKey, gameId);
994
+ this.updateAndReplicate({
995
+ session: {
996
+ token,
997
+ updatedAt: Date.now(),
998
+ },
999
+ });
1000
+ bearerToken = `Bearer ${token}`;
1001
+ } else {
1002
+ bearerToken = `Bearer ${token}`;
1003
+ }
1004
+ }
1005
+
1006
+ const isFormData = options?.body instanceof FormData;
1007
+
1008
+ const headers = {
1009
+ ...(!isFormData && { 'Content-Type': 'application/json' }),
1010
+ ...(bearerToken && { Authorization: bearerToken }),
1011
+ ...options?.headers,
1012
+ };
1013
+
1014
+ return await this.api.fetch<T>(`${endpoint}`, {
1015
+ ...options,
1016
+ headers,
1017
+ });
1018
+ }
1019
+
1020
+ private async paginatedFetch<T>(
1021
+ endpoint: string,
1022
+ opts: PaginatedFetchOpts,
1023
+ reqOptions?: RequestInit,
1024
+ authed: boolean = false,
1025
+ ) {
1026
+ let bearerToken: string | undefined;
1027
+ if (authed) {
1028
+ const { token, updatedAt } = this.state.session ?? {};
1029
+
1030
+ if (!token || (updatedAt ?? 0) + 1000 * 60 * 30 < Date.now()) {
1031
+ const gameId = this.state.gameId;
1032
+ const apiKey = this.state.apiKey;
1033
+ if (!apiKey) {
1034
+ throw new Error(
1035
+ 'No API key found. Please pass an API key to the SDK constructor or include an ogp-key meta tag in your HTML.',
1036
+ );
1037
+ }
1038
+ if (!gameId) {
1039
+ throw new Error('No Game ID found. Failed to fetch session token.');
1040
+ }
1041
+
1042
+ // Get fresh Privy token from widget if needed
1043
+ if (!this.state.privyAccessToken && this._widget) {
1044
+ console.info('[OpenGameSDK] Requesting fresh Privy token from widget');
1045
+ await this.requestPrivyToken();
1046
+ }
1047
+ if (!this.state.privyAccessToken) {
1048
+ throw new Error('No Privy access token found. User must be authenticated.');
1049
+ }
1050
+
1051
+ console.info('[OpenGameSDK] Updating session token for game:', gameId);
1052
+ const token = await this.getSessionToken(apiKey, gameId);
1053
+ this.state.session = { token, updatedAt: Date.now() };
1054
+ bearerToken = `Bearer ${token}`;
1055
+ } else {
1056
+ bearerToken = `Bearer ${token}`;
1057
+ }
1058
+ }
1059
+
1060
+ const headers = {
1061
+ ...(bearerToken && { Authorization: bearerToken }),
1062
+ ...reqOptions?.headers,
1063
+ };
1064
+
1065
+ const { limit = 50, cursor, sort, sortBy, query } = opts;
1066
+ const searchParams = new URLSearchParams({
1067
+ limit: limit.toString(),
1068
+ });
1069
+ if (cursor) {
1070
+ searchParams.append('cursor', cursor);
1071
+ }
1072
+ if (sort && sort !== '') {
1073
+ searchParams.append('sort', sort);
1074
+ }
1075
+ if (sortBy && sortBy !== '') {
1076
+ searchParams.append('sortBy', sortBy);
1077
+ }
1078
+ if (query && query !== '') {
1079
+ searchParams.append('query', query);
1080
+ }
1081
+ return await this.api.paginatedFetch<T>(`${endpoint}?${searchParams.toString()}`, {
1082
+ ...reqOptions,
1083
+ headers,
1084
+ });
1085
+ }
1086
+
1087
+ private _setupCarouselBridge() {
1088
+ window.addEventListener('message', (event) => {
1089
+ const isValidOrigin = event.origin === config.carouselURL;
1090
+ if (isValidOrigin) {
1091
+ handleCarouselMessages(this, event.data);
1092
+ }
1093
+ });
1094
+ console.log('Sending Ready Message to Carousel');
1095
+ this._sendMessageToCarousel({
1096
+ action: SDKActions.SDKReady,
1097
+ data: {
1098
+ apiKey: this.state.apiKey,
1099
+ gameId: this.state.gameId ?? '',
1100
+ playerId: this.state.playerId ?? '',
1101
+ },
1102
+ });
1103
+ }
1104
+
1105
+ private _deriveApiKey() {
1106
+ const apiKey = document.querySelector('meta[name="x-ogp-key"]')?.getAttribute('content');
1107
+ if (!apiKey) {
1108
+ throw new Error(
1109
+ 'No API key found. Please pass an API key to the SDK constructor or include an ogp-key meta tag in your HTML.',
1110
+ );
1111
+ }
1112
+ return apiKey;
1113
+ }
1114
+
1115
+ private _deriveGameId() {
1116
+ const params = new URLSearchParams(window.location.search);
1117
+ const gameId = params.get('gameId');
1118
+ if (!gameId) {
1119
+ return undefined;
1120
+ }
1121
+ return gameId;
1122
+ }
1123
+
1124
+ private _setupDashboardBridge() {
1125
+ window.addEventListener('message', async (event) => {
1126
+ // Only process dashboard messages
1127
+ const dashboardOrigin = new URL(config.dashboardURL).origin;
1128
+ const eventOriginHostname = new URL(event.origin).hostname;
1129
+
1130
+ const isValidOrigin =
1131
+ event.origin === dashboardOrigin ||
1132
+ eventOriginHostname === 'play.fun' ||
1133
+ eventOriginHostname.endsWith('.play.fun') ||
1134
+ isDevOrigin(eventOriginHostname);
1135
+
1136
+ // Debug logging for message origin validation
1137
+ if (
1138
+ event.data?.action &&
1139
+ (event.data.action === DashboardActions.ReplicateAuth ||
1140
+ event.data.action === DashboardActions.Logout)
1141
+ ) {
1142
+ console.log(
1143
+ '[OpenGameSDK] Received dashboard message:',
1144
+ event.data.action,
1145
+ 'from origin:',
1146
+ event.origin,
1147
+ 'expected:',
1148
+ dashboardOrigin,
1149
+ 'valid:',
1150
+ isValidOrigin,
1151
+ );
1152
+ }
1153
+
1154
+ if (!isValidOrigin) {
1155
+ return;
1156
+ }
1157
+
1158
+ const msg = event.data as DashboardMessages;
1159
+ if (!msg.action) {
1160
+ return;
1161
+ }
1162
+
1163
+ switch (msg.action) {
1164
+ case DashboardActions.SetReferral:
1165
+ console.log('[OpenGameSDK] Received SetReferral from dashboard:', msg.data);
1166
+ this.setReferrer(msg.data.referrerId, msg.data.referredGameId);
1167
+ return;
1168
+ case DashboardActions.SetDistributor:
1169
+ console.log('[OpenGameSDK] Received SetDistributor from dashboard:', msg.data);
1170
+ this.setDistributorKeys(msg.data.distributorKeys);
1171
+ return;
1172
+ case DashboardActions.RefreshMultiplier:
1173
+ console.log('[OpenGameSDK] Received RefreshMultiplier from dashboard:', msg.data);
1174
+ const { points: refreshedPoints, activeMultiplier: refreshedActivedMultiplier } =
1175
+ await this.getPoints();
1176
+ this.updateAndReplicate({
1177
+ pointsDisplay: {
1178
+ ...this.state.pointsDisplay,
1179
+ points: parseInt(refreshedPoints),
1180
+ activeMultiplier: refreshedActivedMultiplier,
1181
+ },
1182
+ });
1183
+ return;
1184
+ case DashboardActions.Logout:
1185
+ console.log('[OpenGameSDK] Received Logout event');
1186
+ this.updateAndReplicate({
1187
+ playerId: undefined,
1188
+ session: undefined,
1189
+ pointsDisplay: {
1190
+ ...this.state.pointsDisplay,
1191
+ points: 0,
1192
+ },
1193
+ sessionPoints: undefined,
1194
+ identityToken: undefined,
1195
+ });
1196
+ break;
1197
+ case DashboardActions.ReplicateAuth:
1198
+ console.log('[OpenGameSDK] Received ReplicateAuth:', msg.data);
1199
+
1200
+ const authData: ReplicateAuthData = {
1201
+ gameId: msg.data.gameId,
1202
+ playerId: msg.data.playerId,
1203
+ identityToken: msg.data.identityToken,
1204
+ privyAccessToken: msg.data.privyAccessToken,
1205
+ };
1206
+
1207
+ // If we're currently initializing, resolve the promise if waiting or store for later
1208
+ if (this._isInitializing) {
1209
+ console.log('[OpenGameSDK] Init in progress, storing auth for init to use');
1210
+ if (this._replicateAuthResolver) {
1211
+ // Init is waiting for this data
1212
+ this._replicateAuthResolver(authData);
1213
+ this._replicateAuthResolver = null;
1214
+ } else {
1215
+ // Store for init to pick up
1216
+ this._pendingReplicateAuth = authData;
1217
+ }
1218
+ return;
1219
+ }
1220
+
1221
+ // If init hasn't started yet, store the data for init to use
1222
+ if (!this.state.isReady) {
1223
+ console.log('[OpenGameSDK] Init not started, storing auth for init to use');
1224
+ this._pendingReplicateAuth = authData;
1225
+ return;
1226
+ }
1227
+
1228
+ // Init is complete, safe to call loadGame
1229
+ console.log(`[OpenGameSDK:${this._instanceId}] Init complete loading info`, {
1230
+ playerId: authData.playerId,
1231
+ hasPendingCallback: !!this._pendingLoginCallback,
1232
+ });
1233
+
1234
+ // Skip if no playerId - user is not logged in
1235
+ if (!authData.playerId) {
1236
+ console.log(
1237
+ `[OpenGameSDK:${this._instanceId}] No playerId in ReplicateAuth, skipping session setup`,
1238
+ );
1239
+ return;
1240
+ }
1241
+
1242
+ // Set Privy access token first so getSessionToken can use it for auth
1243
+ this.state.privyAccessToken = authData.privyAccessToken;
1244
+ this.state.identityToken = authData.identityToken;
1245
+
1246
+ const token = await this.getSessionToken(this.state.apiKey, authData.gameId);
1247
+ // playerId is now set by getSessionToken from server response
1248
+ const { points, activeMultiplier } = await this.getPoints();
1249
+ this.updateAndReplicate({
1250
+ identityToken: authData.identityToken,
1251
+ privyAccessToken: authData.privyAccessToken,
1252
+ session: { token, updatedAt: Date.now() },
1253
+ pointsDisplay: {
1254
+ ...this.state.pointsDisplay,
1255
+ points: parseInt(points),
1256
+ activeMultiplier,
1257
+ },
1258
+ });
1259
+
1260
+ console.log(
1261
+ `[OpenGameSDK:${this._instanceId}] Session updated, checking pending callback:`,
1262
+ !!this._pendingLoginCallback,
1263
+ );
1264
+
1265
+ // Emit OnLoginSuccess so games know login completed (important for dashboard mode)
1266
+ // Note: SDKEvents.OnLoginSuccess = 'LoginSuccess', but games may listen for 'OnLoginSuccess'
1267
+ // So we emit both to ensure compatibility
1268
+ console.log(`[OpenGameSDK:${this._instanceId}] Emitting OnLoginSuccess event`);
1269
+ this.emit(SDKEvents.OnLoginSuccess);
1270
+ this.emit('OnLoginSuccess' as SDKEvents);
1271
+
1272
+ if (this._pendingLoginCallback) {
1273
+ console.log(`[OpenGameSDK:${this._instanceId}] Calling pending login callback`);
1274
+ this._pendingLoginCallback();
1275
+ this._pendingLoginCallback = null;
1276
+ }
1277
+ }
1278
+ });
1279
+ // Note: Don't send SDKReady from constructor - it's sent from init() when in dashboard mode
1280
+ // This ensures the SDK is actually ready to receive ReplicateAuth when it signals readiness
1281
+ }
1282
+
1283
+ private _sendMessageToCarousel(msg: SDKMessages) {
1284
+ window.parent.postMessage(msg, '*');
1285
+ }
1286
+
1287
+ private _sendReadyToDashboard() {
1288
+ // Send SDKReady to dashboard so it can respond with ReplicateAuth
1289
+ window.parent.postMessage(
1290
+ {
1291
+ action: SDKActions.SDKReady,
1292
+ data: {
1293
+ apiKey: this.state.apiKey,
1294
+ gameId: this.state.gameId ?? '',
1295
+ playerId: this.state.playerId ?? '',
1296
+ },
1297
+ },
1298
+ '*',
1299
+ );
1300
+ }
1301
+
1302
+ private getWidgetUrl() {
1303
+ // Use state.isDashboard which includes auto-detected dashboard mode
1304
+ const baseWidgetUrl = config.widgetURL;
1305
+
1306
+ const url = new URL(baseWidgetUrl);
1307
+ const queryParams = new URLSearchParams(url.search);
1308
+ if (this.opts.logLevel) {
1309
+ queryParams.append('logLevel', this.opts.logLevel.toString());
1310
+ }
1311
+ if (this.state.isEmbedSession) {
1312
+ queryParams.append('inCarousel', 'true');
1313
+ }
1314
+ if (this.state.isDashboard) {
1315
+ queryParams.append('inDashboard', 'true');
1316
+ }
1317
+ url.search = queryParams.toString();
1318
+ return url.toString();
1319
+ }
1320
+
1321
+ private async getSessionToken(apiKey: string, gameId: string) {
1322
+ const params = new URLSearchParams({
1323
+ apiKey,
1324
+ gameId,
1325
+ });
1326
+
1327
+ // Include Privy access token for authentication
1328
+ // Server will use the authenticated user's Privy ID as the canonical playerId
1329
+ const headers: Record<string, string> = {};
1330
+ if (this.state.privyAccessToken) {
1331
+ headers['Authorization'] = `Bearer ${this.state.privyAccessToken}`;
1332
+ }
1333
+
1334
+ const res = await this.api.fetch<{ token: string; playerId: string }>(
1335
+ `/play/session-token?${params.toString()}`,
1336
+ {
1337
+ method: 'GET',
1338
+ headers,
1339
+ },
1340
+ );
1341
+ if (!res) {
1342
+ throw new Error('Failed to fetch session token');
1343
+ }
1344
+
1345
+ // Update state with canonical playerId from server
1346
+ // This ensures proof computation uses the same ID the server expects
1347
+ this.state.playerId = res.playerId;
1348
+
1349
+ return res.token;
1350
+ }
1351
+
1352
+ /**
1353
+ * Request a fresh Privy access token from the widget.
1354
+ * The widget will respond with SetPrivyToken containing a fresh token.
1355
+ */
1356
+ private async requestPrivyToken(): Promise<string> {
1357
+ if (!this._widget) {
1358
+ throw new Error('No widget available to request Privy token');
1359
+ }
1360
+
1361
+ const requestId = generateUUID();
1362
+
1363
+ return new Promise((resolve, reject) => {
1364
+ // Set up timeout
1365
+ const timeout = setTimeout(() => {
1366
+ this._privyTokenResolvers.delete(requestId);
1367
+ reject(new Error('Privy token request timed out'));
1368
+ }, 10000);
1369
+
1370
+ // Store resolver
1371
+ this._privyTokenResolvers.set(requestId, (token: string) => {
1372
+ clearTimeout(timeout);
1373
+ this._privyTokenResolvers.delete(requestId);
1374
+ resolve(token);
1375
+ });
1376
+
1377
+ // Send request to widget
1378
+ this._widget!.sendMessage({
1379
+ action: SDKActions.RequestPrivyToken,
1380
+ data: { requestId },
1381
+ });
1382
+ });
1383
+ }
1384
+
1385
+ /**
1386
+ * Handle Privy token response from widget.
1387
+ * Called by widget message handler.
1388
+ */
1389
+ handlePrivyTokenResponse(token: string, requestId: string) {
1390
+ const resolver = this._privyTokenResolvers.get(requestId);
1391
+ if (resolver) {
1392
+ this.state.privyAccessToken = token;
1393
+ resolver(token);
1394
+ } else {
1395
+ console.warn('[OpenGameSDK] Received Privy token for unknown request:', requestId);
1396
+ }
1397
+ }
1398
+
1399
+ /** Getters and Setters */
1400
+ get widget() {
1401
+ return this._widget;
1402
+ }
1403
+
1404
+ set widget(value: WidgetBridge | null) {
1405
+ this._widget = value;
1406
+ }
1407
+
1408
+ get pointsDisplay() {
1409
+ return this.state.pointsDisplay;
1410
+ }
1411
+
1412
+ get ui() {
1413
+ return this.state.ui;
1414
+ }
1415
+
1416
+ get gameId() {
1417
+ return this.state.gameId;
1418
+ }
1419
+
1420
+ get playerId() {
1421
+ return this.state.playerId;
1422
+ }
1423
+
1424
+ get sessionPoints() {
1425
+ return this.state.sessionPoints ? decodePointsObf(this.state.sessionPoints) : 0;
1426
+ }
1427
+
1428
+ get activeMultiplier() {
1429
+ return this.state.pointsDisplay?.activeMultiplier ?? 1;
1430
+ }
1431
+
1432
+ get widgetUrl() {
1433
+ return this.getWidgetUrl();
1434
+ }
1435
+
1436
+ get embedId() {
1437
+ return this.state.embedId;
1438
+ }
1439
+
1440
+ get hasSession() {
1441
+ return (
1442
+ !!this.state.session?.token &&
1443
+ !!this.state.session?.updatedAt &&
1444
+ this.state.session.updatedAt > Date.now() - 1000 * 60 * 30
1445
+ );
1446
+ }
1447
+
1448
+ get isDashboard() {
1449
+ return this.state.isDashboard;
1450
+ }
1451
+
1452
+ set isDashboard(value: boolean | undefined) {
1453
+ this.updateAndReplicate({
1454
+ isDashboard: value,
1455
+ });
1456
+ }
1457
+
1458
+ set playerId(value: string | undefined) {
1459
+ // playerId is now determined by server based on Privy auth
1460
+ // This setter is kept for backward compatibility but doesn't fetch session token
1461
+ // Use the authentication flow to set playerId properly
1462
+ if (!value) {
1463
+ this.updateAndReplicate({ playerId: value, session: undefined });
1464
+ } else {
1465
+ console.warn(
1466
+ '[OpenGameSDK] Setting playerId directly is deprecated. playerId is now determined by Privy authentication.',
1467
+ );
1468
+ this.updateAndReplicate({ playerId: value });
1469
+ }
1470
+ }
1471
+
1472
+ private updateAndReplicate(state: RecursivePartial<SDKState>) {
1473
+ const oldState = this.state;
1474
+ const newState = {
1475
+ ...oldState,
1476
+ ...state,
1477
+ } as SDKState;
1478
+
1479
+ this.state = newState;
1480
+
1481
+ console.log('[OpenGameSDK] Replication State:', newState);
1482
+
1483
+ this.widget?.sendMessage({
1484
+ action: SDKActions.Replicate,
1485
+ data: newState,
1486
+ });
1487
+ }
1488
+ }