@chaos-overlords/kernel 0.9.5 → 0.9.7
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/LICENSE +21 -674
- package/NOTICE +9 -12
- package/README.md +1 -1
- package/dist/domain/entities.d.ts +13 -0
- package/dist/domain/entities.d.ts.map +1 -1
- package/dist/domain/entities.js.map +1 -1
- package/dist/logic/canonical-json.js +28 -11
- package/dist/logic/canonical-json.js.map +1 -1
- package/dist/ports/storage.d.ts +79 -9
- package/dist/ports/storage.d.ts.map +1 -1
- package/dist/services/EventPublisher.d.ts +11 -0
- package/dist/services/EventPublisher.d.ts.map +1 -1
- package/dist/services/EventPublisher.js +19 -1
- package/dist/services/EventPublisher.js.map +1 -1
- package/dist/services/LobbyService.d.ts.map +1 -1
- package/dist/services/LobbyService.js +5 -1
- package/dist/services/LobbyService.js.map +1 -1
- package/dist/services/MatchQueryService.d.ts +4 -1
- package/dist/services/MatchQueryService.d.ts.map +1 -1
- package/dist/services/MatchQueryService.js +5 -2
- package/dist/services/MatchQueryService.js.map +1 -1
- package/dist/services/SnapshotService.d.ts +16 -5
- package/dist/services/SnapshotService.d.ts.map +1 -1
- package/dist/services/SnapshotService.js +26 -7
- package/dist/services/SnapshotService.js.map +1 -1
- package/dist/services/TurnService.d.ts +74 -2
- package/dist/services/TurnService.d.ts.map +1 -1
- package/dist/services/TurnService.js +329 -85
- package/dist/services/TurnService.js.map +1 -1
- package/dist/testing/InMemoryStorage.d.ts +2 -0
- package/dist/testing/InMemoryStorage.d.ts.map +1 -1
- package/dist/testing/InMemoryStorage.js +91 -9
- package/dist/testing/InMemoryStorage.js.map +1 -1
- package/package.json +3 -3
|
@@ -5,7 +5,7 @@ import { hashOrderDocument, hashOrderSet } from '../logic/crypto';
|
|
|
5
5
|
import { allAwaitedReady, assignSlots, awaitedSeats, evaluateConsensus, sealedByDeadline, turnDeadline, } from '../logic/turn-logic';
|
|
6
6
|
import { requireInProgress, requireParticipant, requireTurn } from './guards';
|
|
7
7
|
import { matchStartedEvent } from './MatchQueryService';
|
|
8
|
-
import {
|
|
8
|
+
import { publishSeatSummaries } from './SnapshotService';
|
|
9
9
|
/** Turns are numbered from 1; 0 is the lobby's `currentTurn`, before any turn exists. */
|
|
10
10
|
export const FIRST_TURN = 1;
|
|
11
11
|
/**
|
|
@@ -144,26 +144,38 @@ export class TurnService {
|
|
|
144
144
|
}
|
|
145
145
|
/** Seal `number` if its trigger condition holds. Returns whether THIS call sealed it. */
|
|
146
146
|
async trySeal(matchId, number, trigger) {
|
|
147
|
+
const match = await this.claimSeal(matchId, number, trigger);
|
|
148
|
+
if (!match)
|
|
149
|
+
return false;
|
|
150
|
+
await this.completeSeal(match, number);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* The CAS half of {@link trySeal}: move `number` from open to sealed if its trigger condition
|
|
155
|
+
* holds, and answer the match it was sealed in, or null when this call did not seal it. The
|
|
156
|
+
* caller owes the seal its `completeSeal`, which the sweep also finishes if the caller cannot.
|
|
157
|
+
*/
|
|
158
|
+
async claimSeal(matchId, number, trigger) {
|
|
147
159
|
const match = await this.deps.storage.matches.get(matchId);
|
|
148
160
|
if (match?.status !== 'running')
|
|
149
|
-
return
|
|
161
|
+
return null;
|
|
150
162
|
const turn = await this.deps.storage.turns.get(matchId, number);
|
|
151
163
|
if (turn?.status !== 'open')
|
|
152
|
-
return
|
|
164
|
+
return null;
|
|
153
165
|
if (trigger === 'deadline') {
|
|
154
166
|
if (turn.deadlineAt === null)
|
|
155
|
-
return
|
|
167
|
+
return null;
|
|
156
168
|
const now = this.deps.clock.now();
|
|
157
169
|
if (turn.deadlineAt.getTime() > now.getTime()) {
|
|
158
170
|
await this.rearmEarlyDeadline(matchId, number, turn.deadlineAt, now);
|
|
159
|
-
return
|
|
171
|
+
return null;
|
|
160
172
|
}
|
|
161
173
|
// A second guard behind `pauseAbandonedMatch`: a deadline that survived it, or one armed
|
|
162
174
|
// before this rule existed, must not go on sealing empty turns in a match nobody is in.
|
|
163
175
|
const roster = await this.deps.storage.players.listByMatch(matchId);
|
|
164
176
|
if (activePlayers(roster).length === 0) {
|
|
165
177
|
await this.clearOpenDeadline(matchId);
|
|
166
|
-
return
|
|
178
|
+
return null;
|
|
167
179
|
}
|
|
168
180
|
}
|
|
169
181
|
else {
|
|
@@ -172,17 +184,69 @@ export class TurnService {
|
|
|
172
184
|
this.deps.storage.turns.listOrderSummaries(matchId, number),
|
|
173
185
|
]);
|
|
174
186
|
if (!allAwaitedReady(awaited, orders))
|
|
175
|
-
return
|
|
187
|
+
return null;
|
|
176
188
|
}
|
|
177
189
|
const won = await this.deps.storage.turns.transition(matchId, number, ['open'], {
|
|
178
190
|
status: 'sealed',
|
|
179
191
|
sealedAt: this.deps.clock.now(),
|
|
180
192
|
});
|
|
181
193
|
if (!won)
|
|
182
|
-
return
|
|
194
|
+
return null;
|
|
183
195
|
this.deps.logger.info('turn sealed', { matchId, turn: number, trigger });
|
|
184
|
-
|
|
185
|
-
|
|
196
|
+
return match;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Seal the open turn of `match` if its deadline has already passed, and answer the match as it
|
|
200
|
+
* stands afterwards.
|
|
201
|
+
*
|
|
202
|
+
* The timer and the sweep are what normally seal a turn on its deadline, and either can come up
|
|
203
|
+
* short: a timer lost to a restart or replaced by a stale re-arm waits on the sweep, and on
|
|
204
|
+
* Cloudflare the sweep is a cron that runs every few minutes, if it was configured at all. For
|
|
205
|
+
* that whole time every client showed a countdown that had reached zero and a turn that never
|
|
206
|
+
* sealed, until somebody gave up and ended it themselves. A client that notices is resynchronising
|
|
207
|
+
* already, and the match view it reads to do so is the natural place to finish the job: a turn is
|
|
208
|
+
* never sealed early by this, only one the server is already late with.
|
|
209
|
+
*
|
|
210
|
+
* Best effort. A seal that fails here is still owed by the timer and the sweep, and the read the
|
|
211
|
+
* caller came for must not fail with it.
|
|
212
|
+
*
|
|
213
|
+
* The match is read again whenever the turn it names is no longer the open one: a seal that
|
|
214
|
+
* somebody else is finishing — the timer, the sweep, or a ready seal that won the race with this
|
|
215
|
+
* call — has moved the match on since the caller loaded it, and a view built from that copy
|
|
216
|
+
* would name the sealed turn as current beside a log that already carries its seal.
|
|
217
|
+
*/
|
|
218
|
+
async sealIfOverdue(match) {
|
|
219
|
+
// An untimed match never has a deadline to be late with, so it costs the read no query.
|
|
220
|
+
if (match.status !== 'running' || match.settings.turnTimerSeconds === 0)
|
|
221
|
+
return match;
|
|
222
|
+
// Whether this call won the CAS, so a failure after it is not reported as a seal that failed:
|
|
223
|
+
// the turn is sealed by then, and only its completion is left to the sweep.
|
|
224
|
+
let claimed = false;
|
|
225
|
+
try {
|
|
226
|
+
const turn = await this.deps.storage.turns.get(match.id, match.currentTurn);
|
|
227
|
+
if (turn?.status === 'open') {
|
|
228
|
+
if (turn.deadlineAt === null)
|
|
229
|
+
return match;
|
|
230
|
+
if (turn.deadlineAt.getTime() > this.deps.clock.now().getTime())
|
|
231
|
+
return match;
|
|
232
|
+
const sealedIn = await this.claimSeal(match.id, match.currentTurn, 'deadline');
|
|
233
|
+
if (sealedIn) {
|
|
234
|
+
claimed = true;
|
|
235
|
+
this.deps.logger.warn('sealed an overdue turn on read', {
|
|
236
|
+
matchId: match.id,
|
|
237
|
+
turn: match.currentTurn,
|
|
238
|
+
});
|
|
239
|
+
await this.completeSeal(sealedIn, match.currentTurn);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return (await this.deps.storage.matches.get(match.id)) ?? match;
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
this.deps.logger.warn(claimed
|
|
246
|
+
? 'sealed an overdue turn on read but could not complete it; the sweep finishes it'
|
|
247
|
+
: 'could not seal an overdue turn on read', { matchId: match.id, turn: match.currentTurn, error: String(error) });
|
|
248
|
+
return match;
|
|
249
|
+
}
|
|
186
250
|
}
|
|
187
251
|
/**
|
|
188
252
|
* Put a deadline timer that fired before its deadline back on the clock.
|
|
@@ -241,6 +305,13 @@ export class TurnService {
|
|
|
241
305
|
* announce it, and open the next turn. Each step is conditional on its own predecessor, so a call
|
|
242
306
|
* on a turn that is already complete changes nothing and a call on one that stopped halfway
|
|
243
307
|
* finishes it. Orders are immutable from the CAS onwards, so the digest it derives is stable.
|
|
308
|
+
*
|
|
309
|
+
* The announcement is made by every caller that gets this far, not only by the one that froze
|
|
310
|
+
* the set, and it is keyed (`publishOnce`) so the log carries it once. The freeze's winner used to
|
|
311
|
+
* be the only publisher, so an append that threw after the freeze lost `turn.sealed` for good:
|
|
312
|
+
* the sweep found the stalled seal, saw the set frozen, opened the successor without announcing
|
|
313
|
+
* the seal, and every client waited on it forever. Announcing before `openTurn` also makes an
|
|
314
|
+
* open successor proof that its predecessor's seal is in the log, whichever caller opened it.
|
|
244
315
|
*/
|
|
245
316
|
async completeSeal(match, number) {
|
|
246
317
|
const turn = await this.deps.storage.turns.get(match.id, number);
|
|
@@ -256,7 +327,8 @@ export class TurnService {
|
|
|
256
327
|
return this.openTurn(match, Math.max(number, FIRST_TURN));
|
|
257
328
|
}
|
|
258
329
|
let advanced = false;
|
|
259
|
-
|
|
330
|
+
let orderSetHash = turn.orderSetHash;
|
|
331
|
+
if (orderSetHash === null || turn.sealedSlots === null) {
|
|
260
332
|
const [orders, players] = await Promise.all([
|
|
261
333
|
this.deps.storage.turns.listOrderSummaries(match.id, number),
|
|
262
334
|
this.deps.storage.players.listByMatch(match.id),
|
|
@@ -265,13 +337,21 @@ export class TurnService {
|
|
|
265
337
|
// Only a clock that ran out can make anyone absent. A ready seal is decided on the rows it
|
|
266
338
|
// read, every one of them ready; an empty row it finds now was topped up for a late seat in
|
|
267
339
|
// the window between that read and the seal, and that player was never given a chance.
|
|
268
|
-
|
|
340
|
+
//
|
|
341
|
+
// A seat already `takeoverPending` is asked about as well. This step runs again whenever an
|
|
342
|
+
// earlier attempt threw, and a throw between the status change below and the prompt's
|
|
343
|
+
// announcement left the seat pending with its question never put to anybody:
|
|
344
|
+
// `openTakeoverPrompt` is idempotent, and finishes exactly that.
|
|
345
|
+
const absentees = sealedByDeadline(turn)
|
|
346
|
+
? players.filter((player) => player.status === 'active' || player.status === 'takeoverPending')
|
|
347
|
+
: [];
|
|
269
348
|
for (const player of absentees) {
|
|
270
349
|
// A seat with no row at all (a late joiner seated after this turn opened) was never
|
|
271
350
|
// asked for orders, so it is not absent; only a row that stayed empty is.
|
|
272
351
|
if (submitted.has(player.id) || !orders.some((row) => row.playerId === player.id))
|
|
273
352
|
continue;
|
|
274
|
-
if (
|
|
353
|
+
if (player.status === 'takeoverPending' ||
|
|
354
|
+
(await this.deps.storage.players.transitionStatus(player.id, ['active'], 'takeoverPending'))) {
|
|
275
355
|
await this.openTakeoverPrompt(match.id, player.id, number);
|
|
276
356
|
}
|
|
277
357
|
}
|
|
@@ -286,23 +366,31 @@ export class TurnService {
|
|
|
286
366
|
sealedSlots.push({ playerId: row.playerId, slot });
|
|
287
367
|
entries.push({ slot, ordersHash: row.ordersHash });
|
|
288
368
|
}
|
|
289
|
-
const
|
|
369
|
+
const computed = await hashOrderSet(entries);
|
|
290
370
|
// Conditional on the set not being frozen already, NOT on the status: the status stays
|
|
291
371
|
// `sealed` for the whole window this branch runs in, and the sweep deliberately runs
|
|
292
372
|
// `completeSeal` against a seal in flight. Two callers with a departure between their two
|
|
293
373
|
// computations would otherwise each write a different digest and each announce it, and the
|
|
294
374
|
// client that fetched the set after the overwrite failed its digest check.
|
|
295
|
-
|
|
296
|
-
orderSetHash,
|
|
375
|
+
if (await this.deps.storage.turns.freezeSeal(match.id, number, {
|
|
376
|
+
orderSetHash: computed,
|
|
297
377
|
sealedSlots,
|
|
298
|
-
})
|
|
299
|
-
if (frozen) {
|
|
378
|
+
})) {
|
|
300
379
|
advanced = true;
|
|
301
|
-
|
|
302
|
-
type: 'turn.sealed',
|
|
303
|
-
payload: { turn: number, orderSetHash },
|
|
304
|
-
});
|
|
380
|
+
orderSetHash = computed;
|
|
305
381
|
}
|
|
382
|
+
else {
|
|
383
|
+
// Another caller froze it first: announce the digest that stands, never this one.
|
|
384
|
+
orderSetHash = (await this.deps.storage.turns.get(match.id, number))?.orderSetHash ?? null;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (orderSetHash !== null) {
|
|
388
|
+
const announced = await this.publisher.publishOnce(match.id, sealAnnouncementKey(number), {
|
|
389
|
+
type: 'turn.sealed',
|
|
390
|
+
payload: { turn: number, orderSetHash },
|
|
391
|
+
});
|
|
392
|
+
if (announced)
|
|
393
|
+
advanced = true;
|
|
306
394
|
}
|
|
307
395
|
return (await this.openTurn(match, number + 1)) || advanced;
|
|
308
396
|
}
|
|
@@ -371,6 +459,7 @@ export class TurnService {
|
|
|
371
459
|
sealedSlots: null,
|
|
372
460
|
stateHash: null,
|
|
373
461
|
desyncedAt: null,
|
|
462
|
+
settledAt: null,
|
|
374
463
|
};
|
|
375
464
|
const created = await this.deps.storage.turns.open(turn, players.map((player) => player.id));
|
|
376
465
|
// A desynced match counts: a repaired seal must still point `currentTurn` at the turn that is
|
|
@@ -450,6 +539,16 @@ export class TurnService {
|
|
|
450
539
|
repaired += 1;
|
|
451
540
|
}
|
|
452
541
|
}
|
|
542
|
+
// A confirmation cut short after its compare-and-swap: `turn.confirmed` unannounced, or a
|
|
543
|
+
// finished match still `running`. `settle` finishes the follow-ups of a confirmed turn whose
|
|
544
|
+
// `settledAt` is still null, and nothing else ever looks at a confirmed turn again.
|
|
545
|
+
for (const { matchId, number } of await this.deps.storage.turns.listUnannouncedVerdicts(limit)) {
|
|
546
|
+
const finished = await this.guard(matchId, number, () => this.settle(matchId, number));
|
|
547
|
+
if (finished) {
|
|
548
|
+
this.deps.logger.warn('finished an interrupted verdict', { matchId, turn: number });
|
|
549
|
+
repaired += 1;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
453
552
|
// A verdict cut short after its compare-and-swap leaves the match `desynced` with nothing
|
|
454
553
|
// unsettled, which no other path revisits.
|
|
455
554
|
for (const matchId of await this.deps.storage.matches.listDesynced(limit, touchedSince)) {
|
|
@@ -507,7 +606,7 @@ export class TurnService {
|
|
|
507
606
|
// with the host's report keeps late-join selection current without uploading another full save.
|
|
508
607
|
if (player.id === match.hostPlayerId && request.seatSummaries !== undefined) {
|
|
509
608
|
try {
|
|
510
|
-
await this.deps
|
|
609
|
+
await publishSeatSummaries(this.deps, match.id, request.seatSummaries);
|
|
511
610
|
}
|
|
512
611
|
catch (error) {
|
|
513
612
|
// Late-join hints are optional metadata. A full settings blob or a transient metadata write
|
|
@@ -552,14 +651,36 @@ export class TurnService {
|
|
|
552
651
|
*/
|
|
553
652
|
async openTakeoverPrompt(matchId, playerId, turn) {
|
|
554
653
|
const opened = await this.deps.storage.takeovers.openPrompt(matchId, playerId, turn, this.deps.clock.now());
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
654
|
+
// The announcement is owed by the prompt, not by the call that inserted it. The inserting call
|
|
655
|
+
// used to be the only publisher, so a publish that threw after the insert left a prompt that
|
|
656
|
+
// held the turn's clock and that no client was ever shown: every later call found it open and
|
|
657
|
+
// returned. A prompt still unstamped is announced by whichever call finds it — the repeated seal
|
|
658
|
+
// step, a rejoin re-asking about absent seats, a vote on the seat — keyed so racing callers log
|
|
659
|
+
// it once, and stamped only after the event is durable.
|
|
660
|
+
const prompt = await this.deps.storage.takeovers.getPrompt(matchId, playerId);
|
|
661
|
+
if (prompt === null || prompt.announcedAt !== null)
|
|
662
|
+
return opened;
|
|
663
|
+
const logged = await this.publisher.publishOnce(matchId, promptAnnouncementKey(playerId, prompt.openedAt), { type: 'match.takeoverVoteRequested', payload: { playerId, turn: prompt.turn } });
|
|
664
|
+
// The prompt was read a statement before the event was written, and nothing holds it open in
|
|
665
|
+
// between: the seat can report or rejoin, closing it and logging that, before the request is
|
|
666
|
+
// logged behind the close. Clients replay the log in order and would put up a vote about a seat
|
|
667
|
+
// that is active again, so a request that turns out to have followed its own close is withdrawn
|
|
668
|
+
// behind it. Only a seat that came back closes an unannounced prompt: a vote to the computer is
|
|
669
|
+
// cast against a prompt some client was shown, and none was shown this one.
|
|
670
|
+
if (logged !== null &&
|
|
671
|
+
(await this.deps.storage.takeovers.getPrompt(matchId, playerId)) === null) {
|
|
672
|
+
await this.publisher.publishOnce(matchId, promptWithdrawalKey(playerId, prompt.openedAt), {
|
|
673
|
+
type: 'match.takeoverVoteCancelled',
|
|
674
|
+
payload: { playerId },
|
|
675
|
+
});
|
|
676
|
+
return opened;
|
|
677
|
+
}
|
|
561
678
|
await this.clearOpenDeadline(matchId);
|
|
562
|
-
|
|
679
|
+
// The same close, landing after the check above but before the clock was stopped, restarted
|
|
680
|
+
// a clock that was still running and left this call to stop it behind no prompt at all.
|
|
681
|
+
await this.resumeAfterTakeoverVotes(matchId);
|
|
682
|
+
await this.deps.storage.takeovers.markPromptAnnounced(matchId, playerId, prompt.openedAt, this.deps.clock.now());
|
|
683
|
+
return opened;
|
|
563
684
|
}
|
|
564
685
|
/**
|
|
565
686
|
* Stop the clock of a match whose last active player has just left.
|
|
@@ -634,23 +755,37 @@ export class TurnService {
|
|
|
634
755
|
}
|
|
635
756
|
await this.trySeal(matchId, match.currentTurn, 'ready');
|
|
636
757
|
}
|
|
637
|
-
/**
|
|
758
|
+
/**
|
|
759
|
+
* Decide turn `number` from the reports on file. Idempotent and race-safe through CAS. Returns
|
|
760
|
+
* whether this call finished anything a confirmation cut short (see `finishConfirmation`), which
|
|
761
|
+
* is what the sweep reports as a repair.
|
|
762
|
+
*/
|
|
638
763
|
async settle(matchId, number) {
|
|
639
764
|
const [match, turn] = await Promise.all([
|
|
640
765
|
this.deps.storage.matches.get(matchId),
|
|
641
766
|
this.deps.storage.turns.get(matchId, number),
|
|
642
767
|
]);
|
|
643
768
|
if (!match || !turn || turn.status === 'open')
|
|
644
|
-
return;
|
|
769
|
+
return false;
|
|
645
770
|
if (turn.status === 'confirmed') {
|
|
646
771
|
// The compare-and-swap already happened; what may not have is what follows it. A process that
|
|
647
|
-
// died between the two
|
|
648
|
-
//
|
|
649
|
-
//
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
772
|
+
// died between the two, or a publish that threw, left `turn.confirmed` unannounced and a
|
|
773
|
+
// finished match `running` for good, since nothing else revisits a confirmed turn.
|
|
774
|
+
// `settledAt` says whether the follow-ups completed, and they are all safe to repeat.
|
|
775
|
+
if (turn.settledAt === null && turn.stateHash !== null) {
|
|
776
|
+
const reports = await this.deps.storage.turns.listReports(matchId, number);
|
|
777
|
+
const verdict = {
|
|
778
|
+
stateHash: turn.stateHash,
|
|
779
|
+
finished: confirmedFinished(reports, turn.stateHash),
|
|
780
|
+
};
|
|
781
|
+
return this.finishConfirmation(match, number, verdict, true);
|
|
782
|
+
}
|
|
783
|
+
// A desync pause lifted by this verdict whose lift was cut short: `resumeAfterDesync` is
|
|
784
|
+
// private and `reevaluate` only visits sealed and desynced turns, so this is the way back.
|
|
785
|
+
if (match.status === 'desynced') {
|
|
786
|
+
return this.resumeAfterDesync(matchId, this.deps.clock.now());
|
|
787
|
+
}
|
|
788
|
+
return false;
|
|
654
789
|
}
|
|
655
790
|
const [players, reports, snapshot] = await Promise.all([
|
|
656
791
|
this.deps.storage.players.listByMatch(matchId),
|
|
@@ -667,29 +802,9 @@ export class TurnService {
|
|
|
667
802
|
stateHash: verdict.stateHash,
|
|
668
803
|
});
|
|
669
804
|
if (!won)
|
|
670
|
-
return;
|
|
671
|
-
await this.
|
|
672
|
-
|
|
673
|
-
payload: { turn: number, stateHash: verdict.stateHash },
|
|
674
|
-
});
|
|
675
|
-
if (match.status === 'desynced')
|
|
676
|
-
await this.resumeAfterDesync(matchId, now);
|
|
677
|
-
if (verdict.finished &&
|
|
678
|
-
(await this.deps.storage.matches.transition(matchId, ['running', 'desynced'], {
|
|
679
|
-
status: 'finished',
|
|
680
|
-
updatedAt: now,
|
|
681
|
-
}))) {
|
|
682
|
-
// The seal opened the successor before anyone had reported, so a finished match is left
|
|
683
|
-
// holding an open turn with a live deadline. Nothing can be submitted to it (every write
|
|
684
|
-
// requires a running match) but it would sit in `listExpiredOpen` forever, and a client
|
|
685
|
-
// reading the match view would see a turn still counting down after the game ended.
|
|
686
|
-
await this.deps.storage.turns.rescheduleDeadline(matchId, turn.number + 1, null);
|
|
687
|
-
await this.publisher.publish(matchId, {
|
|
688
|
-
type: 'match.statusChanged',
|
|
689
|
-
payload: { status: 'finished' },
|
|
690
|
-
});
|
|
691
|
-
}
|
|
692
|
-
return;
|
|
805
|
+
return false;
|
|
806
|
+
await this.finishConfirmation(match, number, verdict, false);
|
|
807
|
+
return false;
|
|
693
808
|
}
|
|
694
809
|
if (verdict.kind === 'desynced') {
|
|
695
810
|
// Each consequence is conditional on its own state rather than on winning the transition, so
|
|
@@ -697,18 +812,27 @@ export class TurnService {
|
|
|
697
812
|
// left the match `running` with the turn already `desynced`: `turn.desynced` was never
|
|
698
813
|
// announced, `SnapshotService.upload` refused the repair with `snapshot_not_required` because
|
|
699
814
|
// the match was not desynced, and the turn stayed unsettled for good.
|
|
700
|
-
const
|
|
815
|
+
const retry = turn.status === 'desynced';
|
|
816
|
+
const won = retry ||
|
|
701
817
|
(await this.deps.storage.turns.transition(matchId, number, ['sealed'], {
|
|
702
818
|
status: 'desynced',
|
|
703
819
|
}));
|
|
704
820
|
if (!won)
|
|
705
|
-
return;
|
|
821
|
+
return false;
|
|
706
822
|
this.deps.logger.warn('turn desynced', { matchId, turn: number });
|
|
707
|
-
// The
|
|
708
|
-
//
|
|
709
|
-
//
|
|
710
|
-
|
|
711
|
-
|
|
823
|
+
// The pause comes before the announcement. A desynced match is what the sweep re-judges
|
|
824
|
+
// (`listDesynced`), so whatever throws after this point is retried; a `running` match with a
|
|
825
|
+
// desynced turn is revisited by nobody, and it went on sealing turns past the divergence.
|
|
826
|
+
await this.deps.storage.matches.transition(matchId, ['running'], {
|
|
827
|
+
status: 'desynced',
|
|
828
|
+
updatedAt: now,
|
|
829
|
+
});
|
|
830
|
+
// `desyncedAt` is the announcement's receipt, stamped once `turn.desynced` is durable, so a
|
|
831
|
+
// null one means the announcement is still owed. It used to be claimed first and published
|
|
832
|
+
// after, and a publish that threw in between lost the announcement for good. The event is
|
|
833
|
+
// keyed, so racing verdicts and repeats log it once.
|
|
834
|
+
if (turn.desyncedAt === null) {
|
|
835
|
+
await this.publisher.publishOnce(matchId, desyncAnnouncementKey(number), {
|
|
712
836
|
type: 'turn.desynced',
|
|
713
837
|
payload: {
|
|
714
838
|
turn: number,
|
|
@@ -716,39 +840,131 @@ export class TurnService {
|
|
|
716
840
|
candidateStateHashes: verdict.candidateStateHashes,
|
|
717
841
|
},
|
|
718
842
|
});
|
|
843
|
+
await this.deps.storage.turns.claimDesyncAnnouncement(matchId, number, now);
|
|
719
844
|
}
|
|
720
|
-
|
|
721
|
-
|
|
845
|
+
// Not behind the receipt above: a turn stamped by an older build that died before pausing
|
|
846
|
+
// the match is paused only now, and its pause is still owed to clients.
|
|
847
|
+
await this.announcePause(matchId);
|
|
848
|
+
}
|
|
849
|
+
return false;
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Tell clients a match is paused, unless the log's last word on its status already says so.
|
|
853
|
+
*
|
|
854
|
+
* The pause is one fact however many turns diverge behind it, and the turn whose verdict paused
|
|
855
|
+
* the match cannot be told from the others once that verdict is cut short, so the owed
|
|
856
|
+
* announcement is judged from the log rather than from the verdict. Only a match that is still
|
|
857
|
+
* paused is announced as paused; a pause lifted while this ran is announced as lifted as well,
|
|
858
|
+
* since the lift saw no pause to announce and so said nothing.
|
|
859
|
+
*/
|
|
860
|
+
async announcePause(matchId) {
|
|
861
|
+
if ((await this.deps.storage.matches.get(matchId))?.status !== 'desynced')
|
|
862
|
+
return;
|
|
863
|
+
if (!(await this.announceStatus(matchId, 'desynced')))
|
|
864
|
+
return;
|
|
865
|
+
if ((await this.deps.storage.matches.get(matchId))?.status === 'running') {
|
|
866
|
+
await this.announceStatus(matchId, 'running');
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Log `match.statusChanged` for a pause or its lift when it is owed: a pause when the last status
|
|
871
|
+
* announced is anything but a pause, a lift only when it is the pause (a match never paused, or
|
|
872
|
+
* whose pause was never announced, has nothing to lift in any client's eyes). Keyed by the status
|
|
873
|
+
* event it follows, so racing callers who read the same last word log the change once. Returns
|
|
874
|
+
* whether this call logged it.
|
|
875
|
+
*/
|
|
876
|
+
async announceStatus(matchId, status) {
|
|
877
|
+
const last = await this.deps.storage.events.latestOfType(matchId, 'match.statusChanged');
|
|
878
|
+
const lastStatus = last?.type === 'match.statusChanged' ? last.payload.status : null;
|
|
879
|
+
const owed = status === 'desynced' ? lastStatus !== 'desynced' : lastStatus === 'desynced';
|
|
880
|
+
if (!owed)
|
|
881
|
+
return false;
|
|
882
|
+
const logged = await this.publisher.publishOnce(matchId, statusAnnouncementKey(status, last?.seq ?? 0), { type: 'match.statusChanged', payload: { status } });
|
|
883
|
+
return logged !== null;
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Everything a confirmation owes after its compare-and-swap: announce it, lift a desync pause it
|
|
887
|
+
* ends, finish a match it completes, then stamp `settledAt`. Every step is conditional on its own
|
|
888
|
+
* state or keyed in the log, so a repeat — by the sweep, over a verdict cut short — finishes what
|
|
889
|
+
* the first attempt left and duplicates nothing. Returns whether this call logged or changed
|
|
890
|
+
* anything, which for a repeat means it found work the first attempt left.
|
|
891
|
+
*/
|
|
892
|
+
async finishConfirmation(match, number, { stateHash, finished }, repeat) {
|
|
893
|
+
const matchId = match.id;
|
|
894
|
+
const now = this.deps.clock.now();
|
|
895
|
+
let worked = (await this.publisher.publishOnce(matchId, confirmationKey(number), {
|
|
896
|
+
type: 'turn.confirmed',
|
|
897
|
+
payload: { turn: number, stateHash },
|
|
898
|
+
})) !== null;
|
|
899
|
+
// A repeat looks even at a running match: the attempt it completes may have lifted the pause
|
|
900
|
+
// and died before announcing the lift or restarting the clock, and `resumeAfterDesync` judges
|
|
901
|
+
// from the log whether that is still owed.
|
|
902
|
+
if (match.status === 'desynced' || repeat) {
|
|
903
|
+
if (await this.resumeAfterDesync(matchId, now))
|
|
904
|
+
worked = true;
|
|
905
|
+
}
|
|
906
|
+
if (finished) {
|
|
907
|
+
const moved = await this.deps.storage.matches.transition(matchId, ['running', 'desynced'], {
|
|
908
|
+
status: 'finished',
|
|
722
909
|
updatedAt: now,
|
|
723
|
-
})
|
|
724
|
-
|
|
910
|
+
});
|
|
911
|
+
// A repeat finds the match already finished by the attempt it is completing, and still owes
|
|
912
|
+
// what followed that transition.
|
|
913
|
+
if (moved)
|
|
914
|
+
worked = true;
|
|
915
|
+
if (moved || (await this.deps.storage.matches.get(matchId))?.status === 'finished') {
|
|
916
|
+
// The seal opened the successor before anyone had reported, so a finished match is left
|
|
917
|
+
// holding an open turn with a live deadline. Nothing can be submitted to it (every write
|
|
918
|
+
// requires a running match) but it would sit in `listExpiredOpen` forever, and a client
|
|
919
|
+
// reading the match view would see a turn still counting down after the game ended.
|
|
920
|
+
await this.deps.storage.turns.rescheduleDeadline(matchId, number + 1, null);
|
|
921
|
+
const announced = await this.publisher.publishOnce(matchId, FINISH_ANNOUNCEMENT_KEY, {
|
|
725
922
|
type: 'match.statusChanged',
|
|
726
|
-
payload: { status: '
|
|
923
|
+
payload: { status: 'finished' },
|
|
727
924
|
});
|
|
925
|
+
if (announced)
|
|
926
|
+
worked = true;
|
|
728
927
|
}
|
|
729
928
|
}
|
|
929
|
+
await this.deps.storage.turns.markSettled(matchId, number, now);
|
|
930
|
+
return worked;
|
|
730
931
|
}
|
|
731
932
|
/**
|
|
732
933
|
* Lift a desync pause once nothing is unsettled. Orders were refused for the whole pause while
|
|
733
934
|
* the open turn's deadline kept running, so the turn would otherwise seal empty the moment the
|
|
734
935
|
* match resumes: its clock restarts here, and clients are told the new deadline.
|
|
936
|
+
*
|
|
937
|
+
* The lift's follow-ups are owed by the lift, not by the call that won its compare-and-swap. A
|
|
938
|
+
* call that finds the match already running while the log still says it is paused completes them
|
|
939
|
+
* for a lift that died half way; the announcement goes last, as their receipt. Returns whether
|
|
940
|
+
* this call lifted the pause or completed a lift.
|
|
735
941
|
*/
|
|
736
942
|
async resumeAfterDesync(matchId, now) {
|
|
737
943
|
if ((await this.deps.storage.turns.listUnsettled(matchId)).length > 0)
|
|
738
|
-
return;
|
|
944
|
+
return false;
|
|
739
945
|
const match = await this.deps.storage.matches.get(matchId);
|
|
740
946
|
if (!match)
|
|
741
|
-
return;
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
947
|
+
return false;
|
|
948
|
+
const moved = match.status === 'desynced' &&
|
|
949
|
+
(await this.deps.storage.matches.transition(matchId, ['desynced'], {
|
|
950
|
+
status: 'running',
|
|
951
|
+
updatedAt: now,
|
|
952
|
+
}));
|
|
953
|
+
// Lost to another lift, which owes the follow-ups; or no pause at all.
|
|
954
|
+
if (!moved && (match.status !== 'running' || !(await this.liftUnannounced(matchId)))) {
|
|
955
|
+
return false;
|
|
747
956
|
}
|
|
748
|
-
await this.
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
957
|
+
await this.restartClockAfterPause(match, now);
|
|
958
|
+
await this.announceStatus(matchId, 'running');
|
|
959
|
+
return true;
|
|
960
|
+
}
|
|
961
|
+
/** Whether the log's last status announcement is still the pause. */
|
|
962
|
+
async liftUnannounced(matchId) {
|
|
963
|
+
const last = await this.deps.storage.events.latestOfType(matchId, 'match.statusChanged');
|
|
964
|
+
return last?.type === 'match.statusChanged' && last.payload.status === 'desynced';
|
|
965
|
+
}
|
|
966
|
+
async restartClockAfterPause(match, now) {
|
|
967
|
+
const matchId = match.id;
|
|
752
968
|
// Not behind a modal. Kicking the odd one out is the documented remedy for a desync, and the
|
|
753
969
|
// kick opens an absence prompt for the kicked seat, so this path reached a paused turn every
|
|
754
970
|
// time in a timed match and put a full deadline back on it while the vote nobody could dismiss
|
|
@@ -765,6 +981,34 @@ export class TurnService {
|
|
|
765
981
|
await this.announceDeadline(matchId, match.currentTurn, deadlineAt);
|
|
766
982
|
}
|
|
767
983
|
}
|
|
984
|
+
/*
|
|
985
|
+
* Dedupe keys of the announcements that follow a compare-and-swap (`EventPublisher.publishOnce`).
|
|
986
|
+
* Each names the one fact it announces, so a repeat of the announcement is recognised as the same
|
|
987
|
+
* fact and never logged twice. They are server-side only and never reach a client.
|
|
988
|
+
*/
|
|
989
|
+
const sealAnnouncementKey = (turn) => `turn.sealed:${turn}`;
|
|
990
|
+
const confirmationKey = (turn) => `turn.confirmed:${turn}`;
|
|
991
|
+
const desyncAnnouncementKey = (turn) => `turn.desynced:${turn}`;
|
|
992
|
+
/**
|
|
993
|
+
* A pause or its lift, named by the status event it follows: the same change announced after the
|
|
994
|
+
* same last word is the same fact, and the next pause of the match follows a different event.
|
|
995
|
+
*/
|
|
996
|
+
const statusAnnouncementKey = (status, afterSeq) => `match.statusChanged:${status}:after:${afterSeq}`;
|
|
997
|
+
/** A match finishes once. */
|
|
998
|
+
const FINISH_ANNOUNCEMENT_KEY = 'match.statusChanged:finished';
|
|
999
|
+
/** A prompt is one opening of the question about a seat, which its `openedAt` identifies. */
|
|
1000
|
+
const promptAnnouncementKey = (playerId, openedAt) => `match.takeoverVoteRequested:${playerId}:${openedAt.getTime()}`;
|
|
1001
|
+
/** The withdrawal of one prompt's request that was logged after the prompt had closed. */
|
|
1002
|
+
const promptWithdrawalKey = (playerId, openedAt) => `match.takeoverVoteCancelled:${playerId}:${openedAt.getTime()}`;
|
|
1003
|
+
/**
|
|
1004
|
+
* Whether a confirmed turn finished the match, judged again from its reports when the verdict's
|
|
1005
|
+
* follow-ups are repeated. Reports are immutable once the turn is confirmed, and every client that
|
|
1006
|
+
* reported the confirmed state computed the same state, so they agree on whether it is final.
|
|
1007
|
+
*/
|
|
1008
|
+
function confirmedFinished(reports, stateHash) {
|
|
1009
|
+
const agreeing = reports.filter((report) => report.stateHash === stateHash);
|
|
1010
|
+
return agreeing.length > 0 && agreeing.every((report) => report.finished);
|
|
1011
|
+
}
|
|
768
1012
|
/**
|
|
769
1013
|
* Refuse a document whose ops act for a slot other than the submitter's.
|
|
770
1014
|
*
|