@nanotoolkit/game-sdk 0.1.8 → 0.1.10

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.8';
4
+ export const SDK_VERSION = '0.1.10';
4
5
 
5
6
  const REQUEST_TIMEOUT_MS = 10000;
6
7
 
@@ -232,7 +233,8 @@ export function on(event, handler) {
232
233
  }
233
234
 
234
235
  const KEEP_KEYS = 64;
235
- const issues = new Map();
236
+ let gameState = new GameState();
237
+ const cachedIssue = (id) => lastEntry && gameState.get(lastEntry.gameId, lastEntry.gameRoomId, id);
236
238
  const betKeys = new Map();
237
239
  const betStates = new Map();
238
240
  const actionCounts = new Map();
@@ -249,62 +251,31 @@ function remember(map, id, key) {
249
251
  return true;
250
252
  }
251
253
 
252
- const issueKey = (issue) => stringifyWireJSON({ ...issue, serverTime: undefined });
253
254
  const betKey = (bet) => stringifyWireJSON({ ...bet, serverTime: undefined });
254
255
 
255
- const RANK = Object.freeze({ betting: 0, running: 1, shown: 2, ended: 3 });
256
- let unknownStatusNoticed = false;
257
-
258
- function issueRank(r, prev) {
259
- switch (r.status) {
260
- case 'betting':
261
- return RANK.betting;
262
- case 'running':
263
- return r.result == null ? RANK.running : RANK.shown;
264
- case 'settled':
265
- case 'voided':
266
- return RANK.ended;
267
- }
268
-
269
- if (!unknownStatusNoticed) {
270
- unknownStatusNoticed = true;
271
- console.warn(`[game-sdk] invalid status: ${r.status}`);
272
- }
273
- 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;
274
265
  }
275
266
 
276
- function noteIssue(issue, publish) {
277
- if (!issue || typeof issue !== 'object' || !issue.id) return false;
278
- if (!inRoom(issue)) return false;
279
- const prev = issues.get(issue.id);
280
- const rank = issueRank(issue, prev);
281
- const key = issueKey(issue);
282
- if (
283
- prev &&
284
- (rank < prev.rank ||
285
- (prev.issue.settlementCompleted && !issue.settlementCompleted) ||
286
- key === prev.key)
287
- )
288
- return false;
289
- touch(issues, issue.id, { key, rank, issue });
267
+ function afterIssue(issue, publish) {
268
+ if (publish) gameState.expectIssue(issue);
290
269
  if (publish && issue.result == null &&
291
270
  (lastEntry?.issue?.id !== issue.id ||
292
271
  (issue.display?.rulesId && lastEntry?.rulesId !== issue.display.rulesId)))
293
272
  refreshEntry();
294
273
  if (issue.ruleType === 'crash' && issue.flowType === 'timed') {
295
- const old = [...issues.values()]
296
- .reverse()
297
- .find(
298
- (state) =>
299
- state.issue.id !== issue.id &&
300
- state.issue.gameRoomId === issue.gameRoomId &&
301
- state.issue.ruleType === 'crash',
302
- );
303
- if (old) recoverCashouts(old.issue);
274
+ const current = lastEntry?.issue;
275
+ if (publish && current?.ruleType === 'crash' && current.id !== issue.id) recoverCashouts(current);
304
276
  recoverCashouts(issue);
305
277
  }
306
278
  if (publish) emitLocal('issue', issue);
307
- return true;
308
279
  }
309
280
 
310
281
  const STALL_MS = 300;
@@ -322,10 +293,8 @@ function noteHeartbeat(beat) {
322
293
  const cashoutState = mergeCashouts(beat);
323
294
  if (cashoutState && cashoutState.cursor < (beat.cashoutCount ?? 0))
324
295
  recoverCashouts({ ...beat, id: beat.issueId });
325
- const state = issues.get(beat.issueId);
326
- if (state?.rank !== RANK.running) return;
327
-
328
- touch(issues, beat.issueId, state);
296
+ const state = cachedIssue(beat.issueId);
297
+ if (state?.status !== 'running' || state.result != null) return;
329
298
 
330
299
  const behind =
331
300
  lastHeartbeat?.issueId === beat.issueId && beat.elapsedMillis < lastHeartbeat.elapsedMillis;
@@ -342,8 +311,13 @@ function noteHeartbeat(beat) {
342
311
  });
343
312
  }
344
313
 
345
- function noteBet(bet, publish, trackActions = true) {
314
+ function noteBet(bet, publish, trackActions = true, recovery) {
346
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
+ }
347
321
  const previous = betStates.get(bet.id);
348
322
  if (!trackActions && !previous) return;
349
323
  if (
@@ -363,11 +337,15 @@ function noteBet(bet, publish, trackActions = true) {
363
337
  if (remember(betKeys, bet.id, betKey(bet)) && publish) emitLocal('bet', bet);
364
338
  }
365
339
 
366
- function noteIssueInfo(info, publish) {
340
+ function noteIssueInfo(info, publish, recovery, trackActions = true) {
367
341
  if (!info || typeof info !== 'object') return info;
368
- noteIssue(info.issue, publish);
369
- if (Array.isArray(info.bets)) for (const bet of info.bets) noteBet(bet, publish);
370
- 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
+ };
371
349
  }
372
350
 
373
351
  function ensureStateListeners() {
@@ -414,24 +392,13 @@ function ensureStateListeners() {
414
392
  });
415
393
  subscribe('game:flow', (msg) => {
416
394
  if (!roomMatches(msg.flow)) return;
417
- const previous = lastEntry?.issue;
418
- if (msg.flow.issue && previous && msg.flow.issue.id < previous.id) return;
419
- if (flowRegresses(msg.flow, lastEntry)) return;
420
- if (lastEntry)
421
- Object.assign(lastEntry, {
422
- flow: msg.flow.flow,
423
- issue: msg.flow.issue,
424
- lastOpenedIssue: msg.flow.lastOpenedIssue,
425
- serverTime: msg.flow.serverTime ?? lastEntry.serverTime,
426
- bets: (lastEntry.bets ?? []).filter((bet) => bet.issueId === msg.flow.issue?.id),
427
- });
428
- noteIssue(msg.flow.issue, true);
429
- noteIssue(msg.flow.lastOpenedIssue, true);
430
- reconcileEntryState();
395
+ const entry = acceptEntry({ gameId: lastEntry.gameId, ...msg.flow }, false, undefined, true);
396
+ if (!entry) return;
431
397
  emitLocal('flow', {
432
398
  ...msg.flow,
433
- issue: lastEntry.issue,
434
- lastOpenedIssue: lastEntry.lastOpenedIssue,
399
+ flow: publicEntry(entry).flow,
400
+ issue: entry.issue,
401
+ lastOpenedIssue: entry.lastOpenedIssue,
435
402
  });
436
403
  refreshEntry();
437
404
  });
@@ -442,6 +409,7 @@ function ensureStateListeners() {
442
409
  const directory = roomDirectory ?? lastEntry;
443
410
  if (!directory || msg.rooms?.gameId !== directory.gameId || !Array.isArray(msg.rooms.rooms)) return;
444
411
  const known = new Map((directory.rooms ?? []).map((room) => [room.id, room]));
412
+ const updates = new Map(msg.rooms.rooms.map((room) => [room.id, room]));
445
413
  if (msg.rooms.rooms.some((room) => !known.has(room.id))) {
446
414
  getGameRooms().catch(() => {});
447
415
  return;
@@ -450,9 +418,10 @@ function ensureStateListeners() {
450
418
  ...directory,
451
419
  ...msg.rooms,
452
420
  topic: roomDirectory?.topic ?? lastEntry?.roomsTopic ?? 'game:' + directory.gameId,
453
- rooms: msg.rooms.rooms.map((room) => ({
454
- ...known.get(room.id),
421
+ rooms: (directory.rooms ?? []).filter((room) => updates.has(room.id)).map((room) => ({
455
422
  ...room,
423
+ onlineCount: updates.get(room.id).onlineCount,
424
+ targetTime: updates.get(room.id).targetTime,
456
425
  })),
457
426
  };
458
427
  if (lastEntry?.gameId === roomDirectory.gameId) lastEntry.rooms = roomDirectory.rooms;
@@ -476,35 +445,35 @@ function boundRoom(args = {}) {
476
445
  };
477
446
  }
478
447
 
479
- function flowRegresses(next, previous) {
480
- return (
481
- next.issue?.id === previous?.issue?.id &&
482
- previous?.flow?.phase > 0 &&
483
- next.flow?.phase > 0 &&
484
- next.flow.phase < previous.flow.phase &&
485
- next.flow.phaseEndsAt <= previous.flow.phaseEndsAt
486
- );
448
+ function publicEntry(entry) {
449
+ return gameState.conflicts.size || gameState.configurationPending
450
+ ? { ...entry, flow: { ...entry.flow, bettingOpen: false } }
451
+ : entry;
487
452
  }
488
453
 
489
- function acceptEntry(entry, publish = false) {
490
- const changed =
491
- lastEntry && (entry.gameRoomId !== lastEntry.gameRoomId || entry.gameId !== lastEntry.gameId);
454
+ function acceptEntry(entry, publish = false, recovery, publishIssues = publish) {
455
+ const changed = lastEntry && (entry.gameRoomId !== lastEntry.gameRoomId || entry.gameId !== lastEntry.gameId);
456
+ const previousCurrent = lastEntry?.issue;
492
457
  if (changed) {
493
- for (const { issue } of issues.values())
458
+ for (const issue of [lastEntry.issue, lastEntry.lastOpenedIssue].filter(Boolean))
494
459
  if (issue.ruleType === 'crash' && issue.flowType === 'timed') recoverCashouts(issue);
495
- issues.clear();
460
+ gameState = new GameState();
496
461
  betKeys.clear();
497
462
  betStates.clear();
498
463
  actionCounts.clear();
499
464
  lastHeartbeat = null;
500
465
  roomFeed = null;
501
466
  }
502
-
503
- if (!changed && entry.issue && lastEntry?.issue && entry.issue.id < lastEntry.issue.id) return lastEntry;
504
-
505
- const flow = !changed && flowRegresses(entry, lastEntry) ? lastEntry.flow : entry.flow;
506
-
507
- lastEntry = { ...(changed ? {} : lastEntry), ...entry, flow };
467
+ const previousIssues = [entry.issue, entry.lastOpenedIssue].filter(Boolean)
468
+ .map((issue) => gameState.get(issue.gameId, issue.gameRoomId, issue.id));
469
+ const accepted = gameState.merge(entry, recovery);
470
+ if (!accepted) {
471
+ if (lastEntry) recoverState();
472
+ return lastEntry;
473
+ }
474
+ lastEntry = accepted;
475
+ if (!changed && previousCurrent?.id !== accepted.issue?.id && previousCurrent?.ruleType === 'crash')
476
+ recoverCashouts(previousCurrent);
508
477
  if (entry.rooms)
509
478
  roomDirectory = {
510
479
  gameId: entry.gameId,
@@ -512,21 +481,65 @@ function acceptEntry(entry, publish = false) {
512
481
  rooms: entry.rooms,
513
482
  serverTime: entry.serverTime,
514
483
  };
515
- noteIssueInfo({ issue: entry.issue, bets: entry.bets }, publish);
516
- noteIssue(entry.lastOpenedIssue, publish);
484
+ for (const [index, issue] of [entry.issue, entry.lastOpenedIssue].filter(Boolean).entries()) {
485
+ if (!inRoom(issue)) continue;
486
+ const known = cachedIssue(issue.id);
487
+ if (known && known !== previousIssues[index]) afterIssue(known, publishIssues);
488
+ }
489
+ if (Array.isArray(accepted.bets)) for (const bet of accepted.bets) noteBet(bet, publish, true, recovery);
517
490
  reconcileEntryState();
518
- if (publish) emitLocal('snapshot', lastEntry);
519
- return lastEntry;
491
+ if (gameState.conflicts.size) recoverState();
492
+ const result = publicEntry(lastEntry);
493
+ if (publish && !gameState.configurationPending) emitLocal('snapshot', result);
494
+ return result;
520
495
  }
521
496
 
522
497
  function reconcileEntryState() {
523
- lastEntry.issue = issues.get(lastEntry.issue?.id)?.issue ?? lastEntry.issue;
524
- lastEntry.lastOpenedIssue = issues.get(lastEntry.lastOpenedIssue?.id)?.issue ?? lastEntry.lastOpenedIssue;
525
- const latestBets = new Map((lastEntry.bets ?? []).map((bet) => [bet.id, betStates.get(bet.id) ?? bet]));
498
+ if (!lastEntry) return;
499
+ lastEntry.issue = cachedIssue(lastEntry.issue?.id) ?? lastEntry.issue;
500
+ lastEntry.lastOpenedIssue = cachedIssue(lastEntry.lastOpenedIssue?.id) ?? lastEntry.lastOpenedIssue;
501
+ const latestBets = new Map((lastEntry.bets ?? [])
502
+ .filter((bet) => bet.issueId === lastEntry.issue?.id)
503
+ .map((bet) => [bet.id, betStates.get(bet.id) ?? bet]));
526
504
  for (const bet of betStates.values())
527
505
  if (bet.issueId === lastEntry.issue?.id) latestBets.set(bet.id, bet);
528
506
  lastEntry.bets = [...latestBets.values()];
529
507
  }
508
+
509
+ function normalizeBet(bet) {
510
+ if (!bet || !inRoom(bet)) return bet;
511
+ const value = betStates.get(bet.id) ?? bet;
512
+ const issue = gameState.get(value.gameId, value.gameRoomId, value.issueId);
513
+ return issue ? { ...value, issue } : value;
514
+ }
515
+
516
+ let stateRecovery = null;
517
+ function recoverState() {
518
+ if (lastEntry && !gameState.configurationPending && gameState.conflicts.size)
519
+ emitLocal('snapshot', publicEntry(lastEntry));
520
+ if (!lastEntry || !gameState.conflicts.size || stateRecovery?.state === gameState) return;
521
+ const task = { state: gameState, attempted: new Map() };
522
+ stateRecovery = task;
523
+ (async () => {
524
+ while (task.state === gameState) {
525
+ if (refreshingEntry) await refreshingEntry;
526
+ if (task.state !== gameState) break;
527
+ const recovery = gameState.recovery();
528
+ const pending = [...recovery].filter(([key, version]) => task.attempted.get(key) !== version);
529
+ if (!pending.length) break;
530
+ for (const [key, version] of pending) task.attempted.set(key, version);
531
+ const targets = pending.map(([key]) => gameState.conflictIssues.get(key)).filter(Boolean).filter(inRoom);
532
+ await Promise.all(targets.map((issue) => invoke('getGameIssue', boundRoom({ issueId: issue.id }))
533
+ .then((info) => {
534
+ if (task.state === gameState) noteIssueInfo(info, false, recovery, false);
535
+ }).catch(() => {})));
536
+ if (task.state === gameState) await refreshEntry();
537
+ }
538
+ })().finally(() => {
539
+ if (stateRecovery === task) stateRecovery = null;
540
+ });
541
+ }
542
+
530
543
  let refreshingEntry = null;
531
544
  let refreshAgain = false;
532
545
  function refreshEntry() {
@@ -541,7 +554,7 @@ function refreshEntry() {
541
554
  refreshingEntry = pending
542
555
  .then((entry) => {
543
556
  if (generation !== entrySeq || entry.gameRoomId !== lastEntry?.gameRoomId) return;
544
- emitLocal('snapshot', entry);
557
+ if (!gameState.configurationPending) emitLocal('snapshot', publicEntry(entry));
545
558
  return Promise.all([getGameBets({ gameRoomId: entry.gameRoomId }), getGameRoomFeed()]);
546
559
  })
547
560
  .catch(() => {})
@@ -866,9 +879,11 @@ export function enterGame({ gameRoomId } = {}) {
866
879
  if (gameRoomId != null && !(Number.isSafeInteger(gameRoomId) && gameRoomId >= 0))
867
880
  return badRequest('enterGame', 'invalid gameRoomId');
868
881
  const seq = ++entrySeq;
882
+ const recovery = gameState.recovery();
869
883
  return invoke('enterGame', gameRoomId != null ? { gameRoomId } : {}).then((entry) => {
870
- if (seq !== entrySeq) return entry;
871
- const accepted = acceptEntry(entry);
884
+ if (seq !== entrySeq) throw new SdkError(ErrorCode.CONFLICT, 'context changed');
885
+ const accepted = acceptEntry(entry, false, recovery);
886
+ if (!accepted || gameState.conflicts.size || gameState.configurationPending) throw new SdkError(ErrorCode.CONFLICT, 'invalid state');
872
887
  if (watching && !watchPaused) pollWatch();
873
888
  return accepted;
874
889
  });
@@ -888,6 +903,7 @@ function placeWith(params) {
888
903
  }
889
904
 
890
905
  export function placeGameBet({ issueId, bets, clientRequestId } = {}) {
906
+ if (gameState.conflicts.size || gameState.configurationPending) return badRequest('placeGameBet', 'state unavailable');
891
907
  const picked = pickBets(bets);
892
908
  if (!picked) return badRequest('placeGameBet', `invalid bets: 1-${MAX_BETS}`);
893
909
  if (issueId != null && issueId !== 0 && !isId(issueId))
@@ -954,6 +970,7 @@ function submitAction(key, request, confirm = false) {
954
970
  }
955
971
 
956
972
  export function actGameBet({ betId, action, params } = {}) {
973
+ if (gameState.conflicts.size || gameState.configurationPending) return badRequest('actGameBet', 'state unavailable');
957
974
  if (!isId(betId)) return badRequest('actGameBet', 'invalid betId');
958
975
  if (typeof action !== 'string' || action.length === 0) return badRequest('actGameBet', 'invalid action');
959
976
  if (params != null && !isPlainObject(params)) return badRequest('actGameBet', 'invalid params');
@@ -969,6 +986,7 @@ export function actGameBet({ betId, action, params } = {}) {
969
986
  return queueAction(key, actQueues.get(key), (queued) => {
970
987
  if (gameId !== lastEntry?.gameId || room !== lastEntry?.gameRoomId)
971
988
  return badRequest('actGameBet', 'context changed');
989
+ if (gameState.conflicts.size || gameState.configurationPending) return badRequest('actGameBet', 'state unavailable');
972
990
  const request = {
973
991
  gameRoomId: room,
974
992
  betId,
@@ -984,8 +1002,12 @@ export function actGameBet({ betId, action, params } = {}) {
984
1002
  export function getGameIssue({ issueId } = {}) {
985
1003
  if (issueId != null && issueId !== 0 && !isId(issueId))
986
1004
  return badRequest('getGameIssue', 'invalid issueId');
1005
+ const state = gameState;
1006
+ const generation = entrySeq;
1007
+ const recovery = state.recovery();
987
1008
  return invoke('getGameIssue', boundRoom(issueId != null ? { issueId } : {})).then((info) =>
988
- noteIssueInfo(info, false),
1009
+ state === gameState && generation === entrySeq ? noteIssueInfo(info, false, recovery)
1010
+ : Promise.reject(new SdkError(ErrorCode.CONFLICT, 'context changed')),
989
1011
  );
990
1012
  }
991
1013
 
@@ -994,10 +1016,16 @@ export function getGameBets({ earliestId, pageSize, gameRoomId } = {}) {
994
1016
  if (earliestId != null) params.earliestId = earliestId;
995
1017
  if (pageSize != null) params.pageSize = pageSize;
996
1018
  if (gameRoomId != null) params.gameRoomId = gameRoomId;
1019
+ const state = gameState;
1020
+ const generation = entrySeq;
1021
+ const recovery = state.recovery();
997
1022
  return invoke('getGameBets', params).then((bets) => {
998
1023
  const list = Array.isArray(bets) ? bets : [];
999
- for (const bet of list) noteBet(bet, false, false);
1000
- return list;
1024
+ if (state !== gameState || generation !== entrySeq) return list;
1025
+ for (const bet of list) noteBet(bet, false, false, recovery);
1026
+ const normalized = list.map((bet) => normalizeBet(bet));
1027
+ if (gameState.conflicts.size) recoverState();
1028
+ return normalized;
1001
1029
  });
1002
1030
  }
1003
1031
 
@@ -1012,7 +1040,14 @@ export function getGameRoomFeed() {
1012
1040
  }
1013
1041
 
1014
1042
  export function getGameIssues(args = {}) {
1015
- return invoke('getGameIssues', boundRoom(args)).then((list) => (Array.isArray(list) ? list : []));
1043
+ const state = gameState;
1044
+ const generation = entrySeq;
1045
+ const recovery = state.recovery();
1046
+ return invoke('getGameIssues', boundRoom(args)).then((list) => {
1047
+ if (!Array.isArray(list)) return [];
1048
+ if (state !== gameState || generation !== entrySeq) return list;
1049
+ return list.map((info) => inRoom(info.issue) ? noteIssueInfo(info, false, recovery, false) : info);
1050
+ });
1016
1051
  }
1017
1052
 
1018
1053
  export function getGameRooms() {
@@ -1027,8 +1062,12 @@ export function getGameRooms() {
1027
1062
  }
1028
1063
  export function getGameBet({ betId, gameRoomId } = {}) {
1029
1064
  if (!isId(betId)) return badRequest('getGameBet', 'invalid betId');
1065
+ const state = gameState;
1066
+ const generation = entrySeq;
1067
+ const recovery = state.recovery();
1030
1068
  return invoke('getGameBet', boundRoom({ betId, gameRoomId })).then((bet) => {
1031
- noteBet(bet, false);
1069
+ if (state !== gameState || generation !== entrySeq) throw new SdkError(ErrorCode.CONFLICT, 'context changed');
1070
+ noteBet(bet, false, true, recovery);
1032
1071
  const key = actionKey(bet?.gameId, bet?.gameRoomId, bet?.id);
1033
1072
  const queued = actQueues.get(key);
1034
1073
  const request = queued?.request;
@@ -1042,7 +1081,7 @@ export function getGameBet({ betId, gameRoomId } = {}) {
1042
1081
  sameIntent(confirmed.params, request.params)
1043
1082
  )
1044
1083
  actQueues.delete(key);
1045
- return bet;
1084
+ return normalizeBet(bet);
1046
1085
  });
1047
1086
  }
1048
1087
  export function getGameCashouts(args = {}) {
@@ -1117,7 +1156,7 @@ async function pollWatch() {
1117
1156
  noteIssueInfo(info, true);
1118
1157
 
1119
1158
  const id = info?.issue?.id;
1120
- scheduleWatch(id ? issues.get(id)?.issue ?? info.issue : null);
1159
+ scheduleWatch(id ? cachedIssue(id) ?? info.issue : null);
1121
1160
  }
1122
1161
 
1123
1162
  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
@@ -83,6 +83,7 @@ export interface GameRoom {
83
83
  id: number;
84
84
  gameId: number;
85
85
  roomName: string;
86
+ roomIcon: string | null;
86
87
  position: number;
87
88
  onlineCount: number;
88
89
  targetTime: number;
@@ -94,6 +95,9 @@ export interface GameRooms {
94
95
  serverTime: number;
95
96
  }
96
97
  export interface GameFlow {
98
+ revision: WireId | null;
99
+ currentIssueId: WireId;
100
+ lastOpenedIssueId: WireId;
97
101
  phase: -2 | -1 | 1 | 2 | 3;
98
102
  phaseEndsAt: number;
99
103
  bettingOpen: boolean;
@@ -136,6 +140,8 @@ export interface GameEntry {
136
140
  serverTime: number;
137
141
  }
138
142
  export interface GameIssue {
143
+ revision: WireId;
144
+ targetTime: number;
139
145
  id: WireId;
140
146
  issue: WireId;
141
147
  gameId: number;
@@ -151,7 +157,6 @@ export interface GameIssue {
151
157
  cashoutCount: number;
152
158
  showMillis: number;
153
159
  createAt: number | null;
154
- openAt?: number | null;
155
160
  closeAt: number | null;
156
161
  startAt: number | null;
157
162
  resultPublishedAt: number | null;
@@ -317,7 +322,7 @@ export interface EventPayloads {
317
322
  };
318
323
  flow: {
319
324
  gameRoomId: number;
320
- flow: GameFlow;
325
+ flow: Partial<GameFlow>;
321
326
  issue: GameIssue | null;
322
327
  lastOpenedIssue: GameIssue | null;
323
328
  serverTime: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanotoolkit/game-sdk",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",