vimp-engine 0.35.6 → 0.36.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 (130) hide show
  1. package/bin/vimp-sim.js +19 -1
  2. package/core/Cargo.toml +1 -1
  3. package/package.json +1 -1
  4. package/src/client/components/controller/Controls.js +5 -0
  5. package/src/client/components/controller/Lobby.js +4 -4
  6. package/src/client/components/controller/RoomMenu.js +43 -0
  7. package/src/client/components/controller/Vote.js +18 -2
  8. package/src/client/components/model/ClientReports.js +6 -2
  9. package/src/client/components/model/Controls.js +27 -0
  10. package/src/client/components/model/Games.js +5 -1
  11. package/src/client/components/model/Lobby.js +32 -24
  12. package/src/client/components/model/LobbyAuth.js +20 -4
  13. package/src/client/components/model/RoomMenu.js +121 -0
  14. package/src/client/components/model/Stat.js +2 -1
  15. package/src/client/components/model/Vote.js +122 -4
  16. package/src/client/components/view/Auth.js +20 -10
  17. package/src/client/components/view/ClientReports.js +16 -5
  18. package/src/client/components/view/Games.js +39 -14
  19. package/src/client/components/view/Lobby.js +90 -43
  20. package/src/client/components/view/LobbyAuth.js +13 -7
  21. package/src/client/components/view/Panel.js +1 -2
  22. package/src/client/components/view/RoomMenu.js +69 -0
  23. package/src/client/debug.js +24 -1
  24. package/src/client/lib/FpsMeter.js +46 -0
  25. package/src/client/lib/JoinRetry.js +73 -0
  26. package/src/client/lib/diagnostics.js +28 -5
  27. package/src/client/lib/hostCaps.js +82 -0
  28. package/src/client/lib/hostUnloadGuard.js +108 -0
  29. package/src/client/lib/hostVoteCommand.js +92 -0
  30. package/src/client/lib/pageReload.js +29 -0
  31. package/src/client/lib/roomLink.js +257 -0
  32. package/src/client/lib/signalingErrors.js +74 -0
  33. package/src/client/main.js +674 -451
  34. package/src/client/network/HiddenHostHealthLog.js +76 -0
  35. package/src/client/network/HostConnectionManager.js +62 -8
  36. package/src/client/network/HostController.js +376 -23
  37. package/src/client/network/HostHealthPolicy.js +307 -0
  38. package/src/client/network/HostHealthReporter.js +54 -0
  39. package/src/client/network/HostPrewarm.js +235 -0
  40. package/src/client/network/LoopbackTransport.js +16 -1
  41. package/src/client/network/PlannedHandoff.js +366 -0
  42. package/src/client/network/Promotion.js +420 -0
  43. package/src/client/network/RoomPeersReporter.js +81 -0
  44. package/src/client/network/SessionSupervisor.js +730 -0
  45. package/src/client/network/SignalingClient.js +183 -30
  46. package/src/client/network/StandbyReceiver.js +287 -0
  47. package/src/client/network/StandbySender.js +319 -0
  48. package/src/client/network/TokenHandoffTimer.js +81 -0
  49. package/src/client/network/WebRtcManager.js +338 -11
  50. package/src/client/network/policyClose.js +32 -2
  51. package/src/client/network/standbyChunks.js +185 -0
  52. package/src/client/session/GuestSession.js +428 -0
  53. package/src/client/session/HandoffFlow.js +399 -0
  54. package/src/client/session/HostRole.js +708 -0
  55. package/src/client/session/Membership.js +179 -0
  56. package/src/client/session/PromotionFlow.js +325 -0
  57. package/src/client/session/RouteBoot.js +432 -0
  58. package/src/client/session/StandbyRole.js +195 -0
  59. package/src/client/session/hostRoomPrep.js +152 -0
  60. package/src/client/session/roomContext.js +32 -0
  61. package/src/client/style.css +83 -22
  62. package/src/client/views/gameShell.js +7 -0
  63. package/src/client/views/includes/informer.pug +4 -0
  64. package/src/client/views/includes/lobby.pug +0 -3
  65. package/src/client/views/includes/roomMenu.pug +9 -0
  66. package/src/client/views/index.pug +1 -0
  67. package/src/config/closeCodes.js +1 -1
  68. package/src/config/env.js +37 -0
  69. package/src/config/hostDefaults.js +24 -0
  70. package/src/config/lobby.js +154 -3
  71. package/src/config/master.js +104 -21
  72. package/src/config/wsports.js +8 -0
  73. package/src/devtools/RecordingSocketManager.js +3 -2
  74. package/src/devtools/ScenarioRunner.js +123 -8
  75. package/src/devtools/VirtualClient.js +20 -0
  76. package/src/devtools/contract/format.js +4 -1
  77. package/src/devtools/contract/index.js +1 -2
  78. package/src/devtools/contract/rules/a2-package-scripts.js +1 -3
  79. package/src/devtools/contract/rules/b8-system-messages.js +1 -1
  80. package/src/devtools/contract/rules/b9-vote-names.js +25 -1
  81. package/src/devtools/contract/rules/c11-stat-mode.js +4 -1
  82. package/src/devtools/contract/rules/c6-stat-columns.js +1 -1
  83. package/src/devtools/contract/rules/c7-key-sets.js +8 -4
  84. package/src/devtools/contract/rules/d3-snapshot-interp.js +2 -1
  85. package/src/devtools/contract/rules/e1-sound-pairs.js +3 -1
  86. package/src/devtools/contract/rules/e4-map-layers.js +13 -2
  87. package/src/devtools/contract/rules/e5-map-radar-walls.js +7 -1
  88. package/src/devtools/contract/rules/index.js +38 -5
  89. package/src/devtools/invariants.js +10 -4
  90. package/src/devtools/pluginLoader.js +1 -4
  91. package/src/devtools/report.js +17 -1
  92. package/src/host/DebugRecorder.js +6 -1
  93. package/src/host/GameCoreAdapter.js +45 -0
  94. package/src/host/HostGame.js +928 -59
  95. package/src/host/PortMachine.js +268 -20
  96. package/src/host/host.worker.js +222 -16
  97. package/src/host/meta/SocketManager.js +20 -0
  98. package/src/host/meta/core/RoundManager.js +89 -14
  99. package/src/host/meta/modules/Accolades.js +4 -1
  100. package/src/host/meta/modules/Panel.js +30 -0
  101. package/src/host/meta/modules/PlayerDataSync.js +255 -46
  102. package/src/host/meta/modules/RTTManager.js +34 -0
  103. package/src/host/meta/modules/TimerManager.js +215 -18
  104. package/src/host/meta/modules/chat/systemMessages.js +22 -0
  105. package/src/host/meta/player/HumanParticipant.js +10 -0
  106. package/src/host/meta/player/ParticipantManager.js +98 -5
  107. package/src/lib/AbstractTimer.js +63 -1
  108. package/src/lib/Publisher.js +26 -0
  109. package/src/lib/capabilities.js +5 -0
  110. package/src/lib/checkpointCodec.js +125 -0
  111. package/src/lib/clientIp.js +9 -2
  112. package/src/lib/createHostRuntime.js +95 -6
  113. package/src/lib/formOptions.js +3 -1
  114. package/src/lib/gameConfigView.js +10 -0
  115. package/src/lib/gameId.js +13 -0
  116. package/src/lib/jwt.js +3 -1
  117. package/src/lib/loadGamePackage.js +2 -2
  118. package/src/lib/resumeKey.js +38 -0
  119. package/src/lib/roomId.js +39 -0
  120. package/src/lib/roomSettings.js +59 -0
  121. package/src/lib/validators.js +7 -0
  122. package/tests/fixtures/generations/gen-api3/host/ScriptedManager.js +2 -3
  123. package/tests/fixtures/generations/gen-api3/host/fakeCore.js +31 -5
  124. package/tests/fixtures/generations/gen-api3/manifest.json +1 -3
  125. package/tests/fixtures/generations/gen-api4/host/ScriptedManager.js +2 -3
  126. package/tests/fixtures/generations/gen-api4/host/fakeCore.js +31 -5
  127. package/tests/fixtures/generations/gen-api4/manifest.json +1 -3
  128. package/tests/fixtures/miniGame/config/client.js +15 -0
  129. package/tests/fixtures/miniGame/host/ScriptedManager.js +2 -3
  130. package/tests/fixtures/miniGame/host/fakeCore.js +31 -5
@@ -17,6 +17,17 @@ import lobbyConfig from '../../../config/lobby.js';
17
17
 
18
18
  const PERIODS = ['day', 'month', 'all'];
19
19
 
20
+ // старт номеров записей (host-migration 7.7): 48 случайных бит из CSPRNG
21
+ // (crypto изоморфен — Worker, браузер, Node). Ключ дубля в auth — (игрок,
22
+ // игра, номер) без комнаты, поэтому разные записи профиля одного игрока
23
+ // (перевход, другая комната, хост из точки) обязаны не пересекаться: 2^48
24
+ // диапазонов на это хватает, и до Number.MAX_SAFE_INTEGER далеко
25
+ const randomWriteSeq = () => {
26
+ const [high, low] = crypto.getRandomValues(new Uint32Array(2));
27
+
28
+ return (high % 2 ** 16) * 2 ** 32 + low;
29
+ };
30
+
20
31
  export default class PlayerDataSync {
21
32
  // дефолт fetchImpl обёрнут стрелкой, а не взят как голый `fetch`: из поля
22
33
  // объекта он вызывался бы с `this` экземпляра, а в браузере/воркере у
@@ -33,6 +44,7 @@ export default class PlayerDataSync {
33
44
  now = () => Date.now(),
34
45
  sleep = ms => new Promise(resolve => setTimeout(resolve, ms)),
35
46
  random = () => Math.random(),
47
+ writeSeqStart = randomWriteSeq,
36
48
  } = {},
37
49
  ) {
38
50
  this._gameId = gameId;
@@ -40,22 +52,24 @@ export default class PlayerDataSync {
40
52
  this._defaultState = defaultState;
41
53
  this._now = now;
42
54
  this._sleep = sleep;
55
+ this._writeSeqStart = writeSeqStart;
43
56
  this._entries = new Map(); // participantId -> запись профиля (_makeEntry)
44
- // hostId + per-room секрет комнаты (кодревью №1, plan/server-rating/
45
- // review.md): неизвестны при создании (назначаются мастером на
46
- // register_host, после запуска Worker'а) — проставляются позже через
47
- // setHostId(). Едут в теле PUT rank/state; мастер сверяет секрет с
48
- // реестром и по нему подставляет проверенную атрибуцию, не доверяя
49
- // hostId из тела напрямую (иначе можно было бы подставить чужую комнату)
50
- this._hostId = null;
51
- this._hostSecret = null;
57
+ // roomId + секрет эпохи комнаты (host-migration, этап 2): неизвестны при
58
+ // создании (назначаются мастером на register_host, после запуска
59
+ // Worker'а) — проставляются позже через setRoom(). Едут в теле PUT
60
+ // rank/state; мастер сверяет секрет с реестром и по нему подставляет
61
+ // проверенную атрибуцию, не доверяя roomId из тела напрямую (иначе можно
62
+ // было бы подставить чужую комнату)
63
+ this._roomId = null;
64
+ this._roomSecret = null;
52
65
 
53
66
  const settings = lobbyConfig.playerData;
54
67
 
55
68
  // джиттер считается один раз на комнату: сотни серверов, синхронно
56
69
  // проснувшихся по круглому минутному таймеру, дают мастеру пик
57
70
  this._flushInterval = Math.round(
58
- settings.minFlushInterval * (1 + (random() * 2 - 1) * settings.flushJitter),
71
+ settings.minFlushInterval *
72
+ (1 + (random() * 2 - 1) * settings.flushJitter),
59
73
  );
60
74
  // вверх, а не как получится: слот, округлённый вниз, выпускал бы чуть
61
75
  // больше запросов в секунду, чем объявленный потолок
@@ -74,9 +88,9 @@ export default class PlayerDataSync {
74
88
 
75
89
  // вызывается HostGame, когда мастер подтвердил регистрацию комнаты
76
90
  // (host_registered) — до этого flush уходит без атрибуции
77
- setHostId(hostId, hostSecret = null) {
78
- this._hostId = hostId;
79
- this._hostSecret = hostSecret;
91
+ setRoom({ roomId = null, roomSecret = null } = {}) {
92
+ this._roomId = roomId;
93
+ this._roomSecret = roomSecret;
80
94
  }
81
95
 
82
96
  _makeEntry(token) {
@@ -89,7 +103,10 @@ export default class PlayerDataSync {
89
103
  lastSyncedState: null,
90
104
  // значения на момент входа + локальные правки finishGame
91
105
  ratings: Object.fromEntries(
92
- PERIODS.map(period => [period, { value: 0, placement: null, total: 0 }]),
106
+ PERIODS.map(period => [
107
+ period,
108
+ { value: 0, placement: null, total: 0 },
109
+ ]),
93
110
  ),
94
111
  // ПО СРЕЗАМ, а не общим флагом: агрегирующий /auth/placements отдаёт
95
112
  // 200, если приехал хоть один срез, и кладёт провалившийся как null.
@@ -100,7 +117,9 @@ export default class PlayerDataSync {
100
117
  currentGamePoints: 0, // очки незавершённой игры
101
118
  pendingPoints: 0, // сумма завершённых игр, ещё не отправленная
102
119
  pendingBest: 0, // лучшая завершённая игра, ещё не отправленная
103
- placementRefreshedAt: Object.fromEntries(PERIODS.map(period => [period, 0])),
120
+ placementRefreshedAt: Object.fromEntries(
121
+ PERIODS.map(period => [period, 0]),
122
+ ),
104
123
  inFlight: false,
105
124
  // промис текущей серии flush: его отдают повторные вызовы, чтобы
106
125
  // граница, которая ждёт записи (destroy, уход участника), ждала её
@@ -108,6 +127,22 @@ export default class PlayerDataSync {
108
127
  inFlightPromise: null,
109
128
  flushAgain: false,
110
129
  lastFlushAt: 0,
130
+ // номер последней записи результата участника (host-migration 7.7),
131
+ // auth по нему отсеивает повтор. Старт случайный (randomWriteSeq), а не
132
+ // с нуля: участник, вышедший и вернувшийся в комнату, получает новую
133
+ // запись — счёт с нуля совпал бы с номерами прошлого входа, и auth
134
+ // выбросил бы честные очки как дубль
135
+ writeSeq: this._writeSeqStart(),
136
+ // записи результата, которым уже выдан номер, по порядку — { writeSeq,
137
+ // points, best }. Сумма вычтена из pending в момент выдачи номера, и
138
+ // до ответа auth запись живёт здесь: повтор (сбой сети, 5xx/429, хост
139
+ // из точки) уходит с ТЕМ ЖЕ номером и той же суммой — дошедший до auth
140
+ // запрос не засчитается дважды. Больше одной бывает только после
141
+ // restore (см. там)
142
+ writes: [],
143
+ // запись восстановлена из контрольной точки без токена (токены по сети
144
+ // не ездят): писать нечем, пока участник не вернётся (attachToken)
145
+ awaitingToken: false,
111
146
  };
112
147
  }
113
148
 
@@ -151,7 +186,10 @@ export default class PlayerDataSync {
151
186
  _noteFailure() {
152
187
  const { baseMs, maxMs } = lobbyConfig.playerData.backoff;
153
188
 
154
- this._backoffMs = Math.min(this._backoffMs ? this._backoffMs * 2 : baseMs, maxMs);
189
+ this._backoffMs = Math.min(
190
+ this._backoffMs ? this._backoffMs * 2 : baseMs,
191
+ maxMs,
192
+ );
155
193
  this._backoffUntil = this._now() + this._backoffMs;
156
194
  }
157
195
 
@@ -180,6 +218,7 @@ export default class PlayerDataSync {
180
218
  const entry = this._entries.get(participantId) ?? this._makeEntry(token);
181
219
 
182
220
  entry.token = token;
221
+ entry.awaitingToken = false;
183
222
  this._entries.set(participantId, entry);
184
223
 
185
224
  try {
@@ -205,7 +244,9 @@ export default class PlayerDataSync {
205
244
  // навсегда выключает её и выглядит снаружи как «данных просто нет»
206
245
  if (!ratingsRes.ok) {
207
246
  this._noteStatus(ratingsRes.status);
208
- console.warn(`[playerData] GET placements ${ratingsRes.status} for ${participantId}`);
247
+ console.warn(
248
+ `[playerData] GET placements ${ratingsRes.status} for ${participantId}`,
249
+ );
209
250
  } else {
210
251
  const json = (await ratingsRes.json()) ?? {};
211
252
  const at = this._now();
@@ -244,7 +285,9 @@ export default class PlayerDataSync {
244
285
  // сумму вместо максимума, и refreshPlacement её бы не исправил
245
286
  // (при непустом pending он берёт Math.max с показанным)
246
287
  rating.value =
247
- period === 'month' ? rating.value + server : Math.max(rating.value, server);
288
+ period === 'month'
289
+ ? rating.value + server
290
+ : Math.max(rating.value, server);
248
291
  rating.placement = slice.placement ?? null;
249
292
  rating.total = Number(slice.total) || 0;
250
293
  entry.placementRefreshedAt[period] = at;
@@ -254,14 +297,18 @@ export default class PlayerDataSync {
254
297
 
255
298
  if (!stateRes.ok) {
256
299
  this._noteStatus(stateRes.status);
257
- console.warn(`[playerData] GET state ${stateRes.status} for ${participantId}`);
300
+ console.warn(
301
+ `[playerData] GET state ${stateRes.status} for ${participantId}`,
302
+ );
258
303
  } else if (!entry.stateLoaded) {
259
304
  this._noteSuccess();
260
305
 
261
306
  const { state } = await stateRes.json();
262
307
 
263
308
  entry.state =
264
- state && Object.keys(state).length ? state : structuredClone(this._defaultState);
309
+ state && Object.keys(state).length
310
+ ? state
311
+ : structuredClone(this._defaultState);
265
312
  entry.stateLoaded = true;
266
313
  // то, что только что приехало, отправлять обратно незачем
267
314
  entry.lastSyncedState = JSON.stringify(entry.state);
@@ -275,7 +322,10 @@ export default class PlayerDataSync {
275
322
  // в игре) бэкофф обходит, а именно он и тянет за собой повторный
276
323
  // load(), пока рейтинги не загружены
277
324
  this._noteFailure();
278
- console.warn(`[playerData] load failed for ${participantId}:`, err.message);
325
+ console.warn(
326
+ `[playerData] load failed for ${participantId}:`,
327
+ err.message,
328
+ );
279
329
  }
280
330
 
281
331
  return entry;
@@ -323,7 +373,10 @@ export default class PlayerDataSync {
323
373
 
324
374
  const now = this._now();
325
375
 
326
- if (now - entry.placementRefreshedAt[period] < lobbyConfig.playerData.placementTtl) {
376
+ if (
377
+ now - entry.placementRefreshedAt[period] <
378
+ lobbyConfig.playerData.placementTtl
379
+ ) {
327
380
  return this.getRating(participantId, period);
328
381
  }
329
382
 
@@ -342,7 +395,9 @@ export default class PlayerDataSync {
342
395
 
343
396
  if (!res.ok) {
344
397
  this._noteStatus(res.status);
345
- console.warn(`[playerData] GET placement ${res.status} for ${participantId}`);
398
+ console.warn(
399
+ `[playerData] GET placement ${res.status} for ${participantId}`,
400
+ );
346
401
  } else {
347
402
  const { placement, total, rank } = (await res.json()) ?? {};
348
403
  const rating = entry.ratings[period];
@@ -353,7 +408,9 @@ export default class PlayerDataSync {
353
408
  // ноль после смены суток); есть неотправленное — оно ещё не учтено
354
409
  // сервером, и показывать меньше уже показанного нельзя
355
410
  rating.value =
356
- entry.pendingPoints > 0 || entry.pendingBest > 0
411
+ entry.pendingPoints > 0 ||
412
+ entry.pendingBest > 0 ||
413
+ entry.writes.length > 0
357
414
  ? Math.max(rating.value, server)
358
415
  : server;
359
416
  rating.placement = placement ?? null;
@@ -361,7 +418,10 @@ export default class PlayerDataSync {
361
418
  }
362
419
  } catch (err) {
363
420
  this._noteFailure();
364
- console.warn(`[playerData] placement failed for ${participantId}:`, err.message);
421
+ console.warn(
422
+ `[playerData] placement failed for ${participantId}:`,
423
+ err.message,
424
+ );
365
425
  }
366
426
 
367
427
  return this.getRating(participantId, period);
@@ -445,6 +505,114 @@ export default class PlayerDataSync {
445
505
  this._entries.delete(participantId);
446
506
  }
447
507
 
508
+ // свежий токен участника, вернувшегося в матч новым соединением
509
+ // (host-migration этап 4): старый мог истечь за время жизни комнаты, а
510
+ // следующие flush идут с токеном записи. Подгрузку не повторяет —
511
+ // профиль уже в памяти. false — записи участника нет
512
+ attachToken(participantId, token) {
513
+ const entry = this._entries.get(participantId);
514
+
515
+ if (!entry || typeof token !== 'string' || token === '') {
516
+ return false;
517
+ }
518
+
519
+ entry.token = token;
520
+ entry.awaitingToken = false;
521
+
522
+ return true;
523
+ }
524
+
525
+ // ***** контрольная точка (host-migration этап 5) ***** //
526
+
527
+ // профили участников без токенов: точка уезжает по сети к другому игроку.
528
+ // Записи с номерами едут отдельно (writes) и в pending не входят: новый
529
+ // хост повторит их с теми же номерами, и дошедшая до auth не засчитается
530
+ // второй раз (host-migration 7.7)
531
+ serialize() {
532
+ const out = {};
533
+
534
+ for (const [id, entry] of this._entries) {
535
+ out[id] = {
536
+ ratings: structuredClone(entry.ratings),
537
+ ratingsLoaded: { ...entry.ratingsLoaded },
538
+ currentGamePoints: entry.currentGamePoints,
539
+ pendingPoints: entry.pendingPoints,
540
+ pendingBest: entry.pendingBest,
541
+ state: structuredClone(entry.state),
542
+ stateLoaded: entry.stateLoaded,
543
+ lastSyncedState: entry.lastSyncedState,
544
+ writeSeq: entry.writeSeq,
545
+ writes: entry.writes.map(write => ({ ...write })),
546
+ };
547
+ }
548
+
549
+ return out;
550
+ }
551
+
552
+ // поднимает профили из точки. tokens — { [id]: token } только для эстафеты
553
+ // внутри вкладки; без токена запись ждёт возврата участника (attachToken)
554
+ restore(state = {}, { tokens = {} } = {}) {
555
+ for (const [id, saved] of Object.entries(state)) {
556
+ const token = tokens[id] ?? null;
557
+ const entry = this._makeEntry(token);
558
+
559
+ entry.ratings = structuredClone(saved.ratings ?? entry.ratings);
560
+ entry.ratingsLoaded = { ...entry.ratingsLoaded, ...saved.ratingsLoaded };
561
+ entry.currentGamePoints = Number(saved.currentGamePoints) || 0;
562
+ entry.pendingPoints = Number(saved.pendingPoints) || 0;
563
+ entry.pendingBest = Number(saved.pendingBest) || 0;
564
+ entry.state = structuredClone(saved.state ?? this._defaultState);
565
+ entry.stateLoaded = saved.stateLoaded === true;
566
+ entry.lastSyncedState = saved.lastSyncedState ?? null;
567
+ entry.writes = (Array.isArray(saved.writes) ? saved.writes : [])
568
+ .map(write => this._restoreWrite(write))
569
+ .filter(Boolean);
570
+
571
+ // Номера точки НЕ продолжаются: старый хост мог после неё занять
572
+ // writeSeq+1, +2…, и свежие очки этого хоста под теми же номерами auth
573
+ // выбросил бы как дубль. Накопленное на момент точки закрепляется под
574
+ // writeSeq+1 — этот номер дал бы ему старый хост, и если он его успел
575
+ // отправить, auth отсеет повтор. Всё дальше — из нового случайного
576
+ // диапазона (writeSeq из _makeEntry). Точка без номера (Worker до 7.7)
577
+ // своих записей с номерами не слала — её накопленное остаётся в pending
578
+ if (
579
+ Number.isSafeInteger(saved.writeSeq) &&
580
+ (entry.pendingPoints > 0 || entry.pendingBest > 0)
581
+ ) {
582
+ entry.writes.push({
583
+ writeSeq: saved.writeSeq + 1,
584
+ points: entry.pendingPoints,
585
+ best: entry.pendingBest,
586
+ });
587
+ entry.pendingPoints = 0;
588
+ entry.pendingBest = 0;
589
+ }
590
+
591
+ entry.awaitingToken = token === null;
592
+
593
+ this._entries.set(id, entry);
594
+ }
595
+ }
596
+
597
+ _restoreWrite(saved) {
598
+ if (!saved || !Number.isSafeInteger(saved.writeSeq)) {
599
+ return null;
600
+ }
601
+
602
+ return {
603
+ writeSeq: saved.writeSeq,
604
+ points: Number(saved.points) || 0,
605
+ best: Number(saved.best) || 0,
606
+ };
607
+ }
608
+
609
+ // всё ли с мастера уже приехало: тогда повторная загрузка не нужна
610
+ isLoaded(participantId) {
611
+ const entry = this._entries.get(participantId);
612
+
613
+ return Boolean(entry && entry.stateLoaded && this._ratingsComplete(entry));
614
+ }
615
+
448
616
  // синхронизирует накопленный результат+state участника на мастер. Сбой не
449
617
  // бросается дальше — следующий flush попробует снова с уже накопленными
450
618
  // (не потерянными) данными.
@@ -455,7 +623,9 @@ export default class PlayerDataSync {
455
623
  async flush(participantId, { urgent = false } = {}) {
456
624
  const entry = this._entries.get(participantId);
457
625
 
458
- if (!entry) {
626
+ // восстановлен из точки без токена — писать нечем; накопленное ждёт
627
+ // возврата участника
628
+ if (!entry || entry.awaitingToken) {
459
629
  return;
460
630
  }
461
631
 
@@ -464,7 +634,10 @@ export default class PlayerDataSync {
464
634
 
465
635
  // предел владеет движком: просьба игры синхронизироваться чаще
466
636
  // minFlushInterval — это просьба, а не команда (решение пользователя 9)
467
- if (now - entry.lastFlushAt < this._flushInterval || now < this._backoffUntil) {
637
+ if (
638
+ now - entry.lastFlushAt < this._flushInterval ||
639
+ now < this._backoffUntil
640
+ ) {
468
641
  return;
469
642
  }
470
643
  }
@@ -514,11 +687,9 @@ export default class PlayerDataSync {
514
687
 
515
688
  const { token } = entry;
516
689
  const requests = [];
517
- // отправляем именно накопленное на этот момент и вычитаем его же после
518
- // успеха — finishGame во время await не теряется (тот же паттерн, что
519
- // F9 в load())
520
- const points = entry.pendingPoints;
521
- const best = entry.pendingBest;
690
+ // незакрытая запись прошлой попытки (или предшественника — точка,
691
+ // эстафета) уходит первой и как есть: тот же номер, та же сумма
692
+ const carried = entry.writes.length > 0;
522
693
 
523
694
  // РЕЗУЛЬТАТ ИГРЫ ОТПРАВЛЯЕТСЯ БЕЗУСЛОВНО. Гейт по загруженности здесь
524
695
  // был бы вреден: это не абсолютное значение, а строка в леджер (auth
@@ -526,22 +697,49 @@ export default class PlayerDataSync {
526
697
  // /auth/placements держал бы очки в pending до конца жизни комнаты и
527
698
  // терял бы их вместе с ней. Гейт нужен ровно state ниже: тот PUT
528
699
  // ЗАМЕЩАЕТ сохранённый JSON, и дефолтом поверх настоящего нельзя (F4)
529
- if (points > 0 || best > 0) {
700
+ if (!carried && (entry.pendingPoints > 0 || entry.pendingBest > 0)) {
701
+ // накопленное на этот момент получает номер и уходит из pending сразу:
702
+ // finishGame во время await копит уже следующую запись
703
+ entry.writeSeq += 1;
704
+ entry.writes.push({
705
+ writeSeq: entry.writeSeq,
706
+ points: entry.pendingPoints,
707
+ best: entry.pendingBest,
708
+ });
709
+ entry.pendingPoints = 0;
710
+ entry.pendingBest = 0;
711
+ }
712
+
713
+ const [write] = entry.writes;
714
+
715
+ if (write) {
530
716
  requests.push(
531
717
  this._enqueue(() =>
532
718
  this._authedFetch(lobbyConfig.playerData.rankUrl, token, {
533
719
  method: 'PUT',
534
- body: { points, best, hostId: this._hostId, hostSecret: this._hostSecret },
720
+ body: {
721
+ points: write.points,
722
+ best: write.best,
723
+ writeSeq: write.writeSeq,
724
+ roomId: this._roomId,
725
+ roomSecret: this._roomSecret,
726
+ },
535
727
  }),
536
728
  ).then(res => {
537
- // pendingBest только растёт: если он тот же — отправленное учтено
538
- // целиком; если больше — во время запроса закончилась игра лучше,
539
- // и её максимум ещё не отправлен
729
+ // запись закрыта: следующая получит новый номер. Закрыта старая
730
+ // (повтор, запись из точки) — оставшиеся записи и накопленное за
731
+ // время сбоев уходят в этой же серии, а не ждут следующей границы:
732
+ // срочный flush ухода участника иначе увёз бы их вместе с записью
540
733
  const drop = () => {
541
- entry.pendingPoints -= points;
734
+ if (entry.writes[0] === write) {
735
+ entry.writes.shift();
736
+ }
542
737
 
543
- if (entry.pendingBest === best) {
544
- entry.pendingBest = 0;
738
+ if (
739
+ entry.writes.length > 0 ||
740
+ (carried && (entry.pendingPoints > 0 || entry.pendingBest > 0))
741
+ ) {
742
+ entry.flushAgain = true;
545
743
  }
546
744
  };
547
745
 
@@ -550,13 +748,15 @@ export default class PlayerDataSync {
550
748
  drop();
551
749
  } else {
552
750
  this._noteStatus(res.status);
553
- console.warn(`[playerData] PUT rank ${res.status} for ${participantId}`);
751
+ console.warn(
752
+ `[playerData] PUT rank ${res.status} for ${participantId}`,
753
+ );
554
754
 
555
755
  // 4xx (кроме 429) — отказ ПО СОДЕРЖАНИЮ: повтор того же тела
556
- // получит тот же ответ. Оставить его в pending значит слать
756
+ // получит тот же ответ. Оставить запись открытой значит слать
557
757
  // заведомый мусор на каждом flush до конца жизни комнаты, а
558
- // накопленное сверху — сверх него. Очки теряются, и это честнее
559
- // вечного цикла (сам отказ уже в логе выше)
758
+ // накопленное после неё не ушло бы вовсе. Очки теряются, и это
759
+ // честнее вечного цикла (сам отказ уже в логе выше)
560
760
  if (res.status >= 400 && res.status < 500 && res.status !== 429) {
561
761
  drop();
562
762
  }
@@ -572,7 +772,11 @@ export default class PlayerDataSync {
572
772
  this._enqueue(() =>
573
773
  this._authedFetch(lobbyConfig.playerData.stateUrl, token, {
574
774
  method: 'PUT',
575
- body: { state: entry.state, hostId: this._hostId, hostSecret: this._hostSecret },
775
+ body: {
776
+ state: entry.state,
777
+ roomId: this._roomId,
778
+ roomSecret: this._roomSecret,
779
+ },
576
780
  }),
577
781
  ).then(res => {
578
782
  if (res.ok) {
@@ -580,7 +784,9 @@ export default class PlayerDataSync {
580
784
  entry.lastSyncedState = state;
581
785
  } else {
582
786
  this._noteStatus(res.status);
583
- console.warn(`[playerData] PUT state ${res.status} for ${participantId}`);
787
+ console.warn(
788
+ `[playerData] PUT state ${res.status} for ${participantId}`,
789
+ );
584
790
  }
585
791
  }),
586
792
  );
@@ -591,7 +797,10 @@ export default class PlayerDataSync {
591
797
  for (const result of await Promise.allSettled(requests)) {
592
798
  if (result.status === 'rejected') {
593
799
  this._noteFailure();
594
- console.warn(`[playerData] flush failed for ${participantId}:`, result.reason?.message);
800
+ console.warn(
801
+ `[playerData] flush failed for ${participantId}:`,
802
+ result.reason?.message,
803
+ );
595
804
  }
596
805
  }
597
806
  }
@@ -59,6 +59,7 @@ export default class RTTManager {
59
59
  addUser(gameId) {
60
60
  this._users.set(gameId, {
61
61
  rtt: 100, // начальное значение RTT (предположение)
62
+ measured: false, // был ли хоть один pong (иначе rtt — догадка)
62
63
  missedPings: 0,
63
64
  outstandingPings: new Map(),
64
65
  pingIdCounter: 0,
@@ -150,6 +151,7 @@ export default class RTTManager {
150
151
  // с помощью экспоненциального скользящего среднего (EMA)
151
152
  // (предотвращает резкие скачки RTT из-за единичных сетевых флуктуаций)
152
153
  user.rtt = oldRtt * (1 - this._alpha) + newRttSample * this._alpha;
154
+ user.measured = true;
153
155
 
154
156
  // если замер RTT превышает установленный порог
155
157
  if (this._maxLatency && user.rtt > this._maxLatency) {
@@ -167,4 +169,36 @@ export default class RTTManager {
167
169
  // `pong` пришел для пинга, который уже не отслеживается (маловероятно)
168
170
  return null;
169
171
  }
172
+
173
+ /**
174
+ * @description Медиана сглаженного RTT по измеренным пользователям
175
+ * (host-migration этап 9a: здоровье хоста). Пользователь без единого pong
176
+ * не считается — его rtt лишь стартовая догадка.
177
+ * @param {Function} [include] - (gameId) => boolean; например, без
178
+ * хост-игрока на loopback.
179
+ * @returns {{ median: number|null, count: number }}
180
+ */
181
+ getRttStats(include = () => true) {
182
+ const values = [];
183
+
184
+ this._users.forEach((user, gameId) => {
185
+ if (user.measured && include(gameId)) {
186
+ values.push(user.rtt);
187
+ }
188
+ });
189
+
190
+ if (values.length === 0) {
191
+ return { median: null, count: 0 };
192
+ }
193
+
194
+ values.sort((a, b) => a - b);
195
+
196
+ const mid = values.length >> 1;
197
+ const median =
198
+ values.length % 2 === 1
199
+ ? values[mid]
200
+ : (values[mid - 1] + values[mid]) / 2;
201
+
202
+ return { median: Math.round(median), count: values.length };
203
+ }
170
204
  }