vimp-engine 0.41.0 → 0.42.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 (34) hide show
  1. package/package.json +1 -1
  2. package/src/client/components/controller/RoomMenu.js +38 -10
  3. package/src/client/components/model/RoomMenu.js +42 -57
  4. package/src/client/components/view/RoomMenu.js +78 -24
  5. package/src/client/lib/autoRejoin.js +161 -0
  6. package/src/client/lib/hostUnloadGuard.js +40 -15
  7. package/src/client/lib/roomLink.js +32 -0
  8. package/src/client/main.js +116 -36
  9. package/src/client/network/HostConnectionManager.js +27 -1
  10. package/src/client/network/HostController.js +1 -30
  11. package/src/client/network/HostHealthPolicy.js +14 -59
  12. package/src/client/network/PlannedHandoff.js +15 -100
  13. package/src/client/network/SessionSupervisor.js +58 -9
  14. package/src/client/network/SignalingClient.js +1 -1
  15. package/src/client/network/WebRtcManager.js +38 -3
  16. package/src/client/session/GuestSession.js +42 -7
  17. package/src/client/session/HandoffFlow.js +26 -51
  18. package/src/client/session/HostRole.js +8 -6
  19. package/src/client/session/PromotionFlow.js +28 -1
  20. package/src/client/session/RouteBoot.js +28 -9
  21. package/src/client/style.css +31 -16
  22. package/src/client/views/includes/roomMenu.pug +7 -8
  23. package/src/config/chatMessages.js +1 -1
  24. package/src/config/lobby.js +22 -23
  25. package/src/devtools/ScenarioRunner.js +1 -0
  26. package/src/devtools/invariants.js +24 -2
  27. package/src/devtools/report.js +2 -2
  28. package/src/host/HostGame.js +14 -26
  29. package/src/host/PortMachine.js +34 -17
  30. package/src/host/host.worker.js +1 -13
  31. package/src/host/meta/core/RoundManager.js +0 -21
  32. package/src/host/meta/modules/chat/systemMessages.js +1 -1
  33. package/src/lib/capabilities.js +2 -4
  34. package/src/lib/gameConfigView.js +0 -6
@@ -103,8 +103,8 @@ export function formatMarkdown(report) {
103
103
  }
104
104
  }
105
105
 
106
- // смены хоста через контрольную точку: 'soft' у игры, которая ждала
107
- // 'midRound', — первое, что автор должен увидеть
106
+ // смены хоста через контрольную точку: 'soft' (дамп ядра не снялся) —
107
+ // первое, что автор должен увидеть
108
108
  if (report.checkpointRestores?.length) {
109
109
  lines.push('');
110
110
  lines.push('## Host checkpoints');
@@ -156,9 +156,6 @@ export default class HostGame {
156
156
  this._isDevMode = data.isDevMode || false;
157
157
  this._seed = seed;
158
158
 
159
- // контрольная точка (host-migration этап 5): игра гарантирует, что дамп
160
- // ядра и хуки модулей переносят всё её состояние посреди раунда
161
- this._migrationMidRound = data.migration?.midRound === true;
162
159
  this._handoffFlushTimeoutMs = data.handoffFlushTimeoutMs ?? 3000;
163
160
  // промоушен преемника (host-migration этап 7.4): сколько восстановленный
164
161
  // матч ждёт возврата людей точки перед стартом
@@ -377,6 +374,9 @@ export default class HostGame {
377
374
  this._lastCheckpointAt = 0;
378
375
  this._checkpointRequest = null; // { final } — точка на ближайшем кадре
379
376
  this._checkpointCounter = 0;
377
+ // дамп упал на прошлой точке: повторный сбой не спамит консоль дважды в
378
+ // секунду, а успех снимает флаг — следующий сбой снова виден
379
+ this._dumpFailing = false;
380
380
  // матч поднят из точки и ждёт startAfterRestore: цикл и таймеры стоят
381
381
  this._restorePending = false;
382
382
  this._restoreMode = null; // 'midRound' | 'soft'
@@ -931,23 +931,6 @@ export default class HostGame {
931
931
  this._roundManager.cancelHandoff();
932
932
  }
933
933
 
934
- // плановая передача хоста (host-migration этап 8d): игра без
935
- // migration.midRound переносит только мету — передача посреди раунда
936
- // отняла бы его у всех, поэтому она ждёт следующего раунда. Игра с
937
- // midRound продолжает у преемника с того же тика — ждать нечего
938
- awaitRoundBoundary(cb) {
939
- if (this._migrationMidRound) {
940
- cb();
941
- return;
942
- }
943
-
944
- this._roundManager.onRoundBoundary(cb);
945
- }
946
-
947
- cancelRoundBoundary() {
948
- this._roundManager.cancelRoundBoundary();
949
- }
950
-
951
934
  // финальная синхронизация профилей, но не дольше handoffFlushTimeoutMs:
952
935
  // зависший auth-сервис не должен держать эстафету — неотправленное едет
953
936
  // в состоянии (playerData) и уйдёт из нового Worker'а
@@ -1211,9 +1194,9 @@ export default class HostGame {
1211
1194
 
1212
1195
  /**
1213
1196
  * Собирает контрольную точку: мета (формат HANDOFF_VERSION 4) и дамп
1214
- * ядра. Ядро прикладывается только у игры с migration.midRound и только
1215
- * если дамп снялся — иначе режим 'soft' (восстановление начнёт раунд
1216
- * заново).
1197
+ * ядра. Сетевая точка ('checkpoint') всегда пробует дамп; не снялся —
1198
+ * режим 'soft' (восстановление начнёт раунд заново). Эстафета Worker'ов
1199
+ * ('boundary') стоит на границе раунда и ядро не несёт.
1217
1200
  * @param {'checkpoint'|'boundary'} kind
1218
1201
  * @returns {{meta: Object, core: Uint8Array|null}}
1219
1202
  */
@@ -1221,14 +1204,19 @@ export default class HostGame {
1221
1204
  let core = null;
1222
1205
  let plugin = null;
1223
1206
 
1224
- if (kind === 'checkpoint' && this._migrationMidRound) {
1207
+ if (kind === 'checkpoint') {
1225
1208
  try {
1226
1209
  plugin = this._serializeModules();
1227
1210
  core = this._game.serializeState();
1211
+ this._dumpFailing = false;
1228
1212
  } catch (e) {
1229
1213
  // точка остаётся полезной и без ядра: аварийный преемник начнёт
1230
1214
  // раунд заново, а не потеряет комнату
1231
- console.warn(`[checkpoint] mid-round state skipped: ${e.message}`);
1215
+ if (!this._dumpFailing) {
1216
+ console.warn(`[checkpoint] mid-round state skipped: ${e.message}`);
1217
+ }
1218
+
1219
+ this._dumpFailing = true;
1232
1220
  core = null;
1233
1221
  plugin = null;
1234
1222
  }
@@ -2075,7 +2063,7 @@ export default class HostGame {
2075
2063
  // транспорт участника оборвался без LEAVE: место держится до возврата.
2076
2064
  // Слот занят (isFull его считает), актор остаётся в мире, но все его
2077
2065
  // команды отпущены; RTT- и idle-кики его не трогают. false — держать
2078
- // нечего (не вошёл в матч, хост-игрок, уже отсоединён) — снимать сразу
2066
+ // нечего (нет ключа места, хост-игрок, уже отсоединён) — снимать сразу
2079
2067
  detachUser(gameId) {
2080
2068
  const user = this._participants.get(gameId);
2081
2069
 
@@ -145,6 +145,8 @@ export default class PortMachine {
145
145
  gameId: undefined,
146
146
  enabled: new Array(PORT_COUNT).fill(false),
147
147
  refreshing: false,
148
+ // соединение входило в матч (первый кадр или RESUME)
149
+ entered: false,
148
150
  };
149
151
 
150
152
  this._clients.set(socketId, state);
@@ -160,6 +162,8 @@ export default class PortMachine {
160
162
  gameId: undefined,
161
163
  enabled: new Array(PORT_COUNT).fill(false),
162
164
  refreshing: false,
165
+ // соединение входило в матч (первый кадр или RESUME)
166
+ entered: false,
163
167
  resumeTimer: null,
164
168
  };
165
169
 
@@ -218,6 +222,7 @@ export default class PortMachine {
218
222
  gameId,
219
223
  enabled: new Array(PORT_COUNT).fill(false),
220
224
  refreshing: false,
225
+ entered: true,
221
226
  };
222
227
 
223
228
  state.methods = this._buildPortMethods(socketId, state);
@@ -281,8 +286,14 @@ export default class PortMachine {
281
286
  }
282
287
 
283
288
  // обрыв без LEAVE (host-migration этап 4): место держится resumeGraceMs,
284
- // если участнику есть куда вернуться (он вошёл в матч и получил ключ)
285
- if (this._resumeGraceMs > 0 && this._host.detachUser(state.gameId)) {
289
+ // если соединение уже входило в матч — только из матча клиент умеет
290
+ // вернуться (SessionSupervisor). Ключ у грузящего карту есть ради точки
291
+ // смены хоста, но его обрыв до первого кадра снимает место сразу
292
+ if (
293
+ this._resumeGraceMs > 0 &&
294
+ state.entered &&
295
+ this._host.detachUser(state.gameId)
296
+ ) {
286
297
  this._startGrace(state.gameId);
287
298
  return;
288
299
  }
@@ -389,6 +400,7 @@ export default class PortMachine {
389
400
  this._stopGrace(gameId);
390
401
 
391
402
  state.gameId = gameId;
403
+ state.entered = true;
392
404
  state.enabled[PC_RESUME_REQUEST] = false;
393
405
  this._enableInGame(state);
394
406
 
@@ -397,6 +409,23 @@ export default class PortMachine {
397
409
  .catch(() => reject('auth'));
398
410
  }
399
411
 
412
+ // участник вошёл — с этого момента ему есть куда вернуться: секрет места
413
+ // (SESSION_DATA). Выдаётся при принятии авторизации, а не на первом
414
+ // кадре: смена хоста посреди загрузки карты не должна стоить места
415
+ _issueSession(socketId, gameId) {
416
+ if (this._resumeGraceMs <= 0 || !this._clients.has(socketId)) {
417
+ return;
418
+ }
419
+
420
+ const resumeKey = this._host.issueResumeKey(gameId);
421
+
422
+ // gameId — клиенту для RESUME_REQUEST: наблюдатель свой id больше
423
+ // ниоткуда не узнаёт
424
+ if (resumeKey) {
425
+ this._socketManager.sendSessionData(socketId, { resumeKey, gameId });
426
+ }
427
+ }
428
+
400
429
  // TOKEN_REFRESH { token }: токен принимается, только если он подписан
401
430
  // auth-сервисом и выдан на тот же ник, под которым участник вошёл
402
431
  _refreshToken(socketId, state, data) {
@@ -526,6 +555,7 @@ export default class PortMachine {
526
555
 
527
556
  host.createUser({ ...data, name }, socketId, createdId => {
528
557
  state.gameId = createdId;
558
+ this._issueSession(socketId, createdId);
529
559
  });
530
560
 
531
561
  socketManager.sendTechInform(socketId, 'loading');
@@ -549,23 +579,10 @@ export default class PortMachine {
549
579
  // 3: map ready
550
580
  () => host.mapReady(state.gameId),
551
581
 
552
- // 4: first shot ready. Участник вошёл в матч — с этого момента ему
553
- // есть куда вернуться после обрыва: секрет места (SESSION_DATA)
582
+ // 4: first shot ready: участник в матче — обрыв держит место
554
583
  () => {
584
+ state.entered = true;
555
585
  host.firstShotReady(state.gameId);
556
-
557
- if (this._resumeGraceMs > 0) {
558
- const resumeKey = host.issueResumeKey(state.gameId);
559
-
560
- // gameId — клиенту для RESUME_REQUEST: наблюдатель свой id
561
- // больше ниоткуда не узнаёт
562
- if (resumeKey) {
563
- socketManager.sendSessionData(socketId, {
564
- resumeKey,
565
- gameId: state.gameId,
566
- });
567
- }
568
- }
569
586
  },
570
587
 
571
588
  // 5: keys data ('seq:action:name'; указатель — 'seq:aim:x:y:flags')
@@ -402,18 +402,6 @@ self.onmessage = async event => {
402
402
  }
403
403
  break;
404
404
 
405
- // плановая передача хоста (этап 8d): дождаться границы раунда (у игры
406
- // с migration.midRound — ответ сразу)
407
- case 'round_boundary_wait':
408
- host?.awaitRoundBoundary(() =>
409
- self.postMessage({ type: 'round_boundary' }),
410
- );
411
- break;
412
-
413
- case 'round_boundary_cancel':
414
- host?.cancelRoundBoundary();
415
- break;
416
-
417
405
  case 'freeze':
418
406
  host?.freeze();
419
407
  break;
@@ -422,7 +410,7 @@ self.onmessage = async event => {
422
410
  host?.unfreeze();
423
411
  break;
424
412
 
425
- // хост закрывает комнату («Leave server» последнего человека): закрыть
413
+ // хост закрывает комнату (Exit последнего человека): закрыть
426
414
  // игры участников и дождаться записи профилей — не дольше timeoutMs
427
415
  case 'shutdown': {
428
416
  const timeoutMs =
@@ -84,8 +84,6 @@ class RoundManager {
84
84
 
85
85
  // эстафета Worker'ов (Этап 5.2): колбэк переноса на границе раунда
86
86
  this._handoffCallback = null;
87
- // плановая передача хоста ждёт границы раунда (host-migration этап 8d)
88
- this._boundaryCallback = null;
89
87
  }
90
88
 
91
89
  get currentMap() {
@@ -325,18 +323,6 @@ class RoundManager {
325
323
  this._handoffCallback = null;
326
324
  }
327
325
 
328
- // плановая передача хоста в игре без migration.midRound (host-migration
329
- // этап 8d): колбэк сработает один раз, когда стартует следующий раунд.
330
- // Раунд не придерживается — бета всё равно начнёт его заново из мягкой
331
- // точки, а сорвавшаяся передача не должна оставить комнату без раунда
332
- onRoundBoundary(cb) {
333
- this._boundaryCallback = cb;
334
- }
335
-
336
- cancelRoundBoundary() {
337
- this._boundaryCallback = null;
338
- }
339
-
340
326
  // запуск нового раунда
341
327
  initiateNewRound() {
342
328
  this._timerManager.stopRoundTimer();
@@ -354,13 +340,6 @@ class RoundManager {
354
340
 
355
341
  this._startRound();
356
342
  this._timerManager.startRoundTimer();
357
-
358
- if (this._boundaryCallback) {
359
- const cb = this._boundaryCallback;
360
-
361
- this._boundaryCallback = null;
362
- cb();
363
- }
364
343
  }
365
344
 
366
345
  // начало раунда
@@ -30,7 +30,7 @@ export const ENGINE_MESSAGE_CODES = Object.freeze({
30
30
  // голосование «Change host» (host-migration этап 10): ведёт мастер,
31
31
  // сообщения добавляет сам клиент, по проводу они не ходят
32
32
  HOST_VOTE_USAGE: 'v:6', // Usage: /changehost
33
- // You are the host — use “Hand over host” in the room menu
33
+ // You are the host — the room hands the host role over automatically when needed
34
34
  HOST_VOTE_IS_HOST: 'v:7',
35
35
  HOST_VOTE_OFFLINE: 'v:8', // No connection to the master server
36
36
  HOST_VOTE_RECENT: 'v:9', // A host vote was held recently
@@ -46,10 +46,8 @@ export const ENGINE_CAPABILITIES = createRegistry('engine-capabilities', [
46
46
  // сервис пула `diagnostics` (журнал клиентских ошибок);
47
47
  // опционален — игра не пишет его в `requires`
48
48
  { value: 'diagnostics', since: '0.35.0' },
49
- // контрольная точка хоста посреди раунда (host-migration этап 5):
50
- // gameConfig.migration.midRound, хуки модулей serializeState/restoreState.
51
- // Писать в `requires` не нужно: старый движок флаг просто игнорирует, и
52
- // игра на нём мигрирует мягко
49
+ // контрольная точка хоста посреди раунда (host-migration этап 5): хуки
50
+ // модулей serializeState/restoreState. Писать в `requires` не нужно
53
51
  { value: 'host.migration', since: '0.36.0' },
54
52
  // голосовая связь (gameConfig.voice, порты VOICE_*, сервис пула `voice`,
55
53
  // хуки `hooks.voiceSounds`/`hooks.voiceFilter`); опциональна — игра не
@@ -74,12 +74,6 @@ const FIELDS = {
74
74
  // сливает конфиг поверхностно, и игра, задавшая `timers`, потеряла бы
75
75
  // остальные таймеры движка
76
76
  voteHoldTime: { default: 0 },
77
-
78
- // смена хоста посреди раунда (host-migration этап 5): true — игра
79
- // гарантирует, что GameSim::serialize/deserialize и хуки модулей
80
- // serializeState/restoreState переносят всё её состояние. Без поля —
81
- // мягкий режим: мета переносится, раунд начинается заново
82
- 'migration.midRound': { default: false },
83
77
  };
84
78
 
85
79
  // Все пути gameConfig, которые движок читает: объявленные игрой поля плюс