@bountyboard/arcade-sdk 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/global.js CHANGED
@@ -12,6 +12,8 @@
12
12
  var AD_PLACEMENT_SCRIPT_ID = "bb-arcade-h5-ads";
13
13
  var DEFAULT_AD_LOAD_TIMEOUT_MS = 6e3;
14
14
  var SAVE_TIMEOUT_MS = 15e3;
15
+ var LOCK_TIMEOUT_MS = 5e3;
16
+ var MAX_SAVE_BYTES = 1024 * 1024;
15
17
  var INIT_CONFIG_GRACE_MS = 1500;
16
18
  var DEFAULT_ALLOW = [
17
19
  "bountyboard.gg",
@@ -36,6 +38,14 @@
36
38
  if (typeof value !== "string") return fallback || "";
37
39
  return value.replace(/[^\w:.-]+/g, "_").slice(0, max || 80) || fallback || "";
38
40
  }
41
+ var cachedTextEncoder = null;
42
+ function utf8ByteLength(value) {
43
+ if (typeof TextEncoder !== "undefined") {
44
+ cachedTextEncoder = cachedTextEncoder || new TextEncoder();
45
+ return cachedTextEncoder.encode(value).length;
46
+ }
47
+ return new Blob([value]).size;
48
+ }
39
49
  function toBoolean(value) {
40
50
  return value === true || value === "true" || value === "1";
41
51
  }
@@ -155,13 +165,14 @@
155
165
  };
156
166
  let hostConfigReceived = false;
157
167
  let hostPlayer = null;
168
+ const playerChangeHandlers = [];
158
169
  let blocked = false;
159
170
  const pending = {};
160
171
  let seq = 0;
161
172
  function post(type, score, extra) {
162
173
  if (blocked) return;
163
174
  const message = { source: SDK_MESSAGE_SOURCE, v: VERSION, type };
164
- if (typeof score === "number" && isFinite(score)) message.score = Math.floor(score);
175
+ if (typeof score === "number" && isFinite(score)) message.score = Math.trunc(score);
165
176
  if (extra && typeof extra === "object") {
166
177
  const fields = extra;
167
178
  for (const key in fields) {
@@ -182,7 +193,7 @@
182
193
  return true;
183
194
  }
184
195
  }
185
- function request(type, payload) {
196
+ function request(type, payload, timeoutMs = SAVE_TIMEOUT_MS) {
186
197
  return new Promise(function(resolve, reject) {
187
198
  if (blocked) return reject(sdkError("error"));
188
199
  if (!isEmbedded()) return reject(sdkError("unsupported"));
@@ -192,7 +203,7 @@
192
203
  delete pending[requestId];
193
204
  reject(sdkError("error"));
194
205
  }
195
- }, SAVE_TIMEOUT_MS);
206
+ }, timeoutMs);
196
207
  pending[requestId] = { resolve, reject, timer };
197
208
  try {
198
209
  window.parent.postMessage(
@@ -284,7 +295,7 @@
284
295
  if (!hostnameAllowed(origin, allow)) blockHost(options);
285
296
  return;
286
297
  }
287
- request("host").then(
298
+ request("host", void 0, LOCK_TIMEOUT_MS).then(
288
299
  function(res) {
289
300
  const r = res;
290
301
  if (!hostnameAllowed(r && r.origin, allow)) blockHost(options);
@@ -308,7 +319,7 @@
308
319
  lockToHostBestEffort(opts, allow);
309
320
  return;
310
321
  }
311
- request("host_attest").then(
322
+ request("host_attest", void 0, LOCK_TIMEOUT_MS).then(
312
323
  function(res) {
313
324
  const r = res;
314
325
  if (!r || !r.attestation) {
@@ -370,7 +381,15 @@
370
381
  if (msg.source !== HOST_MESSAGE_SOURCE || msg.type !== "config") return;
371
382
  hostConfigReceived = true;
372
383
  if (msg.player !== void 0) {
373
- hostPlayer = sanitizePlayer(msg.player);
384
+ const nextPlayer = sanitizePlayer(msg.player);
385
+ const changed = nextPlayer === null ? hostPlayer !== null : hostPlayer === null || nextPlayer.name !== hostPlayer.name || nextPlayer.avatarUrl !== hostPlayer.avatarUrl;
386
+ hostPlayer = nextPlayer;
387
+ if (changed) {
388
+ const snapshot = nextPlayer ? { name: nextPlayer.name, avatarUrl: nextPlayer.avatarUrl } : null;
389
+ for (let i = 0; i < playerChangeHandlers.length; i++) {
390
+ safeCall(playerChangeHandlers[i], snapshot);
391
+ }
392
+ }
374
393
  }
375
394
  const rewardedAds = msg.rewardedAds;
376
395
  if (rewardedAds && typeof rewardedAds === "object") {
@@ -502,6 +521,7 @@
502
521
  let resolved = false;
503
522
  let status = "unavailable";
504
523
  let rewardOffered = false;
524
+ let adShown = false;
505
525
  const waitTimer = setTimeout(function() {
506
526
  if (rewardOffered || resolved) return;
507
527
  const timeout = Object.assign(
@@ -546,6 +566,7 @@
546
566
  }
547
567
  post("rewarded_ad", void 0, Object.assign({ event: "started" }, base));
548
568
  try {
569
+ adShown = true;
549
570
  showAdFn();
550
571
  } catch (err) {
551
572
  const showError = Object.assign(
@@ -595,7 +616,13 @@
595
616
  },
596
617
  adBreakDone: function(placementInfo) {
597
618
  if (resolved) return;
598
- const result = rewardOffered || status !== "unavailable" ? { status } : { status: "unavailable", error: "no_rewarded_ad" };
619
+ const result = rewardOffered || status !== "unavailable" ? {
620
+ // A reward was offered but the ad never reached the screen
621
+ // (the game retained show() and let the break lapse): the
622
+ // break is over without a view, so resolve 'dismissed',
623
+ // not the non-terminal-looking 'ready'.
624
+ status: status === "ready" && !adShown ? "dismissed" : status
625
+ } : { status: "unavailable", error: "no_rewarded_ad" };
599
626
  if (placementInfo && typeof placementInfo === "object") {
600
627
  result.breakStatus = cleanText(
601
628
  placementInfo.breakStatus
@@ -798,12 +825,25 @@
798
825
  prepareRewardedBreak: prepareRewardedAd,
799
826
  rewardedBreak,
800
827
  save: function(blob) {
801
- return request("save", String(blob));
828
+ const data = String(blob);
829
+ if (utf8ByteLength(data) > MAX_SAVE_BYTES) {
830
+ return Promise.reject(sdkError("too_large"));
831
+ }
832
+ return request("save", data);
802
833
  },
803
834
  load: function() {
804
835
  return request("load");
805
836
  },
806
837
  getPlayer,
838
+ onPlayerChange: function(handler) {
839
+ if (typeof handler !== "function") return function() {
840
+ };
841
+ playerChangeHandlers.push(handler);
842
+ return function() {
843
+ const idx = playerChangeHandlers.indexOf(handler);
844
+ if (idx !== -1) playerChangeHandlers.splice(idx, 1);
845
+ };
846
+ },
807
847
  getVariant: function(key, variants) {
808
848
  const sorted = Array.isArray(variants) ? variants.slice().sort() : [];
809
849
  const fallback = sorted.length > 0 ? sorted[0] : null;
@@ -870,6 +910,8 @@
870
910
  save: () => Promise.reject(sdkError2("unsupported")),
871
911
  load: () => Promise.reject(sdkError2("unsupported")),
872
912
  getPlayer: () => Promise.resolve(null),
913
+ onPlayerChange: () => () => {
914
+ },
873
915
  getVariant: (key, variants) => {
874
916
  const sorted = Array.isArray(variants) ? variants.slice().sort() : [];
875
917
  return Promise.resolve(sorted.length > 0 ? sorted[0] : null);
@@ -883,13 +925,17 @@
883
925
 
884
926
  // src/multiplayer.ts
885
927
  var WELCOME_TIMEOUT_MS = 1e4;
928
+ var MIN_WELCOME_TIMEOUT_MS = 1e3;
929
+ var MAX_WELCOME_TIMEOUT_MS = 6e4;
886
930
  var MAX_RECONNECT_ATTEMPTS = 3;
887
931
  var MAX_JOIN_DATA_BYTES = 1024;
932
+ var SEAT_FULL_CLOSE_CODE = 4005;
888
933
  var CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
889
934
  var CODE_LENGTH = 4;
890
- function sdkError3(code) {
935
+ function sdkError3(code, detail) {
891
936
  const err = new Error(code);
892
937
  err.code = code;
938
+ if (detail) err.detail = detail;
893
939
  return err;
894
940
  }
895
941
  function randomChars(length) {
@@ -906,23 +952,13 @@
906
952
  function generateCode() {
907
953
  return randomChars(CODE_LENGTH);
908
954
  }
909
- function utf8ByteLength(value) {
910
- let bytes = 0;
911
- for (let i = 0; i < value.length; i++) {
912
- const code = value.charCodeAt(i);
913
- if (code < 128) bytes += 1;
914
- else if (code < 2048) bytes += 2;
915
- else if (code >= 55296 && code <= 56319 && i + 1 < value.length) {
916
- const next = value.charCodeAt(i + 1);
917
- if (next >= 56320 && next <= 57343) {
918
- bytes += 4;
919
- i += 1;
920
- } else {
921
- bytes += 3;
922
- }
923
- } else bytes += 3;
955
+ var cachedTextEncoder2 = null;
956
+ function utf8ByteLength2(value) {
957
+ if (typeof TextEncoder !== "undefined") {
958
+ cachedTextEncoder2 = cachedTextEncoder2 || new TextEncoder();
959
+ return cachedTextEncoder2.encode(value).length;
924
960
  }
925
- return bytes;
961
+ return new Blob([value]).size;
926
962
  }
927
963
  function serializeJoinData(data) {
928
964
  if (data === void 0) return null;
@@ -933,28 +969,34 @@
933
969
  } catch (e) {
934
970
  throw sdkError3("rejected");
935
971
  }
936
- if (!serialized || utf8ByteLength(serialized) > MAX_JOIN_DATA_BYTES) {
972
+ if (!serialized || utf8ByteLength2(serialized) > MAX_JOIN_DATA_BYTES) {
937
973
  throw sdkError3("rejected");
938
974
  }
939
975
  return serialized;
940
976
  }
941
- function requestTicket(roomId) {
977
+ function requestTicket(payload) {
942
978
  const request = BBArcade._request;
943
979
  if (typeof request !== "function") return Promise.reject(sdkError3("unsupported"));
944
- return request("mp_ticket", { roomId }).then((grant) => {
980
+ return request("mp_ticket", payload).then((grant) => {
945
981
  const g = grant;
946
982
  if (!g || typeof g.ticket !== "string" || typeof g.roomUrl !== "string") {
947
983
  throw sdkError3("error");
948
984
  }
949
- return { ticket: g.ticket, roomUrl: g.roomUrl, expiresAt: Number(g.expiresAt) || 0 };
985
+ return {
986
+ ticket: g.ticket,
987
+ roomUrl: g.roomUrl,
988
+ expiresAt: Number(g.expiresAt) || 0,
989
+ roomId: typeof g.roomId === "string" && g.roomId ? g.roomId : null
990
+ };
950
991
  });
951
992
  }
952
993
  var RoomConnection = class {
953
- constructor(code, devGrant, joinData) {
994
+ constructor(code, devGrant, joinData, welcomeTimeoutMs = WELCOME_TIMEOUT_MS) {
954
995
  this.playerId = "";
955
996
  this.players = [];
956
997
  this.state = null;
957
998
  this.connected = false;
999
+ this.latencyMs = null;
958
1000
  this.ws = null;
959
1001
  // Keyed by event name; values are erased to (unknown) => void internally and
960
1002
  // retyped at the on()/emit() boundary, which keeps the public API strictly
@@ -967,15 +1009,20 @@
967
1009
  // or the welcome timed out — permanent outcomes that must not reconnect.
968
1010
  this.terminal = false;
969
1011
  this.reconnectAttempts = 0;
1012
+ // The in-flight connect()'s settlement, so leave() can reject a join that
1013
+ // is still waiting on its welcome instead of stranding it on the timeout.
1014
+ this.pendingConnect = null;
970
1015
  this.code = code;
971
1016
  this.devGrant = devGrant;
972
1017
  this.joinData = joinData;
1018
+ this.welcomeTimeoutMs = welcomeTimeoutMs;
973
1019
  }
974
1020
  /** Connect and resolve on the server's welcome (post-ticket-verification). */
975
1021
  connect(grant) {
976
1022
  return new Promise((resolve, reject) => {
977
1023
  let settled = false;
978
1024
  const url = grant.roomUrl + (grant.roomUrl.indexOf("?") === -1 ? "?" : "&") + "ticket=" + encodeURIComponent(grant.ticket) + (this.joinData === null ? "" : "&join=" + encodeURIComponent(this.joinData));
1025
+ const connectStartedAt = Date.now();
979
1026
  let ws;
980
1027
  try {
981
1028
  ws = new WebSocket(url);
@@ -986,13 +1033,15 @@
986
1033
  const welcomeTimer = setTimeout(() => {
987
1034
  if (settled) return;
988
1035
  settled = true;
1036
+ this.pendingConnect = null;
989
1037
  this.terminal = true;
990
1038
  try {
991
1039
  ws.close();
992
1040
  } catch (err) {
993
1041
  }
994
1042
  reject(sdkError3("error"));
995
- }, WELCOME_TIMEOUT_MS);
1043
+ }, this.welcomeTimeoutMs);
1044
+ this.pendingConnect = { reject, timer: welcomeTimer };
996
1045
  ws.onmessage = (event) => {
997
1046
  var _a, _b, _c, _d, _e;
998
1047
  let msg;
@@ -1003,14 +1052,23 @@
1003
1052
  }
1004
1053
  if (!msg || typeof msg.t !== "string") return;
1005
1054
  if (msg.t === "welcome") {
1055
+ if (this.intentionalClose) {
1056
+ try {
1057
+ ws.close();
1058
+ } catch (err) {
1059
+ }
1060
+ return;
1061
+ }
1006
1062
  const reconnected = this.reconnectAttempts > 0;
1007
1063
  this.playerId = String((_a = msg.playerId) != null ? _a : "");
1008
1064
  this.players = Array.isArray(msg.players) ? msg.players : [];
1009
1065
  this.state = (_b = msg.state) != null ? _b : null;
1010
1066
  this.connected = true;
1011
1067
  this.reconnectAttempts = 0;
1068
+ this.latencyMs = Math.max(0, Date.now() - connectStartedAt);
1012
1069
  if (!settled) {
1013
1070
  settled = true;
1071
+ this.pendingConnect = null;
1014
1072
  clearTimeout(welcomeTimer);
1015
1073
  resolve(this);
1016
1074
  }
@@ -1052,21 +1110,24 @@
1052
1110
  if (!settled) {
1053
1111
  settled = true;
1054
1112
  this.terminal = true;
1113
+ this.pendingConnect = null;
1055
1114
  clearTimeout(welcomeTimer);
1056
1115
  try {
1057
1116
  ws.close();
1058
1117
  } catch (err) {
1059
1118
  }
1060
- reject(sdkError3(code === "unauthenticated" ? "unauthenticated" : "rejected"));
1119
+ reject(sdkError3(code === "unauthenticated" ? "unauthenticated" : "rejected", code));
1061
1120
  return;
1062
1121
  }
1063
1122
  this.emit("error", { code });
1064
1123
  }
1065
1124
  };
1066
- ws.onclose = () => {
1125
+ ws.onclose = (event) => {
1067
1126
  this.connected = false;
1127
+ if (event && event.code === SEAT_FULL_CLOSE_CODE) this.terminal = true;
1068
1128
  if (!settled) {
1069
1129
  settled = true;
1130
+ this.pendingConnect = null;
1070
1131
  clearTimeout(welcomeTimer);
1071
1132
  reject(sdkError3("error"));
1072
1133
  return;
@@ -1095,7 +1156,7 @@
1095
1156
  await new Promise((r) => setTimeout(r, 400 * this.reconnectAttempts));
1096
1157
  if (this.intentionalClose || this.ended || this.terminal) return;
1097
1158
  try {
1098
- const grant = (_a = this.devGrant) != null ? _a : await requestTicket(this.code);
1159
+ const grant = (_a = this.devGrant) != null ? _a : await requestTicket({ roomId: this.code });
1099
1160
  if (this.intentionalClose || this.ended || this.terminal) return;
1100
1161
  await this.connect(grant);
1101
1162
  if (this.intentionalClose) this.leave();
@@ -1130,6 +1191,12 @@
1130
1191
  var _a;
1131
1192
  this.intentionalClose = true;
1132
1193
  this.connected = false;
1194
+ if (this.pendingConnect) {
1195
+ const pending = this.pendingConnect;
1196
+ this.pendingConnect = null;
1197
+ clearTimeout(pending.timer);
1198
+ pending.reject(sdkError3("error"));
1199
+ }
1133
1200
  try {
1134
1201
  (_a = this.ws) == null ? void 0 : _a.close(1e3);
1135
1202
  } catch (err) {
@@ -1149,23 +1216,57 @@
1149
1216
  }
1150
1217
  }
1151
1218
  };
1219
+ var MATCH_ATTEMPTS = 3;
1220
+ function normalizeWelcomeTimeoutMs(value) {
1221
+ if (typeof value !== "number" || !isFinite(value)) return WELCOME_TIMEOUT_MS;
1222
+ return Math.min(MAX_WELCOME_TIMEOUT_MS, Math.max(MIN_WELCOME_TIMEOUT_MS, Math.floor(value)));
1223
+ }
1224
+ async function matchJoin(joinData, timeoutMs) {
1225
+ let lastError = sdkError3("error");
1226
+ for (let attempt = 0; attempt < MATCH_ATTEMPTS; attempt++) {
1227
+ const grant = await requestTicket({ matchmake: true });
1228
+ if (!grant.roomId) throw sdkError3("error");
1229
+ try {
1230
+ return await new RoomConnection(grant.roomId, null, joinData, timeoutMs).connect(grant);
1231
+ } catch (error) {
1232
+ lastError = error;
1233
+ const detail = error == null ? void 0 : error.detail;
1234
+ if (detail !== "room_full" && detail !== "room_over") throw error;
1235
+ }
1236
+ }
1237
+ throw lastError;
1238
+ }
1152
1239
  function joinRoom(options) {
1153
1240
  const opts = options != null ? options : {};
1154
1241
  if (typeof window === "undefined" || typeof WebSocket === "undefined") {
1155
1242
  return Promise.reject(sdkError3("unsupported"));
1156
1243
  }
1157
- const code = opts.create || !opts.code ? generateCode() : opts.code.toUpperCase();
1158
1244
  let joinData;
1159
1245
  try {
1160
1246
  joinData = serializeJoinData(opts.joinData);
1161
1247
  } catch (err) {
1162
1248
  return Promise.reject(err);
1163
1249
  }
1250
+ const timeoutMs = normalizeWelcomeTimeoutMs(opts.timeoutMs);
1251
+ if (opts.match) {
1252
+ if (opts.code || opts.create || opts.roomUrl || opts.ticket) {
1253
+ return Promise.reject(sdkError3("rejected"));
1254
+ }
1255
+ return matchJoin(joinData, timeoutMs);
1256
+ }
1257
+ const code = opts.create || !opts.code ? generateCode() : opts.code.toUpperCase();
1164
1258
  if (opts.roomUrl && opts.ticket) {
1165
- const devGrant = { ticket: opts.ticket, roomUrl: opts.roomUrl, expiresAt: 0 };
1166
- return new RoomConnection(code, devGrant, joinData).connect(devGrant);
1259
+ const devGrant = {
1260
+ ticket: opts.ticket,
1261
+ roomUrl: opts.roomUrl,
1262
+ expiresAt: 0,
1263
+ roomId: code
1264
+ };
1265
+ return new RoomConnection(code, devGrant, joinData, timeoutMs).connect(devGrant);
1167
1266
  }
1168
- return requestTicket(code).then((grant) => new RoomConnection(code, null, joinData).connect(grant));
1267
+ return requestTicket({ roomId: code }).then(
1268
+ (grant) => new RoomConnection(code, null, joinData, timeoutMs).connect(grant)
1269
+ );
1169
1270
  }
1170
1271
  var multiplayer = { joinRoom };
1171
1272
 
package/dist/index.cjs CHANGED
@@ -38,6 +38,8 @@ var AD_PLACEMENT_SRC = "https://pagead2.googlesyndication.com/pagead/js/adsbygoo
38
38
  var AD_PLACEMENT_SCRIPT_ID = "bb-arcade-h5-ads";
39
39
  var DEFAULT_AD_LOAD_TIMEOUT_MS = 6e3;
40
40
  var SAVE_TIMEOUT_MS = 15e3;
41
+ var LOCK_TIMEOUT_MS = 5e3;
42
+ var MAX_SAVE_BYTES = 1024 * 1024;
41
43
  var INIT_CONFIG_GRACE_MS = 1500;
42
44
  var DEFAULT_ALLOW = [
43
45
  "bountyboard.gg",
@@ -62,6 +64,14 @@ function cleanText(value, fallback, max) {
62
64
  if (typeof value !== "string") return fallback || "";
63
65
  return value.replace(/[^\w:.-]+/g, "_").slice(0, max || 80) || fallback || "";
64
66
  }
67
+ var cachedTextEncoder = null;
68
+ function utf8ByteLength(value) {
69
+ if (typeof TextEncoder !== "undefined") {
70
+ cachedTextEncoder = cachedTextEncoder || new TextEncoder();
71
+ return cachedTextEncoder.encode(value).length;
72
+ }
73
+ return new Blob([value]).size;
74
+ }
65
75
  function toBoolean(value) {
66
76
  return value === true || value === "true" || value === "1";
67
77
  }
@@ -181,13 +191,14 @@ function createBBArcade() {
181
191
  };
182
192
  let hostConfigReceived = false;
183
193
  let hostPlayer = null;
194
+ const playerChangeHandlers = [];
184
195
  let blocked = false;
185
196
  const pending = {};
186
197
  let seq = 0;
187
198
  function post(type, score, extra) {
188
199
  if (blocked) return;
189
200
  const message = { source: SDK_MESSAGE_SOURCE, v: VERSION, type };
190
- if (typeof score === "number" && isFinite(score)) message.score = Math.floor(score);
201
+ if (typeof score === "number" && isFinite(score)) message.score = Math.trunc(score);
191
202
  if (extra && typeof extra === "object") {
192
203
  const fields = extra;
193
204
  for (const key in fields) {
@@ -208,7 +219,7 @@ function createBBArcade() {
208
219
  return true;
209
220
  }
210
221
  }
211
- function request(type, payload) {
222
+ function request(type, payload, timeoutMs = SAVE_TIMEOUT_MS) {
212
223
  return new Promise(function(resolve, reject) {
213
224
  if (blocked) return reject(sdkError("error"));
214
225
  if (!isEmbedded()) return reject(sdkError("unsupported"));
@@ -218,7 +229,7 @@ function createBBArcade() {
218
229
  delete pending[requestId];
219
230
  reject(sdkError("error"));
220
231
  }
221
- }, SAVE_TIMEOUT_MS);
232
+ }, timeoutMs);
222
233
  pending[requestId] = { resolve, reject, timer };
223
234
  try {
224
235
  window.parent.postMessage(
@@ -310,7 +321,7 @@ function createBBArcade() {
310
321
  if (!hostnameAllowed(origin, allow)) blockHost(options);
311
322
  return;
312
323
  }
313
- request("host").then(
324
+ request("host", void 0, LOCK_TIMEOUT_MS).then(
314
325
  function(res) {
315
326
  const r = res;
316
327
  if (!hostnameAllowed(r && r.origin, allow)) blockHost(options);
@@ -334,7 +345,7 @@ function createBBArcade() {
334
345
  lockToHostBestEffort(opts, allow);
335
346
  return;
336
347
  }
337
- request("host_attest").then(
348
+ request("host_attest", void 0, LOCK_TIMEOUT_MS).then(
338
349
  function(res) {
339
350
  const r = res;
340
351
  if (!r || !r.attestation) {
@@ -396,7 +407,15 @@ function createBBArcade() {
396
407
  if (msg.source !== HOST_MESSAGE_SOURCE || msg.type !== "config") return;
397
408
  hostConfigReceived = true;
398
409
  if (msg.player !== void 0) {
399
- hostPlayer = sanitizePlayer(msg.player);
410
+ const nextPlayer = sanitizePlayer(msg.player);
411
+ const changed = nextPlayer === null ? hostPlayer !== null : hostPlayer === null || nextPlayer.name !== hostPlayer.name || nextPlayer.avatarUrl !== hostPlayer.avatarUrl;
412
+ hostPlayer = nextPlayer;
413
+ if (changed) {
414
+ const snapshot = nextPlayer ? { name: nextPlayer.name, avatarUrl: nextPlayer.avatarUrl } : null;
415
+ for (let i = 0; i < playerChangeHandlers.length; i++) {
416
+ safeCall(playerChangeHandlers[i], snapshot);
417
+ }
418
+ }
400
419
  }
401
420
  const rewardedAds = msg.rewardedAds;
402
421
  if (rewardedAds && typeof rewardedAds === "object") {
@@ -528,6 +547,7 @@ function createBBArcade() {
528
547
  let resolved = false;
529
548
  let status = "unavailable";
530
549
  let rewardOffered = false;
550
+ let adShown = false;
531
551
  const waitTimer = setTimeout(function() {
532
552
  if (rewardOffered || resolved) return;
533
553
  const timeout = Object.assign(
@@ -572,6 +592,7 @@ function createBBArcade() {
572
592
  }
573
593
  post("rewarded_ad", void 0, Object.assign({ event: "started" }, base));
574
594
  try {
595
+ adShown = true;
575
596
  showAdFn();
576
597
  } catch (err) {
577
598
  const showError = Object.assign(
@@ -621,7 +642,13 @@ function createBBArcade() {
621
642
  },
622
643
  adBreakDone: function(placementInfo) {
623
644
  if (resolved) return;
624
- const result = rewardOffered || status !== "unavailable" ? { status } : { status: "unavailable", error: "no_rewarded_ad" };
645
+ const result = rewardOffered || status !== "unavailable" ? {
646
+ // A reward was offered but the ad never reached the screen
647
+ // (the game retained show() and let the break lapse): the
648
+ // break is over without a view, so resolve 'dismissed',
649
+ // not the non-terminal-looking 'ready'.
650
+ status: status === "ready" && !adShown ? "dismissed" : status
651
+ } : { status: "unavailable", error: "no_rewarded_ad" };
625
652
  if (placementInfo && typeof placementInfo === "object") {
626
653
  result.breakStatus = cleanText(
627
654
  placementInfo.breakStatus
@@ -824,12 +851,25 @@ function createBBArcade() {
824
851
  prepareRewardedBreak: prepareRewardedAd,
825
852
  rewardedBreak,
826
853
  save: function(blob) {
827
- return request("save", String(blob));
854
+ const data = String(blob);
855
+ if (utf8ByteLength(data) > MAX_SAVE_BYTES) {
856
+ return Promise.reject(sdkError("too_large"));
857
+ }
858
+ return request("save", data);
828
859
  },
829
860
  load: function() {
830
861
  return request("load");
831
862
  },
832
863
  getPlayer,
864
+ onPlayerChange: function(handler) {
865
+ if (typeof handler !== "function") return function() {
866
+ };
867
+ playerChangeHandlers.push(handler);
868
+ return function() {
869
+ const idx = playerChangeHandlers.indexOf(handler);
870
+ if (idx !== -1) playerChangeHandlers.splice(idx, 1);
871
+ };
872
+ },
833
873
  getVariant: function(key, variants) {
834
874
  const sorted = Array.isArray(variants) ? variants.slice().sort() : [];
835
875
  const fallback = sorted.length > 0 ? sorted[0] : null;
@@ -896,6 +936,8 @@ function createInertBBArcade() {
896
936
  save: () => Promise.reject(sdkError2("unsupported")),
897
937
  load: () => Promise.reject(sdkError2("unsupported")),
898
938
  getPlayer: () => Promise.resolve(null),
939
+ onPlayerChange: () => () => {
940
+ },
899
941
  getVariant: (key, variants) => {
900
942
  const sorted = Array.isArray(variants) ? variants.slice().sort() : [];
901
943
  return Promise.resolve(sorted.length > 0 ? sorted[0] : null);