@usions/sdk 2.11.1 → 2.13.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.
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { isTrustedMessageSource } from './core.js';
6
+ import { UsionError, toUsionError, ERROR_CODES } from './errors.js';
6
7
 
7
8
  /**
8
9
  * Add proxy connection methods to game module
@@ -42,7 +43,7 @@ export function applyGameProxy(game, Usion) {
42
43
  setTimeout(function() {
43
44
  if (!self.connected) {
44
45
  self._connecting = false;
45
- reject(new Error('Proxy connection timeout'));
46
+ reject(new UsionError(ERROR_CODES.CONNECT_TIMEOUT, 'Proxy connection timeout'));
46
47
  }
47
48
  }, 10000);
48
49
  });
@@ -83,69 +84,97 @@ export function applyGameProxy(game, Usion) {
83
84
  self._connecting = false;
84
85
  break;
85
86
 
87
+ case 'GAME_DISCONNECTED':
88
+ // The host app's socket dropped; it will rejoin and resync for us.
89
+ self.connected = false;
90
+ self._dispatch('disconnect', data.reason || 'transport');
91
+ break;
92
+
93
+ case 'GAME_RECONNECTED':
94
+ self.connected = true;
95
+ self._dispatch('reconnect', data.attempts || 1);
96
+ break;
97
+
98
+ case 'GAME_PLAYER_CONNECTION':
99
+ self._dispatch('playerConnection', data);
100
+ break;
101
+
86
102
  case 'GAME_JOINED':
87
103
  self._joined = true;
88
- if (data.sequence !== undefined) self._lastSequence = data.sequence;
104
+ if (data.sequence !== undefined) {
105
+ self._lastSequence = data.sequence;
106
+ self._lastActionApplied = Math.max(self._lastActionApplied, data.sequence);
107
+ }
89
108
  if (self._proxyJoinResolve) {
90
109
  self._proxyJoinResolve(data);
91
110
  self._proxyJoinResolve = null;
92
111
  }
93
- if (self._eventHandlers.joined) self._eventHandlers.joined(data);
112
+ self._dispatch('joined', data);
94
113
  break;
95
114
 
96
115
  case 'GAME_JOIN_ERROR':
97
116
  self._joined = false;
98
117
  if (self._proxyJoinReject) {
99
- self._proxyJoinReject(new Error(data.message || 'Join failed'));
118
+ self._proxyJoinReject(toUsionError(data.message || 'Join failed'));
100
119
  self._proxyJoinReject = null;
101
120
  }
102
121
  break;
103
122
 
104
123
  case 'GAME_PLAYER_JOINED':
105
- if (self._eventHandlers.playerJoined) self._eventHandlers.playerJoined(data);
124
+ self._dispatch('playerJoined', data);
106
125
  break;
107
126
 
108
127
  case 'GAME_PLAYER_LEFT':
109
- if (self._eventHandlers.playerLeft) self._eventHandlers.playerLeft(data);
128
+ self._dispatch('playerLeft', data);
110
129
  break;
111
130
 
112
131
  case 'GAME_STATE':
113
132
  if (data.sequence !== undefined) self._lastSequence = Math.max(self._lastSequence, data.sequence);
114
- if (self._eventHandlers.stateUpdate) self._eventHandlers.stateUpdate(data);
133
+ self._dispatch('stateUpdate', data);
115
134
  break;
116
135
 
117
136
  case 'GAME_ACTION_DATA':
118
- if (data.sequence !== undefined) self._lastSequence = Math.max(self._lastSequence, data.sequence);
119
- if (self._eventHandlers.action) self._eventHandlers.action(data);
137
+ if (data.sequence !== undefined) {
138
+ // Drop duplicates: actions are delivered exactly once per
139
+ // sequence (live, sender echo, or replay after reconnect).
140
+ if (data.sequence <= self._lastActionApplied) break;
141
+ self._lastActionApplied = data.sequence;
142
+ self._lastSequence = Math.max(self._lastSequence, data.sequence);
143
+ }
144
+ self._dispatch('action', data);
120
145
  break;
121
146
 
122
147
  case 'GAME_REALTIME_DATA':
123
- if (self._eventHandlers.realtime) self._eventHandlers.realtime(data);
148
+ self._dispatch('realtime', data);
124
149
  break;
125
150
 
126
151
  case 'GAME_FINISHED':
127
152
  if (data.sequence !== undefined) self._lastSequence = data.sequence;
128
- if (self._eventHandlers.finished) self._eventHandlers.finished(data);
153
+ self._dispatch('finished', data);
129
154
  break;
130
155
 
131
156
  case 'GAME_ERROR':
132
157
  Usion.log('Game error via proxy: ' + (data.message || data.code));
133
- if (self._eventHandlers.error) self._eventHandlers.error(data);
158
+ self._dispatch('error', data);
134
159
  break;
135
160
 
136
161
  case 'GAME_RESTARTED':
137
162
  self._lastSequence = 0;
138
- if (self._eventHandlers.restarted) self._eventHandlers.restarted(data);
163
+ self._lastActionApplied = 0;
164
+ self._dispatch('restarted', data);
139
165
  break;
140
166
 
141
167
  case 'GAME_REMATCH_REQUEST':
142
- if (self._eventHandlers.rematchRequest) self._eventHandlers.rematchRequest(data);
168
+ self._dispatch('rematchRequest', data);
143
169
  break;
144
170
 
145
171
  case 'GAME_SYNC':
146
- if (data.sequence !== undefined) self._lastSequence = data.sequence;
147
- if (self._eventHandlers.sync) self._eventHandlers.sync(data);
148
- if (self._eventHandlers.stateUpdate) self._eventHandlers.stateUpdate(data);
172
+ if (data.sequence !== undefined) {
173
+ self._lastSequence = data.sequence;
174
+ self._lastActionApplied = Math.max(self._lastActionApplied, data.sequence);
175
+ }
176
+ self._dispatch('sync', data);
177
+ self._dispatch('stateUpdate', data);
149
178
  break;
150
179
  }
151
180
  });
@@ -57,17 +57,27 @@ export function applyGameSocket(game, Usion) {
57
57
  }
58
58
  }, 25000);
59
59
 
60
- // Re-join room after reconnect
60
+ // Re-join room after reconnect. The promise is kept on
61
+ // _rejoinPromise so action() can gate sends until the room
62
+ // membership and sync are restored — otherwise a stale move could
63
+ // go out before the client has caught up.
61
64
  if (self.roomId) {
62
65
  self._joined = false;
63
66
  self._joinPromise = null;
64
- self.join(self.roomId)
67
+ self._rejoinPromise = self.join(self.roomId)
65
68
  .then(function() {
66
69
  Usion.log('Reconnected - joined room ' + self.roomId);
67
70
  self.requestSync(self._lastSequence || 0);
68
71
  })
69
72
  .catch(function(err) {
70
73
  Usion.log('Rejoin failed: ' + (err && err.message ? err.message : String(err)));
74
+ })
75
+ .then(function() {
76
+ self._rejoinPromise = null;
77
+ // Send any moves queued while offline, now that membership
78
+ // and sync are restored. (Socket.IO v4 emits 'reconnect' on
79
+ // the Manager, not the Socket, so this is the reliable hook.)
80
+ if (self._flushOfflineQueue) self._flushOfflineQueue();
71
81
  });
72
82
  }
73
83
 
@@ -77,9 +87,7 @@ export function applyGameSocket(game, Usion) {
77
87
  self.socket.on('connect_error', function(err) {
78
88
  self._connecting = false;
79
89
  Usion.log('Game socket error: ' + err.message);
80
- if (self._eventHandlers.connectionError) {
81
- self._eventHandlers.connectionError(err);
82
- }
90
+ self._dispatch('connectionError', err);
83
91
  reject(err);
84
92
  });
85
93
 
@@ -92,104 +100,92 @@ export function applyGameSocket(game, Usion) {
92
100
  self._heartbeatInterval = null;
93
101
  }
94
102
  Usion.log('Game socket disconnected: ' + reason);
95
- if (self._eventHandlers.disconnect) {
96
- self._eventHandlers.disconnect(reason);
97
- }
103
+ self._dispatch('disconnect', reason);
98
104
  });
99
105
 
100
106
  self.socket.on('reconnect', function(attemptNumber) {
101
107
  Usion.log('Game socket reconnected after ' + attemptNumber + ' attempts');
102
- if (self._eventHandlers.reconnect) {
103
- self._eventHandlers.reconnect(attemptNumber);
104
- }
108
+ self._dispatch('reconnect', attemptNumber);
105
109
  });
106
110
 
107
111
  // Game event handlers
108
112
  self.socket.on('game:joined', function(data) {
109
113
  if (data.sequence !== undefined) {
110
114
  self._lastSequence = data.sequence;
115
+ // Everything up to this sequence is reflected in the joined
116
+ // state — actions at or below it must not be re-delivered.
117
+ self._lastActionApplied = Math.max(self._lastActionApplied, data.sequence);
111
118
  }
112
- if (self._eventHandlers.joined) {
113
- self._eventHandlers.joined(data);
114
- }
119
+ self._dispatch('joined', data);
115
120
  });
116
121
 
117
122
  self.socket.on('game:player_joined', function(data) {
118
- if (self._eventHandlers.playerJoined) {
119
- self._eventHandlers.playerJoined(data);
120
- }
123
+ self._dispatch('playerJoined', data);
121
124
  });
122
125
 
123
126
  self.socket.on('game:player_left', function(data) {
124
- if (self._eventHandlers.playerLeft) {
125
- self._eventHandlers.playerLeft(data);
126
- }
127
+ self._dispatch('playerLeft', data);
127
128
  });
128
129
 
129
130
  self.socket.on('game:state', function(data) {
130
131
  if (data.sequence !== undefined) {
131
132
  self._lastSequence = Math.max(self._lastSequence, data.sequence);
132
133
  }
133
- if (self._eventHandlers.stateUpdate) {
134
- self._eventHandlers.stateUpdate(data);
135
- }
134
+ self._dispatch('stateUpdate', data);
136
135
  });
137
136
 
138
137
  self.socket.on('game:sync', function(data) {
139
138
  if (data.sequence !== undefined) {
140
139
  self._lastSequence = data.sequence;
140
+ // The sync payload carries all actions up to this sequence; a
141
+ // live echo of one of them must not be applied a second time.
142
+ self._lastActionApplied = Math.max(self._lastActionApplied, data.sequence);
141
143
  }
142
- if (self._eventHandlers.sync) {
143
- self._eventHandlers.sync(data);
144
- }
144
+ self._dispatch('sync', data);
145
145
  // Also trigger stateUpdate for backwards compat
146
- if (self._eventHandlers.stateUpdate) {
147
- self._eventHandlers.stateUpdate(data);
148
- }
146
+ self._dispatch('stateUpdate', data);
149
147
  });
150
148
 
151
149
  self.socket.on('game:action', function(data) {
152
150
  if (data.sequence !== undefined) {
151
+ // Drop duplicates: actions are delivered exactly once per
152
+ // sequence, whether they arrive live, as the sender's own echo,
153
+ // or replayed after a reconnect.
154
+ if (data.sequence <= self._lastActionApplied) return;
155
+ self._lastActionApplied = data.sequence;
153
156
  self._lastSequence = Math.max(self._lastSequence, data.sequence);
154
157
  }
155
- if (self._eventHandlers.action) {
156
- self._eventHandlers.action(data);
157
- }
158
+ self._dispatch('action', data);
159
+ });
160
+
161
+ self.socket.on('game:player_connection', function(data) {
162
+ self._dispatch('playerConnection', data);
158
163
  });
159
164
 
160
165
  self.socket.on('game:realtime', function(data) {
161
- if (self._eventHandlers.realtime) {
162
- self._eventHandlers.realtime(data);
163
- }
166
+ self._dispatch('realtime', data);
164
167
  });
165
168
 
166
169
  self.socket.on('game:finished', function(data) {
167
170
  if (data.sequence !== undefined) {
168
171
  self._lastSequence = data.sequence;
169
172
  }
170
- if (self._eventHandlers.finished) {
171
- self._eventHandlers.finished(data);
172
- }
173
+ self._dispatch('finished', data);
173
174
  });
174
175
 
175
176
  self.socket.on('game:error', function(data) {
176
177
  Usion.log('Game error: ' + (data.message || data.code));
177
- if (self._eventHandlers.error) {
178
- self._eventHandlers.error(data);
179
- }
178
+ self._dispatch('error', data);
180
179
  });
181
180
 
182
181
  self.socket.on('game:rematch_request', function(data) {
183
- if (self._eventHandlers.rematchRequest) {
184
- self._eventHandlers.rematchRequest(data);
185
- }
182
+ self._dispatch('rematchRequest', data);
186
183
  });
187
184
 
188
185
  self.socket.on('game:restarted', function(data) {
189
186
  self._lastSequence = 0; // Reset sequence on rematch
190
- if (self._eventHandlers.restarted) {
191
- self._eventHandlers.restarted(data);
192
- }
187
+ self._lastActionApplied = 0;
188
+ self._dispatch('restarted', data);
193
189
  });
194
190
 
195
191
  } catch (err) {
@@ -29,8 +29,10 @@ import { createLobbyModule } from './lobby.js';
29
29
  import { createLeaderboardModule } from './leaderboard.js';
30
30
  import { createCloudModule } from './cloud.js';
31
31
  import { createMatchmakingModule } from './matchmaking.js';
32
+ import { createNotifyModule } from './notify.js';
32
33
  import { applyBackendChannel } from './backend-channel.js';
33
34
  import { netcode } from './netcode/index.js';
35
+ import { UsionError, ERROR_CODES } from './errors.js';
34
36
 
35
37
  // Build the Usion object from core
36
38
  const Usion = Object.assign({}, core);
@@ -44,12 +46,16 @@ Usion.chat = createChatModule(Usion);
44
46
  Usion.bot = createBotModule(Usion);
45
47
  Usion.fileStorage = createFileStorageModule(Usion);
46
48
  Usion.game = createGameModule(Usion);
49
+ // Stable error class + codes — developers branch on err.code, not message text.
50
+ Usion.UsionError = UsionError;
51
+ Usion.ERROR_CODES = ERROR_CODES;
47
52
  // Unified backend channel (used by lobby etc.; works standalone + embedded).
48
53
  applyBackendChannel(Usion);
49
54
  Usion.lobby = createLobbyModule(Usion);
50
55
  Usion.leaderboard = createLeaderboardModule(Usion);
51
56
  Usion.cloud = createCloudModule(Usion);
52
57
  Usion.matchmaking = createMatchmakingModule(Usion);
58
+ Usion.notify = createNotifyModule(Usion);
53
59
 
54
60
  // Netcode toolkit (transport-agnostic, zero-dependency).
55
61
  Usion.netcode = netcode;
@@ -13,6 +13,26 @@ export const miscMethods = {
13
13
  return this.wallet.requestPayment(amount, reason, data);
14
14
  },
15
15
 
16
+ /**
17
+ * Live SDK diagnostics snapshot: version, transport, connection and
18
+ * sequence state. Surfaced automatically in the platform debug overlay
19
+ * (game.debug attaches it as _diag) and useful in bug reports.
20
+ */
21
+ diagnostics: function() {
22
+ const game = this.game || {};
23
+ return {
24
+ version: this.version,
25
+ transport: game.directMode ? 'direct' : (game._useProxy ? 'proxy' : (game.socket ? 'socket' : 'none')),
26
+ connected: !!game.connected,
27
+ joined: !!game._joined,
28
+ roomId: game.roomId || null,
29
+ playerId: game.playerId || null,
30
+ lastSequence: game._lastSequence || 0,
31
+ lastActionApplied: game._lastActionApplied || 0,
32
+ rejoining: !!game._rejoinPromise,
33
+ };
34
+ },
35
+
16
36
  /**
17
37
  * Submit result and signal completion
18
38
  * @param {object} data - Result data to send to parent
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Usion SDK Notify — let a mini-app notify its own user.
3
+ *
4
+ * A notification reaches the user even when they aren't looking at the app:
5
+ * - online elsewhere in Usion -> in-app banner
6
+ * - offline / app backgrounded -> OS push notification
7
+ * Tapping it re-opens THIS mini-app, optionally at a specific internal screen
8
+ * via `path` (read back on launch with `Usion.getLaunchParams().path`).
9
+ *
10
+ * Rides the unified backend channel, so it works standalone AND embedded.
11
+ * Scope & safety: a notification can only target the CURRENT user (you cannot
12
+ * notify other people from here), and the platform rate-limits per user per
13
+ * service. Users can silence a service with `setMuted(true)`.
14
+ *
15
+ * await Usion.notify.send({
16
+ * title: 'Render complete',
17
+ * body: 'Your video is ready to view.',
18
+ * path: '/render/abc123', // optional — deep-links inside the app
19
+ * });
20
+ *
21
+ * await Usion.notify.setMuted(true); // user opts out for this app
22
+ * const muted = await Usion.notify.isMuted();
23
+ *
24
+ * For server-triggered notifications (e.g. a long-running job finishing while
25
+ * the app is closed), a mini-app's own backend calls the signed REST endpoint
26
+ * `POST /services/{id}/notify` instead — see the publishing reference.
27
+ */
28
+ export function createNotifyModule(Usion) {
29
+ function serviceId(opts) {
30
+ return (opts && opts.serviceId) || (Usion.config && Usion.config.serviceId);
31
+ }
32
+
33
+ return {
34
+ /**
35
+ * Send a notification to the current user.
36
+ * @param {{title: string, body: string, path?: string, serviceId?: string}} opts
37
+ * @returns {Promise<{success: boolean, delivered?: string}>}
38
+ */
39
+ send: function (opts) {
40
+ opts = opts || {};
41
+ return Usion._backendEmit('notify:send', {
42
+ service_id: serviceId(opts),
43
+ title: opts.title,
44
+ body: opts.body,
45
+ path: opts.path,
46
+ });
47
+ },
48
+
49
+ /**
50
+ * Mute (or unmute) notifications from this app for the current user.
51
+ * @param {boolean} muted
52
+ * @returns {Promise<{success: boolean, muted: boolean}>}
53
+ */
54
+ setMuted: function (muted, opts) {
55
+ return Usion._backendEmit('notify:set_pref', {
56
+ service_id: serviceId(opts),
57
+ muted: !!muted,
58
+ });
59
+ },
60
+
61
+ /**
62
+ * Whether the current user has muted notifications from this app.
63
+ * @returns {Promise<boolean>}
64
+ */
65
+ isMuted: function (opts) {
66
+ return Usion._backendEmit('notify:get_pref', { service_id: serviceId(opts) })
67
+ .then(function (r) { return !!(r && r.muted); });
68
+ },
69
+ };
70
+ }
@@ -112,7 +112,13 @@ export function createWalletModule(Usion) {
112
112
  * @param {function} callback - Called with new balance
113
113
  */
114
114
  onBalanceChange: function(callback) {
115
- this._balanceChangeHandler = callback;
115
+ const self = this;
116
+ self._balanceChangeHandler = callback;
117
+ return function() {
118
+ if (self._balanceChangeHandler === callback) {
119
+ self._balanceChangeHandler = null;
120
+ }
121
+ };
116
122
  }
117
123
  };
118
124
  }