@nanotoolkit/game-sdk 0.1.7 → 0.1.9

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.
@@ -1,6 +1,7 @@
1
1
  import { parseWireJSON, stringifyWireJSON } from './WireJSON.js';
2
+ import { GameState } from './GameState.js';
2
3
 
3
- export const SDK_VERSION = '0.1.7';
4
+ export const SDK_VERSION = '0.1.9';
4
5
 
5
6
  const REQUEST_TIMEOUT_MS = 10000;
6
7
 
@@ -34,6 +35,7 @@ export class SdkError extends Error {
34
35
  super(message);
35
36
  this.name = 'SdkError';
36
37
  this.code = code;
38
+ this.errorMessage = message;
37
39
  this.retryable = RETRYABLE.has(code);
38
40
  }
39
41
  }
@@ -231,7 +233,8 @@ export function on(event, handler) {
231
233
  }
232
234
 
233
235
  const KEEP_KEYS = 64;
234
- const issues = new Map();
236
+ let gameState = new GameState();
237
+ const cachedIssue = (id) => lastEntry && gameState.get(lastEntry.gameId, lastEntry.gameRoomId, id);
235
238
  const betKeys = new Map();
236
239
  const betStates = new Map();
237
240
  const actionCounts = new Map();
@@ -248,60 +251,31 @@ function remember(map, id, key) {
248
251
  return true;
249
252
  }
250
253
 
251
- const issueKey = (issue) => stringifyWireJSON({ ...issue, serverTime: undefined });
252
254
  const betKey = (bet) => stringifyWireJSON({ ...bet, serverTime: undefined });
253
255
 
254
- const RANK = Object.freeze({ betting: 0, running: 1, shown: 2, ended: 3 });
255
- let unknownStatusNoticed = false;
256
-
257
- function issueRank(r, prev) {
258
- switch (r.status) {
259
- case 'betting':
260
- return RANK.betting;
261
- case 'running':
262
- return r.result == null ? RANK.running : RANK.shown;
263
- case 'settled':
264
- case 'voided':
265
- return RANK.ended;
266
- }
267
-
268
- if (!unknownStatusNoticed) {
269
- unknownStatusNoticed = true;
270
- console.warn(`[game-sdk] invalid status: ${r.status}`);
271
- }
272
- return prev ? prev.rank : RANK.betting;
256
+ function noteIssue(issue, publish, recovery, live = true) {
257
+ if (!issue || typeof issue !== 'object' || !issue.id || !inRoom(issue)) return false;
258
+ const accepted = gameState.noteIssue(issue, recovery);
259
+ if (gameState.conflicts.size) recoverState();
260
+ if (!accepted.changed) return false;
261
+ issue = accepted.issue;
262
+ reconcileEntryState();
263
+ if (live) afterIssue(issue, publish);
264
+ return true;
273
265
  }
274
266
 
275
- function noteIssue(issue, publish) {
276
- if (!issue || typeof issue !== 'object' || !issue.id) return false;
277
- if (!inRoom(issue)) return false;
278
- const prev = issues.get(issue.id);
279
- const rank = issueRank(issue, prev);
280
- const key = issueKey(issue);
281
- if (
282
- prev &&
283
- (rank < prev.rank ||
284
- (prev.issue.settlementCompleted && !issue.settlementCompleted) ||
285
- key === prev.key)
286
- )
287
- return false;
288
- touch(issues, issue.id, { key, rank, issue });
289
- if (issue.result == null && issue.display?.rulesId && lastEntry?.rulesId !== issue.display.rulesId)
267
+ function afterIssue(issue, publish) {
268
+ if (publish) gameState.expectIssue(issue);
269
+ if (publish && issue.result == null &&
270
+ (lastEntry?.issue?.id !== issue.id ||
271
+ (issue.display?.rulesId && lastEntry?.rulesId !== issue.display.rulesId)))
290
272
  refreshEntry();
291
273
  if (issue.ruleType === 'crash' && issue.flowType === 'timed') {
292
- const old = [...issues.values()]
293
- .reverse()
294
- .find(
295
- (state) =>
296
- state.issue.id !== issue.id &&
297
- state.issue.gameRoomId === issue.gameRoomId &&
298
- state.issue.ruleType === 'crash',
299
- );
300
- if (old) recoverCashouts(old.issue);
274
+ const current = lastEntry?.issue;
275
+ if (publish && current?.ruleType === 'crash' && current.id !== issue.id) recoverCashouts(current);
301
276
  recoverCashouts(issue);
302
277
  }
303
278
  if (publish) emitLocal('issue', issue);
304
- return true;
305
279
  }
306
280
 
307
281
  const STALL_MS = 300;
@@ -319,10 +293,8 @@ function noteHeartbeat(beat) {
319
293
  const cashoutState = mergeCashouts(beat);
320
294
  if (cashoutState && cashoutState.cursor < (beat.cashoutCount ?? 0))
321
295
  recoverCashouts({ ...beat, id: beat.issueId });
322
- const state = issues.get(beat.issueId);
323
- if (state?.rank !== RANK.running) return;
324
-
325
- touch(issues, beat.issueId, state);
296
+ const state = cachedIssue(beat.issueId);
297
+ if (state?.status !== 'running' || state.result != null) return;
326
298
 
327
299
  const behind =
328
300
  lastHeartbeat?.issueId === beat.issueId && beat.elapsedMillis < lastHeartbeat.elapsedMillis;
@@ -339,8 +311,13 @@ function noteHeartbeat(beat) {
339
311
  });
340
312
  }
341
313
 
342
- function noteBet(bet, publish, trackActions = true) {
314
+ function noteBet(bet, publish, trackActions = true, recovery) {
343
315
  if (!bet || typeof bet !== 'object' || !bet.id || !inRoom(bet)) return;
316
+ if (bet.issue) {
317
+ bet = gameState.normalizeBet(bet, recovery);
318
+ reconcileEntryState();
319
+ if (gameState.conflicts.size) recoverState();
320
+ }
344
321
  const previous = betStates.get(bet.id);
345
322
  if (!trackActions && !previous) return;
346
323
  if (
@@ -360,11 +337,15 @@ function noteBet(bet, publish, trackActions = true) {
360
337
  if (remember(betKeys, bet.id, betKey(bet)) && publish) emitLocal('bet', bet);
361
338
  }
362
339
 
363
- function noteIssueInfo(info, publish) {
340
+ function noteIssueInfo(info, publish, recovery, trackActions = true) {
364
341
  if (!info || typeof info !== 'object') return info;
365
- noteIssue(info.issue, publish);
366
- if (Array.isArray(info.bets)) for (const bet of info.bets) noteBet(bet, publish);
367
- return info;
342
+ noteIssue(info.issue, publish, recovery, trackActions);
343
+ if (Array.isArray(info.bets)) for (const bet of info.bets) noteBet(bet, publish, trackActions, recovery);
344
+ return {
345
+ ...info,
346
+ issue: inRoom(info.issue) ? cachedIssue(info.issue.id) ?? info.issue : info.issue,
347
+ bets: Array.isArray(info.bets) ? info.bets.map((bet) => normalizeBet(bet)) : info.bets,
348
+ };
368
349
  }
369
350
 
370
351
  function ensureStateListeners() {
@@ -411,25 +392,15 @@ function ensureStateListeners() {
411
392
  });
412
393
  subscribe('game:flow', (msg) => {
413
394
  if (!roomMatches(msg.flow)) return;
414
- const previous = lastEntry?.issue;
415
- if (msg.flow.issue && previous && msg.flow.issue.id < previous.id) return;
416
- if (flowRegresses(msg.flow, lastEntry)) return;
417
- if (lastEntry)
418
- Object.assign(lastEntry, {
419
- flow: msg.flow.flow,
420
- issue: msg.flow.issue,
421
- lastOpenedIssue: msg.flow.lastOpenedIssue,
422
- serverTime: msg.flow.serverTime ?? lastEntry.serverTime,
423
- bets: (lastEntry.bets ?? []).filter((bet) => bet.issueId === msg.flow.issue?.id),
424
- });
425
- noteIssue(msg.flow.issue, true);
426
- noteIssue(msg.flow.lastOpenedIssue, true);
427
- reconcileEntryState();
395
+ const entry = acceptEntry({ gameId: lastEntry.gameId, ...msg.flow }, false, undefined, true);
396
+ if (!entry) return;
428
397
  emitLocal('flow', {
429
398
  ...msg.flow,
430
- issue: lastEntry.issue,
431
- lastOpenedIssue: lastEntry.lastOpenedIssue,
399
+ flow: publicEntry(entry).flow,
400
+ issue: entry.issue,
401
+ lastOpenedIssue: entry.lastOpenedIssue,
432
402
  });
403
+ refreshEntry();
433
404
  });
434
405
  subscribe('game:snapshot', (msg) => {
435
406
  if (inRoom(msg.snapshot)) acceptEntry(msg.snapshot, true);
@@ -472,35 +443,35 @@ function boundRoom(args = {}) {
472
443
  };
473
444
  }
474
445
 
475
- function flowRegresses(next, previous) {
476
- return (
477
- next.issue?.id === previous?.issue?.id &&
478
- previous?.flow?.phase > 0 &&
479
- next.flow?.phase > 0 &&
480
- next.flow.phase < previous.flow.phase &&
481
- next.flow.phaseEndsAt <= previous.flow.phaseEndsAt
482
- );
446
+ function publicEntry(entry) {
447
+ return gameState.conflicts.size || gameState.configurationPending
448
+ ? { ...entry, flow: { ...entry.flow, bettingOpen: false } }
449
+ : entry;
483
450
  }
484
451
 
485
- function acceptEntry(entry, publish = false) {
486
- const changed =
487
- lastEntry && (entry.gameRoomId !== lastEntry.gameRoomId || entry.gameId !== lastEntry.gameId);
452
+ function acceptEntry(entry, publish = false, recovery, publishIssues = publish) {
453
+ const changed = lastEntry && (entry.gameRoomId !== lastEntry.gameRoomId || entry.gameId !== lastEntry.gameId);
454
+ const previousCurrent = lastEntry?.issue;
488
455
  if (changed) {
489
- for (const { issue } of issues.values())
456
+ for (const issue of [lastEntry.issue, lastEntry.lastOpenedIssue].filter(Boolean))
490
457
  if (issue.ruleType === 'crash' && issue.flowType === 'timed') recoverCashouts(issue);
491
- issues.clear();
458
+ gameState = new GameState();
492
459
  betKeys.clear();
493
460
  betStates.clear();
494
461
  actionCounts.clear();
495
462
  lastHeartbeat = null;
496
463
  roomFeed = null;
497
464
  }
498
-
499
- if (!changed && entry.issue && lastEntry?.issue && entry.issue.id < lastEntry.issue.id) return lastEntry;
500
-
501
- const flow = !changed && flowRegresses(entry, lastEntry) ? lastEntry.flow : entry.flow;
502
-
503
- lastEntry = { ...(changed ? {} : lastEntry), ...entry, flow };
465
+ const previousIssues = [entry.issue, entry.lastOpenedIssue].filter(Boolean)
466
+ .map((issue) => gameState.get(issue.gameId, issue.gameRoomId, issue.id));
467
+ const accepted = gameState.merge(entry, recovery);
468
+ if (!accepted) {
469
+ if (lastEntry) recoverState();
470
+ return lastEntry;
471
+ }
472
+ lastEntry = accepted;
473
+ if (!changed && previousCurrent?.id !== accepted.issue?.id && previousCurrent?.ruleType === 'crash')
474
+ recoverCashouts(previousCurrent);
504
475
  if (entry.rooms)
505
476
  roomDirectory = {
506
477
  gameId: entry.gameId,
@@ -508,33 +479,88 @@ function acceptEntry(entry, publish = false) {
508
479
  rooms: entry.rooms,
509
480
  serverTime: entry.serverTime,
510
481
  };
511
- noteIssueInfo({ issue: entry.issue, bets: entry.bets }, publish);
512
- noteIssue(entry.lastOpenedIssue, publish);
482
+ for (const [index, issue] of [entry.issue, entry.lastOpenedIssue].filter(Boolean).entries()) {
483
+ if (!inRoom(issue)) continue;
484
+ const known = cachedIssue(issue.id);
485
+ if (known && known !== previousIssues[index]) afterIssue(known, publishIssues);
486
+ }
487
+ if (Array.isArray(accepted.bets)) for (const bet of accepted.bets) noteBet(bet, publish, true, recovery);
513
488
  reconcileEntryState();
514
- if (publish) emitLocal('snapshot', lastEntry);
515
- return lastEntry;
489
+ if (gameState.conflicts.size) recoverState();
490
+ const result = publicEntry(lastEntry);
491
+ if (publish && !gameState.configurationPending) emitLocal('snapshot', result);
492
+ return result;
516
493
  }
517
494
 
518
495
  function reconcileEntryState() {
519
- lastEntry.issue = issues.get(lastEntry.issue?.id)?.issue ?? lastEntry.issue;
520
- lastEntry.lastOpenedIssue = issues.get(lastEntry.lastOpenedIssue?.id)?.issue ?? lastEntry.lastOpenedIssue;
521
- const latestBets = new Map((lastEntry.bets ?? []).map((bet) => [bet.id, betStates.get(bet.id) ?? bet]));
496
+ if (!lastEntry) return;
497
+ lastEntry.issue = cachedIssue(lastEntry.issue?.id) ?? lastEntry.issue;
498
+ lastEntry.lastOpenedIssue = cachedIssue(lastEntry.lastOpenedIssue?.id) ?? lastEntry.lastOpenedIssue;
499
+ const latestBets = new Map((lastEntry.bets ?? [])
500
+ .filter((bet) => bet.issueId === lastEntry.issue?.id)
501
+ .map((bet) => [bet.id, betStates.get(bet.id) ?? bet]));
522
502
  for (const bet of betStates.values())
523
503
  if (bet.issueId === lastEntry.issue?.id) latestBets.set(bet.id, bet);
524
504
  lastEntry.bets = [...latestBets.values()];
525
505
  }
506
+
507
+ function normalizeBet(bet) {
508
+ if (!bet || !inRoom(bet)) return bet;
509
+ const value = betStates.get(bet.id) ?? bet;
510
+ const issue = gameState.get(value.gameId, value.gameRoomId, value.issueId);
511
+ return issue ? { ...value, issue } : value;
512
+ }
513
+
514
+ let stateRecovery = null;
515
+ function recoverState() {
516
+ if (lastEntry && !gameState.configurationPending && gameState.conflicts.size)
517
+ emitLocal('snapshot', publicEntry(lastEntry));
518
+ if (!lastEntry || !gameState.conflicts.size || stateRecovery?.state === gameState) return;
519
+ const task = { state: gameState, attempted: new Map() };
520
+ stateRecovery = task;
521
+ (async () => {
522
+ while (task.state === gameState) {
523
+ if (refreshingEntry) await refreshingEntry;
524
+ if (task.state !== gameState) break;
525
+ const recovery = gameState.recovery();
526
+ const pending = [...recovery].filter(([key, version]) => task.attempted.get(key) !== version);
527
+ if (!pending.length) break;
528
+ for (const [key, version] of pending) task.attempted.set(key, version);
529
+ const targets = pending.map(([key]) => gameState.conflictIssues.get(key)).filter(Boolean).filter(inRoom);
530
+ await Promise.all(targets.map((issue) => invoke('getGameIssue', boundRoom({ issueId: issue.id }))
531
+ .then((info) => {
532
+ if (task.state === gameState) noteIssueInfo(info, false, recovery, false);
533
+ }).catch(() => {})));
534
+ if (task.state === gameState) await refreshEntry();
535
+ }
536
+ })().finally(() => {
537
+ if (stateRecovery === task) stateRecovery = null;
538
+ });
539
+ }
540
+
526
541
  let refreshingEntry = null;
542
+ let refreshAgain = false;
527
543
  function refreshEntry() {
528
- if (!lastEntry || refreshingEntry) return refreshingEntry;
544
+ if (!lastEntry) return;
545
+ if (refreshingEntry) {
546
+ refreshAgain = true;
547
+ return refreshingEntry;
548
+ }
529
549
  const room = lastEntry.gameRoomId;
530
- refreshingEntry = enterGame({ gameRoomId: room })
550
+ const pending = enterGame({ gameRoomId: room });
551
+ const generation = entrySeq;
552
+ refreshingEntry = pending
531
553
  .then((entry) => {
532
- if (entry.gameRoomId === lastEntry?.gameRoomId) emitLocal('snapshot', entry);
554
+ if (generation !== entrySeq || entry.gameRoomId !== lastEntry?.gameRoomId) return;
555
+ if (!gameState.configurationPending) emitLocal('snapshot', publicEntry(entry));
533
556
  return Promise.all([getGameBets({ gameRoomId: entry.gameRoomId }), getGameRoomFeed()]);
534
557
  })
535
558
  .catch(() => {})
536
559
  .finally(() => {
537
560
  refreshingEntry = null;
561
+ const repeat = refreshAgain && generation === entrySeq && room === lastEntry?.gameRoomId;
562
+ refreshAgain = false;
563
+ if (repeat) refreshEntry();
538
564
  });
539
565
  return refreshingEntry;
540
566
  }
@@ -721,8 +747,10 @@ export const storage = {
721
747
 
722
748
  function toSdkError(error) {
723
749
  const code = typeof error?.code === 'string' ? error.code : ErrorCode.SERVER;
724
- const message = typeof error?.message === 'string' && error.message.length > 0 ? error.message : code;
750
+ const text = error?.errorMessage ?? error?.message;
751
+ const message = typeof text === 'string' && text.length > 0 ? text : code;
725
752
  const result = new SdkError(code, message);
753
+ if (Number.isInteger(error?.serverCode) && error.serverCode < 0) result.serverCode = error.serverCode;
726
754
  if (error?.details) result.details = error.details;
727
755
  return result;
728
756
  }
@@ -849,9 +877,11 @@ export function enterGame({ gameRoomId } = {}) {
849
877
  if (gameRoomId != null && !(Number.isSafeInteger(gameRoomId) && gameRoomId >= 0))
850
878
  return badRequest('enterGame', 'invalid gameRoomId');
851
879
  const seq = ++entrySeq;
880
+ const recovery = gameState.recovery();
852
881
  return invoke('enterGame', gameRoomId != null ? { gameRoomId } : {}).then((entry) => {
853
- if (seq !== entrySeq) return entry;
854
- const accepted = acceptEntry(entry);
882
+ if (seq !== entrySeq) throw new SdkError(ErrorCode.CONFLICT, 'context changed');
883
+ const accepted = acceptEntry(entry, false, recovery);
884
+ if (!accepted || gameState.conflicts.size || gameState.configurationPending) throw new SdkError(ErrorCode.CONFLICT, 'invalid state');
855
885
  if (watching && !watchPaused) pollWatch();
856
886
  return accepted;
857
887
  });
@@ -871,6 +901,7 @@ function placeWith(params) {
871
901
  }
872
902
 
873
903
  export function placeGameBet({ issueId, bets, clientRequestId } = {}) {
904
+ if (gameState.conflicts.size || gameState.configurationPending) return badRequest('placeGameBet', 'state unavailable');
874
905
  const picked = pickBets(bets);
875
906
  if (!picked) return badRequest('placeGameBet', `invalid bets: 1-${MAX_BETS}`);
876
907
  if (issueId != null && issueId !== 0 && !isId(issueId))
@@ -937,6 +968,7 @@ function submitAction(key, request, confirm = false) {
937
968
  }
938
969
 
939
970
  export function actGameBet({ betId, action, params } = {}) {
971
+ if (gameState.conflicts.size || gameState.configurationPending) return badRequest('actGameBet', 'state unavailable');
940
972
  if (!isId(betId)) return badRequest('actGameBet', 'invalid betId');
941
973
  if (typeof action !== 'string' || action.length === 0) return badRequest('actGameBet', 'invalid action');
942
974
  if (params != null && !isPlainObject(params)) return badRequest('actGameBet', 'invalid params');
@@ -952,6 +984,7 @@ export function actGameBet({ betId, action, params } = {}) {
952
984
  return queueAction(key, actQueues.get(key), (queued) => {
953
985
  if (gameId !== lastEntry?.gameId || room !== lastEntry?.gameRoomId)
954
986
  return badRequest('actGameBet', 'context changed');
987
+ if (gameState.conflicts.size || gameState.configurationPending) return badRequest('actGameBet', 'state unavailable');
955
988
  const request = {
956
989
  gameRoomId: room,
957
990
  betId,
@@ -967,8 +1000,12 @@ export function actGameBet({ betId, action, params } = {}) {
967
1000
  export function getGameIssue({ issueId } = {}) {
968
1001
  if (issueId != null && issueId !== 0 && !isId(issueId))
969
1002
  return badRequest('getGameIssue', 'invalid issueId');
1003
+ const state = gameState;
1004
+ const generation = entrySeq;
1005
+ const recovery = state.recovery();
970
1006
  return invoke('getGameIssue', boundRoom(issueId != null ? { issueId } : {})).then((info) =>
971
- noteIssueInfo(info, false),
1007
+ state === gameState && generation === entrySeq ? noteIssueInfo(info, false, recovery)
1008
+ : Promise.reject(new SdkError(ErrorCode.CONFLICT, 'context changed')),
972
1009
  );
973
1010
  }
974
1011
 
@@ -977,10 +1014,16 @@ export function getGameBets({ earliestId, pageSize, gameRoomId } = {}) {
977
1014
  if (earliestId != null) params.earliestId = earliestId;
978
1015
  if (pageSize != null) params.pageSize = pageSize;
979
1016
  if (gameRoomId != null) params.gameRoomId = gameRoomId;
1017
+ const state = gameState;
1018
+ const generation = entrySeq;
1019
+ const recovery = state.recovery();
980
1020
  return invoke('getGameBets', params).then((bets) => {
981
1021
  const list = Array.isArray(bets) ? bets : [];
982
- for (const bet of list) noteBet(bet, false, false);
983
- return list;
1022
+ if (state !== gameState || generation !== entrySeq) return list;
1023
+ for (const bet of list) noteBet(bet, false, false, recovery);
1024
+ const normalized = list.map((bet) => normalizeBet(bet));
1025
+ if (gameState.conflicts.size) recoverState();
1026
+ return normalized;
984
1027
  });
985
1028
  }
986
1029
 
@@ -995,7 +1038,14 @@ export function getGameRoomFeed() {
995
1038
  }
996
1039
 
997
1040
  export function getGameIssues(args = {}) {
998
- return invoke('getGameIssues', boundRoom(args)).then((list) => (Array.isArray(list) ? list : []));
1041
+ const state = gameState;
1042
+ const generation = entrySeq;
1043
+ const recovery = state.recovery();
1044
+ return invoke('getGameIssues', boundRoom(args)).then((list) => {
1045
+ if (!Array.isArray(list)) return [];
1046
+ if (state !== gameState || generation !== entrySeq) return list;
1047
+ return list.map((info) => inRoom(info.issue) ? noteIssueInfo(info, false, recovery, false) : info);
1048
+ });
999
1049
  }
1000
1050
 
1001
1051
  export function getGameRooms() {
@@ -1010,8 +1060,12 @@ export function getGameRooms() {
1010
1060
  }
1011
1061
  export function getGameBet({ betId, gameRoomId } = {}) {
1012
1062
  if (!isId(betId)) return badRequest('getGameBet', 'invalid betId');
1063
+ const state = gameState;
1064
+ const generation = entrySeq;
1065
+ const recovery = state.recovery();
1013
1066
  return invoke('getGameBet', boundRoom({ betId, gameRoomId })).then((bet) => {
1014
- noteBet(bet, false);
1067
+ if (state !== gameState || generation !== entrySeq) throw new SdkError(ErrorCode.CONFLICT, 'context changed');
1068
+ noteBet(bet, false, true, recovery);
1015
1069
  const key = actionKey(bet?.gameId, bet?.gameRoomId, bet?.id);
1016
1070
  const queued = actQueues.get(key);
1017
1071
  const request = queued?.request;
@@ -1025,7 +1079,7 @@ export function getGameBet({ betId, gameRoomId } = {}) {
1025
1079
  sameIntent(confirmed.params, request.params)
1026
1080
  )
1027
1081
  actQueues.delete(key);
1028
- return bet;
1082
+ return normalizeBet(bet);
1029
1083
  });
1030
1084
  }
1031
1085
  export function getGameCashouts(args = {}) {
@@ -1100,7 +1154,7 @@ async function pollWatch() {
1100
1154
  noteIssueInfo(info, true);
1101
1155
 
1102
1156
  const id = info?.issue?.id;
1103
- scheduleWatch(id ? issues.get(id)?.issue ?? info.issue : null);
1157
+ scheduleWatch(id ? cachedIssue(id) ?? info.issue : null);
1104
1158
  }
1105
1159
 
1106
1160
  function pauseWatch() {
@@ -0,0 +1,277 @@
1
+ export const validRevision = (value) =>
2
+ (typeof value === 'bigint' && value >= 0n && value <= 9223372036854775807n) ||
3
+ (Number.isSafeInteger(value) && value >= 0);
4
+
5
+ const sameId = (a, b) => a != null && b != null && String(a) === String(b);
6
+ const issueKey = (value) => `${value.gameId}:${value.gameRoomId}:${value.id}`;
7
+ const flowFields = ['phase', 'phaseEndsAt', 'currentIssueId', 'lastOpenedIssueId'];
8
+
9
+ function canonical(value) {
10
+ if (value === null || typeof value !== 'object')
11
+ return typeof value === 'bigint' ? value.toString() : JSON.stringify(value);
12
+ if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']';
13
+ return (
14
+ '{' +
15
+ Object.keys(value)
16
+ .sort()
17
+ .filter((key) => value[key] !== undefined)
18
+ .map((key) => JSON.stringify(key) + ':' + canonical(value[key]))
19
+ .join(',') +
20
+ '}'
21
+ );
22
+ }
23
+
24
+ const issueContent = (value) => canonical({ ...value, serverTime: undefined });
25
+ const flowContent = (value) =>
26
+ canonical(Object.fromEntries(flowFields.map((key) => [key, value[key]])));
27
+
28
+ export class GameState {
29
+ constructor() {
30
+ this.entry = null;
31
+ this.issues = new Map();
32
+ this.conflicts = new Map();
33
+ this.conflictIssues = new Map();
34
+ this.sequence = 0;
35
+ this.configIssueId = undefined;
36
+ this.expectedIssueId = null;
37
+ }
38
+
39
+ recovery() {
40
+ return new Map(this.conflicts);
41
+ }
42
+
43
+ conflict(key, issue) {
44
+ if (!this.conflicts.has(key)) this.conflicts.set(key, ++this.sequence);
45
+ if (issue && key !== 'flow') this.conflictIssues.set(key, issue);
46
+ }
47
+
48
+ recovered(key, recovery) {
49
+ return recovery?.has(key) && recovery.get(key) === this.conflicts.get(key);
50
+ }
51
+
52
+ clearConflict(key) {
53
+ this.conflicts.delete(key);
54
+ this.conflictIssues.delete(key);
55
+ }
56
+
57
+ get(gameId, gameRoomId, id) {
58
+ return this.issues.get(issueKey({ gameId, gameRoomId, id }));
59
+ }
60
+
61
+ noteIssue(incoming, recovery) {
62
+ if (!incoming) return { issue: null, changed: false };
63
+ const key = issueKey(incoming);
64
+ const previous = this.issues.get(key);
65
+ if (
66
+ !validRevision(incoming.revision) ||
67
+ !validRevision(incoming.targetTime) ||
68
+ !validRevision(incoming.id) ||
69
+ incoming.id <= 0 ||
70
+ !Number.isInteger(incoming.gameId) ||
71
+ !Number.isInteger(incoming.gameRoomId) ||
72
+ !['betting', 'running', 'settled', 'voided'].includes(incoming.status)
73
+ ) {
74
+ this.conflict(key, incoming);
75
+ return { issue: previous ?? null, changed: false };
76
+ }
77
+ if (previous) {
78
+ if (incoming.revision < previous.revision) return { issue: previous, changed: false };
79
+ if (
80
+ incoming.revision == previous.revision &&
81
+ issueContent(incoming) === issueContent(previous)
82
+ ) {
83
+ if (this.recovered(key, recovery)) this.clearConflict(key);
84
+ return { issue: previous, changed: false };
85
+ }
86
+ if (
87
+ (incoming.revision == previous.revision && !this.recovered(key, recovery)) ||
88
+ (previous.settlementCompleted &&
89
+ (!incoming.settlementCompleted ||
90
+ incoming.status !== previous.status ||
91
+ canonical(incoming.result) !== canonical(previous.result))) ||
92
+ (previous.status === 'voided' && incoming.status !== 'voided') ||
93
+ (previous.result != null && incoming.result == null && incoming.status !== 'voided')
94
+ ) {
95
+ this.conflict(key, incoming);
96
+ return { issue: previous, changed: false };
97
+ }
98
+ }
99
+ this.issues.delete(key);
100
+ this.issues.set(key, incoming);
101
+ if (this.recovered(key, recovery) || (previous && incoming.revision > previous.revision))
102
+ this.clearConflict(key);
103
+ if (this.issues.size > 256) {
104
+ const pinned = [this.entry?.issue, this.entry?.lastOpenedIssue]
105
+ .filter(Boolean)
106
+ .map(issueKey);
107
+ const oldest = [...this.issues.keys()].find(
108
+ (item) => !pinned.includes(item) && !this.conflicts.has(item),
109
+ );
110
+ if (oldest) this.issues.delete(oldest);
111
+ }
112
+ return { issue: incoming, changed: true };
113
+ }
114
+
115
+ normalizeBet(bet, recovery) {
116
+ if (!bet?.issue || typeof bet.issue !== 'object') return bet;
117
+ const issue = bet.issue;
118
+ if (
119
+ !sameId(issue.id, bet.issueId) ||
120
+ issue.gameId !== bet.gameId ||
121
+ issue.gameRoomId !== bet.gameRoomId
122
+ ) {
123
+ const expected = { gameId: bet.gameId, gameRoomId: bet.gameRoomId, id: bet.issueId };
124
+ this.conflict(issueKey(expected), expected);
125
+ return { ...bet, issue: null };
126
+ }
127
+ return { ...bet, issue: this.noteIssue(issue, recovery).issue };
128
+ }
129
+
130
+ expectIssue(issue) {
131
+ if (
132
+ this.entry &&
133
+ issue.result == null &&
134
+ ['betting', 'running'].includes(issue.status) &&
135
+ (this.entry.issue == null || issue.id > this.entry.issue.id)
136
+ )
137
+ this.expectedIssueId =
138
+ this.expectedIssueId == null || issue.id > this.expectedIssueId
139
+ ? issue.id
140
+ : this.expectedIssueId;
141
+ }
142
+
143
+ get configurationPending() {
144
+ return (
145
+ this.expectedIssueId != null ||
146
+ (this.entry != null && !sameId(this.configIssueId, this.entry.issue?.id ?? 0))
147
+ );
148
+ }
149
+
150
+ merge(incoming, recovery) {
151
+ const previous = this.entry;
152
+ if (
153
+ previous &&
154
+ (incoming.gameId !== previous.gameId || incoming.gameRoomId !== previous.gameRoomId)
155
+ )
156
+ throw new TypeError('context changed');
157
+ const nested = [incoming.issue, incoming.lastOpenedIssue].filter(Boolean);
158
+ const scoped = nested.every(
159
+ (issue) => issue.gameId === incoming.gameId && issue.gameRoomId === incoming.gameRoomId,
160
+ );
161
+ if (scoped) for (const issue of nested) this.noteIssue(issue, recovery);
162
+ const bets = Array.isArray(incoming.bets)
163
+ ? incoming.bets.map((bet) => this.normalizeBet(bet, recovery))
164
+ : incoming.bets;
165
+ incoming = { ...incoming, ...(bets !== undefined ? { bets } : {}) };
166
+ const flow = incoming.flow;
167
+ const aligned =
168
+ incoming.param?.alignedCycle ??
169
+ previous?.param?.alignedCycle ??
170
+ (previous?.flow && 'revision' in previous.flow);
171
+ let accept = scoped;
172
+ if (!scoped) this.conflict('flow');
173
+ const changedMode =
174
+ previous?.param?.alignedCycle != null &&
175
+ incoming.param?.alignedCycle != null &&
176
+ incoming.param.alignedCycle !== previous.param.alignedCycle;
177
+ const unknownMode =
178
+ previous?.param?.alignedCycle === false &&
179
+ incoming.param == null &&
180
+ flow &&
181
+ 'revision' in flow;
182
+ if ((changedMode && !recovery) || unknownMode) {
183
+ accept = false;
184
+ this.conflict('flow');
185
+ }
186
+ if (aligned || (flow && 'revision' in flow)) {
187
+ const valid =
188
+ flow &&
189
+ [-2, -1, 1, 2, 3].includes(flow.phase) &&
190
+ Number.isSafeInteger(flow.phaseEndsAt) &&
191
+ flow.phaseEndsAt >= 0 &&
192
+ typeof flow.bettingOpen === 'boolean' &&
193
+ (validRevision(flow.revision) || flow.revision === null) &&
194
+ validRevision(flow.currentIssueId) &&
195
+ validRevision(flow.lastOpenedIssueId) &&
196
+ sameId(flow.currentIssueId, incoming.issue?.id ?? 0) &&
197
+ sameId(flow.lastOpenedIssueId, incoming.lastOpenedIssue?.id ?? 0) &&
198
+ (flow.revision !== null ||
199
+ (flow.currentIssueId == 0 &&
200
+ flow.lastOpenedIssueId == 0 &&
201
+ flow.phase === -2 &&
202
+ flow.phaseEndsAt === 0 &&
203
+ flow.bettingOpen === false));
204
+ if (!valid) {
205
+ accept = false;
206
+ this.conflict('flow');
207
+ } else if (
208
+ previous?.flow?.revision != null &&
209
+ (flow.revision === null || flow.revision < previous.flow.revision)
210
+ ) {
211
+ accept = false;
212
+ } else if (
213
+ previous?.flow &&
214
+ flow.revision === previous.flow.revision &&
215
+ flowContent(flow) !== flowContent(previous.flow) &&
216
+ !this.recovered('flow', recovery)
217
+ ) {
218
+ accept = false;
219
+ this.conflict('flow');
220
+ } else if (
221
+ accept &&
222
+ (this.recovered('flow', recovery) ||
223
+ (flow.revision != null &&
224
+ previous?.flow?.revision != null &&
225
+ flow.revision > previous.flow.revision))
226
+ )
227
+ this.clearConflict('flow');
228
+ } else if (previous?.issue && incoming.issue && incoming.issue.id < previous.issue.id) {
229
+ accept = false;
230
+ } else if (accept && this.recovered('flow', recovery)) {
231
+ this.clearConflict('flow');
232
+ }
233
+ const independent = Object.fromEntries(
234
+ [
235
+ 'bets',
236
+ 'admission',
237
+ 'rooms',
238
+ 'roomsTopic',
239
+ 'bundleId',
240
+ 'serverTime',
241
+ 'currency',
242
+ 'usdRate',
243
+ ]
244
+ .filter((key) => incoming[key] !== undefined)
245
+ .map((key) => [key, incoming[key]]),
246
+ );
247
+ const entry = accept
248
+ ? { ...previous, ...incoming }
249
+ : previous
250
+ ? { ...previous, ...independent }
251
+ : null;
252
+ if (entry) {
253
+ for (const field of ['issue', 'lastOpenedIssue']) {
254
+ const id =
255
+ entry.flow && 'revision' in entry.flow
256
+ ? entry.flow[field === 'issue' ? 'currentIssueId' : 'lastOpenedIssueId']
257
+ : entry[field]?.id;
258
+ entry[field] =
259
+ id == null || id == 0
260
+ ? null
261
+ : this.get(entry.gameId, entry.gameRoomId, id) ?? null;
262
+ if (id != null && id != 0 && !entry[field]) this.conflict('flow');
263
+ }
264
+ this.entry = entry;
265
+ if (accept && (incoming.param != null || incoming.rulesId != null)) {
266
+ this.configIssueId = entry.issue?.id ?? 0;
267
+ if (
268
+ this.expectedIssueId == null ||
269
+ entry.issue?.id >= this.expectedIssueId ||
270
+ (entry.issue == null && entry.flow?.phase <= 0)
271
+ )
272
+ this.expectedIssueId = null;
273
+ }
274
+ }
275
+ return entry;
276
+ }
277
+ }
package/index.d.ts CHANGED
@@ -28,6 +28,8 @@ export type SdkErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode] | string;
28
28
  export class SdkError extends Error {
29
29
  readonly name: 'SdkError';
30
30
  readonly code: SdkErrorCode;
31
+ readonly serverCode?: number;
32
+ readonly errorMessage: string;
31
33
 
32
34
  readonly retryable: boolean;
33
35
 
@@ -92,6 +94,9 @@ export interface GameRooms {
92
94
  serverTime: number;
93
95
  }
94
96
  export interface GameFlow {
97
+ revision: WireId | null;
98
+ currentIssueId: WireId;
99
+ lastOpenedIssueId: WireId;
95
100
  phase: -2 | -1 | 1 | 2 | 3;
96
101
  phaseEndsAt: number;
97
102
  bettingOpen: boolean;
@@ -116,6 +121,8 @@ export interface GameEntry {
116
121
  showSeconds?: number;
117
122
  intervalSeconds?: number;
118
123
  alignedCycle?: boolean;
124
+ cycleSeconds?: number;
125
+ readySeconds?: number;
119
126
  allowAdditionalBatches?: boolean;
120
127
  limitsZeroMeansUnlimited?: boolean;
121
128
  [key: string]: unknown;
@@ -132,6 +139,8 @@ export interface GameEntry {
132
139
  serverTime: number;
133
140
  }
134
141
  export interface GameIssue {
142
+ revision: WireId;
143
+ targetTime: number;
135
144
  id: WireId;
136
145
  issue: WireId;
137
146
  gameId: number;
@@ -147,7 +156,6 @@ export interface GameIssue {
147
156
  cashoutCount: number;
148
157
  showMillis: number;
149
158
  createAt: number | null;
150
- openAt?: number | null;
151
159
  closeAt: number | null;
152
160
  startAt: number | null;
153
161
  resultPublishedAt: number | null;
@@ -263,7 +271,7 @@ export function actGameBet(args: {
263
271
  params?: Record<string, any>;
264
272
  }): Promise<IssueInfo>;
265
273
  export function getGameIssue(args?: { issueId?: WireId }): Promise<IssueInfo | null>;
266
- export function getGameIssues(args?: { earliestId?: WireId; pageSize?: number }): Promise<IssueInfo[]>;
274
+ export function getGameIssues(args?: { gameRoomId?: number; earliestId?: WireId; pageSize?: number }): Promise<IssueInfo[]>;
267
275
  export function getGameBet(args: { betId: WireId; gameRoomId?: number }): Promise<GameBet>;
268
276
 
269
277
  export function getGameBets(args?: {
@@ -313,7 +321,7 @@ export interface EventPayloads {
313
321
  };
314
322
  flow: {
315
323
  gameRoomId: number;
316
- flow: GameFlow;
324
+ flow: Partial<GameFlow>;
317
325
  issue: GameIssue | null;
318
326
  lastOpenedIssue: GameIssue | null;
319
327
  serverTime: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanotoolkit/game-sdk",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",