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
@@ -2,6 +2,10 @@ import Publisher from '../../../lib/Publisher.js';
2
2
 
3
3
  // Singleton VoteModel
4
4
 
5
+ // имя с '@' — голосование движка (host-migration этап 10: '@changeHost'):
6
+ // его ведёт мастер, а не хост, и игра таких имён не объявляет
7
+ const isEngineVote = name => typeof name === 'string' && name.startsWith('@');
8
+
5
9
  let voteModel;
6
10
 
7
11
  export default class VoteModel {
@@ -13,6 +17,7 @@ export default class VoteModel {
13
17
  voteModel = this;
14
18
 
15
19
  this._formatMessage = data.formatMessage;
20
+ this._now = data.now ?? (() => Date.now());
16
21
 
17
22
  // игра может не объявлять голосований вовсе (noSpectators): меню по 'm'
18
23
  // тогда пустое, а не падение на длине undefined
@@ -28,6 +33,12 @@ export default class VoteModel {
28
33
  this._timeOff = false; // флаг отключения времени жизни голосования
29
34
 
30
35
  this._voteName = ''; // название голосования
36
+ this._deadline = null; // конец голосования движка (окно — на остаток)
37
+ this._showing = false; // окно (голосование или меню) открыто
38
+
39
+ // голосования, ждущие закрытия открытого окна: голосование движка не
40
+ // затирает чужое и не затирается им (этап 10)
41
+ this._queue = [];
31
42
 
32
43
  this._title = null; // заголовок голосования
33
44
  this._values = []; // все значения голосования
@@ -45,7 +56,18 @@ export default class VoteModel {
45
56
  this.publisher.emit('mode', { name: 'vote', status: 'opened' });
46
57
  }
47
58
 
59
+ // голосование хоста по шаблону; false — встало в очередь за открытым
60
+ // голосованием движка
48
61
  createWithTemplate({ name, params, values }) {
62
+ if (
63
+ this._showing &&
64
+ this._type === 'vote' &&
65
+ isEngineVote(this._voteName)
66
+ ) {
67
+ this._queue.push({ template: { name, params, values } });
68
+ return false;
69
+ }
70
+
49
71
  const templateArr = this._templates[name];
50
72
 
51
73
  if (templateArr) {
@@ -59,14 +81,63 @@ export default class VoteModel {
59
81
 
60
82
  this.createVote(name, title, values, timeOff);
61
83
  }
84
+
85
+ return true;
86
+ }
87
+
88
+ /**
89
+ * Голосование движка (имя с '@'): окно живёт до deadline; открыто другое
90
+ * окно — ждёт в очереди.
91
+ * @param {Object} vote - { name, title, values, deadline }.
92
+ * @returns {boolean} окно открыто сразу.
93
+ */
94
+ createEngineVote(vote) {
95
+ if (this._showing || this._waitingValues) {
96
+ this._queue.push({ engine: { ...vote } });
97
+ return false;
98
+ }
99
+
100
+ this._startEngineVote(vote);
101
+
102
+ return true;
103
+ }
104
+
105
+ /**
106
+ * Голосование движка завершилось (итог от мастера): окно закрывается или
107
+ * снимается из очереди.
108
+ * @param {string} name
109
+ */
110
+ closeEngineVote(name) {
111
+ this._queue = this._queue.filter(item => item.engine?.name !== name);
112
+
113
+ if (this._showing && this._type === 'vote' && this._voteName === name) {
114
+ this.complete();
115
+ }
116
+ }
117
+
118
+ // голосования хоста не пережили переподключение: открытое закрывается,
119
+ // ждущие отбрасываются; голосование движка (его ведёт мастер) остаётся
120
+ removeHostVotes() {
121
+ this._queue = this._queue.filter(item => item.engine);
122
+
123
+ if (!(this._type === 'vote' && isEngineVote(this._voteName))) {
124
+ this.complete();
125
+ }
126
+ }
127
+
128
+ _startEngineVote({ name, title, values, deadline }) {
129
+ this.createVote(name, title, values, false, deadline);
130
+ this.open();
62
131
  }
63
132
 
64
- // создает голосование
65
- createVote(name, title, values, timeOff) {
133
+ // создает голосование; deadline — только у голосований движка
134
+ createVote(name, title, values, timeOff, deadline = null) {
66
135
  if (this._waitingValues) {
67
136
  return;
68
137
  }
69
138
 
139
+ this._deadline = deadline;
140
+
70
141
  this._type = 'vote';
71
142
  this._back = false;
72
143
  this._more = false;
@@ -91,7 +162,24 @@ export default class VoteModel {
91
162
  return;
92
163
  }
93
164
 
165
+ // меню открывается поверх голосования движка — оно вернётся следом
166
+ if (
167
+ this._showing &&
168
+ this._type === 'vote' &&
169
+ isEngineVote(this._voteName)
170
+ ) {
171
+ this._queue.unshift({
172
+ engine: {
173
+ name: this._voteName,
174
+ title: this._title,
175
+ values: this._values,
176
+ deadline: this._deadline,
177
+ },
178
+ });
179
+ }
180
+
94
181
  this._timeOff = false;
182
+ this._deadline = null;
95
183
 
96
184
  this._type = 'menu';
97
185
  this._back = false;
@@ -204,6 +292,7 @@ export default class VoteModel {
204
292
  currentValues = this._currentValues;
205
293
  }
206
294
 
295
+ this._showing = true;
207
296
  this.publisher.emit('clear', this._timerId);
208
297
 
209
298
  this.publisher.emit('vote', {
@@ -211,15 +300,44 @@ export default class VoteModel {
211
300
  list: currentValues,
212
301
  back: this._back,
213
302
  more: this._more,
214
- time: this._timeOff === true ? null : this._time,
303
+ time: this._timeOff === true ? null : this._windowTime(),
215
304
  });
216
305
  }
217
306
 
218
- // завершает голосование
307
+ // время окна: у голосования движка — остаток до его конца
308
+ _windowTime() {
309
+ return this._deadline === null
310
+ ? this._time
311
+ : Math.max(0, this._deadline - this._now());
312
+ }
313
+
314
+ // завершает голосование; следом открывается ждущее в очереди
219
315
  complete() {
220
316
  this._waitingValues = false;
317
+ this._showing = false;
318
+ this._deadline = null;
221
319
  this.publisher.emit('clear', this._timerId);
222
320
  this.publisher.emit('mode', { name: 'vote', status: 'closed' });
321
+ this._openNext();
322
+ }
323
+
324
+ // первое ждущее голосование; голосование движка, чьё время вышло, —
325
+ // пропускается
326
+ _openNext() {
327
+ while (this._queue.length > 0) {
328
+ const { engine, template } = this._queue.shift();
329
+
330
+ if (engine) {
331
+ if (engine.deadline - this._now() > 0) {
332
+ this._startEngineVote(engine);
333
+ return;
334
+ }
335
+ } else {
336
+ this.createWithTemplate(template);
337
+ this.open();
338
+ return;
339
+ }
340
+ }
223
341
  }
224
342
 
225
343
  // добавляет id таймера голосования
@@ -1,5 +1,9 @@
1
1
  import Publisher from '../../../lib/Publisher.js';
2
- import { bindLiveErrors, buildForm, renderFormErrors } from '../../lib/formBuilder.js';
2
+ import {
3
+ bindLiveErrors,
4
+ buildForm,
5
+ renderFormErrors,
6
+ } from '../../lib/formBuilder.js';
3
7
 
4
8
  // Singleton AuthView
5
9
 
@@ -33,10 +37,14 @@ export default class AuthView {
33
37
 
34
38
  // ошибки видны по ходу правки (см. bindLiveErrors), а не по клику:
35
39
  // до сабмита — по тронутым полям, после — по всей форме
36
- this._liveErrors = bindLiveErrors(this._fieldsContainer, this._error, () => ({
37
- descriptors: this._descriptors,
38
- fields: this._fields,
39
- }));
40
+ this._liveErrors = bindLiveErrors(
41
+ this._fieldsContainer,
42
+ this._error,
43
+ () => ({
44
+ descriptors: this._descriptors,
45
+ fields: this._fields,
46
+ }),
47
+ );
40
48
 
41
49
  // форма заполнена; клиентская проверка (required/regExp/min/max) —
42
50
  // сервер всё равно валидирует своими validators (renderError), это лишь
@@ -69,11 +77,13 @@ export default class AuthView {
69
77
 
70
78
  // param.options — дескриптор-хвост (control/label/min/max/... —
71
79
  // "options" здесь ключ протокола PS_AUTH_DATA, не список выбора select
72
- const descriptors = params.map(({ name, value, options: descriptorRest }) => ({
73
- name,
74
- default: value,
75
- ...descriptorRest,
76
- }));
80
+ const descriptors = params.map(
81
+ ({ name, value, options: descriptorRest }) => ({
82
+ name,
83
+ default: value,
84
+ ...descriptorRest,
85
+ }),
86
+ );
77
87
 
78
88
  this._descriptors = descriptors;
79
89
  // params сюда приходят чужие (PS_AUTH_DATA, тот же массив main.js
@@ -169,12 +169,17 @@ export default class ClientReportsView {
169
169
  [timeAgo(report.lastSeen), 'reports-when'],
170
170
  [`×${report.count}`, 'reports-count'],
171
171
  [`${report.kind}/${report.source}`, 'reports-kind'],
172
- [truncate(String(report.code ?? report.message ?? ''), MESSAGE_PREVIEW), 'reports-message'],
172
+ [
173
+ truncate(String(report.code ?? report.message ?? ''), MESSAGE_PREVIEW),
174
+ 'reports-message',
175
+ ],
173
176
  [report.gameId ? `${report.gameId}@${report.gameVersion ?? '—'}` : '—'],
174
177
  [`engine ${report.engineVersion ?? '—'}`],
175
178
  [report.box ?? '—'],
176
179
  [report.status, `reports-status reports-status-${report.status}`],
177
- ].forEach(([text, className]) => summary.appendChild(this._span(text, className)));
180
+ ].forEach(([text, className]) =>
181
+ summary.appendChild(this._span(text, className)),
182
+ );
178
183
 
179
184
  details.style.display = this._expanded.has(report.id) ? '' : 'none';
180
185
  summary.onclick = () => {
@@ -199,7 +204,9 @@ export default class ClientReportsView {
199
204
  const details = document.createElement('div');
200
205
 
201
206
  details.className = 'reports-details';
202
- details.appendChild(this._line(report.message ?? '', 'reports-full-message'));
207
+ details.appendChild(
208
+ this._line(report.message ?? '', 'reports-full-message'),
209
+ );
203
210
 
204
211
  if (report.stack) {
205
212
  details.appendChild(this._pre(report.stack));
@@ -210,7 +217,9 @@ export default class ClientReportsView {
210
217
  }
211
218
 
212
219
  details.appendChild(this._line(`User agent: ${report.userAgent ?? '—'}`));
213
- details.appendChild(this._line(`Role: ${report.role ?? '—'}; page: ${report.page ?? '—'}`));
220
+ details.appendChild(
221
+ this._line(`Role: ${report.role ?? '—'}; page: ${report.page ?? '—'}`),
222
+ );
214
223
  details.appendChild(
215
224
  this._line(
216
225
  `First seen: ${report.firstSeen ?? '—'}; last seen: ${report.lastSeen ?? '—'}` +
@@ -218,7 +227,9 @@ export default class ClientReportsView {
218
227
  ),
219
228
  );
220
229
  details.appendChild(
221
- this._line(`Fingerprint: ${String(report.fingerprint ?? '').slice(0, 12)}`),
230
+ this._line(
231
+ `Fingerprint: ${String(report.fingerprint ?? '').slice(0, 12)}`,
232
+ ),
222
233
  );
223
234
 
224
235
  if (report.statusAt) {
@@ -209,7 +209,10 @@ export default class GamesView {
209
209
  this._submitError.textContent = '';
210
210
 
211
211
  this._preview.appendChild(
212
- this._line(`${title ? `${title} · ` : ''}${id ?? '—'}`, 'games-item-title'),
212
+ this._line(
213
+ `${title ? `${title} · ` : ''}${id ?? '—'}`,
214
+ 'games-item-title',
215
+ ),
213
216
  );
214
217
  this._preview.appendChild(this._line(`Version: ${version ?? '—'}`));
215
218
  this._preview.appendChild(this._line(`Engine API: ${engineApi ?? '—'}`));
@@ -233,9 +236,9 @@ export default class GamesView {
233
236
  // разрешённая версия в поле меняет `${packageName}@${version}`, и
234
237
  // следующий blur прошёл бы как «другой пакет»: запрет повтора обновляется
235
238
  // фактическим ответом мастера
236
- this._lastLookup = `${this._fields.get('packageName').value.trim()}@${
237
- this._fields.get('version').value.trim()
238
- }`;
239
+ this._lastLookup = `${this._fields.get('packageName').value.trim()}@${this._fields
240
+ .get('version')
241
+ .value.trim()}`;
239
242
 
240
243
  const problems = errors || [];
241
244
 
@@ -283,7 +286,10 @@ export default class GamesView {
283
286
  send.type = 'button';
284
287
  send.value = 'Update version';
285
288
  send.onclick = () =>
286
- this.publisher.emit('update-version', { id: game.id, version: version.value.trim() });
289
+ this.publisher.emit('update-version', {
290
+ id: game.id,
291
+ version: version.value.trim(),
292
+ });
287
293
 
288
294
  item.appendChild(version);
289
295
  item.appendChild(send);
@@ -344,11 +350,15 @@ export default class GamesView {
344
350
  // решение по ней приняли. Из всей карточки ей нужны срок полного
345
351
  // удаления и возврат — остальные кнопки правили бы то, чего не видно
346
352
  if (game.deletedAt) {
347
- item.appendChild(this._line(`Author: ${game.authorNick ?? game.authorUserId ?? '—'}`));
353
+ item.appendChild(
354
+ this._line(`Author: ${game.authorNick ?? game.authorUserId ?? '—'}`),
355
+ );
348
356
  this._appendRepo(item, game.repoUrl);
349
357
  item.appendChild(this._line(this._statusLine(game), 'games-purge-line'));
350
358
  item.appendChild(
351
- this._button('Restore', () => this.publisher.emit('restore', { id: game.id })),
359
+ this._button('Restore', () =>
360
+ this.publisher.emit('restore', { id: game.id }),
361
+ ),
352
362
  );
353
363
 
354
364
  return item;
@@ -356,7 +366,9 @@ export default class GamesView {
356
366
 
357
367
  // у игр, засеянных миграцией, автора нет вовсе: без запасного прочерка
358
368
  // в строке печаталось бы литеральное "null"
359
- item.appendChild(this._line(`Author: ${game.authorNick ?? game.authorUserId ?? '—'}`));
369
+ item.appendChild(
370
+ this._line(`Author: ${game.authorNick ?? game.authorUserId ?? '—'}`),
371
+ );
360
372
 
361
373
  // переназначение автора: игры платформы засеяны без него, и «My games»
362
374
  // у их автора пуст, пока админ не проставит ник здесь. Пустое поле —
@@ -371,7 +383,10 @@ export default class GamesView {
371
383
  item.appendChild(author);
372
384
  item.appendChild(
373
385
  this._button('Set author', () =>
374
- this.publisher.emit('set-author', { id: game.id, nick: author.value.trim() }),
386
+ this.publisher.emit('set-author', {
387
+ id: game.id,
388
+ nick: author.value.trim(),
389
+ }),
375
390
  ),
376
391
  );
377
392
  this._appendRepo(item, game.repoUrl);
@@ -387,7 +402,9 @@ export default class GamesView {
387
402
  item.appendChild(
388
403
  this._line(
389
404
  `On this master: ${game.local.downloaded ? 'downloaded' : 'not downloaded'}` +
390
- (game.local.stagedVersion ? `; staged ${game.local.stagedVersion}` : '') +
405
+ (game.local.stagedVersion
406
+ ? `; staged ${game.local.stagedVersion}`
407
+ : '') +
391
408
  (game.local.lastError ? `; error: ${game.local.lastError}` : ''),
392
409
  ),
393
410
  );
@@ -409,7 +426,9 @@ export default class GamesView {
409
426
  ),
410
427
  );
411
428
  item.appendChild(
412
- this._button('Approve', () => this.publisher.emit('approve', { id: game.id })),
429
+ this._button('Approve', () =>
430
+ this.publisher.emit('approve', { id: game.id }),
431
+ ),
413
432
  );
414
433
  item.appendChild(
415
434
  this._button('Reject', () =>
@@ -417,7 +436,9 @@ export default class GamesView {
417
436
  ),
418
437
  );
419
438
  item.appendChild(
420
- this._button('Disable', () => this.publisher.emit('disable', { id: game.id })),
439
+ this._button('Disable', () =>
440
+ this.publisher.emit('disable', { id: game.id }),
441
+ ),
421
442
  );
422
443
  item.appendChild(
423
444
  this._button('npm versions', () =>
@@ -457,14 +478,18 @@ export default class GamesView {
457
478
 
458
479
  _statusLine(game) {
459
480
  const status = STATUS_TITLES[game.status] ?? game.status;
460
- const date = game.createdAt ? new Date(game.createdAt).toLocaleDateString() : '';
481
+ const date = game.createdAt
482
+ ? new Date(game.createdAt).toLocaleDateString()
483
+ : '';
461
484
 
462
485
  // у удалённой игры прежний статус — след того, куда её вернёт Restore, —
463
486
  // а вести отсчёт человеку нужно от срока полного удаления: только он
464
487
  // ограничен во времени
465
488
  if (game.deletedAt) {
466
489
  const deleted = new Date(game.deletedAt).toLocaleDateString();
467
- const purge = game.purgeAt ? new Date(game.purgeAt).toLocaleDateString() : '—';
490
+ const purge = game.purgeAt
491
+ ? new Date(game.purgeAt).toLocaleDateString()
492
+ : '—';
468
493
 
469
494
  return `Deleted ${deleted} (was ${status}); removed for good on ${purge}`;
470
495
  }
@@ -1,6 +1,7 @@
1
1
  import Publisher from '../../../lib/Publisher.js';
2
2
  import { ENGINE_PROJECT_URL, ENGINE_VERSION } from '../../lib/engineVersion.js';
3
3
  import { renderProjectLink } from '../../lib/footerLink.js';
4
+ import { absoluteLink, formatRoomLink } from '../../lib/roomLink.js';
4
5
 
5
6
  // Singleton LobbyView
6
7
 
@@ -9,9 +10,16 @@ let lobbyView;
9
10
  // неизвестная задержка сортируется в конец списка
10
11
  const UNKNOWN_LATENCY = Infinity;
11
12
 
13
+ // 'unknown' мастер ставит, когда заголовка региона нет: в карточке это шум
14
+ const knownRegion = region =>
15
+ typeof region === 'string' && region !== '' && region !== 'unknown'
16
+ ? region
17
+ : null;
18
+
12
19
  // Представление лобби: рендер списка серверов, поиск, «Загрузить ещё» и
13
20
  // умный пинг через IntersectionObserver (пинг шлётся только для карточек,
14
21
  // попавших в видимую область). Observer инъектируется ради тестируемости.
22
+
15
23
  export default class LobbyView {
16
24
  constructor(model, elems, observerFactory) {
17
25
  if (lobbyView) {
@@ -28,9 +36,13 @@ export default class LobbyView {
28
36
 
29
37
  // вкладки правой панели (lobby-page-plan)
30
38
  this._tabServersBtn = document.getElementById(elems.tabServersBtnId);
31
- this._tabLeaderboardBtn = document.getElementById(elems.tabLeaderboardBtnId);
39
+ this._tabLeaderboardBtn = document.getElementById(
40
+ elems.tabLeaderboardBtnId,
41
+ );
32
42
  this._serversContent = document.getElementById(elems.serversContentId);
33
- this._leaderboardContent = document.getElementById(elems.leaderboardContentId);
43
+ this._leaderboardContent = document.getElementById(
44
+ elems.leaderboardContentId,
45
+ );
34
46
  this._leaderboardList = document.getElementById(elems.leaderboardListId);
35
47
  this._leaderboardTitle = document.getElementById(elems.leaderboardTitleId);
36
48
  this._leaderboardTotal = document.getElementById(elems.leaderboardTotalId);
@@ -53,7 +65,10 @@ export default class LobbyView {
53
65
  version.textContent = ENGINE_VERSION;
54
66
  }
55
67
 
56
- renderProjectLink(document.getElementById(elems.linkId), ENGINE_PROJECT_URL);
68
+ renderProjectLink(
69
+ document.getElementById(elems.linkId),
70
+ ENGINE_PROJECT_URL,
71
+ );
57
72
 
58
73
  // заголовок игры для "<TITLE> TOP-N" (SVG-ориентир) — задаётся controller'ом
59
74
  // при выборе игры (сама модель не хранит title манифеста)
@@ -76,7 +91,7 @@ export default class LobbyView {
76
91
  // границе LIMIT, см. комментарий в renderLeaderboard)
77
92
  this._selfNick = '';
78
93
 
79
- this._cards = new Map(); // hostId -> { card, latencyEl, latency }
94
+ this._cards = new Map(); // roomId -> { card, latencyEl, latency }
80
95
 
81
96
  this.publisher = new Publisher();
82
97
 
@@ -84,20 +99,23 @@ export default class LobbyView {
84
99
  observerFactory ||
85
100
  (cb => new IntersectionObserver(cb, { root: this._list }));
86
101
 
87
- // видимая карточка → запрос пинга (hostId в data-атрибуте)
102
+ // видимая карточка → запрос пинга (roomId в data-атрибуте)
88
103
  this._observer = makeObserver(entries => {
89
104
  entries.forEach(entry => {
90
105
  if (entry.isIntersecting) {
91
- this.publisher.emit('visible', entry.target.dataset.hostId);
106
+ this.publisher.emit('visible', entry.target.dataset.roomId);
92
107
  }
93
108
  });
94
109
  });
95
110
 
96
- this._search.oninput = () => this.publisher.emit('search', this._search.value);
111
+ this._search.oninput = () =>
112
+ this.publisher.emit('search', this._search.value);
97
113
  this._more.onclick = () => this.publisher.emit('more');
98
114
 
99
- this._tabServersBtn.onclick = () => this.publisher.emit('show-tab', 'servers');
100
- this._tabLeaderboardBtn.onclick = () => this.publisher.emit('show-tab', 'leaderboard');
115
+ this._tabServersBtn.onclick = () =>
116
+ this.publisher.emit('show-tab', 'servers');
117
+ this._tabLeaderboardBtn.onclick = () =>
118
+ this.publisher.emit('show-tab', 'leaderboard');
101
119
 
102
120
  for (const [period, btn] of this._periodBtns) {
103
121
  btn.onclick = () => this.publisher.emit('show-period', period);
@@ -160,8 +178,7 @@ export default class LobbyView {
160
178
  renderLeaderboard({ leaderboard, total, myPlacement, loaded = true }) {
161
179
  const period = this._periodTitle ? ` — ${this._periodTitle}` : '';
162
180
 
163
- this._leaderboardTitle.textContent =
164
- `${this._gameTitle.toUpperCase()} TOP-${this._leaderboardLimit}${period}`;
181
+ this._leaderboardTitle.textContent = `${this._gameTitle.toUpperCase()} TOP-${this._leaderboardLimit}${period}`;
165
182
  this._leaderboardTotal.textContent = `Total: ${total} players`;
166
183
 
167
184
  this._leaderboardList.textContent = '';
@@ -217,7 +234,8 @@ export default class LobbyView {
217
234
  // списке, ни в плашке. Ники уникальны (users_nick_lower_unique_idx),
218
235
  // так что membership по нику однозначен
219
236
  const inTop =
220
- this._selfNick !== '' && leaderboard.some(entry => entry.nick === this._selfNick);
237
+ this._selfNick !== '' &&
238
+ leaderboard.some(entry => entry.nick === this._selfNick);
221
239
 
222
240
  if (inTop) {
223
241
  this._myPlacement.textContent = '';
@@ -252,8 +270,8 @@ export default class LobbyView {
252
270
  }
253
271
 
254
272
  // обновляет задержку карточки и переставляет её по возрастанию latency
255
- updatePing({ hostId, latency }) {
256
- const entry = this._cards.get(hostId);
273
+ updatePing({ roomId, latency }) {
274
+ const entry = this._cards.get(roomId);
257
275
 
258
276
  if (!entry) {
259
277
  return;
@@ -262,35 +280,34 @@ export default class LobbyView {
262
280
  entry.latency = latency;
263
281
  entry.latencyEl.textContent = `${latency} ms`;
264
282
 
265
- this._reorderCard(hostId);
283
+ this._reorderCard(roomId);
266
284
  }
267
285
 
268
286
  _appendCard(server) {
269
287
  const card = document.createElement('li');
270
288
 
271
289
  card.className = 'lobby-card';
272
- card.dataset.hostId = server.hostId;
290
+ card.dataset.roomId = server.roomId;
273
291
 
274
292
  const name = document.createElement('span');
275
293
 
276
294
  name.className = 'lobby-card-name';
277
- // формат "gameId/name" (lobby-page-plan): готовит серверный поиск
278
- // "gameId/name" к тому, чтобы искать по тому же, что видно на карточке;
279
- // gameId nullable для хостов до Этапа 6.4 (code review L6)
280
- name.textContent = `${server.gameId ?? '?'}/${server.name}`;
295
+ // формат "gameId/roomId": серверный поиск понимает ровно то, что видно
296
+ // на карточке; gameId nullable для хостов до Этапа 6.4 (code review L6)
297
+ name.textContent = `${server.gameId ?? '?'}/${server.roomId}`;
281
298
 
282
299
  const info = document.createElement('span');
283
300
 
284
301
  info.className = 'lobby-card-info';
285
- info.textContent = `${server.mapName} · ${server.currentPlayers}/${server.maxPlayers} · ${server.region}`;
286
-
287
- // рейтинг хостера (server-rating этап 3) — сервер уже отдаёт кэшированное
288
- // значение в GET /servers, диапазон известен из конфига движка
289
- const ratingEl = document.createElement('span');
290
-
291
- ratingEl.className = 'lobby-card-rating';
292
- ratingEl.classList.toggle('is-negative', server.rating < 0);
293
- ratingEl.textContent = server.rating > 0 ? `+${server.rating}` : `${server.rating}`;
302
+ // пустые сегменты не выводятся: строку карточки игра может не задавать
303
+ // (gameConfig.lobbyInfo), а регион мастер не знает без заголовка CDN
304
+ info.textContent = [
305
+ server.info,
306
+ `${server.currentPlayers}/${server.maxPlayers}`,
307
+ knownRegion(server.region),
308
+ ]
309
+ .filter(Boolean)
310
+ .join(' · ');
294
311
 
295
312
  const latencyEl = document.createElement('span');
296
313
 
@@ -299,27 +316,61 @@ export default class LobbyView {
299
316
  server.latency === null ? '…' : `${server.latency} ms`;
300
317
 
301
318
  card.appendChild(name);
319
+
320
+ // прямая ссылка на комнату (host-migration, этап 3); без gameId (хост до
321
+ // Этапа 6.4) ссылку не собрать
322
+ if (server.gameId) {
323
+ card.appendChild(this._copyLinkButton(server));
324
+ }
325
+
302
326
  card.appendChild(info);
303
- card.appendChild(ratingEl);
304
327
  card.appendChild(latencyEl);
305
328
 
306
- card.onclick = () => this.publisher.emit('join', server.hostId);
329
+ card.onclick = () => this.publisher.emit('join', server.roomId);
307
330
 
308
331
  this._list.appendChild(card);
309
332
  this._observer.observe(card);
310
333
 
311
- this._cards.set(server.hostId, {
334
+ this._cards.set(server.roomId, {
312
335
  card,
313
336
  latencyEl,
314
337
  latency: server.latency === null ? UNKNOWN_LATENCY : server.latency,
315
- rating: server.rating,
316
338
  });
317
339
  }
318
340
 
319
- // вставляет карточку перед первым соседом с большей задержкой; при равном
320
- // пинге тай-брейк по rating (убыв.) — lobby-page-plan
321
- _reorderCard(hostId) {
322
- const entry = this._cards.get(hostId);
341
+ // вставляет карточку перед первым соседом с большей задержкой
342
+ // «Copy link» карточки: клик не должен всплыть до карточки (вход в комнату)
343
+ _copyLinkButton(server) {
344
+ const btn = document.createElement('input');
345
+ const label = 'Copy link';
346
+
347
+ btn.type = 'button';
348
+ btn.className = 'lobby-card-copy';
349
+ btn.value = label;
350
+ btn.onclick = event => {
351
+ event.stopPropagation();
352
+
353
+ const link = absoluteLink(formatRoomLink(server.gameId, server.roomId));
354
+ const feedback = text => {
355
+ btn.value = text;
356
+ setTimeout(() => {
357
+ btn.value = label;
358
+ }, 1500);
359
+ };
360
+
361
+ Promise.resolve()
362
+ .then(() => navigator.clipboard.writeText(link))
363
+ .then(
364
+ () => feedback('Copied'),
365
+ () => feedback('Copy failed'),
366
+ );
367
+ };
368
+
369
+ return btn;
370
+ }
371
+
372
+ _reorderCard(roomId) {
373
+ const entry = this._cards.get(roomId);
323
374
  const { card } = entry;
324
375
 
325
376
  let before = null;
@@ -329,13 +380,9 @@ export default class LobbyView {
329
380
  continue;
330
381
  }
331
382
 
332
- const other = this._cards.get(sibling.dataset.hostId);
383
+ const other = this._cards.get(sibling.dataset.roomId);
333
384
 
334
- if (
335
- other &&
336
- (other.latency > entry.latency ||
337
- (other.latency === entry.latency && other.rating < entry.rating))
338
- ) {
385
+ if (other && other.latency > entry.latency) {
339
386
  before = sibling;
340
387
  break;
341
388
  }