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
@@ -177,6 +177,39 @@ body.hide-cursor {
177
177
  background-color: #333;
178
178
  }
179
179
 
180
+ /* ячейки с подписью (panel.cells): подпись, значение и шкала */
181
+ #panel table td {
182
+ position: relative;
183
+ }
184
+
185
+ #panel table td .panel-label {
186
+ opacity: 0.6;
187
+ margin-right: 0.4em;
188
+ }
189
+
190
+ #panel table td.is-empty {
191
+ opacity: 0.5;
192
+ }
193
+
194
+ #panel table td div.panel-gauge {
195
+ position: absolute;
196
+ left: 0;
197
+ bottom: 0;
198
+ width: 0;
199
+ height: 2px;
200
+ background-color: currentColor;
201
+ }
202
+
203
+ @keyframes panel-gauge-fill {
204
+ from {
205
+ width: 0;
206
+ }
207
+
208
+ to {
209
+ width: 100%;
210
+ }
211
+ }
212
+
180
213
  /* bar-поля панели (type: 'bar' в схеме игры) — классы нейтральные,
181
214
  без привязки к игровым DOM-id */
182
215
  #panel table td div.panel-bar-wrapper {
@@ -211,6 +244,11 @@ body.hide-cursor {
211
244
  #panel table td div.panel-bar-blink {
212
245
  animation: none;
213
246
  }
247
+
248
+ #panel table td div.panel-gauge {
249
+ animation: none !important;
250
+ width: 100%;
251
+ }
214
252
  }
215
253
 
216
254
  /*stat*/
@@ -314,6 +352,29 @@ body.hide-cursor {
314
352
  text-align: right;
315
353
  }
316
354
 
355
+ /*menu hint*/
356
+
357
+ /* Подсказка личного меню (корзина) под местом HUD связи; игра
358
+ перестилизует её по #menu-hints и .menu-hint.is-on. Селектор по id
359
+ перебивает `.vimp-shell > * { display: none }` */
360
+ #menu-hints {
361
+ display: block;
362
+ position: absolute;
363
+ right: 24px;
364
+ top: calc(50% + 44px);
365
+ z-index: 4;
366
+ pointer-events: none;
367
+ color: #fff;
368
+ }
369
+
370
+ #menu-hints .menu-hint {
371
+ display: none;
372
+ }
373
+
374
+ #menu-hints .menu-hint.is-on {
375
+ display: block;
376
+ }
377
+
317
378
  /*voice*/
318
379
 
319
380
  /* Нейтральный вид HUD связи; игра перестилизует его по #voice-hud и
@@ -331,7 +392,7 @@ body.hide-cursor {
331
392
  gap: 8px;
332
393
  color: #fff;
333
394
  font-size: 14px;
334
- text-shadow: 1px 1px 1px #000;
395
+ text-shadow: 0 0 1px #000;
335
396
  }
336
397
 
337
398
  #voice-hud.is-talking,
@@ -358,6 +419,12 @@ body.hide-cursor {
358
419
  display: flex;
359
420
  }
360
421
 
422
+ /* тёмная обводка: серый и зелёный значок читаются на светлой карте */
423
+ #voice-hud .voice-icon,
424
+ #voice-hud .voice-ring {
425
+ filter: drop-shadow(0 0 1px #000);
426
+ }
427
+
361
428
  #voice-hud .voice-icon {
362
429
  width: 32px;
363
430
  height: 32px;
@@ -375,17 +442,22 @@ body.hide-cursor {
375
442
  display: none;
376
443
  }
377
444
 
445
+ #voice-hud .voice-mark {
446
+ position: relative;
447
+ display: flex;
448
+ }
449
+
378
450
  #voice-hud .voice-ring {
379
451
  display: none;
380
452
  position: absolute;
381
- left: -2px;
382
- top: -2px;
453
+ left: 50%;
454
+ top: 50%;
383
455
  width: 36px;
384
456
  height: 36px;
385
457
  fill: none;
386
458
  stroke: currentColor;
387
459
  stroke-width: 2;
388
- transform: rotate(-90deg);
460
+ transform: translate(-50%, -50%) rotate(-90deg);
389
461
  }
390
462
 
391
463
  #voice-hud.is-talking .voice-ring {
@@ -489,6 +561,11 @@ body.hide-cursor {
489
561
  overflow: hidden;
490
562
  }
491
563
 
564
+ /* недоступный пункт личного меню */
565
+ #vote .vote-list li.disabled {
566
+ opacity: 0.4;
567
+ }
568
+
492
569
  #vote .vote-nav {
493
570
  padding: 0 0 0 5px;
494
571
  margin-top: 10px;
@@ -726,8 +803,8 @@ body.hide-cursor {
726
803
  display: block;
727
804
  }
728
805
 
729
- /* футеры лобби и формы входа: одна и та же полоса из трёх ячеек —
730
- ссылка на пакет, его версия, копирайт. Оба заполняет JS, поэтому вид
806
+ /* футеры лобби (Terms/Privacy | версия движка | копирайт) и формы входа
807
+ (версия игры | копирайт): одна полоса, оба заполняет JS, поэтому вид
731
808
  держится в одном месте (#lobby-footer лежит в секции lobby ниже только
732
809
  разметкой) */
733
810
  #auth-link,
@@ -761,9 +838,8 @@ body.hide-cursor {
761
838
  padding-left: 10px;
762
839
  }
763
840
 
764
- /* три равные ячейки: середину держит не space-between, а сама равность —
765
- любая из крайних бывает пустой (у пакета игры может не быть метаданных),
766
- и при space-between версия съезжала бы с центра */
841
+ /* равные ячейки: версия игры у левого края, копирайт у правого; пустая
842
+ версия (у пакета игры может не быть метаданных) не сдвигает копирайт */
767
843
  #auth-link p {
768
844
  margin: 0;
769
845
  flex: 1;
@@ -781,7 +857,6 @@ body.hide-cursor {
781
857
  text-align: left;
782
858
  }
783
859
 
784
- #auth-version,
785
860
  #lobby-version {
786
861
  text-align: center;
787
862
  }
@@ -55,9 +55,7 @@ const SHELL = [
55
55
  tag: 'div',
56
56
  id: 'auth-link',
57
57
  children: [
58
- // версия пакета ИГРЫ (из манифеста) и ссылка на документацию
59
- // (только в dev-сборке) — заполняет main.js по AUTH_DATA
60
- { tag: 'p', children: [{ tag: 'a', id: 'auth-docs-link' }] },
58
+ // версия пакета ИГРЫ (из манифеста) — заполняет main.js по AUTH_DATA
61
59
  { tag: 'p', id: 'auth-version' },
62
60
  { tag: 'p', text: `© ${new Date().getFullYear()} VIMP` },
63
61
  ],
@@ -67,7 +65,7 @@ const SHELL = [
67
65
  ],
68
66
  },
69
67
  { tag: 'div', id: 'game-informer' },
70
- // оверлей переподключения (host-migration этап 4): в solo/dedicated не
68
+ // оверлей переподключения (host-migration этап 4): в solo не
71
69
  // показывается, но разметка общая с informer.pug
72
70
  {
73
71
  tag: 'div',
@@ -16,9 +16,6 @@ div#auth
16
16
 
17
17
  div#auth-link
18
18
  //- версия пакета ИГРЫ (мастер кладёт её в манифест, читая package.json
19
- //- пакета) и ссылка на документацию (только в dev-сборке) —
20
- //- проставляет main.js по AUTH_DATA; без данных ячейки остаются пустыми
21
- p
22
- a#auth-docs-link
19
+ //- пакета) — проставляет main.js по AUTH_DATA; без данных ячейка пустая
23
20
  p#auth-version
24
21
  p © #{new Date().getFullYear()} VIMP
@@ -32,8 +32,7 @@ div#lobby(style='display:none')
32
32
  label.form-label(for='lobby-game')= 'Select game'
33
33
  select#lobby-game.field-select
34
34
  //- поля настроек комнаты генерируются по явной схеме roomForm
35
- //- манифеста активной игры (docs.vimp.lgick.dev/en/plugin-api "Form
36
- //- schema")
35
+ //- манифеста активной игры (VIMP docs: plugin-api.md § "Form schema")
37
36
  div#lobby-fields
38
37
  //- ошибки формы (formBuilder.renderFormErrors) и отказ загрузки
39
38
  //- плагина выбранной игры — строкой в форме, а не в #tech-informer:
@@ -67,15 +66,13 @@ div#lobby(style='display:none')
67
66
  span#leaderboard-total
68
67
  ol#lobby-leaderboard-list
69
68
  p#lobby-my-placement
70
- //- футер: ссылка на документацию статична, версию пакета движка
71
- //- проставляет LobbyView (engineVersion.js)
69
+ //- футер: Terms/Privacy, версия пакета движка (проставляет LobbyView,
70
+ //- engineVersion.js), копирайт
72
71
  div#lobby-footer
73
72
  p
74
73
  a#lobby-terms-link(target='_blank', rel='noopener') Terms of Service
75
74
  | ·
76
75
  |
77
76
  a#lobby-privacy-link(target='_blank', rel='noopener') Privacy Policy
78
- p
79
- a#lobby-link(href='https://docs.vimp.lgick.dev', target='_blank', rel='noopener') docs
80
77
  p#lobby-version
81
78
  p © #{new Date().getFullYear()} VIMP
@@ -186,7 +186,9 @@ export default class MicCapture {
186
186
  try {
187
187
  stream = await this._mediaDevices.getUserMedia({
188
188
  audio: {
189
- echoCancellation: true,
189
+ // эхоподавление включает системную обработку голоса (macOS) и
190
+ // приглушает весь остальной звук на время открытия и закрытия
191
+ echoCancellation: false,
190
192
  noiseSuppression: true,
191
193
  autoGainControl: true,
192
194
  channelCount: 1,
@@ -23,6 +23,43 @@ export const abiOps = createRegistry('abiOps', [
23
23
  payload: 'none',
24
24
  response: 'json',
25
25
  },
26
+ // ответ игрока на личное меню: { id, name, path } (путь листа «2.1»)
27
+ {
28
+ value: 'menu.answer',
29
+ since: 4,
30
+ payload: 'json',
31
+ response: 'none',
32
+ },
33
+ // участник покинул матч: { id } — ядро игры удаляет всё, что хранит о нём
34
+ {
35
+ value: 'actor.forget',
36
+ since: 4,
37
+ payload: 'json',
38
+ response: 'none',
39
+ },
40
+ // новая игра: движок обнулил счёт, ядро игры сбрасывает накопленное
41
+ // между раундами
42
+ {
43
+ value: 'match.reset',
44
+ since: 4,
45
+ payload: 'json',
46
+ response: 'none',
47
+ },
48
+ // состояние игры между раундами: запрос { roundOver }, ответ — JSON игры,
49
+ // который переживает мягкую миграцию хоста и эстафету Worker'а
50
+ {
51
+ value: 'state.persist',
52
+ since: 4,
53
+ payload: 'json',
54
+ response: 'json',
55
+ },
56
+ // тот же JSON обратно новому ядру до первого раунда
57
+ {
58
+ value: 'state.restore',
59
+ since: 4,
60
+ payload: 'json',
61
+ response: 'none',
62
+ },
26
63
  ]);
27
64
 
28
65
  // плоский список активных опкодов — то, что вправе позвать движок
@@ -33,5 +70,10 @@ export const ABI_OPS = abiOps.values();
33
70
  // опкод уехал бы в прод, не попав ни в слепок поверхности, ни в
34
71
  // CHANGELOG. Имя опкода читается ТОЛЬКО отсюда.
35
72
  export const ABI_OP_DEBUG_JSON = 'debug.json';
73
+ export const ABI_OP_MENU_ANSWER = 'menu.answer';
74
+ export const ABI_OP_ACTOR_FORGET = 'actor.forget';
75
+ export const ABI_OP_MATCH_RESET = 'match.reset';
76
+ export const ABI_OP_STATE_PERSIST = 'state.persist';
77
+ export const ABI_OP_STATE_RESTORE = 'state.restore';
36
78
 
37
79
  export default abiOps;
@@ -1,18 +1,16 @@
1
- // коды закрытия транспорта — общий контракт серверных контуров (мастер,
2
- // dedicated, Worker-хост) и клиента, тот же приём, что в gameCodes.js:
3
- // значение не должно расходиться между ними молча. Отказы политики — те, что
4
- // клиент не лечит перезагрузкой, — перечислены в
5
- // `client/network/policyClose.js`, и тест там требует явного решения по
6
- // каждому коду из этой карты
1
+ // коды закрытия транспорта — общий контракт сигналинга мастера и
2
+ // Worker-хоста, тот же приём, что в gameCodes.js: значение не должно
3
+ // расходиться между ними молча. Игровой транспорт клиента кода не передаёт
4
+ // (у data channel его нет): причину кика или отказа клиент узнаёт по
5
+ // TECH_INFORM перед закрытием
7
6
  export default {
8
7
  staleHost: 4000, // master/SignalingServer.js — сигнальный WS хоста протух
9
- invalidOrigin: 4001, // master/SignalingServer.js, dedicated/main.js
8
+ invalidOrigin: 4001, // master/SignalingServer.js
10
9
  // 4002 retired (server rating removed) — номер не переиспользуется
11
10
  kickForMaxLatency: 4003, // host/HostGame.js
12
11
  kickForMissedPings: 4004, // host/HostGame.js
13
12
  kickIdle: 4005, // host/HostGame.js
14
13
  roomFull: 4006, // host/PortMachine.js
15
14
  // 4007 свободен
16
- handshakeTimeout: 4008, // dedicated/main.js
17
- tooManyConnections: 4009, // dedicated/main.js
15
+ handshakeTimeout: 4008, // host/PortMachine.js — нет RESUME_REQUEST вовремя
18
16
  };
@@ -1,5 +1,5 @@
1
1
  // коды игровых информ-сообщений (порт GAME_INFORM_DATA, см.
2
- // docs.vimp.lgick.dev/en/snapshot) — общий контракт хоста (SocketManager) и
2
+ // VIMP docs: snapshot.md) — общий контракт хоста (SocketManager) и
3
3
  // клиента (main.js): значение не должно расходиться между ними молча
4
4
  export default {
5
5
  winnerTeam: [0],
@@ -66,8 +66,8 @@ export default {
66
66
 
67
67
  // возобновление сессии (host-migration этап 4). Сколько хост держит место
68
68
  // участника, чей транспорт оборвался без LEAVE: слот занят, актор в мире,
69
- // клавиши отпущены. Включает его только Worker лобби — dedicated и
70
- // standalone поднимают PortMachine без этой опции (0 — снять сразу)
69
+ // клавиши отпущены. Включает его только Worker лобби — standalone
70
+ // поднимает PortMachine без этой опции (0 — снять сразу)
71
71
  resumeGraceMs: 20000,
72
72
 
73
73
  // сколько возобновляющее соединение может молчать до RESUME_REQUEST
@@ -50,9 +50,8 @@ export default {
50
50
  // клиент берёт не из assetsBase, а по отдельному URL, и версионность
51
51
  // каталога обязана доехать и сюда, иначе комната на застейдженной версии
52
52
  // играла бы на картах одобренной. `mapsBase` проставляет мастер при
53
- // ребейзе версионного каталога; его отсутствие (dev, standalone,
54
- // dedicated, старый мастер) — законный случай, тогда работает прежний
55
- // путь по id
53
+ // ребейзе версионного каталога; его отсутствие (dev, standalone, старый
54
+ // мастер) — законный случай, тогда работает путь по id
56
55
  maps: {
57
56
  manifestUrl: manifest =>
58
57
  `${manifest.mapsBase ?? `/games/${manifest.id}/maps`}/manifest.json`,
@@ -305,7 +304,7 @@ export default {
305
304
  debugReportUrl: '/debug/report',
306
305
 
307
306
  // журнал клиентских ошибок: приём на том же боксе,
308
- // что раздал страницу, — лобби-мастер или dedicated
307
+ // что раздал страницу
309
308
  clientReportUrl: '/client-reports',
310
309
 
311
310
  // размер страницы для «Загрузить ещё» (offset/limit к мастеру)
@@ -0,0 +1,3 @@
1
+ // формат имени личного меню: проверяется и хостом (ответ игрока), и клиентом
2
+ // (данные недоверенного хоста)
3
+ export const MENU_NAME_PATTERN = /^[a-z][a-zA-Z0-9]{0,15}$/;
@@ -1,4 +1,4 @@
1
- // Номера портов JSON-канала (docs.vimp.lgick.dev/en/snapshot). Реестр
1
+ // Номера портов JSON-канала (VIMP docs: snapshot.md). Реестр
2
2
  // append-only, как и остальные словари плагинной поверхности (этап 3 плана
3
3
  // plugin-forward-compat): номер НИКОГДА не переиспользуется и не
4
4
  // перенумеровывается — уже опубликованная сборка игры шлёт и слушает те
@@ -39,7 +39,7 @@ const RECORDED_PORTS = {
39
39
  const FLUSHING_METHODS = new Set(['sendClear', 'sendMap', 'sendFirstShot']);
40
40
 
41
41
  // В headless-контуре нет ни мастера, ни auth-сервиса — участник стартует с
42
- // пустым профилем (lib/offlinePlayerData.js, общее со standalone/dedicated).
42
+ // пустым профилем (lib/offlinePlayerData.js, общее со standalone).
43
43
  const emptyProfileFetch = offlinePlayerData();
44
44
 
45
45
  /**
@@ -13,7 +13,7 @@ import { isFixture } from './pluginLoader.js';
13
13
  // Ввод начинается на 40-м тике, а не сразу: кадр спавна с force_reset
14
14
  // приходит примерно на interpolation.delay (движковый дефолт — 100 мс, тут
15
15
  // ~3× запаса) позже входа и чистит удержанные клавиши предиктора
16
- // (docs.vimp.lgick.dev/en/debugging) — встроенный сценарий не должен
16
+ // (VIMP docs: debugging.md) — встроенный сценарий не должен
17
17
  // демонстрировать ровно ту ловушку, от которой предостерегает документация
18
18
  const INPUT_DOWN_TICK = 40;
19
19
  const INPUT_UP_TICK = 100;
@@ -26,7 +26,7 @@ const FIXTURE_UNUSED_KEYS = ['e1'];
26
26
  const firstKey = dict => Object.keys(dict ?? {})[0];
27
27
 
28
28
  // Клавиша для смоука обязана быть удерживаемой: по конвенции playerKeys
29
- // (docs.vimp.lgick.dev/en/plugin-api) `type: 1` — триггер, у которого 'up'
29
+ // (VIMP docs: plugin-api.md) `type: 1` — триггер, у которого 'up'
30
30
  // игнорируется, и «нажал — отпустил» не даёт ни удержания, ни движения.
31
31
  // Порядок объявления в playerKeys контрактом не задан, поэтому берём первую
32
32
  // без type; если игра объявила одни триггеры — первую любую, смоук всё равно
@@ -1,6 +1,6 @@
1
1
  import { ERROR, skip, verdict } from '../result.js';
2
2
 
3
- // Набор npm-скриптов пакета игры (docs.vimp.lgick.dev/en/packaging). Это не
3
+ // Набор npm-скриптов пакета игры (VIMP docs: packaging.md). Это не
4
4
  // вкусовое требование: инструкции движка и CI сгенерированной игры зовут их по
5
5
  // имени, а отсутствующий core:build:node тихо оставляет headless-прогон без
6
6
  // ядра.
@@ -3,7 +3,7 @@ import { ERROR, skip, verdict } from '../result.js';
3
3
  // vite.config.js проверяется как текст, а не импортом: конфиг —
4
4
  // функция от mode и вне Vite не исполняется. Каждый пункт здесь ловит
5
5
  // молчаливую поломку сборки, а не стилистику
6
- // (docs.vimp.lgick.dev/en/packaging): без preserveEntrySignatures
6
+ // (VIMP docs: packaging.md): без preserveEntrySignatures
7
7
  // default-экспорт плагина вытряхивает tree-shaking, а build.lib всегда инлайнит
8
8
  // ассеты.
9
9
  const REQUIRED = [
@@ -4,7 +4,7 @@ import { SERVICES } from '../../../config/clientServices.js';
4
4
  // Движковый пул сервисов держит реестр (config/clientServices.js), но пул
5
5
  // ими не исчерпывается: игра доливает туда свои через
6
6
  // ClientPlugin.hooks.services(core) — например геометрию предсказанной
7
- // динамики карты (docs.vimp.lgick.dev/en/plugin-api «hooks.services»).
7
+ // динамики карты (VIMP docs: plugin-api.md § "Services").
8
8
  // Незнакомое имя не ошибка для движка: part получает undefined и рисует пустоту
9
9
  // — карта без assetsBase выглядит как чистый холст без единой строки в консоли,
10
10
  // ради этого правило и существует. Правило остаётся ERROR: оно работает на
@@ -484,7 +484,7 @@ function predictionDrift({ clients, scenario }) {
484
484
  // Компоненты player-блока — игровая раскладка, движок знает только их
485
485
  // порядок, поэтому нарушение адресуется индексом. Исключение — уровень 0
486
486
  // (source 'camera'): там движок сравнивает ровно мировые x/y, и называть их
487
- // «#0/#1» значит прятать причину (см. docs.vimp.lgick.dev/en/debugging).
487
+ // «#0/#1» значит прятать причину (см. VIMP docs: debugging.md).
488
488
  const CAMERA_COMPONENTS = ['x', 'y'];
489
489
 
490
490
  function formatDivergence(record) {
@@ -12,7 +12,7 @@ import { loadGamePackage } from '../lib/loadGamePackage.js';
12
12
  // 3. ничего — фикстура miniGame из этого репозитория.
13
13
  //
14
14
  // entries.wasmNode — новое ОПЦИОНАЛЬНОЕ поле манифеста: конвенция
15
- // core/pkg-node/ уже описана в docs.vimp.lgick.dev/en/packaging, не хватало
15
+ // core/pkg-node/ уже описана в VIMP docs: packaging.md, не хватало
16
16
  // только пути в манифесте. Его отсутствие не ошибка — просто игра не
17
17
  // прогоняется headless своим настоящим ядром.
18
18
 
@@ -51,7 +51,7 @@ export async function loadGameForSim({ game = null, core = null } = {}) {
51
51
  }
52
52
 
53
53
  // путь --game указывает либо на пакет игры, либо прямо на манифест; сама
54
- // загрузка общая с dedicated-сервером (lib/loadGamePackage.js).
54
+ // загрузка — lib/loadGamePackage.js.
55
55
  // client: true — прогону виртуальные клиенты обязательны, и отказ на
56
56
  // загрузке пакета лучше отказа посреди сценария
57
57
  async function loadFromManifest(game, core) {
@@ -10,7 +10,14 @@
10
10
  // (spawn_scripted_actor/remove_scripted_actor), человек — только танк
11
11
  // (spawn_actor/remove_actor).
12
12
 
13
- import { ABI_OP_DEBUG_JSON } from '../config/abiOps.js';
13
+ import {
14
+ ABI_OP_ACTOR_FORGET,
15
+ ABI_OP_DEBUG_JSON,
16
+ ABI_OP_MATCH_RESET,
17
+ ABI_OP_MENU_ANSWER,
18
+ ABI_OP_STATE_PERSIST,
19
+ ABI_OP_STATE_RESTORE,
20
+ } from '../config/abiOps.js';
14
21
  import { readCoreAbi, dispatchCoreOp } from '../lib/coreAbi.js';
15
22
 
16
23
  export default class GameCoreAdapter {
@@ -26,7 +33,7 @@ export default class GameCoreAdapter {
26
33
  this._core = core;
27
34
  this._participants = participants;
28
35
  this._onCoreEvent = onCoreEvent;
29
- this._services = {}; // { vimp, panel } — инъекция как у Game.js
36
+ this._services = {}; // { vimp, panel, vote } — инъекция как у Game.js
30
37
 
31
38
  // Метода нет (или самоописание нечитаемо) — ядро собрано до появления
32
39
  // механизма. Это не ошибка: поколение 0, ни одного опционального опкода
@@ -44,7 +51,7 @@ export default class GameCoreAdapter {
44
51
  return this._abi;
45
52
  }
46
53
 
47
- // получает сервисы (аналог Game.injectServices): { vimp, panel }
54
+ // получает сервисы (аналог Game.injectServices): { vimp, panel, vote }
48
55
  injectServices(services) {
49
56
  Object.assign(this._services, services);
50
57
  }
@@ -159,7 +166,7 @@ export default class GameCoreAdapter {
159
166
  // vimp }); 'custom' (игровой смысл вне словаря) — в HostPlugin.onCoreEvent
160
167
  _drainEvents() {
161
168
  const events = JSON.parse(this._core.take_events());
162
- const { panel, vimp } = this._services;
169
+ const { panel, vimp, vote } = this._services;
163
170
 
164
171
  for (const event of events) {
165
172
  switch (event.type) {
@@ -182,6 +189,20 @@ export default class GameCoreAdapter {
182
189
  });
183
190
  break;
184
191
 
192
+ case 'menu':
193
+ this._pushPersonal(vote, event.id, { menu: event.menu });
194
+ break;
195
+
196
+ case 'menuClose':
197
+ this._pushPersonal(vote, event.id, { menuClose: event.name });
198
+ break;
199
+
200
+ case 'menuHint':
201
+ this._pushPersonal(vote, event.id, {
202
+ menuHint: { name: event.name, on: event.on },
203
+ });
204
+ break;
205
+
185
206
  case 'custom':
186
207
  this._onCoreEvent?.(event.data, this._services);
187
208
  break;
@@ -189,6 +210,60 @@ export default class GameCoreAdapter {
189
210
  }
190
211
  }
191
212
 
213
+ // личное сообщение меню адресату; получатель — только человек с сокетом
214
+ // (бот и ушедший id отбрасываются: у них нет личной очереди)
215
+ _pushPersonal(vote, id, payload) {
216
+ const gameId = String(id);
217
+
218
+ if (vote && this._participants.get(gameId)?.isNetworked) {
219
+ vote.pushPersonal(gameId, payload);
220
+ }
221
+ }
222
+
223
+ // ***** опкоды игры ***** //
224
+
225
+ // ответ игрока на личное меню; ядро без опкода молча игнорирует
226
+ menuAnswer(gameId, name, path) {
227
+ this._op(
228
+ ABI_OP_MENU_ANSWER,
229
+ encodeJson({ id: Number(gameId), name, path }),
230
+ );
231
+ }
232
+
233
+ // участник покинул матч (в том числе наблюдатель)
234
+ forgetActor(gameId) {
235
+ this._op(ABI_OP_ACTOR_FORGET, encodeJson({ id: Number(gameId) }));
236
+ }
237
+
238
+ // новая игра: движок обнулил счёт
239
+ resetMatch() {
240
+ this._op(ABI_OP_MATCH_RESET, encodeJson({}));
241
+ }
242
+
243
+ /**
244
+ * Состояние игры между раундами для мягкой миграции и эстафеты.
245
+ * @param {{roundOver: boolean}} request - `roundOver` — раунд закончился,
246
+ * но ядро его ещё не подвело (эстафета на границе раунда).
247
+ * @returns {Object|null} JSON игры; `null` — ядро опкода не знает.
248
+ */
249
+ persistState({ roundOver }) {
250
+ const { handled, bytes } = this._op(
251
+ ABI_OP_STATE_PERSIST,
252
+ encodeJson({ roundOver }),
253
+ );
254
+
255
+ if (handled && bytes !== null) {
256
+ return JSON.parse(new TextDecoder().decode(bytes));
257
+ }
258
+
259
+ return null;
260
+ }
261
+
262
+ // отдаёт новому ядру JSON, снятый persistState
263
+ restorePersisted(state) {
264
+ this._op(ABI_OP_STATE_RESTORE, encodeJson(state));
265
+ }
266
+
192
267
  // ***** упаковка снапшота ***** //
193
268
 
194
269
  // пакует broadcast-тело кадра (один раз за отправляемый кадр)
@@ -333,6 +408,10 @@ export default class GameCoreAdapter {
333
408
  }
334
409
  }
335
410
 
411
+ function encodeJson(value) {
412
+ return new TextEncoder().encode(JSON.stringify(value));
413
+ }
414
+
336
415
  // JsError wasm-bindgen и обычный Error — у обоих есть message; строка из
337
416
  // throw внутри фейка — тоже ответ
338
417
  function errorText(e) {