vimp-engine 0.35.6 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/bin/vimp-sim.js +19 -1
  2. package/core/Cargo.toml +1 -1
  3. package/package.json +1 -1
  4. package/src/client/components/controller/Controls.js +5 -0
  5. package/src/client/components/controller/Lobby.js +4 -4
  6. package/src/client/components/controller/RoomMenu.js +43 -0
  7. package/src/client/components/controller/Vote.js +18 -2
  8. package/src/client/components/model/ClientReports.js +6 -2
  9. package/src/client/components/model/Controls.js +27 -0
  10. package/src/client/components/model/Games.js +5 -1
  11. package/src/client/components/model/Lobby.js +32 -24
  12. package/src/client/components/model/LobbyAuth.js +20 -4
  13. package/src/client/components/model/RoomMenu.js +121 -0
  14. package/src/client/components/model/Stat.js +2 -1
  15. package/src/client/components/model/Vote.js +122 -4
  16. package/src/client/components/view/Auth.js +20 -10
  17. package/src/client/components/view/ClientReports.js +16 -5
  18. package/src/client/components/view/Games.js +39 -14
  19. package/src/client/components/view/Lobby.js +90 -43
  20. package/src/client/components/view/LobbyAuth.js +13 -7
  21. package/src/client/components/view/Panel.js +1 -2
  22. package/src/client/components/view/RoomMenu.js +69 -0
  23. package/src/client/debug.js +24 -1
  24. package/src/client/lib/FpsMeter.js +46 -0
  25. package/src/client/lib/JoinRetry.js +73 -0
  26. package/src/client/lib/diagnostics.js +28 -5
  27. package/src/client/lib/hostCaps.js +82 -0
  28. package/src/client/lib/hostUnloadGuard.js +108 -0
  29. package/src/client/lib/hostVoteCommand.js +92 -0
  30. package/src/client/lib/pageReload.js +29 -0
  31. package/src/client/lib/roomLink.js +257 -0
  32. package/src/client/lib/signalingErrors.js +74 -0
  33. package/src/client/main.js +674 -451
  34. package/src/client/network/HiddenHostHealthLog.js +76 -0
  35. package/src/client/network/HostConnectionManager.js +62 -8
  36. package/src/client/network/HostController.js +376 -23
  37. package/src/client/network/HostHealthPolicy.js +307 -0
  38. package/src/client/network/HostHealthReporter.js +54 -0
  39. package/src/client/network/HostPrewarm.js +235 -0
  40. package/src/client/network/LoopbackTransport.js +16 -1
  41. package/src/client/network/PlannedHandoff.js +366 -0
  42. package/src/client/network/Promotion.js +420 -0
  43. package/src/client/network/RoomPeersReporter.js +81 -0
  44. package/src/client/network/SessionSupervisor.js +730 -0
  45. package/src/client/network/SignalingClient.js +183 -30
  46. package/src/client/network/StandbyReceiver.js +287 -0
  47. package/src/client/network/StandbySender.js +319 -0
  48. package/src/client/network/TokenHandoffTimer.js +81 -0
  49. package/src/client/network/WebRtcManager.js +338 -11
  50. package/src/client/network/policyClose.js +32 -2
  51. package/src/client/network/standbyChunks.js +185 -0
  52. package/src/client/session/GuestSession.js +428 -0
  53. package/src/client/session/HandoffFlow.js +399 -0
  54. package/src/client/session/HostRole.js +708 -0
  55. package/src/client/session/Membership.js +179 -0
  56. package/src/client/session/PromotionFlow.js +325 -0
  57. package/src/client/session/RouteBoot.js +432 -0
  58. package/src/client/session/StandbyRole.js +195 -0
  59. package/src/client/session/hostRoomPrep.js +152 -0
  60. package/src/client/session/roomContext.js +32 -0
  61. package/src/client/style.css +83 -22
  62. package/src/client/views/gameShell.js +7 -0
  63. package/src/client/views/includes/informer.pug +4 -0
  64. package/src/client/views/includes/lobby.pug +0 -3
  65. package/src/client/views/includes/roomMenu.pug +9 -0
  66. package/src/client/views/index.pug +1 -0
  67. package/src/config/closeCodes.js +1 -1
  68. package/src/config/env.js +37 -0
  69. package/src/config/hostDefaults.js +24 -0
  70. package/src/config/lobby.js +154 -3
  71. package/src/config/master.js +104 -21
  72. package/src/config/wsports.js +8 -0
  73. package/src/devtools/RecordingSocketManager.js +3 -2
  74. package/src/devtools/ScenarioRunner.js +123 -8
  75. package/src/devtools/VirtualClient.js +20 -0
  76. package/src/devtools/contract/format.js +4 -1
  77. package/src/devtools/contract/index.js +1 -2
  78. package/src/devtools/contract/rules/a2-package-scripts.js +1 -3
  79. package/src/devtools/contract/rules/b8-system-messages.js +1 -1
  80. package/src/devtools/contract/rules/b9-vote-names.js +25 -1
  81. package/src/devtools/contract/rules/c11-stat-mode.js +4 -1
  82. package/src/devtools/contract/rules/c6-stat-columns.js +1 -1
  83. package/src/devtools/contract/rules/c7-key-sets.js +8 -4
  84. package/src/devtools/contract/rules/d3-snapshot-interp.js +2 -1
  85. package/src/devtools/contract/rules/e1-sound-pairs.js +3 -1
  86. package/src/devtools/contract/rules/e4-map-layers.js +13 -2
  87. package/src/devtools/contract/rules/e5-map-radar-walls.js +7 -1
  88. package/src/devtools/contract/rules/index.js +38 -5
  89. package/src/devtools/invariants.js +10 -4
  90. package/src/devtools/pluginLoader.js +1 -4
  91. package/src/devtools/report.js +17 -1
  92. package/src/host/DebugRecorder.js +6 -1
  93. package/src/host/GameCoreAdapter.js +45 -0
  94. package/src/host/HostGame.js +928 -59
  95. package/src/host/PortMachine.js +268 -20
  96. package/src/host/host.worker.js +222 -16
  97. package/src/host/meta/SocketManager.js +20 -0
  98. package/src/host/meta/core/RoundManager.js +89 -14
  99. package/src/host/meta/modules/Accolades.js +4 -1
  100. package/src/host/meta/modules/Panel.js +30 -0
  101. package/src/host/meta/modules/PlayerDataSync.js +255 -46
  102. package/src/host/meta/modules/RTTManager.js +34 -0
  103. package/src/host/meta/modules/TimerManager.js +215 -18
  104. package/src/host/meta/modules/chat/systemMessages.js +22 -0
  105. package/src/host/meta/player/HumanParticipant.js +10 -0
  106. package/src/host/meta/player/ParticipantManager.js +98 -5
  107. package/src/lib/AbstractTimer.js +63 -1
  108. package/src/lib/Publisher.js +26 -0
  109. package/src/lib/capabilities.js +5 -0
  110. package/src/lib/checkpointCodec.js +125 -0
  111. package/src/lib/clientIp.js +9 -2
  112. package/src/lib/createHostRuntime.js +95 -6
  113. package/src/lib/formOptions.js +3 -1
  114. package/src/lib/gameConfigView.js +10 -0
  115. package/src/lib/gameId.js +13 -0
  116. package/src/lib/jwt.js +3 -1
  117. package/src/lib/loadGamePackage.js +2 -2
  118. package/src/lib/resumeKey.js +38 -0
  119. package/src/lib/roomId.js +39 -0
  120. package/src/lib/roomSettings.js +59 -0
  121. package/src/lib/validators.js +7 -0
  122. package/tests/fixtures/generations/gen-api3/host/ScriptedManager.js +2 -3
  123. package/tests/fixtures/generations/gen-api3/host/fakeCore.js +31 -5
  124. package/tests/fixtures/generations/gen-api3/manifest.json +1 -3
  125. package/tests/fixtures/generations/gen-api4/host/ScriptedManager.js +2 -3
  126. package/tests/fixtures/generations/gen-api4/host/fakeCore.js +31 -5
  127. package/tests/fixtures/generations/gen-api4/manifest.json +1 -3
  128. package/tests/fixtures/miniGame/config/client.js +15 -0
  129. package/tests/fixtures/miniGame/host/ScriptedManager.js +2 -3
  130. package/tests/fixtures/miniGame/host/fakeCore.js +31 -5
@@ -0,0 +1,399 @@
1
+ import HostHealthPolicy from '../network/HostHealthPolicy.js';
2
+ import PlannedHandoff, {
3
+ controlsAfterAbort,
4
+ } from '../network/PlannedHandoff.js';
5
+ import { SESSION_STATES } from '../network/SessionSupervisor.js';
6
+ import TokenHandoffTimer from '../network/TokenHandoffTimer.js';
7
+
8
+ // Плановая передача хоста (host-migration этап 8.2) и уход из комнаты:
9
+ // «Hand over host», «Leave server», автотриггеры (этап 9b), просьба мастера
10
+ // (сеть — этап 9c, голосование — этап 10), передача по сроку входа
11
+ // (host-migration-review этап 15).
12
+ //
13
+ // Без DOM: сигналинг, роль хоста, супервизор, UI-действия и фабрики
14
+ // инъектирует сборщик (client/main.js).
15
+
16
+ export default class HandoffFlow {
17
+ /**
18
+ * @param {Object} deps
19
+ * @param {Object} deps.signaling - SignalingClient.
20
+ * @param {Object} deps.ctx - client/session/roomContext.js.
21
+ * @param {Object} deps.hostRole - client/session/HostRole.js.
22
+ * @param {Object} deps.standby - client/session/StandbyRole.js.
23
+ * @param {Object} deps.membership - client/session/Membership.js.
24
+ * @param {Function} deps.getPromotion - () → PromotionFlow.
25
+ * @param {Function} deps.getSupervisor - () → SessionSupervisor | null.
26
+ * @param {Function} deps.getTokenExpiresAt - () → мс эпохи | null.
27
+ * @param {Object} deps.config - lobby.migration.
28
+ * @param {Object} deps.ui - { showSessionOverlay(text|null),
29
+ * disableControls(), enableControls(), mute(), unmute(), isHidden(),
30
+ * setHandoffMenu(state|null), sendLeave(), reloadPage(hash) }.
31
+ * @param {Object} [deps.factories] - конструкторы (тесты).
32
+ */
33
+ constructor({
34
+ signaling,
35
+ ctx,
36
+ hostRole,
37
+ standby,
38
+ membership,
39
+ getPromotion,
40
+ getSupervisor,
41
+ getTokenExpiresAt,
42
+ config,
43
+ ui,
44
+ factories = {},
45
+ }) {
46
+ this._signaling = signaling;
47
+ this._ctx = ctx;
48
+ this._hostRole = hostRole;
49
+ this._standby = standby;
50
+ this._membership = membership;
51
+ this._getPromotion = getPromotion;
52
+ this._getSupervisor = getSupervisor;
53
+ this._getTokenExpiresAt = getTokenExpiresAt;
54
+ this._config = config;
55
+ this._ui = ui;
56
+ this._factories = {
57
+ plannedHandoff: options => new PlannedHandoff(options),
58
+ healthPolicy: options => new HostHealthPolicy(options),
59
+ tokenTimer: options => new TokenHandoffTimer(options),
60
+ ...factories,
61
+ };
62
+
63
+ // создаются в bind (лобби-режим)
64
+ this._planned = null;
65
+ // автотриггеры передачи (этап 9b)
66
+ this._policy = null;
67
+ // хост: проактивная передача роли перед истечением входа (повторяется,
68
+ // пока не начнётся или вход не истечёт)
69
+ this._tokenHandoff = null;
70
+ // уход уже идёт (ожидание записи очков): повторный клик ничего не делает
71
+ this._leavingServer = false;
72
+ }
73
+
74
+ // передача идёт (begin → released/aborted)
75
+ get active() {
76
+ return this._planned?.active === true;
77
+ }
78
+
79
+ // хост уходит из комнаты (stay: false)
80
+ get leaving() {
81
+ return this._planned?.leaving === true;
82
+ }
83
+
84
+ // подписки сигналинга передачи (лобби-режим, один раз на страницу)
85
+ bind() {
86
+ const migration = this._config;
87
+
88
+ this._planned = this._factories.plannedHandoff({
89
+ signaling: this._signaling,
90
+ getRoom: () => this._hostRole.room,
91
+ getController: () => this._hostRole.controller,
92
+ slowAfterMs: migration.handoffSlowMs,
93
+ deadlineMs: migration.handoffDeadlineMs,
94
+ deferMaxMs: migration.deferMaxMs,
95
+ onSlow: () => this._ui.setHandoffMenu('slow'),
96
+ onFrozen: () => this._onFrozen(),
97
+ onReleased: msg => this._onReleased(msg),
98
+ onAborted: msg => this._onAborted(msg),
99
+ onLeave: () => this.leave(),
100
+ });
101
+
102
+ // автотриггеры передачи (этап 9b): перегрузка и скрытая вкладка
103
+ this._policy = this._factories.healthPolicy({
104
+ config: migration.auto,
105
+ handoff: {
106
+ start: ({ reason, defer }) => this.start({ reason, stay: true, defer }),
107
+ hurry: reason => this._planned.hurry(reason),
108
+ // нагрузка нормализовалась до границы раунда — передача не нужна
109
+ cancelDeferred: () => {
110
+ const cancelled = this._planned.cancelDeferred();
111
+
112
+ if (cancelled) {
113
+ this._ui.setHandoffMenu(null);
114
+ // нагрузка нормализовалась до границы раунда — передача не
115
+ // нужна, а отложенное или вытесненное ею обновление кода — нужно
116
+ this._hostRole.resumeCodeUpdate();
117
+ }
118
+
119
+ return cancelled;
120
+ },
121
+ deferredReason: () =>
122
+ this._planned.deferred ? this._planned.reason : null,
123
+ },
124
+ });
125
+ this._policy.setHidden(this._ui.isHidden());
126
+
127
+ // мастер просит отдать роль: сеть хоста заметно хуже, чем у беты (этап
128
+ // 9c). Передача ждёт границы раунда; выключенные автотриггеры — отказ
129
+ this._signaling.publisher.on('request_handoff', msg => {
130
+ const room = this._hostRole.room;
131
+ const ownRoom =
132
+ Boolean(this._hostRole.controller) &&
133
+ msg.roomId === room?.roomId &&
134
+ msg.epoch === room.epoch;
135
+
136
+ // хоста сняли голосованием (этап 10): передача сразу, при любых
137
+ // настройках автотриггеров — иначе мастер снимет его аварийно
138
+ if (msg.reason === 'vote' && ownRoom) {
139
+ // передача уже ждёт границы раунда — сразу, с причиной vote; уже
140
+ // идущая — её исход решит мастер
141
+ if (!this._planned?.hurry('vote')) {
142
+ this.start({ reason: 'vote', stay: true, defer: false });
143
+ }
144
+
145
+ return;
146
+ }
147
+
148
+ if (
149
+ msg.reason === 'network' &&
150
+ migration.auto.enabled !== false &&
151
+ ownRoom
152
+ ) {
153
+ this.start({
154
+ reason: 'network',
155
+ stay: true,
156
+ defer: msg.defer !== false,
157
+ });
158
+ }
159
+ });
160
+
161
+ // роль отдана, а передачи эта вкладка уже не ждёт (ответ мастера опоздал
162
+ // к дедлайну): бета заняла комнату — свой игрок возвращается к ней гостем
163
+ this._signaling.publisher.on('host_released', msg => {
164
+ if (
165
+ !this._planned.active &&
166
+ this._hostRole.controller &&
167
+ msg.roomId === this._hostRole.room?.roomId
168
+ ) {
169
+ this._onReleased({ stay: true, epoch: msg.epoch });
170
+ }
171
+ });
172
+ }
173
+
174
+ /**
175
+ * Отдать роль хоста бете без отката: «Leave server» (stay: false — затем в
176
+ * лобби), «Hand over host» (stay: true — дальше гостем). Повторный вызов во
177
+ * время идущей передачи игнорируется.
178
+ *
179
+ * defer (по умолчанию — для stay): передача ждёт границы раунда, если игра
180
+ * не умеет продолжать посреди него (без migration.midRound мягкая точка
181
+ * начала бы раунд у беты заново); решает Worker, потолок —
182
+ * lobby.migration.deferMaxMs. Уходящий хост не ждёт.
183
+ * @param {Object} options
184
+ * @param {string} options.reason - 'leave' | 'handover' | 'overload' |
185
+ * 'hidden' | 'network' | 'vote'.
186
+ * @param {boolean} [options.stay]
187
+ * @param {boolean} [options.defer]
188
+ * @returns {boolean} передача началась.
189
+ */
190
+ start({ reason, stay = true, defer = stay }) {
191
+ const controller = this._hostRole.controller;
192
+
193
+ // промоушен сам владеет Worker'ом; передача уже идёт; комната ещё не
194
+ // зарегистрирована
195
+ if (
196
+ !this._planned ||
197
+ !controller ||
198
+ this._hostRole.promotion ||
199
+ this._planned.active ||
200
+ !this._hostRole.room
201
+ ) {
202
+ return false;
203
+ }
204
+
205
+ // своп, уже переносящий состояние, передача не вытесняет — она ждёт
206
+ if (!this._hostRole.canPreemptSwap()) {
207
+ return false;
208
+ }
209
+
210
+ const started = this._planned.start({ reason, stay, defer });
211
+
212
+ // эстафета Worker'ов уступает только начавшейся передаче (преемник и так
213
+ // поднимется на актуальном коде); всё синхронно — состояние свопа между
214
+ // проверкой и вытеснением не меняется
215
+ if (started) {
216
+ this._hostRole.preemptSwap();
217
+ this._ui.setHandoffMenu('pending');
218
+ }
219
+
220
+ return started;
221
+ }
222
+
223
+ // роль хоста снимается: идущая передача бросается
224
+ abort() {
225
+ this._planned?.abort();
226
+ }
227
+
228
+ // хост с истекающим входом не вернёт комнату reclaim_host после моргания
229
+ // сигналинга — роль заранее уходит бете
230
+ armTokenHandoff() {
231
+ this._tokenHandoff ??= this._factories.tokenTimer({
232
+ getExpiresAt: () => this._getTokenExpiresAt(),
233
+ leadMs: this._config.tokenHandoffLeadMs,
234
+ retryMs: this._config.tokenHandoffRetryMs,
235
+ tryStart: () =>
236
+ Boolean(this._hostRole.controller) &&
237
+ this._hostRole.successorMemberId !== null &&
238
+ this.start({ reason: 'handover', stay: true, defer: true }),
239
+ });
240
+ this._tokenHandoff.arm();
241
+ }
242
+
243
+ cancelTokenHandoff() {
244
+ this._tokenHandoff?.cancel();
245
+ }
246
+
247
+ // входы автотриггеров (этап 9b)
248
+ setHost(isHost) {
249
+ this._policy?.setHost(isHost);
250
+ }
251
+
252
+ setSuccessor(available) {
253
+ this._policy?.setSuccessor(available);
254
+ }
255
+
256
+ setHidden(hidden) {
257
+ this._policy?.setHidden(hidden);
258
+ }
259
+
260
+ addHealth(health) {
261
+ this._policy?.addHealth(health);
262
+ }
263
+
264
+ // «Leave server» из меню комнаты (этап 8.3). Хост при других людях отдаёт
265
+ // роль бете без отката и уходит; один в комнате — закрывает её
266
+ // (host_leaving: людей нет — мастер закрывает комнату сразу). Гость
267
+ // снимается у хоста сразу (LEAVE), а не через resumeGraceMs
268
+ async leaveByUser() {
269
+ if (this._leavingServer) {
270
+ return;
271
+ }
272
+
273
+ const controller = this._hostRole.controller;
274
+
275
+ if (controller) {
276
+ const peerCount = this._hostRole.peerCount;
277
+
278
+ if (peerCount > 0 && this.start({ reason: 'leave', stay: false })) {
279
+ return;
280
+ }
281
+
282
+ // передавать некому: очки участников иначе пропали бы вместе с
283
+ // Worker'ом. Пока они пишутся, мастер прячет комнату и не пускает в
284
+ // неё (host_closing) — вошедший сразу вылетел бы по закрытию
285
+ if (peerCount === 0) {
286
+ this._leavingServer = true;
287
+ this._ui.showSessionOverlay('Leaving…');
288
+
289
+ const room = this._hostRole.room;
290
+
291
+ if (room) {
292
+ this._signaling.hostClosing(room.roomId, room.epoch);
293
+ }
294
+
295
+ try {
296
+ await controller.shutdown({
297
+ timeoutMs: this._config.leaveFlushTimeoutMs,
298
+ });
299
+ } catch (e) {
300
+ console.warn('host shutdown failed', e);
301
+ }
302
+ }
303
+
304
+ // передача уже идёт (эстафета Worker'ов, промоушен) или передавать
305
+ // некому: уход по аварийному пути мастера
306
+ const room = this._hostRole.room;
307
+
308
+ if (room) {
309
+ this._signaling.hostLeaving(room.roomId, room.epoch);
310
+ }
311
+ } else {
312
+ this._ui.sendLeave();
313
+ }
314
+
315
+ this.leave();
316
+ }
317
+
318
+ // гость уходит: LEAVE хосту и leave_room мастеру (best-effort — страница
319
+ // может закрыться раньше, чем пакеты уйдут)
320
+ announceGuestLeave() {
321
+ this._ui.sendLeave();
322
+
323
+ if (this._ctx.roomId) {
324
+ this._signaling.leaveRoom(this._ctx.roomId);
325
+ }
326
+ }
327
+
328
+ // уход из комнаты в лобби (не быструю игру — она вернула бы в эту же
329
+ // комнату). Мастер освобождает место сразу; бывшему хосту leave_room
330
+ // принимается после host_released (сессия уже не хост). Уход объявлен —
331
+ // диалог закрытия и pagehide снимает reloadPage
332
+ leave() {
333
+ if (this._ctx.roomId) {
334
+ this._signaling.leaveRoom(this._ctx.roomId);
335
+ }
336
+
337
+ this._membership.forget();
338
+
339
+ this._hostRole.teardown();
340
+ this._standby.teardown();
341
+ this._ui.reloadPage('');
342
+ }
343
+
344
+ // матч заморожен до прихода нового хоста — у своего игрока та же пауза,
345
+ // что у гостей
346
+ _onFrozen() {
347
+ this._ui.showSessionOverlay('Switching host…');
348
+ this._ui.disableControls();
349
+ this._ui.mute();
350
+ }
351
+
352
+ // передача не состоялась, вкладка осталась хостом: замороженный матч
353
+ // продолжается (Worker разморожен, гостям ушла полная синхронизация)
354
+ _onAborted({ reason, frozen }) {
355
+ console.warn(`[handoff] not completed: ${reason}`);
356
+ this._ui.setHandoffMenu('failed');
357
+
358
+ const { hideOverlay, restoreInput } = controlsAfterAbort({
359
+ frozen,
360
+ sessionState: this._getSupervisor()?.state ?? null,
361
+ });
362
+
363
+ if (hideOverlay) {
364
+ this._ui.showSessionOverlay(null);
365
+ }
366
+
367
+ // вкладка осталась хостом: обновление кода, отложенное или вытесненное
368
+ // передачей, нужно снова
369
+ this._hostRole.resumeCodeUpdate();
370
+
371
+ // передача по сроку входа всё ещё нужна
372
+ this._tokenHandoff?.retry();
373
+
374
+ if (!restoreInput) {
375
+ return;
376
+ }
377
+
378
+ this._ui.enableControls();
379
+
380
+ if (!this._ui.isHidden()) {
381
+ this._ui.unmute();
382
+ }
383
+ }
384
+
385
+ // бета заняла комнату с того же тика: Worker этой вкладки больше не нужен
386
+ _onReleased({ stay, epoch }) {
387
+ if (!stay) {
388
+ this.leave();
389
+ return;
390
+ }
391
+
392
+ this._getPromotion().demote(epoch, { notice: false });
393
+
394
+ // возобновление идёт под тем же оверлеем, что и пауза передачи
395
+ if (this._getSupervisor()?.state === SESSION_STATES.reconnecting) {
396
+ this._ui.showSessionOverlay('Switching host…');
397
+ }
398
+ }
399
+ }