@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.
- package/Lib/Bridge/GameBridge.js +144 -107
- package/Lib/Bridge/GameState.js +277 -0
- package/index.d.ts +6 -2
- 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
|
|
|
@@ -232,7 +233,8 @@ export function on(event, handler) {
|
|
|
232
233
|
}
|
|
233
234
|
|
|
234
235
|
const KEEP_KEYS = 64;
|
|
235
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
277
|
-
if (
|
|
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
|
|
296
|
-
|
|
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 =
|
|
326
|
-
if (state?.
|
|
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
|
|
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
|
|
418
|
-
if (
|
|
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
|
-
|
|
434
|
-
|
|
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
|
|
480
|
-
return
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
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
|
|
456
|
+
for (const issue of [lastEntry.issue, lastEntry.lastOpenedIssue].filter(Boolean))
|
|
494
457
|
if (issue.ruleType === 'crash' && issue.flowType === 'timed') recoverCashouts(issue);
|
|
495
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
|
|
516
|
-
|
|
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 (
|
|
519
|
-
|
|
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
|
-
|
|
524
|
-
lastEntry.
|
|
525
|
-
|
|
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)
|
|
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
|
-
|
|
1000
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ?
|
|
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;
|