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.
@@ -1156,6 +1156,109 @@ body.hide-cursor {
1156
1156
  color: #d66;
1157
1157
  }
1158
1158
 
1159
+ /* журнал клиентских ошибок (plan/client-reports, этап 5): тот же каркас и
1160
+ палитра, что у панели реестра игр; графы — .games-filters/.games-filter-btn */
1161
+ #reports-panel {
1162
+ background-color: #222;
1163
+ position: absolute;
1164
+ width: 100%;
1165
+ min-height: 100%;
1166
+ top: 0;
1167
+ left: 0;
1168
+ z-index: 8;
1169
+ display: flex;
1170
+ align-items: flex-start;
1171
+ justify-content: center;
1172
+ box-sizing: border-box;
1173
+ }
1174
+
1175
+ .reports-card {
1176
+ width: 900px;
1177
+ padding: 10px;
1178
+ text-align: left;
1179
+ }
1180
+
1181
+ .reports-head {
1182
+ display: flex;
1183
+ align-items: center;
1184
+ justify-content: space-between;
1185
+ gap: 8px;
1186
+ width: 900px;
1187
+ padding: 0 10px;
1188
+ box-sizing: border-box;
1189
+ }
1190
+
1191
+ .reports-toolbar {
1192
+ display: flex;
1193
+ align-items: flex-start;
1194
+ justify-content: space-between;
1195
+ gap: 12px;
1196
+ }
1197
+
1198
+ .reports-list {
1199
+ list-style: none;
1200
+ margin: 0;
1201
+ padding: 0;
1202
+ }
1203
+
1204
+ .reports-item {
1205
+ border-bottom: 1px solid #444;
1206
+ padding: 8px;
1207
+ }
1208
+
1209
+ .reports-summary {
1210
+ display: flex;
1211
+ flex-wrap: wrap;
1212
+ gap: 4px 12px;
1213
+ cursor: pointer;
1214
+ }
1215
+
1216
+ .reports-count,
1217
+ .reports-message {
1218
+ font-weight: bold;
1219
+ }
1220
+
1221
+ .reports-message {
1222
+ flex-basis: 100%;
1223
+ word-break: break-word;
1224
+ }
1225
+
1226
+ .reports-when,
1227
+ .reports-kind {
1228
+ color: #888;
1229
+ }
1230
+
1231
+ .reports-status-open {
1232
+ color: #d66;
1233
+ }
1234
+
1235
+ .reports-details {
1236
+ margin-top: 8px;
1237
+ }
1238
+
1239
+ .reports-full-message {
1240
+ word-break: break-word;
1241
+ }
1242
+
1243
+ /* стек и details: моноширинно, длинные строки прокручиваются по
1244
+ горизонтали, высокий стек — по вертикали */
1245
+ .reports-pre {
1246
+ font-family: monospace;
1247
+ font-size: 12px;
1248
+ white-space: pre;
1249
+ overflow: auto;
1250
+ max-height: 300px;
1251
+ margin: 8px 0;
1252
+ padding: 8px;
1253
+ border: 1px solid #444;
1254
+ }
1255
+
1256
+ .reports-note-input {
1257
+ width: 260px;
1258
+ text-align: left;
1259
+ margin-right: 8px;
1260
+ }
1261
+
1159
1262
  /* кнопки входа в панель стоят рядом с Sign out в бейдже пользователя */
1160
1263
  .lobby-user-actions {
1161
1264
  display: flex;
@@ -1166,7 +1269,9 @@ body.hide-cursor {
1166
1269
  @media (max-width: 900px) {
1167
1270
  #games-mine,
1168
1271
  #games-moderation,
1169
- .games-head {
1272
+ .games-head,
1273
+ .reports-card,
1274
+ .reports-head {
1170
1275
  width: 100%;
1171
1276
  }
1172
1277
  }
@@ -20,6 +20,8 @@ div#lobby(style='display:none')
20
20
  div.lobby-user-actions
21
21
  input#games-open-mine(type='button', value='My games')
22
22
  input#games-open-moderation(type='button', value='Moderation', style='display:none')
23
+ //- журнал клиентских ошибок (plan/client-reports, этап 5): только админ
24
+ input#reports-open(type='button', value='Errors', style='display:none')
23
25
  input#lobby-user-logout(type='button', value='Sign out')
24
26
  hr.lobby-divider
25
27
  h3.lobby-section-title= 'HOST A GAME'
@@ -0,0 +1,18 @@
1
+ //- Журнал клиентских ошибок (plan/client-reports, этап 5): только админ.
2
+ //- Панель скрыта по умолчанию — открывается кнопкой «Errors» бейджа
3
+ //- пользователя (#lobby-user в lobby.pug); id элементов перечислены в
4
+ //- config/lobby.js. Подписи — по-английски, как и остальное лобби
5
+ div#reports-panel(style='display:none')
6
+ div.lobby-column
7
+ div.reports-head
8
+ h3.lobby-section-title= 'Client errors'
9
+ input#reports-close(type='button', value='Back to lobby')
10
+
11
+ div.card.reports-card
12
+ div.reports-toolbar
13
+ div#reports-filters.games-filters
14
+ select#reports-game.field-select
15
+ option(value='')= 'All games'
16
+ ul#reports-list.reports-list
17
+ div#reports-error.form-error
18
+ input#reports-more(type='button', value='Load more', style='display:none')
@@ -5,5 +5,6 @@ include includes/stat
5
5
  include includes/lobbyAuth
6
6
  include includes/lobby
7
7
  include includes/games
8
+ include includes/reports
8
9
  include includes/auth
9
10
  include includes/informer
@@ -19,6 +19,8 @@ export const clientServices = createRegistry('clientServices', [
19
19
  { value: 'localPlayer', since: 3 },
20
20
  // места в глобальном топе (lib/accolades.js)
21
21
  { value: 'accolades', since: 4 },
22
+ // журнал клиентских ошибок (lib/diagnostics.js): warn/capture партов
23
+ { value: 'diagnostics', since: 4 },
22
24
  ]);
23
25
 
24
26
  // плоский список имён — то, чем правило C4 проверяет componentDependencies
package/src/config/env.js CHANGED
@@ -31,6 +31,12 @@ export function applyMasterEnv(config, env = process.env) {
31
31
  if (env.VIMP_GAMES_DIR) {
32
32
  config.set('master:gameStore:dir', env.VIMP_GAMES_DIR);
33
33
  }
34
+
35
+ // общий секрет бокса и auth-сервиса для пересылки журнала клиентских
36
+ // ошибок (plan/client-reports)
37
+ if (env.VIMP_CLIENT_REPORTS_TOKEN) {
38
+ config.set('master:clientReports:token', env.VIMP_CLIENT_REPORTS_TOKEN);
39
+ }
34
40
  }
35
41
 
36
42
  /**
@@ -145,6 +145,10 @@ export default {
145
145
  // маршрут поднимается мастером только в dev, в проде вернёт 404
146
146
  debugReportUrl: '/debug/report',
147
147
 
148
+ // журнал клиентских ошибок (plan/client-reports): приём на том же боксе,
149
+ // что раздал страницу, — лобби-мастер или dedicated
150
+ clientReportUrl: '/client-reports',
151
+
148
152
  // размер страницы для «Загрузить ещё» (offset/limit к мастеру)
149
153
  pageSize: 10,
150
154
 
@@ -284,6 +288,41 @@ export default {
284
288
  },
285
289
  },
286
290
 
291
+ // журнал клиентских ошибок (plan/client-reports, этап 5): только админ.
292
+ // URL и id элементов — здесь, как у панели реестра игр (reports.pug)
293
+ clientReports: {
294
+ urls: {
295
+ // страница журнала: ?status&gameId&limit&offset
296
+ list: '/admin/client-reports',
297
+ // статус и заметка админа по строке
298
+ setStatus: id => `/admin/client-reports/${encodeURIComponent(id)}`,
299
+ },
300
+ pageSize: 50,
301
+ // графы: значения совпадают со статусами auth-сервиса, 'all' — без фильтра
302
+ statuses: [
303
+ { id: 'open', title: 'Open' },
304
+ { id: 'fixed', title: 'Fixed' },
305
+ { id: 'ignored', title: 'Ignored' },
306
+ { id: 'all', title: 'All' },
307
+ ],
308
+ defaultStatus: 'open',
309
+
310
+ // DOM-элементы панели (из reports.pug)
311
+ elems: {
312
+ panelId: 'reports-panel',
313
+ // панель и лобби делят место, как у панели реестра игр
314
+ lobbyId: 'lobby',
315
+ // кнопка в бейдже пользователя (lobby.pug), видна только админу
316
+ openBtnId: 'reports-open',
317
+ closeBtnId: 'reports-close',
318
+ filtersId: 'reports-filters',
319
+ gameSelectId: 'reports-game',
320
+ listId: 'reports-list',
321
+ moreBtnId: 'reports-more',
322
+ errorId: 'reports-error',
323
+ },
324
+ },
325
+
287
326
  // создание комнаты (хост в этой же вкладке); лимит игроков/время
288
327
  // раунда-карты/огонь по своим/карта по умолчанию — из roomDefaults
289
328
  // манифеста активной игры (Этап 6.3), не бандлятся здесь
@@ -160,6 +160,26 @@ export default {
160
160
  referrerPolicy: 'no-referrer',
161
161
  },
162
162
 
163
+ // журнал клиентских ошибок (plan/client-reports): приём POST
164
+ // /client-reports на боксе и пересылка пачками в auth-сервис. Пустой
165
+ // token — пересылка выключена, остаётся строка в журнале процесса
166
+ clientReports: {
167
+ token: '',
168
+ flushIntervalMs: 30000,
169
+ forwardBatch: 50,
170
+ forwardTimeoutMs: 5000,
171
+ maxPending: 500, // потолок разных отпечатков в буфере
172
+ logSeenMax: 5000, // сколько отпечатков процесс помнит «уже печатал»
173
+ // бюджет НОВЫХ отпечатков на бокс (решение 9 плана): распределённый спам
174
+ // обходит лимит по IP, а новая строка в auth — главный ресурс. Повторы
175
+ // известных отпечатков бюджет не тратят
176
+ newFingerprintsPerMinute: 60,
177
+ // ключ — адрес, для IPv6 — подсеть /64 (lib/clientIp.js → rateLimitKey)
178
+ rateLimit: { limit: 10, windowMs: 60000 },
179
+ bodyLimit: '16kb',
180
+ maxItemsPerRequest: 10,
181
+ },
182
+
163
183
  // заголовок с регионом хоста от Nginx/CDN (например, CF-IPCountry);
164
184
  // выбран вместо geoip-lite — бесплатнее по памяти
165
185
  regionHeader: 'x-region',
@@ -151,6 +151,20 @@ function onDebug({ requestId, action }) {
151
151
  self.postMessage({ type: 'debug_result', requestId, result, error });
152
152
  }
153
153
 
154
+ // журнал клиентских ошибок (plan/client-reports): необработанный reject в
155
+ // Worker не всплывает в worker.onerror главного потока — пересылаем сами.
156
+ // Отдельный тип сообщения: 'error' занят сбоем init и запускает откат эстафеты
157
+ self.addEventListener('unhandledrejection', event => {
158
+ const reason = event.reason;
159
+
160
+ self.postMessage({
161
+ type: 'diagnostic',
162
+ kind: 'rejection',
163
+ message: reason && reason.message ? reason.message : String(reason),
164
+ stack: reason && typeof reason.stack === 'string' ? reason.stack : null,
165
+ });
166
+ });
167
+
154
168
  self.onmessage = async event => {
155
169
  const msg = event.data;
156
170
 
@@ -43,6 +43,9 @@ export const ENGINE_CAPABILITIES = createRegistry('engine-capabilities', [
43
43
  // `SimCtx::map_body_state`) и доступ игры к телам карты по индексу
44
44
  // (хэндл, индекс по хэндлу, отключение тела)
45
45
  { value: 'map.bodyState', since: '0.34.0' },
46
+ // сервис пула `diagnostics` (журнал клиентских ошибок, plan/client-reports);
47
+ // опционален — игра не пишет его в `requires`
48
+ { value: 'diagnostics', since: '0.35.0' },
46
49
  ]);
47
50
 
48
51
  // плоский список имён — то, чем правило контракта B2 проверяет `requires`
@@ -37,3 +37,53 @@ export function clientIp(req, { trustProxy = false } = {}) {
37
37
 
38
38
  return String(header || req.socket?.remoteAddress || '').trim();
39
39
  }
40
+
41
+ // Ключ rate-limit'а по адресу: IPv4 — сам адрес, IPv6 — подсеть /64.
42
+ // Провайдер выдаёт абоненту /64 целиком, то есть у одного человека
43
+ // 2^64 адресов, и лимит «на адрес» для IPv6 не лимит вовсе
44
+ export function rateLimitKey(ip) {
45
+ const addr = String(ip ?? '').trim();
46
+
47
+ if (!addr) {
48
+ return '';
49
+ }
50
+
51
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(addr);
52
+
53
+ if (mapped) {
54
+ return mapped[1];
55
+ }
56
+
57
+ if (!addr.includes(':')) {
58
+ return addr;
59
+ }
60
+
61
+ const groups = expandIpv6(addr.split('%')[0]);
62
+
63
+ if (!groups) {
64
+ return addr;
65
+ }
66
+
67
+ return `v6:${groups.slice(0, 4).map(g => parseInt(g, 16).toString(16)).join(':')}::/64`;
68
+ }
69
+
70
+ // '2001:db8::1' → 8 hex-групп или null, если запись непарсима
71
+ function expandIpv6(addr) {
72
+ const halves = addr.split('::');
73
+
74
+ if (halves.length > 2) {
75
+ return null;
76
+ }
77
+
78
+ const head = halves[0] ? halves[0].split(':') : [];
79
+ const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
80
+ const missing = 8 - head.length - tail.length;
81
+
82
+ if (halves.length === 1 ? missing !== 0 : missing < 1) {
83
+ return null;
84
+ }
85
+
86
+ const groups = [...head, ...Array(halves.length === 2 ? missing : 0).fill('0'), ...tail];
87
+
88
+ return groups.every(g => /^[0-9a-f]{1,4}$/i.test(g)) ? groups : null;
89
+ }
@@ -43,6 +43,9 @@ import { ensureGameShell } from '../client/views/gameShell.js';
43
43
  * плагина.
44
44
  * @param {boolean} [options.devMode] - room.isDevMode: рекордер и хостовый
45
45
  * CONSOLE-лог.
46
+ * @param {string|null} [options.reportUrl] - Адрес приёма журнала клиентских
47
+ * ошибок (`POST`, формат — docs/en/client.md, «Error reporting»). По
48
+ * умолчанию `null`: в solo нет бокса, и отчёты не отправляются.
46
49
  * @returns {Promise<Object>} `{ stop() }` — останов матча.
47
50
  */
48
51
  export async function startStandaloneGame({
@@ -59,6 +62,7 @@ export async function startStandaloneGame({
59
62
  room = {},
60
63
  requires,
61
64
  devMode = false,
65
+ reportUrl = null,
62
66
  } = {}) {
63
67
  requireOption(hostPlugin, 'hostPlugin');
64
68
  requireOption(clientPlugin, 'clientPlugin');
@@ -102,6 +106,7 @@ export async function startStandaloneGame({
102
106
  : null,
103
107
  startupVotes,
104
108
  startupCommands,
109
+ reportUrl,
105
110
  });
106
111
 
107
112
  // конфиг загрузки задан — дальше работает обычный клиент движка в