@nanotoolkit/game-sdk 0.1.8 → 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.8';
4
+ export const SDK_VERSION = '0.1.9';
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
  });
@@ -476,35 +443,35 @@ function boundRoom(args = {}) {
476
443
  };
477
444
  }
478
445
 
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
- );
446
+ function publicEntry(entry) {
447
+ return gameState.conflicts.size || gameState.configurationPending
448
+ ? { ...entry, flow: { ...entry.flow, bettingOpen: false } }
449
+ : entry;
487
450
  }
488
451
 
489
- function acceptEntry(entry, publish = false) {
490
- const changed =
491
- 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;
492
455
  if (changed) {
493
- for (const { issue } of issues.values())
456
+ for (const issue of [lastEntry.issue, lastEntry.lastOpenedIssue].filter(Boolean))
494
457
  if (issue.ruleType === 'crash' && issue.flowType === 'timed') recoverCashouts(issue);
495
- issues.clear();
458
+ gameState = new GameState();
496
459
  betKeys.clear();
497
460
  betStates.clear();
498
461
  actionCounts.clear();
499
462
  lastHeartbeat = null;
500
463
  roomFeed = null;
501
464
  }
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 };
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);
508
475
  if (entry.rooms)
509
476
  roomDirectory = {
510
477
  gameId: entry.gameId,
@@ -512,21 +479,65 @@ function acceptEntry(entry, publish = false) {
512
479
  rooms: entry.rooms,
513
480
  serverTime: entry.serverTime,
514
481
  };
515
- noteIssueInfo({ issue: entry.issue, bets: entry.bets }, publish);
516
- 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);
517
488
  reconcileEntryState();
518
- if (publish) emitLocal('snapshot', lastEntry);
519
- return lastEntry;
489
+ if (gameState.conflicts.size) recoverState();
490
+ const result = publicEntry(lastEntry);
491
+ if (publish && !gameState.configurationPending) emitLocal('snapshot', result);
492
+ return result;
520
493
  }
521
494
 
522
495
  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]));
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]));
526
502
  for (const bet of betStates.values())
527
503
  if (bet.issueId === lastEntry.issue?.id) latestBets.set(bet.id, bet);
528
504
  lastEntry.bets = [...latestBets.values()];
529
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
+
530
541
  let refreshingEntry = null;
531
542
  let refreshAgain = false;
532
543
  function refreshEntry() {
@@ -541,7 +552,7 @@ function refreshEntry() {
541
552
  refreshingEntry = pending
542
553
  .then((entry) => {
543
554
  if (generation !== entrySeq || entry.gameRoomId !== lastEntry?.gameRoomId) return;
544
- emitLocal('snapshot', entry);
555
+ if (!gameState.configurationPending) emitLocal('snapshot', publicEntry(entry));
545
556
  return Promise.all([getGameBets({ gameRoomId: entry.gameRoomId }), getGameRoomFeed()]);
546
557
  })
547
558
  .catch(() => {})
@@ -866,9 +877,11 @@ export function enterGame({ gameRoomId } = {}) {
866
877
  if (gameRoomId != null && !(Number.isSafeInteger(gameRoomId) && gameRoomId >= 0))
867
878
  return badRequest('enterGame', 'invalid gameRoomId');
868
879
  const seq = ++entrySeq;
880
+ const recovery = gameState.recovery();
869
881
  return invoke('enterGame', gameRoomId != null ? { gameRoomId } : {}).then((entry) => {
870
- if (seq !== entrySeq) return entry;
871
- 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');
872
885
  if (watching && !watchPaused) pollWatch();
873
886
  return accepted;
874
887
  });
@@ -888,6 +901,7 @@ function placeWith(params) {
888
901
  }
889
902
 
890
903
  export function placeGameBet({ issueId, bets, clientRequestId } = {}) {
904
+ if (gameState.conflicts.size || gameState.configurationPending) return badRequest('placeGameBet', 'state unavailable');
891
905
  const picked = pickBets(bets);
892
906
  if (!picked) return badRequest('placeGameBet', `invalid bets: 1-${MAX_BETS}`);
893
907
  if (issueId != null && issueId !== 0 && !isId(issueId))
@@ -954,6 +968,7 @@ function submitAction(key, request, confirm = false) {
954
968
  }
955
969
 
956
970
  export function actGameBet({ betId, action, params } = {}) {
971
+ if (gameState.conflicts.size || gameState.configurationPending) return badRequest('actGameBet', 'state unavailable');
957
972
  if (!isId(betId)) return badRequest('actGameBet', 'invalid betId');
958
973
  if (typeof action !== 'string' || action.length === 0) return badRequest('actGameBet', 'invalid action');
959
974
  if (params != null && !isPlainObject(params)) return badRequest('actGameBet', 'invalid params');
@@ -969,6 +984,7 @@ export function actGameBet({ betId, action, params } = {}) {
969
984
  return queueAction(key, actQueues.get(key), (queued) => {
970
985
  if (gameId !== lastEntry?.gameId || room !== lastEntry?.gameRoomId)
971
986
  return badRequest('actGameBet', 'context changed');
987
+ if (gameState.conflicts.size || gameState.configurationPending) return badRequest('actGameBet', 'state unavailable');
972
988
  const request = {
973
989
  gameRoomId: room,
974
990
  betId,
@@ -984,8 +1000,12 @@ export function actGameBet({ betId, action, params } = {}) {
984
1000
  export function getGameIssue({ issueId } = {}) {
985
1001
  if (issueId != null && issueId !== 0 && !isId(issueId))
986
1002
  return badRequest('getGameIssue', 'invalid issueId');
1003
+ const state = gameState;
1004
+ const generation = entrySeq;
1005
+ const recovery = state.recovery();
987
1006
  return invoke('getGameIssue', boundRoom(issueId != null ? { issueId } : {})).then((info) =>
988
- noteIssueInfo(info, false),
1007
+ state === gameState && generation === entrySeq ? noteIssueInfo(info, false, recovery)
1008
+ : Promise.reject(new SdkError(ErrorCode.CONFLICT, 'context changed')),
989
1009
  );
990
1010
  }
991
1011
 
@@ -994,10 +1014,16 @@ export function getGameBets({ earliestId, pageSize, gameRoomId } = {}) {
994
1014
  if (earliestId != null) params.earliestId = earliestId;
995
1015
  if (pageSize != null) params.pageSize = pageSize;
996
1016
  if (gameRoomId != null) params.gameRoomId = gameRoomId;
1017
+ const state = gameState;
1018
+ const generation = entrySeq;
1019
+ const recovery = state.recovery();
997
1020
  return invoke('getGameBets', params).then((bets) => {
998
1021
  const list = Array.isArray(bets) ? bets : [];
999
- for (const bet of list) noteBet(bet, false, false);
1000
- 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;
1001
1027
  });
1002
1028
  }
1003
1029
 
@@ -1012,7 +1038,14 @@ export function getGameRoomFeed() {
1012
1038
  }
1013
1039
 
1014
1040
  export function getGameIssues(args = {}) {
1015
- 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
+ });
1016
1049
  }
1017
1050
 
1018
1051
  export function getGameRooms() {
@@ -1027,8 +1060,12 @@ export function getGameRooms() {
1027
1060
  }
1028
1061
  export function getGameBet({ betId, gameRoomId } = {}) {
1029
1062
  if (!isId(betId)) return badRequest('getGameBet', 'invalid betId');
1063
+ const state = gameState;
1064
+ const generation = entrySeq;
1065
+ const recovery = state.recovery();
1030
1066
  return invoke('getGameBet', boundRoom({ betId, gameRoomId })).then((bet) => {
1031
- noteBet(bet, false);
1067
+ if (state !== gameState || generation !== entrySeq) throw new SdkError(ErrorCode.CONFLICT, 'context changed');
1068
+ noteBet(bet, false, true, recovery);
1032
1069
  const key = actionKey(bet?.gameId, bet?.gameRoomId, bet?.id);
1033
1070
  const queued = actQueues.get(key);
1034
1071
  const request = queued?.request;
@@ -1042,7 +1079,7 @@ export function getGameBet({ betId, gameRoomId } = {}) {
1042
1079
  sameIntent(confirmed.params, request.params)
1043
1080
  )
1044
1081
  actQueues.delete(key);
1045
- return bet;
1082
+ return normalizeBet(bet);
1046
1083
  });
1047
1084
  }
1048
1085
  export function getGameCashouts(args = {}) {
@@ -1117,7 +1154,7 @@ async function pollWatch() {
1117
1154
  noteIssueInfo(info, true);
1118
1155
 
1119
1156
  const id = info?.issue?.id;
1120
- scheduleWatch(id ? issues.get(id)?.issue ?? info.issue : null);
1157
+ scheduleWatch(id ? cachedIssue(id) ?? info.issue : null);
1121
1158
  }
1122
1159
 
1123
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
@@ -94,6 +94,9 @@ export interface GameRooms {
94
94
  serverTime: number;
95
95
  }
96
96
  export interface GameFlow {
97
+ revision: WireId | null;
98
+ currentIssueId: WireId;
99
+ lastOpenedIssueId: WireId;
97
100
  phase: -2 | -1 | 1 | 2 | 3;
98
101
  phaseEndsAt: number;
99
102
  bettingOpen: boolean;
@@ -136,6 +139,8 @@ export interface GameEntry {
136
139
  serverTime: number;
137
140
  }
138
141
  export interface GameIssue {
142
+ revision: WireId;
143
+ targetTime: number;
139
144
  id: WireId;
140
145
  issue: WireId;
141
146
  gameId: number;
@@ -151,7 +156,6 @@ export interface GameIssue {
151
156
  cashoutCount: number;
152
157
  showMillis: number;
153
158
  createAt: number | null;
154
- openAt?: number | null;
155
159
  closeAt: number | null;
156
160
  startAt: number | null;
157
161
  resultPublishedAt: number | null;
@@ -317,7 +321,7 @@ export interface EventPayloads {
317
321
  };
318
322
  flow: {
319
323
  gameRoomId: number;
320
- flow: GameFlow;
324
+ flow: Partial<GameFlow>;
321
325
  issue: GameIssue | null;
322
326
  lastOpenedIssue: GameIssue | null;
323
327
  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.9",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",