vimp-engine 0.38.2 → 0.39.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 (99) hide show
  1. package/core/Cargo.toml +1 -1
  2. package/package.json +3 -1
  3. package/src/client/InputListener.js +3 -0
  4. package/src/client/SoundManager.js +13 -0
  5. package/src/client/boot.js +30 -7
  6. package/src/client/components/controller/ClientReports.js +1 -1
  7. package/src/client/components/controller/Controls.js +10 -0
  8. package/src/client/components/controller/Games.js +1 -1
  9. package/src/client/components/controller/Voice.js +31 -0
  10. package/src/client/components/model/ClientReports.js +1 -1
  11. package/src/client/components/model/Controls.js +64 -0
  12. package/src/client/components/model/Games.js +24 -0
  13. package/src/client/components/model/Voice.js +96 -0
  14. package/src/client/components/model/Vote.js +2 -1
  15. package/src/client/components/view/ClientReports.js +1 -1
  16. package/src/client/components/view/Games.js +19 -1
  17. package/src/client/components/view/Lobby.js +1 -7
  18. package/src/client/components/view/Voice.js +172 -0
  19. package/src/client/debug.js +1 -1
  20. package/src/client/lib/authApi.js +1 -1
  21. package/src/client/lib/diagnostics.js +19 -4
  22. package/src/client/lib/engineVersion.js +0 -6
  23. package/src/client/lib/footerLink.js +15 -14
  24. package/src/client/lib/retryOnNetworkError.js +60 -0
  25. package/src/client/lib/voiceState.js +78 -0
  26. package/src/client/main.js +152 -18
  27. package/src/client/network/HiddenHostHealthLog.js +40 -3
  28. package/src/client/network/HostConnectionManager.js +46 -5
  29. package/src/client/network/HostController.js +47 -4
  30. package/src/client/network/HostHealthPolicy.js +41 -0
  31. package/src/client/network/InlineHostBridge.js +8 -1
  32. package/src/client/network/LoopbackTransport.js +14 -0
  33. package/src/client/network/SessionSupervisor.js +24 -0
  34. package/src/client/network/WebRtcManager.js +45 -3
  35. package/src/client/session/HandoffFlow.js +5 -0
  36. package/src/client/session/HostRole.js +5 -2
  37. package/src/client/style.css +151 -9
  38. package/src/client/views/gameShell.js +3 -3
  39. package/src/client/views/includes/auth.pug +4 -4
  40. package/src/client/views/includes/lobby.pug +4 -4
  41. package/src/client/views/includes/reports.pug +1 -1
  42. package/src/client/voice/JitterBuffer.js +137 -0
  43. package/src/client/voice/MicCapture.js +307 -0
  44. package/src/client/voice/PttMachine.js +369 -0
  45. package/src/client/voice/VoiceClient.js +336 -0
  46. package/src/client/voice/VoiceCues.js +295 -0
  47. package/src/client/voice/VoiceEncoder.js +174 -0
  48. package/src/client/voice/VoicePlayback.js +558 -0
  49. package/src/client/voice/audioData.js +35 -0
  50. package/src/client/voice/captureProcessor.worklet.js +136 -0
  51. package/src/client/voice/opusConfig.js +49 -0
  52. package/src/client/voice/voiceOutput.js +72 -0
  53. package/src/client/voice/voiceSupport.js +71 -0
  54. package/src/client/voice/voiceTones.js +90 -0
  55. package/src/config/authClient.js +3 -3
  56. package/src/config/clientServices.js +2 -0
  57. package/src/config/lobby.js +4 -5
  58. package/src/config/opcodes.js +2 -1
  59. package/src/config/voiceDefaults.js +44 -0
  60. package/src/config/voiceProtocol.js +80 -0
  61. package/src/config/voteKeys.js +4 -0
  62. package/src/config/wsports.js +12 -0
  63. package/src/devtools/contract/loadContext.js +2 -2
  64. package/src/devtools/contract/rules/a7-package-repository.js +7 -8
  65. package/src/devtools/contract/rules/b11-voice-config.js +34 -0
  66. package/src/devtools/contract/rules/c12-voice-hotkey.js +70 -0
  67. package/src/devtools/contract/rules/index.js +5 -1
  68. package/src/devtools/invariants.js +1 -1
  69. package/src/host/DebugRecorder.js +1 -1
  70. package/src/host/HostGame.js +93 -15
  71. package/src/host/PortMachine.js +19 -3
  72. package/src/host/commandFailure.js +140 -0
  73. package/src/host/host.worker.js +15 -2
  74. package/src/host/meta/SocketManager.js +8 -2
  75. package/src/host/meta/core/CommandProcessor.js +37 -7
  76. package/src/host/meta/modules/voice/Voice.js +430 -0
  77. package/src/host/meta/modules/voice/channels.js +58 -0
  78. package/src/host/meta/modules/voice/index.js +1 -0
  79. package/src/lib/VoiceRelay.js +200 -0
  80. package/src/lib/applyRoomOverrides.js +1 -1
  81. package/src/lib/buildClientConfig.js +7 -0
  82. package/src/lib/capabilities.js +5 -1
  83. package/src/lib/checkpointCodec.js +1 -2
  84. package/src/lib/coreConfig.js +1 -1
  85. package/src/lib/createHostRuntime.js +9 -1
  86. package/src/lib/gameConfigView.js +11 -1
  87. package/src/lib/gameId.js +1 -1
  88. package/src/lib/gamePlugin.js +6 -2
  89. package/src/lib/jwt.js +1 -1
  90. package/src/lib/packageLink.js +18 -61
  91. package/src/lib/validators.js +3 -3
  92. package/src/lib/voiceConfig.js +136 -0
  93. package/src/lib/voiceMessages.js +172 -0
  94. package/src/lib/voicePacket.js +142 -0
  95. package/src/standalone/index.js +1 -1
  96. package/tests/fixtures/generations/gen-api3/README.md +2 -2
  97. package/tests/fixtures/generations/gen-api4/README.md +2 -2
  98. package/src/devtools/surface/abiParse.js +0 -175
  99. package/src/devtools/surface/collect.js +0 -413
package/core/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vimp-engine-core"
3
- version = "0.23.3"
3
+ version = "0.23.4"
4
4
  edition = "2024"
5
5
  description = "VIMP engine simulation core: physics, map, snapshot framing, interpolation/prediction/raycast primitives, navigation utilities. No wasm-bindgen — the game crate builds the WASM ABI wrappers."
6
6
  license = "MIT"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.38.2",
3
+ "version": "0.39.0",
4
4
  "description": "VIMP engine SDK: browser host and client runtime, standalone mode and CLI tools (vimp-sim, vimp-contract) for VIMP game plugins",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,6 +35,8 @@
35
35
  "!src/client/_*",
36
36
  "src/standalone",
37
37
  "src/devtools",
38
+ "!src/devtools/surface/abiParse.js",
39
+ "!src/devtools/surface/collect.js",
38
40
  "tests/fixtures",
39
41
  "core/Cargo.toml",
40
42
  "bin",
@@ -20,6 +20,9 @@ export default class InputListener {
20
20
  this.publisher.emit('keyUp', event),
21
21
  );
22
22
 
23
+ // keyup за пределами окна не приходит — эфир залип бы до таймаута
24
+ window.addEventListener('blur', () => this.publisher.emit('blur'));
25
+
23
26
  const emitMouse = () => this.publisher.emit('mouseAction');
24
27
 
25
28
  window.addEventListener('mouseup', emitMouse);
@@ -892,6 +892,19 @@ export default class SoundManager {
892
892
  }
893
893
  }
894
894
 
895
+ /**
896
+ * Выход Web Audio движка для голосовой связи: контекст Howler и его master
897
+ * gain. Голос подчиняется общей громкости и mute (скрытая вкладка, пауза
898
+ * миграции).
899
+ * @returns {{ ctx: AudioContext, destination: AudioNode } | null} null —
900
+ * Web Audio недоступен.
901
+ */
902
+ getAudioOutput() {
903
+ return Howler.usingWebAudio && Howler.ctx && Howler.masterGain
904
+ ? { ctx: Howler.ctx, destination: Howler.masterGain }
905
+ : null;
906
+ }
907
+
895
908
  /**
896
909
  * Выключает все звуки.
897
910
  */
@@ -11,8 +11,14 @@
11
11
  // window не вводим: они переживают перезагрузку бандла и ломают вторую
12
12
  // вкладку SDK в том же документе.
13
13
 
14
+ import {
15
+ bodyReadFailure,
16
+ fetchRetrying,
17
+ isNetworkFailure,
18
+ } from './lib/retryOnNetworkError.js';
19
+
14
20
  // эндпоинт режима: его отдаёт dedicated-сервер, у мастера его нет —
15
- // 404/сеть/мусор означают прод-лобби (обратная совместимость)
21
+ // 404 и мусор означают прод-лобби; сбой сети — отказ загрузки
16
22
  const CONFIG_URL = '/config';
17
23
 
18
24
  // умолчание игрового WS dedicated-сервера: тот же хост, путь /game
@@ -65,23 +71,40 @@ export function resetBootConfig() {
65
71
  /**
66
72
  * Разрешает режим загрузки: инъекция SDK → GET /config → лобби.
67
73
  * @param {Function} [fetchImpl] - Реализация fetch (тесты подставляют свою).
68
- * @returns {Promise<Object>} Конфиг загрузки.
74
+ * @param {Object} [retry] - Параметры fetchRetrying (тесты отключают паузы).
75
+ * @returns {Promise<Object>} Конфиг загрузки; отклоняется NetworkFailure, если
76
+ * сеть не ответила после повторов.
69
77
  */
70
- export async function resolveBootConfig(fetchImpl) {
78
+ export async function resolveBootConfig(fetchImpl, retry) {
71
79
  if (bootConfig) {
72
80
  return bootConfig;
73
81
  }
74
82
 
75
83
  const doFetch = fetchImpl ?? ((...args) => fetch(...args));
84
+ let res = null;
76
85
 
77
86
  try {
78
- const res = await doFetch(CONFIG_URL);
87
+ res = await fetchRetrying(doFetch, CONFIG_URL, retry);
88
+ } catch (e) {
89
+ // сети нет: режим неизвестен, и лобби здесь — догадка, а не факт
90
+ if (isNetworkFailure(e)) {
91
+ throw e;
92
+ }
93
+ }
79
94
 
80
- if (res.ok) {
95
+ if (res?.ok) {
96
+ try {
81
97
  bootConfig = normalize(await res.json());
98
+ } catch (e) {
99
+ // мусор в JSON — лобби; обрыв при чтении тела — сбой сети
100
+ const failure = bodyReadFailure(e);
101
+
102
+ if (isNetworkFailure(failure)) {
103
+ throw failure;
104
+ }
105
+
106
+ bootConfig = null;
82
107
  }
83
- } catch (e) {
84
- // сети/эндпоинта нет — это и есть прод-лобби, шуметь не о чем
85
108
  }
86
109
 
87
110
  bootConfig = bootConfig ?? { mode: 'lobby' };
@@ -2,7 +2,7 @@
2
2
 
3
3
  let clientReportsCtrl;
4
4
 
5
- // Контроллер журнала клиентских ошибок (plan/client-reports, этап 5):
5
+ // Контроллер журнала клиентских ошибок:
6
6
  // связывает view-события с моделью, собственного сетевого I/O не делает
7
7
  export default class ClientReportsCtrl {
8
8
  constructor(model, view) {
@@ -54,6 +54,16 @@ export default class ControlsCtrl {
54
54
  this._model.resendHeld();
55
55
  }
56
56
 
57
+ // назначает клавишу связи (push-to-talk)
58
+ setPushToTalk(keyCode) {
59
+ this._model.setPushToTalk(keyCode);
60
+ }
61
+
62
+ // отпускает связь (потеря фокуса, скрытая вкладка)
63
+ releasePtt() {
64
+ this._model.releasePtt();
65
+ }
66
+
57
67
  // скрывает курсор
58
68
  resetCursorHideTimer() {
59
69
  this._view.resetCursorHideTimer();
@@ -95,7 +95,7 @@ export default class GamesCtrl {
95
95
  }
96
96
 
97
97
  approve({ id }) {
98
- this._model.moderate(id, { status: 'approved' });
98
+ this._model.approve(id);
99
99
  }
100
100
 
101
101
  reject({ id, note }) {
@@ -0,0 +1,31 @@
1
+ // Singleton VoiceCtrl
2
+
3
+ let voiceCtrl;
4
+
5
+ export default class VoiceCtrl {
6
+ constructor(model, view) {
7
+ if (voiceCtrl) {
8
+ return voiceCtrl;
9
+ }
10
+
11
+ voiceCtrl = this;
12
+
13
+ this._model = model;
14
+ this._view = view;
15
+ }
16
+
17
+ // состояние своего эфира
18
+ showOwn(kind, data) {
19
+ this._model.showOwn(kind, data);
20
+ }
21
+
22
+ // чужой эфир (null — скрыть)
23
+ setIncoming(speaker) {
24
+ this._model.setIncoming(speaker);
25
+ }
26
+
27
+ destroy() {
28
+ this._model.destroy();
29
+ this._view.destroy();
30
+ }
31
+ }
@@ -4,7 +4,7 @@ import Publisher from '../../../lib/Publisher.js';
4
4
 
5
5
  let clientReportsModel;
6
6
 
7
- // Модель журнала клиентских ошибок в лобби (plan/client-reports, этап 5):
7
+ // Модель журнала клиентских ошибок в лобби:
8
8
  // страницы журнала, фильтры и решение админа по строке. Как GamesModel,
9
9
  // сетевые запросы делает сама (простой REST к мастеру) и DOM не трогает.
10
10
  // Наружу — одно событие 'changed' с полным состоянием: список, счётчик,
@@ -2,6 +2,17 @@ import Publisher from '../../../lib/Publisher.js';
2
2
 
3
3
  // Singleton ControlsModel
4
4
 
5
+ // keyCode самих модификаторов: Ctrl, Alt, Meta (два кода и Firefox)
6
+ const MODIFIER_CODES = new Set([17, 18, 91, 93, 224]);
7
+
8
+ // фокус в поле ввода: клавиша печатает букву
9
+ function isEditableTarget(target) {
10
+ return (
11
+ target?.isContentEditable === true ||
12
+ ['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName)
13
+ );
14
+ }
15
+
5
16
  let controlsModel;
6
17
 
7
18
  export default class ControlsModel {
@@ -25,6 +36,8 @@ export default class ControlsModel {
25
36
  // возобновления сессии должен знать, что игрок держит на самом деле
26
37
  this._heldCodes = new Set();
27
38
  this._areKeysEnabled = false; // статус возможности нажатия клавиш
39
+ this._pttKey = null; // keyCode клавиши связи, null — связи нет
40
+ this._pttHeld = false;
28
41
 
29
42
  // канал указателя: игра, не объявившая modules.controls.pointer, его не
30
43
  // получает вовсе — ни одного лишнего сообщения по проводу
@@ -79,6 +92,34 @@ export default class ControlsModel {
79
92
  this.publisher.emit('mode', mode);
80
93
  }
81
94
  } else {
95
+ // Cmd, нажатый при уже зажатой клавише связи: её keyup на macOS не
96
+ // придёт — эфир отпускается сразу
97
+ if (event.metaKey && this._pttHeld && keyCode !== this._pttKey) {
98
+ this.releasePtt();
99
+ }
100
+
101
+ // клавиша связи никогда не уходит в игру командой. Автоповтор keydown
102
+ // игнорируется — удержание и так длится до keyup. Фокус в поле ввода
103
+ // (любом, не только в строке чата) — клавиша печатает букву. С
104
+ // Ctrl/Cmd/Alt — системная комбинация (вставка), не связь: на macOS при
105
+ // Cmd keyup обычной клавиши не приходит и связь залипла бы
106
+ // Сам модификатор может быть клавишей связи: его keydown несёт свой флаг
107
+ if (
108
+ keyCode === this._pttKey &&
109
+ (MODIFIER_CODES.has(keyCode) ||
110
+ !(event.ctrlKey || event.metaKey || event.altKey)) &&
111
+ !isEditableTarget(event.target)
112
+ ) {
113
+ event.preventDefault();
114
+
115
+ if (!event.repeat && !this._pttHeld) {
116
+ this._pttHeld = true;
117
+ this.publisher.emit('ptt', true);
118
+ }
119
+
120
+ return;
121
+ }
122
+
82
123
  // если клавиша ещё не зажата
83
124
  if (!this._pressedKeys[keyCode]) {
84
125
  const name = this._currentKeySet[keyCode];
@@ -111,6 +152,11 @@ export default class ControlsModel {
111
152
 
112
153
  this._heldCodes.delete(keyCode);
113
154
 
155
+ // отпускание засчитывается всегда, без проверки цели
156
+ if (keyCode === this._pttKey && this._pttHeld) {
157
+ this.releasePtt();
158
+ }
159
+
114
160
  // если клавиша была зажата
115
161
  if (this._pressedKeys[keyCode]) {
116
162
  const name = this._currentKeySet[keyCode];
@@ -131,6 +177,10 @@ export default class ControlsModel {
131
177
  if (status === 'opened') {
132
178
  this._currentModes[mode] = true;
133
179
 
180
+ if (mode === 'chat') {
181
+ this.releasePtt();
182
+ }
183
+
134
184
  // иначе змейка так и ехала бы к последней точке, пока игрок набирает
135
185
  // сообщение в чат
136
186
  this._releasePointer(this._lastTapX, this._lastTapY);
@@ -260,6 +310,20 @@ export default class ControlsModel {
260
310
 
261
311
  if (isEnabled === false) {
262
312
  this._releasePointer(this._lastTapX, this._lastTapY);
313
+ this.releasePtt();
314
+ }
315
+ }
316
+
317
+ // клавиша связи (push-to-talk); null выключает
318
+ setPushToTalk(keyCode) {
319
+ this._pttKey = keyCode ?? null;
320
+ }
321
+
322
+ // отпускает связь (клавиша вверх, потеря фокуса, чат, запрет ввода)
323
+ releasePtt() {
324
+ if (this._pttHeld) {
325
+ this._pttHeld = false;
326
+ this.publisher.emit('ptt', false);
263
327
  }
264
328
  }
265
329
  }
@@ -222,6 +222,24 @@ export default class GamesModel {
222
222
  await this.loadAdmin();
223
223
  }
224
224
 
225
+ // одобрение называет заявку, которую модератор видит в карточке
226
+ // (this._all — то, что нарисовано): переподанная после загрузки очереди
227
+ // заявка вернёт 409 moderationStale
228
+ async approve(id) {
229
+ const game = this._all.find(item => item.id === id);
230
+
231
+ if (!game) {
232
+ this._fail('admin', { error: 'unknownGame' });
233
+ return;
234
+ }
235
+
236
+ await this.moderate(id, {
237
+ status: 'approved',
238
+ expectedPendingVersion: game.pendingVersion ?? null,
239
+ expectedPendingRepoUrl: game.pendingRepoUrl ?? null,
240
+ });
241
+ }
242
+
225
243
  async moderate(id, patch) {
226
244
  const { ok, json } = await this._request(this._config.urls.moderate(id), {
227
245
  method: 'PATCH',
@@ -229,6 +247,12 @@ export default class GamesModel {
229
247
  });
230
248
 
231
249
  if (!ok) {
250
+ // карточка устарела: показать свежую и только потом отказ — иначе
251
+ // перерисовка сотрёт его тем же тиком
252
+ if (json?.error === 'moderationStale') {
253
+ await this.loadAdmin();
254
+ }
255
+
232
256
  this._fail('admin', json);
233
257
  return;
234
258
  }
@@ -0,0 +1,96 @@
1
+ import Publisher from '../../../lib/Publisher.js';
2
+
3
+ // вспышка ошибки гаснет сама
4
+ const ERROR_FLASH_MS = 1500;
5
+
6
+ // Singleton VoiceModel
7
+
8
+ let voiceModel;
9
+
10
+ export default class VoiceModel {
11
+ /**
12
+ * @param {Object} data
13
+ * @param {Object} data.texts - CONFIG_DATA.voice.texts.
14
+ */
15
+ constructor({ texts }) {
16
+ if (voiceModel) {
17
+ return voiceModel;
18
+ }
19
+
20
+ voiceModel = this;
21
+
22
+ this._texts = texts ?? {};
23
+ // один таймер авто-скрытия: новый показ снимает прежний, иначе таймер
24
+ // старой ошибки погасил бы эфир, начатый после неё
25
+ this._timer = null;
26
+
27
+ // 'own' ({ kind, text, endsAt, totalMs }) и 'incoming' (speaker | null)
28
+ this.publisher = new Publisher();
29
+ }
30
+
31
+ /**
32
+ * Состояние своего эфира.
33
+ * @param {'hidden'|'talking'|'busy'|'cooldown'|'error'} kind
34
+ * @param {Object} [data] - talking: { endsAt, totalMs }, cooldown:
35
+ * { endsAt }, error: { textKey }.
36
+ */
37
+ showOwn(kind, data = {}) {
38
+ this._clearTimer();
39
+
40
+ let text = '';
41
+ let endsAt = 0;
42
+ let totalMs = 0;
43
+
44
+ if (kind === 'busy') {
45
+ text = this._texts.busy ?? '';
46
+ } else if (kind === 'cooldown') {
47
+ text = this._texts.cooldown ?? '';
48
+ endsAt = data.endsAt;
49
+ } else if (kind === 'error') {
50
+ text = this._texts[data.textKey] ?? '';
51
+ } else if (kind === 'talking') {
52
+ endsAt = data.endsAt;
53
+ totalMs = data.totalMs;
54
+ }
55
+
56
+ this.publisher.emit('own', { kind, text, endsAt, totalMs });
57
+
58
+ if (kind === 'error') {
59
+ this._arm(ERROR_FLASH_MS);
60
+ } else if (kind === 'cooldown') {
61
+ this._arm(Math.max(0, endsAt - performance.now()));
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Чужой эфир.
67
+ * @param {{ name: string, teamId: number|null, color: string|null }|null}
68
+ * speaker - null скрывает.
69
+ */
70
+ setIncoming(speaker) {
71
+ this.publisher.emit('incoming', speaker);
72
+ }
73
+
74
+ destroy() {
75
+ this._clearTimer();
76
+ }
77
+
78
+ _arm(ms) {
79
+ this._timer = setTimeout(() => {
80
+ this._timer = null;
81
+ this.publisher.emit('own', {
82
+ kind: 'hidden',
83
+ text: '',
84
+ endsAt: 0,
85
+ totalMs: 0,
86
+ });
87
+ }, ms);
88
+ }
89
+
90
+ _clearTimer() {
91
+ if (this._timer !== null) {
92
+ clearTimeout(this._timer);
93
+ this._timer = null;
94
+ }
95
+ }
96
+ }
@@ -1,4 +1,5 @@
1
1
  import Publisher from '../../../lib/Publisher.js';
2
+ import { VOTE_ANSWER_KEYS } from '../../../config/voteKeys.js';
2
3
 
3
4
  // Singleton VoteModel
4
5
 
@@ -215,7 +216,7 @@ export default class VoteModel {
215
216
  }
216
217
 
217
218
  // если keyCode это число от 0 до 9
218
- if (48 <= keyCode && keyCode <= 57) {
219
+ if (VOTE_ANSWER_KEYS[0] <= keyCode && keyCode <= VOTE_ANSWER_KEYS[1]) {
219
220
  number = String.fromCharCode(keyCode);
220
221
  number = parseInt(number, 10);
221
222
 
@@ -57,7 +57,7 @@ function truncate(text, max) {
57
57
  return text.length > max ? `${text.slice(0, max - 1)}…` : text;
58
58
  }
59
59
 
60
- // Представление журнала клиентских ошибок (plan/client-reports, этап 5).
60
+ // Представление журнала клиентских ошибок.
61
61
  //
62
62
  // ***** БЕЗОПАСНОСТЬ *****
63
63
  //
@@ -19,6 +19,9 @@ const ERROR_MESSAGES = {
19
19
  gameExists: 'A game with this id already exists',
20
20
  tooManyGames: 'Too many submissions from one author',
21
21
  unknownGame: 'Game is not in the registry',
22
+ // автор переподал заявку, пока модератор смотрел карточку
23
+ moderationStale:
24
+ 'The request changed while you were reviewing it — check the refreshed card',
22
25
  gamePublished: 'Published game — ask an admin to disable it first',
23
26
  unknownUser: 'No player with this nick',
24
27
  invalidGameId: 'Invalid game id',
@@ -270,6 +273,7 @@ export default class GamesView {
270
273
  );
271
274
  item.appendChild(this._line(this._statusLine(game)));
272
275
  this._appendRepo(item, game.repoUrl);
276
+ this._appendPendingRepo(item, game);
273
277
 
274
278
  if (game.moderatorNote) {
275
279
  item.appendChild(this._line(`Note: ${game.moderatorNote}`));
@@ -390,6 +394,7 @@ export default class GamesView {
390
394
  ),
391
395
  );
392
396
  this._appendRepo(item, game.repoUrl);
397
+ this._appendPendingRepo(item, game);
393
398
  item.appendChild(
394
399
  this._line(
395
400
  `Served: ${game.version ?? '—'}; requested: ${game.pendingVersion ?? '—'}` +
@@ -460,7 +465,7 @@ export default class GamesView {
460
465
  // принимает только http(s): href — единственное место представления, где
461
466
  // содержимое поля становится исполняемым (javascript:), и полагаться на
462
467
  // одну проверку на другой стороне сети тут не стоит
463
- _appendRepo(item, url) {
468
+ _appendRepo(item, url, prefix = '') {
464
469
  if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) {
465
470
  return;
466
471
  }
@@ -472,10 +477,23 @@ export default class GamesView {
472
477
  link.textContent = url;
473
478
  link.target = '_blank';
474
479
  link.rel = 'noopener noreferrer';
480
+
481
+ if (prefix) {
482
+ line.append(prefix);
483
+ }
484
+
475
485
  line.appendChild(link);
476
486
  item.appendChild(line);
477
487
  }
478
488
 
489
+ // адрес, который версия на модерации принесёт при одобрении: строка
490
+ // появляется, только если он отличается от текущего
491
+ _appendPendingRepo(item, game) {
492
+ if (game.pendingRepoUrl && game.pendingRepoUrl !== game.repoUrl) {
493
+ this._appendRepo(item, game.pendingRepoUrl, 'After approval: ');
494
+ }
495
+ }
496
+
479
497
  _statusLine(game) {
480
498
  const status = STATUS_TITLES[game.status] ?? game.status;
481
499
  const date = game.createdAt
@@ -1,6 +1,5 @@
1
1
  import Publisher from '../../../lib/Publisher.js';
2
- import { ENGINE_PROJECT_URL, ENGINE_VERSION } from '../../lib/engineVersion.js';
3
- import { renderProjectLink } from '../../lib/footerLink.js';
2
+ import { ENGINE_VERSION } from '../../lib/engineVersion.js';
4
3
  import { absoluteLink, formatRoomLink } from '../../lib/roomLink.js';
5
4
 
6
5
  // Singleton LobbyView
@@ -65,11 +64,6 @@ export default class LobbyView {
65
64
  version.textContent = ENGINE_VERSION;
66
65
  }
67
66
 
68
- renderProjectLink(
69
- document.getElementById(elems.linkId),
70
- ENGINE_PROJECT_URL,
71
- );
72
-
73
67
  // заголовок игры для "<TITLE> TOP-N" (SVG-ориентир) — задаётся controller'ом
74
68
  // при выборе игры (сама модель не хранит title манифеста)
75
69
  this._gameTitle = '';