vimp-engine 0.26.0 → 0.28.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.28.0",
4
4
  "description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -25,14 +25,18 @@ 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);
32
+ vp.on('restore', 'restore', this);
30
33
  vp.on('filter', 'filter', this);
31
34
  vp.on('stage', 'stage', this);
32
35
  vp.on('approve', 'approve', this);
33
36
  vp.on('reject', 'reject', this);
34
37
  vp.on('disable', 'disable', this);
35
38
  vp.on('load-versions', 'loadVersions', this);
39
+ vp.on('set-author', 'setAuthor', this);
36
40
 
37
41
  model.publisher.on('staged', 'staged', this);
38
42
  }
@@ -55,12 +59,17 @@ export default class GamesCtrl {
55
59
  this._model.loadMine();
56
60
  }
57
61
 
62
+ // «Moderation» — отдельная страница панели: карточка «My games» в этом
63
+ // режиме не видна, и запрос за её данными был бы холостым
58
64
  openModeration() {
59
65
  this._view.show(true);
60
- this._model.loadMine();
61
66
  this._model.loadAdmin();
62
67
  }
63
68
 
69
+ lookup({ packageName, version }) {
70
+ this._model.lookup(packageName, version);
71
+ }
72
+
64
73
  submit(form) {
65
74
  this._model.submit(form);
66
75
  }
@@ -69,6 +78,14 @@ export default class GamesCtrl {
69
78
  this._model.requestVersion(id, version);
70
79
  }
71
80
 
81
+ remove({ id, scope }) {
82
+ this._model.remove(id, scope);
83
+ }
84
+
85
+ restore({ id }) {
86
+ this._model.restore(id);
87
+ }
88
+
72
89
  filter(status) {
73
90
  this._model.setFilter(status);
74
91
  }
@@ -93,6 +110,10 @@ export default class GamesCtrl {
93
110
  this._model.loadVersions(id);
94
111
  }
95
112
 
113
+ setAuthor({ id, nick }) {
114
+ this._model.setAuthor(id, nick);
115
+ }
116
+
96
117
  staged(data) {
97
118
  this.publisher.emit('staged', data);
98
119
  }
@@ -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,52 @@ export default class GamesModel {
133
155
  await this.loadMine();
134
156
  }
135
157
 
158
+ // удаление игры. scope решает, чей список перечитать: карточка автора и
159
+ // очередь модерации показывают разные проекции одной строки.
160
+ //
161
+ // Удаление обратимо: игра уходит в графу Deleted очереди модерации и
162
+ // возвращается оттуда `restore` — подтверждения второй кнопкой оно
163
+ // поэтому не требует
164
+ async remove(id, scope = 'mine') {
165
+ const { ok, json } = await this._request(this._config.urls.remove(id), {
166
+ method: 'DELETE',
167
+ });
168
+
169
+ if (!ok) {
170
+ this._fail(scope, json);
171
+ return;
172
+ }
173
+
174
+ if (scope === 'admin') {
175
+ await this.loadAdmin();
176
+
177
+ // после loadAdmin, а не до: renderAdmin чистит строку панели (см.
178
+ // moderate). Снятая с раздачи последняя игра оставляет лобби без
179
+ // каталога, и сказать об этом обязан тот же тик
180
+ if (json?.warning) {
181
+ this.publisher.emit('warning', { scope: 'admin', code: json.warning });
182
+ }
183
+
184
+ return;
185
+ }
186
+
187
+ await this.loadMine();
188
+ }
189
+
190
+ // возврат мягко удалённой игры из графы Deleted (только админ)
191
+ async restore(id) {
192
+ const { ok, json } = await this._request(this._config.urls.restore(id), {
193
+ method: 'POST',
194
+ });
195
+
196
+ if (!ok) {
197
+ this._fail('admin', json);
198
+ return;
199
+ }
200
+
201
+ await this.loadAdmin();
202
+ }
203
+
136
204
  // «Test»: мастер качает версию и кладёт её в каталог не раздаваемой.
137
205
  // Манифест едет наружу — по нему вкладка админа поднимает комнату
138
206
  async stage(id, version) {
@@ -170,9 +238,23 @@ export default class GamesModel {
170
238
  }
171
239
  }
172
240
 
241
+ // переназначение автора — тот же PATCH, что и решение модератора: наружу
242
+ // едет ник, пустой означает «снять автора»
243
+ async setAuthor(id, nick) {
244
+ await this.moderate(id, { authorNick: nick === '' ? null : nick });
245
+ }
246
+
247
+ // графа, в которой стоит игра. Мягко удалённая уходит в свою графу
248
+ // ЦЕЛИКОМ, сохраняя прежний статус: восстановление возвращает её туда,
249
+ // где она была, а показывать её ещё и в Pending/Published значило бы
250
+ // предлагать модерировать то, чего у игроков уже нет
251
+ _bucketOf(game) {
252
+ return game.deletedAt ? 'deleted' : game.status;
253
+ }
254
+
173
255
  _emitAdmin() {
174
256
  this.publisher.emit('admin-changed', {
175
- games: this._all.filter(game => game.status === this._filter),
257
+ games: this._all.filter(game => this._bucketOf(game) === this._filter),
176
258
  filter: this._filter,
177
259
  versions: this._versions,
178
260
  });
@@ -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,13 @@ export default class GamesView {
184
287
 
185
288
  item.appendChild(version);
186
289
  item.appendChild(send);
290
+ item.appendChild(
291
+ this._button(
292
+ 'Delete',
293
+ () => this.publisher.emit('delete', { id: game.id, scope: 'mine' }),
294
+ 'games-delete-btn',
295
+ ),
296
+ );
187
297
  this._mineList.appendChild(item);
188
298
  });
189
299
  }
@@ -229,9 +339,41 @@ export default class GamesView {
229
339
  'games-item-title',
230
340
  ),
231
341
  );
342
+
343
+ // Мягко удалённая игра модерации не подлежит: у игроков её уже нет, а
344
+ // решение по ней приняли. Из всей карточки ей нужны срок полного
345
+ // удаления и возврат — остальные кнопки правили бы то, чего не видно
346
+ if (game.deletedAt) {
347
+ item.appendChild(this._line(`Author: ${game.authorNick ?? game.authorUserId ?? '—'}`));
348
+ this._appendRepo(item, game.repoUrl);
349
+ item.appendChild(this._line(this._statusLine(game), 'games-purge-line'));
350
+ item.appendChild(
351
+ this._button('Restore', () => this.publisher.emit('restore', { id: game.id })),
352
+ );
353
+
354
+ return item;
355
+ }
356
+
232
357
  // у игр, засеянных миграцией, автора нет вовсе: без запасного прочерка
233
358
  // в строке печаталось бы литеральное "null"
234
359
  item.appendChild(this._line(`Author: ${game.authorNick ?? game.authorUserId ?? '—'}`));
360
+
361
+ // переназначение автора: игры платформы засеяны без него, и «My games»
362
+ // у их автора пуст, пока админ не проставит ник здесь. Пустое поле —
363
+ // снять автора
364
+ const author = document.createElement('input');
365
+
366
+ author.type = 'text';
367
+ author.className = 'field-text games-author-input';
368
+ author.placeholder = 'Author nick';
369
+ author.value = game.authorNick ?? '';
370
+
371
+ item.appendChild(author);
372
+ item.appendChild(
373
+ this._button('Set author', () =>
374
+ this.publisher.emit('set-author', { id: game.id, nick: author.value.trim() }),
375
+ ),
376
+ );
235
377
  this._appendRepo(item, game.repoUrl);
236
378
  item.appendChild(
237
379
  this._line(
@@ -282,6 +424,13 @@ export default class GamesView {
282
424
  this.publisher.emit('load-versions', { id: game.id }),
283
425
  ),
284
426
  );
427
+ item.appendChild(
428
+ this._button(
429
+ 'Delete',
430
+ () => this.publisher.emit('delete', { id: game.id, scope: 'admin' }),
431
+ 'games-delete-btn',
432
+ ),
433
+ );
285
434
 
286
435
  return item;
287
436
  }
@@ -310,6 +459,16 @@ export default class GamesView {
310
459
  const status = STATUS_TITLES[game.status] ?? game.status;
311
460
  const date = game.createdAt ? new Date(game.createdAt).toLocaleDateString() : '';
312
461
 
462
+ // у удалённой игры прежний статус — след того, куда её вернёт Restore, —
463
+ // а вести отсчёт человеку нужно от срока полного удаления: только он
464
+ // ограничен во времени
465
+ if (game.deletedAt) {
466
+ const deleted = new Date(game.deletedAt).toLocaleDateString();
467
+ const purge = game.purgeAt ? new Date(game.purgeAt).toLocaleDateString() : '—';
468
+
469
+ return `Deleted ${deleted} (was ${status}); removed for good on ${purge}`;
470
+ }
471
+
313
472
  return date ? `Status: ${status} (submitted ${date})` : `Status: ${status}`;
314
473
  }
315
474
 
@@ -331,13 +490,17 @@ export default class GamesView {
331
490
  });
332
491
  }
333
492
 
334
- _button(value, onclick) {
493
+ _button(value, onclick, className) {
335
494
  const btn = document.createElement('input');
336
495
 
337
496
  btn.type = 'button';
338
497
  btn.value = value;
339
498
  btn.onclick = onclick;
340
499
 
500
+ if (className) {
501
+ btn.className = className;
502
+ }
503
+
341
504
  return btn;
342
505
  }
343
506
 
@@ -353,6 +516,22 @@ export default class GamesView {
353
516
  return line;
354
517
  }
355
518
 
519
+ // Один и тот же пакет не разбирается дважды: blur поля и клик по «Load»
520
+ // приходят парой (mousedown → blur → click), а каждый разбор стоит мастеру
521
+ // скачанного тарболла и единицы общего лимитера заявок (5/мин на человека)
522
+ _emitLookup() {
523
+ const packageName = this._fields.get('packageName').value.trim();
524
+ const version = this._fields.get('version').value.trim();
525
+ const ref = `${packageName}@${version}`;
526
+
527
+ if (!packageName || ref === this._lastLookup) {
528
+ return;
529
+ }
530
+
531
+ this._lastLookup = ref;
532
+ this.publisher.emit('lookup', { packageName, version });
533
+ }
534
+
356
535
  _readForm() {
357
536
  const form = {};
358
537
 
@@ -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
@@ -217,9 +217,9 @@ body.hide-cursor {
217
217
  background: rgba(25, 25, 25, 0.9);
218
218
  border-radius: 5px;
219
219
  border: 3px solid #fff;
220
- overflow: hidden;
220
+ overflow: auto;
221
221
  width: 90%;
222
- max-width: 550px;
222
+ max-width: 500px;
223
223
  font-size: 10px;
224
224
  line-height: 12px;
225
225
  color: #fff;
@@ -438,7 +438,7 @@ body.hide-cursor {
438
438
  background-color: #333;
439
439
  border: 1px solid #eee;
440
440
  padding: 10px;
441
- margin: 10px auto 5px;
441
+ margin: 5px auto 0;
442
442
  text-shadow: 1px 1px 1px #000;
443
443
  font-size: 12px;
444
444
  line-height: 15px;
@@ -549,15 +549,11 @@ body.hide-cursor {
549
549
  padding: 0 10px;
550
550
  }
551
551
 
552
- /* ширина — контентная область правой панели лобби (её ширина минус
553
- собственные поля .lobby-browser-inner), под которой футер и стоит;
554
- max-width, а не width: в одну колонку он обязан ужиматься по экрану */
555
552
  #lobby-footer {
556
- width: 100%;
557
- max-width: calc(var(--lobby-browser-width) - 2 * var(--lobby-panel-pad));
553
+ width: calc(var(--lobby-width) + var(--lobby-panel-pad));
558
554
  font-size: 10px;
559
555
  padding: 0;
560
- margin: 0 var(--lobby-panel-pad) 0 0;
556
+ margin: 0 calc(var(--lobby-panel-pad) / 2) 0 0;
561
557
  }
562
558
 
563
559
  /* три равные ячейки: середину держит не space-between, а сама равность —
@@ -670,11 +666,7 @@ body.hide-cursor {
670
666
  /*lobby*/
671
667
 
672
668
  #lobby {
673
- /* ширины панелей и их внутренние поля: по ним же считается ширина футера
674
- (секция auth выше), поэтому числа живут в одном месте — иначе смена
675
- ширины любой панели молча разъезжает вёрстку */
676
- --lobby-setup-width: 410px;
677
- --lobby-browser-width: 430px;
669
+ --lobby-width: 370px;
678
670
  --lobby-panel-pad: 10px;
679
671
 
680
672
  background-color: #222;
@@ -832,12 +824,12 @@ body.hide-cursor {
832
824
  высоты более высокой панели */
833
825
  .lobby-grid {
834
826
  display: flex;
835
- gap: 10px;
827
+ gap: 5px;
836
828
  justify-content: center;
837
829
  }
838
830
 
839
831
  #lobby-setup-panel {
840
- width: var(--lobby-setup-width);
832
+ width: var(--lobby-width);
841
833
  }
842
834
 
843
835
  /* высоту правой .card задаёт форма слева: обе панели тянутся до высоты
@@ -846,9 +838,8 @@ body.hide-cursor {
846
838
  вынесен в абсолютный слой .lobby-browser-inner (его высота не участвует в
847
839
  расчёте высоты строки) */
848
840
  #lobby-browser-panel {
849
- width: var(--lobby-browser-width);
841
+ width: var(--lobby-width);
850
842
  position: relative;
851
- padding: 0;
852
843
  }
853
844
 
854
845
  .lobby-browser-inner {
@@ -1081,11 +1072,17 @@ body.hide-cursor {
1081
1072
  text-align: left;
1082
1073
  }
1083
1074
 
1084
- /* заголовок панели и кнопка возврата в одной строке */
1075
+ /* заголовок панели, переключатель страниц и кнопка возврата в одной
1076
+ строке; шапка лежит НАД карточками, поэтому ширину и поля повторяет
1077
+ за ними — иначе flex-end колонки увёл бы её вправо от карточки */
1085
1078
  .games-head {
1086
1079
  display: flex;
1087
1080
  align-items: center;
1088
1081
  justify-content: space-between;
1082
+ gap: 8px;
1083
+ width: 700px;
1084
+ padding: 0 10px;
1085
+ box-sizing: border-box;
1089
1086
  }
1090
1087
 
1091
1088
  .games-list {
@@ -1141,6 +1138,18 @@ body.hide-cursor {
1141
1138
  color: #eee;
1142
1139
  }
1143
1140
 
1141
+ /* удаление обратимо (игра уходит в графу Deleted на 30 суток), но выделяется
1142
+ среди соседних кнопок: рядом стоит правка версии, а не действие того же веса */
1143
+ .games-delete-btn {
1144
+ color: #d66;
1145
+ }
1146
+
1147
+ /* срок полного удаления — единственное, что в карточке удалённой игры
1148
+ ограничено во времени */
1149
+ .games-purge-line {
1150
+ color: #d66;
1151
+ }
1152
+
1144
1153
  /* кнопки входа в панель стоят рядом с Sign out в бейдже пользователя */
1145
1154
  .lobby-user-actions {
1146
1155
  display: flex;
@@ -1150,7 +1159,8 @@ body.hide-cursor {
1150
1159
 
1151
1160
  @media (max-width: 900px) {
1152
1161
  #games-mine,
1153
- #games-moderation {
1162
+ #games-moderation,
1163
+ .games-head {
1154
1164
  width: 100%;
1155
1165
  }
1156
1166
  }
@@ -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
  // манифесты застейдженных версий — по ним админ поднимает тестовую
@@ -212,19 +218,26 @@ export default {
212
218
  staged: '/admin/games/manifest.json',
213
219
  // «Test»: скачать версию и положить её в каталог не раздаваемой
214
220
  stage: id => `/admin/games/${encodeURIComponent(id)}/stage`,
221
+ // возврат мягко удалённой игры из графы Deleted (только админ)
222
+ restore: id => `/admin/games/${encodeURIComponent(id)}/restore`,
215
223
  // решение модератора
216
224
  moderate: id => `/admin/games/${encodeURIComponent(id)}`,
217
225
  // что опубликовано в npm — индикатор «есть версия новее»
218
226
  versions: id => `/admin/games/${encodeURIComponent(id)}/versions`,
219
227
  },
220
228
 
221
- // фильтры очереди модерации: id статуса реестра -> подпись кнопки.
222
- // Значения обязаны совпадать со статусами auth-сервиса
229
+ // фильтры («графы») очереди модерации: id -> подпись кнопки. Первые
230
+ // четыре — статусы реестра, и значения обязаны совпадать со статусами
231
+ // auth-сервиса. Пятая графа стоит особняком: 'deleted' статусом НЕ
232
+ // является (мягко удалённая игра сохраняет свой прежний статус, иначе
233
+ // восстанавливать было бы не во что) — под неё попадает всё с
234
+ // непустым deletedAt, см. GamesModel._bucketOf
223
235
  statuses: [
224
236
  { id: 'pending', title: 'Pending' },
225
237
  { id: 'approved', title: 'Published' },
226
238
  { id: 'rejected', title: 'Rejected' },
227
239
  { id: 'disabled', title: 'Disabled' },
240
+ { id: 'deleted', title: 'Deleted' },
228
241
  ],
229
242
  defaultStatus: 'pending',
230
243
 
@@ -247,13 +260,21 @@ export default {
247
260
  submitFormId: 'games-submit-form',
248
261
  submitErrorId: 'games-submit-error',
249
262
  submitBtnId: 'games-submit',
263
+ // форма спрашивает ровно две вещи; id, title и репозиторий приезжают
264
+ // предпросмотром из разобранного пакета
250
265
  fieldIds: {
251
- id: 'games-field-id',
252
266
  packageName: 'games-field-package',
253
267
  version: 'games-field-version',
254
- repoUrl: 'games-field-repo',
255
- title: 'games-field-title',
256
268
  },
269
+ lookupBtnId: 'games-lookup',
270
+ previewId: 'games-preview',
271
+ versionListId: 'games-version-list',
272
+
273
+ // карточки панели показываются по одной; заголовок общий, как и
274
+ // переключатель страниц (виден только админу)
275
+ mineId: 'games-mine',
276
+ titleId: 'games-title',
277
+ switchBtnId: 'games-switch',
257
278
 
258
279
  // модерация
259
280
  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` + путь