vimp-engine 0.26.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.26.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) {
@@ -170,6 +212,12 @@ export default class GamesModel {
170
212
  }
171
213
  }
172
214
 
215
+ // переназначение автора — тот же PATCH, что и решение модератора: наружу
216
+ // едет ник, пустой означает «снять автора»
217
+ async setAuthor(id, nick) {
218
+ await this.moderate(id, { authorNick: nick === '' ? null : nick });
219
+ }
220
+
173
221
  _emitAdmin() {
174
222
  this.publisher.emit('admin-changed', {
175
223
  games: this._all.filter(game => game.status === this._filter),
@@ -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',
@@ -71,6 +73,19 @@ export default class GamesView {
71
73
  this._mineList = document.getElementById(elems.mineListId);
72
74
  this._submitForm = document.getElementById(elems.submitFormId);
73
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;
74
89
 
75
90
  this._moderation = document.getElementById(elems.moderationId);
76
91
  this._adminList = document.getElementById(elems.adminListId);
@@ -99,17 +114,34 @@ export default class GamesView {
99
114
  this._openMine.onclick = () => this.publisher.emit('open-mine');
100
115
  this._openModeration.onclick = () => this.publisher.emit('open-moderation');
101
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');
102
121
 
103
122
  this._submitForm.onsubmit = e => {
104
123
  e.preventDefault();
105
124
  this.publisher.emit('submit', this._readForm());
106
125
  };
107
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
+
108
139
  this._renderFilters();
109
140
 
110
141
  const mp = model.publisher;
111
142
 
112
143
  mp.on('submitted', 'clearForm', this);
144
+ mp.on('looked-up', 'renderPreview', this);
113
145
  mp.on('mine-changed', 'renderMine', this);
114
146
  mp.on('admin-changed', 'renderAdmin', this);
115
147
  mp.on('error', 'renderError', this);
@@ -120,12 +152,21 @@ export default class GamesView {
120
152
  // (main.js по LobbyAuthModel.getRole())
121
153
  setAdmin(isAdmin) {
122
154
  this._openModeration.style.display = isAdmin ? '' : 'none';
155
+ this._switch.style.display = isAdmin ? '' : 'none';
123
156
  }
124
157
 
158
+ // Панель — две страницы, а не одна: «My games» и «Moderation»
159
+ // показываются по одной. Общая шапка (заголовок, переключатель и
160
+ // «Back to lobby») живёт над карточками и переживает переключение
125
161
  show(moderation = false) {
162
+ this._isModeration = moderation;
126
163
  this._panel.style.display = 'flex';
127
164
  this._lobby.style.display = 'none';
165
+ this._mine.style.display = moderation ? 'none' : 'block';
128
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';
129
170
  }
130
171
 
131
172
  hide() {
@@ -145,6 +186,68 @@ export default class GamesView {
145
186
  this._fields.forEach(field => {
146
187
  field.value = '';
147
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;
148
251
  }
149
252
 
150
253
  renderMine(games) {
@@ -184,6 +287,11 @@ export default class GamesView {
184
287
 
185
288
  item.appendChild(version);
186
289
  item.appendChild(send);
290
+ item.appendChild(
291
+ this._deleteButton(() =>
292
+ this.publisher.emit('delete', { id: game.id, scope: 'mine' }),
293
+ ),
294
+ );
187
295
  this._mineList.appendChild(item);
188
296
  });
189
297
  }
@@ -232,6 +340,23 @@ export default class GamesView {
232
340
  // у игр, засеянных миграцией, автора нет вовсе: без запасного прочерка
233
341
  // в строке печаталось бы литеральное "null"
234
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
+ );
235
360
  this._appendRepo(item, game.repoUrl);
236
361
  item.appendChild(
237
362
  this._line(
@@ -282,6 +407,9 @@ export default class GamesView {
282
407
  this.publisher.emit('load-versions', { id: game.id }),
283
408
  ),
284
409
  );
410
+ item.appendChild(
411
+ this._deleteButton(() => this.publisher.emit('delete', { id: game.id, scope: 'admin' })),
412
+ );
285
413
 
286
414
  return item;
287
415
  }
@@ -331,6 +459,27 @@ export default class GamesView {
331
459
  });
332
460
  }
333
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
+
334
483
  _button(value, onclick) {
335
484
  const btn = document.createElement('input');
336
485
 
@@ -353,6 +502,22 @@ export default class GamesView {
353
502
  return line;
354
503
  }
355
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
+
356
521
  _readForm() {
357
522
  const form = {};
358
523
 
@@ -1978,11 +1978,6 @@ function populateRoomForm(manifest) {
1978
1978
  }
1979
1979
  }
1980
1980
 
1981
- // роли, которым лобби показывает кнопку «Moderation» (master-game-registry,
1982
- // этап 4). Это подсказка интерфейсу: доступ к данным проверяет мастер, а
1983
- // запись — auth-сервис, перечитывая роль из БД
1984
- const ADMIN_ROLES = ['admin', 'superadmin'];
1985
-
1986
1981
  // каталог манифестов по id: форма и leaderboard селектора игр, а также
1987
1982
  // активация игры перед созданием комнаты и входом в чужую
1988
1983
  const gamesById = new Map(
@@ -2240,7 +2235,10 @@ function initLobby() {
2240
2235
  const gamesView = new GamesView(gamesModel, lobbyConfig.games);
2241
2236
  const games = new GamesCtrl(gamesModel, gamesView);
2242
2237
 
2243
- games.setAdmin(ADMIN_ROLES.includes(lobbyAuthModel.getRole()));
2238
+ // кнопку «Moderation» лобби показывает роли admin (master-game-registry,
2239
+ // этап 4). Это подсказка интерфейсу: доступ к данным проверяет мастер, а
2240
+ // запись — auth-сервис, перечитывая роль из БД
2241
+ games.setAdmin(lobbyAuthModel.getRole() === 'admin');
2244
2242
 
2245
2243
  // «Test»: манифест застейдженной версии кладётся в каталог вкладки, и
2246
2244
  // админ поднимает по нему комнату обычной кнопкой Create server
@@ -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
  }
@@ -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
@@ -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',
@@ -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` + путь