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
package/bin/vimp-sim.js CHANGED
@@ -26,6 +26,11 @@ const USAGE = `Usage: vimp-sim [options]
26
26
  --out <dir> report root (default: .debug)
27
27
  --no-write print the report to stdout instead of writing files
28
28
  --determinism run the scenario twice and compare the frame streams
29
+ --checkpoint-every <ms>
30
+ every <ms> of match time take a host checkpoint, raise a
31
+ NEW host from it and resume every client (RESUME) —
32
+ checks the game survives a host change; overrides the
33
+ scenario's checkpointEvery
29
34
  --help
30
35
  `;
31
36
 
@@ -43,7 +48,7 @@ const BUILTIN_NOTICE =
43
48
  // wasmUrl не смотрит, поэтому прогон был бы зелёным, не тронув ядро игры
44
49
  const STRAY_CORE_NOTICE =
45
50
  'notice: --core without --game does nothing: the run falls back to the\n' +
46
- "notice: built-in fixture, whose core is plain JS. Add --game <path>.\n\n";
51
+ 'notice: built-in fixture, whose core is plain JS. Add --game <path>.\n\n';
47
52
 
48
53
  async function main(argv) {
49
54
  const args = parseArgs(argv);
@@ -70,6 +75,18 @@ async function main(argv) {
70
75
  const scenario = args.scenario
71
76
  ? JSON.parse(await readFile(args.scenario, 'utf8'))
72
77
  : builtinScenario(plugin);
78
+
79
+ if (args['checkpoint-every'] !== undefined) {
80
+ const every = Number(args['checkpoint-every']);
81
+
82
+ if (!Number.isFinite(every) || every <= 0) {
83
+ throw new UsageError(
84
+ `option '--checkpoint-every' needs a positive number of ms\n\n${USAGE}`,
85
+ );
86
+ }
87
+
88
+ scenario.checkpointEvery = every;
89
+ }
73
90
  // хеши потока кадров собираются только под --determinism: их единственный
74
91
  // потребитель — сравнение двух прогонов, а объём линеен по длине матча
75
92
  const captureFrames = args.determinism === true;
@@ -132,6 +149,7 @@ function parseArgs(argv) {
132
149
  case '--game':
133
150
  case '--core':
134
151
  case '--out':
152
+ case '--checkpoint-every':
135
153
  i += 1;
136
154
 
137
155
  // «--game» последним аргументом (или перед следующим флагом) тихо
package/core/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vimp-engine-core"
3
- version = "0.23.1"
3
+ version = "0.23.2"
4
4
  edition = "2024"
5
5
  description = "VIMP — движковый каркас симуляции (физика, карта, снапшот-фрейминг, интерполяция/предикт/raycast-примитивы, нав-утилиты). Без wasm-bindgen — обёртки для WASM-ABI собирает game-crate."
6
6
  license = "MIT"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.35.6",
3
+ "version": "0.36.0",
4
4
  "description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -49,6 +49,11 @@ export default class ControlsCtrl {
49
49
  this._model.setKeysEnabled(false);
50
50
  }
51
51
 
52
+ // повторяет нажатия удерживаемых клавиш (возобновление сессии)
53
+ resendHeld() {
54
+ this._model.resendHeld();
55
+ }
56
+
52
57
  // скрывает курсор
53
58
  resetCursorHideTimer() {
54
59
  this._view.resetCursorHideTimer();
@@ -64,12 +64,12 @@ export default class LobbyCtrl {
64
64
  this._model.loadMore();
65
65
  }
66
66
 
67
- pingHost(hostId) {
68
- this._model.pingHost(hostId, this._clock());
67
+ pingHost(roomId) {
68
+ this._model.pingHost(roomId, this._clock());
69
69
  }
70
70
 
71
- join(hostId) {
72
- this._model.join(hostId);
71
+ join(roomId) {
72
+ this._model.join(roomId);
73
73
  }
74
74
 
75
75
  showTab(tab) {
@@ -0,0 +1,43 @@
1
+ // Singleton RoomMenuCtrl
2
+
3
+ let roomMenuCtrl;
4
+
5
+ export default class RoomMenuCtrl {
6
+ // actions — { onLeave, onHandover }: действия пунктов (client/main.js)
7
+ constructor(model, view, actions = {}) {
8
+ if (roomMenuCtrl) {
9
+ return roomMenuCtrl;
10
+ }
11
+
12
+ roomMenuCtrl = this;
13
+
14
+ this._model = model;
15
+ this._view = view;
16
+
17
+ this._vPublic = view.publisher;
18
+ this._vPublic.on('toggle', () => model.toggle());
19
+ // меню остаётся открытым: на месте пунктов — статус передачи
20
+ this._vPublic.on('leave', () => actions.onLeave?.());
21
+ this._vPublic.on('handover', () => actions.onHandover?.());
22
+ }
23
+
24
+ // абсолютная ссылка на комнату, в которой вкладка; null — скрыть меню
25
+ setLink(link) {
26
+ this._model.setLink(link);
27
+ }
28
+
29
+ // { role, othersPresent, hasSuccessor } — см. RoomMenuModel.setRole
30
+ setRole(state) {
31
+ this._model.setRole(state);
32
+ }
33
+
34
+ // true — кнопка внутри панели (игра её показывает), false — в углу
35
+ setInPanel(inPanel) {
36
+ this._view.placeInPanel(inPanel);
37
+ }
38
+
39
+ // null | 'pending' | 'slow' | 'failed'
40
+ setHandoff(status) {
41
+ this._model.setHandoff(status);
42
+ }
43
+ }
@@ -29,8 +29,10 @@ export default class VoteCtrl {
29
29
  this._model.updateValues(data);
30
30
  // если данные - объект (данные для создания голосования)
31
31
  } else if (typeof data === 'object' && data !== null) {
32
- this._model.createWithTemplate(data);
33
- this._model.open();
32
+ // false — ждёт закрытия открытого голосования движка
33
+ if (this._model.createWithTemplate(data) !== false) {
34
+ this._model.open();
35
+ }
34
36
  // иначе открыть меню
35
37
  } else {
36
38
  this._model.createMenu();
@@ -52,4 +54,18 @@ export default class VoteCtrl {
52
54
  removeVote() {
53
55
  this._model.complete();
54
56
  }
57
+
58
+ // голосование движка (host-migration этап 10: «Change host?» от мастера)
59
+ openEngineVote(vote) {
60
+ this._model.createEngineVote(vote);
61
+ }
62
+
63
+ closeEngineVote(name) {
64
+ this._model.closeEngineVote(name);
65
+ }
66
+
67
+ // голосования хоста не пережили переподключение
68
+ removeHostVotes() {
69
+ this._model.removeHostVotes();
70
+ }
55
71
  }
@@ -90,7 +90,9 @@ export default class ClientReportsModel {
90
90
  this._error = null;
91
91
  this._emit();
92
92
 
93
- const { ok, json } = await this._request(`${this._config.urls.list}?${query}`);
93
+ const { ok, json } = await this._request(
94
+ `${this._config.urls.list}?${query}`,
95
+ );
94
96
 
95
97
  if (seq !== this._seq) {
96
98
  return;
@@ -128,7 +130,9 @@ export default class ClientReportsModel {
128
130
  this._error = null;
129
131
 
130
132
  if (this._status === 'all' || report.status === this._status) {
131
- this._items = this._items.map(item => (item.id === report.id ? report : item));
133
+ this._items = this._items.map(item =>
134
+ item.id === report.id ? report : item,
135
+ );
132
136
  } else {
133
137
  const before = this._items.length;
134
138
 
@@ -20,6 +20,10 @@ export default class ControlsModel {
20
20
  this._currentKeySet = this._keySetList[0]; // текущий набор клавиш
21
21
  this._currentModes = {}; // статусы режимов
22
22
  this._pressedKeys = {}; // объект для хранения состояния зажатых клавиш
23
+ // физически зажатые клавиши (keyCode) — независимо от набора и запрета
24
+ // ввода: _pressedKeys сбрасывает смена набора, а resendHeld() после
25
+ // возобновления сессии должен знать, что игрок держит на самом деле
26
+ this._heldCodes = new Set();
23
27
  this._areKeysEnabled = false; // статус возможности нажатия клавиш
24
28
 
25
29
  // канал указателя: игра, не объявившая modules.controls.pointer, его не
@@ -51,6 +55,8 @@ export default class ControlsModel {
51
55
  const mode = this._modes[keyCode];
52
56
  const cmd = this._cmds[keyCode];
53
57
 
58
+ this._heldCodes.add(keyCode);
59
+
54
60
  // если запрет на ввод клавиш,
55
61
  // то доступны только stat и chat
56
62
  if (
@@ -103,6 +109,8 @@ export default class ControlsModel {
103
109
  const keyCode = event.keyCode;
104
110
  const mode = this._modes[keyCode];
105
111
 
112
+ this._heldCodes.delete(keyCode);
113
+
106
114
  // если клавиша была зажата
107
115
  if (this._pressedKeys[keyCode]) {
108
116
  const name = this._currentKeySet[keyCode];
@@ -226,6 +234,25 @@ export default class ControlsModel {
226
234
  this._releasePointer(this._lastTapX, this._lastTapY);
227
235
  }
228
236
 
237
+ // снова отправляет нажатия удерживаемых команд (host-migration этап 4):
238
+ // при обрыве транспорта хост отпустил все клавиши игрока, а тот их так и
239
+ // держит. Зовётся после пакета входа — смена набора в нём уже сбросила
240
+ // _pressedKeys, поэтому источник — физически зажатые клавиши
241
+ resendHeld() {
242
+ if (this._areKeysEnabled === false || this._currentModes.chat) {
243
+ return;
244
+ }
245
+
246
+ for (const keyCode of this._heldCodes) {
247
+ const name = this._currentKeySet[keyCode];
248
+
249
+ if (name && !this._pressedKeys[keyCode]) {
250
+ this._pressedKeys[keyCode] = true;
251
+ this.publisher.emit('socket', `down:${name}`);
252
+ }
253
+ }
254
+ }
255
+
229
256
  // задаёт возможность нажатия клавиш
230
257
  // (stat и chat доступны при любом значении)
231
258
  setKeysEnabled(isEnabled) {
@@ -214,7 +214,11 @@ export default class GamesModel {
214
214
  return;
215
215
  }
216
216
 
217
- this.publisher.emit('staged', { id, version: json.version, manifest: json.manifest });
217
+ this.publisher.emit('staged', {
218
+ id,
219
+ version: json.version,
220
+ manifest: json.manifest,
221
+ });
218
222
  await this.loadAdmin();
219
223
  }
220
224
 
@@ -18,16 +18,16 @@ export default class LobbyModel {
18
18
  this._pageSize = params.pageSize;
19
19
  this._pingInterval = params.pingInterval;
20
20
 
21
- this._servers = new Map(); // hostId -> { ...server, latency }
22
- this._order = []; // hostId в порядке выдачи мастером
21
+ this._servers = new Map(); // roomId -> { ...server, latency }
22
+ this._order = []; // roomId в порядке выдачи мастером
23
23
  this._total = 0; // всего серверов у мастера (для «Загрузить ещё»)
24
24
  this._offset = 0;
25
25
  this._search = '';
26
26
 
27
27
  this._pingCounter = 0;
28
- this._pending = new Map(); // pingId -> { hostId, time }
29
- this._lastPing = new Map(); // hostId -> time последнего пинга
30
- this._latencies = new Map(); // hostId -> latency (переживает refresh)
28
+ this._pending = new Map(); // pingId -> { roomId, time }
29
+ this._lastPing = new Map(); // roomId -> time последнего пинга
30
+ this._latencies = new Map(); // roomId -> latency (переживает refresh)
31
31
 
32
32
  // Leaderboard выбранной игры (lobby-page-plan) — сетевого I/O не делает,
33
33
  // как и остальная модель; fetch живёт в main.js (fetchLeaderboard/fetchPlacement)
@@ -49,7 +49,7 @@ export default class LobbyModel {
49
49
  this._emitFetch();
50
50
  }
51
51
 
52
- // задаёт поиск по имени и запрашивает список заново
52
+ // задаёт поиск (roomId, игра, карта) и запрашивает список заново
53
53
  setSearch(text) {
54
54
  this._search = typeof text === 'string' ? text.trim() : '';
55
55
  this._offset = 0;
@@ -72,14 +72,18 @@ export default class LobbyModel {
72
72
  this._total = total || 0;
73
73
 
74
74
  (servers || []).forEach(server => {
75
- if (!this._servers.has(server.hostId)) {
76
- this._order.push(server.hostId);
75
+ // hostId — имя roomId у мастера до host-migration этапа 2
76
+ const roomId = server.roomId ?? server.hostId;
77
+
78
+ if (!this._servers.has(roomId)) {
79
+ this._order.push(roomId);
77
80
  }
78
81
 
79
82
  // latency живёт в _latencies и переживает refresh/пагинацию
80
- this._servers.set(server.hostId, {
83
+ this._servers.set(roomId, {
81
84
  ...server,
82
- latency: this._latencies.get(server.hostId) ?? null,
85
+ roomId,
86
+ latency: this._latencies.get(roomId) ?? null,
83
87
  });
84
88
  });
85
89
 
@@ -87,35 +91,35 @@ export default class LobbyModel {
87
91
  }
88
92
 
89
93
  // пользователь выбрал сервер — во внешнего подписчика (P2P-подключение).
90
- // gameId едет вместе с hostId: комната может быть по другой игре, и клиент
94
+ // gameId едет вместе с roomId: комната может быть по другой игре, и клиент
91
95
  // обязан активировать её ClientPlugin до подключения
92
- join(hostId) {
93
- const server = this._servers.get(hostId);
96
+ join(roomId) {
97
+ const server = this._servers.get(roomId);
94
98
 
95
99
  if (server) {
96
- this.publisher.emit('join', { hostId, gameId: server.gameId });
100
+ this.publisher.emit('join', { roomId, gameId: server.gameId });
97
101
  }
98
102
  }
99
103
 
100
104
  // готовит пинг видимого сервера; false — если пинговали недавно/сервер ушёл
101
- pingHost(hostId, now) {
102
- const last = this._lastPing.get(hostId);
105
+ pingHost(roomId, now) {
106
+ const last = this._lastPing.get(roomId);
103
107
 
104
108
  if (last !== undefined && now - last < this._pingInterval) {
105
109
  return false;
106
110
  }
107
111
 
108
- if (!this._servers.has(hostId)) {
112
+ if (!this._servers.has(roomId)) {
109
113
  return false;
110
114
  }
111
115
 
112
- this._lastPing.set(hostId, now);
116
+ this._lastPing.set(roomId, now);
113
117
  this._pingCounter = (this._pingCounter + 1) >>> 0;
114
118
 
115
119
  const pingId = this._pingCounter;
116
120
 
117
- this._pending.set(pingId, { hostId, time: now });
118
- this.publisher.emit('ping-request', { hostId, pingId });
121
+ this._pending.set(pingId, { roomId, time: now });
122
+ this.publisher.emit('ping-request', { roomId, pingId });
119
123
 
120
124
  return true;
121
125
  }
@@ -130,17 +134,17 @@ export default class LobbyModel {
130
134
 
131
135
  this._pending.delete(pingId);
132
136
 
133
- const server = this._servers.get(pending.hostId);
137
+ const server = this._servers.get(pending.roomId);
134
138
 
135
139
  if (!server) {
136
140
  return;
137
141
  }
138
142
 
139
143
  server.latency = Math.round(now - pending.time);
140
- this._latencies.set(pending.hostId, server.latency);
144
+ this._latencies.set(pending.roomId, server.latency);
141
145
 
142
146
  this.publisher.emit('ping-update', {
143
- hostId: pending.hostId,
147
+ roomId: pending.roomId,
144
148
  latency: server.latency,
145
149
  });
146
150
  }
@@ -157,7 +161,11 @@ export default class LobbyModel {
157
161
  // позиция вызывающего, применяется ответом GET /auth/placement;
158
162
  // placement === null — игрок ещё не ранжирован в этой игре
159
163
  setPlacement({ placement, total, rank } = {}) {
160
- this._myPlacement = { placement: placement ?? null, total: total || 0, rank: rank || 0 };
164
+ this._myPlacement = {
165
+ placement: placement ?? null,
166
+ total: total || 0,
167
+ rank: rank || 0,
168
+ };
161
169
 
162
170
  this._scheduleLeaderboardEmit();
163
171
  }
@@ -60,9 +60,12 @@ export default class LobbyAuthModel {
60
60
  }
61
61
 
62
62
  // URL редиректа на старт OAuth-потока auth-сервиса; вызывающий должен сам
63
- // сделать переход (window.location.href) — модель не трогает location
63
+ // сделать переход (window.location.href) — модель не трогает location.
64
+ // hash (#/<gameId>[/<roomId>]) едет в returnUrl: после логина игрок
65
+ // попадает туда, куда вёл открытый URL, а не в общее лобби
64
66
  loginUrl(provider) {
65
- const returnUrl = `${window.location.origin}${window.location.pathname}`;
67
+ const { origin, pathname, hash } = window.location;
68
+ const returnUrl = `${origin}${pathname}${hash}`;
66
69
 
67
70
  return `${this._config.serviceUrl}/oauth/${provider}/start?returnUrl=${encodeURIComponent(returnUrl)}`;
68
71
  }
@@ -125,6 +128,15 @@ export default class LobbyAuthModel {
125
128
  return decodeJwtPayload(this._identityToken)?.role ?? 'user';
126
129
  }
127
130
 
131
+ // срок входа (мс эпохи) или null — неизвестен. Вкладка с кончающимся
132
+ // токеном не принимает роль хоста: register_host/reclaim_host предъявят
133
+ // его мастеру посреди матча (host-migration-review этап 7)
134
+ getTokenExpiresAt() {
135
+ const exp = decodeJwtPayload(this._identityToken)?.exp;
136
+
137
+ return typeof exp === 'number' ? exp * 1000 : null;
138
+ }
139
+
128
140
  _restore(tokenStorageKey) {
129
141
  const token = localStorage[tokenStorageKey];
130
142
 
@@ -141,7 +153,8 @@ export default class LobbyAuthModel {
141
153
  // показывает «залогинен» токеном, который хост уже отклонит при входе в игру
142
154
  _setIdentity(token, tokenStorageKey, persist = true) {
143
155
  const payload = decodeJwtPayload(token);
144
- const isExpired = typeof payload?.exp === 'number' && Date.now() >= payload.exp * 1000;
156
+ const isExpired =
157
+ typeof payload?.exp === 'number' && Date.now() >= payload.exp * 1000;
145
158
 
146
159
  if (!payload || !payload.nick || isExpired) {
147
160
  delete localStorage[tokenStorageKey];
@@ -150,7 +163,10 @@ export default class LobbyAuthModel {
150
163
  // битый токен — штатная ситуация возврата на сайт, не ошибка. login-error
151
164
  // — только на интерактивном пути.
152
165
  if (persist) {
153
- this.publisher.emit('login-error', isExpired ? 'tokenExpired' : 'invalidToken');
166
+ this.publisher.emit(
167
+ 'login-error',
168
+ isExpired ? 'tokenExpired' : 'invalidToken',
169
+ );
154
170
  }
155
171
 
156
172
  this.publisher.emit('login-required', this._config.providers);
@@ -0,0 +1,121 @@
1
+ import Publisher from '../../../lib/Publisher.js';
2
+
3
+ // Singleton RoomMenuModel
4
+ //
5
+ // Меню комнаты внутри матча (host-migration, этапы 3 и 8d). Ссылка на
6
+ // текущую комнату — признак «вкладка в комнате». Пункты:
7
+ // - «Leave server» — у всех;
8
+ // - «Hand over host» — только у текущего хоста, когда в комнате есть другие
9
+ // люди и мастер назначил бету (иначе передавать некому).
10
+ // Пока идёт передача, пункты заблокированы, на их месте статус. Только
11
+ // лобби-режим: в solo/dedicated ссылки на комнату нет
12
+
13
+ // handoff: null | 'pending' | 'slow' | 'failed'
14
+ const STATUS_TEXT = {
15
+ pending: 'Handing over…',
16
+ slow: 'Slow connection…',
17
+ failed: 'Host handover failed',
18
+ };
19
+
20
+ let roomMenuModel;
21
+
22
+ export default class RoomMenuModel {
23
+ constructor() {
24
+ if (roomMenuModel) {
25
+ return roomMenuModel;
26
+ }
27
+
28
+ roomMenuModel = this;
29
+
30
+ this._link = null;
31
+ this._open = false;
32
+ this._role = null; // null | 'host' | 'guest'
33
+ this._othersPresent = false;
34
+ this._hasSuccessor = false;
35
+ this._handoff = null;
36
+ this.publisher = new Publisher();
37
+ }
38
+
39
+ // абсолютная ссылка на комнату; null — вкладка не в комнате, меню скрыто
40
+ setLink(link) {
41
+ this._link = link || null;
42
+
43
+ if (!this._link) {
44
+ this.setOpen(false);
45
+ }
46
+
47
+ this._emitState();
48
+ }
49
+
50
+ getLink() {
51
+ return this._link;
52
+ }
53
+
54
+ /**
55
+ * Роль вкладки в комнате.
56
+ * @param {Object} state
57
+ * @param {'host'|'guest'|null} state.role
58
+ * @param {boolean} [state.othersPresent] - в комнате есть другие люди.
59
+ * @param {boolean} [state.hasSuccessor] - мастер назначил бету.
60
+ */
61
+ setRole({ role, othersPresent = false, hasSuccessor = false }) {
62
+ this._role = role ?? null;
63
+ this._othersPresent = othersPresent === true;
64
+ this._hasSuccessor = hasSuccessor === true;
65
+ this._emitState();
66
+ }
67
+
68
+ // ход плановой передачи: null | 'pending' | 'slow' | 'failed'
69
+ setHandoff(status) {
70
+ this._handoff = status ?? null;
71
+ this._emitState();
72
+ }
73
+
74
+ setOpen(open) {
75
+ const next = Boolean(open) && this._link !== null;
76
+
77
+ if (next === this._open) {
78
+ return;
79
+ }
80
+
81
+ this._open = next;
82
+ this.publisher.emit('open', next);
83
+
84
+ // ошибка передачи показана — закрытое меню её забывает
85
+ if (!next && this._handoff === 'failed') {
86
+ this.setHandoff(null);
87
+ }
88
+ }
89
+
90
+ toggle() {
91
+ this.setOpen(!this._open);
92
+ }
93
+
94
+ /**
95
+ * Что показывать.
96
+ * @returns {{ visible: boolean, leave: Object, handover: Object,
97
+ * status: string|null }} leave/handover — { visible, disabled }.
98
+ */
99
+ getState() {
100
+ const inRoom = this._link !== null;
101
+ const busy = this._handoff === 'pending' || this._handoff === 'slow';
102
+
103
+ return {
104
+ visible: inRoom,
105
+ leave: { visible: inRoom, disabled: busy },
106
+ handover: {
107
+ visible:
108
+ inRoom &&
109
+ this._role === 'host' &&
110
+ this._othersPresent &&
111
+ this._hasSuccessor,
112
+ disabled: busy,
113
+ },
114
+ status: STATUS_TEXT[this._handoff] ?? null,
115
+ };
116
+ }
117
+
118
+ _emitState() {
119
+ this.publisher.emit('state', this.getState());
120
+ }
121
+ }
@@ -174,7 +174,8 @@ export default class StatModel {
174
174
 
175
175
  // в топе игрока нет — его собственное место знает только рассылка хоста
176
176
  const myId = this._localPlayer?.id ?? null;
177
- const mine = myId === null ? null : this._accolades.selfOf(myId, this._period);
177
+ const mine =
178
+ myId === null ? null : this._accolades.selfOf(myId, this._period);
178
179
 
179
180
  if (!mine || !nick) {
180
181
  return marked;