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 +1 -1
- package/src/client/components/controller/Games.js +17 -1
- package/src/client/components/model/Games.js +54 -0
- package/src/client/components/view/Games.js +193 -4
- package/src/client/lib/catalogState.js +44 -0
- package/src/client/main.js +112 -21
- package/src/client/style.css +21 -2
- package/src/client/views/gameShell.js +31 -0
- package/src/client/views/includes/games.pug +14 -14
- package/src/config/env.js +0 -6
- package/src/config/lobby.js +23 -3
- package/src/config/master.js +5 -5
- package/src/devtools/pluginLoader.js +4 -2
- package/src/lib/loadGamePackage.js +78 -11
package/package.json
CHANGED
|
@@ -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
|
-
|
|
76
|
-
|
|
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;
|
package/src/client/main.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
195
|
+
if (activeGameManifest) {
|
|
196
|
+
bindActiveGame(
|
|
197
|
+
activeGameManifest,
|
|
198
|
+
boot.clientPlugin ?? (await loadClientPlugin(activeGameManifest)),
|
|
199
|
+
);
|
|
200
|
+
}
|
|
172
201
|
} catch (e) {
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2074
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
2189
|
-
lobby.gameChanged(activeGameManifest.id, activeGameManifest.title);
|
|
2280
|
+
syncCatalogState();
|
|
2190
2281
|
|
|
2191
2282
|
// селектор игр: меняет форму создания комнаты и Leaderboard сразу
|
|
2192
2283
|
// (синхронно, без сети); сама игра активируется уже по клику
|
package/src/client/style.css
CHANGED
|
@@ -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
|
-
|
|
24
|
-
|
|
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) {
|
package/src/config/lobby.js
CHANGED
|
@@ -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,
|
package/src/config/master.js
CHANGED
|
@@ -23,11 +23,11 @@ export default {
|
|
|
23
23
|
// auth-сервиса, откуда мастер скачивает одобренные пакеты сам
|
|
24
24
|
// (master/GameSync.js). Движок игр не знает и зависимостями их не тянет.
|
|
25
25
|
//
|
|
26
|
-
// Массив и
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
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
|
-
* @
|
|
25
|
-
*
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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` + путь
|