vimp-engine 0.24.1 → 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.1",
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
+ }