@nanotoolkit/game-sdk 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Lib/Bridge/GameBridge.js +148 -109
- package/Lib/Bridge/GameState.js +277 -0
- package/index.d.ts +7 -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.10';
|
|
4
5
|
|
|
5
6
|
const REQUEST_TIMEOUT_MS = 10000;
|
|
6
7
|
|
|
@@ -232,7 +233,8 @@ export function on(event, handler) {
|
|
|
232
233
|
}
|
|
233
234
|
|
|
234
235
|
const KEEP_KEYS = 64;
|
|
235
|
-
|
|
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
|
});
|
|
@@ -442,6 +409,7 @@ function ensureStateListeners() {
|
|
|
442
409
|
const directory = roomDirectory ?? lastEntry;
|
|
443
410
|
if (!directory || msg.rooms?.gameId !== directory.gameId || !Array.isArray(msg.rooms.rooms)) return;
|
|
444
411
|
const known = new Map((directory.rooms ?? []).map((room) => [room.id, room]));
|
|
412
|
+
const updates = new Map(msg.rooms.rooms.map((room) => [room.id, room]));
|
|
445
413
|
if (msg.rooms.rooms.some((room) => !known.has(room.id))) {
|
|
446
414
|
getGameRooms().catch(() => {});
|
|
447
415
|
return;
|
|
@@ -450,9 +418,10 @@ function ensureStateListeners() {
|
|
|
450
418
|
...directory,
|
|
451
419
|
...msg.rooms,
|
|
452
420
|
topic: roomDirectory?.topic ?? lastEntry?.roomsTopic ?? 'game:' + directory.gameId,
|
|
453
|
-
rooms:
|
|
454
|
-
...known.get(room.id),
|
|
421
|
+
rooms: (directory.rooms ?? []).filter((room) => updates.has(room.id)).map((room) => ({
|
|
455
422
|
...room,
|
|
423
|
+
onlineCount: updates.get(room.id).onlineCount,
|
|
424
|
+
targetTime: updates.get(room.id).targetTime,
|
|
456
425
|
})),
|
|
457
426
|
};
|
|
458
427
|
if (lastEntry?.gameId === roomDirectory.gameId) lastEntry.rooms = roomDirectory.rooms;
|
|
@@ -476,35 +445,35 @@ function boundRoom(args = {}) {
|
|
|
476
445
|
};
|
|
477
446
|
}
|
|
478
447
|
|
|
479
|
-
function
|
|
480
|
-
return
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
next.flow?.phase > 0 &&
|
|
484
|
-
next.flow.phase < previous.flow.phase &&
|
|
485
|
-
next.flow.phaseEndsAt <= previous.flow.phaseEndsAt
|
|
486
|
-
);
|
|
448
|
+
function publicEntry(entry) {
|
|
449
|
+
return gameState.conflicts.size || gameState.configurationPending
|
|
450
|
+
? { ...entry, flow: { ...entry.flow, bettingOpen: false } }
|
|
451
|
+
: entry;
|
|
487
452
|
}
|
|
488
453
|
|
|
489
|
-
function acceptEntry(entry, publish = false) {
|
|
490
|
-
const changed =
|
|
491
|
-
|
|
454
|
+
function acceptEntry(entry, publish = false, recovery, publishIssues = publish) {
|
|
455
|
+
const changed = lastEntry && (entry.gameRoomId !== lastEntry.gameRoomId || entry.gameId !== lastEntry.gameId);
|
|
456
|
+
const previousCurrent = lastEntry?.issue;
|
|
492
457
|
if (changed) {
|
|
493
|
-
for (const
|
|
458
|
+
for (const issue of [lastEntry.issue, lastEntry.lastOpenedIssue].filter(Boolean))
|
|
494
459
|
if (issue.ruleType === 'crash' && issue.flowType === 'timed') recoverCashouts(issue);
|
|
495
|
-
|
|
460
|
+
gameState = new GameState();
|
|
496
461
|
betKeys.clear();
|
|
497
462
|
betStates.clear();
|
|
498
463
|
actionCounts.clear();
|
|
499
464
|
lastHeartbeat = null;
|
|
500
465
|
roomFeed = null;
|
|
501
466
|
}
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
467
|
+
const previousIssues = [entry.issue, entry.lastOpenedIssue].filter(Boolean)
|
|
468
|
+
.map((issue) => gameState.get(issue.gameId, issue.gameRoomId, issue.id));
|
|
469
|
+
const accepted = gameState.merge(entry, recovery);
|
|
470
|
+
if (!accepted) {
|
|
471
|
+
if (lastEntry) recoverState();
|
|
472
|
+
return lastEntry;
|
|
473
|
+
}
|
|
474
|
+
lastEntry = accepted;
|
|
475
|
+
if (!changed && previousCurrent?.id !== accepted.issue?.id && previousCurrent?.ruleType === 'crash')
|
|
476
|
+
recoverCashouts(previousCurrent);
|
|
508
477
|
if (entry.rooms)
|
|
509
478
|
roomDirectory = {
|
|
510
479
|
gameId: entry.gameId,
|
|
@@ -512,21 +481,65 @@ function acceptEntry(entry, publish = false) {
|
|
|
512
481
|
rooms: entry.rooms,
|
|
513
482
|
serverTime: entry.serverTime,
|
|
514
483
|
};
|
|
515
|
-
|
|
516
|
-
|
|
484
|
+
for (const [index, issue] of [entry.issue, entry.lastOpenedIssue].filter(Boolean).entries()) {
|
|
485
|
+
if (!inRoom(issue)) continue;
|
|
486
|
+
const known = cachedIssue(issue.id);
|
|
487
|
+
if (known && known !== previousIssues[index]) afterIssue(known, publishIssues);
|
|
488
|
+
}
|
|
489
|
+
if (Array.isArray(accepted.bets)) for (const bet of accepted.bets) noteBet(bet, publish, true, recovery);
|
|
517
490
|
reconcileEntryState();
|
|
518
|
-
if (
|
|
519
|
-
|
|
491
|
+
if (gameState.conflicts.size) recoverState();
|
|
492
|
+
const result = publicEntry(lastEntry);
|
|
493
|
+
if (publish && !gameState.configurationPending) emitLocal('snapshot', result);
|
|
494
|
+
return result;
|
|
520
495
|
}
|
|
521
496
|
|
|
522
497
|
function reconcileEntryState() {
|
|
523
|
-
|
|
524
|
-
lastEntry.
|
|
525
|
-
|
|
498
|
+
if (!lastEntry) return;
|
|
499
|
+
lastEntry.issue = cachedIssue(lastEntry.issue?.id) ?? lastEntry.issue;
|
|
500
|
+
lastEntry.lastOpenedIssue = cachedIssue(lastEntry.lastOpenedIssue?.id) ?? lastEntry.lastOpenedIssue;
|
|
501
|
+
const latestBets = new Map((lastEntry.bets ?? [])
|
|
502
|
+
.filter((bet) => bet.issueId === lastEntry.issue?.id)
|
|
503
|
+
.map((bet) => [bet.id, betStates.get(bet.id) ?? bet]));
|
|
526
504
|
for (const bet of betStates.values())
|
|
527
505
|
if (bet.issueId === lastEntry.issue?.id) latestBets.set(bet.id, bet);
|
|
528
506
|
lastEntry.bets = [...latestBets.values()];
|
|
529
507
|
}
|
|
508
|
+
|
|
509
|
+
function normalizeBet(bet) {
|
|
510
|
+
if (!bet || !inRoom(bet)) return bet;
|
|
511
|
+
const value = betStates.get(bet.id) ?? bet;
|
|
512
|
+
const issue = gameState.get(value.gameId, value.gameRoomId, value.issueId);
|
|
513
|
+
return issue ? { ...value, issue } : value;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
let stateRecovery = null;
|
|
517
|
+
function recoverState() {
|
|
518
|
+
if (lastEntry && !gameState.configurationPending && gameState.conflicts.size)
|
|
519
|
+
emitLocal('snapshot', publicEntry(lastEntry));
|
|
520
|
+
if (!lastEntry || !gameState.conflicts.size || stateRecovery?.state === gameState) return;
|
|
521
|
+
const task = { state: gameState, attempted: new Map() };
|
|
522
|
+
stateRecovery = task;
|
|
523
|
+
(async () => {
|
|
524
|
+
while (task.state === gameState) {
|
|
525
|
+
if (refreshingEntry) await refreshingEntry;
|
|
526
|
+
if (task.state !== gameState) break;
|
|
527
|
+
const recovery = gameState.recovery();
|
|
528
|
+
const pending = [...recovery].filter(([key, version]) => task.attempted.get(key) !== version);
|
|
529
|
+
if (!pending.length) break;
|
|
530
|
+
for (const [key, version] of pending) task.attempted.set(key, version);
|
|
531
|
+
const targets = pending.map(([key]) => gameState.conflictIssues.get(key)).filter(Boolean).filter(inRoom);
|
|
532
|
+
await Promise.all(targets.map((issue) => invoke('getGameIssue', boundRoom({ issueId: issue.id }))
|
|
533
|
+
.then((info) => {
|
|
534
|
+
if (task.state === gameState) noteIssueInfo(info, false, recovery, false);
|
|
535
|
+
}).catch(() => {})));
|
|
536
|
+
if (task.state === gameState) await refreshEntry();
|
|
537
|
+
}
|
|
538
|
+
})().finally(() => {
|
|
539
|
+
if (stateRecovery === task) stateRecovery = null;
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
530
543
|
let refreshingEntry = null;
|
|
531
544
|
let refreshAgain = false;
|
|
532
545
|
function refreshEntry() {
|
|
@@ -541,7 +554,7 @@ function refreshEntry() {
|
|
|
541
554
|
refreshingEntry = pending
|
|
542
555
|
.then((entry) => {
|
|
543
556
|
if (generation !== entrySeq || entry.gameRoomId !== lastEntry?.gameRoomId) return;
|
|
544
|
-
emitLocal('snapshot', entry);
|
|
557
|
+
if (!gameState.configurationPending) emitLocal('snapshot', publicEntry(entry));
|
|
545
558
|
return Promise.all([getGameBets({ gameRoomId: entry.gameRoomId }), getGameRoomFeed()]);
|
|
546
559
|
})
|
|
547
560
|
.catch(() => {})
|
|
@@ -866,9 +879,11 @@ export function enterGame({ gameRoomId } = {}) {
|
|
|
866
879
|
if (gameRoomId != null && !(Number.isSafeInteger(gameRoomId) && gameRoomId >= 0))
|
|
867
880
|
return badRequest('enterGame', 'invalid gameRoomId');
|
|
868
881
|
const seq = ++entrySeq;
|
|
882
|
+
const recovery = gameState.recovery();
|
|
869
883
|
return invoke('enterGame', gameRoomId != null ? { gameRoomId } : {}).then((entry) => {
|
|
870
|
-
if (seq !== entrySeq)
|
|
871
|
-
const accepted = acceptEntry(entry);
|
|
884
|
+
if (seq !== entrySeq) throw new SdkError(ErrorCode.CONFLICT, 'context changed');
|
|
885
|
+
const accepted = acceptEntry(entry, false, recovery);
|
|
886
|
+
if (!accepted || gameState.conflicts.size || gameState.configurationPending) throw new SdkError(ErrorCode.CONFLICT, 'invalid state');
|
|
872
887
|
if (watching && !watchPaused) pollWatch();
|
|
873
888
|
return accepted;
|
|
874
889
|
});
|
|
@@ -888,6 +903,7 @@ function placeWith(params) {
|
|
|
888
903
|
}
|
|
889
904
|
|
|
890
905
|
export function placeGameBet({ issueId, bets, clientRequestId } = {}) {
|
|
906
|
+
if (gameState.conflicts.size || gameState.configurationPending) return badRequest('placeGameBet', 'state unavailable');
|
|
891
907
|
const picked = pickBets(bets);
|
|
892
908
|
if (!picked) return badRequest('placeGameBet', `invalid bets: 1-${MAX_BETS}`);
|
|
893
909
|
if (issueId != null && issueId !== 0 && !isId(issueId))
|
|
@@ -954,6 +970,7 @@ function submitAction(key, request, confirm = false) {
|
|
|
954
970
|
}
|
|
955
971
|
|
|
956
972
|
export function actGameBet({ betId, action, params } = {}) {
|
|
973
|
+
if (gameState.conflicts.size || gameState.configurationPending) return badRequest('actGameBet', 'state unavailable');
|
|
957
974
|
if (!isId(betId)) return badRequest('actGameBet', 'invalid betId');
|
|
958
975
|
if (typeof action !== 'string' || action.length === 0) return badRequest('actGameBet', 'invalid action');
|
|
959
976
|
if (params != null && !isPlainObject(params)) return badRequest('actGameBet', 'invalid params');
|
|
@@ -969,6 +986,7 @@ export function actGameBet({ betId, action, params } = {}) {
|
|
|
969
986
|
return queueAction(key, actQueues.get(key), (queued) => {
|
|
970
987
|
if (gameId !== lastEntry?.gameId || room !== lastEntry?.gameRoomId)
|
|
971
988
|
return badRequest('actGameBet', 'context changed');
|
|
989
|
+
if (gameState.conflicts.size || gameState.configurationPending) return badRequest('actGameBet', 'state unavailable');
|
|
972
990
|
const request = {
|
|
973
991
|
gameRoomId: room,
|
|
974
992
|
betId,
|
|
@@ -984,8 +1002,12 @@ export function actGameBet({ betId, action, params } = {}) {
|
|
|
984
1002
|
export function getGameIssue({ issueId } = {}) {
|
|
985
1003
|
if (issueId != null && issueId !== 0 && !isId(issueId))
|
|
986
1004
|
return badRequest('getGameIssue', 'invalid issueId');
|
|
1005
|
+
const state = gameState;
|
|
1006
|
+
const generation = entrySeq;
|
|
1007
|
+
const recovery = state.recovery();
|
|
987
1008
|
return invoke('getGameIssue', boundRoom(issueId != null ? { issueId } : {})).then((info) =>
|
|
988
|
-
noteIssueInfo(info, false)
|
|
1009
|
+
state === gameState && generation === entrySeq ? noteIssueInfo(info, false, recovery)
|
|
1010
|
+
: Promise.reject(new SdkError(ErrorCode.CONFLICT, 'context changed')),
|
|
989
1011
|
);
|
|
990
1012
|
}
|
|
991
1013
|
|
|
@@ -994,10 +1016,16 @@ export function getGameBets({ earliestId, pageSize, gameRoomId } = {}) {
|
|
|
994
1016
|
if (earliestId != null) params.earliestId = earliestId;
|
|
995
1017
|
if (pageSize != null) params.pageSize = pageSize;
|
|
996
1018
|
if (gameRoomId != null) params.gameRoomId = gameRoomId;
|
|
1019
|
+
const state = gameState;
|
|
1020
|
+
const generation = entrySeq;
|
|
1021
|
+
const recovery = state.recovery();
|
|
997
1022
|
return invoke('getGameBets', params).then((bets) => {
|
|
998
1023
|
const list = Array.isArray(bets) ? bets : [];
|
|
999
|
-
|
|
1000
|
-
|
|
1024
|
+
if (state !== gameState || generation !== entrySeq) return list;
|
|
1025
|
+
for (const bet of list) noteBet(bet, false, false, recovery);
|
|
1026
|
+
const normalized = list.map((bet) => normalizeBet(bet));
|
|
1027
|
+
if (gameState.conflicts.size) recoverState();
|
|
1028
|
+
return normalized;
|
|
1001
1029
|
});
|
|
1002
1030
|
}
|
|
1003
1031
|
|
|
@@ -1012,7 +1040,14 @@ export function getGameRoomFeed() {
|
|
|
1012
1040
|
}
|
|
1013
1041
|
|
|
1014
1042
|
export function getGameIssues(args = {}) {
|
|
1015
|
-
|
|
1043
|
+
const state = gameState;
|
|
1044
|
+
const generation = entrySeq;
|
|
1045
|
+
const recovery = state.recovery();
|
|
1046
|
+
return invoke('getGameIssues', boundRoom(args)).then((list) => {
|
|
1047
|
+
if (!Array.isArray(list)) return [];
|
|
1048
|
+
if (state !== gameState || generation !== entrySeq) return list;
|
|
1049
|
+
return list.map((info) => inRoom(info.issue) ? noteIssueInfo(info, false, recovery, false) : info);
|
|
1050
|
+
});
|
|
1016
1051
|
}
|
|
1017
1052
|
|
|
1018
1053
|
export function getGameRooms() {
|
|
@@ -1027,8 +1062,12 @@ export function getGameRooms() {
|
|
|
1027
1062
|
}
|
|
1028
1063
|
export function getGameBet({ betId, gameRoomId } = {}) {
|
|
1029
1064
|
if (!isId(betId)) return badRequest('getGameBet', 'invalid betId');
|
|
1065
|
+
const state = gameState;
|
|
1066
|
+
const generation = entrySeq;
|
|
1067
|
+
const recovery = state.recovery();
|
|
1030
1068
|
return invoke('getGameBet', boundRoom({ betId, gameRoomId })).then((bet) => {
|
|
1031
|
-
|
|
1069
|
+
if (state !== gameState || generation !== entrySeq) throw new SdkError(ErrorCode.CONFLICT, 'context changed');
|
|
1070
|
+
noteBet(bet, false, true, recovery);
|
|
1032
1071
|
const key = actionKey(bet?.gameId, bet?.gameRoomId, bet?.id);
|
|
1033
1072
|
const queued = actQueues.get(key);
|
|
1034
1073
|
const request = queued?.request;
|
|
@@ -1042,7 +1081,7 @@ export function getGameBet({ betId, gameRoomId } = {}) {
|
|
|
1042
1081
|
sameIntent(confirmed.params, request.params)
|
|
1043
1082
|
)
|
|
1044
1083
|
actQueues.delete(key);
|
|
1045
|
-
return bet;
|
|
1084
|
+
return normalizeBet(bet);
|
|
1046
1085
|
});
|
|
1047
1086
|
}
|
|
1048
1087
|
export function getGameCashouts(args = {}) {
|
|
@@ -1117,7 +1156,7 @@ async function pollWatch() {
|
|
|
1117
1156
|
noteIssueInfo(info, true);
|
|
1118
1157
|
|
|
1119
1158
|
const id = info?.issue?.id;
|
|
1120
|
-
scheduleWatch(id ?
|
|
1159
|
+
scheduleWatch(id ? cachedIssue(id) ?? info.issue : null);
|
|
1121
1160
|
}
|
|
1122
1161
|
|
|
1123
1162
|
function pauseWatch() {
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
export const validRevision = (value) =>
|
|
2
|
+
(typeof value === 'bigint' && value >= 0n && value <= 9223372036854775807n) ||
|
|
3
|
+
(Number.isSafeInteger(value) && value >= 0);
|
|
4
|
+
|
|
5
|
+
const sameId = (a, b) => a != null && b != null && String(a) === String(b);
|
|
6
|
+
const issueKey = (value) => `${value.gameId}:${value.gameRoomId}:${value.id}`;
|
|
7
|
+
const flowFields = ['phase', 'phaseEndsAt', 'currentIssueId', 'lastOpenedIssueId'];
|
|
8
|
+
|
|
9
|
+
function canonical(value) {
|
|
10
|
+
if (value === null || typeof value !== 'object')
|
|
11
|
+
return typeof value === 'bigint' ? value.toString() : JSON.stringify(value);
|
|
12
|
+
if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']';
|
|
13
|
+
return (
|
|
14
|
+
'{' +
|
|
15
|
+
Object.keys(value)
|
|
16
|
+
.sort()
|
|
17
|
+
.filter((key) => value[key] !== undefined)
|
|
18
|
+
.map((key) => JSON.stringify(key) + ':' + canonical(value[key]))
|
|
19
|
+
.join(',') +
|
|
20
|
+
'}'
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const issueContent = (value) => canonical({ ...value, serverTime: undefined });
|
|
25
|
+
const flowContent = (value) =>
|
|
26
|
+
canonical(Object.fromEntries(flowFields.map((key) => [key, value[key]])));
|
|
27
|
+
|
|
28
|
+
export class GameState {
|
|
29
|
+
constructor() {
|
|
30
|
+
this.entry = null;
|
|
31
|
+
this.issues = new Map();
|
|
32
|
+
this.conflicts = new Map();
|
|
33
|
+
this.conflictIssues = new Map();
|
|
34
|
+
this.sequence = 0;
|
|
35
|
+
this.configIssueId = undefined;
|
|
36
|
+
this.expectedIssueId = null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
recovery() {
|
|
40
|
+
return new Map(this.conflicts);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
conflict(key, issue) {
|
|
44
|
+
if (!this.conflicts.has(key)) this.conflicts.set(key, ++this.sequence);
|
|
45
|
+
if (issue && key !== 'flow') this.conflictIssues.set(key, issue);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
recovered(key, recovery) {
|
|
49
|
+
return recovery?.has(key) && recovery.get(key) === this.conflicts.get(key);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
clearConflict(key) {
|
|
53
|
+
this.conflicts.delete(key);
|
|
54
|
+
this.conflictIssues.delete(key);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get(gameId, gameRoomId, id) {
|
|
58
|
+
return this.issues.get(issueKey({ gameId, gameRoomId, id }));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
noteIssue(incoming, recovery) {
|
|
62
|
+
if (!incoming) return { issue: null, changed: false };
|
|
63
|
+
const key = issueKey(incoming);
|
|
64
|
+
const previous = this.issues.get(key);
|
|
65
|
+
if (
|
|
66
|
+
!validRevision(incoming.revision) ||
|
|
67
|
+
!validRevision(incoming.targetTime) ||
|
|
68
|
+
!validRevision(incoming.id) ||
|
|
69
|
+
incoming.id <= 0 ||
|
|
70
|
+
!Number.isInteger(incoming.gameId) ||
|
|
71
|
+
!Number.isInteger(incoming.gameRoomId) ||
|
|
72
|
+
!['betting', 'running', 'settled', 'voided'].includes(incoming.status)
|
|
73
|
+
) {
|
|
74
|
+
this.conflict(key, incoming);
|
|
75
|
+
return { issue: previous ?? null, changed: false };
|
|
76
|
+
}
|
|
77
|
+
if (previous) {
|
|
78
|
+
if (incoming.revision < previous.revision) return { issue: previous, changed: false };
|
|
79
|
+
if (
|
|
80
|
+
incoming.revision == previous.revision &&
|
|
81
|
+
issueContent(incoming) === issueContent(previous)
|
|
82
|
+
) {
|
|
83
|
+
if (this.recovered(key, recovery)) this.clearConflict(key);
|
|
84
|
+
return { issue: previous, changed: false };
|
|
85
|
+
}
|
|
86
|
+
if (
|
|
87
|
+
(incoming.revision == previous.revision && !this.recovered(key, recovery)) ||
|
|
88
|
+
(previous.settlementCompleted &&
|
|
89
|
+
(!incoming.settlementCompleted ||
|
|
90
|
+
incoming.status !== previous.status ||
|
|
91
|
+
canonical(incoming.result) !== canonical(previous.result))) ||
|
|
92
|
+
(previous.status === 'voided' && incoming.status !== 'voided') ||
|
|
93
|
+
(previous.result != null && incoming.result == null && incoming.status !== 'voided')
|
|
94
|
+
) {
|
|
95
|
+
this.conflict(key, incoming);
|
|
96
|
+
return { issue: previous, changed: false };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
this.issues.delete(key);
|
|
100
|
+
this.issues.set(key, incoming);
|
|
101
|
+
if (this.recovered(key, recovery) || (previous && incoming.revision > previous.revision))
|
|
102
|
+
this.clearConflict(key);
|
|
103
|
+
if (this.issues.size > 256) {
|
|
104
|
+
const pinned = [this.entry?.issue, this.entry?.lastOpenedIssue]
|
|
105
|
+
.filter(Boolean)
|
|
106
|
+
.map(issueKey);
|
|
107
|
+
const oldest = [...this.issues.keys()].find(
|
|
108
|
+
(item) => !pinned.includes(item) && !this.conflicts.has(item),
|
|
109
|
+
);
|
|
110
|
+
if (oldest) this.issues.delete(oldest);
|
|
111
|
+
}
|
|
112
|
+
return { issue: incoming, changed: true };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
normalizeBet(bet, recovery) {
|
|
116
|
+
if (!bet?.issue || typeof bet.issue !== 'object') return bet;
|
|
117
|
+
const issue = bet.issue;
|
|
118
|
+
if (
|
|
119
|
+
!sameId(issue.id, bet.issueId) ||
|
|
120
|
+
issue.gameId !== bet.gameId ||
|
|
121
|
+
issue.gameRoomId !== bet.gameRoomId
|
|
122
|
+
) {
|
|
123
|
+
const expected = { gameId: bet.gameId, gameRoomId: bet.gameRoomId, id: bet.issueId };
|
|
124
|
+
this.conflict(issueKey(expected), expected);
|
|
125
|
+
return { ...bet, issue: null };
|
|
126
|
+
}
|
|
127
|
+
return { ...bet, issue: this.noteIssue(issue, recovery).issue };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
expectIssue(issue) {
|
|
131
|
+
if (
|
|
132
|
+
this.entry &&
|
|
133
|
+
issue.result == null &&
|
|
134
|
+
['betting', 'running'].includes(issue.status) &&
|
|
135
|
+
(this.entry.issue == null || issue.id > this.entry.issue.id)
|
|
136
|
+
)
|
|
137
|
+
this.expectedIssueId =
|
|
138
|
+
this.expectedIssueId == null || issue.id > this.expectedIssueId
|
|
139
|
+
? issue.id
|
|
140
|
+
: this.expectedIssueId;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
get configurationPending() {
|
|
144
|
+
return (
|
|
145
|
+
this.expectedIssueId != null ||
|
|
146
|
+
(this.entry != null && !sameId(this.configIssueId, this.entry.issue?.id ?? 0))
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
merge(incoming, recovery) {
|
|
151
|
+
const previous = this.entry;
|
|
152
|
+
if (
|
|
153
|
+
previous &&
|
|
154
|
+
(incoming.gameId !== previous.gameId || incoming.gameRoomId !== previous.gameRoomId)
|
|
155
|
+
)
|
|
156
|
+
throw new TypeError('context changed');
|
|
157
|
+
const nested = [incoming.issue, incoming.lastOpenedIssue].filter(Boolean);
|
|
158
|
+
const scoped = nested.every(
|
|
159
|
+
(issue) => issue.gameId === incoming.gameId && issue.gameRoomId === incoming.gameRoomId,
|
|
160
|
+
);
|
|
161
|
+
if (scoped) for (const issue of nested) this.noteIssue(issue, recovery);
|
|
162
|
+
const bets = Array.isArray(incoming.bets)
|
|
163
|
+
? incoming.bets.map((bet) => this.normalizeBet(bet, recovery))
|
|
164
|
+
: incoming.bets;
|
|
165
|
+
incoming = { ...incoming, ...(bets !== undefined ? { bets } : {}) };
|
|
166
|
+
const flow = incoming.flow;
|
|
167
|
+
const aligned =
|
|
168
|
+
incoming.param?.alignedCycle ??
|
|
169
|
+
previous?.param?.alignedCycle ??
|
|
170
|
+
(previous?.flow && 'revision' in previous.flow);
|
|
171
|
+
let accept = scoped;
|
|
172
|
+
if (!scoped) this.conflict('flow');
|
|
173
|
+
const changedMode =
|
|
174
|
+
previous?.param?.alignedCycle != null &&
|
|
175
|
+
incoming.param?.alignedCycle != null &&
|
|
176
|
+
incoming.param.alignedCycle !== previous.param.alignedCycle;
|
|
177
|
+
const unknownMode =
|
|
178
|
+
previous?.param?.alignedCycle === false &&
|
|
179
|
+
incoming.param == null &&
|
|
180
|
+
flow &&
|
|
181
|
+
'revision' in flow;
|
|
182
|
+
if ((changedMode && !recovery) || unknownMode) {
|
|
183
|
+
accept = false;
|
|
184
|
+
this.conflict('flow');
|
|
185
|
+
}
|
|
186
|
+
if (aligned || (flow && 'revision' in flow)) {
|
|
187
|
+
const valid =
|
|
188
|
+
flow &&
|
|
189
|
+
[-2, -1, 1, 2, 3].includes(flow.phase) &&
|
|
190
|
+
Number.isSafeInteger(flow.phaseEndsAt) &&
|
|
191
|
+
flow.phaseEndsAt >= 0 &&
|
|
192
|
+
typeof flow.bettingOpen === 'boolean' &&
|
|
193
|
+
(validRevision(flow.revision) || flow.revision === null) &&
|
|
194
|
+
validRevision(flow.currentIssueId) &&
|
|
195
|
+
validRevision(flow.lastOpenedIssueId) &&
|
|
196
|
+
sameId(flow.currentIssueId, incoming.issue?.id ?? 0) &&
|
|
197
|
+
sameId(flow.lastOpenedIssueId, incoming.lastOpenedIssue?.id ?? 0) &&
|
|
198
|
+
(flow.revision !== null ||
|
|
199
|
+
(flow.currentIssueId == 0 &&
|
|
200
|
+
flow.lastOpenedIssueId == 0 &&
|
|
201
|
+
flow.phase === -2 &&
|
|
202
|
+
flow.phaseEndsAt === 0 &&
|
|
203
|
+
flow.bettingOpen === false));
|
|
204
|
+
if (!valid) {
|
|
205
|
+
accept = false;
|
|
206
|
+
this.conflict('flow');
|
|
207
|
+
} else if (
|
|
208
|
+
previous?.flow?.revision != null &&
|
|
209
|
+
(flow.revision === null || flow.revision < previous.flow.revision)
|
|
210
|
+
) {
|
|
211
|
+
accept = false;
|
|
212
|
+
} else if (
|
|
213
|
+
previous?.flow &&
|
|
214
|
+
flow.revision === previous.flow.revision &&
|
|
215
|
+
flowContent(flow) !== flowContent(previous.flow) &&
|
|
216
|
+
!this.recovered('flow', recovery)
|
|
217
|
+
) {
|
|
218
|
+
accept = false;
|
|
219
|
+
this.conflict('flow');
|
|
220
|
+
} else if (
|
|
221
|
+
accept &&
|
|
222
|
+
(this.recovered('flow', recovery) ||
|
|
223
|
+
(flow.revision != null &&
|
|
224
|
+
previous?.flow?.revision != null &&
|
|
225
|
+
flow.revision > previous.flow.revision))
|
|
226
|
+
)
|
|
227
|
+
this.clearConflict('flow');
|
|
228
|
+
} else if (previous?.issue && incoming.issue && incoming.issue.id < previous.issue.id) {
|
|
229
|
+
accept = false;
|
|
230
|
+
} else if (accept && this.recovered('flow', recovery)) {
|
|
231
|
+
this.clearConflict('flow');
|
|
232
|
+
}
|
|
233
|
+
const independent = Object.fromEntries(
|
|
234
|
+
[
|
|
235
|
+
'bets',
|
|
236
|
+
'admission',
|
|
237
|
+
'rooms',
|
|
238
|
+
'roomsTopic',
|
|
239
|
+
'bundleId',
|
|
240
|
+
'serverTime',
|
|
241
|
+
'currency',
|
|
242
|
+
'usdRate',
|
|
243
|
+
]
|
|
244
|
+
.filter((key) => incoming[key] !== undefined)
|
|
245
|
+
.map((key) => [key, incoming[key]]),
|
|
246
|
+
);
|
|
247
|
+
const entry = accept
|
|
248
|
+
? { ...previous, ...incoming }
|
|
249
|
+
: previous
|
|
250
|
+
? { ...previous, ...independent }
|
|
251
|
+
: null;
|
|
252
|
+
if (entry) {
|
|
253
|
+
for (const field of ['issue', 'lastOpenedIssue']) {
|
|
254
|
+
const id =
|
|
255
|
+
entry.flow && 'revision' in entry.flow
|
|
256
|
+
? entry.flow[field === 'issue' ? 'currentIssueId' : 'lastOpenedIssueId']
|
|
257
|
+
: entry[field]?.id;
|
|
258
|
+
entry[field] =
|
|
259
|
+
id == null || id == 0
|
|
260
|
+
? null
|
|
261
|
+
: this.get(entry.gameId, entry.gameRoomId, id) ?? null;
|
|
262
|
+
if (id != null && id != 0 && !entry[field]) this.conflict('flow');
|
|
263
|
+
}
|
|
264
|
+
this.entry = entry;
|
|
265
|
+
if (accept && (incoming.param != null || incoming.rulesId != null)) {
|
|
266
|
+
this.configIssueId = entry.issue?.id ?? 0;
|
|
267
|
+
if (
|
|
268
|
+
this.expectedIssueId == null ||
|
|
269
|
+
entry.issue?.id >= this.expectedIssueId ||
|
|
270
|
+
(entry.issue == null && entry.flow?.phase <= 0)
|
|
271
|
+
)
|
|
272
|
+
this.expectedIssueId = null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return entry;
|
|
276
|
+
}
|
|
277
|
+
}
|
package/index.d.ts
CHANGED
|
@@ -83,6 +83,7 @@ export interface GameRoom {
|
|
|
83
83
|
id: number;
|
|
84
84
|
gameId: number;
|
|
85
85
|
roomName: string;
|
|
86
|
+
roomIcon: string | null;
|
|
86
87
|
position: number;
|
|
87
88
|
onlineCount: number;
|
|
88
89
|
targetTime: number;
|
|
@@ -94,6 +95,9 @@ export interface GameRooms {
|
|
|
94
95
|
serverTime: number;
|
|
95
96
|
}
|
|
96
97
|
export interface GameFlow {
|
|
98
|
+
revision: WireId | null;
|
|
99
|
+
currentIssueId: WireId;
|
|
100
|
+
lastOpenedIssueId: WireId;
|
|
97
101
|
phase: -2 | -1 | 1 | 2 | 3;
|
|
98
102
|
phaseEndsAt: number;
|
|
99
103
|
bettingOpen: boolean;
|
|
@@ -136,6 +140,8 @@ export interface GameEntry {
|
|
|
136
140
|
serverTime: number;
|
|
137
141
|
}
|
|
138
142
|
export interface GameIssue {
|
|
143
|
+
revision: WireId;
|
|
144
|
+
targetTime: number;
|
|
139
145
|
id: WireId;
|
|
140
146
|
issue: WireId;
|
|
141
147
|
gameId: number;
|
|
@@ -151,7 +157,6 @@ export interface GameIssue {
|
|
|
151
157
|
cashoutCount: number;
|
|
152
158
|
showMillis: number;
|
|
153
159
|
createAt: number | null;
|
|
154
|
-
openAt?: number | null;
|
|
155
160
|
closeAt: number | null;
|
|
156
161
|
startAt: number | null;
|
|
157
162
|
resultPublishedAt: number | null;
|
|
@@ -317,7 +322,7 @@ export interface EventPayloads {
|
|
|
317
322
|
};
|
|
318
323
|
flow: {
|
|
319
324
|
gameRoomId: number;
|
|
320
|
-
flow: GameFlow
|
|
325
|
+
flow: Partial<GameFlow>;
|
|
321
326
|
issue: GameIssue | null;
|
|
322
327
|
lastOpenedIssue: GameIssue | null;
|
|
323
328
|
serverTime: number;
|