vimp-engine 0.34.8 → 0.35.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.34.8",
3
+ "version": "0.35.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
+ "source-map-js": "^1.2.1",
49
50
  "tar": "^7.5.1",
50
51
  "uuid": "^13.0.0",
51
52
  "vite-express": "^0.21.1",
@@ -30,6 +30,7 @@ const DEDICATED_WS_PATH = '/game';
30
30
  * autoAuth, // solo: { name, model, … } → AUTH_RESPONSE без формы
31
31
  * startupVotes, // solo: ответы на initialVote (выход из наблюдателей)
32
32
  * startupCommands, // solo: чат-команды игры (спавн ботов)
33
+ * reportUrl, // solo: приём журнала клиентских ошибок (null — выкл.)
33
34
  * wsUrl, // dedicated: адрес игрового WS
34
35
  * gameId, // dedicated: какую игру каталога считать активной
35
36
  * }
@@ -0,0 +1,54 @@
1
+ // Singleton ClientReportsCtrl
2
+
3
+ let clientReportsCtrl;
4
+
5
+ // Контроллер журнала клиентских ошибок (plan/client-reports, этап 5):
6
+ // связывает view-события с моделью, собственного сетевого I/O не делает
7
+ export default class ClientReportsCtrl {
8
+ constructor(model, view) {
9
+ if (clientReportsCtrl) {
10
+ return clientReportsCtrl;
11
+ }
12
+
13
+ clientReportsCtrl = this;
14
+
15
+ this._model = model;
16
+ this._view = view;
17
+
18
+ const vp = view.publisher;
19
+
20
+ vp.on('open', 'open', this);
21
+ vp.on('filter', 'filter', this);
22
+ vp.on('game', 'game', this);
23
+ vp.on('more', 'more', this);
24
+ vp.on('set-status', 'setStatus', this);
25
+ }
26
+
27
+ // роль решает только видимость кнопки «Errors»: доступ к данным проверяет
28
+ // мастер, а auth перечитывает роль из БД
29
+ setAdmin(isAdmin) {
30
+ this._view.setAdmin(isAdmin);
31
+ }
32
+
33
+ // журнал живой — каждое открытие панели перечитывает его с первой страницы
34
+ open() {
35
+ this._view.show();
36
+ this._model.load({ reset: true });
37
+ }
38
+
39
+ filter(status) {
40
+ this._model.setFilter({ status });
41
+ }
42
+
43
+ game(gameId) {
44
+ this._model.setFilter({ gameId });
45
+ }
46
+
47
+ more() {
48
+ this._model.load();
49
+ }
50
+
51
+ setStatus({ id, status, note }) {
52
+ this._model.setStatus(id, status, note);
53
+ }
54
+ }
@@ -0,0 +1,170 @@
1
+ import Publisher from '../../../lib/Publisher.js';
2
+
3
+ // Singleton ClientReportsModel
4
+
5
+ let clientReportsModel;
6
+
7
+ // Модель журнала клиентских ошибок в лобби (plan/client-reports, этап 5):
8
+ // страницы журнала, фильтры и решение админа по строке. Как GamesModel,
9
+ // сетевые запросы делает сама (простой REST к мастеру) и DOM не трогает.
10
+ // Наружу — одно событие 'changed' с полным состоянием: список, счётчик,
11
+ // фильтры и код ошибки (человеческая формулировка — во view)
12
+ export default class ClientReportsModel {
13
+ /**
14
+ * @param {Object} config - Блок `clientReports` конфига лобби.
15
+ * @param {Function} getToken - Возвращает identity-токен лобби.
16
+ */
17
+ constructor(config, getToken) {
18
+ if (clientReportsModel) {
19
+ return clientReportsModel;
20
+ }
21
+
22
+ clientReportsModel = this;
23
+
24
+ this._config = config;
25
+ this._getToken = getToken;
26
+
27
+ this._status = config.defaultStatus ?? 'open';
28
+ this._gameId = null;
29
+ this._items = [];
30
+ this._total = 0;
31
+ this._loading = false;
32
+ this._error = null;
33
+ // номер последнего запроса страницы: ответ, обогнанный сменой фильтра,
34
+ // не должен дописаться к списку другой графы
35
+ this._seq = 0;
36
+
37
+ this.publisher = new Publisher();
38
+ }
39
+
40
+ getState() {
41
+ return {
42
+ status: this._status,
43
+ gameId: this._gameId,
44
+ items: this._items,
45
+ total: this._total,
46
+ loading: this._loading,
47
+ error: this._error,
48
+ };
49
+ }
50
+
51
+ // смена графы или игры: список начинается заново
52
+ setFilter({ status, gameId } = {}) {
53
+ if (status !== undefined) {
54
+ this._status = status;
55
+ }
56
+
57
+ if (gameId !== undefined) {
58
+ this._gameId = gameId || null;
59
+ }
60
+
61
+ return this.load({ reset: true });
62
+ }
63
+
64
+ /**
65
+ * Страница журнала. Смещение — длина уже загруженного списка: запись,
66
+ * убранная из графы решением админа, уменьшает и total на сервере, так что
67
+ * следующая страница не пропускает строк.
68
+ * @param {Object} [options]
69
+ * @param {boolean} [options.reset] - Начать список заново.
70
+ */
71
+ async load({ reset = false } = {}) {
72
+ const seq = ++this._seq;
73
+
74
+ if (reset) {
75
+ this._items = [];
76
+ this._total = 0;
77
+ }
78
+
79
+ const query = new URLSearchParams({
80
+ status: this._status,
81
+ limit: String(this._config.pageSize),
82
+ offset: String(this._items.length),
83
+ });
84
+
85
+ if (this._gameId) {
86
+ query.set('gameId', this._gameId);
87
+ }
88
+
89
+ this._loading = true;
90
+ this._error = null;
91
+ this._emit();
92
+
93
+ const { ok, json } = await this._request(`${this._config.urls.list}?${query}`);
94
+
95
+ if (seq !== this._seq) {
96
+ return;
97
+ }
98
+
99
+ this._loading = false;
100
+
101
+ if (!ok || !Array.isArray(json?.reports)) {
102
+ this._error = json?.error ?? 'requestFailed';
103
+ this._emit();
104
+ return;
105
+ }
106
+
107
+ this._items = this._items.concat(json.reports);
108
+ this._total = Number(json.total) || 0;
109
+ this._emit();
110
+ }
111
+
112
+ // решение админа: строка заменяется ответом auth, а если она больше не
113
+ // подходит под графу (например, «Open» → fixed) — уходит из списка
114
+ async setStatus(id, status, note) {
115
+ const { ok, json } = await this._request(this._config.urls.setStatus(id), {
116
+ method: 'PATCH',
117
+ body: { status, note },
118
+ });
119
+
120
+ if (!ok || !json?.report) {
121
+ this._error = json?.error ?? 'requestFailed';
122
+ this._emit();
123
+ return;
124
+ }
125
+
126
+ const { report } = json;
127
+
128
+ this._error = null;
129
+
130
+ if (this._status === 'all' || report.status === this._status) {
131
+ this._items = this._items.map(item => (item.id === report.id ? report : item));
132
+ } else {
133
+ const before = this._items.length;
134
+
135
+ this._items = this._items.filter(item => item.id !== report.id);
136
+ this._total = Math.max(0, this._total - (before - this._items.length));
137
+ }
138
+
139
+ this._emit();
140
+ }
141
+
142
+ _emit() {
143
+ this.publisher.emit('changed', this.getState());
144
+ }
145
+
146
+ // ошибки сети и отказы мастера/auth возвращаются кодом, без исключений
147
+ async _request(url, { method = 'GET', body } = {}) {
148
+ const token = this._getToken();
149
+
150
+ if (!token) {
151
+ return { ok: false, json: { error: 'unauthorized' } };
152
+ }
153
+
154
+ try {
155
+ const res = await fetch(url, {
156
+ method,
157
+ headers: {
158
+ authorization: `Bearer ${token}`,
159
+ ...(body ? { 'content-type': 'application/json' } : {}),
160
+ },
161
+ body: body ? JSON.stringify(body) : undefined,
162
+ });
163
+ const json = await res.json().catch(() => null);
164
+
165
+ return { ok: res.ok, json };
166
+ } catch {
167
+ return { ok: false, json: { error: 'network' } };
168
+ }
169
+ }
170
+ }
@@ -0,0 +1,317 @@
1
+ import Publisher from '../../../lib/Publisher.js';
2
+
3
+ // Singleton ClientReportsView
4
+
5
+ let clientReportsView;
6
+
7
+ // коды отказов мастера/auth — человеческая формулировка живёт здесь, как в
8
+ // GamesView. Язык интерфейса — английский, как и в остальном лобби
9
+ const ERROR_MESSAGES = {
10
+ unauthorized: 'Please sign in again',
11
+ forbidden: 'Not enough rights',
12
+ network: 'Network unavailable, try again',
13
+ requestFailed: 'Request failed',
14
+ badRequest: 'Bad request',
15
+ unknownReport: 'Report not found',
16
+ authServiceUnavailable: 'Journal service unavailable',
17
+ };
18
+
19
+ // кнопки решения админа: у записи видны все, кроме её текущего статуса
20
+ const STATUS_ACTIONS = [
21
+ { status: 'fixed', title: 'Mark fixed' },
22
+ { status: 'ignored', title: 'Ignore' },
23
+ { status: 'open', title: 'Reopen' },
24
+ ];
25
+
26
+ const MESSAGE_PREVIEW = 120;
27
+
28
+ // «5 min ago»: в журнале важнее давность, чем точная дата — она есть в
29
+ // развёрнутой части
30
+ function timeAgo(iso, now = Date.now()) {
31
+ const time = Date.parse(iso);
32
+
33
+ if (Number.isNaN(time)) {
34
+ return '—';
35
+ }
36
+
37
+ const minutes = Math.max(0, Math.floor((now - time) / 60000));
38
+
39
+ if (minutes < 1) {
40
+ return 'just now';
41
+ }
42
+
43
+ if (minutes < 60) {
44
+ return `${minutes} min ago`;
45
+ }
46
+
47
+ const hours = Math.floor(minutes / 60);
48
+
49
+ if (hours < 24) {
50
+ return `${hours} h ago`;
51
+ }
52
+
53
+ return `${Math.floor(hours / 24)} d ago`;
54
+ }
55
+
56
+ function truncate(text, max) {
57
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
58
+ }
59
+
60
+ // Представление журнала клиентских ошибок (plan/client-reports, этап 5).
61
+ //
62
+ // ***** БЕЗОПАСНОСТЬ *****
63
+ //
64
+ // Каждое поле отчёта — данные злоумышленника: любой браузер может прислать
65
+ // любую строку, а бокс её только режет по длине. Поэтому текст отчёта
66
+ // попадает в DOM ТОЛЬКО через textContent — никакого innerHTML, шаблонных
67
+ // строк в разметку и insertAdjacentHTML в этом модуле быть не должно
68
+ export default class ClientReportsView {
69
+ /**
70
+ * @param {Object} model - ClientReportsModel (источник событий).
71
+ * @param {Object} config - Блок `clientReports` конфига лобби.
72
+ */
73
+ constructor(model, config) {
74
+ if (clientReportsView) {
75
+ return clientReportsView;
76
+ }
77
+
78
+ clientReportsView = this;
79
+
80
+ const { elems } = config;
81
+
82
+ this._config = config;
83
+
84
+ this._panel = document.getElementById(elems.panelId);
85
+ this._lobby = document.getElementById(elems.lobbyId);
86
+ this._open = document.getElementById(elems.openBtnId);
87
+ this._close = document.getElementById(elems.closeBtnId);
88
+ this._filters = document.getElementById(elems.filtersId);
89
+ this._gameSelect = document.getElementById(elems.gameSelectId);
90
+ this._list = document.getElementById(elems.listId);
91
+ this._more = document.getElementById(elems.moreBtnId);
92
+ this._error = document.getElementById(elems.errorId);
93
+
94
+ // развёрнутые записи переживают перерисовку списка (решение по соседней
95
+ // строке, «Load more»)
96
+ this._expanded = new Set();
97
+
98
+ this.publisher = new Publisher();
99
+
100
+ this._open.onclick = () => this.publisher.emit('open');
101
+ this._close.onclick = () => this.hide();
102
+ this._more.onclick = () => this.publisher.emit('more');
103
+ this._gameSelect.onchange = () =>
104
+ this.publisher.emit('game', this._gameSelect.value || null);
105
+
106
+ this._renderFilters();
107
+
108
+ model.publisher.on('changed', 'render', this);
109
+ }
110
+
111
+ // кнопку показывает не сама панель, а роль вызывающего (main.js)
112
+ setAdmin(isAdmin) {
113
+ this._open.style.display = isAdmin ? '' : 'none';
114
+ }
115
+
116
+ /**
117
+ * Игры фильтра: каталог вкладки. Первая опция («All games») — из разметки.
118
+ * @param {Array<{id: string, title?: string}>} games
119
+ */
120
+ setGames(games) {
121
+ while (this._gameSelect.options.length > 1) {
122
+ this._gameSelect.remove(1);
123
+ }
124
+
125
+ (games || []).forEach(({ id, title }) => {
126
+ const option = document.createElement('option');
127
+
128
+ option.value = id;
129
+ option.textContent = title ? `${title} (${id})` : id;
130
+ this._gameSelect.appendChild(option);
131
+ });
132
+ }
133
+
134
+ // та же механика, что у панели реестра игр: панель занимает место лобби
135
+ show() {
136
+ this._panel.style.display = 'flex';
137
+ this._lobby.style.display = 'none';
138
+ }
139
+
140
+ hide() {
141
+ if (this._panel.style.display === 'none') {
142
+ return;
143
+ }
144
+
145
+ this._panel.style.display = 'none';
146
+ this._lobby.style.display = 'flex';
147
+ }
148
+
149
+ render({ items, total, status, gameId, error }) {
150
+ this._markFilter(status);
151
+ this._gameSelect.value = gameId ?? '';
152
+
153
+ this._list.textContent = '';
154
+ (items || []).forEach(report => this._list.appendChild(this._item(report)));
155
+
156
+ this._error.textContent = error ? (ERROR_MESSAGES[error] ?? error) : '';
157
+ this._more.style.display = (items || []).length < total ? '' : 'none';
158
+ }
159
+
160
+ _item(report) {
161
+ const item = document.createElement('li');
162
+ const summary = document.createElement('div');
163
+ const details = this._details(report);
164
+
165
+ item.className = 'reports-item';
166
+ summary.className = 'reports-summary';
167
+
168
+ [
169
+ [timeAgo(report.lastSeen), 'reports-when'],
170
+ [`×${report.count}`, 'reports-count'],
171
+ [`${report.kind}/${report.source}`, 'reports-kind'],
172
+ [truncate(String(report.code ?? report.message ?? ''), MESSAGE_PREVIEW), 'reports-message'],
173
+ [report.gameId ? `${report.gameId}@${report.gameVersion ?? '—'}` : '—'],
174
+ [`engine ${report.engineVersion ?? '—'}`],
175
+ [report.box ?? '—'],
176
+ [report.status, `reports-status reports-status-${report.status}`],
177
+ ].forEach(([text, className]) => summary.appendChild(this._span(text, className)));
178
+
179
+ details.style.display = this._expanded.has(report.id) ? '' : 'none';
180
+ summary.onclick = () => {
181
+ const open = details.style.display === 'none';
182
+
183
+ details.style.display = open ? '' : 'none';
184
+
185
+ if (open) {
186
+ this._expanded.add(report.id);
187
+ } else {
188
+ this._expanded.delete(report.id);
189
+ }
190
+ };
191
+
192
+ item.appendChild(summary);
193
+ item.appendChild(details);
194
+
195
+ return item;
196
+ }
197
+
198
+ _details(report) {
199
+ const details = document.createElement('div');
200
+
201
+ details.className = 'reports-details';
202
+ details.appendChild(this._line(report.message ?? '', 'reports-full-message'));
203
+
204
+ if (report.stack) {
205
+ details.appendChild(this._pre(report.stack));
206
+ }
207
+
208
+ if (report.details !== null && report.details !== undefined) {
209
+ details.appendChild(this._pre(JSON.stringify(report.details, null, 2)));
210
+ }
211
+
212
+ details.appendChild(this._line(`User agent: ${report.userAgent ?? '—'}`));
213
+ details.appendChild(
214
+ this._line(
215
+ `First seen: ${report.firstSeen ?? '—'}; last seen: ${report.lastSeen ?? '—'}` +
216
+ `; mode: ${report.mode ?? '—'}`,
217
+ ),
218
+ );
219
+ details.appendChild(
220
+ this._line(`Fingerprint: ${String(report.fingerprint ?? '').slice(0, 12)}`),
221
+ );
222
+
223
+ if (report.statusAt) {
224
+ details.appendChild(
225
+ this._line(
226
+ `Status ${report.status} by ${report.statusByNick ?? '—'} at ${report.statusAt}` +
227
+ (report.statusNote ? `: ${report.statusNote}` : ''),
228
+ ),
229
+ );
230
+ }
231
+
232
+ // заметка едет с каждым решением: поле заполнено текущей, иначе
233
+ // «Reopen» молча стёр бы объяснение прошлого решения
234
+ const note = document.createElement('input');
235
+
236
+ note.type = 'text';
237
+ note.className = 'field-text reports-note-input';
238
+ note.placeholder = 'Note';
239
+ note.maxLength = 500;
240
+ note.value = report.statusNote ?? '';
241
+ details.appendChild(note);
242
+
243
+ STATUS_ACTIONS.filter(({ status }) => status !== report.status).forEach(
244
+ ({ status, title }) => {
245
+ const btn = document.createElement('input');
246
+
247
+ btn.type = 'button';
248
+ btn.value = title;
249
+ btn.className = 'reports-action-btn';
250
+ btn.dataset.status = status;
251
+ btn.onclick = () =>
252
+ this.publisher.emit('set-status', {
253
+ id: report.id,
254
+ status,
255
+ note: note.value.trim() || null,
256
+ });
257
+ details.appendChild(btn);
258
+ },
259
+ );
260
+
261
+ return details;
262
+ }
263
+
264
+ _renderFilters() {
265
+ this._filterButtons = new Map();
266
+
267
+ this._config.statuses.forEach(({ id, title }) => {
268
+ const btn = document.createElement('input');
269
+
270
+ btn.type = 'button';
271
+ btn.value = title;
272
+ btn.className = 'games-filter-btn';
273
+ btn.onclick = () => this.publisher.emit('filter', id);
274
+ this._filterButtons.set(id, btn);
275
+ this._filters.appendChild(btn);
276
+ });
277
+ }
278
+
279
+ _markFilter(status) {
280
+ this._filterButtons.forEach((btn, id) => {
281
+ btn.classList.toggle('active', id === status);
282
+ });
283
+ }
284
+
285
+ _span(text, className) {
286
+ const span = document.createElement('span');
287
+
288
+ span.textContent = text;
289
+
290
+ if (className) {
291
+ span.className = className;
292
+ }
293
+
294
+ return span;
295
+ }
296
+
297
+ _line(text, className) {
298
+ const line = document.createElement('div');
299
+
300
+ line.textContent = text;
301
+
302
+ if (className) {
303
+ line.className = className;
304
+ }
305
+
306
+ return line;
307
+ }
308
+
309
+ _pre(text) {
310
+ const pre = document.createElement('pre');
311
+
312
+ pre.className = 'reports-pre';
313
+ pre.textContent = text;
314
+
315
+ return pre;
316
+ }
317
+ }