vimp-engine 0.40.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 (49) hide show
  1. package/core/Cargo.toml +1 -1
  2. package/package.json +1 -1
  3. package/src/client/components/controller/RoomMenu.js +38 -10
  4. package/src/client/components/controller/Vote.js +26 -0
  5. package/src/client/components/model/Controls.js +9 -1
  6. package/src/client/components/model/RoomMenu.js +42 -57
  7. package/src/client/components/model/Vote.js +202 -0
  8. package/src/client/components/view/Panel.js +176 -6
  9. package/src/client/components/view/RoomMenu.js +78 -24
  10. package/src/client/components/view/Voice.js +6 -1
  11. package/src/client/components/view/Vote.js +71 -2
  12. package/src/client/lib/autoRejoin.js +161 -0
  13. package/src/client/lib/hostUnloadGuard.js +40 -15
  14. package/src/client/lib/roomLink.js +32 -0
  15. package/src/client/main.js +120 -36
  16. package/src/client/network/HostConnectionManager.js +27 -1
  17. package/src/client/network/HostController.js +1 -30
  18. package/src/client/network/HostHealthPolicy.js +14 -59
  19. package/src/client/network/PlannedHandoff.js +15 -100
  20. package/src/client/network/SessionSupervisor.js +58 -9
  21. package/src/client/network/SignalingClient.js +1 -1
  22. package/src/client/network/WebRtcManager.js +38 -3
  23. package/src/client/session/GuestSession.js +42 -7
  24. package/src/client/session/HandoffFlow.js +26 -51
  25. package/src/client/session/HostRole.js +8 -6
  26. package/src/client/session/PromotionFlow.js +28 -1
  27. package/src/client/session/RouteBoot.js +28 -9
  28. package/src/client/style.css +112 -20
  29. package/src/client/views/includes/roomMenu.pug +7 -8
  30. package/src/client/voice/MicCapture.js +3 -1
  31. package/src/config/abiOps.js +42 -0
  32. package/src/config/chatMessages.js +1 -1
  33. package/src/config/lobby.js +22 -23
  34. package/src/config/menuName.js +3 -0
  35. package/src/devtools/ScenarioRunner.js +1 -0
  36. package/src/devtools/invariants.js +24 -2
  37. package/src/devtools/report.js +2 -2
  38. package/src/host/GameCoreAdapter.js +83 -4
  39. package/src/host/HostGame.js +103 -39
  40. package/src/host/PortMachine.js +34 -17
  41. package/src/host/host.worker.js +1 -13
  42. package/src/host/meta/core/RoundManager.js +8 -22
  43. package/src/host/meta/core/VoteCoordinator.js +56 -1
  44. package/src/host/meta/modules/TimerManager.js +16 -2
  45. package/src/host/meta/modules/Vote.js +65 -0
  46. package/src/host/meta/modules/chat/systemMessages.js +1 -1
  47. package/src/host/meta/player/ParticipantManager.js +19 -3
  48. package/src/lib/capabilities.js +19 -4
  49. package/src/lib/gameConfigView.js +6 -5
@@ -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
  // начало раунда
@@ -398,6 +377,10 @@ class RoundManager {
398
377
  this._game.createMap(this._scaledMapData);
399
378
 
400
379
  for (const user of this._participants.getHumans()) {
380
+ // CLEAR уходит синхронно, личная очередь — по сообщению на кадр:
381
+ // меню и подсказка прошлого раунда не должны прийти после CLEAR
382
+ this._voteCoordinator.dropPersonal(user.gameId);
383
+
401
384
  if (user.isReady === false) {
402
385
  continue;
403
386
  }
@@ -579,6 +562,9 @@ class RoundManager {
579
562
  .filter(u => u.teamId !== this._spectatorId && u.gameId !== gameId)
580
563
  .length < 2
581
564
  ) {
565
+ // новая игра: ядро сбрасывает накопленное между раундами до
566
+ // removePlayersAndShots, который зовёт initiateNewRound
567
+ this._game.resetMatch?.();
582
568
  this._stat.reset();
583
569
  this.initiateNewRound();
584
570
  this._chat.pushSystemByUser(gameId, 'TEAMS_NEW_TEAM', [newTeam]);
@@ -649,7 +635,7 @@ class RoundManager {
649
635
 
650
636
  user.status = 'spectator';
651
637
  user.isWatching = true;
652
- user.watchedGameId = this._participants.getActiveList()[0] || null;
638
+ user.watchedGameId = this._participants.firstWatchable();
653
639
  user.forceCameraReset = true;
654
640
  // слот освобождается вместе с актором, иначе он держится за наблюдателем
655
641
  user.respawnIndex = null;
@@ -2,14 +2,36 @@
2
2
  // Инфраструктура поверх Vote + TimerManager; не знает о round/map-логике
3
3
  // (вызывающие передают готовые payload и resultFunc).
4
4
  class VoteCoordinator {
5
- constructor({ vote, chat, timerManager }) {
5
+ constructor({ vote, chat, timerManager, voteHoldTime = 0 }) {
6
6
  this._vote = vote;
7
7
  this._chat = chat;
8
8
  this._timerManager = timerManager;
9
+ this._voteHoldTime =
10
+ Number.isFinite(voteHoldTime) && voteHoldTime > 0 ? voteHoldTime : 0;
11
+ this._deferred = new Map(); // voteCategory → data голосования игры
12
+ }
13
+
14
+ // идут ли первые `voteHoldTime` мс раунда (удержание голосований)
15
+ isHeld() {
16
+ return (
17
+ this._voteHoldTime > 0 &&
18
+ this._timerManager.roundElapsed() < this._voteHoldTime
19
+ );
20
+ }
21
+
22
+ // убирает неотправленные личные сообщения меню игрока (старт раунда)
23
+ dropPersonal(gameId) {
24
+ this._vote.dropPersonal(gameId);
9
25
  }
10
26
 
11
27
  // проверяет возможность создать голосование в данной категории
12
28
  canCreateVote(voteCategory, gameId) {
29
+ if (gameId && this.isHeld()) {
30
+ this._chat.pushSystemByUser(gameId, 'VOTE_UNAVAILABLE');
31
+
32
+ return false;
33
+ }
34
+
13
35
  if (
14
36
  this._timerManager.isVoteBlocked(voteCategory) ||
15
37
  this._vote.hasVoteCategory(voteCategory)
@@ -29,6 +51,14 @@ class VoteCoordinator {
29
51
  const { voteName, voteCategory, payload, resultFunc, userList, gameId } =
30
52
  data;
31
53
 
54
+ // голосование игры откладывается до конца удержания; новое того же
55
+ // раздела заменяет отложенное
56
+ if (!gameId && this.isHeld()) {
57
+ this._defer(voteCategory, data);
58
+
59
+ return;
60
+ }
61
+
32
62
  // если голосование инициировано пользователем
33
63
  if (gameId) {
34
64
  this._chat.pushSystemByUser(gameId, 'VOTE_CREATED');
@@ -60,8 +90,33 @@ class VoteCoordinator {
60
90
  });
61
91
  }
62
92
 
93
+ // откладывает голосование игры на остаток удержания
94
+ _defer(voteCategory, data) {
95
+ this._deferred.set(voteCategory, data);
96
+
97
+ this._timerManager.startVoteHoldTimer(
98
+ () => this._releaseDeferred(),
99
+ this._voteHoldTime - this._timerManager.roundElapsed(),
100
+ );
101
+ }
102
+
103
+ // запускает отложенные голосования по окончании удержания
104
+ _releaseDeferred() {
105
+ const list = [...this._deferred];
106
+
107
+ this._deferred.clear();
108
+
109
+ for (const [category, data] of list) {
110
+ if (this.canCreateVote(category)) {
111
+ this.createVote(data);
112
+ }
113
+ }
114
+ }
115
+
63
116
  // сбрасывает активные и запланированные опросы и таймеры по ним
64
117
  reset() {
118
+ this._deferred.clear();
119
+ this._timerManager.stopVoteHoldTimer();
65
120
  this._timerManager.stopAllVoteTimers();
66
121
  this._timerManager.stopAllBlockedVoteTimers();
67
122
  this._vote.reset();
@@ -82,6 +82,7 @@ class TimerManager extends AbstractTimer {
82
82
  this._stopRoundRestartDelay();
83
83
  this._stopMapChangeDelay();
84
84
  this._stopRttPingTimer();
85
+ this.stopVoteHoldTimer();
85
86
  }
86
87
 
87
88
  // запускает таймер до конца текущей карты (duration — остаток времени
@@ -141,9 +142,12 @@ class TimerManager extends AbstractTimer {
141
142
 
142
143
  // проверяет возможно сменить команду игроку в текущем раунде
143
144
  canChangeTeamInCurrentRound() {
144
- const roundTime = this._now() - this._startRoundTime;
145
+ return this.roundElapsed() <= this._teamChangeGracePeriod;
146
+ }
145
147
 
146
- return roundTime <= this._teamChangeGracePeriod;
148
+ // миллисекунды от старта раунда
149
+ roundElapsed() {
150
+ return this._now() - this._startRoundTime;
147
151
  }
148
152
 
149
153
  // логика одного "тика" игрового цикла
@@ -230,6 +234,16 @@ class TimerManager extends AbstractTimer {
230
234
  this._startTimer(`vote:${voteName}`, onEndCallback, this._voteTime);
231
235
  }
232
236
 
237
+ // запускает таймер конца удержания голосований (A18)
238
+ startVoteHoldTimer(onEndCallback, duration) {
239
+ this._startTimer('voteHold', onEndCallback, duration);
240
+ }
241
+
242
+ // останавливает таймер удержания голосований
243
+ stopVoteHoldTimer() {
244
+ this._stopTimer('voteHold');
245
+ }
246
+
233
247
  // останавливает все активные таймеры голосования
234
248
  stopAllVoteTimers() {
235
249
  for (const key of this._timers.keys()) {
@@ -56,6 +56,43 @@ class Vote {
56
56
  this._userList[gameId].push(arr);
57
57
  }
58
58
 
59
+ // добавляет личное сообщение меню/подсказки (`menu`, `menuClose`,
60
+ // `menuHint`). Ещё не отправленные сообщения того же вида и имени
61
+ // вытесняются: очередь не растёт от частых обновлений, порядок
62
+ // «закрыть → открыть» сохраняется. Списки `teams`/`maps` не трогаются
63
+ pushPersonal(gameId, payload) {
64
+ const queue = this._userList[gameId];
65
+
66
+ if (!queue) {
67
+ return;
68
+ }
69
+
70
+ const key = personalKey(payload);
71
+ const name = personalName(payload);
72
+
73
+ this._userList[gameId] = queue.filter(item => {
74
+ const itemKey = personalKey(item);
75
+
76
+ return (
77
+ itemKey === null ||
78
+ personalName(item) !== name ||
79
+ personalGroup(itemKey) !== personalGroup(key)
80
+ );
81
+ });
82
+
83
+ this._userList[gameId].push(payload);
84
+ }
85
+
86
+ // убирает неотправленные личные сообщения: CLEAR уходит синхронно, а
87
+ // очередь — по сообщению на кадр, и меню прошлого раунда пришло бы после
88
+ dropPersonal(gameId) {
89
+ const queue = this._userList[gameId];
90
+
91
+ if (queue) {
92
+ this._userList[gameId] = queue.filter(item => personalKey(item) === null);
93
+ }
94
+ }
95
+
59
96
  // возвращает данные
60
97
  shift() {
61
98
  return this._list.shift();
@@ -178,6 +215,34 @@ class Vote {
178
215
  }
179
216
  }
180
217
 
218
+ const PERSONAL_KEYS = ['menu', 'menuClose', 'menuHint'];
219
+
220
+ // ключ личного сообщения (`menu`/`menuClose`/`menuHint`) или null — это
221
+ // список голосования (массив или шаблон)
222
+ function personalKey(item) {
223
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) {
224
+ return null;
225
+ }
226
+
227
+ return PERSONAL_KEYS.find(key => Object.hasOwn(item, key)) ?? null;
228
+ }
229
+
230
+ // имя меню, к которому относится личное сообщение
231
+ function personalName(item) {
232
+ const key = personalKey(item);
233
+
234
+ if (key === 'menu') {
235
+ return item.menu.name;
236
+ }
237
+
238
+ return key === 'menuHint' ? item.menuHint.name : item[key];
239
+ }
240
+
241
+ // `menu` и `menuClose` вытесняют друг друга, `menuHint` — только себя
242
+ function personalGroup(key) {
243
+ return key === 'menuHint' ? 'hint' : 'menu';
244
+ }
245
+
181
246
  // Сброс синглтона. Нужен только тем, кто крутит больше одного матча в
182
247
  // процессе — headless-runner (devtools/resetHostSingletons.js) и тесты;
183
248
  // в браузерной вкладке матч всегда один, поэтому прод его не зовёт.
@@ -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
@@ -24,6 +24,8 @@ class ParticipantManager {
24
24
  this._participants = new Map(); // gameId -> Participant
25
25
  this._teamSizes = {}; // team -> Set<gameId>
26
26
  this._activePlayersList = []; // gameId[]
27
+ // жив ли игрок в ядре; ядро появляется позже менеджера (setAliveCheck)
28
+ this._isAlive = () => true;
27
29
 
28
30
  this.resetTeamSizes();
29
31
  }
@@ -62,7 +64,7 @@ class ParticipantManager {
62
64
  team: this._joinTeam,
63
65
  teamId: this._joinTeamId,
64
66
  socketId,
65
- watchedGameId: this._activePlayersList[0] || null,
67
+ watchedGameId: this.firstWatchable(),
66
68
  token: params.token,
67
69
  identityName: params.name,
68
70
  });
@@ -113,7 +115,7 @@ class ParticipantManager {
113
115
  team,
114
116
  teamId,
115
117
  socketId,
116
- watchedGameId: this._activePlayersList[0] || null,
118
+ watchedGameId: this.firstWatchable(),
117
119
  token: record.token ?? null,
118
120
  identityName: record.identityName ?? null,
119
121
  });
@@ -335,11 +337,25 @@ class ParticipantManager {
335
337
  // перецепление наблюдателей на другого активного игрока
336
338
  for (const participant of this._participants.values()) {
337
339
  if (participant.watchedGameId === gameId) {
338
- participant.watchedGameId = this._activePlayersList[0] || null;
340
+ participant.watchedGameId = this.firstWatchable();
339
341
  }
340
342
  }
341
343
  }
342
344
 
345
+ setAliveCheck(isAlive) {
346
+ this._isAlive = isAlive;
347
+ }
348
+
349
+ // первая цель наблюдателя: живой активный игрок; живых нет — первый из
350
+ // списка (остов), списка нет — null
351
+ firstWatchable() {
352
+ return (
353
+ this._activePlayersList.find(id => this._isAlive(id)) ??
354
+ this._activePlayersList[0] ??
355
+ null
356
+ );
357
+ }
358
+
343
359
  clearActive() {
344
360
  this._activePlayersList = [];
345
361
  }
@@ -46,15 +46,30 @@ 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`); опциональна — игра не
56
54
  // пишет её в `requires`
57
55
  { value: 'voice', since: '0.39.0' },
56
+ // личное меню игрока в окне `#vote` и подсказка под значком рации:
57
+ // события ядра menu/menuClose/menuHint, опкод `menu.answer`
58
+ { value: 'menu.personal', since: '0.41.0' },
59
+ // опкод `actor.forget`: участник покинул матч (в том числе наблюдатель);
60
+ // без него id ушедшего наследует новичок вместе с профилем игры
61
+ { value: 'actor.forget', since: '0.41.0' },
62
+ // опкод `match.reset`: новая игра (движок обнулил счёт); игра, чей баланс
63
+ // сбрасывается вместе со счётом, объявляет её в `requires`
64
+ { value: 'match.reset', since: '0.41.0' },
65
+ // опкоды `state.persist`/`state.restore`: состояние игры между раундами
66
+ // переживает мягкую миграцию хоста и эстафету Worker'а (мета `persist`)
67
+ { value: 'state.persist', since: '0.41.0' },
68
+ // поле конфига игры `voteHoldTime`: удержание голосований в начале раунда
69
+ { value: 'vote.hold', since: '0.41.0' },
70
+ // свойства ячейки панели (modules.panel.fields[]): label, labelFrom,
71
+ // hidden, format, prefix, suffix, emptyWhen, flashOnChange, countdown
72
+ { value: 'panel.cells', since: '0.41.0' },
58
73
  ]);
59
74
 
60
75
  // плоский список имён — то, чем правило контракта B2 проверяет `requires`
@@ -68,11 +68,12 @@ const FIELDS = {
68
68
 
69
69
  spectatorTeam: { derive: deriveSpectatorTeam },
70
70
 
71
- // смена хоста посреди раунда (host-migration этап 5): true — игра
72
- // гарантирует, что GameSim::serialize/deserialize и хуки модулей
73
- // serializeState/restoreState переносят всё её состояние. Без поля —
74
- // мягкий режим: мета переносится, раунд начинается заново
75
- 'migration.midRound': { default: false },
71
+ // мс от старта раунда, в которые голосования удерживаются: запущенное
72
+ // игроком для других отклоняется, запущенное игрой откладывается; 0 — без
73
+ // удержания. Поле верхнего уровня, а не `timers.*`: applyRoomOverrides
74
+ // сливает конфиг поверхностно, и игра, задавшая `timers`, потеряла бы
75
+ // остальные таймеры движка
76
+ voteHoldTime: { default: 0 },
76
77
  };
77
78
 
78
79
  // Все пути gameConfig, которые движок читает: объявленные игрой поля плюс