@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.
- package/Lib/Bridge/GameBridge.js +165 -111
- package/Lib/Bridge/GameState.js +277 -0
- package/index.d.ts +11 -3
- package/package.json +1 -1
package/Lib/Bridge/GameBridge.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
276
|
-
if (
|
|
277
|
-
if (
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
293
|
-
|
|
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 =
|
|
323
|
-
if (state?.
|
|
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
|
|
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
|
|
415
|
-
if (
|
|
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
|
-
|
|
431
|
-
|
|
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
|
|
476
|
-
return
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
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
|
|
456
|
+
for (const issue of [lastEntry.issue, lastEntry.lastOpenedIssue].filter(Boolean))
|
|
490
457
|
if (issue.ruleType === 'crash' && issue.flowType === 'timed') recoverCashouts(issue);
|
|
491
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
512
|
-
|
|
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 (
|
|
515
|
-
|
|
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
|
-
|
|
520
|
-
lastEntry.
|
|
521
|
-
|
|
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
|
|
544
|
+
if (!lastEntry) return;
|
|
545
|
+
if (refreshingEntry) {
|
|
546
|
+
refreshAgain = true;
|
|
547
|
+
return refreshingEntry;
|
|
548
|
+
}
|
|
529
549
|
const room = lastEntry.gameRoomId;
|
|
530
|
-
|
|
550
|
+
const pending = enterGame({ gameRoomId: room });
|
|
551
|
+
const generation = entrySeq;
|
|
552
|
+
refreshingEntry = pending
|
|
531
553
|
.then((entry) => {
|
|
532
|
-
if (entry.gameRoomId
|
|
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
|
|
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)
|
|
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
|
-
|
|
983
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ?
|
|
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;
|