@chaos-overlords/kernel 0.1.0

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.
Files changed (104) hide show
  1. package/LICENSE +674 -0
  2. package/NOTICE +21 -0
  3. package/README.md +28 -0
  4. package/dist/domain/entities.d.ts +91 -0
  5. package/dist/domain/entities.d.ts.map +1 -0
  6. package/dist/domain/entities.js +9 -0
  7. package/dist/domain/entities.js.map +1 -0
  8. package/dist/domain/errors.d.ts +30 -0
  9. package/dist/domain/errors.d.ts.map +1 -0
  10. package/dist/domain/errors.js +45 -0
  11. package/dist/domain/errors.js.map +1 -0
  12. package/dist/index.d.ts +7 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +7 -0
  15. package/dist/index.js.map +1 -0
  16. package/dist/logic/canonical-json.d.ts +17 -0
  17. package/dist/logic/canonical-json.d.ts.map +1 -0
  18. package/dist/logic/canonical-json.js +44 -0
  19. package/dist/logic/canonical-json.js.map +1 -0
  20. package/dist/logic/crypto.d.ts +29 -0
  21. package/dist/logic/crypto.d.ts.map +1 -0
  22. package/dist/logic/crypto.js +76 -0
  23. package/dist/logic/crypto.js.map +1 -0
  24. package/dist/logic/index.d.ts +5 -0
  25. package/dist/logic/index.d.ts.map +1 -0
  26. package/dist/logic/index.js +5 -0
  27. package/dist/logic/index.js.map +1 -0
  28. package/dist/logic/password.d.ts +4 -0
  29. package/dist/logic/password.d.ts.map +1 -0
  30. package/dist/logic/password.js +38 -0
  31. package/dist/logic/password.js.map +1 -0
  32. package/dist/logic/turn-logic.d.ts +52 -0
  33. package/dist/logic/turn-logic.d.ts.map +1 -0
  34. package/dist/logic/turn-logic.js +101 -0
  35. package/dist/logic/turn-logic.js.map +1 -0
  36. package/dist/ports/index.d.ts +3 -0
  37. package/dist/ports/index.d.ts.map +1 -0
  38. package/dist/ports/index.js +3 -0
  39. package/dist/ports/index.js.map +1 -0
  40. package/dist/ports/runtime.d.ts +38 -0
  41. package/dist/ports/runtime.d.ts.map +1 -0
  42. package/dist/ports/runtime.js +7 -0
  43. package/dist/ports/runtime.js.map +1 -0
  44. package/dist/ports/storage.d.ts +141 -0
  45. package/dist/ports/storage.d.ts.map +1 -0
  46. package/dist/ports/storage.js +2 -0
  47. package/dist/ports/storage.js.map +1 -0
  48. package/dist/services/AuthService.d.ts +13 -0
  49. package/dist/services/AuthService.d.ts.map +1 -0
  50. package/dist/services/AuthService.js +24 -0
  51. package/dist/services/AuthService.js.map +1 -0
  52. package/dist/services/EventPublisher.d.ts +20 -0
  53. package/dist/services/EventPublisher.d.ts.map +1 -0
  54. package/dist/services/EventPublisher.js +37 -0
  55. package/dist/services/EventPublisher.js.map +1 -0
  56. package/dist/services/LobbyService.d.ts +45 -0
  57. package/dist/services/LobbyService.d.ts.map +1 -0
  58. package/dist/services/LobbyService.js +366 -0
  59. package/dist/services/LobbyService.js.map +1 -0
  60. package/dist/services/MatchQueryService.d.ts +24 -0
  61. package/dist/services/MatchQueryService.d.ts.map +1 -0
  62. package/dist/services/MatchQueryService.js +99 -0
  63. package/dist/services/MatchQueryService.js.map +1 -0
  64. package/dist/services/RateLimiter.d.ts +28 -0
  65. package/dist/services/RateLimiter.d.ts.map +1 -0
  66. package/dist/services/RateLimiter.js +48 -0
  67. package/dist/services/RateLimiter.js.map +1 -0
  68. package/dist/services/RetentionService.d.ts +23 -0
  69. package/dist/services/RetentionService.d.ts.map +1 -0
  70. package/dist/services/RetentionService.js +36 -0
  71. package/dist/services/RetentionService.js.map +1 -0
  72. package/dist/services/SnapshotService.d.ts +40 -0
  73. package/dist/services/SnapshotService.d.ts.map +1 -0
  74. package/dist/services/SnapshotService.js +137 -0
  75. package/dist/services/SnapshotService.js.map +1 -0
  76. package/dist/services/TurnService.d.ts +63 -0
  77. package/dist/services/TurnService.d.ts.map +1 -0
  78. package/dist/services/TurnService.js +392 -0
  79. package/dist/services/TurnService.js.map +1 -0
  80. package/dist/services/createKernel.d.ts +23 -0
  81. package/dist/services/createKernel.d.ts.map +1 -0
  82. package/dist/services/createKernel.js +22 -0
  83. package/dist/services/createKernel.js.map +1 -0
  84. package/dist/services/deps.d.ts +11 -0
  85. package/dist/services/deps.d.ts.map +1 -0
  86. package/dist/services/deps.js +2 -0
  87. package/dist/services/deps.js.map +1 -0
  88. package/dist/services/index.d.ts +10 -0
  89. package/dist/services/index.d.ts.map +1 -0
  90. package/dist/services/index.js +10 -0
  91. package/dist/services/index.js.map +1 -0
  92. package/dist/testing/InMemoryStorage.d.ts +28 -0
  93. package/dist/testing/InMemoryStorage.d.ts.map +1 -0
  94. package/dist/testing/InMemoryStorage.js +279 -0
  95. package/dist/testing/InMemoryStorage.js.map +1 -0
  96. package/dist/testing/fakes.d.ts +38 -0
  97. package/dist/testing/fakes.d.ts.map +1 -0
  98. package/dist/testing/fakes.js +44 -0
  99. package/dist/testing/fakes.js.map +1 -0
  100. package/dist/testing/index.d.ts +3 -0
  101. package/dist/testing/index.d.ts.map +1 -0
  102. package/dist/testing/index.js +3 -0
  103. package/dist/testing/index.js.map +1 -0
  104. package/package.json +68 -0
@@ -0,0 +1,36 @@
1
+ /** Matches that will never be played again, and are therefore the only ones retention may delete. */
2
+ const COLLECTABLE = ['finished', 'abandoned', 'lobby'];
3
+ export const DEFAULT_RETENTION = {
4
+ maxAgeMs: 30 * 24 * 60 * 60 * 1000,
5
+ batchSize: 50,
6
+ };
7
+ /**
8
+ * Deletes the matches nobody will come back to. A coordination server accumulates rows that have no
9
+ * second use — a finished match's order documents, a megabyte of snapshot per desync, the lobby
10
+ * someone opened and never started — and a self-hosted SQLite file has no operator watching it
11
+ * grow. Deleting the match row takes everything it owns with it, because every child table cascades.
12
+ *
13
+ * Running and desynced matches are never in scope at any age: a desync pause is not abandonment.
14
+ */
15
+ export class RetentionService {
16
+ deps;
17
+ policy;
18
+ constructor(deps, policy = DEFAULT_RETENTION) {
19
+ this.deps = deps;
20
+ this.policy = policy;
21
+ }
22
+ async collect() {
23
+ if (this.policy.maxAgeMs <= 0)
24
+ return 0;
25
+ const before = new Date(this.deps.clock.now().getTime() - this.policy.maxAgeMs);
26
+ const deleted = await this.deps.storage.matches.deleteInactive(COLLECTABLE, before, this.policy.batchSize);
27
+ if (deleted > 0) {
28
+ this.deps.logger.info('retention deleted inactive matches', {
29
+ deleted,
30
+ before: before.toISOString(),
31
+ });
32
+ }
33
+ return deleted;
34
+ }
35
+ }
36
+ //# sourceMappingURL=RetentionService.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"RetentionService.js","sourceRoot":"","sources":["../../src/services/RetentionService.ts"],"names":[],"mappings":"AAGA,qGAAqG;AACrG,MAAM,WAAW,GAA2B,CAAC,UAAU,EAAE,WAAW,EAAE,OAAO,CAAC,CAAA;AAS9E,MAAM,CAAC,MAAM,iBAAiB,GAAoB;IAChD,QAAQ,EAAE,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI;IAClC,SAAS,EAAE,EAAE;CACd,CAAA;AAED;;;;;;;GAOG;AACH,MAAM,OAAO,gBAAgB;IAER;IACA;IAFnB,YACmB,IAAsD,EACtD,SAA0B,iBAAiB;QAD3C,SAAI,GAAJ,IAAI,CAAkD;QACtD,WAAM,GAAN,MAAM,CAAqC;IAC3D,CAAC;IAEJ,KAAK,CAAC,OAAO;QACX,IAAI,IAAI,CAAC,MAAM,CAAC,QAAQ,IAAI,CAAC;YAAE,OAAO,CAAC,CAAA;QACvC,MAAM,MAAM,GAAG,IAAI,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,OAAO,EAAE,GAAG,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAA;QAC/E,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,cAAc,CAC5D,WAAW,EACX,MAAM,EACN,IAAI,CAAC,MAAM,CAAC,SAAS,CACtB,CAAA;QACD,IAAI,OAAO,GAAG,CAAC,EAAE,CAAC;YAChB,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,oCAAoC,EAAE;gBAC1D,OAAO;gBACP,MAAM,EAAE,MAAM,CAAC,WAAW,EAAE;aAC7B,CAAC,CAAA;QACJ,CAAC;QACD,OAAO,OAAO,CAAA;IAChB,CAAC;CACF"}
@@ -0,0 +1,40 @@
1
+ import type { SnapshotView, UploadSnapshotRequest } from '@chaos-overlords/contracts';
2
+ import type { Principal } from './AuthService';
3
+ import type { KernelDeps } from './deps';
4
+ import type { EventPublisher } from './EventPublisher';
5
+ import type { TurnService } from './TurnService';
6
+ /**
7
+ * Host-uploaded native snapshots: the recovery path for a desync and the bootstrap for a
8
+ * reconnecting client. The server stores bytes it never decodes; the hash beside them is what
9
+ * a desynced turn's reports are then judged against.
10
+ */
11
+ export declare class SnapshotService {
12
+ private readonly deps;
13
+ private readonly publisher;
14
+ private readonly turns;
15
+ constructor(deps: KernelDeps, publisher: EventPublisher, turns: TurnService);
16
+ upload(principal: Principal, request: UploadSnapshotRequest): Promise<void>;
17
+ /**
18
+ * A recovery snapshot may only claim a state hash that the players themselves already computed
19
+ * in the greatest number.
20
+ *
21
+ * This is what keeps the host from arbitrating a disagreement it is a party to. The snapshot for
22
+ * a desynced turn becomes the hash every other client is told to converge on, so a host free to
23
+ * name any hash could resolve a deliberate desync in favour of a doctored state. A hash that more
24
+ * active players reported than any other cannot be minted by one of them. A tie leaves nothing to
25
+ * count and the host breaks it; turns with no reports at all (a bootstrap snapshot for a
26
+ * reconnecting client) are unconstrained, because there is no consensus to contradict yet.
27
+ */
28
+ private requireCorroboration;
29
+ /**
30
+ * Bound what one live match holds. A snapshot is a megabyte of base64 and a long match can desync
31
+ * many times; retention only collects matches that are over, so without this a single match that
32
+ * runs for months is unbounded growth. Only the recent ones have a use — a desync is repaired
33
+ * from the turn it happened on, and a reconnecting client bootstraps from the newest — so the
34
+ * older ones are dead weight. Failing to prune must never fail the upload that just succeeded.
35
+ */
36
+ private pruneOldSnapshots;
37
+ latest(matchId: string): Promise<SnapshotView>;
38
+ get(matchId: string, turn: number): Promise<SnapshotView>;
39
+ }
40
+ //# sourceMappingURL=SnapshotService.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SnapshotService.d.ts","sourceRoot":"","sources":["../../src/services/SnapshotService.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAA;AAIrF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,eAAe,CAAA;AAC9C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAA;AACxC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AACtD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAQhD;;;;GAIG;AACH,qBAAa,eAAe;IAExB,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,KAAK;gBAFL,IAAI,EAAE,UAAU,EAChB,SAAS,EAAE,cAAc,EACzB,KAAK,EAAE,WAAW;IAG/B,MAAM,CAAC,SAAS,EAAE,SAAS,EAAE,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC;IAuDjF;;;;;;;;;;OAUG;YACW,oBAAoB;IAiBlC;;;;;;OAMG;YACW,iBAAiB;IASzB,MAAM,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;IAM9C,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;CAKhE"}
@@ -0,0 +1,137 @@
1
+ import { ConflictError, ForbiddenError, NotFoundError } from '../domain/errors';
2
+ import { authoritativeCandidates } from '../logic/turn-logic';
3
+ /**
4
+ * Snapshots kept per live match. Enough to cover a desync being repaired while an earlier one is
5
+ * still being fetched by a straggler, and far fewer than a long match would otherwise accumulate.
6
+ */
7
+ const SNAPSHOTS_KEPT_PER_MATCH = 5;
8
+ /**
9
+ * Host-uploaded native snapshots: the recovery path for a desync and the bootstrap for a
10
+ * reconnecting client. The server stores bytes it never decodes; the hash beside them is what
11
+ * a desynced turn's reports are then judged against.
12
+ */
13
+ export class SnapshotService {
14
+ deps;
15
+ publisher;
16
+ turns;
17
+ constructor(deps, publisher, turns) {
18
+ this.deps = deps;
19
+ this.publisher = publisher;
20
+ this.turns = turns;
21
+ }
22
+ async upload(principal, request) {
23
+ const { match, player } = principal;
24
+ if (player.id !== match.hostPlayerId) {
25
+ throw new ForbiddenError('Only the host uploads snapshots', { reason: 'host_only' });
26
+ }
27
+ if (match.status !== 'running' && match.status !== 'desynced') {
28
+ throw new ConflictError('The match is not in progress', { reason: 'match_not_running' });
29
+ }
30
+ if (request.turn > match.currentTurn) {
31
+ throw new ConflictError('Cannot snapshot a turn that has not opened', {
32
+ reason: 'future_turn',
33
+ });
34
+ }
35
+ if (request.turn === match.currentTurn && match.currentTurn > 0) {
36
+ throw new ConflictError('The current turn is still open; snapshot the last sealed turn', {
37
+ reason: 'turn_open',
38
+ });
39
+ }
40
+ // A confirmed turn's state hash is settled consensus. Re-uploading the same bytes is fine (a
41
+ // reconnecting client may need them); contradicting it is not, or the snapshot clients bootstrap
42
+ // from would disagree with the turn they already agreed on.
43
+ const turn = await this.deps.storage.turns.get(match.id, request.turn);
44
+ if (turn?.status === 'confirmed') {
45
+ const existing = await this.deps.storage.snapshots.get(match.id, request.turn);
46
+ if (existing && existing.stateHash !== request.stateHash) {
47
+ throw new ConflictError('Turn already confirmed with a different state hash', {
48
+ reason: 'turn_confirmed',
49
+ stateHash: existing.stateHash,
50
+ });
51
+ }
52
+ }
53
+ await this.requireCorroboration(match.id, request.turn, request.stateHash);
54
+ const snapshot = {
55
+ matchId: match.id,
56
+ turn: request.turn,
57
+ formatVersion: request.formatVersion,
58
+ stateHash: request.stateHash,
59
+ uploadedByPlayerId: player.id,
60
+ uploadedAt: this.deps.clock.now(),
61
+ body: request.body,
62
+ };
63
+ await this.deps.storage.snapshots.put(snapshot);
64
+ await this.pruneOldSnapshots(match.id);
65
+ await this.publisher.publish(match.id, {
66
+ type: 'snapshot.available',
67
+ payload: {
68
+ turn: request.turn,
69
+ formatVersion: request.formatVersion,
70
+ stateHash: request.stateHash,
71
+ uploadedByPlayerId: player.id,
72
+ },
73
+ });
74
+ await this.turns.settle(match.id, request.turn);
75
+ }
76
+ /**
77
+ * A recovery snapshot may only claim a state hash that the players themselves already computed
78
+ * in the greatest number.
79
+ *
80
+ * This is what keeps the host from arbitrating a disagreement it is a party to. The snapshot for
81
+ * a desynced turn becomes the hash every other client is told to converge on, so a host free to
82
+ * name any hash could resolve a deliberate desync in favour of a doctored state. A hash that more
83
+ * active players reported than any other cannot be minted by one of them. A tie leaves nothing to
84
+ * count and the host breaks it; turns with no reports at all (a bootstrap snapshot for a
85
+ * reconnecting client) are unconstrained, because there is no consensus to contradict yet.
86
+ */
87
+ async requireCorroboration(matchId, turn, stateHash) {
88
+ const [players, reports] = await Promise.all([
89
+ this.deps.storage.players.listByMatch(matchId),
90
+ this.deps.storage.turns.listReports(matchId, turn),
91
+ ]);
92
+ const candidates = authoritativeCandidates(players, reports);
93
+ if (candidates.length === 0 || candidates.includes(stateHash))
94
+ return;
95
+ throw new ConflictError('A recovery snapshot must match a state hash the players reported most often', { reason: 'uncorroborated_state_hash', candidateStateHashes: candidates });
96
+ }
97
+ /**
98
+ * Bound what one live match holds. A snapshot is a megabyte of base64 and a long match can desync
99
+ * many times; retention only collects matches that are over, so without this a single match that
100
+ * runs for months is unbounded growth. Only the recent ones have a use — a desync is repaired
101
+ * from the turn it happened on, and a reconnecting client bootstraps from the newest — so the
102
+ * older ones are dead weight. Failing to prune must never fail the upload that just succeeded.
103
+ */
104
+ async pruneOldSnapshots(matchId) {
105
+ try {
106
+ const dropped = await this.deps.storage.snapshots.prune(matchId, SNAPSHOTS_KEPT_PER_MATCH);
107
+ if (dropped > 0)
108
+ this.deps.logger.debug('pruned old snapshots', { matchId, dropped });
109
+ }
110
+ catch (error) {
111
+ this.deps.logger.warn('could not prune old snapshots', { matchId, error: String(error) });
112
+ }
113
+ }
114
+ async latest(matchId) {
115
+ const snapshot = await this.deps.storage.snapshots.getLatest(matchId);
116
+ if (!snapshot)
117
+ throw new NotFoundError('No snapshot uploaded yet', { reason: 'no_snapshot' });
118
+ return toView(snapshot);
119
+ }
120
+ async get(matchId, turn) {
121
+ const snapshot = await this.deps.storage.snapshots.get(matchId, turn);
122
+ if (!snapshot)
123
+ throw new NotFoundError('No snapshot for that turn', { reason: 'no_snapshot' });
124
+ return toView(snapshot);
125
+ }
126
+ }
127
+ function toView(snapshot) {
128
+ return {
129
+ turn: snapshot.turn,
130
+ formatVersion: snapshot.formatVersion,
131
+ stateHash: snapshot.stateHash,
132
+ uploadedByPlayerId: snapshot.uploadedByPlayerId,
133
+ uploadedAt: snapshot.uploadedAt.toISOString(),
134
+ body: snapshot.body,
135
+ };
136
+ }
137
+ //# sourceMappingURL=SnapshotService.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SnapshotService.js","sourceRoot":"","sources":["../../src/services/SnapshotService.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,63 @@
1
+ import { type OwnSubmissionView, type SubmitOrdersRequest, type TurnReportRequest } from '@chaos-overlords/contracts';
2
+ import { type Match } from '../domain/entities';
3
+ import type { Principal } from './AuthService';
4
+ import type { KernelDeps } from './deps';
5
+ import type { EventPublisher } from './EventPublisher';
6
+ export type SealTrigger = 'ready' | 'deadline';
7
+ /** Turns are numbered from 1; 0 is the lobby's `currentTurn`, before any turn exists. */
8
+ export declare const FIRST_TURN = 1;
9
+ /**
10
+ * The simultaneous-turn barrier. Players submit orders privately; the turn seals when every
11
+ * active player is ready or the deadline passes; the sealed set becomes readable and the next
12
+ * turn opens at once. Clients resolve the turn locally and report the resulting state hash, and
13
+ * the server confirms the turn on agreement or flags a desync.
14
+ *
15
+ * Every state change here is a compare-and-swap, so concurrent callers (the last player's ready
16
+ * racing the timer, two players' reports landing together) produce exactly one seal and one
17
+ * verdict. Sealing is a CAS followed by `completeSeal`, which is idempotent from any point: that is
18
+ * what lets `sweep` finish a seal whose process died halfway through instead of stranding the match.
19
+ */
20
+ export declare class TurnService {
21
+ private readonly deps;
22
+ private readonly publisher;
23
+ constructor(deps: KernelDeps, publisher: EventPublisher);
24
+ submitOrders(principal: Principal, number: number, request: SubmitOrdersRequest): Promise<OwnSubmissionView>;
25
+ /** Seal `number` if its trigger condition holds. Returns whether THIS call sealed it. */
26
+ trySeal(matchId: string, number: number, trigger: SealTrigger): Promise<boolean>;
27
+ /**
28
+ * Everything that follows the seal's compare-and-swap: freeze the participant set and its digest,
29
+ * announce it, and open the next turn. Each step is conditional on its own predecessor, so a call
30
+ * on a turn that is already complete changes nothing and a call on one that stopped halfway
31
+ * finishes it. Orders are immutable from the CAS onwards, so the digest it derives is stable.
32
+ */
33
+ private completeSeal;
34
+ /**
35
+ * Opens turn `number` for every active player and arms its deadline. Idempotent: the insert is
36
+ * refused if the turn already exists, and only the caller that created it announces it. Returns
37
+ * whether this call created the turn.
38
+ */
39
+ openTurn(match: Match, number: number): Promise<boolean>;
40
+ /**
41
+ * The safety net behind the scheduler and behind any interrupted seal: seal every open turn whose
42
+ * deadline has passed, then finish every seal that stopped before opening its successor. Runtimes
43
+ * call this on an interval (Node) or a cron (Cloudflare).
44
+ */
45
+ sweep(limit?: number): Promise<{
46
+ sealed: number;
47
+ repaired: number;
48
+ }>;
49
+ report(principal: Principal, number: number, request: TurnReportRequest): Promise<void>;
50
+ /** Authenticated turn activity wins the race with an AI vote and restores the human seat. */
51
+ private restorePendingPlayer;
52
+ /** Re-run the verdict of every unconfirmed turn and the auto-seal of the open one. */
53
+ reevaluate(matchId: string): Promise<void>;
54
+ /** Decide turn `number` from the reports on file. Idempotent and race-safe through CAS. */
55
+ settle(matchId: string, number: number): Promise<void>;
56
+ /**
57
+ * Lift a desync pause once nothing is unsettled. Orders were refused for the whole pause while
58
+ * the open turn's deadline kept running, so the turn would otherwise seal empty the moment the
59
+ * match resumes: its clock restarts here, and clients are told the new deadline.
60
+ */
61
+ private resumeAfterDesync;
62
+ }
63
+ //# sourceMappingURL=TurnService.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TurnService.d.ts","sourceRoot":"","sources":["../../src/services/TurnService.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,iBAAiB,EACtB,KAAK,mBAAmB,EACxB,KAAK,iBAAiB,EACvB,MAAM,4BAA4B,CAAA;AACnC,OAAO,EAAoC,KAAK,KAAK,EAAmB,MAAM,oBAAoB,CAAA;AAIlG,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,eAAe,CAAA;AAC9C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAA;AACxC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AAEtD,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,UAAU,CAAA;AAE9C,yFAAyF;AACzF,eAAO,MAAM,UAAU,IAAI,CAAA;AAE3B;;;;;;;;;;GAUG;AACH,qBAAa,WAAW;IAEpB,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,SAAS;gBADT,IAAI,EAAE,UAAU,EAChB,SAAS,EAAE,cAAc;IAGtC,YAAY,CAChB,SAAS,EAAE,SAAS,EACpB,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,iBAAiB,CAAC;IAmC7B,yFAAyF;IACnF,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC;IA0BtF;;;;;OAKG;YACW,YAAY;IAyD1B;;;;OAIG;IACG,QAAQ,CAAC,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAmC9D;;;;OAIG;IACG,KAAK,CAAC,KAAK,SAAM,GAAG,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IAqBjE,MAAM,CAAC,SAAS,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAyB7F,6FAA6F;YAC/E,oBAAoB;IASlC,sFAAsF;IAChF,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAShD,2FAA2F;IACrF,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IA2E5D;;;;OAIG;YACW,iBAAiB;CA6BhC"}