vimp-engine 0.24.2 → 0.26.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.26.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,215 @@
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
+ // после loadAdmin, а не до: renderAdmin чистит строку отказа панели, и
167
+ // предупреждение, показанное раньше, стёрлось бы тем же тиком
168
+ if (json?.warning) {
169
+ this.publisher.emit('warning', { scope: 'admin', code: json.warning });
170
+ }
171
+ }
172
+
173
+ _emitAdmin() {
174
+ this.publisher.emit('admin-changed', {
175
+ games: this._all.filter(game => game.status === this._filter),
176
+ filter: this._filter,
177
+ versions: this._versions,
178
+ });
179
+ }
180
+
181
+ // ошибки приезжают двумя формами: список проблем пакета от мастера
182
+ // ({errors: [...]}, структурная проверка до записи в реестр) и код отказа
183
+ // auth-сервиса ({error: '...'}). Наружу уходит одна
184
+ _fail(scope, json) {
185
+ const errors = Array.isArray(json?.errors)
186
+ ? json.errors.map(error => ({ name: 'package', error }))
187
+ : [{ name: 'request', error: json?.error ?? REQUEST_FAILED }];
188
+
189
+ this.publisher.emit('error', { scope, errors });
190
+ }
191
+
192
+ async _request(url, { method = 'GET', body } = {}) {
193
+ const token = this._getToken();
194
+
195
+ if (!token) {
196
+ return { ok: false, json: { error: 'unauthorized' } };
197
+ }
198
+
199
+ try {
200
+ const res = await fetch(url, {
201
+ method,
202
+ headers: {
203
+ authorization: `Bearer ${token}`,
204
+ ...(body ? { 'content-type': 'application/json' } : {}),
205
+ },
206
+ body: body ? JSON.stringify(body) : undefined,
207
+ });
208
+ const json = await res.json().catch(() => null);
209
+
210
+ return { ok: res.ok, json };
211
+ } catch {
212
+ return { ok: false, json: { error: 'network' } };
213
+ }
214
+ }
215
+ }
@@ -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