vimp-engine 0.25.0 → 0.27.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.25.0",
3
+ "version": "0.27.0",
4
4
  "description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -25,14 +25,17 @@ export default class GamesCtrl {
25
25
 
26
26
  vp.on('open-mine', 'openMine', this);
27
27
  vp.on('open-moderation', 'openModeration', this);
28
+ vp.on('lookup', 'lookup', this);
28
29
  vp.on('submit', 'submit', this);
29
30
  vp.on('update-version', 'updateVersion', this);
31
+ vp.on('delete', 'remove', this);
30
32
  vp.on('filter', 'filter', this);
31
33
  vp.on('stage', 'stage', this);
32
34
  vp.on('approve', 'approve', this);
33
35
  vp.on('reject', 'reject', this);
34
36
  vp.on('disable', 'disable', this);
35
37
  vp.on('load-versions', 'loadVersions', this);
38
+ vp.on('set-author', 'setAuthor', this);
36
39
 
37
40
  model.publisher.on('staged', 'staged', this);
38
41
  }
@@ -55,12 +58,17 @@ export default class GamesCtrl {
55
58
  this._model.loadMine();
56
59
  }
57
60
 
61
+ // «Moderation» — отдельная страница панели: карточка «My games» в этом
62
+ // режиме не видна, и запрос за её данными был бы холостым
58
63
  openModeration() {
59
64
  this._view.show(true);
60
- this._model.loadMine();
61
65
  this._model.loadAdmin();
62
66
  }
63
67
 
68
+ lookup({ packageName, version }) {
69
+ this._model.lookup(packageName, version);
70
+ }
71
+
64
72
  submit(form) {
65
73
  this._model.submit(form);
66
74
  }
@@ -69,6 +77,10 @@ export default class GamesCtrl {
69
77
  this._model.requestVersion(id, version);
70
78
  }
71
79
 
80
+ remove({ id, scope }) {
81
+ this._model.remove(id, scope);
82
+ }
83
+
72
84
  filter(status) {
73
85
  this._model.setFilter(status);
74
86
  }
@@ -93,6 +105,10 @@ export default class GamesCtrl {
93
105
  this._model.loadVersions(id);
94
106
  }
95
107
 
108
+ setAuthor({ id, nick }) {
109
+ this._model.setAuthor(id, nick);
110
+ }
111
+
96
112
  staged(data) {
97
113
  this.publisher.emit('staged', data);
98
114
  }
@@ -99,6 +99,28 @@ export default class GamesModel {
99
99
  this._emitAdmin();
100
100
  }
101
101
 
102
+ // разбор пакета для формы заявки: мастер качает тарболл, читает манифест
103
+ // и отдаёт то, что раньше человек вводил руками. Отдельный запрос, а не
104
+ // часть submit: предпросмотр обязан быть виден ДО отправки заявки
105
+ async lookup(packageName, version) {
106
+ const query = new URLSearchParams({ package: packageName });
107
+
108
+ if (version) {
109
+ query.set('version', version);
110
+ }
111
+
112
+ const { ok, json } = await this._request(
113
+ `${this._config.urls.lookup}?${query}`,
114
+ );
115
+
116
+ if (!ok) {
117
+ this._fail('mine', json);
118
+ return;
119
+ }
120
+
121
+ this.publisher.emit('looked-up', json);
122
+ }
123
+
102
124
  async submit(form) {
103
125
  const { ok, json } = await this._request(this._config.urls.submit, {
104
126
  method: 'POST',
@@ -133,6 +155,26 @@ export default class GamesModel {
133
155
  await this.loadMine();
134
156
  }
135
157
 
158
+ // удаление игры. scope решает, чей список перечитать: карточка автора и
159
+ // очередь модерации показывают разные проекции одной строки
160
+ async remove(id, scope = 'mine') {
161
+ const { ok, json } = await this._request(this._config.urls.remove(id), {
162
+ method: 'DELETE',
163
+ });
164
+
165
+ if (!ok) {
166
+ this._fail(scope, json);
167
+ return;
168
+ }
169
+
170
+ if (scope === 'admin') {
171
+ await this.loadAdmin();
172
+ return;
173
+ }
174
+
175
+ await this.loadMine();
176
+ }
177
+
136
178
  // «Test»: мастер качает версию и кладёт её в каталог не раздаваемой.
137
179
  // Манифест едет наружу — по нему вкладка админа поднимает комнату
138
180
  async stage(id, version) {
@@ -162,6 +204,18 @@ export default class GamesModel {
162
204
  }
163
205
 
164
206
  await this.loadAdmin();
207
+
208
+ // после loadAdmin, а не до: renderAdmin чистит строку отказа панели, и
209
+ // предупреждение, показанное раньше, стёрлось бы тем же тиком
210
+ if (json?.warning) {
211
+ this.publisher.emit('warning', { scope: 'admin', code: json.warning });
212
+ }
213
+ }
214
+
215
+ // переназначение автора — тот же PATCH, что и решение модератора: наружу
216
+ // едет ник, пустой означает «снять автора»
217
+ async setAuthor(id, nick) {
218
+ await this.moderate(id, { authorNick: nick === '' ? null : nick });
165
219
  }
166
220
 
167
221
  _emitAdmin() {
@@ -19,6 +19,8 @@ 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
+ gamePublished: 'Published game — ask an admin to disable it first',
23
+ unknownUser: 'No player with this nick',
22
24
  invalidGameId: 'Invalid game id',
23
25
  invalidPackageName: 'Invalid npm package name',
24
26
  invalidVersion: 'Invalid version',
@@ -28,6 +30,13 @@ const ERROR_MESSAGES = {
28
30
  authServiceUnavailable: 'Registry service unavailable',
29
31
  };
30
32
 
33
+ // предупреждения: решение принято, отказа не было, но состояние платформы
34
+ // стоит назвать вслух
35
+ const WARNING_MESSAGES = {
36
+ catalogEmpty:
37
+ 'No published games left — the lobby cannot create rooms until one is published',
38
+ };
39
+
31
40
  const STATUS_TITLES = {
32
41
  pending: 'in review',
33
42
  approved: 'published',
@@ -64,6 +73,19 @@ export default class GamesView {
64
73
  this._mineList = document.getElementById(elems.mineListId);
65
74
  this._submitForm = document.getElementById(elems.submitFormId);
66
75
  this._submitError = document.getElementById(elems.submitErrorId);
76
+ this._submitBtn = document.getElementById(elems.submitBtnId);
77
+ this._lookupBtn = document.getElementById(elems.lookupBtnId);
78
+ this._preview = document.getElementById(elems.previewId);
79
+ this._versionList = document.getElementById(elems.versionListId);
80
+
81
+ this._mine = document.getElementById(elems.mineId);
82
+ this._title = document.getElementById(elems.titleId);
83
+ this._switch = document.getElementById(elems.switchBtnId);
84
+ // текущая страница панели: от неё зависит, куда уводит переключатель
85
+ this._isModeration = false;
86
+ // последний разобранный `${packageName}@${version}` — защита от парного
87
+ // разбора одного и того же пакета, см. _emitLookup
88
+ this._lastLookup = null;
67
89
 
68
90
  this._moderation = document.getElementById(elems.moderationId);
69
91
  this._adminList = document.getElementById(elems.adminListId);
@@ -71,10 +93,20 @@ export default class GamesView {
71
93
  this._filters = document.getElementById(elems.filtersId);
72
94
 
73
95
  this._fields = new Map(
74
- Object.entries(elems.fieldIds).map(([name, id]) => [
75
- name,
76
- document.getElementById(id),
77
- ]),
96
+ Object.entries(elems.fieldIds).map(([name, id]) => {
97
+ const field = document.getElementById(id);
98
+
99
+ // карта строится по конфигу, а используется из обработчиков событий:
100
+ // разъехавшийся с games.pug id дал бы здесь null, а упал бы позже —
101
+ // безымянным TypeError внутри clearForm или _readForm. Остальная
102
+ // разметка панели проверяется тем же способом, только неявно: первое
103
+ // же обращение к отсутствующему элементу бросает в конструкторе
104
+ if (!field) {
105
+ throw new Error(`GamesView: no element "#${id}" for field "${name}"`);
106
+ }
107
+
108
+ return [name, field];
109
+ }),
78
110
  );
79
111
 
80
112
  this.publisher = new Publisher();
@@ -82,32 +114,59 @@ export default class GamesView {
82
114
  this._openMine.onclick = () => this.publisher.emit('open-mine');
83
115
  this._openModeration.onclick = () => this.publisher.emit('open-moderation');
84
116
  this._close.onclick = () => this.hide();
117
+ // переключатель страниц панели: без него выход из «Moderation» в
118
+ // «My games» шёл бы только через лобби
119
+ this._switch.onclick = () =>
120
+ this.publisher.emit(this._isModeration ? 'open-mine' : 'open-moderation');
85
121
 
86
122
  this._submitForm.onsubmit = e => {
87
123
  e.preventDefault();
88
124
  this.publisher.emit('submit', this._readForm());
89
125
  };
90
126
 
127
+ const packageField = this._fields.get('packageName');
128
+
129
+ // разбор пакета по кнопке И по уходу из поля: заполнять форму
130
+ // предпросмотром — обычный путь, а не отдельное действие
131
+ this._lookupBtn.onclick = () => this._emitLookup();
132
+ packageField.onblur = () => this._emitLookup();
133
+ // правка пакета обесценивает показанный предпросмотр: заявка ушла бы с
134
+ // разобранным ранее пакетом, а человек читал бы карточку другого
135
+ packageField.oninput = () => this.clearPreview();
136
+
137
+ this.clearPreview();
138
+
91
139
  this._renderFilters();
92
140
 
93
141
  const mp = model.publisher;
94
142
 
95
143
  mp.on('submitted', 'clearForm', this);
144
+ mp.on('looked-up', 'renderPreview', this);
96
145
  mp.on('mine-changed', 'renderMine', this);
97
146
  mp.on('admin-changed', 'renderAdmin', this);
98
147
  mp.on('error', 'renderError', this);
148
+ mp.on('warning', 'renderWarning', this);
99
149
  }
100
150
 
101
151
  // кнопку модерации показывает не сама панель, а роль вызывающего
102
152
  // (main.js по LobbyAuthModel.getRole())
103
153
  setAdmin(isAdmin) {
104
154
  this._openModeration.style.display = isAdmin ? '' : 'none';
155
+ this._switch.style.display = isAdmin ? '' : 'none';
105
156
  }
106
157
 
158
+ // Панель — две страницы, а не одна: «My games» и «Moderation»
159
+ // показываются по одной. Общая шапка (заголовок, переключатель и
160
+ // «Back to lobby») живёт над карточками и переживает переключение
107
161
  show(moderation = false) {
162
+ this._isModeration = moderation;
108
163
  this._panel.style.display = 'flex';
109
164
  this._lobby.style.display = 'none';
165
+ this._mine.style.display = moderation ? 'none' : 'block';
110
166
  this._moderation.style.display = moderation ? 'block' : 'none';
167
+ this._title.textContent = moderation ? 'Moderation' : 'My games';
168
+ // подпись переключателя называет ту страницу, куда он уводит
169
+ this._switch.value = moderation ? 'My games' : 'Moderation';
111
170
  }
112
171
 
113
172
  hide() {
@@ -127,6 +186,68 @@ export default class GamesView {
127
186
  this._fields.forEach(field => {
128
187
  field.value = '';
129
188
  });
189
+ this.clearPreview();
190
+ }
191
+
192
+ // предпросмотра нет — отправлять нечего: мастер всё равно откажет, а
193
+ // человек не увидит, за какую игру он ручается
194
+ clearPreview() {
195
+ this._preview.textContent = '';
196
+ this._versionList.textContent = '';
197
+ this._submitBtn.disabled = true;
198
+ // предпросмотра нет — значит, и запрета на повторный разбор быть не
199
+ // должно: правка поля обесценивает прошлый вердикт
200
+ this._lastLookup = null;
201
+ }
202
+
203
+ /**
204
+ * Карточка разобранного пакета: то, что раньше человек вводил руками.
205
+ * @param {Object} data - Ответ мастера на /games/lookup.
206
+ */
207
+ renderPreview({ id, title, version, versions, repoUrl, engineApi, errors }) {
208
+ this._preview.textContent = '';
209
+ this._submitError.textContent = '';
210
+
211
+ this._preview.appendChild(
212
+ this._line(`${title ? `${title} · ` : ''}${id ?? '—'}`, 'games-item-title'),
213
+ );
214
+ this._preview.appendChild(this._line(`Version: ${version ?? '—'}`));
215
+ this._preview.appendChild(this._line(`Engine API: ${engineApi ?? '—'}`));
216
+ this._appendRepo(this._preview, repoUrl);
217
+
218
+ // список опубликованных версий — подсказка поля, а не отдельный контрол
219
+ this._versionList.textContent = '';
220
+ (versions || []).forEach(value => {
221
+ const option = document.createElement('option');
222
+
223
+ option.value = value;
224
+ this._versionList.appendChild(option);
225
+ });
226
+
227
+ // резолвнутая мастером версия: 'latest' в поле осталась бы ссылкой,
228
+ // которая завтра означает другой пакет
229
+ if (version) {
230
+ this._fields.get('version').value = version;
231
+ }
232
+
233
+ // разрешённая версия в поле меняет `${packageName}@${version}`, и
234
+ // следующий blur прошёл бы как «другой пакет»: запрет повтора обновляется
235
+ // фактическим ответом мастера
236
+ this._lastLookup = `${this._fields.get('packageName').value.trim()}@${
237
+ this._fields.get('version').value.trim()
238
+ }`;
239
+
240
+ const problems = errors || [];
241
+
242
+ if (problems.length) {
243
+ renderFormErrors(
244
+ this._submitError,
245
+ problems.map(error => ({ name: 'package', label: 'package', error })),
246
+ );
247
+ }
248
+
249
+ // заявка на заведомо нерабочий пакет реестру не нужна
250
+ this._submitBtn.disabled = problems.length > 0 || !id;
130
251
  }
131
252
 
132
253
  renderMine(games) {
@@ -166,6 +287,11 @@ export default class GamesView {
166
287
 
167
288
  item.appendChild(version);
168
289
  item.appendChild(send);
290
+ item.appendChild(
291
+ this._deleteButton(() =>
292
+ this.publisher.emit('delete', { id: game.id, scope: 'mine' }),
293
+ ),
294
+ );
169
295
  this._mineList.appendChild(item);
170
296
  });
171
297
  }
@@ -180,6 +306,12 @@ export default class GamesView {
180
306
  });
181
307
  }
182
308
 
309
+ renderWarning({ scope, code }) {
310
+ const container = scope === 'admin' ? this._adminError : this._submitError;
311
+
312
+ container.textContent = WARNING_MESSAGES[code] ?? code;
313
+ }
314
+
183
315
  renderError({ scope, errors }) {
184
316
  const container = scope === 'admin' ? this._adminError : this._submitError;
185
317
 
@@ -208,6 +340,23 @@ export default class GamesView {
208
340
  // у игр, засеянных миграцией, автора нет вовсе: без запасного прочерка
209
341
  // в строке печаталось бы литеральное "null"
210
342
  item.appendChild(this._line(`Author: ${game.authorNick ?? game.authorUserId ?? '—'}`));
343
+
344
+ // переназначение автора: игры платформы засеяны без него, и «My games»
345
+ // у их автора пуст, пока админ не проставит ник здесь. Пустое поле —
346
+ // снять автора
347
+ const author = document.createElement('input');
348
+
349
+ author.type = 'text';
350
+ author.className = 'field-text games-author-input';
351
+ author.placeholder = 'Author nick';
352
+ author.value = game.authorNick ?? '';
353
+
354
+ item.appendChild(author);
355
+ item.appendChild(
356
+ this._button('Set author', () =>
357
+ this.publisher.emit('set-author', { id: game.id, nick: author.value.trim() }),
358
+ ),
359
+ );
211
360
  this._appendRepo(item, game.repoUrl);
212
361
  item.appendChild(
213
362
  this._line(
@@ -258,6 +407,9 @@ export default class GamesView {
258
407
  this.publisher.emit('load-versions', { id: game.id }),
259
408
  ),
260
409
  );
410
+ item.appendChild(
411
+ this._deleteButton(() => this.publisher.emit('delete', { id: game.id, scope: 'admin' })),
412
+ );
261
413
 
262
414
  return item;
263
415
  }
@@ -307,6 +459,27 @@ export default class GamesView {
307
459
  });
308
460
  }
309
461
 
462
+ // Удаление необратимо и уносит рейтинги игры, поэтому кнопка требует
463
+ // второго нажатия: первое переводит её в «Confirm delete», второе публикует
464
+ // событие. Повторная отрисовка списка возвращает её в исходное состояние —
465
+ // незавершённое подтверждение не переживает перерисовку
466
+ _deleteButton(onConfirm) {
467
+ const btn = this._button('Delete', () => {
468
+ if (btn.dataset.armed !== '1') {
469
+ btn.dataset.armed = '1';
470
+ btn.value = 'Confirm delete';
471
+ btn.classList.add('games-delete-armed');
472
+ return;
473
+ }
474
+
475
+ onConfirm();
476
+ });
477
+
478
+ btn.className = 'games-delete-btn';
479
+
480
+ return btn;
481
+ }
482
+
310
483
  _button(value, onclick) {
311
484
  const btn = document.createElement('input');
312
485
 
@@ -329,6 +502,22 @@ export default class GamesView {
329
502
  return line;
330
503
  }
331
504
 
505
+ // Один и тот же пакет не разбирается дважды: blur поля и клик по «Load»
506
+ // приходят парой (mousedown → blur → click), а каждый разбор стоит мастеру
507
+ // скачанного тарболла и единицы общего лимитера заявок (5/мин на человека)
508
+ _emitLookup() {
509
+ const packageName = this._fields.get('packageName').value.trim();
510
+ const version = this._fields.get('version').value.trim();
511
+ const ref = `${packageName}@${version}`;
512
+
513
+ if (!packageName || ref === this._lastLookup) {
514
+ return;
515
+ }
516
+
517
+ this._lastLookup = ref;
518
+ this.publisher.emit('lookup', { packageName, version });
519
+ }
520
+
332
521
  _readForm() {
333
522
  const form = {};
334
523
 
@@ -0,0 +1,44 @@
1
+ // Каталог игр платформы может быть ПУСТ, и это законное состояние лобби, а не
2
+ // отказ загрузки: игры живут в реестре auth-сервиса, модератор вправе снять с
3
+ // раздачи последнюю, а на первом развёртывании не одобрена ещё ни одна.
4
+ //
5
+ // Лобби в этом состоянии обязано жить целиком: вход, бейдж пользователя, «My
6
+ // games» и «Moderation» от игры не зависят — и только через них каталог
7
+ // возвращается к жизни. Раньше пустой каталог бросал из бутстрапа и стирал
8
+ // разметку страницы: модератор, отключивший последнюю игру, запирал сам себя
9
+ // и вернуть её мог только запросом мимо интерфейса.
10
+ //
11
+ // Вынесено из main.js (бутстрап, в happy-dom не поднимается) отдельным
12
+ // модулем, чтобы обе ветки проверялись юнит-тестом.
13
+
14
+ /**
15
+ * Приводит лобби в соответствие с активной игрой.
16
+ * @param {Object} [manifest] - Манифест активной игры; `undefined` — каталог
17
+ * платформы пуст.
18
+ * @param {Object} deps - Проводка лобби.
19
+ * @param {HTMLElement} [deps.hostBtn] - Кнопка «Create server».
20
+ * @param {string} deps.emptyText - Строка отказа для пустого каталога.
21
+ * @param {Function} deps.bindGame - Форма комнаты и Leaderboard активной игры.
22
+ * @param {Function} deps.showError - Показать строку отказа лобби.
23
+ * @param {Function} deps.clearError - Снять строку отказа лобби.
24
+ * @returns {void}
25
+ */
26
+ export function applyCatalogState(
27
+ manifest,
28
+ { hostBtn, emptyText, bindGame, showError, clearError },
29
+ ) {
30
+ if (manifest) {
31
+ bindGame(manifest);
32
+ clearError();
33
+ } else {
34
+ // форму комнаты и Leaderboard не трогаем вовсе: без игры у первой нет
35
+ // схемы полей, а второй — игры, за рейтингом которой идти
36
+ showError(emptyText);
37
+ }
38
+
39
+ if (hostBtn) {
40
+ hostBtn.disabled = !manifest;
41
+ }
42
+ }
43
+
44
+ export default applyCatalogState;
@@ -35,8 +35,13 @@ import { normalizeAuthParams } from './lib/authParams.js';
35
35
  import { renderProjectLink } from './lib/footerLink.js';
36
36
  import { createGameActivator } from './lib/gameActivator.js';
37
37
  import createAutostart from './lib/autostart.js';
38
+ import { applyCatalogState } from './lib/catalogState.js';
38
39
  import { getBootConfig, resolveBootConfig } from './boot.js';
39
- import { ensureGameShell, ensureCanvas } from './views/gameShell.js';
40
+ import {
41
+ ensureGameShell,
42
+ ensureCanvas,
43
+ showBootFailure,
44
+ } from './views/gameShell.js';
40
45
  import { createContextTracker } from './lib/contextTracker.js';
41
46
  import { createLocalPlayer } from './lib/localPlayer.js';
42
47
  import { createAccolades } from './lib/accolades.js';
@@ -102,6 +107,10 @@ let activeGameManifest;
102
107
  let clientPlugin;
103
108
  let gamesManifest;
104
109
 
110
+ // почему активной игры нет, если каталог непуст: строка отказа лобби вместо
111
+ // общего «игр пока не опубликовано» (см. pickActiveGame)
112
+ let catalogProblem = null;
113
+
105
114
  // узел игрового CSS: при переключении игры его текст заменяется, иначе стили
106
115
  // двух игр жили бы в head одновременно и конфликтовали селекторами
107
116
  let gameStyleNode = null;
@@ -157,20 +166,40 @@ try {
157
166
  throw gamesManifest;
158
167
  }
159
168
 
160
- // недоступная игра активной быть не может (lib/pickActiveGame.js)
161
- activeGameManifest = pickActiveGame(gamesManifest, boot.gameId);
169
+ try {
170
+ // недоступная игра активной быть не может (lib/pickActiveGame.js)
171
+ activeGameManifest = pickActiveGame(gamesManifest, boot.gameId);
172
+ } catch (e) {
173
+ // «каталог непустой, но играбельного в нём нет» (движок обновили, все
174
+ // опубликованные игры просят возможность, которой в нём уже нет) — для
175
+ // лобби это то же самое, что пустой каталог: играть не во что, но
176
+ // модерация и заявка на месте, и только они это чинят. Терять их из-за
177
+ // такого нельзя, поэтому причина едет строкой отказа лобби, а не
178
+ // терминальным оверлеем. В solo/dedicated отказ остаётся отказом
179
+ if (!isLobbyMode) {
180
+ throw e;
181
+ }
182
+
183
+ catalogProblem = e.message;
184
+ }
162
185
  }
163
186
 
164
- if (!activeGameManifest) {
187
+ // пустой каталог — состояние лобби, а не отказ загрузки: модератор вправе
188
+ // отключить последнюю игру, реестр вправе ещё ничего не одобрить, и вернуть
189
+ // каталог к жизни можно только из панели, которая живёт в этой же вкладке.
190
+ // В solo/dedicated игры нет — это отказ: там весь смысл вкладки в матче
191
+ if (!activeGameManifest && !isLobbyMode) {
165
192
  throw new Error('master has no games in its catalog');
166
193
  }
167
194
 
168
- bindActiveGame(
169
- activeGameManifest,
170
- boot.clientPlugin ?? (await loadClientPlugin(activeGameManifest)),
171
- );
195
+ if (activeGameManifest) {
196
+ bindActiveGame(
197
+ activeGameManifest,
198
+ boot.clientPlugin ?? (await loadClientPlugin(activeGameManifest)),
199
+ );
200
+ }
172
201
  } catch (e) {
173
- document.body.textContent = `Failed to load the game: ${e.message}`;
202
+ showBootFailure(`Failed to load the game: ${e.message}`, gameContainer);
174
203
  throw e;
175
204
  }
176
205
 
@@ -1949,11 +1978,6 @@ function populateRoomForm(manifest) {
1949
1978
  }
1950
1979
  }
1951
1980
 
1952
- // роли, которым лобби показывает кнопку «Moderation» (master-game-registry,
1953
- // этап 4). Это подсказка интерфейсу: доступ к данным проверяет мастер, а
1954
- // запись — auth-сервис, перечитывая роль из БД
1955
- const ADMIN_ROLES = ['admin', 'superadmin'];
1956
-
1957
1981
  // каталог манифестов по id: форма и leaderboard селектора игр, а также
1958
1982
  // активация игры перед созданием комнаты и входом в чужую
1959
1983
  const gamesById = new Map(
@@ -1990,7 +2014,12 @@ async function selectActiveGame(gameId) {
1990
2014
  // сверка идёт с самим манифестом, а не с его id: каталог вкладки может
1991
2015
  // пополниться застейдженной версией той же игры (registerGameManifest), и
1992
2016
  // сравнение по id оставило бы активной одобренную версию
1993
- if (gameId === activeGameManifest.id && gamesById.get(gameId) === activeGameManifest) {
2017
+ // activeGameManifest?. каталог платформы может быть пуст: активной игры
2018
+ // у вкладки тогда нет вовсе, и первая же активация обязана состояться
2019
+ if (
2020
+ gameId === activeGameManifest?.id &&
2021
+ gamesById.get(gameId) === activeGameManifest
2022
+ ) {
1994
2023
  return true;
1995
2024
  }
1996
2025
 
@@ -2011,6 +2040,9 @@ async function selectActiveGame(gameId) {
2011
2040
  }
2012
2041
  }
2013
2042
 
2043
+ // очередь активации черновиков (см. подписку на 'staged' в initLobby)
2044
+ let stagedActivation = Promise.resolve();
2045
+
2014
2046
  // id игр, чей манифест в каталоге вкладки подменён застейдженной версией
2015
2047
  // (master-game-registry, этап 4): в селекторе такая игра помечается, чтобы
2016
2048
  // админ видел, что поднимет комнату НЕ на одобренной версии
@@ -2070,8 +2102,29 @@ function populateGameSelect() {
2070
2102
  gameSelect.appendChild(option);
2071
2103
  });
2072
2104
 
2073
- gameSelect.value =
2074
- selected && gamesById.has(selected) ? selected : activeGameManifest.id;
2105
+ if (selected && gamesById.has(selected)) {
2106
+ gameSelect.value = selected;
2107
+ } else if (activeGameManifest) {
2108
+ gameSelect.value = activeGameManifest.id;
2109
+ }
2110
+ // без активной игры выбор не трогаем: каталог был пуст, и селектор только
2111
+ // что получил первую строку — она и стоит выбранной по умолчанию.
2112
+ // Присваивание `''` вместо этого сняло бы выбор совсем (selectedIndex = -1)
2113
+ }
2114
+
2115
+ // форма комнаты, Leaderboard и «Create server» по активной игре; пустой
2116
+ // каталог — вторая законная ветка (client/lib/catalogState.js)
2117
+ function syncCatalogState() {
2118
+ applyCatalogState(activeGameManifest, {
2119
+ hostBtn: document.getElementById(lobbyConfig.elems.hostBtnId),
2120
+ emptyText: catalogProblem ?? lobbyConfig.create.emptyCatalogText,
2121
+ bindGame: manifest => {
2122
+ populateRoomForm(manifest);
2123
+ lobby.gameChanged(manifest.id, manifest.title);
2124
+ },
2125
+ showError: showLobbyError,
2126
+ clearError: clearLobbyError,
2127
+ });
2075
2128
  }
2076
2129
 
2077
2130
  // поднимает лобби после welcome от мастера (iceServers уже получены);
@@ -2117,7 +2170,15 @@ function initLobby() {
2117
2170
  // хостов старше 6.4 — тогда заходим на активной игре, как раньше: неизвестная
2118
2171
  // версия не повод запретить вход
2119
2172
  lobbyModel.publisher.on('join', async ({ hostId, gameId }) => {
2120
- if (gameId && gamesById.has(gameId) && !(await selectActiveGame(gameId))) {
2173
+ if (gameId && gamesById.has(gameId)) {
2174
+ if (!(await selectActiveGame(gameId))) {
2175
+ return;
2176
+ }
2177
+ } else if (!activeGameManifest) {
2178
+ // комната пережила свою игру: её сняли с раздачи, пока комната шла, и
2179
+ // каталог вкладки пуст — заходить не на чем
2180
+ showLobbyError('This room runs a game that is no longer published');
2181
+
2121
2182
  return;
2122
2183
  }
2123
2184
 
@@ -2174,19 +2235,49 @@ function initLobby() {
2174
2235
  const gamesView = new GamesView(gamesModel, lobbyConfig.games);
2175
2236
  const games = new GamesCtrl(gamesModel, gamesView);
2176
2237
 
2177
- games.setAdmin(ADMIN_ROLES.includes(lobbyAuthModel.getRole()));
2238
+ // кнопку «Moderation» лобби показывает роли admin (master-game-registry,
2239
+ // этап 4). Это подсказка интерфейсу: доступ к данным проверяет мастер, а
2240
+ // запись — auth-сервис, перечитывая роль из БД
2241
+ games.setAdmin(lobbyAuthModel.getRole() === 'admin');
2178
2242
 
2179
2243
  // «Test»: манифест застейдженной версии кладётся в каталог вкладки, и
2180
2244
  // админ поднимает по нему комнату обычной кнопкой Create server
2181
2245
  games.publisher.on('staged', ({ manifest }) => {
2182
2246
  registerGameManifest(manifest);
2183
2247
  gamesView.hide();
2248
+
2249
+ // каталог был пуст (первое развёртывание платформы либо снятая с раздачи
2250
+ // последняя игра): черновик — единственная игра вкладки, и создание
2251
+ // комнаты включается только после того, как её плагин привязан
2252
+ if (activeGameManifest) {
2253
+ return;
2254
+ }
2255
+
2256
+ // строго по очереди: loadStaged() восстанавливает все черновики этого
2257
+ // мастера синхронным циклом, и без очереди два черновика при пустом
2258
+ // каталоге ушли бы активироваться одновременно — активной осталась бы
2259
+ // одна игра, а форма комнаты собралась бы по другой
2260
+ stagedActivation = stagedActivation.then(async () => {
2261
+ if (activeGameManifest) {
2262
+ return; // предыдущий черновик уже стал активным
2263
+ }
2264
+
2265
+ // только на успехе: отказ активации уже написал в ту же строку СВОЮ
2266
+ // причину («Failed to load X: …»), и общее «игр пока не опубликовано»
2267
+ // затёрло бы её — админ увидел бы не то, что случилось
2268
+ if (await selectActiveGame(manifest.id)) {
2269
+ syncCatalogState();
2270
+ }
2271
+ }).catch(e => {
2272
+ // отказ не должен заклинить очередь: следующий черновик обязан
2273
+ // получить свой шанс стать активной игрой
2274
+ console.error('[game] staged activation failed:', e);
2275
+ });
2184
2276
  });
2185
2277
 
2186
2278
  // создание комнаты в этой же вкладке (хост-игрок через loopback)
2187
2279
  populateGameSelect();
2188
- populateRoomForm(activeGameManifest);
2189
- lobby.gameChanged(activeGameManifest.id, activeGameManifest.title);
2280
+ syncCatalogState();
2190
2281
 
2191
2282
  // селектор игр: меняет форму создания комнаты и Leaderboard сразу
2192
2283
  // (синхронно, без сети); сама игра активируется уже по клику
@@ -1081,11 +1081,17 @@ body.hide-cursor {
1081
1081
  text-align: left;
1082
1082
  }
1083
1083
 
1084
- /* заголовок панели и кнопка возврата в одной строке */
1084
+ /* заголовок панели, переключатель страниц и кнопка возврата в одной
1085
+ строке; шапка лежит НАД карточками, поэтому ширину и поля повторяет
1086
+ за ними — иначе flex-end колонки увёл бы её вправо от карточки */
1085
1087
  .games-head {
1086
1088
  display: flex;
1087
1089
  align-items: center;
1088
1090
  justify-content: space-between;
1091
+ gap: 8px;
1092
+ width: 700px;
1093
+ padding: 0 10px;
1094
+ box-sizing: border-box;
1089
1095
  }
1090
1096
 
1091
1097
  .games-list {
@@ -1141,6 +1147,18 @@ body.hide-cursor {
1141
1147
  color: #eee;
1142
1148
  }
1143
1149
 
1150
+ /* удаление необратимо: взведённое подтверждение обязано отличаться от
1151
+ первого нажатия не только подписью */
1152
+ .games-delete-btn {
1153
+ color: #d66;
1154
+ }
1155
+
1156
+ .games-delete-armed {
1157
+ background: #802020;
1158
+ border-color: #d66;
1159
+ color: #fee;
1160
+ }
1161
+
1144
1162
  /* кнопки входа в панель стоят рядом с Sign out в бейдже пользователя */
1145
1163
  .lobby-user-actions {
1146
1164
  display: flex;
@@ -1150,7 +1168,8 @@ body.hide-cursor {
1150
1168
 
1151
1169
  @media (max-width: 900px) {
1152
1170
  #games-mine,
1153
- #games-moderation {
1171
+ #games-moderation,
1172
+ .games-head {
1154
1173
  width: 100%;
1155
1174
  }
1156
1175
  }
@@ -135,6 +135,37 @@ export function ensureCanvas(canvasId, size, container = document.body) {
135
135
  return canvas;
136
136
  }
137
137
 
138
+ /**
139
+ * Терминальный отказ загрузки — поверх страницы, а не вместо неё.
140
+ *
141
+ * Раньше здесь стояло `document.body.textContent = …`: разметка страницы
142
+ * стиралась целиком, вместе с формой входа и панелью реестра игр, — то есть
143
+ * любая ошибка загрузки игры уносила с собой и путь к её исправлению.
144
+ * `#tech-informer` для того и есть: полноэкранный слой поверх (z-index 9),
145
+ * под которым разметка остаётся целой и рабочей после перезагрузки вкладки.
146
+ *
147
+ * Слой ТЕРМИНАЛЬНЫЙ и снять его нечем — он кроет и `#lobby-auth`, и
148
+ * `#games-panel` (обе z-index 8). Поэтому сюда попадает только то, что и
149
+ * правда терминально: отсутствие каталога само по себе не терминально, и в
150
+ * lobby-режиме оно идёт строкой отказа лобби (client/lib/catalogState.js).
151
+ * @param {string} text - Сообщение игроку.
152
+ * @param {HTMLElement} [container] - Точка монтирования каркаса.
153
+ * @returns {void}
154
+ */
155
+ export function showBootFailure(text, container = document.body) {
156
+ let elem = document.getElementById('tech-informer');
157
+
158
+ if (!elem) {
159
+ // каркаса нет (отказ случился до ensureGameShell): добавляем узел ПЕРЕД
160
+ // содержимым, а не вместо него
161
+ elem = createNode({ tag: 'div', id: 'tech-informer' });
162
+ container.prepend(elem);
163
+ }
164
+
165
+ elem.textContent = text;
166
+ elem.style.display = 'block';
167
+ }
168
+
138
169
  /**
139
170
  * Набор id, которые собирает каркас. Нужен тесту паритета с pug.
140
171
  * @returns {Set<string>}
@@ -5,32 +5,32 @@
5
5
  //- Подписи — по-английски, как и остальное лобби
6
6
  div#games-panel(style='display:none')
7
7
  div.lobby-column
8
+ //- Шапка панели, а не карточки: карточки «My games» и «Moderation»
9
+ //- показываются по одной (панель — это две разные страницы), и
10
+ //- кнопка выхода в лобби обязана пережить переключение между ними
11
+ div.games-head
12
+ h3#games-title.lobby-section-title= 'My games'
13
+ input#games-switch(type='button', value='Moderation', style='display:none')
14
+ input#games-close(type='button', value='Back to lobby')
15
+
8
16
  div.card#games-mine
9
- div.games-head
10
- h3.lobby-section-title= 'My games'
11
- input#games-close(type='button', value='Back to lobby')
12
17
  ul#games-mine-list.games-list
18
+ //- Заявка спрашивает только пакет и версию: id, title и репозиторий
19
+ //- мастер читает из самого пакета и показывает предпросмотром
13
20
  form#games-submit-form
14
- div.form-row
15
- label.form-label(for='games-field-id')= 'Game id'
16
- input#games-field-id.field-text(type='text', name='id', maxlength='32', autocomplete='off')
17
21
  div.form-row
18
22
  label.form-label(for='games-field-package')= 'npm package'
19
23
  input#games-field-package.field-text(type='text', name='packageName', maxlength='214', autocomplete='off')
24
+ input#games-lookup(type='button', value='Load')
20
25
  div.form-row
21
26
  label.form-label(for='games-field-version')= 'Version'
22
- input#games-field-version.field-text(type='text', name='version', maxlength='64', autocomplete='off')
23
- div.form-row
24
- label.form-label(for='games-field-repo')= 'Repository URL'
25
- input#games-field-repo.field-text(type='text', name='repoUrl', maxlength='256', autocomplete='off')
26
- div.form-row
27
- label.form-label(for='games-field-title')= 'Title'
28
- input#games-field-title.field-text(type='text', name='title', maxlength='64', autocomplete='off')
27
+ input#games-field-version.field-text(type='text', name='version', maxlength='64', autocomplete='off', placeholder='latest', list='games-version-list')
28
+ datalist#games-version-list
29
+ div#games-preview.games-preview
29
30
  div#games-submit-error.form-error
30
31
  input#games-submit(type='submit', value='Submit for review')
31
32
 
32
33
  div.card#games-moderation(style='display:none')
33
- h3.lobby-section-title= 'Moderation'
34
34
  div#games-filters.games-filters
35
35
  ul#games-admin-list.games-list
36
36
  div#games-admin-error.form-error
package/src/config/env.js CHANGED
@@ -26,12 +26,6 @@ export function applyMasterEnv(config, env = process.env) {
26
26
  config.set('master:security:authServiceUrl', env.VIMP_AUTH_SERVICE_URL);
27
27
  }
28
28
 
29
- // список игр-плагинов мастера (Этап A2), по образцу остальных *_MATRIX
30
- // env-переопределений — JSON-массив {id, package}
31
- if (env.GAMES_MATRIX) {
32
- config.set('master:games', JSON.parse(env.GAMES_MATRIX));
33
- }
34
-
35
29
  // корень хранилища игровых пакетов (направление master-game-registry) —
36
30
  // в проде это смонтированный том, переживающий пересоздание контейнера
37
31
  if (env.VIMP_GAMES_DIR) {
@@ -201,10 +201,16 @@ export default {
201
201
  urls: {
202
202
  // заявки вызывающего со статусами и замечаниями модератора
203
203
  mine: '/games/mine',
204
+ // разбор npm-пакета для формы заявки: id, title, версии и репозиторий
205
+ // мастер читает сам — человек вводит только пакет и версию
206
+ lookup: '/games/lookup',
204
207
  // заявка на новую игру платформы (валидируется мастером до записи)
205
208
  submit: '/games/submit',
206
209
  // заявка на новую версию уже заведённой игры
207
210
  version: id => `/games/mine/${encodeURIComponent(id)}/version`,
211
+ // удаление игры: и «My games» (автор), и «Moderation» (админ) ходят
212
+ // одним URL — право решает auth
213
+ remove: id => `/games/mine/${encodeURIComponent(id)}`,
208
214
  // очередь модерации целиком плюс локальное состояние на этом мастере
209
215
  admin: '/admin/games',
210
216
  // манифесты застейдженных версий — по ним админ поднимает тестовую
@@ -247,13 +253,21 @@ export default {
247
253
  submitFormId: 'games-submit-form',
248
254
  submitErrorId: 'games-submit-error',
249
255
  submitBtnId: 'games-submit',
256
+ // форма спрашивает ровно две вещи; id, title и репозиторий приезжают
257
+ // предпросмотром из разобранного пакета
250
258
  fieldIds: {
251
- id: 'games-field-id',
252
259
  packageName: 'games-field-package',
253
260
  version: 'games-field-version',
254
- repoUrl: 'games-field-repo',
255
- title: 'games-field-title',
256
261
  },
262
+ lookupBtnId: 'games-lookup',
263
+ previewId: 'games-preview',
264
+ versionListId: 'games-version-list',
265
+
266
+ // карточки панели показываются по одной; заголовок общий, как и
267
+ // переключатель страниц (виден только админу)
268
+ mineId: 'games-mine',
269
+ titleId: 'games-title',
270
+ switchBtnId: 'games-switch',
257
271
 
258
272
  // модерация
259
273
  moderationId: 'games-moderation',
@@ -269,6 +283,12 @@ export default {
269
283
  create: {
270
284
  defaultName: 'My Server',
271
285
 
286
+ // каталог платформы пуст: реестр ещё ничего не одобрил либо модератор
287
+ // снял с раздачи последнюю игру. Комнату создавать не на чем, но лобби
288
+ // живо — и текст называет то единственное, что выводит его из этого
289
+ // состояния
290
+ emptyCatalogText: 'No games are published yet — see “My games”',
291
+
272
292
  // период heartbeat/актуализации комнаты у мастера (мс); должен быть
273
293
  // меньше master.host.heartbeatTimeout (30 c), иначе комнату выметет
274
294
  heartbeatInterval: 10000,
@@ -23,11 +23,11 @@ export default {
23
23
  // auth-сервиса, откуда мастер скачивает одобренные пакеты сам
24
24
  // (master/GameSync.js). Движок игр не знает и зависимостями их не тянет.
25
25
  //
26
- // Массив и переменная окружения GAMES_MATRIX (JSON) остаются
27
- // переопределением для двух случаев: локальная разработка (каталог вдобавок
28
- // дополняется собранными пакетами @vimp-games/* из node_modules,
29
- // master/localGames.js прилинкованная игра важнее реестра) и self-hosted
30
- // мастер без реестра. `package` — имя npm-пакета игры, резолвится через
26
+ // Массив остаётся двум потребителям, и env-переопределения у него нет:
27
+ // локальной разработке (каталог наполняют собранные пакеты @vimp-games/*
28
+ // из node_modules, master/localGames.js прилинкованная игра важнее
29
+ // реестра) и dedicated-серверу, который берёт отсюда игру, названную
30
+ // VIMP_DEDICATED_GAME. `package` — имя npm-пакета игры, резолвится через
31
31
  // node_modules; версию задаёт сама установленная зависимость.
32
32
  // maxGameScore (snakes-v3 этап 3.3, необязательное поле рядом с
33
33
  // id/package) — потолок результата ОДНОЙ игры для этой игры; мастер
@@ -54,12 +54,14 @@ export async function loadGameForSim({ game = null, core = null } = {}) {
54
54
  }
55
55
 
56
56
  // путь --game указывает либо на пакет игры, либо прямо на манифест; сама
57
- // загрузка общая с dedicated-сервером (lib/loadGamePackage.js)
57
+ // загрузка общая с dedicated-сервером (lib/loadGamePackage.js).
58
+ // client: true — прогону виртуальные клиенты обязательны, и отказ на
59
+ // загрузке пакета лучше отказа посреди сценария
58
60
  async function loadFromManifest(game, core) {
59
61
  const target = game.endsWith('.json')
60
62
  ? path.resolve(game)
61
63
  : path.resolve(game, 'dist');
62
- const pkg = await loadGamePackage(target, { core });
64
+ const pkg = await loadGamePackage(target, { core, client: true });
63
65
 
64
66
  return { ...pkg, source: pkg.manifestPath };
65
67
  }
@@ -21,10 +21,17 @@ import { ENGINE_CAPABILITIES } from './capabilities.js';
21
21
  * @param {Object} [options]
22
22
  * @param {string} [options.core] - Путь к node-сборке ядра, перекрывающий
23
23
  * entries.wasmNode манифеста.
24
- * @returns {Promise<Object>} { id, manifest, hostPlugin, clientPlugin, wasmUrl,
25
- * distDir, manifestPath }.
24
+ * @param {(boolean|'lazy')} [options.client] - Когда грузить клиентскую
25
+ * половину: 'lazy' (умолчание) — только по вызову loadClientPlugin(),
26
+ * true — сразу вместе с host-половиной.
27
+ * @returns {Promise<Object>} { id, manifest, hostPlugin, clientPlugin,
28
+ * loadClientPlugin, wasmUrl, distDir, manifestPath }. В ленивом режиме
29
+ * clientPlugin равен null, а половину отдаёт loadClientPlugin().
26
30
  */
27
- export async function loadGamePackage(distDir, { core = null } = {}) {
31
+ export async function loadGamePackage(
32
+ distDir,
33
+ { core = null, client = 'lazy' } = {},
34
+ ) {
28
35
  // манифест под нестандартным именем нужен headless-прогону (--game
29
36
  // <path>/manifest-variant.json); dedicated-сервер всегда даёт каталог
30
37
  const manifestPath = distDir.endsWith('.json')
@@ -47,18 +54,53 @@ export async function loadGamePackage(distDir, { core = null } = {}) {
47
54
  baseDir,
48
55
  manifest.entries.host,
49
56
  assetsBase,
50
- );
51
- const clientPlugin = await importDefault(
52
- baseDir,
53
- manifest.entries.client,
54
- assetsBase,
57
+ manifestPath,
55
58
  );
56
59
 
60
+ // Клиентская половина в Node нужна только виртуальным клиентам
61
+ // headless-прогона; dedicated-сервер её не касается вовсе (в браузер она
62
+ // едет статикой, и pixi.js там резолвится import map'ом). Грузить её
63
+ // безусловно значило бы требовать от игры самодостаточной клиентской
64
+ // сборки — а правило контракта A1 держит pixi.js внешним намеренно.
65
+ //
66
+ // Размен ленивого режима: расхождение engineApi между манифестом и
67
+ // клиентской сборкой в Node-контуре ничего не ломает — клиента собирает
68
+ // браузер, совместимость манифеста уже проверена checkPluginCompatibility
69
+ // выше, а структуру пакета проверяет checkGamePackage на стороне
70
+ // GameStore.
71
+ const clientPlugin =
72
+ client === true
73
+ ? await importDefault(
74
+ baseDir,
75
+ manifest.entries.client,
76
+ assetsBase,
77
+ manifestPath,
78
+ )
79
+ : null;
80
+
57
81
  assertPluginMatchesManifest(manifest, {
58
82
  host: hostPlugin,
59
- client: clientPlugin,
83
+ ...(clientPlugin ? { client: clientPlugin } : {}),
60
84
  });
61
85
 
86
+ // одна форма результата при любом режиме: потребитель зовёт
87
+ // loadClientPlugin() и не разбирается, была ли половина уже импортирована
88
+ let clientLoad = clientPlugin ? Promise.resolve(clientPlugin) : null;
89
+ const loadClientPlugin = () => {
90
+ clientLoad ??= importDefault(
91
+ baseDir,
92
+ manifest.entries.client,
93
+ assetsBase,
94
+ manifestPath,
95
+ ).then(plugin => {
96
+ assertPluginMatchesManifest(manifest, { client: plugin });
97
+
98
+ return plugin;
99
+ });
100
+
101
+ return clientLoad;
102
+ };
103
+
62
104
  const nodeCore = core ?? manifest.entries.wasmNode ?? null;
63
105
 
64
106
  if (!nodeCore) {
@@ -88,6 +130,7 @@ export async function loadGamePackage(distDir, { core = null } = {}) {
88
130
  manifest,
89
131
  hostPlugin,
90
132
  clientPlugin,
133
+ loadClientPlugin,
91
134
  wasmUrl: pathToFileURL(corePath).href,
92
135
  distDir: baseDir,
93
136
  manifestPath,
@@ -195,10 +238,34 @@ function resolveNames(names) {
195
238
  );
196
239
  }
197
240
 
198
- function importDefault(baseDir, entry, assetsBase) {
241
+ // Половина плагина, загружаемая в Node, обязана быть самодостаточной:
242
+ // node_modules резолвятся вверх по дереву от файла, а пакет игры лежит там,
243
+ // куда его положил реестр (VIMP_GAMES_DIR), — вне node_modules движка.
244
+ // Сырой ERR_MODULE_NOT_FOUND называет только ненайденный пакет и молчит о
245
+ // том, чья половина и какого пакета игры его просит
246
+ function importDefault(baseDir, entry, assetsBase, manifestPath) {
199
247
  const file = path.resolve(baseDir, stripBase(entry, assetsBase));
200
248
 
201
- return import(pathToFileURL(file).href).then(module => module.default);
249
+ return import(pathToFileURL(file).href).then(
250
+ module => module.default,
251
+ err => {
252
+ if (err?.code !== 'ERR_MODULE_NOT_FOUND') {
253
+ throw err;
254
+ }
255
+
256
+ // имя ненайденного пакета — из текста ошибки; не нашлось — не
257
+ // подставляем
258
+ const missing = /Cannot find package '([^']+)'/.exec(err.message)?.[1];
259
+
260
+ throw new Error(
261
+ `${manifestPath}: entry '${entry}'` +
262
+ `${missing ? ` imports '${missing}', which` : ''} cannot be ` +
263
+ 'resolved by Node — a plugin half loaded in Node must be ' +
264
+ 'self-contained; external dependencies (pixi.js) are allowed only ' +
265
+ 'in the client half and only in the browser',
266
+ );
267
+ },
268
+ );
202
269
  }
203
270
 
204
271
  // entries.host/client — URL-ы, какими их видит браузер (`assetsBase` + путь