@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.
Files changed (34) hide show
  1. package/LICENSE +21 -674
  2. package/NOTICE +9 -12
  3. package/README.md +1 -1
  4. package/dist/domain/entities.d.ts +13 -0
  5. package/dist/domain/entities.d.ts.map +1 -1
  6. package/dist/domain/entities.js.map +1 -1
  7. package/dist/logic/canonical-json.js +28 -11
  8. package/dist/logic/canonical-json.js.map +1 -1
  9. package/dist/ports/storage.d.ts +79 -9
  10. package/dist/ports/storage.d.ts.map +1 -1
  11. package/dist/services/EventPublisher.d.ts +11 -0
  12. package/dist/services/EventPublisher.d.ts.map +1 -1
  13. package/dist/services/EventPublisher.js +19 -1
  14. package/dist/services/EventPublisher.js.map +1 -1
  15. package/dist/services/LobbyService.d.ts.map +1 -1
  16. package/dist/services/LobbyService.js +5 -1
  17. package/dist/services/LobbyService.js.map +1 -1
  18. package/dist/services/MatchQueryService.d.ts +4 -1
  19. package/dist/services/MatchQueryService.d.ts.map +1 -1
  20. package/dist/services/MatchQueryService.js +5 -2
  21. package/dist/services/MatchQueryService.js.map +1 -1
  22. package/dist/services/SnapshotService.d.ts +16 -5
  23. package/dist/services/SnapshotService.d.ts.map +1 -1
  24. package/dist/services/SnapshotService.js +26 -7
  25. package/dist/services/SnapshotService.js.map +1 -1
  26. package/dist/services/TurnService.d.ts +74 -2
  27. package/dist/services/TurnService.d.ts.map +1 -1
  28. package/dist/services/TurnService.js +329 -85
  29. package/dist/services/TurnService.js.map +1 -1
  30. package/dist/testing/InMemoryStorage.d.ts +2 -0
  31. package/dist/testing/InMemoryStorage.d.ts.map +1 -1
  32. package/dist/testing/InMemoryStorage.js +91 -9
  33. package/dist/testing/InMemoryStorage.js.map +1 -1
  34. 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 { mergeSeatSummaries } from './SnapshotService';
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 false;
161
+ return null;
150
162
  const turn = await this.deps.storage.turns.get(matchId, number);
151
163
  if (turn?.status !== 'open')
152
- return false;
164
+ return null;
153
165
  if (trigger === 'deadline') {
154
166
  if (turn.deadlineAt === null)
155
- return false;
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 false;
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 false;
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 false;
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 false;
194
+ return null;
183
195
  this.deps.logger.info('turn sealed', { matchId, turn: number, trigger });
184
- await this.completeSeal(match, number);
185
- return true;
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
- if (turn.orderSetHash === null || turn.sealedSlots === null) {
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
- const absentees = sealedByDeadline(turn) ? activePlayers(players) : [];
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 (await this.deps.storage.players.transitionStatus(player.id, ['active'], 'takeoverPending')) {
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 orderSetHash = await hashOrderSet(entries);
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
- const frozen = await this.deps.storage.turns.freezeSeal(match.id, number, {
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
- await this.publisher.publish(match.id, {
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.storage.matches.updateRuntimeGameSettings(match.id, mergeSeatSummaries(match.settings.gameSettings, request.seatSummaries), this.deps.clock.now());
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
- if (!opened)
556
- return false;
557
- await this.publisher.publish(matchId, {
558
- type: 'match.takeoverVoteRequested',
559
- payload: { playerId, turn },
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
- return true;
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
- /** Decide turn `number` from the reports on file. Idempotent and race-safe through CAS. */
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 left the match `desynced` for good with nothing unsettled, so
648
- // `submitOrders` answered `match_desynced` forever and nothing could reach `resumeAfterDesync`
649
- // again — it is private and `reevaluate` only visits sealed and desynced turns. The seal path
650
- // was built to survive exactly this; the verdict path was not.
651
- if (match.status === 'desynced')
652
- await this.resumeAfterDesync(matchId, this.deps.clock.now());
653
- return;
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.publisher.publish(matchId, {
672
- type: 'turn.confirmed',
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 won = turn.status === 'desynced' ||
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 announcement is claimed on the turn row, the way the seal's digest is, so exactly one
708
- // caller publishes it. Asking the event log instead paged every event the match had ever
709
- // logged, on every sweep, for the whole of a pause that can last the retention window.
710
- if (await this.deps.storage.turns.claimDesyncAnnouncement(matchId, number, now)) {
711
- await this.publisher.publish(matchId, {
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
- if (await this.deps.storage.matches.transition(matchId, ['running'], {
721
- status: 'desynced',
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
- await this.publisher.publish(matchId, {
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: 'desynced' },
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
- if (!(await this.deps.storage.matches.transition(matchId, ['desynced'], {
743
- status: 'running',
744
- updatedAt: now,
745
- }))) {
746
- return;
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.publisher.publish(matchId, {
749
- type: 'match.statusChanged',
750
- payload: { status: 'running' },
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
  *