vimp-engine 0.24.2 → 0.25.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.24.2",
3
+ "version": "0.25.0",
4
4
  "description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -46,6 +46,7 @@
46
46
  "express": "^5.1.0",
47
47
  "howler": "^2.2.4",
48
48
  "pixi.js": "8.19.0",
49
+ "tar": "^7.5.1",
49
50
  "uuid": "^13.0.0",
50
51
  "vite-express": "^0.21.1",
51
52
  "ws": "^8.18.3"
@@ -0,0 +1,99 @@
1
+ import Publisher from '../../../lib/Publisher.js';
2
+
3
+ // Singleton GamesCtrl
4
+
5
+ let gamesCtrl;
6
+
7
+ // Контроллер реестра игр: связывает view-события с моделью, как LobbyCtrl.
8
+ // Собственного сетевого I/O не делает. Наружу (main.js) пробрасывает только
9
+ // 'staged' — манифест застейдженной версии, который нужно положить в каталог
10
+ // вкладки, чтобы админ мог поднять по нему комнату
11
+ export default class GamesCtrl {
12
+ constructor(model, view) {
13
+ if (gamesCtrl) {
14
+ return gamesCtrl;
15
+ }
16
+
17
+ gamesCtrl = this;
18
+
19
+ this._model = model;
20
+ this._view = view;
21
+
22
+ this.publisher = new Publisher();
23
+
24
+ const vp = view.publisher;
25
+
26
+ vp.on('open-mine', 'openMine', this);
27
+ vp.on('open-moderation', 'openModeration', this);
28
+ vp.on('submit', 'submit', this);
29
+ vp.on('update-version', 'updateVersion', this);
30
+ vp.on('filter', 'filter', this);
31
+ vp.on('stage', 'stage', this);
32
+ vp.on('approve', 'approve', this);
33
+ vp.on('reject', 'reject', this);
34
+ vp.on('disable', 'disable', this);
35
+ vp.on('load-versions', 'loadVersions', this);
36
+
37
+ model.publisher.on('staged', 'staged', this);
38
+ }
39
+
40
+ // роль решает только видимость кнопки модерации: доступ к данным
41
+ // проверяет мастер, а запись — auth-сервис по роли из БД
42
+ setAdmin(isAdmin) {
43
+ this._view.setAdmin(isAdmin);
44
+
45
+ // черновики, уже скачанные этим мастером, возвращаются в каталог вкладки
46
+ // сразу: после матча лобби перезагружает страницу, и без этого админ
47
+ // терял бы из селектора версию, которую сам же поставил на тест
48
+ if (isAdmin) {
49
+ this._model.loadStaged();
50
+ }
51
+ }
52
+
53
+ openMine() {
54
+ this._view.show(false);
55
+ this._model.loadMine();
56
+ }
57
+
58
+ openModeration() {
59
+ this._view.show(true);
60
+ this._model.loadMine();
61
+ this._model.loadAdmin();
62
+ }
63
+
64
+ submit(form) {
65
+ this._model.submit(form);
66
+ }
67
+
68
+ updateVersion({ id, version }) {
69
+ this._model.requestVersion(id, version);
70
+ }
71
+
72
+ filter(status) {
73
+ this._model.setFilter(status);
74
+ }
75
+
76
+ stage({ id, version }) {
77
+ this._model.stage(id, version);
78
+ }
79
+
80
+ approve({ id }) {
81
+ this._model.moderate(id, { status: 'approved' });
82
+ }
83
+
84
+ reject({ id, note }) {
85
+ this._model.moderate(id, { status: 'rejected', note });
86
+ }
87
+
88
+ disable({ id }) {
89
+ this._model.moderate(id, { status: 'disabled' });
90
+ }
91
+
92
+ loadVersions({ id }) {
93
+ this._model.loadVersions(id);
94
+ }
95
+
96
+ staged(data) {
97
+ this.publisher.emit('staged', data);
98
+ }
99
+ }
@@ -0,0 +1,209 @@
1
+ import Publisher from '../../../lib/Publisher.js';
2
+
3
+ // Singleton GamesModel
4
+
5
+ let gamesModel;
6
+
7
+ // коды отказов мастера и auth-сервиса — единственное, что приезжает вместо
8
+ // текста; человеческая формулировка живёт во view, как в LobbyAuthView
9
+ const REQUEST_FAILED = 'requestFailed';
10
+
11
+ // Модель реестра игр в лобби (master-game-registry, этап 4): заявки
12
+ // вызывающего, очередь модерации и «Test» новой версии. В отличие от
13
+ // LobbyModel сетевые запросы делает сама — это простой REST к мастеру,
14
+ // сигнальный сокет здесь ни при чём (тот же приём, что у LobbyAuthModel с
15
+ // POST /nick). DOM не трогает.
16
+ export default class GamesModel {
17
+ /**
18
+ * @param {Object} config - Блок `games` конфига лобби (urls, statuses).
19
+ * @param {Function} getToken - Возвращает identity-токен лобби.
20
+ */
21
+ constructor(config, getToken) {
22
+ if (gamesModel) {
23
+ return gamesModel;
24
+ }
25
+
26
+ gamesModel = this;
27
+
28
+ this._config = config;
29
+ this._getToken = getToken;
30
+
31
+ this._mine = [];
32
+ this._all = [];
33
+ this._versions = new Map(); // id игры -> версии, опубликованные в npm
34
+ this._filter = config.defaultStatus;
35
+
36
+ this.publisher = new Publisher();
37
+ }
38
+
39
+ getFilter() {
40
+ return this._filter;
41
+ }
42
+
43
+ // фильтр очереди модерации: строки уже загружены, повторный запрос не нужен
44
+ setFilter(status) {
45
+ this._filter = status;
46
+ this._emitAdmin();
47
+ }
48
+
49
+ async loadMine() {
50
+ const { ok, json } = await this._request(this._config.urls.mine);
51
+
52
+ if (!ok) {
53
+ this._fail('mine', json);
54
+ return;
55
+ }
56
+
57
+ this._mine = json.games ?? [];
58
+ this.publisher.emit('mine-changed', this._mine);
59
+ }
60
+
61
+ // застейдженные версии, уже лежащие на этом мастере: после перезагрузки
62
+ // вкладки (а lobby-режим перезагружает её после каждого матча) черновик
63
+ // иначе исчезал бы из селектора, оставаясь скачанным
64
+ async loadStaged() {
65
+ const { ok, json } = await this._request(this._config.urls.staged);
66
+
67
+ if (!ok) {
68
+ return;
69
+ }
70
+
71
+ (json.manifests ?? []).forEach(({ id, version, manifest }) => {
72
+ this.publisher.emit('staged', { id, version, manifest });
73
+ });
74
+ }
75
+
76
+ async loadAdmin() {
77
+ const { ok, json } = await this._request(this._config.urls.admin);
78
+
79
+ if (!ok) {
80
+ this._fail('admin', json);
81
+ return;
82
+ }
83
+
84
+ this._all = json.games ?? [];
85
+ this._emitAdmin();
86
+ }
87
+
88
+ // список опубликованных версий пакета: индикатор «есть версия новее
89
+ // раздаваемой». Запрашивается по игре, а не для всей очереди сразу —
90
+ // каждый ответ стоит похода мастера в npm registry
91
+ async loadVersions(id) {
92
+ const { ok, json } = await this._request(this._config.urls.versions(id));
93
+
94
+ if (!ok) {
95
+ return;
96
+ }
97
+
98
+ this._versions.set(id, json.versions ?? []);
99
+ this._emitAdmin();
100
+ }
101
+
102
+ async submit(form) {
103
+ const { ok, json } = await this._request(this._config.urls.submit, {
104
+ method: 'POST',
105
+ body: form,
106
+ });
107
+
108
+ if (!ok) {
109
+ this._fail('mine', json);
110
+ return;
111
+ }
112
+
113
+ // отдельным событием, а не внутри loadMine: список перерисовывается и
114
+ // при открытии панели, а чистить форму нужно ровно на успехе отправки —
115
+ // иначе повторная отправка того же содержимого выглядит как «кнопка не
116
+ // сработала» (ответ gameExists при заполненных полях)
117
+ this.publisher.emit('submitted');
118
+
119
+ await this.loadMine();
120
+ }
121
+
122
+ async requestVersion(id, version) {
123
+ const { ok, json } = await this._request(this._config.urls.version(id), {
124
+ method: 'POST',
125
+ body: { version },
126
+ });
127
+
128
+ if (!ok) {
129
+ this._fail('mine', json);
130
+ return;
131
+ }
132
+
133
+ await this.loadMine();
134
+ }
135
+
136
+ // «Test»: мастер качает версию и кладёт её в каталог не раздаваемой.
137
+ // Манифест едет наружу — по нему вкладка админа поднимает комнату
138
+ async stage(id, version) {
139
+ const { ok, json } = await this._request(this._config.urls.stage(id), {
140
+ method: 'POST',
141
+ body: { version },
142
+ });
143
+
144
+ if (!ok) {
145
+ this._fail('admin', json);
146
+ return;
147
+ }
148
+
149
+ this.publisher.emit('staged', { id, version: json.version, manifest: json.manifest });
150
+ await this.loadAdmin();
151
+ }
152
+
153
+ async moderate(id, patch) {
154
+ const { ok, json } = await this._request(this._config.urls.moderate(id), {
155
+ method: 'PATCH',
156
+ body: patch,
157
+ });
158
+
159
+ if (!ok) {
160
+ this._fail('admin', json);
161
+ return;
162
+ }
163
+
164
+ await this.loadAdmin();
165
+ }
166
+
167
+ _emitAdmin() {
168
+ this.publisher.emit('admin-changed', {
169
+ games: this._all.filter(game => game.status === this._filter),
170
+ filter: this._filter,
171
+ versions: this._versions,
172
+ });
173
+ }
174
+
175
+ // ошибки приезжают двумя формами: список проблем пакета от мастера
176
+ // ({errors: [...]}, структурная проверка до записи в реестр) и код отказа
177
+ // auth-сервиса ({error: '...'}). Наружу уходит одна
178
+ _fail(scope, json) {
179
+ const errors = Array.isArray(json?.errors)
180
+ ? json.errors.map(error => ({ name: 'package', error }))
181
+ : [{ name: 'request', error: json?.error ?? REQUEST_FAILED }];
182
+
183
+ this.publisher.emit('error', { scope, errors });
184
+ }
185
+
186
+ async _request(url, { method = 'GET', body } = {}) {
187
+ const token = this._getToken();
188
+
189
+ if (!token) {
190
+ return { ok: false, json: { error: 'unauthorized' } };
191
+ }
192
+
193
+ try {
194
+ const res = await fetch(url, {
195
+ method,
196
+ headers: {
197
+ authorization: `Bearer ${token}`,
198
+ ...(body ? { 'content-type': 'application/json' } : {}),
199
+ },
200
+ body: body ? JSON.stringify(body) : undefined,
201
+ });
202
+ const json = await res.json().catch(() => null);
203
+
204
+ return { ok: res.ok, json };
205
+ } catch {
206
+ return { ok: false, json: { error: 'network' } };
207
+ }
208
+ }
209
+ }
@@ -117,6 +117,14 @@ export default class LobbyAuthModel {
117
117
  return this._nick;
118
118
  }
119
119
 
120
+ // роль из payload уже проверенного на срок токена (master-game-registry,
121
+ // этап 4). Это ПОДСКАЗКА ИНТЕРФЕЙСУ — показывать ли кнопку модерации, — а
122
+ // не право: доступ к данным проверяет мастер по подписи, а запись —
123
+ // auth-сервис, перечитывая роль из БД
124
+ getRole() {
125
+ return decodeJwtPayload(this._identityToken)?.role ?? 'user';
126
+ }
127
+
120
128
  _restore(tokenStorageKey) {
121
129
  const token = localStorage[tokenStorageKey];
122
130
 
@@ -0,0 +1,341 @@
1
+ import Publisher from '../../../lib/Publisher.js';
2
+ import { renderFormErrors } from '../../lib/formBuilder.js';
3
+
4
+ // Singleton GamesView
5
+
6
+ let gamesView;
7
+
8
+ // коды отказов мастера/auth — человеческая формулировка живёт здесь, как в
9
+ // LobbyAuthView: модель кодов не переводит, view не ходит в сеть.
10
+ // Язык интерфейса — английский, как и в остальном лобби
11
+ const ERROR_MESSAGES = {
12
+ unauthorized: 'Please sign in again',
13
+ forbidden: 'Not enough rights',
14
+ network: 'Network unavailable, try again',
15
+ requestFailed: 'Request failed',
16
+ // лимитер мастера (5 заявок в минуту на пользователя) считает и заявки,
17
+ // отклонённые по формату, — без своей строки код уезжал бы в интерфейс сырым
18
+ tooManyRequests: 'Too many requests, try again in a minute',
19
+ gameExists: 'A game with this id already exists',
20
+ tooManyGames: 'Too many submissions from one author',
21
+ unknownGame: 'Game is not in the registry',
22
+ invalidGameId: 'Invalid game id',
23
+ invalidPackageName: 'Invalid npm package name',
24
+ invalidVersion: 'Invalid version',
25
+ invalidTitle: 'Invalid title',
26
+ invalidRepoUrl: 'Invalid repository URL',
27
+ invalidMaxGameScore: 'Invalid score cap',
28
+ authServiceUnavailable: 'Registry service unavailable',
29
+ };
30
+
31
+ const STATUS_TITLES = {
32
+ pending: 'in review',
33
+ approved: 'published',
34
+ rejected: 'rejected',
35
+ disabled: 'disabled',
36
+ };
37
+
38
+ // Представление реестра игр: списки заявок и очереди модерации, фильтры,
39
+ // строки ошибок. В сеть не ходит и состояния не держит — рисует то, что
40
+ // пришло событием модели, и публикует намерения пользователя
41
+ export default class GamesView {
42
+ /**
43
+ * @param {Object} model - GamesModel (источник событий).
44
+ * @param {Object} config - Блок `games` конфига лобби.
45
+ */
46
+ constructor(model, config) {
47
+ if (gamesView) {
48
+ return gamesView;
49
+ }
50
+
51
+ gamesView = this;
52
+
53
+ const { elems } = config;
54
+
55
+ this._config = config;
56
+ this._elems = elems;
57
+
58
+ this._panel = document.getElementById(elems.panelId);
59
+ this._lobby = document.getElementById(elems.lobbyId);
60
+ this._openMine = document.getElementById(elems.openMineBtnId);
61
+ this._openModeration = document.getElementById(elems.openModerationBtnId);
62
+ this._close = document.getElementById(elems.closeBtnId);
63
+
64
+ this._mineList = document.getElementById(elems.mineListId);
65
+ this._submitForm = document.getElementById(elems.submitFormId);
66
+ this._submitError = document.getElementById(elems.submitErrorId);
67
+
68
+ this._moderation = document.getElementById(elems.moderationId);
69
+ this._adminList = document.getElementById(elems.adminListId);
70
+ this._adminError = document.getElementById(elems.adminErrorId);
71
+ this._filters = document.getElementById(elems.filtersId);
72
+
73
+ this._fields = new Map(
74
+ Object.entries(elems.fieldIds).map(([name, id]) => [
75
+ name,
76
+ document.getElementById(id),
77
+ ]),
78
+ );
79
+
80
+ this.publisher = new Publisher();
81
+
82
+ this._openMine.onclick = () => this.publisher.emit('open-mine');
83
+ this._openModeration.onclick = () => this.publisher.emit('open-moderation');
84
+ this._close.onclick = () => this.hide();
85
+
86
+ this._submitForm.onsubmit = e => {
87
+ e.preventDefault();
88
+ this.publisher.emit('submit', this._readForm());
89
+ };
90
+
91
+ this._renderFilters();
92
+
93
+ const mp = model.publisher;
94
+
95
+ mp.on('submitted', 'clearForm', this);
96
+ mp.on('mine-changed', 'renderMine', this);
97
+ mp.on('admin-changed', 'renderAdmin', this);
98
+ mp.on('error', 'renderError', this);
99
+ }
100
+
101
+ // кнопку модерации показывает не сама панель, а роль вызывающего
102
+ // (main.js по LobbyAuthModel.getRole())
103
+ setAdmin(isAdmin) {
104
+ this._openModeration.style.display = isAdmin ? '' : 'none';
105
+ }
106
+
107
+ show(moderation = false) {
108
+ this._panel.style.display = 'flex';
109
+ this._lobby.style.display = 'none';
110
+ this._moderation.style.display = moderation ? 'block' : 'none';
111
+ }
112
+
113
+ hide() {
114
+ // закрытой панели закрывать нечего: возврат черновиков после
115
+ // перезагрузки идёт тем же событием 'staged', что и «Test», и не должен
116
+ // трогать разметку лобби
117
+ if (this._panel.style.display === 'none') {
118
+ return;
119
+ }
120
+
121
+ this._panel.style.display = 'none';
122
+ this._lobby.style.display = 'flex';
123
+ }
124
+
125
+ // заявка ушла — поля пустые: следующая отправка начинается с чистой формы
126
+ clearForm() {
127
+ this._fields.forEach(field => {
128
+ field.value = '';
129
+ });
130
+ }
131
+
132
+ renderMine(games) {
133
+ this._submitError.textContent = '';
134
+ this._mineList.textContent = '';
135
+
136
+ (games || []).forEach(game => {
137
+ const item = document.createElement('li');
138
+
139
+ item.className = 'games-item';
140
+ item.appendChild(
141
+ this._line(
142
+ `${game.title ? `${game.title} · ` : ''}${game.id} — ` +
143
+ `${game.packageName} @ ${game.version ?? '—'}`,
144
+ 'games-item-title',
145
+ ),
146
+ );
147
+ item.appendChild(this._line(this._statusLine(game)));
148
+ this._appendRepo(item, game.repoUrl);
149
+
150
+ if (game.moderatorNote) {
151
+ item.appendChild(this._line(`Note: ${game.moderatorNote}`));
152
+ }
153
+
154
+ // заявка на новую версию своей игры: поле рядом со строкой, а не
155
+ // отдельной формой — версия относится к конкретной заявке
156
+ const version = document.createElement('input');
157
+ const send = document.createElement('input');
158
+
159
+ version.type = 'text';
160
+ version.className = 'field-text games-version-input';
161
+ version.placeholder = 'New version';
162
+ send.type = 'button';
163
+ send.value = 'Update version';
164
+ send.onclick = () =>
165
+ this.publisher.emit('update-version', { id: game.id, version: version.value.trim() });
166
+
167
+ item.appendChild(version);
168
+ item.appendChild(send);
169
+ this._mineList.appendChild(item);
170
+ });
171
+ }
172
+
173
+ renderAdmin({ games, filter, versions }) {
174
+ this._adminError.textContent = '';
175
+ this._adminList.textContent = '';
176
+ this._markFilter(filter);
177
+
178
+ (games || []).forEach(game => {
179
+ this._adminList.appendChild(this._adminItem(game, versions));
180
+ });
181
+ }
182
+
183
+ renderError({ scope, errors }) {
184
+ const container = scope === 'admin' ? this._adminError : this._submitError;
185
+
186
+ renderFormErrors(
187
+ container,
188
+ errors.map(({ name, error }) => ({
189
+ name,
190
+ label: name,
191
+ error: ERROR_MESSAGES[error] ?? error,
192
+ })),
193
+ );
194
+ }
195
+
196
+ _adminItem(game, versions) {
197
+ const item = document.createElement('li');
198
+ const published = versions?.get(game.id) ?? [];
199
+ const latest = published[published.length - 1];
200
+
201
+ item.className = 'games-item';
202
+ item.appendChild(
203
+ this._line(
204
+ `${game.title ? `${game.title} · ` : ''}${game.id} — ${game.packageName}`,
205
+ 'games-item-title',
206
+ ),
207
+ );
208
+ // у игр, засеянных миграцией, автора нет вовсе: без запасного прочерка
209
+ // в строке печаталось бы литеральное "null"
210
+ item.appendChild(this._line(`Author: ${game.authorNick ?? game.authorUserId ?? '—'}`));
211
+ this._appendRepo(item, game.repoUrl);
212
+ item.appendChild(
213
+ this._line(
214
+ `Served: ${game.version ?? '—'}; requested: ${game.pendingVersion ?? '—'}` +
215
+ (latest ? `; in npm: ${latest}` : ''),
216
+ ),
217
+ );
218
+ item.appendChild(this._line(this._statusLine(game)));
219
+
220
+ if (game.local) {
221
+ item.appendChild(
222
+ this._line(
223
+ `On this master: ${game.local.downloaded ? 'downloaded' : 'not downloaded'}` +
224
+ (game.local.stagedVersion ? `; staged ${game.local.stagedVersion}` : '') +
225
+ (game.local.lastError ? `; error: ${game.local.lastError}` : ''),
226
+ ),
227
+ );
228
+ }
229
+
230
+ const note = document.createElement('input');
231
+
232
+ note.type = 'text';
233
+ note.className = 'field-text games-note-input';
234
+ note.placeholder = 'Rejection reason';
235
+
236
+ item.appendChild(note);
237
+ item.appendChild(
238
+ this._button('Test', () =>
239
+ this.publisher.emit('stage', {
240
+ id: game.id,
241
+ version: game.pendingVersion ?? game.version,
242
+ }),
243
+ ),
244
+ );
245
+ item.appendChild(
246
+ this._button('Approve', () => this.publisher.emit('approve', { id: game.id })),
247
+ );
248
+ item.appendChild(
249
+ this._button('Reject', () =>
250
+ this.publisher.emit('reject', { id: game.id, note: note.value.trim() }),
251
+ ),
252
+ );
253
+ item.appendChild(
254
+ this._button('Disable', () => this.publisher.emit('disable', { id: game.id })),
255
+ );
256
+ item.appendChild(
257
+ this._button('npm versions', () =>
258
+ this.publisher.emit('load-versions', { id: game.id }),
259
+ ),
260
+ );
261
+
262
+ return item;
263
+ }
264
+
265
+ // Ссылка на репозиторий игры. Протокол проверяется и здесь, хотя auth уже
266
+ // принимает только http(s): href — единственное место представления, где
267
+ // содержимое поля становится исполняемым (javascript:), и полагаться на
268
+ // одну проверку на другой стороне сети тут не стоит
269
+ _appendRepo(item, url) {
270
+ if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) {
271
+ return;
272
+ }
273
+
274
+ const line = document.createElement('div');
275
+ const link = document.createElement('a');
276
+
277
+ link.href = url;
278
+ link.textContent = url;
279
+ link.target = '_blank';
280
+ link.rel = 'noopener noreferrer';
281
+ line.appendChild(link);
282
+ item.appendChild(line);
283
+ }
284
+
285
+ _statusLine(game) {
286
+ const status = STATUS_TITLES[game.status] ?? game.status;
287
+ const date = game.createdAt ? new Date(game.createdAt).toLocaleDateString() : '';
288
+
289
+ return date ? `Status: ${status} (submitted ${date})` : `Status: ${status}`;
290
+ }
291
+
292
+ _renderFilters() {
293
+ this._filterButtons = new Map();
294
+
295
+ this._config.statuses.forEach(({ id, title }) => {
296
+ const btn = this._button(title, () => this.publisher.emit('filter', id));
297
+
298
+ btn.className = 'games-filter-btn';
299
+ this._filterButtons.set(id, btn);
300
+ this._filters.appendChild(btn);
301
+ });
302
+ }
303
+
304
+ _markFilter(filter) {
305
+ this._filterButtons.forEach((btn, id) => {
306
+ btn.classList.toggle('active', id === filter);
307
+ });
308
+ }
309
+
310
+ _button(value, onclick) {
311
+ const btn = document.createElement('input');
312
+
313
+ btn.type = 'button';
314
+ btn.value = value;
315
+ btn.onclick = onclick;
316
+
317
+ return btn;
318
+ }
319
+
320
+ _line(text, className) {
321
+ const line = document.createElement('div');
322
+
323
+ line.textContent = text;
324
+
325
+ if (className) {
326
+ line.className = className;
327
+ }
328
+
329
+ return line;
330
+ }
331
+
332
+ _readForm() {
333
+ const form = {};
334
+
335
+ this._fields.forEach((field, name) => {
336
+ form[name] = field.value.trim();
337
+ });
338
+
339
+ return form;
340
+ }
341
+ }
@@ -8,8 +8,14 @@
8
8
  // Вынесено из main.js (бутстрап, тестами не покрывается) отдельным модулем,
9
9
  // чтобы кеш и обработка отказа проверялись юнит-тестом.
10
10
  export function createGameActivator({ gamesById, loadClientPlugin }) {
11
- // gameId -> промис загрузки: кешируется именно промис, иначе два быстрых
12
- // клика подряд запустили бы импорт дважды
11
+ // `${gameId}@${manifest.version}` -> промис загрузки: кешируется именно
12
+ // промис, иначе два быстрых клика подряд запустили бы импорт дважды.
13
+ //
14
+ // Версия в ключе обязательна (master-game-registry, этап 3): в каталоге
15
+ // мастера две версии одной игры живут одновременно (админ стейджит новую,
16
+ // игроки играют в одобренную), и ключ по одному gameId вернул бы уже
17
+ // выполненный import() НЕ ТОГО кода. manifest.version — хеш бандла, то
18
+ // есть идентификатор самого кода
13
19
  const plugins = new Map();
14
20
 
15
21
  return async function activateGame(gameId) {
@@ -19,18 +25,20 @@ export function createGameActivator({ gamesById, loadClientPlugin }) {
19
25
  throw new Error(`unknown game "${gameId}"`);
20
26
  }
21
27
 
22
- let pending = plugins.get(gameId);
28
+ const key = `${gameId}@${manifest.version}`;
29
+
30
+ let pending = plugins.get(key);
23
31
 
24
32
  if (!pending) {
25
33
  pending = loadClientPlugin(manifest).catch(e => {
26
34
  // отказ не кешируем: сеть могла моргнуть, повторный клик обязан
27
35
  // попробовать снова, а не переигрывать ту же ошибку вечно
28
- plugins.delete(gameId);
36
+ plugins.delete(key);
29
37
 
30
38
  throw e;
31
39
  });
32
40
 
33
- plugins.set(gameId, pending);
41
+ plugins.set(key, pending);
34
42
  }
35
43
 
36
44
  return { manifest, plugin: await pending };
@@ -73,6 +73,9 @@ import LobbyCtrl from './components/controller/Lobby.js';
73
73
  import LobbyAuthModel from './components/model/LobbyAuth.js';
74
74
  import LobbyAuthView from './components/view/LobbyAuth.js';
75
75
  import LobbyAuthCtrl from './components/controller/LobbyAuth.js';
76
+ import GamesModel from './components/model/Games.js';
77
+ import GamesView from './components/view/Games.js';
78
+ import GamesCtrl from './components/controller/Games.js';
76
79
  import BakingProvider from './providers/BakingProvider.js';
77
80
  import DependencyProvider from './providers/DependencyProvider.js';
78
81
  import { HOT_FLAGS } from '../config/opcodes.js';
@@ -1718,8 +1721,7 @@ let hostRegistration = null;
1718
1721
  // Этап 5.1/6.4: скачивает каталог карт мастера активной игры (манифест +
1719
1722
  // все карты)
1720
1723
  async function fetchMasterMaps() {
1721
- const gameId = activeGameManifest.id;
1722
- const manifestRes = await fetch(lobbyConfig.maps.manifestUrl(gameId));
1724
+ const manifestRes = await fetch(lobbyConfig.maps.manifestUrl(activeGameManifest));
1723
1725
 
1724
1726
  if (!manifestRes.ok) {
1725
1727
  throw new Error(`maps manifest: HTTP ${manifestRes.status}`);
@@ -1729,7 +1731,7 @@ async function fetchMasterMaps() {
1729
1731
 
1730
1732
  const entries = await Promise.all(
1731
1733
  manifest.maps.map(async name => {
1732
- const url = `${lobbyConfig.maps.baseUrl(gameId)}/${encodeURIComponent(name)}`;
1734
+ const url = `${lobbyConfig.maps.baseUrl(activeGameManifest)}/${encodeURIComponent(name)}`;
1733
1735
  const res = await fetch(url);
1734
1736
 
1735
1737
  if (!res.ok) {
@@ -1840,8 +1842,16 @@ async function fetchServers({ offset, limit, search }) {
1840
1842
  params.set('limit', limit);
1841
1843
  }
1842
1844
 
1845
+ // токен едет и сюда (master-game-registry, этап 4): список серверов
1846
+ // публичный, но админу мастер отдаёт вместе с ним скрытые тестовые
1847
+ // комнаты застейдженных версий
1848
+ const token = lobbyAuthModel?.getToken();
1849
+
1843
1850
  try {
1844
- const res = await fetch(`${lobbyConfig.serversUrl}?${params}`);
1851
+ const res = await fetch(
1852
+ `${lobbyConfig.serversUrl}?${params}`,
1853
+ token ? { headers: { authorization: `Bearer ${token}` } } : undefined,
1854
+ );
1845
1855
 
1846
1856
  return res.ok ? await res.json() : null;
1847
1857
  } catch (e) {
@@ -1939,6 +1949,11 @@ function populateRoomForm(manifest) {
1939
1949
  }
1940
1950
  }
1941
1951
 
1952
+ // роли, которым лобби показывает кнопку «Moderation» (master-game-registry,
1953
+ // этап 4). Это подсказка интерфейсу: доступ к данным проверяет мастер, а
1954
+ // запись — auth-сервис, перечитывая роль из БД
1955
+ const ADMIN_ROLES = ['admin', 'superadmin'];
1956
+
1942
1957
  // каталог манифестов по id: форма и leaderboard селектора игр, а также
1943
1958
  // активация игры перед созданием комнаты и входом в чужую
1944
1959
  const gamesById = new Map(
@@ -1972,7 +1987,10 @@ function clearLobbyError() {
1972
1987
  // активирует игру и перепривязывает манифест/плагин/CSS; false — отказ уже
1973
1988
  // показан игроку, лобби остаётся рабочим
1974
1989
  async function selectActiveGame(gameId) {
1975
- if (gameId === activeGameManifest.id) {
1990
+ // сверка идёт с самим манифестом, а не с его id: каталог вкладки может
1991
+ // пополниться застейдженной версией той же игры (registerGameManifest), и
1992
+ // сравнение по id оставило бы активной одобренную версию
1993
+ if (gameId === activeGameManifest.id && gamesById.get(gameId) === activeGameManifest) {
1976
1994
  return true;
1977
1995
  }
1978
1996
 
@@ -1993,8 +2011,36 @@ async function selectActiveGame(gameId) {
1993
2011
  }
1994
2012
  }
1995
2013
 
2014
+ // id игр, чей манифест в каталоге вкладки подменён застейдженной версией
2015
+ // (master-game-registry, этап 4): в селекторе такая игра помечается, чтобы
2016
+ // админ видел, что поднимет комнату НЕ на одобренной версии
2017
+ const stagedGameIds = new Set();
2018
+
2019
+ /**
2020
+ * Кладёт манифест застейдженной (не одобренной) версии в каталог вкладки —
2021
+ * так админ может поднять по нему комнату, не трогая каталог игроков.
2022
+ * Дальше всё работает существующим путём: createGameActivator грузит
2023
+ * ClientPlugin по entries.client (ключ кеша версионный, поэтому плагин
2024
+ * черновика не подменит одобренный), connectAsHost поднимает комнату, а
2025
+ * мастер помечает её скрытой по версии манифеста.
2026
+ * @param {Object} manifest - Манифест застейдженной версии из /admin/games.
2027
+ * @returns {void}
2028
+ */
2029
+ function registerGameManifest(manifest) {
2030
+ if (!manifest?.id) {
2031
+ return;
2032
+ }
2033
+
2034
+ gamesById.set(manifest.id, manifest);
2035
+ stagedGameIds.add(manifest.id);
2036
+ populateGameSelect();
2037
+ }
2038
+
1996
2039
  // заполняет #lobby-game всем каталогом манифестов мастера — раньше туда
1997
- // попадал только gamesManifest[0], теперь селектор рабочий
2040
+ // попадал только gamesManifest[0], теперь селектор рабочий.
2041
+ // Источник — gamesById, а не gamesManifest: каталог вкладки пополняется на
2042
+ // лету застейдженными версиями (registerGameManifest), и селектор
2043
+ // перерисовывается целиком
1998
2044
  function populateGameSelect() {
1999
2045
  const gameSelect = document.getElementById(lobbyConfig.elems.gameId);
2000
2046
 
@@ -2002,7 +2048,13 @@ function populateGameSelect() {
2002
2048
  return;
2003
2049
  }
2004
2050
 
2005
- gamesManifest.forEach(manifest => {
2051
+ // перерисовка не должна сбрасывать выбор игрока: селектор целиком
2052
+ // пересобирается и при добавлении застейдженной версии
2053
+ const selected = gameSelect.value;
2054
+
2055
+ gameSelect.textContent = '';
2056
+
2057
+ gamesById.forEach(manifest => {
2006
2058
  const option = document.createElement('option');
2007
2059
  const available = isGameAvailable(manifest);
2008
2060
 
@@ -2011,14 +2063,15 @@ function populateGameSelect() {
2011
2063
  // каталога, и игрок видел пустое лобби без причины), но выбрать её
2012
2064
  // нельзя: комнату по ней всё равно не поднять
2013
2065
  option.textContent = available
2014
- ? manifest.title
2066
+ ? `${manifest.title}${stagedGameIds.has(manifest.id) ? lobbyConfig.games.stagedSuffix : ''}`
2015
2067
  : `${manifest.title} — unavailable`;
2016
2068
  option.disabled = !available;
2017
2069
  option.title = available ? '' : manifest.compat.text;
2018
2070
  gameSelect.appendChild(option);
2019
2071
  });
2020
2072
 
2021
- gameSelect.value = activeGameManifest.id;
2073
+ gameSelect.value =
2074
+ selected && gamesById.has(selected) ? selected : activeGameManifest.id;
2022
2075
  }
2023
2076
 
2024
2077
  // поднимает лобби после welcome от мастера (iceServers уже получены);
@@ -2114,6 +2167,22 @@ function initLobby() {
2114
2167
  () => ({ descriptors: roomFormDescriptors, fields: roomFormFields }),
2115
2168
  );
2116
2169
 
2170
+ // реестр игр (master-game-registry, этап 4): заявка разработчика и панель
2171
+ // модерации живут в том же лобби. Триплет поднимается вместе с лобби —
2172
+ // панель доступна любому авторизованному, кнопку модерации показывает роль
2173
+ const gamesModel = new GamesModel(lobbyConfig.games, () => lobbyAuthModel.getToken());
2174
+ const gamesView = new GamesView(gamesModel, lobbyConfig.games);
2175
+ const games = new GamesCtrl(gamesModel, gamesView);
2176
+
2177
+ games.setAdmin(ADMIN_ROLES.includes(lobbyAuthModel.getRole()));
2178
+
2179
+ // «Test»: манифест застейдженной версии кладётся в каталог вкладки, и
2180
+ // админ поднимает по нему комнату обычной кнопкой Create server
2181
+ games.publisher.on('staged', ({ manifest }) => {
2182
+ registerGameManifest(manifest);
2183
+ gamesView.hide();
2184
+ });
2185
+
2117
2186
  // создание комнаты в этой же вкладке (хост-игрок через loopback)
2118
2187
  populateGameSelect();
2119
2188
  populateRoomForm(activeGameManifest);
@@ -1053,3 +1053,104 @@ body.hide-cursor {
1053
1053
  color: #f56;
1054
1054
  margin: 8px 0;
1055
1055
  }
1056
+
1057
+ /*games*/
1058
+
1059
+ /* панель реестра игр (master-game-registry, этап 4): открывается вместо
1060
+ #lobby теми же кнопками бейджа пользователя. Каркас — тот же, что у
1061
+ лобби (.card, .form-row, .lobby-column), собственных цветовых переменных
1062
+ секция не заводит */
1063
+ #games-panel {
1064
+ background-color: #222;
1065
+ position: absolute;
1066
+ width: 100%;
1067
+ min-height: 100%;
1068
+ top: 0;
1069
+ left: 0;
1070
+ z-index: 8;
1071
+ display: flex;
1072
+ align-items: flex-start;
1073
+ justify-content: center;
1074
+ box-sizing: border-box;
1075
+ }
1076
+
1077
+ #games-mine,
1078
+ #games-moderation {
1079
+ width: 700px;
1080
+ padding: 10px;
1081
+ text-align: left;
1082
+ }
1083
+
1084
+ /* заголовок панели и кнопка возврата в одной строке */
1085
+ .games-head {
1086
+ display: flex;
1087
+ align-items: center;
1088
+ justify-content: space-between;
1089
+ }
1090
+
1091
+ .games-list {
1092
+ list-style: none;
1093
+ margin: 0;
1094
+ padding: 0;
1095
+ }
1096
+
1097
+ .games-item {
1098
+ border-bottom: 1px solid #444;
1099
+ padding: 8px;
1100
+ }
1101
+
1102
+ .games-item-title {
1103
+ font-weight: bold;
1104
+ }
1105
+
1106
+ /* поля прямо в карточке заявки: версия и причина отклонения относятся к
1107
+ конкретной строке списка, а не к форме панели */
1108
+ .games-version-input,
1109
+ .games-note-input {
1110
+ width: 200px;
1111
+ text-align: left;
1112
+ margin-right: 8px;
1113
+ }
1114
+
1115
+ /* поля заявки — текстовые, а не счётчики: правило .form-row
1116
+ input[type='text'] рассчитано на «max players» room-формы */
1117
+ #games-submit-form .form-row input[type='text'] {
1118
+ width: 260px;
1119
+ max-width: 100%;
1120
+ text-align: left;
1121
+ }
1122
+
1123
+ .games-filters {
1124
+ display: flex;
1125
+ gap: 12px;
1126
+ border-bottom: 1px solid #444;
1127
+ margin-bottom: 12px;
1128
+ }
1129
+
1130
+ .games-filter-btn {
1131
+ background: none;
1132
+ border: none;
1133
+ color: #888;
1134
+ font-weight: bold;
1135
+ padding: 6px 4px 8px;
1136
+ margin: 0;
1137
+ text-shadow: none;
1138
+ }
1139
+
1140
+ .games-filter-btn.active {
1141
+ color: #eee;
1142
+ }
1143
+
1144
+ /* кнопки входа в панель стоят рядом с Sign out в бейдже пользователя */
1145
+ .lobby-user-actions {
1146
+ display: flex;
1147
+ gap: 8px;
1148
+ align-items: center;
1149
+ }
1150
+
1151
+ @media (max-width: 900px) {
1152
+ #games-mine,
1153
+ #games-moderation {
1154
+ width: 100%;
1155
+ }
1156
+ }
@@ -0,0 +1,36 @@
1
+ //- Реестр игр в лобби (master-game-registry, этап 4): разработчик подаёт
2
+ //- заявку и следит за её статусом, админ модерирует и играет в черновик.
3
+ //- Панель скрыта по умолчанию — открывается кнопками бейджа пользователя
4
+ //- (#lobby-user в lobby.pug); id элементов перечислены в config/lobby.js.
5
+ //- Подписи — по-английски, как и остальное лобби
6
+ div#games-panel(style='display:none')
7
+ div.lobby-column
8
+ 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
+ ul#games-mine-list.games-list
13
+ 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
+ div.form-row
18
+ label.form-label(for='games-field-package')= 'npm package'
19
+ input#games-field-package.field-text(type='text', name='packageName', maxlength='214', autocomplete='off')
20
+ div.form-row
21
+ 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')
29
+ div#games-submit-error.form-error
30
+ input#games-submit(type='submit', value='Submit for review')
31
+
32
+ div.card#games-moderation(style='display:none')
33
+ h3.lobby-section-title= 'Moderation'
34
+ div#games-filters.games-filters
35
+ ul#games-admin-list.games-list
36
+ div#games-admin-error.form-error
@@ -14,7 +14,13 @@ div#lobby(style='display:none')
14
14
  div
15
15
  span= 'Signed in as '
16
16
  b#lobby-user-nick
17
- input#lobby-user-logout(type='button', value='Sign out')
17
+ //- вход в панель реестра игр (master-game-registry, этап 4):
18
+ //- «My games» видит любой авторизованный, «Moderation» — только
19
+ //- админская роль (main.js по LobbyAuthModel.getRole())
20
+ div.lobby-user-actions
21
+ input#games-open-mine(type='button', value='My games')
22
+ input#games-open-moderation(type='button', value='Moderation', style='display:none')
23
+ input#lobby-user-logout(type='button', value='Sign out')
18
24
  hr.lobby-divider
19
25
  h3.lobby-section-title= 'HOST A GAME'
20
26
  div#lobby-create
@@ -4,5 +4,6 @@ include includes/chat
4
4
  include includes/stat
5
5
  include includes/lobbyAuth
6
6
  include includes/lobby
7
+ include includes/games
7
8
  include includes/auth
8
9
  include includes/informer
package/src/config/env.js CHANGED
@@ -31,6 +31,12 @@ export function applyMasterEnv(config, env = process.env) {
31
31
  if (env.GAMES_MATRIX) {
32
32
  config.set('master:games', JSON.parse(env.GAMES_MATRIX));
33
33
  }
34
+
35
+ // корень хранилища игровых пакетов (направление master-game-registry) —
36
+ // в проде это смонтированный том, переживающий пересоздание контейнера
37
+ if (env.VIMP_GAMES_DIR) {
38
+ config.set('master:gameStore:dir', env.VIMP_GAMES_DIR);
39
+ }
34
40
  }
35
41
 
36
42
  /**
@@ -11,10 +11,19 @@ export default {
11
11
 
12
12
  // каталог карт мастера, per-game (Этап 6.4): комната хоста стартует на
13
13
  // актуальных картах активной игры, недоступность каталога — fallback на
14
- // карты из бандла
14
+ // карты из бандла.
15
+ //
16
+ // Аргумент — МАНИФЕСТ, а не gameId (master-game-registry, этап 3): карты
17
+ // клиент берёт не из assetsBase, а по отдельному URL, и версионность
18
+ // каталога обязана доехать и сюда, иначе комната на застейдженной версии
19
+ // играла бы на картах одобренной. `mapsBase` проставляет мастер при
20
+ // ребейзе версионного каталога; его отсутствие (dev, standalone,
21
+ // dedicated, старый мастер) — законный случай, тогда работает прежний
22
+ // путь по id
15
23
  maps: {
16
- manifestUrl: gameId => `/games/${gameId}/maps/manifest.json`,
17
- baseUrl: gameId => `/games/${gameId}/maps`,
24
+ manifestUrl: manifest =>
25
+ `${manifest.mapsBase ?? `/games/${manifest.id}/maps`}/manifest.json`,
26
+ baseUrl: manifest => manifest.mapsBase ?? `/games/${manifest.id}/maps`,
18
27
  },
19
28
 
20
29
  // манифест конкретной игры (Этап 6.5): эстафета Worker'ов перечитывает его
@@ -184,6 +193,76 @@ export default {
184
193
  linkId: 'lobby-link',
185
194
  },
186
195
 
196
+ // реестр игр (master-game-registry, этап 4): заявка разработчика и панель
197
+ // модерации живут в том же лобби, без правки конфигов и рестартов.
198
+ // Все URL и id элементов — здесь: правило репозитория, модули их не
199
+ // хардкодят (games.pug)
200
+ games: {
201
+ urls: {
202
+ // заявки вызывающего со статусами и замечаниями модератора
203
+ mine: '/games/mine',
204
+ // заявка на новую игру платформы (валидируется мастером до записи)
205
+ submit: '/games/submit',
206
+ // заявка на новую версию уже заведённой игры
207
+ version: id => `/games/mine/${encodeURIComponent(id)}/version`,
208
+ // очередь модерации целиком плюс локальное состояние на этом мастере
209
+ admin: '/admin/games',
210
+ // манифесты застейдженных версий — по ним админ поднимает тестовую
211
+ // комнату, не трогая каталог игроков
212
+ staged: '/admin/games/manifest.json',
213
+ // «Test»: скачать версию и положить её в каталог не раздаваемой
214
+ stage: id => `/admin/games/${encodeURIComponent(id)}/stage`,
215
+ // решение модератора
216
+ moderate: id => `/admin/games/${encodeURIComponent(id)}`,
217
+ // что опубликовано в npm — индикатор «есть версия новее»
218
+ versions: id => `/admin/games/${encodeURIComponent(id)}/versions`,
219
+ },
220
+
221
+ // фильтры очереди модерации: id статуса реестра -> подпись кнопки.
222
+ // Значения обязаны совпадать со статусами auth-сервиса
223
+ statuses: [
224
+ { id: 'pending', title: 'Pending' },
225
+ { id: 'approved', title: 'Published' },
226
+ { id: 'rejected', title: 'Rejected' },
227
+ { id: 'disabled', title: 'Disabled' },
228
+ ],
229
+ defaultStatus: 'pending',
230
+
231
+ // суффикс игры, поднятой из застейдженной версии: в селекторе она
232
+ // стоит рядом с одобренной, и различать их обязано быть видно
233
+ stagedSuffix: ' (test)',
234
+
235
+ // DOM-элементы панели (из games.pug)
236
+ elems: {
237
+ panelId: 'games-panel',
238
+ // панель и лобби делят место: открытая панель прячет #lobby целиком
239
+ lobbyId: 'lobby',
240
+ // кнопки в бейдже пользователя (lobby.pug)
241
+ openMineBtnId: 'games-open-mine',
242
+ openModerationBtnId: 'games-open-moderation',
243
+ closeBtnId: 'games-close',
244
+
245
+ // «My games»
246
+ mineListId: 'games-mine-list',
247
+ submitFormId: 'games-submit-form',
248
+ submitErrorId: 'games-submit-error',
249
+ submitBtnId: 'games-submit',
250
+ fieldIds: {
251
+ id: 'games-field-id',
252
+ packageName: 'games-field-package',
253
+ version: 'games-field-version',
254
+ repoUrl: 'games-field-repo',
255
+ title: 'games-field-title',
256
+ },
257
+
258
+ // модерация
259
+ moderationId: 'games-moderation',
260
+ adminListId: 'games-admin-list',
261
+ adminErrorId: 'games-admin-error',
262
+ filtersId: 'games-filters',
263
+ },
264
+ },
265
+
187
266
  // создание комнаты (хост в этой же вкладке); лимит игроков/время
188
267
  // раунда-карты/огонь по своим/карта по умолчанию — из roomDefaults
189
268
  // манифеста активной игры (Этап 6.3), не бандлятся здесь
@@ -18,22 +18,42 @@ export default {
18
18
  cert: path.join(rootDir, '.certs', 'cert.pem'),
19
19
  },
20
20
 
21
- // список игр-плагинов, подключаемых к мастеру (Этап A2 плана разделения):
22
- // `package` имя npm-пакета игры, резолвится через node_modules; пакет
23
- // объявлен зависимостью в корневом package.json репозитория (деплой-уровень),
24
- // не в packages/engine vimp-engine остаётся game-agnostic и не тянет
25
- // конкретную игру за собой (кодревью Этапов A, находка F1). Версию задаёт
26
- // именно эта зависимость, каталог берёт её из dist/manifest.json пакета.
27
- // Список переопределяется переменной окружения GAMES_MATRIX (JSON) — её
28
- // задаёт деплой; вне прода каталог вдобавок дополняется собранными
29
- // пакетами @vimp-games/* из node_modules (master/localGames.js), чтобы
30
- // прилинкованная игра попадала в лобби без правки этого массива: он
31
- // публикуется вместе с пакетом vimp-engine
21
+ // Статический каталог игр-плагинов. ПУСТ по умолчанию
22
+ // (master-game-registry, этап 3): штатный источник каталога реестр игр
23
+ // auth-сервиса, откуда мастер скачивает одобренные пакеты сам
24
+ // (master/GameSync.js). Движок игр не знает и зависимостями их не тянет.
25
+ //
26
+ // Массив и переменная окружения GAMES_MATRIX (JSON) остаются
27
+ // переопределением для двух случаев: локальная разработка (каталог вдобавок
28
+ // дополняется собранными пакетами @vimp-games/* из node_modules,
29
+ // master/localGames.js — прилинкованная игра важнее реестра) и self-hosted
30
+ // мастер без реестра. `package` имя npm-пакета игры, резолвится через
31
+ // node_modules; версию задаёт сама установленная зависимость.
32
32
  // maxGameScore (snakes-v3 этап 3.3, необязательное поле рядом с
33
33
  // id/package) — потолок результата ОДНОЙ игры для этой игры; мастер
34
- // клампит им `best`/`points` PUT /auth/rank. Не задан дефолт
34
+ // клампит им `best`/`points` PUT /auth/rank. Для игр реестра его задаёт
35
+ // админ при модерации, здесь — только запасной путь. Не задан — дефолт
35
36
  // master:playerData:maxGameScore
36
- games: [{ id: 'tanks', package: '@vimp-games/tanks' }],
37
+ games: [],
38
+
39
+ // Хранилище игровых пакетов (направление master-game-registry): мастер
40
+ // качает одобренные игры из npm registry и раздаёт их с диска, вместо того
41
+ // чтобы получать их npm-зависимостью на этапе сборки образа.
42
+ gameStore: {
43
+ // корень хранилища; null → <repoRoot>/.games (локальная разработка).
44
+ // В проде задаётся VIMP_GAMES_DIR и монтируется томом
45
+ dir: null,
46
+ registryUrl: 'https://registry.npmjs.org',
47
+ // период опроса реестра auth за изменениями каталога
48
+ refreshInterval: 60000,
49
+ // потолки распаковки недоверенного архива
50
+ maxTarballBytes: 64 * 1024 * 1024,
51
+ maxFiles: 5000,
52
+ // сколько версий одной игры держать на диске: активная + стейджевая
53
+ keepVersions: 2,
54
+ // потолок ожидания ответа реестра
55
+ timeout: 30000,
56
+ },
37
57
 
38
58
  // список серверов (GET /servers)
39
59
  servers: {