vimp-engine 0.35.5 → 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
@@ -1,5 +1,5 @@
1
1
  // Хендшейк-машина клиента, вынутая из host.worker.js: автомат по клиентским
2
- // портам 0–8 (см. docs/en/network.md). Изоморфна — ни self, ни postMessage,
2
+ // портам 0–10 (см. docs/en/network.md). Изоморфна — ни self, ни postMessage,
3
3
  // ни DOM: всё, что знает про транспорт, приходит через makeSocket. Поэтому её
4
4
  // крутит и Worker браузерного хоста, и inline-хост standalone SDK, и
5
5
  // Node-процесс dedicated-сервера: копий автомата быть не должно, иначе они
@@ -7,6 +7,8 @@
7
7
 
8
8
  import wsports from '../config/wsports.js';
9
9
  import closeCodes from '../config/closeCodes.js';
10
+ import clock from '../lib/clock.js';
11
+ import { resumeKeysEqual } from '../lib/resumeKey.js';
10
12
  import { resolveValidator, validateAuth } from '../lib/validators.js';
11
13
 
12
14
  // PC (client ports): порты получения данных от клиента
@@ -19,8 +21,24 @@ const PC_KEYS_DATA = wsports.client.KEYS_DATA;
19
21
  const PC_CHAT_DATA = wsports.client.CHAT_DATA;
20
22
  const PC_VOTE_DATA = wsports.client.VOTE_DATA;
21
23
  const PC_PONG = wsports.client.PONG;
24
+ const PC_RESUME_REQUEST = wsports.client.RESUME_REQUEST;
25
+ const PC_LEAVE = wsports.client.LEAVE;
22
26
 
23
- const PORT_COUNT = 9;
27
+ const PORT_COUNT = 11;
28
+
29
+ // версии формата RESUME_REQUEST, которые понимает хост
30
+ const RESUME_VERSION = 1;
31
+
32
+ // порты игрового состояния (после MODULES_READY, после restore и resume)
33
+ const IN_GAME_PORTS = [
34
+ PC_MAP_READY,
35
+ PC_FIRST_SHOT_READY,
36
+ PC_KEYS_DATA,
37
+ PC_CHAT_DATA,
38
+ PC_VOTE_DATA,
39
+ PC_PONG,
40
+ PC_LEAVE,
41
+ ];
24
42
 
25
43
  export default class PortMachine {
26
44
  /**
@@ -31,9 +49,27 @@ export default class PortMachine {
31
49
  * @param {Object} deps.authSchema - hostPlugin.authSchema.
32
50
  * @param {Function} deps.makeSocket - (socketId) => { send, sendBinary, close }.
33
51
  * @param {Object} deps.identity - стратегия идентичности (./identity.js).
52
+ * @param {number} [deps.resumeGraceMs] - сколько держать место участника,
53
+ * чей транспорт оборвался (host-migration этап 4); 0 — снимать сразу
54
+ * (dedicated, standalone: поведение до этапа 4).
55
+ * @param {number} [deps.resumeRequestTimeoutMs] - сколько ждать
56
+ * RESUME_REQUEST от возобновляющего соединения.
34
57
  */
35
- constructor({ host, socketManager, clientCfg, authSchema, makeSocket, identity }) {
58
+ constructor({
59
+ host,
60
+ socketManager,
61
+ clientCfg,
62
+ authSchema,
63
+ makeSocket,
64
+ identity,
65
+ resumeGraceMs = 0,
66
+ resumeRequestTimeoutMs = 5000,
67
+ }) {
36
68
  this._host = host;
69
+ this._resumeGraceMs = resumeGraceMs > 0 ? resumeGraceMs : 0;
70
+ this._resumeRequestTimeoutMs = resumeRequestTimeoutMs;
71
+ // gameId → таймер истечения ожидания отсоединённого участника
72
+ this._graceTimers = new Map();
37
73
  this._socketManager = socketManager;
38
74
  this._clientCfg = clientCfg;
39
75
  this._authSchema = authSchema;
@@ -72,14 +108,23 @@ export default class PortMachine {
72
108
  /**
73
109
  * Новое подключение клиента: регистрация сокета и старт хендшейка.
74
110
  * @param {string} socketId
111
+ * @param {Object} [opts]
112
+ * @param {boolean} [opts.resume] - переподключение к матчу: CONFIG_DATA
113
+ * не шлётся, ждём RESUME_REQUEST (только при включённом resumeGraceMs).
75
114
  */
76
- connect(socketId) {
115
+ connect(socketId, { resume = false } = {}) {
77
116
  if (this._clients.has(socketId)) {
78
117
  return;
79
118
  }
80
119
 
81
120
  this._socketManager.addUser(socketId, this._makeSocket(socketId));
82
121
 
122
+ // место уже занято участником — полнота комнаты его не касается
123
+ if (resume && this._resumeGraceMs > 0) {
124
+ this._connectForResume(socketId);
125
+ return;
126
+ }
127
+
83
128
  // комната заполнена (люди + боты) — отказ без порт-машины
84
129
  // (очереди ожидания легаси-сервера в P2P-комнате нет)
85
130
  if (this._host.isFull) {
@@ -103,6 +148,51 @@ export default class PortMachine {
103
148
  this._socketManager.sendConfig(socketId, this._clientCfg);
104
149
  }
105
150
 
151
+ // соединение ждёт RESUME_REQUEST; молчание дольше таймаута — закрыть
152
+ _connectForResume(socketId) {
153
+ const state = {
154
+ gameId: undefined,
155
+ enabled: new Array(PORT_COUNT).fill(false),
156
+ resumeTimer: null,
157
+ };
158
+
159
+ this._clients.set(socketId, state);
160
+ state.methods = this._buildPortMethods(socketId, state);
161
+ state.enabled[PC_RESUME_REQUEST] = true;
162
+
163
+ state.resumeTimer = clock.setTimeout(() => {
164
+ state.resumeTimer = null;
165
+ this._dropConnection(socketId, closeCodes.handshakeTimeout);
166
+ }, this._resumeRequestTimeoutMs);
167
+ }
168
+
169
+ // закрывает соединение без участника (отказ возобновления, таймаут)
170
+ _dropConnection(socketId, code) {
171
+ const state = this._clients.get(socketId);
172
+
173
+ if (!state) {
174
+ return;
175
+ }
176
+
177
+ this._clearResumeTimer(state);
178
+ this._socketManager.close(socketId, code);
179
+ this._socketManager.removeUser(socketId);
180
+ this._clients.delete(socketId);
181
+ }
182
+
183
+ _clearResumeTimer(state) {
184
+ if (state.resumeTimer) {
185
+ clock.clearTimeout(state.resumeTimer);
186
+ state.resumeTimer = null;
187
+ }
188
+ }
189
+
190
+ _enableInGame(state) {
191
+ for (const port of IN_GAME_PORTS) {
192
+ state.enabled[port] = true;
193
+ }
194
+ }
195
+
106
196
  /**
107
197
  * Подключение участника, уже восстановленного в HostGame (эстафета
108
198
  * Worker'ов, Этап 5.2): порт-машина поднимается сразу в игровом
@@ -123,12 +213,7 @@ export default class PortMachine {
123
213
  };
124
214
 
125
215
  state.methods = this._buildPortMethods(socketId, state);
126
- state.enabled[PC_MAP_READY] = true;
127
- state.enabled[PC_FIRST_SHOT_READY] = true;
128
- state.enabled[PC_KEYS_DATA] = true;
129
- state.enabled[PC_CHAT_DATA] = true;
130
- state.enabled[PC_VOTE_DATA] = true;
131
- state.enabled[PC_PONG] = true;
216
+ this._enableInGame(state);
132
217
 
133
218
  this._clients.set(socketId, state);
134
219
  }
@@ -169,13 +254,156 @@ export default class PortMachine {
169
254
  return;
170
255
  }
171
256
 
257
+ this._clearResumeTimer(state);
172
258
  this._socketManager.removeUser(socketId);
259
+ this._clients.delete(socketId);
173
260
 
174
- if (state.gameId !== undefined) {
175
- this._host.removeUser(state.gameId);
261
+ if (state.gameId === undefined) {
262
+ return;
263
+ }
264
+
265
+ // обрыв без LEAVE (host-migration этап 4): место держится resumeGraceMs,
266
+ // если участнику есть куда вернуться (он вошёл в матч и получил ключ)
267
+ if (this._resumeGraceMs > 0 && this._host.detachUser(state.gameId)) {
268
+ this._startGrace(state.gameId);
269
+ return;
176
270
  }
177
271
 
272
+ this._host.removeUser(state.gameId);
273
+ }
274
+
275
+ // ожидание возврата отсоединённого участника; не дождались — снять
276
+ _startGrace(gameId) {
277
+ this._stopGrace(gameId);
278
+
279
+ this._graceTimers.set(
280
+ gameId,
281
+ clock.setTimeout(() => {
282
+ this._graceTimers.delete(gameId);
283
+
284
+ if (this._host.isDetached(gameId)) {
285
+ this._host.removeUser(gameId);
286
+ }
287
+ }, this._resumeGraceMs),
288
+ );
289
+ }
290
+
291
+ /**
292
+ * Ожидание возврата людей, поднятых из контрольной точки отсоединёнными
293
+ * (host-migration этап 7.4): у них не было обрыва на этом хосте, и
294
+ * таймер им никто не заводил — не вернувшийся занимал бы слот вечно.
295
+ * Отсчёт — со старта восстановленного матча.
296
+ * @param {Iterable<number>} gameIds
297
+ */
298
+ startGraceFor(gameIds) {
299
+ for (const gameId of gameIds) {
300
+ if (!this._host.isDetached(gameId) || this._graceTimers.has(gameId)) {
301
+ continue;
302
+ }
303
+
304
+ if (this._resumeGraceMs > 0) {
305
+ this._startGrace(gameId);
306
+ } else {
307
+ this._host.removeUser(gameId);
308
+ }
309
+ }
310
+ }
311
+
312
+ _stopGrace(gameId) {
313
+ const timer = this._graceTimers.get(gameId);
314
+
315
+ if (timer) {
316
+ clock.clearTimeout(timer);
317
+ this._graceTimers.delete(gameId);
318
+ }
319
+ }
320
+
321
+ // RESUME_REQUEST { v, gameId, resumeKey, token }: проверки по порядку —
322
+ // версия формата, место (существует, ключ совпал), личность по токену
323
+ _resume(socketId, state, data) {
324
+ const reject = reason => {
325
+ if (!this._clients.has(socketId)) {
326
+ return;
327
+ }
328
+
329
+ this._socketManager.sendResumeResult(socketId, { ok: false, reason });
330
+ this._dropConnection(socketId);
331
+ };
332
+
333
+ if (!data || typeof data !== 'object' || data.v !== RESUME_VERSION) {
334
+ reject('version');
335
+ return;
336
+ }
337
+
338
+ const { gameId } = data;
339
+ const target = this._host.getResumeTarget(gameId);
340
+
341
+ // неизвестный участник и чужой ключ неразличимы снаружи: перебором
342
+ // gameId нельзя узнать, чьё место существует
343
+ if (!target || !resumeKeysEqual(data.resumeKey, target.resumeKey)) {
344
+ reject('unknown');
345
+ return;
346
+ }
347
+
348
+ this._identity
349
+ .resolve(data, socketId)
350
+ .then(name => {
351
+ // клиент отключился, пока проверялась личность
352
+ if (!this._clients.has(socketId)) {
353
+ return;
354
+ }
355
+
356
+ if (name !== target.identityName) {
357
+ reject('auth');
358
+ return;
359
+ }
360
+
361
+ // место могли снять, пока проверялась личность (истекло ожидание)
362
+ const current = this._host.getResumeTarget(gameId);
363
+
364
+ if (!current || !resumeKeysEqual(data.resumeKey, current.resumeKey)) {
365
+ reject('unknown');
366
+ return;
367
+ }
368
+
369
+ this._takeOver(gameId, socketId);
370
+ this._clearResumeTimer(state);
371
+ this._stopGrace(gameId);
372
+
373
+ state.gameId = gameId;
374
+ state.enabled[PC_RESUME_REQUEST] = false;
375
+ this._enableInGame(state);
376
+
377
+ this._host.resumeUser(gameId, socketId, data.token);
378
+ })
379
+ .catch(() => reject('auth'));
380
+ }
381
+
382
+ // перехват полуоткрытой сессии: участник ещё числится за прежним
383
+ // соединением (обрыв, которого хост не заметил) — закрыть его, не снимая
384
+ // участника; его поздний disconnect уже ничего не найдёт
385
+ _takeOver(gameId, socketId) {
386
+ for (const [otherId, other] of this._clients) {
387
+ if (otherId !== socketId && other.gameId === gameId) {
388
+ this._clients.delete(otherId);
389
+ this._clearResumeTimer(other);
390
+ this._socketManager.close(otherId);
391
+ this._socketManager.removeUser(otherId);
392
+ }
393
+ }
394
+ }
395
+
396
+ // LEAVE: игрок уходит сам — снять сразу, без ожидания возврата
397
+ _leave(socketId, state) {
398
+ const { gameId } = state;
399
+
400
+ this._socketManager.close(socketId);
401
+ this._socketManager.removeUser(socketId);
178
402
  this._clients.delete(socketId);
403
+
404
+ if (gameId !== undefined) {
405
+ this._host.removeUser(gameId);
406
+ }
179
407
  }
180
408
 
181
409
  /**
@@ -270,12 +498,7 @@ export default class PortMachine {
270
498
  // 2: modules ready
271
499
  () => {
272
500
  state.enabled[PC_MODULES_READY] = false;
273
- state.enabled[PC_MAP_READY] = true;
274
- state.enabled[PC_FIRST_SHOT_READY] = true;
275
- state.enabled[PC_KEYS_DATA] = true;
276
- state.enabled[PC_CHAT_DATA] = true;
277
- state.enabled[PC_VOTE_DATA] = true;
278
- state.enabled[PC_PONG] = true;
501
+ this._enableInGame(state);
279
502
 
280
503
  host.sendMap(state.gameId);
281
504
  },
@@ -283,8 +506,24 @@ export default class PortMachine {
283
506
  // 3: map ready
284
507
  () => host.mapReady(state.gameId),
285
508
 
286
- // 4: first shot ready
287
- () => host.firstShotReady(state.gameId),
509
+ // 4: first shot ready. Участник вошёл в матч — с этого момента ему
510
+ // есть куда вернуться после обрыва: секрет места (SESSION_DATA)
511
+ () => {
512
+ host.firstShotReady(state.gameId);
513
+
514
+ if (this._resumeGraceMs > 0) {
515
+ const resumeKey = host.issueResumeKey(state.gameId);
516
+
517
+ // gameId — клиенту для RESUME_REQUEST: наблюдатель свой id
518
+ // больше ниоткуда не узнаёт
519
+ if (resumeKey) {
520
+ socketManager.sendSessionData(socketId, {
521
+ resumeKey,
522
+ gameId: state.gameId,
523
+ });
524
+ }
525
+ }
526
+ },
288
527
 
289
528
  // 5: keys data ('seq:action:name'; указатель — 'seq:aim:x:y:flags')
290
529
  keyEventString => {
@@ -305,6 +544,15 @@ export default class PortMachine {
305
544
 
306
545
  // 8: pong
307
546
  pingId => host.updateRTT(state.gameId, pingId),
547
+
548
+ // 9: resume request — попытка одна на соединение
549
+ data => {
550
+ state.enabled[PC_RESUME_REQUEST] = false;
551
+ this._resume(socketId, state, data);
552
+ },
553
+
554
+ // 10: leave
555
+ () => this._leave(socketId, state),
308
556
  ];
309
557
  }
310
558
  }