vimp-engine 0.39.0 → 0.41.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 (62) hide show
  1. package/README.md +2 -2
  2. package/bin/vimp-sim.js +2 -2
  3. package/core/Cargo.toml +2 -2
  4. package/package.json +3 -4
  5. package/src/client/boot.js +11 -87
  6. package/src/client/components/controller/Vote.js +26 -0
  7. package/src/client/components/model/Controls.js +9 -1
  8. package/src/client/components/model/RoomMenu.js +1 -1
  9. package/src/client/components/model/Vote.js +202 -0
  10. package/src/client/components/view/Auth.js +3 -3
  11. package/src/client/components/view/Panel.js +176 -6
  12. package/src/client/components/view/Voice.js +6 -1
  13. package/src/client/components/view/Vote.js +71 -2
  14. package/src/client/lib/formBuilder.js +7 -6
  15. package/src/client/lib/hostVoteCommand.js +2 -2
  16. package/src/client/lib/pickActiveGame.js +3 -12
  17. package/src/client/lib/retryOnNetworkError.js +0 -9
  18. package/src/client/main.js +47 -90
  19. package/src/client/network/SessionSupervisor.js +14 -14
  20. package/src/client/network/WebRtcManager.js +1 -2
  21. package/src/client/network/policyClose.js +10 -77
  22. package/src/client/style.css +85 -10
  23. package/src/client/views/gameShell.js +2 -4
  24. package/src/client/views/includes/auth.pug +1 -4
  25. package/src/client/views/includes/lobby.pug +3 -6
  26. package/src/client/voice/MicCapture.js +3 -1
  27. package/src/config/abiOps.js +42 -0
  28. package/src/config/closeCodes.js +7 -9
  29. package/src/config/gameCodes.js +1 -1
  30. package/src/config/hostDefaults.js +2 -2
  31. package/src/config/lobby.js +3 -4
  32. package/src/config/menuName.js +3 -0
  33. package/src/config/wsports.js +1 -1
  34. package/src/devtools/ScenarioRunner.js +1 -1
  35. package/src/devtools/builtinScenario.js +2 -2
  36. package/src/devtools/contract/rules/a2-package-scripts.js +1 -1
  37. package/src/devtools/contract/rules/a4-vite-config.js +1 -1
  38. package/src/devtools/contract/rules/c4-component-dependencies.js +1 -1
  39. package/src/devtools/invariants.js +1 -1
  40. package/src/devtools/pluginLoader.js +2 -2
  41. package/src/host/GameCoreAdapter.js +83 -4
  42. package/src/host/HostGame.js +93 -17
  43. package/src/host/PortMachine.js +4 -21
  44. package/src/host/commandFailure.js +2 -2
  45. package/src/host/identity.js +3 -3
  46. package/src/host/meta/core/CommandProcessor.js +2 -2
  47. package/src/host/meta/core/RoundManager.js +8 -1
  48. package/src/host/meta/core/VoteCoordinator.js +56 -1
  49. package/src/host/meta/modules/Accolades.js +1 -1
  50. package/src/host/meta/modules/TimerManager.js +16 -2
  51. package/src/host/meta/modules/Vote.js +65 -0
  52. package/src/host/meta/player/ParticipantManager.js +19 -3
  53. package/src/lib/capabilities.js +17 -0
  54. package/src/lib/createHostRuntime.js +1 -1
  55. package/src/lib/gameConfigView.js +7 -0
  56. package/src/lib/loadGamePackage.js +7 -9
  57. package/src/lib/offlinePlayerData.js +1 -1
  58. package/src/lib/validators.js +2 -2
  59. package/src/lib/voicePacket.js +1 -1
  60. package/src/standalone/index.js +3 -2
  61. package/src/client/lib/footerLink.js +0 -30
  62. package/src/client/network/WebSocketTransport.js +0 -78
@@ -15,6 +15,7 @@ import CommandProcessor from './meta/core/CommandProcessor.js';
15
15
  import { sanitizeMessage } from '../lib/sanitizers.js';
16
16
  import { parseFloorMessage } from '../lib/voiceMessages.js';
17
17
  import closeCodes from '../config/closeCodes.js';
18
+ import { MENU_NAME_PATTERN } from '../config/menuName.js';
18
19
  import clock from '../lib/clock.js';
19
20
  import { createResumeKey } from '../lib/resumeKey.js';
20
21
  import GameCoreAdapter from './GameCoreAdapter.js';
@@ -224,6 +225,7 @@ export default class HostGame {
224
225
  participants: this._participants,
225
226
  onCoreEvent: hostPlugin.onCoreEvent,
226
227
  });
228
+ this._participants.setAliveCheck(id => this._game.isAlive(id));
227
229
 
228
230
  this._panel = new Panel(data.panel);
229
231
  this._stat = new Stat(data.stat, this._teams);
@@ -307,6 +309,7 @@ export default class HostGame {
307
309
  vote: this._vote,
308
310
  chat: this._chat,
309
311
  timerManager: this._timerManager,
312
+ voteHoldTime: data.voteHoldTime,
310
313
  });
311
314
 
312
315
  this._roundManager = new RoundManager({
@@ -394,7 +397,11 @@ export default class HostGame {
394
397
 
395
398
  // внедрение зависимостей (ядро отдаёт панель/фасад события через адаптер)
396
399
  this._socketManager.injectServices(this._game, this._panel, this._stat);
397
- this._game.injectServices({ vimp: this, panel: this._panel });
400
+ this._game.injectServices({
401
+ vimp: this,
402
+ panel: this._panel,
403
+ vote: this._vote,
404
+ });
398
405
  this._panel.injectTimerManager(this._timerManager);
399
406
 
400
407
  this._timerManager.startIdleCheckTimer();
@@ -858,9 +865,9 @@ export default class HostGame {
858
865
 
859
866
  /**
860
867
  * Публичный teardown матча: останов таймеров, финальная синхронизация
861
- * профилей и снятие всех участников. Во вкладке матч умирает вместе с
862
- * Worker'ом, а долгоживущему процессу (dedicated-сервер) нужен graceful
863
- * shutdown — иначе таймеры держат процесс, а rank/state теряются.
868
+ * профилей и снятие всех участников. Его зовут Worker при закрытии
869
+ * комнаты и inline-хост solo при останове матча: без него таймеры
870
+ * переживают матч, а rank/state теряются.
864
871
  * @returns {Promise} Завершение финальной синхронизации профилей.
865
872
  */
866
873
  async destroy() {
@@ -1264,6 +1271,20 @@ export default class HostGame {
1264
1271
  // преемник снимает их сам — иначе в мире остались бы бесхозные тела
1265
1272
  participants.dropped = dropped;
1266
1273
 
1274
+ // малое состояние игры между раундами (опкод state.persist): ядро в
1275
+ // точку не попало, а профили игры должны пережить мягкий перезапуск.
1276
+ // Эстафета стоит на границе раунда — игра подводит закончившийся раунд
1277
+ let persist = null;
1278
+
1279
+ if (core === null) {
1280
+ try {
1281
+ persist = this._game.persistState({ roundOver: kind === 'boundary' });
1282
+ } catch (e) {
1283
+ console.warn(`[checkpoint] persisted state skipped: ${e.message}`);
1284
+ persist = null;
1285
+ }
1286
+ }
1287
+
1267
1288
  const meta = {
1268
1289
  version: HANDOFF_VERSION,
1269
1290
  kind,
@@ -1303,6 +1324,7 @@ export default class HostGame {
1303
1324
  panel: this._panel.serialize(),
1304
1325
  playerData: this._playerDataSync.serialize(),
1305
1326
  plugin: plugin ?? {},
1327
+ persist,
1306
1328
  // текст карточки лобби, выставленный модулем игры (lobby.setInfo):
1307
1329
  // модули нового Worker'а создаются с нуля и сами его не повторят
1308
1330
  lobbyInfo: this._lobbyInfoOverride,
@@ -1463,6 +1485,29 @@ export default class HostGame {
1463
1485
  this._restoreModules(meta.plugin);
1464
1486
  }
1465
1487
 
1488
+ if (!midRound && meta.persist !== undefined && meta.persist !== null) {
1489
+ try {
1490
+ this._game.restorePersisted?.(meta.persist);
1491
+ } catch (e) {
1492
+ console.warn(`[checkpoint] persisted state skipped: ${e.message}`);
1493
+ }
1494
+ }
1495
+
1496
+ // участник точки, которого восстановление не вернуло (не попал в точку,
1497
+ // restoreHuman/restoreScripted отказали), не должен оставить профиль в
1498
+ // ядре: его унаследовал бы новичок с тем же gameId
1499
+ const listed = [
1500
+ ...meta.participants.humans.map(record => record.gameId),
1501
+ ...meta.participants.scripted.map(record => record.gameId),
1502
+ ...(meta.participants.dropped ?? []),
1503
+ ];
1504
+
1505
+ for (const gameId of listed) {
1506
+ if (!this._participants.get(gameId)) {
1507
+ this._forgetActor(gameId);
1508
+ }
1509
+ }
1510
+
1466
1511
  this._restoreMode = midRound ? 'midRound' : 'soft';
1467
1512
  this._restoredTimers = meta.timers ?? {};
1468
1513
  this._restorePending = true;
@@ -1614,25 +1659,27 @@ export default class HostGame {
1614
1659
  return this._frozen;
1615
1660
  }
1616
1661
 
1617
- // меняет и возвращает gameId наблюдаемого игрока
1662
+ // gameId следующего (или предыдущего) наблюдаемого игрока по кругу. Остовы
1663
+ // погибших в списке активных остаются до конца раунда, но камера на них не
1664
+ // переключается; живых нет — наблюдатель остаётся на текущем
1618
1665
  _getNextActivePlayerForUser(gameId, back) {
1619
1666
  const currentId = this._participants.get(gameId)?.watchedGameId;
1620
1667
  const activeList = this._participants.getActiveList();
1621
- let key = activeList.indexOf(currentId);
1668
+ const size = activeList.length;
1669
+ const found = activeList.indexOf(currentId);
1670
+ // текущего нет в списке: счёт идёт с края, в нужную сторону
1671
+ const start = found === -1 ? (back ? 0 : -1) : found;
1672
+ const step = back ? -1 : 1;
1622
1673
 
1623
- if (key !== -1) {
1624
- key = back ? key - 1 : key + 1;
1674
+ for (let i = 1; i <= size; i += 1) {
1675
+ const key = (((start + step * i) % size) + size) % size;
1625
1676
 
1626
- if (key < 0) {
1627
- key = activeList.length - 1;
1628
- } else if (key >= activeList.length) {
1629
- key = 0;
1677
+ if (this._game.isAlive(activeList[key])) {
1678
+ return activeList[key];
1630
1679
  }
1631
-
1632
- return activeList[key];
1633
1680
  }
1634
1681
 
1635
- return activeList[0] || null;
1682
+ return currentId ?? null;
1636
1683
  }
1637
1684
 
1638
1685
  // активирует тряску камеры у игрока (из события ядра)
@@ -1736,6 +1783,10 @@ export default class HostGame {
1736
1783
  this._game.removePlayer(gameId);
1737
1784
  }
1738
1785
 
1786
+ // профиль игры (деньги и т.п.) живёт дольше танка: id ушедшего сразу
1787
+ // достаётся новичку
1788
+ this._forgetActor(gameId);
1789
+
1739
1790
  this._participants.remove(gameId);
1740
1791
 
1741
1792
  this._chat.pushSystem('USER_LEFT', [user.name]);
@@ -1869,7 +1920,9 @@ export default class HostGame {
1869
1920
  } else if (typeof data === 'object' && data !== null) {
1870
1921
  const [type, value] = data;
1871
1922
 
1872
- if (type === 'mapChange') {
1923
+ if (typeof type === 'string' && type.startsWith('menu:')) {
1924
+ this._menuAnswer(gameId, type.slice(5), value);
1925
+ } else if (type === 'mapChange') {
1873
1926
  if (this._participants.getHumans().length === 1) {
1874
1927
  this._roundManager.forceChangeMap(value);
1875
1928
  } else {
@@ -1884,6 +1937,29 @@ export default class HostGame {
1884
1937
  }
1885
1938
  }
1886
1939
 
1940
+ // исключение ядра не должно прервать уход участника или миграцию
1941
+ _forgetActor(gameId) {
1942
+ try {
1943
+ this._game.forgetActor?.(gameId);
1944
+ } catch (e) {
1945
+ console.warn(`[actor.forget] skipped: ${e.message}`);
1946
+ }
1947
+ }
1948
+
1949
+ // ответ игрока на личное меню: проверка формы и передача ядру игры;
1950
+ // `id` — всегда отправитель, клиент его не указывает
1951
+ _menuAnswer(gameId, name, path) {
1952
+ if (
1953
+ !MENU_NAME_PATTERN.test(name) ||
1954
+ typeof path !== 'string' ||
1955
+ !/^[1-9](\.[1-9])?$/.test(path)
1956
+ ) {
1957
+ return;
1958
+ }
1959
+
1960
+ this._game.menuAnswer(gameId, name, path);
1961
+ }
1962
+
1887
1963
  // rank/state участника (Этап B4) — для игровых модулей и чат-команды /rank
1888
1964
  // (Этап B5, CommandProcessor)
1889
1965
  getPlayerRank(gameId) {
@@ -2052,7 +2128,7 @@ export default class HostGame {
2052
2128
  };
2053
2129
  }
2054
2130
 
2055
- // есть ли у участника identity-токен: в standalone и dedicated личность
2131
+ // есть ли у участника identity-токен: в standalone личность
2056
2132
  // гостевая, и обновлять там нечего
2057
2133
  hasToken(gameId) {
2058
2134
  const token = this._participants.get(gameId)?.token;
@@ -1,10 +1,9 @@
1
1
  // Хендшейк-машина клиента, вынутая из host.worker.js: автомат по клиентским
2
- // портам (таблица портов — docs.vimp.lgick.dev/en/snapshot). Изоморфна — ни
2
+ // портам (таблица портов — VIMP docs: snapshot.md). Изоморфна — ни
3
3
  // self, ни postMessage, ни DOM: всё, что знает про транспорт, приходит через
4
4
  // makeSocket. Поэтому её крутит и Worker браузерного хоста, и inline-хост
5
- // standalone SDK, и Node-процесс dedicated-сервера: копий автомата быть не
6
- // должно, иначе они разъедутся ровно так, как разъезжались бы копии
7
- // createHostRuntime.
5
+ // standalone SDK, и headless-прогон: копий автомата быть не должно, иначе
6
+ // они разъедутся ровно так, как разъезжались бы копии createHostRuntime.
8
7
 
9
8
  import wsports from '../config/wsports.js';
10
9
  import closeCodes from '../config/closeCodes.js';
@@ -57,7 +56,7 @@ export default class PortMachine {
57
56
  * @param {Object} deps.identity - стратегия идентичности (./identity.js).
58
57
  * @param {number} [deps.resumeGraceMs] - сколько держать место участника,
59
58
  * чей транспорт оборвался (host-migration этап 4); 0 — снимать сразу
60
- * (dedicated, standalone: поведение до этапа 4).
59
+ * (standalone).
61
60
  * @param {number} [deps.resumeRequestTimeoutMs] - сколько ждать
62
61
  * RESUME_REQUEST от возобновляющего соединения.
63
62
  */
@@ -466,22 +465,6 @@ export default class PortMachine {
466
465
  }
467
466
  }
468
467
 
469
- /**
470
- * @param {string} socketId
471
- * @returns {boolean} Есть ли порт-машина у этого соединения.
472
- */
473
- has(socketId) {
474
- return this._clients.has(socketId);
475
- }
476
-
477
- /**
478
- * @param {string} socketId
479
- * @returns {boolean} Дошёл ли клиент до созданного участника матча.
480
- */
481
- hasParticipant(socketId) {
482
- return this._clients.get(socketId)?.gameId !== undefined;
483
- }
484
-
485
468
  /**
486
469
  * @returns {Iterator<string>} socketId живых соединений (для
487
470
  * host.completeHandoff(new Set(...))).
@@ -1,7 +1,7 @@
1
1
  // Отчёт о сбое чат-команды игры для приёмника диагностики
2
2
  // (HostGame.setDiagnosticSink): сообщение в одну строку и стек без текста
3
- // ошибки. Один отчёт на все среды — Worker (журнал платформы), dedicated
4
- // (лог процесса), standalone (консоль вкладки). Чистые функции, без DOM и
3
+ // ошибки. Один отчёт на все среды — Worker (журнал платформы), standalone
4
+ // (консоль вкладки), headless-прогон (лог процесса). Чистые функции, без DOM и
5
5
  // Node API: модуль работает в Worker'е
6
6
 
7
7
  // символы, подделывающие строки лога: управляющие (перевод строки, ESC),
@@ -1,7 +1,7 @@
1
1
  // Стратегии идентичности хоста: как порт-машина (PortMachine.js) узнаёт,
2
2
  // под каким ником участник входит в комнату. Прод-лобби доверяет только
3
- // подписанному identity-токену центрального auth-сервиса; standalone- и
4
- // dedicated-контуры мастера не имеют вовсе и пускают гостей по нику из формы.
3
+ // подписанному identity-токену центрального auth-сервиса; standalone-контур
4
+ // мастера не имеет вовсе и пускает гостей по нику из формы.
5
5
  //
6
6
  // Контракт стратегии:
7
7
  // { params: Array, errorField: string, resolve(data, socketId): Promise<string> }
@@ -61,7 +61,7 @@ export function createTokenIdentity({ jwksUrl, issuer }) {
61
61
  }
62
62
 
63
63
  /**
64
- * Гостевой вход (standalone/dedicated): ник — поле формы, которое хост
64
+ * Гостевой вход (standalone): ник — поле формы, которое хост
65
65
  * валидирует сам движковым isValidName, без кода игры.
66
66
  *
67
67
  * Ограничение (принимается осознанно): гостевые ники не уникальны и не
@@ -51,8 +51,8 @@ class CommandProcessor {
51
51
  return;
52
52
  }
53
53
 
54
- // в Worker'е сбой стал бы необработанным reject'ом, в dedicated-процессе —
55
- // uncaughtException, то есть падением матча всех игроков
54
+ // в Worker'е сбой стал бы необработанным reject'ом, то есть падением
55
+ // матча всех игроков
56
56
  try {
57
57
  const result = handler(this._ctx, gameId, arr);
58
58
 
@@ -398,6 +398,10 @@ class RoundManager {
398
398
  this._game.createMap(this._scaledMapData);
399
399
 
400
400
  for (const user of this._participants.getHumans()) {
401
+ // CLEAR уходит синхронно, личная очередь — по сообщению на кадр:
402
+ // меню и подсказка прошлого раунда не должны прийти после CLEAR
403
+ this._voteCoordinator.dropPersonal(user.gameId);
404
+
401
405
  if (user.isReady === false) {
402
406
  continue;
403
407
  }
@@ -579,6 +583,9 @@ class RoundManager {
579
583
  .filter(u => u.teamId !== this._spectatorId && u.gameId !== gameId)
580
584
  .length < 2
581
585
  ) {
586
+ // новая игра: ядро сбрасывает накопленное между раундами до
587
+ // removePlayersAndShots, который зовёт initiateNewRound
588
+ this._game.resetMatch?.();
582
589
  this._stat.reset();
583
590
  this.initiateNewRound();
584
591
  this._chat.pushSystemByUser(gameId, 'TEAMS_NEW_TEAM', [newTeam]);
@@ -649,7 +656,7 @@ class RoundManager {
649
656
 
650
657
  user.status = 'spectator';
651
658
  user.isWatching = true;
652
- user.watchedGameId = this._participants.getActiveList()[0] || null;
659
+ user.watchedGameId = this._participants.firstWatchable();
653
660
  user.forceCameraReset = true;
654
661
  // слот освобождается вместе с актором, иначе он держится за наблюдателем
655
662
  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();
@@ -234,7 +234,7 @@ export default class Accolades {
234
234
  // payload.nick, и PortMachine передаёт в createUser именно его), а не
235
235
  // то, что игрок написал в форме.
236
236
  //
237
- // Меняет это в гостевом контуре (standalone/dedicated), где ник —
237
+ // Меняет это в гостевом контуре (standalone), где ник —
238
238
  // поле формы и «не защищён от подмены» по прямому признанию
239
239
  // createGuestIdentity. Токена у гостя нет, и сопоставление по нику
240
240
  // выдало бы ему чужую корону — стоит она ровно того, что значит.
@@ -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
  // в браузерной вкладке матч всегда один, поэтому прод его не зовёт.
@@ -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
  }
@@ -55,6 +55,23 @@ export const ENGINE_CAPABILITIES = createRegistry('engine-capabilities', [
55
55
  // хуки `hooks.voiceSounds`/`hooks.voiceFilter`); опциональна — игра не
56
56
  // пишет её в `requires`
57
57
  { value: 'voice', since: '0.39.0' },
58
+ // личное меню игрока в окне `#vote` и подсказка под значком рации:
59
+ // события ядра menu/menuClose/menuHint, опкод `menu.answer`
60
+ { value: 'menu.personal', since: '0.41.0' },
61
+ // опкод `actor.forget`: участник покинул матч (в том числе наблюдатель);
62
+ // без него id ушедшего наследует новичок вместе с профилем игры
63
+ { value: 'actor.forget', since: '0.41.0' },
64
+ // опкод `match.reset`: новая игра (движок обнулил счёт); игра, чей баланс
65
+ // сбрасывается вместе со счётом, объявляет её в `requires`
66
+ { value: 'match.reset', since: '0.41.0' },
67
+ // опкоды `state.persist`/`state.restore`: состояние игры между раундами
68
+ // переживает мягкую миграцию хоста и эстафету Worker'а (мета `persist`)
69
+ { value: 'state.persist', since: '0.41.0' },
70
+ // поле конфига игры `voteHoldTime`: удержание голосований в начале раунда
71
+ { value: 'vote.hold', since: '0.41.0' },
72
+ // свойства ячейки панели (modules.panel.fields[]): label, labelFrom,
73
+ // hidden, format, prefix, suffix, emptyWhen, flashOnChange, countdown
74
+ { value: 'panel.cells', since: '0.41.0' },
58
75
  ]);
59
76
 
60
77
  // плоский список имён — то, чем правило контракта B2 проверяет `requires`
@@ -31,7 +31,7 @@ import HostGame from '../host/HostGame.js';
31
31
  * прогону (сценарий подкручивает networkSendRate и таймеры).
32
32
  * @param {boolean} [options.voiceRelay] - У контура есть ретранслятор голоса
33
33
  * на транспортном уровне (браузерный хост лобби); без него связь
34
- * выключается, даже если игра её объявила (standalone, headless, dedicated).
34
+ * выключается, даже если игра её объявила (standalone, headless).
35
35
  * @returns {Promise<Object>} { hostPlugin, game, seed, core, clientCfg,
36
36
  * socketManager, host }.
37
37
  */
@@ -68,6 +68,13 @@ const FIELDS = {
68
68
 
69
69
  spectatorTeam: { derive: deriveSpectatorTeam },
70
70
 
71
+ // мс от старта раунда, в которые голосования удерживаются: запущенное
72
+ // игроком для других отклоняется, запущенное игрой откладывается; 0 — без
73
+ // удержания. Поле верхнего уровня, а не `timers.*`: applyRoomOverrides
74
+ // сливает конфиг поверхностно, и игра, задавшая `timers`, потеряла бы
75
+ // остальные таймеры движка
76
+ voteHoldTime: { default: 0 },
77
+
71
78
  // смена хоста посреди раунда (host-migration этап 5): true — игра
72
79
  // гарантирует, что GameSim::serialize/deserialize и хуки модулей
73
80
  // serializeState/restoreState переносят всё её состояние. Без поля —