@usions/sdk 2.0.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/src/browser.js ADDED
@@ -0,0 +1,1793 @@
1
+ /**
2
+ * Usion Mini App SDK v2.0
3
+ *
4
+ * JavaScript utilities for Mini Apps (Iframe Games & Services)
5
+ * Import via: <script src="https://usions.com/usion-sdk.js"></script>
6
+ *
7
+ * Features:
8
+ * - User info and authentication
9
+ * - Persistent storage (per-user, per-service)
10
+ * - Wallet/payment integration
11
+ * - Session management
12
+ * - Real-time game support via Socket.IO
13
+ */
14
+
15
+ (function(global) {
16
+ 'use strict';
17
+
18
+ // Request ID counter for tracking async responses
19
+ let _requestId = 0;
20
+ const _pendingRequests = {};
21
+
22
+ const Usion = {
23
+ version: '2.0.1',
24
+ config: {},
25
+ _initialized: false,
26
+ _initCallback: null,
27
+ _messageHandlerRegistered: false,
28
+
29
+ /**
30
+ * Initialize the SDK with config from parent app
31
+ * @param {function} callback - Called with config when ready
32
+ */
33
+ init: function(callback) {
34
+ const self = this;
35
+
36
+ // Prevent double initialization - just update callback
37
+ if (self._initialized) {
38
+ if (callback) callback(self.config);
39
+ return;
40
+ }
41
+
42
+ // Store callback for when config arrives
43
+ self._initCallback = callback;
44
+
45
+ // Only register message handler once
46
+ if (self._messageHandlerRegistered) {
47
+ return;
48
+ }
49
+ self._messageHandlerRegistered = true;
50
+
51
+ // Setup global message handler
52
+ window.addEventListener('message', function(event) {
53
+ let data;
54
+ try {
55
+ data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
56
+ } catch (e) {
57
+ return;
58
+ }
59
+
60
+ // Handle INIT message
61
+ if (data.type === 'INIT' && data.config) {
62
+ // Prevent double config - only set once
63
+ if (self._initialized) {
64
+ return;
65
+ }
66
+
67
+ self.config = data.config;
68
+ self._initialized = true;
69
+ // We received INIT from a parent → we are embedded (iframe or WebView)
70
+ self._isEmbedded = true;
71
+
72
+ // Initialize user module with config data
73
+ if (data.config.userId) {
74
+ self.user._id = data.config.userId;
75
+ self.user._name = data.config.userName;
76
+ self.user._avatar = data.config.userAvatar;
77
+ self.user._token = data.config.authToken;
78
+ }
79
+
80
+ // Initialize session module
81
+ if (data.config.sessionId) {
82
+ self.session._id = data.config.sessionId;
83
+ self.session._data = data.config.sessionData || {};
84
+ }
85
+
86
+ // Initialize wallet with balance if provided
87
+ if (data.config.balance !== undefined) {
88
+ self.wallet._balance = data.config.balance;
89
+ }
90
+
91
+ // Call the stored init callback
92
+ if (self._initCallback) {
93
+ self._initCallback(data.config);
94
+ }
95
+ }
96
+
97
+ // Handle response messages for async requests
98
+ if (data._requestId && _pendingRequests[data._requestId]) {
99
+ const { resolve, reject } = _pendingRequests[data._requestId];
100
+ delete _pendingRequests[data._requestId];
101
+
102
+ if (data.error) {
103
+ reject(new Error(data.error));
104
+ } else {
105
+ resolve(data);
106
+ }
107
+ }
108
+
109
+ // Handle balance updates
110
+ if (data.type === 'BALANCE_UPDATE') {
111
+ self.wallet._balance = data.balance;
112
+ if (self.wallet._balanceChangeHandler) {
113
+ self.wallet._balanceChangeHandler(data.balance);
114
+ }
115
+ }
116
+ });
117
+
118
+ // Signal ready to parent
119
+ this._post({ type: 'READY' });
120
+ },
121
+
122
+ /**
123
+ * Send message to parent app
124
+ * @private
125
+ */
126
+ _post: function(message) {
127
+ const msg = JSON.stringify(message);
128
+
129
+ // React Native WebView
130
+ if (window.ReactNativeWebView) {
131
+ window.ReactNativeWebView.postMessage(msg);
132
+ return;
133
+ }
134
+
135
+ // Web iframe
136
+ if (window.parent !== window) {
137
+ window.parent.postMessage(message, '*');
138
+ }
139
+ },
140
+
141
+ /**
142
+ * Send async request to parent and wait for response
143
+ * @private
144
+ */
145
+ _request: function(type, data, timeout) {
146
+ const self = this;
147
+ timeout = timeout || 5000;
148
+
149
+ return new Promise(function(resolve, reject) {
150
+ const requestId = ++_requestId;
151
+
152
+ // Setup timeout
153
+ const timer = setTimeout(function() {
154
+ delete _pendingRequests[requestId];
155
+ reject(new Error('Request timeout'));
156
+ }, timeout);
157
+
158
+ // Store pending request
159
+ _pendingRequests[requestId] = {
160
+ resolve: function(result) {
161
+ clearTimeout(timer);
162
+ resolve(result);
163
+ },
164
+ reject: function(error) {
165
+ clearTimeout(timer);
166
+ reject(error);
167
+ }
168
+ };
169
+
170
+ // Send request
171
+ self._post({
172
+ type: type,
173
+ _requestId: requestId,
174
+ ...data
175
+ });
176
+ });
177
+ },
178
+
179
+ // ============================================
180
+ // User Module
181
+ // ============================================
182
+
183
+ /**
184
+ * User information and authentication
185
+ */
186
+ user: {
187
+ _id: null,
188
+ _name: null,
189
+ _avatar: null,
190
+ _token: null,
191
+
192
+ /**
193
+ * Get the current user's ID
194
+ * @returns {string|null}
195
+ */
196
+ getId: function() {
197
+ return this._id || Usion.config.userId || null;
198
+ },
199
+
200
+ /**
201
+ * Get the current user's display name
202
+ * @returns {string|null}
203
+ */
204
+ getName: function() {
205
+ return this._name || Usion.config.userName || null;
206
+ },
207
+
208
+ /**
209
+ * Get the current user's avatar URL
210
+ * @returns {string|null}
211
+ */
212
+ getAvatar: function() {
213
+ return this._avatar || Usion.config.userAvatar || null;
214
+ },
215
+
216
+ /**
217
+ * Get the user's auth token for socket connections
218
+ * @returns {string|null}
219
+ */
220
+ getToken: function() {
221
+ return this._token || Usion.config.authToken || null;
222
+ },
223
+
224
+ /**
225
+ * Get full user profile
226
+ * @returns {Promise<object>} User profile with id, name, avatar
227
+ */
228
+ getProfile: function() {
229
+ return Usion._request('GET_USER_PROFILE', {}).then(function(response) {
230
+ return response.profile || {
231
+ id: Usion.user.getId(),
232
+ name: Usion.user.getName(),
233
+ avatar: Usion.user.getAvatar()
234
+ };
235
+ });
236
+ }
237
+ },
238
+
239
+ // ============================================
240
+ // Storage Module
241
+ // ============================================
242
+
243
+ /**
244
+ * Persistent storage (per-user, per-service)
245
+ */
246
+ storage: {
247
+ /**
248
+ * Get a stored value
249
+ * @param {string} key - Storage key
250
+ * @returns {Promise<any>} Stored value or null
251
+ */
252
+ get: function(key) {
253
+ return Usion._request('STORAGE_GET', { key: key }).then(function(response) {
254
+ return response.value;
255
+ });
256
+ },
257
+
258
+ /**
259
+ * Set a stored value
260
+ * @param {string} key - Storage key
261
+ * @param {any} value - Value to store (will be JSON serialized)
262
+ * @returns {Promise<void>}
263
+ */
264
+ set: function(key, value) {
265
+ return Usion._request('STORAGE_SET', { key: key, value: value }).then(function() {
266
+ return;
267
+ });
268
+ },
269
+
270
+ /**
271
+ * Remove a stored value
272
+ * @param {string} key - Storage key
273
+ * @returns {Promise<void>}
274
+ */
275
+ remove: function(key) {
276
+ return Usion._request('STORAGE_REMOVE', { key: key }).then(function() {
277
+ return;
278
+ });
279
+ },
280
+
281
+ /**
282
+ * Clear all stored values for this service
283
+ * @returns {Promise<void>}
284
+ */
285
+ clear: function() {
286
+ return Usion._request('STORAGE_CLEAR', {}).then(function() {
287
+ return;
288
+ });
289
+ },
290
+
291
+ /**
292
+ * Get all keys
293
+ * @returns {Promise<string[]>}
294
+ */
295
+ keys: function() {
296
+ return Usion._request('STORAGE_KEYS', {}).then(function(response) {
297
+ return response.keys || [];
298
+ });
299
+ }
300
+ },
301
+
302
+ // ============================================
303
+ // Wallet Module
304
+ // ============================================
305
+
306
+ /**
307
+ * Wallet and payment operations
308
+ */
309
+ wallet: {
310
+ _balance: null,
311
+ _balanceChangeHandler: null,
312
+
313
+ /**
314
+ * Get current wallet balance
315
+ * @returns {Promise<number>} Balance in credits
316
+ */
317
+ getBalance: function() {
318
+ const self = this;
319
+
320
+ // If we have cached balance, return it
321
+ if (self._balance !== null) {
322
+ return Promise.resolve(self._balance);
323
+ }
324
+
325
+ return Usion._request('GET_BALANCE', {}).then(function(response) {
326
+ self._balance = response.balance;
327
+ return response.balance;
328
+ });
329
+ },
330
+
331
+ /**
332
+ * Check if user has enough credits
333
+ * @param {number} amount - Amount to check
334
+ * @returns {Promise<boolean>}
335
+ */
336
+ hasCredits: function(amount) {
337
+ return this.getBalance().then(function(balance) {
338
+ return balance >= amount;
339
+ });
340
+ },
341
+
342
+ /**
343
+ * Request payment from user with balance check
344
+ * @param {number} amount - Credit amount to charge
345
+ * @param {string} reason - Description shown to user
346
+ * @param {object} data - Optional additional data
347
+ * @returns {Promise} Resolves on payment success, rejects on failure
348
+ */
349
+ requestPayment: function(amount, reason, data) {
350
+ const self = this;
351
+
352
+ return new Promise(function(resolve, reject) {
353
+ const requestId = ++_requestId;
354
+ const timeoutMs = 60000;
355
+
356
+ // Listen for response
357
+ function handler(event) {
358
+ let response;
359
+ try {
360
+ response = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
361
+ } catch (e) {
362
+ return;
363
+ }
364
+
365
+ // Only accept responses for this specific payment request.
366
+ if (response._requestId !== requestId) {
367
+ return;
368
+ }
369
+
370
+ if (response.type === 'PAYMENT_SUCCESS') {
371
+ clearTimeout(timer);
372
+ window.removeEventListener('message', handler);
373
+ // Update cached balance
374
+ if (response.newBalance !== undefined) {
375
+ self._balance = response.newBalance;
376
+ } else if (self._balance !== null) {
377
+ self._balance -= amount;
378
+ }
379
+ resolve(response);
380
+ } else if (response.type === 'PAYMENT_FAILED') {
381
+ clearTimeout(timer);
382
+ window.removeEventListener('message', handler);
383
+ reject(new Error(response.reason || 'Payment failed'));
384
+ }
385
+ }
386
+
387
+ window.addEventListener('message', handler);
388
+ const timer = setTimeout(function() {
389
+ window.removeEventListener('message', handler);
390
+ reject(new Error('Payment confirmation timeout'));
391
+ }, timeoutMs);
392
+
393
+ // Send payment request
394
+ Usion._post({
395
+ type: 'PAYMENT_REQUEST',
396
+ _requestId: requestId,
397
+ amount: amount,
398
+ reason: reason,
399
+ data: data
400
+ });
401
+ });
402
+ },
403
+
404
+ /**
405
+ * Listen for balance changes
406
+ * @param {function} callback - Called with new balance
407
+ */
408
+ onBalanceChange: function(callback) {
409
+ this._balanceChangeHandler = callback;
410
+ }
411
+ },
412
+
413
+ // ============================================
414
+ // Session Module
415
+ // ============================================
416
+
417
+ /**
418
+ * Session management (ephemeral data for current session)
419
+ */
420
+ session: {
421
+ _id: null,
422
+ _data: {},
423
+
424
+ /**
425
+ * Get the current session ID
426
+ * @returns {string|null}
427
+ */
428
+ getId: function() {
429
+ return this._id || Usion.config.sessionId || null;
430
+ },
431
+
432
+ /**
433
+ * Get session data
434
+ * @param {string} key - Optional key to get specific value
435
+ * @returns {any} Session data or specific value
436
+ */
437
+ getData: function(key) {
438
+ if (key) {
439
+ return this._data[key];
440
+ }
441
+ return this._data;
442
+ },
443
+
444
+ /**
445
+ * Set session data (ephemeral, cleared on session end)
446
+ * @param {string|object} keyOrData - Key or object of data to set
447
+ * @param {any} value - Value if key is string
448
+ */
449
+ setData: function(keyOrData, value) {
450
+ if (typeof keyOrData === 'object') {
451
+ Object.assign(this._data, keyOrData);
452
+ } else {
453
+ this._data[keyOrData] = value;
454
+ }
455
+
456
+ // Notify parent of session data change
457
+ Usion._post({
458
+ type: 'SESSION_DATA_UPDATE',
459
+ data: this._data
460
+ });
461
+ },
462
+
463
+ /**
464
+ * Clear session data
465
+ */
466
+ clear: function() {
467
+ this._data = {};
468
+ Usion._post({
469
+ type: 'SESSION_DATA_CLEAR'
470
+ });
471
+ }
472
+ },
473
+
474
+ // ============================================
475
+ // Legacy Payment Method (for backwards compatibility)
476
+ // ============================================
477
+
478
+ /**
479
+ * Request payment from user (legacy method)
480
+ * @deprecated Use Usion.wallet.requestPayment instead
481
+ */
482
+ requestPayment: function(amount, reason, data) {
483
+ return this.wallet.requestPayment(amount, reason, data);
484
+ },
485
+
486
+ /**
487
+ * Submit result and signal completion
488
+ * @param {object} data - Result data to send to parent
489
+ */
490
+ submit: function(data) {
491
+ this._post({
492
+ type: 'SUBMIT',
493
+ data: data
494
+ });
495
+ },
496
+
497
+ /**
498
+ * Report an error to parent app
499
+ * @param {string} message - Error message
500
+ */
501
+ error: function(message) {
502
+ this._post({
503
+ type: 'ERROR',
504
+ message: message
505
+ });
506
+ },
507
+
508
+ /**
509
+ * Request to close the mini app
510
+ */
511
+ exit: function() {
512
+ this._post({ type: 'EXIT' });
513
+ },
514
+
515
+ /**
516
+ * Share content through the app's native share and optionally post to Usions feed
517
+ *
518
+ * @param {string} contentType - Type of content: 'audio' | 'image' | 'video' | 'text' | 'mixed'
519
+ * @param {object} data - Content data to share:
520
+ * - text: Optional text/caption for the post
521
+ * - audioUrl: URL for audio content (when contentType is 'audio')
522
+ * - imageUrl: URL for image content (when contentType is 'image')
523
+ * - videoUrl: URL for video content (when contentType is 'video')
524
+ * - thumbnailUrl: Optional thumbnail URL for video/audio
525
+ * - width: Optional width for image/video
526
+ * - height: Optional height for image/video
527
+ * - duration: Optional duration in seconds for audio/video
528
+ * - media: Array of media items for 'mixed' content type
529
+ * - Each item: { type: 'image'|'video'|'audio', url: string, thumbnailUrl?, width?, height?, duration? }
530
+ *
531
+ * @example
532
+ * // Share audio
533
+ * Usion.share('audio', {
534
+ * text: 'Check out this AI voice!',
535
+ * audioUrl: 'https://cdn.example.com/audio.mp3',
536
+ * duration: 5.2
537
+ * });
538
+ *
539
+ * @example
540
+ * // Share image
541
+ * Usion.share('image', {
542
+ * text: 'AI-generated art',
543
+ * imageUrl: 'https://cdn.example.com/image.webp',
544
+ * thumbnailUrl: 'https://cdn.example.com/thumb.webp',
545
+ * width: 1024,
546
+ * height: 1024
547
+ * });
548
+ *
549
+ * @example
550
+ * // Share video
551
+ * Usion.share('video', {
552
+ * text: 'My AI video creation',
553
+ * videoUrl: 'https://cdn.example.com/video.mp4',
554
+ * thumbnailUrl: 'https://cdn.example.com/poster.jpg',
555
+ * duration: 30
556
+ * });
557
+ *
558
+ * @example
559
+ * // Share mixed content (multiple media)
560
+ * Usion.share('mixed', {
561
+ * text: 'Gallery of AI creations',
562
+ * media: [
563
+ * { type: 'image', url: 'https://cdn.example.com/1.webp' },
564
+ * { type: 'image', url: 'https://cdn.example.com/2.webp' },
565
+ * { type: 'video', url: 'https://cdn.example.com/video.mp4', thumbnailUrl: '...' }
566
+ * ]
567
+ * });
568
+ */
569
+ share: function(contentType, data) {
570
+ var shareData = Object.assign({}, data, {
571
+ contentType: contentType,
572
+ serviceId: this.config.serviceId,
573
+ serviceName: this.config.serviceName
574
+ });
575
+
576
+ this._post({
577
+ type: 'SHARE',
578
+ contentType: contentType,
579
+ data: shareData
580
+ });
581
+ },
582
+
583
+ // ============================================
584
+ // Chat
585
+ // ============================================
586
+
587
+ chat: {
588
+ /**
589
+ * Request to send a message to another user.
590
+ * The parent app will show a confirmation prompt to the user.
591
+ * @param {string} recipientId - Usion user ID of the recipient
592
+ * @param {string} message - Message content to send
593
+ * @returns {Promise<{success: boolean, reason?: string}>}
594
+ *
595
+ * @example
596
+ * const result = await Usion.chat.sendMessage('user_abc', '👋');
597
+ * if (result.success) console.log('Message sent!');
598
+ */
599
+ sendMessage: function(recipientId, message) {
600
+ return Usion._request('SEND_MESSAGE_REQUEST', {
601
+ recipientId: recipientId,
602
+ message: message
603
+ });
604
+ },
605
+
606
+ /**
607
+ * Create a personal chat with another user (no message sent).
608
+ * @param {string} peerUserId - Usion user ID of the other user
609
+ * @returns {Promise<{chatId: string, peerName: string, peerUsername: string, peerAvatar: string}>}
610
+ */
611
+ createPersonalChat: function(peerUserId) {
612
+ return Usion._request('CREATE_PERSONAL_CHAT', {
613
+ peerUserId: peerUserId
614
+ });
615
+ }
616
+ },
617
+
618
+ /**
619
+ * Log message to native console (for debugging)
620
+ * @param {string} msg - Message to log
621
+ */
622
+ log: function(msg) {
623
+ this._post({
624
+ type: 'LOG',
625
+ msg: msg
626
+ });
627
+ console.log('[Usion]', msg);
628
+ },
629
+
630
+ /**
631
+ * Listen for messages from parent app
632
+ * @param {string} type - Message type to listen for
633
+ * @param {function} callback - Handler function
634
+ */
635
+ on: function(type, callback) {
636
+ window.addEventListener('message', function(event) {
637
+ let data;
638
+ try {
639
+ data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
640
+ } catch (e) {
641
+ return;
642
+ }
643
+
644
+ if (data.type === type) {
645
+ callback(data);
646
+ }
647
+ });
648
+ },
649
+
650
+ // ============================================
651
+ // UI Utilities
652
+ // ============================================
653
+
654
+ /**
655
+ * Set button to loading state
656
+ * @param {HTMLElement|string} btn - Button element or selector
657
+ * @param {boolean} loading - Whether to show loading state
658
+ */
659
+ setLoading: function(btn, loading) {
660
+ const el = typeof btn === 'string' ? document.querySelector(btn) : btn;
661
+ if (!el) return;
662
+
663
+ if (loading) {
664
+ el.classList.add('usion-btn-loading');
665
+ el.disabled = true;
666
+ el.dataset.originalText = el.textContent;
667
+ } else {
668
+ el.classList.remove('usion-btn-loading');
669
+ el.disabled = false;
670
+ if (el.dataset.originalText) {
671
+ el.textContent = el.dataset.originalText;
672
+ }
673
+ }
674
+ },
675
+
676
+ /**
677
+ * Show/hide an element
678
+ * @param {HTMLElement|string} el - Element or selector
679
+ * @param {boolean} show - Whether to show or hide
680
+ */
681
+ toggle: function(el, show) {
682
+ const element = typeof el === 'string' ? document.querySelector(el) : el;
683
+ if (!element) return;
684
+
685
+ if (show) {
686
+ element.classList.remove('usion-hidden', 'hidden');
687
+ element.classList.add('usion-visible');
688
+ } else {
689
+ element.classList.add('usion-hidden');
690
+ element.classList.remove('usion-visible');
691
+ }
692
+ },
693
+
694
+ /**
695
+ * Update character count display
696
+ * @param {HTMLElement|string} input - Input element or selector
697
+ * @param {HTMLElement|string} counter - Counter element or selector
698
+ * @param {number} max - Maximum characters
699
+ */
700
+ charCount: function(input, counter, max) {
701
+ const inputEl = typeof input === 'string' ? document.querySelector(input) : input;
702
+ const counterEl = typeof counter === 'string' ? document.querySelector(counter) : counter;
703
+
704
+ if (!inputEl || !counterEl) return;
705
+
706
+ function update() {
707
+ const count = inputEl.value.length;
708
+ counterEl.textContent = count + ' / ' + max;
709
+
710
+ counterEl.classList.remove('warning', 'error');
711
+ if (count > max * 0.9) {
712
+ counterEl.classList.add('error');
713
+ } else if (count > max * 0.7) {
714
+ counterEl.classList.add('warning');
715
+ }
716
+ }
717
+
718
+ inputEl.addEventListener('input', update);
719
+ update();
720
+ },
721
+
722
+ /**
723
+ * Create a selection handler for grid items
724
+ * @param {string} containerSelector - Container selector
725
+ * @param {string} itemSelector - Item selector
726
+ * @param {function} onChange - Callback when selection changes
727
+ */
728
+ selectionGrid: function(containerSelector, itemSelector, onChange) {
729
+ const container = document.querySelector(containerSelector);
730
+ if (!container) return;
731
+
732
+ let selected = null;
733
+
734
+ container.querySelectorAll(itemSelector).forEach(function(item) {
735
+ item.addEventListener('click', function() {
736
+ // Remove selection from all
737
+ container.querySelectorAll(itemSelector).forEach(function(i) {
738
+ i.classList.remove('selected');
739
+ });
740
+
741
+ // Select this one
742
+ item.classList.add('selected');
743
+ selected = item.dataset.value || item.dataset.id;
744
+
745
+ if (onChange) onChange(selected, item);
746
+ });
747
+ });
748
+
749
+ return {
750
+ getSelected: function() { return selected; },
751
+ clear: function() {
752
+ container.querySelectorAll(itemSelector).forEach(function(i) {
753
+ i.classList.remove('selected');
754
+ });
755
+ selected = null;
756
+ }
757
+ };
758
+ },
759
+
760
+ // ============================================
761
+ // Game/Multiplayer Utilities
762
+ // ============================================
763
+
764
+ /**
765
+ * Game module for multiplayer real-time games
766
+ */
767
+ game: {
768
+ socket: null,
769
+ directSocket: null,
770
+ roomId: null,
771
+ playerId: null,
772
+ connected: false,
773
+ directMode: false,
774
+ directConfig: null,
775
+ _directSeq: 0,
776
+ _eventHandlers: {},
777
+ _lastSequence: 0,
778
+ _connecting: false,
779
+ _connectPromise: null,
780
+ _joined: false,
781
+ _joinPromise: null,
782
+ _useProxy: false,
783
+ _proxyListenerSetup: false,
784
+ _heartbeatInterval: null,
785
+
786
+ /**
787
+ * Connect to the game socket server
788
+ * @param {string} socketUrl - Socket.IO server URL (optional, uses config)
789
+ * @param {string} token - JWT auth token (optional, uses user.getToken())
790
+ * @returns {Promise} Resolves when connected
791
+ */
792
+ connect: function(socketUrl, token) {
793
+ const self = this;
794
+ var connectionMode = (Usion.config && Usion.config.connectionMode) || 'platform';
795
+ if (connectionMode === 'direct') {
796
+ return self.connectDirect();
797
+ }
798
+
799
+ // Use config values as defaults
800
+ socketUrl = socketUrl || Usion.config.socketUrl;
801
+ token = token || Usion.user.getToken();
802
+
803
+ if (!socketUrl) {
804
+ return Promise.reject(new Error('No socket URL provided'));
805
+ }
806
+ if (!token) {
807
+ return Promise.reject(new Error('No auth token available'));
808
+ }
809
+
810
+ // If already connected (direct or proxy), return immediately
811
+ if (self._useProxy && self.connected) {
812
+ return Promise.resolve();
813
+ }
814
+ if (self.socket && self.connected) {
815
+ return Promise.resolve();
816
+ }
817
+
818
+ // If currently connecting, return the existing promise
819
+ if (self._connecting && self._connectPromise) {
820
+ return self._connectPromise;
821
+ }
822
+
823
+ // When running inside an iframe or WebView, always use the parent app
824
+ // as a socket proxy. The parent already has an authenticated socket
825
+ // connection, avoids CORS issues, and avoids mixed-content blocks.
826
+ // Detection order (first truthy wins):
827
+ // (1) __USION_PROXY__ injected by parent before page load (most reliable)
828
+ // (2) iframe check (window.parent !== window)
829
+ // (3) ReactNativeWebView global
830
+ // (4) _isEmbedded flag set when INIT message was received
831
+ var isInFrame = !!window.__USION_PROXY__
832
+ || window.parent !== window
833
+ || !!window.ReactNativeWebView
834
+ || !!Usion._isEmbedded;
835
+
836
+ if (isInFrame) {
837
+ Usion.log('Running in iframe – using parent app as socket proxy');
838
+ return self._connectViaProxy();
839
+ }
840
+
841
+ self._connecting = true;
842
+ self._connectPromise = new Promise(function(resolve, reject) {
843
+ // Check if socket.io-client is available
844
+ if (typeof io === 'undefined') {
845
+ // Load socket.io client – try local copy first, fallback to CDN
846
+ var script = document.createElement('script');
847
+ script.src = '/socket.io.min.js';
848
+ script.onload = function() {
849
+ self._initSocket(socketUrl, token, resolve, reject);
850
+ };
851
+ script.onerror = function() {
852
+ // Local file not available, try CDN as fallback
853
+ var cdnScript = document.createElement('script');
854
+ cdnScript.src = 'https://cdn.socket.io/4.7.2/socket.io.min.js';
855
+ cdnScript.onload = function() {
856
+ self._initSocket(socketUrl, token, resolve, reject);
857
+ };
858
+ cdnScript.onerror = function() {
859
+ self._connecting = false;
860
+ reject(new Error('Failed to load Socket.IO client'));
861
+ };
862
+ document.head.appendChild(cdnScript);
863
+ };
864
+ document.head.appendChild(script);
865
+ } else {
866
+ self._initSocket(socketUrl, token, resolve, reject);
867
+ }
868
+ });
869
+
870
+ return self._connectPromise;
871
+ },
872
+
873
+ /**
874
+ * Connect directly to creator-controlled WebSocket server.
875
+ * Uses backend-issued short-lived room token.
876
+ * @returns {Promise}
877
+ */
878
+ connectDirect: function(config) {
879
+ var self = this;
880
+ config = config || {};
881
+
882
+ if (self.directMode && self.directSocket && self.connected) {
883
+ return Promise.resolve();
884
+ }
885
+ if (self._connecting && self._connectPromise) {
886
+ return self._connectPromise;
887
+ }
888
+
889
+ self._connecting = true;
890
+ self.directMode = true;
891
+ self._connectPromise = self._fetchDirectAccess(config)
892
+ .then(function(access) {
893
+ self.directConfig = access;
894
+ return self._initDirectSocket(access);
895
+ })
896
+ .then(function() {
897
+ self.connected = true;
898
+ self._connecting = false;
899
+ Usion.log('Direct game socket connected');
900
+ })
901
+ .catch(function(err) {
902
+ self._connecting = false;
903
+ self.connected = false;
904
+ self.directMode = false;
905
+ if (self._eventHandlers.connectionError) {
906
+ self._eventHandlers.connectionError(err);
907
+ }
908
+ throw err;
909
+ });
910
+ return self._connectPromise;
911
+ },
912
+
913
+ _fetchDirectAccess: function(config) {
914
+ var roomId = config.roomId || this.roomId || Usion.config.roomId;
915
+ var serviceId = config.serviceId || Usion.config.serviceId;
916
+ var apiUrl = config.apiUrl || Usion.config.apiUrl || '';
917
+ var token = config.token || Usion.user.getToken();
918
+
919
+ if (!roomId) return Promise.reject(new Error('No room ID provided'));
920
+ if (!serviceId) return Promise.reject(new Error('No service ID provided'));
921
+ if (!apiUrl) return Promise.reject(new Error('No API URL provided'));
922
+ if (!token) return Promise.reject(new Error('No auth token available'));
923
+
924
+ this.roomId = roomId;
925
+ this.playerId = Usion.user.getId();
926
+
927
+ var cleanApiUrl = String(apiUrl).replace(/\/$/, '');
928
+ var endpoint = cleanApiUrl + '/games/rooms/' + encodeURIComponent(roomId) + '/access';
929
+ return fetch(endpoint, {
930
+ method: 'POST',
931
+ headers: {
932
+ 'Content-Type': 'application/json',
933
+ 'Authorization': 'Bearer ' + token
934
+ },
935
+ body: JSON.stringify({
936
+ service_id: serviceId,
937
+ client_type: 'iframe',
938
+ protocol_version: (config.protocolVersion || Usion.config.protocolVersion || Usion.config.protocol_version || '2')
939
+ })
940
+ }).then(function(res) {
941
+ if (!res.ok) {
942
+ return res.text().then(function(text) {
943
+ throw new Error(text || ('Direct access failed: HTTP ' + res.status));
944
+ });
945
+ }
946
+ return res.json();
947
+ });
948
+ },
949
+
950
+ _initDirectSocket: function(access) {
951
+ var self = this;
952
+ return new Promise(function(resolve, reject) {
953
+ if (!access || !access.ws_url || !access.access_token) {
954
+ reject(new Error('Invalid direct access payload'));
955
+ return;
956
+ }
957
+
958
+ var wsUrl = access.ws_url;
959
+ var separator = wsUrl.indexOf('?') === -1 ? '?' : '&';
960
+ var urlWithToken = wsUrl + separator + 'token=' + encodeURIComponent(access.access_token);
961
+ var ws = new WebSocket(urlWithToken);
962
+ self.directSocket = ws;
963
+
964
+ var opened = false;
965
+ var joinSent = false;
966
+ var timeout = setTimeout(function() {
967
+ if (!opened) {
968
+ try { ws.close(); } catch (e) {}
969
+ reject(new Error('Direct WebSocket connection timeout'));
970
+ }
971
+ }, 10000);
972
+
973
+ ws.onopen = function() {
974
+ opened = true;
975
+ clearTimeout(timeout);
976
+ if (!joinSent) {
977
+ joinSent = true;
978
+ self._sendDirect('join', {});
979
+ }
980
+ // Start heartbeat for direct mode
981
+ if (self._heartbeatInterval) clearInterval(self._heartbeatInterval);
982
+ self._heartbeatInterval = setInterval(function() {
983
+ if (self.directSocket && self.directSocket.readyState === WebSocket.OPEN) {
984
+ self._sendDirect('heartbeat', {});
985
+ }
986
+ }, 25000);
987
+ resolve();
988
+ };
989
+
990
+ ws.onerror = function() {
991
+ if (!opened) {
992
+ clearTimeout(timeout);
993
+ reject(new Error('Direct WebSocket connection error'));
994
+ }
995
+ };
996
+
997
+ ws.onclose = function(evt) {
998
+ self.connected = false;
999
+ self._joined = false;
1000
+ self._joinPromise = null;
1001
+ if (self._heartbeatInterval) {
1002
+ clearInterval(self._heartbeatInterval);
1003
+ self._heartbeatInterval = null;
1004
+ }
1005
+ if (self._eventHandlers.disconnect) {
1006
+ self._eventHandlers.disconnect(evt && evt.reason ? evt.reason : 'direct socket closed');
1007
+ }
1008
+ };
1009
+
1010
+ ws.onmessage = function(evt) {
1011
+ self._handleDirectMessage(evt && evt.data);
1012
+ };
1013
+ });
1014
+ },
1015
+
1016
+ _sendDirect: function(type, payload) {
1017
+ if (!this.directSocket || this.directSocket.readyState !== WebSocket.OPEN) return;
1018
+ this._directSeq = this._directSeq + 1;
1019
+ this.directSocket.send(JSON.stringify({
1020
+ type: type,
1021
+ room_id: this.roomId,
1022
+ ts: Date.now(),
1023
+ seq: this._directSeq,
1024
+ session_id: (this.directConfig && this.directConfig.session_id) ? this.directConfig.session_id : null,
1025
+ protocol_version: (this.directConfig && this.directConfig.protocol_version) ? this.directConfig.protocol_version : '2',
1026
+ payload: payload || {}
1027
+ }));
1028
+ },
1029
+
1030
+ _handleDirectMessage: function(raw) {
1031
+ var data;
1032
+ try {
1033
+ data = typeof raw === 'string' ? JSON.parse(raw) : raw;
1034
+ } catch (e) {
1035
+ return;
1036
+ }
1037
+ if (!data || !data.type) return;
1038
+ var payload = data.payload || {};
1039
+
1040
+ if (data.type === 'joined') {
1041
+ this._joined = true;
1042
+ if (this._eventHandlers.joined) this._eventHandlers.joined(payload);
1043
+ return;
1044
+ }
1045
+ if (data.type === 'player_joined') {
1046
+ if (this._eventHandlers.playerJoined) this._eventHandlers.playerJoined(payload);
1047
+ return;
1048
+ }
1049
+ if (data.type === 'player_left') {
1050
+ if (this._eventHandlers.playerLeft) this._eventHandlers.playerLeft(payload);
1051
+ return;
1052
+ }
1053
+ if (data.type === 'state_snapshot' || data.type === 'state_delta') {
1054
+ if (this._eventHandlers.realtime) this._eventHandlers.realtime(payload);
1055
+ if (this._eventHandlers.stateUpdate) this._eventHandlers.stateUpdate(payload);
1056
+ return;
1057
+ }
1058
+ if (data.type === 'pong') {
1059
+ if (this._eventHandlers.sync) this._eventHandlers.sync(payload);
1060
+ return;
1061
+ }
1062
+ if (data.type === 'match_end') {
1063
+ if (this._eventHandlers.finished) this._eventHandlers.finished(payload);
1064
+ return;
1065
+ }
1066
+ if (data.type === 'error' && this._eventHandlers.error) {
1067
+ this._eventHandlers.error(payload);
1068
+ }
1069
+ },
1070
+
1071
+ /**
1072
+ * Initialize socket connection
1073
+ * @private
1074
+ */
1075
+ _initSocket: function(socketUrl, token, resolve, reject) {
1076
+ const self = this;
1077
+
1078
+ // Prevent creating duplicate sockets
1079
+ if (self.socket && self.socket.connected) {
1080
+ self._connecting = false;
1081
+ resolve();
1082
+ return;
1083
+ }
1084
+
1085
+ // Clean up any existing disconnected socket
1086
+ if (self.socket) {
1087
+ self.socket.disconnect();
1088
+ self.socket = null;
1089
+ }
1090
+
1091
+ try {
1092
+ self.socket = io(socketUrl, {
1093
+ path: '/socket.io',
1094
+ transports: ['websocket', 'polling'],
1095
+ auth: { token: token },
1096
+ autoConnect: true,
1097
+ reconnection: true,
1098
+ reconnectionAttempts: 50,
1099
+ reconnectionDelay: 1000,
1100
+ reconnectionDelayMax: 10000
1101
+ });
1102
+
1103
+ self.socket.on('connect', function() {
1104
+ self.connected = true;
1105
+ self._connecting = false;
1106
+ Usion.log('Game socket connected');
1107
+
1108
+ // Start heartbeat to keep game session alive
1109
+ if (self._heartbeatInterval) clearInterval(self._heartbeatInterval);
1110
+ self._heartbeatInterval = setInterval(function() {
1111
+ if (self.socket && self.connected && self.roomId) {
1112
+ self.socket.emit('game:heartbeat', { room_id: self.roomId });
1113
+ }
1114
+ }, 25000);
1115
+
1116
+ // Re-join room after reconnect so this socket gets room broadcasts again
1117
+ if (self.roomId) {
1118
+ self._joined = false;
1119
+ self._joinPromise = null;
1120
+ self.join(self.roomId)
1121
+ .then(function() {
1122
+ Usion.log('Reconnected - joined room ' + self.roomId);
1123
+ self.requestSync(self._lastSequence || 0);
1124
+ })
1125
+ .catch(function(err) {
1126
+ Usion.log('Rejoin failed: ' + (err && err.message ? err.message : String(err)));
1127
+ });
1128
+ }
1129
+
1130
+ resolve();
1131
+ });
1132
+
1133
+ self.socket.on('connect_error', function(err) {
1134
+ self._connecting = false;
1135
+ Usion.log('Game socket error: ' + err.message);
1136
+ if (self._eventHandlers.connectionError) {
1137
+ self._eventHandlers.connectionError(err);
1138
+ }
1139
+ reject(err);
1140
+ });
1141
+
1142
+ self.socket.on('disconnect', function(reason) {
1143
+ self.connected = false;
1144
+ self._joined = false;
1145
+ self._joinPromise = null;
1146
+ if (self._heartbeatInterval) {
1147
+ clearInterval(self._heartbeatInterval);
1148
+ self._heartbeatInterval = null;
1149
+ }
1150
+ Usion.log('Game socket disconnected: ' + reason);
1151
+ if (self._eventHandlers.disconnect) {
1152
+ self._eventHandlers.disconnect(reason);
1153
+ }
1154
+ });
1155
+
1156
+ self.socket.on('reconnect', function(attemptNumber) {
1157
+ Usion.log('Game socket reconnected after ' + attemptNumber + ' attempts');
1158
+ if (self._eventHandlers.reconnect) {
1159
+ self._eventHandlers.reconnect(attemptNumber);
1160
+ }
1161
+ });
1162
+
1163
+ // Game event handlers
1164
+ self.socket.on('game:joined', function(data) {
1165
+ if (data.sequence !== undefined) {
1166
+ self._lastSequence = data.sequence;
1167
+ }
1168
+ if (self._eventHandlers.joined) {
1169
+ self._eventHandlers.joined(data);
1170
+ }
1171
+ });
1172
+
1173
+ self.socket.on('game:player_joined', function(data) {
1174
+ if (self._eventHandlers.playerJoined) {
1175
+ self._eventHandlers.playerJoined(data);
1176
+ }
1177
+ });
1178
+
1179
+ self.socket.on('game:player_left', function(data) {
1180
+ if (self._eventHandlers.playerLeft) {
1181
+ self._eventHandlers.playerLeft(data);
1182
+ }
1183
+ });
1184
+
1185
+ self.socket.on('game:state', function(data) {
1186
+ if (data.sequence !== undefined) {
1187
+ self._lastSequence = Math.max(self._lastSequence, data.sequence);
1188
+ }
1189
+ if (self._eventHandlers.stateUpdate) {
1190
+ self._eventHandlers.stateUpdate(data);
1191
+ }
1192
+ });
1193
+
1194
+ self.socket.on('game:sync', function(data) {
1195
+ if (data.sequence !== undefined) {
1196
+ self._lastSequence = data.sequence;
1197
+ }
1198
+ if (self._eventHandlers.sync) {
1199
+ self._eventHandlers.sync(data);
1200
+ }
1201
+ // Also trigger stateUpdate for backwards compat
1202
+ if (self._eventHandlers.stateUpdate) {
1203
+ self._eventHandlers.stateUpdate(data);
1204
+ }
1205
+ });
1206
+
1207
+ self.socket.on('game:action', function(data) {
1208
+ if (data.sequence !== undefined) {
1209
+ self._lastSequence = Math.max(self._lastSequence, data.sequence);
1210
+ }
1211
+ if (self._eventHandlers.action) {
1212
+ self._eventHandlers.action(data);
1213
+ }
1214
+ });
1215
+
1216
+ self.socket.on('game:realtime', function(data) {
1217
+ if (self._eventHandlers.realtime) {
1218
+ self._eventHandlers.realtime(data);
1219
+ }
1220
+ });
1221
+
1222
+ self.socket.on('game:finished', function(data) {
1223
+ if (data.sequence !== undefined) {
1224
+ self._lastSequence = data.sequence;
1225
+ }
1226
+ if (self._eventHandlers.finished) {
1227
+ self._eventHandlers.finished(data);
1228
+ }
1229
+ });
1230
+
1231
+ self.socket.on('game:error', function(data) {
1232
+ Usion.log('Game error: ' + (data.message || data.code));
1233
+ if (self._eventHandlers.error) {
1234
+ self._eventHandlers.error(data);
1235
+ }
1236
+ });
1237
+
1238
+ self.socket.on('game:rematch_request', function(data) {
1239
+ if (self._eventHandlers.rematchRequest) {
1240
+ self._eventHandlers.rematchRequest(data);
1241
+ }
1242
+ });
1243
+
1244
+ self.socket.on('game:restarted', function(data) {
1245
+ self._lastSequence = 0; // Reset sequence on rematch
1246
+ if (self._eventHandlers.restarted) {
1247
+ self._eventHandlers.restarted(data);
1248
+ }
1249
+ });
1250
+
1251
+ } catch (err) {
1252
+ reject(err);
1253
+ }
1254
+ },
1255
+
1256
+ /**
1257
+ * Connect via parent app proxy (postMessage relay).
1258
+ * Used when mixed content prevents direct socket connection.
1259
+ * @private
1260
+ */
1261
+ _connectViaProxy: function() {
1262
+ var self = this;
1263
+
1264
+ if (self._useProxy && self.connected) {
1265
+ return Promise.resolve();
1266
+ }
1267
+
1268
+ self._useProxy = true;
1269
+ self._connecting = true;
1270
+ self._setupProxyListener();
1271
+
1272
+ self._connectPromise = new Promise(function(resolve, reject) {
1273
+ // Listen for GAME_CONNECTED from parent
1274
+ self._proxyConnectResolve = function() {
1275
+ self.connected = true;
1276
+ self._connecting = false;
1277
+ Usion.log('Game socket connected via parent proxy');
1278
+ resolve();
1279
+ };
1280
+
1281
+ // Send connect request to parent
1282
+ Usion._post({ type: 'GAME_CONNECT' });
1283
+
1284
+ // Timeout after 10s
1285
+ setTimeout(function() {
1286
+ if (!self.connected) {
1287
+ self._connecting = false;
1288
+ reject(new Error('Proxy connection timeout'));
1289
+ }
1290
+ }, 10000);
1291
+ });
1292
+
1293
+ return self._connectPromise;
1294
+ },
1295
+
1296
+ /**
1297
+ * Set up message listener for proxy game events from parent.
1298
+ * @private
1299
+ */
1300
+ _setupProxyListener: function() {
1301
+ var self = this;
1302
+ if (self._proxyListenerSetup) return;
1303
+ self._proxyListenerSetup = true;
1304
+
1305
+ window.addEventListener('message', function(event) {
1306
+ var data;
1307
+ try {
1308
+ data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
1309
+ } catch (e) {
1310
+ return;
1311
+ }
1312
+ if (!data || typeof data !== 'object' || !self._useProxy) return;
1313
+
1314
+ switch (data.type) {
1315
+ case 'GAME_CONNECTED':
1316
+ if (self._proxyConnectResolve) {
1317
+ self._proxyConnectResolve();
1318
+ self._proxyConnectResolve = null;
1319
+ }
1320
+ break;
1321
+
1322
+ case 'GAME_CONNECT_ERROR':
1323
+ self.connected = false;
1324
+ self._connecting = false;
1325
+ break;
1326
+
1327
+ case 'GAME_JOINED':
1328
+ self._joined = true;
1329
+ if (data.sequence !== undefined) self._lastSequence = data.sequence;
1330
+ if (self._proxyJoinResolve) {
1331
+ self._proxyJoinResolve(data);
1332
+ self._proxyJoinResolve = null;
1333
+ }
1334
+ if (self._eventHandlers.joined) self._eventHandlers.joined(data);
1335
+ break;
1336
+
1337
+ case 'GAME_JOIN_ERROR':
1338
+ self._joined = false;
1339
+ if (self._proxyJoinReject) {
1340
+ self._proxyJoinReject(new Error(data.message || 'Join failed'));
1341
+ self._proxyJoinReject = null;
1342
+ }
1343
+ break;
1344
+
1345
+ case 'GAME_PLAYER_JOINED':
1346
+ if (self._eventHandlers.playerJoined) self._eventHandlers.playerJoined(data);
1347
+ break;
1348
+
1349
+ case 'GAME_PLAYER_LEFT':
1350
+ if (self._eventHandlers.playerLeft) self._eventHandlers.playerLeft(data);
1351
+ break;
1352
+
1353
+ case 'GAME_STATE':
1354
+ if (data.sequence !== undefined) self._lastSequence = Math.max(self._lastSequence, data.sequence);
1355
+ if (self._eventHandlers.stateUpdate) self._eventHandlers.stateUpdate(data);
1356
+ break;
1357
+
1358
+ case 'GAME_ACTION_DATA':
1359
+ if (data.sequence !== undefined) self._lastSequence = Math.max(self._lastSequence, data.sequence);
1360
+ if (self._eventHandlers.action) self._eventHandlers.action(data);
1361
+ break;
1362
+
1363
+ case 'GAME_REALTIME_DATA':
1364
+ if (self._eventHandlers.realtime) self._eventHandlers.realtime(data);
1365
+ break;
1366
+
1367
+ case 'GAME_FINISHED':
1368
+ if (data.sequence !== undefined) self._lastSequence = data.sequence;
1369
+ if (self._eventHandlers.finished) self._eventHandlers.finished(data);
1370
+ break;
1371
+
1372
+ case 'GAME_ERROR':
1373
+ Usion.log('Game error via proxy: ' + (data.message || data.code));
1374
+ if (self._eventHandlers.error) self._eventHandlers.error(data);
1375
+ break;
1376
+
1377
+ case 'GAME_RESTARTED':
1378
+ self._lastSequence = 0;
1379
+ if (self._eventHandlers.restarted) self._eventHandlers.restarted(data);
1380
+ break;
1381
+
1382
+ case 'GAME_REMATCH_REQUEST':
1383
+ if (self._eventHandlers.rematchRequest) self._eventHandlers.rematchRequest(data);
1384
+ break;
1385
+
1386
+ case 'GAME_SYNC':
1387
+ if (data.sequence !== undefined) self._lastSequence = data.sequence;
1388
+ if (self._eventHandlers.sync) self._eventHandlers.sync(data);
1389
+ if (self._eventHandlers.stateUpdate) self._eventHandlers.stateUpdate(data);
1390
+ break;
1391
+ }
1392
+ });
1393
+ },
1394
+
1395
+ /**
1396
+ * Join a game room
1397
+ * @param {string} roomId - Game room ID (optional, uses config)
1398
+ * @returns {Promise} Resolves with join data
1399
+ */
1400
+ join: function(roomId) {
1401
+ const self = this;
1402
+ roomId = roomId || Usion.config.roomId;
1403
+
1404
+ // If already joined this room, return cached promise/data
1405
+ if (self._joined && self.roomId === roomId && self._joinPromise) {
1406
+ return self._joinPromise;
1407
+ }
1408
+
1409
+ self.roomId = roomId;
1410
+ self.playerId = Usion.user.getId();
1411
+
1412
+ if (self.directMode) {
1413
+ self._joined = true;
1414
+ self._joinPromise = Promise.resolve({
1415
+ room_id: roomId,
1416
+ player_id: self.playerId
1417
+ });
1418
+ return self._joinPromise;
1419
+ }
1420
+
1421
+ // Proxy mode: send join request to parent
1422
+ if (self._useProxy) {
1423
+ self._joinPromise = new Promise(function(resolve, reject) {
1424
+ self._proxyJoinResolve = resolve;
1425
+ self._proxyJoinReject = reject;
1426
+ Usion._post({ type: 'GAME_JOIN', room_id: roomId });
1427
+ setTimeout(function() {
1428
+ if (!self._joined && self._proxyJoinReject) {
1429
+ self._proxyJoinReject = null;
1430
+ reject(new Error('Join timeout'));
1431
+ }
1432
+ }, 15000);
1433
+ });
1434
+ return self._joinPromise;
1435
+ }
1436
+
1437
+ self._joinPromise = new Promise(function(resolve, reject) {
1438
+ if (!self.socket || !self.connected) {
1439
+ reject(new Error('Not connected'));
1440
+ return;
1441
+ }
1442
+
1443
+ if (!roomId) {
1444
+ reject(new Error('No room ID provided'));
1445
+ return;
1446
+ }
1447
+
1448
+ self.socket.emit('game:join', { room_id: roomId }, function(response) {
1449
+ if (response.error) {
1450
+ self._joined = false;
1451
+ reject(new Error(response.message || response.error));
1452
+ } else {
1453
+ self._joined = true;
1454
+ if (response.sequence !== undefined) {
1455
+ self._lastSequence = response.sequence;
1456
+ }
1457
+ resolve(response);
1458
+ }
1459
+ });
1460
+ });
1461
+
1462
+ return self._joinPromise;
1463
+ },
1464
+
1465
+ /**
1466
+ * Leave the current game room
1467
+ */
1468
+ leave: function() {
1469
+ const self = this;
1470
+
1471
+ if (self.directMode) {
1472
+ if (self.roomId) self._sendDirect('leave', {});
1473
+ self.roomId = null;
1474
+ self._lastSequence = 0;
1475
+ self._joined = false;
1476
+ self._joinPromise = null;
1477
+ return;
1478
+ }
1479
+
1480
+ if (self._useProxy) {
1481
+ if (self.roomId) Usion._post({ type: 'GAME_LEAVE', room_id: self.roomId });
1482
+ self.roomId = null;
1483
+ self._lastSequence = 0;
1484
+ self._joined = false;
1485
+ self._joinPromise = null;
1486
+ return;
1487
+ }
1488
+
1489
+ if (self.socket && self.connected && self.roomId) {
1490
+ self.socket.emit('game:leave', { room_id: self.roomId });
1491
+ self.roomId = null;
1492
+ self._lastSequence = 0;
1493
+ self._joined = false;
1494
+ self._joinPromise = null;
1495
+ }
1496
+ },
1497
+
1498
+ /**
1499
+ * Send a game action
1500
+ * @param {string} actionType - Type of action (e.g., 'move')
1501
+ * @param {object} actionData - Action data
1502
+ * @returns {Promise} Resolves when action is processed
1503
+ */
1504
+ action: function(actionType, actionData) {
1505
+ const self = this;
1506
+
1507
+ if (self.directMode) {
1508
+ self._sendDirect(actionType || 'action', actionData || {});
1509
+ return Promise.resolve({ success: true });
1510
+ }
1511
+
1512
+ if (self._useProxy) {
1513
+ Usion._post({ type: 'GAME_ACTION', room_id: self.roomId, action_type: actionType, action_data: actionData });
1514
+ return Promise.resolve({ success: true });
1515
+ }
1516
+
1517
+ return new Promise(function(resolve, reject) {
1518
+ if (!self.socket || !self.connected) {
1519
+ reject(new Error('Not connected'));
1520
+ return;
1521
+ }
1522
+
1523
+ self.socket.emit('game:action', {
1524
+ room_id: self.roomId,
1525
+ action_type: actionType,
1526
+ action_data: actionData
1527
+ }, function(response) {
1528
+ if (response.error) {
1529
+ reject(new Error(response.message || response.error));
1530
+ } else {
1531
+ if (response.sequence !== undefined) {
1532
+ self._lastSequence = response.sequence;
1533
+ }
1534
+ resolve(response);
1535
+ }
1536
+ });
1537
+ });
1538
+ },
1539
+
1540
+ /**
1541
+ * Send a real-time game update (no locking, no rate limiting, fire-and-forget).
1542
+ * Use this for high-frequency updates like position, input, state broadcasts.
1543
+ * @param {string} actionType - Type of action (e.g., 'snake_input', 'position')
1544
+ * @param {object} actionData - Action data
1545
+ */
1546
+ realtime: function(actionType, actionData) {
1547
+ const self = this;
1548
+
1549
+ if (self.directMode) {
1550
+ self._sendDirect('input', {
1551
+ action_type: actionType,
1552
+ action_data: actionData || {}
1553
+ });
1554
+ return;
1555
+ }
1556
+
1557
+ if (self._useProxy) {
1558
+ Usion._post({ type: 'GAME_REALTIME', room_id: self.roomId, action_type: actionType, action_data: actionData });
1559
+ return;
1560
+ }
1561
+
1562
+ if (!self.socket || !self.connected) {
1563
+ return;
1564
+ }
1565
+
1566
+ self.socket.emit('game:realtime', {
1567
+ room_id: self.roomId,
1568
+ action_type: actionType,
1569
+ action_data: actionData
1570
+ });
1571
+ },
1572
+
1573
+ /**
1574
+ * Request game state sync (for reconnection)
1575
+ * @param {number} lastSequence - Last known sequence number
1576
+ */
1577
+ requestSync: function(lastSequence) {
1578
+ const self = this;
1579
+ lastSequence = lastSequence !== undefined ? lastSequence : self._lastSequence;
1580
+
1581
+ if (self.directMode) {
1582
+ self._sendDirect('ping', { last_sequence: lastSequence || 0 });
1583
+ return;
1584
+ }
1585
+
1586
+ if (self._useProxy && self.roomId) {
1587
+ Usion._post({ type: 'GAME_SYNC_REQUEST', room_id: self.roomId, last_sequence: lastSequence || 0 });
1588
+ return;
1589
+ }
1590
+
1591
+ if (self.socket && self.connected && self.roomId) {
1592
+ self.socket.emit('game:sync_request', {
1593
+ room_id: self.roomId,
1594
+ last_sequence: lastSequence || 0
1595
+ });
1596
+ }
1597
+ },
1598
+
1599
+ /**
1600
+ * Request a rematch
1601
+ */
1602
+ requestRematch: function() {
1603
+ const self = this;
1604
+
1605
+ if (self.directMode) {
1606
+ self._sendDirect('rematch', {});
1607
+ return;
1608
+ }
1609
+
1610
+ if (self._useProxy && self.roomId) {
1611
+ Usion._post({ type: 'GAME_REMATCH', room_id: self.roomId });
1612
+ return;
1613
+ }
1614
+
1615
+ if (self.socket && self.connected && self.roomId) {
1616
+ self.socket.emit('game:rematch', { room_id: self.roomId });
1617
+ }
1618
+ },
1619
+
1620
+ /**
1621
+ * Forfeit the current game
1622
+ * @returns {Promise}
1623
+ */
1624
+ forfeit: function() {
1625
+ const self = this;
1626
+
1627
+ if (self.directMode) {
1628
+ self._sendDirect('forfeit', {});
1629
+ return Promise.resolve({ success: true });
1630
+ }
1631
+
1632
+ if (self._useProxy) {
1633
+ Usion._post({ type: 'GAME_FORFEIT', room_id: self.roomId });
1634
+ return Promise.resolve({ success: true });
1635
+ }
1636
+
1637
+ return new Promise(function(resolve, reject) {
1638
+ if (!self.socket || !self.connected) {
1639
+ reject(new Error('Not connected'));
1640
+ return;
1641
+ }
1642
+
1643
+ self.socket.emit('game:forfeit', { room_id: self.roomId }, function(response) {
1644
+ if (response.error) {
1645
+ reject(new Error(response.message || response.error));
1646
+ } else {
1647
+ resolve(response);
1648
+ }
1649
+ });
1650
+ });
1651
+ },
1652
+
1653
+ /**
1654
+ * Disconnect from the game socket
1655
+ */
1656
+ disconnect: function() {
1657
+ const self = this;
1658
+
1659
+ // Always clear heartbeat
1660
+ if (self._heartbeatInterval) {
1661
+ clearInterval(self._heartbeatInterval);
1662
+ self._heartbeatInterval = null;
1663
+ }
1664
+
1665
+ if (self.directMode) {
1666
+ if (self.directSocket) {
1667
+ try { self.directSocket.close(); } catch (e) {}
1668
+ }
1669
+ self.directSocket = null;
1670
+ self.connected = false;
1671
+ self.roomId = null;
1672
+ self._lastSequence = 0;
1673
+ self._connecting = false;
1674
+ self._connectPromise = null;
1675
+ self._joined = false;
1676
+ self._joinPromise = null;
1677
+ self.directMode = false;
1678
+ self.directConfig = null;
1679
+ self._directSeq = 0;
1680
+ return;
1681
+ }
1682
+
1683
+ if (self._useProxy) {
1684
+ Usion._post({ type: 'GAME_DISCONNECT' });
1685
+ self.connected = false;
1686
+ self.roomId = null;
1687
+ self._lastSequence = 0;
1688
+ self._connecting = false;
1689
+ self._connectPromise = null;
1690
+ self._joined = false;
1691
+ self._joinPromise = null;
1692
+ self._useProxy = false;
1693
+ return;
1694
+ }
1695
+
1696
+ if (self.socket) {
1697
+ self.socket.disconnect();
1698
+ self.socket = null;
1699
+ self.connected = false;
1700
+ self.roomId = null;
1701
+ self._lastSequence = 0;
1702
+ self._connecting = false;
1703
+ self._connectPromise = null;
1704
+ self._joined = false;
1705
+ self._joinPromise = null;
1706
+ }
1707
+ },
1708
+
1709
+ /**
1710
+ * Get connection status
1711
+ * @returns {boolean}
1712
+ */
1713
+ isConnected: function() {
1714
+ if (this.directMode) {
1715
+ return this.connected && this.directSocket && this.directSocket.readyState === WebSocket.OPEN;
1716
+ }
1717
+ return this.connected && this.socket && this.socket.connected;
1718
+ },
1719
+
1720
+ // Event handler registrations
1721
+ onJoined: function(callback) {
1722
+ this._eventHandlers.joined = callback;
1723
+ },
1724
+
1725
+ onPlayerJoined: function(callback) {
1726
+ this._eventHandlers.playerJoined = callback;
1727
+ },
1728
+
1729
+ onPlayerLeft: function(callback) {
1730
+ this._eventHandlers.playerLeft = callback;
1731
+ },
1732
+
1733
+ onStateUpdate: function(callback) {
1734
+ this._eventHandlers.stateUpdate = callback;
1735
+ },
1736
+
1737
+ onSync: function(callback) {
1738
+ this._eventHandlers.sync = callback;
1739
+ },
1740
+
1741
+ onAction: function(callback) {
1742
+ this._eventHandlers.action = callback;
1743
+ },
1744
+
1745
+ onRealtime: function(callback) {
1746
+ this._eventHandlers.realtime = callback;
1747
+ },
1748
+
1749
+ onGameFinished: function(callback) {
1750
+ this._eventHandlers.finished = callback;
1751
+ },
1752
+
1753
+ onGameRestarted: function(callback) {
1754
+ this._eventHandlers.restarted = callback;
1755
+ },
1756
+
1757
+ onError: function(callback) {
1758
+ this._eventHandlers.error = callback;
1759
+ },
1760
+
1761
+ onRematchRequest: function(callback) {
1762
+ this._eventHandlers.rematchRequest = callback;
1763
+ },
1764
+
1765
+ onDisconnect: function(callback) {
1766
+ this._eventHandlers.disconnect = callback;
1767
+ },
1768
+
1769
+ onReconnect: function(callback) {
1770
+ this._eventHandlers.reconnect = callback;
1771
+ },
1772
+
1773
+ onConnectionError: function(callback) {
1774
+ this._eventHandlers.connectionError = callback;
1775
+ },
1776
+
1777
+ /**
1778
+ * Register a generic event handler
1779
+ * @param {string} event - Event name
1780
+ * @param {function} callback - Handler function
1781
+ */
1782
+ on: function(event, callback) {
1783
+ if (this.socket) {
1784
+ this.socket.on(event, callback);
1785
+ }
1786
+ }
1787
+ }
1788
+ };
1789
+
1790
+ // Expose to global
1791
+ global.Usion = Usion;
1792
+
1793
+ })(typeof window !== 'undefined' ? window : this);