vimp-engine 0.34.7 → 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 +2 -1
- package/src/client/boot.js +1 -0
- package/src/client/components/controller/ClientReports.js +54 -0
- package/src/client/components/model/ClientReports.js +170 -0
- package/src/client/components/view/ClientReports.js +317 -0
- package/src/client/lib/diagnostics.js +430 -0
- package/src/client/main.js +56 -0
- package/src/client/network/HostController.js +50 -2
- package/src/client/style.css +106 -1
- package/src/client/views/includes/lobby.pug +2 -0
- package/src/client/views/includes/reports.pug +18 -0
- package/src/client/views/index.pug +1 -0
- package/src/config/clientServices.js +2 -0
- package/src/config/env.js +6 -0
- package/src/config/lobby.js +39 -0
- package/src/config/master.js +20 -0
- package/src/host/HostGame.js +12 -0
- package/src/host/host.worker.js +14 -0
- package/src/host/meta/core/RoundManager.js +109 -43
- package/src/lib/capabilities.js +3 -0
- package/src/lib/clientIp.js +50 -0
- package/src/standalone/index.js +5 -0
package/src/client/style.css
CHANGED
|
@@ -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')
|
|
@@ -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
|
/**
|
package/src/config/lobby.js
CHANGED
|
@@ -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), не бандлятся здесь
|
package/src/config/master.js
CHANGED
|
@@ -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',
|
package/src/host/HostGame.js
CHANGED
|
@@ -258,6 +258,9 @@ export default class HostGame {
|
|
|
258
258
|
this._handoffRestored = false;
|
|
259
259
|
this._handoffMapTimeLeft = null;
|
|
260
260
|
|
|
261
|
+
// матч закрывается (destroy): снятие участников исход раунда не решает
|
|
262
|
+
this._isDestroying = false;
|
|
263
|
+
|
|
261
264
|
// внедрение зависимостей (ядро отдаёт панель/фасад события через адаптер)
|
|
262
265
|
this._socketManager.injectServices(this._game, this._panel, this._stat);
|
|
263
266
|
this._game.injectServices({ vimp: this, panel: this._panel });
|
|
@@ -679,6 +682,7 @@ export default class HostGame {
|
|
|
679
682
|
* @returns {Promise} Завершение финальной синхронизации профилей.
|
|
680
683
|
*/
|
|
681
684
|
async destroy() {
|
|
685
|
+
this._isDestroying = true;
|
|
682
686
|
this._timerManager.stopGameTimers();
|
|
683
687
|
this._timerManager.stopIdleCheckTimer();
|
|
684
688
|
this._timerManager.stopAllVoteTimers();
|
|
@@ -884,6 +888,7 @@ export default class HostGame {
|
|
|
884
888
|
|
|
885
889
|
if (team) {
|
|
886
890
|
this._scripted.removeOneForHuman(team);
|
|
891
|
+
this._roundManager.checkRoundOutcome();
|
|
887
892
|
}
|
|
888
893
|
}
|
|
889
894
|
|
|
@@ -960,6 +965,13 @@ export default class HostGame {
|
|
|
960
965
|
this._participants.remove(gameId);
|
|
961
966
|
|
|
962
967
|
this._chat.pushSystem('USER_LEFT', [user.name]);
|
|
968
|
+
|
|
969
|
+
// ушёл последний живой команды, пока раунд ждал одной выжившей. При
|
|
970
|
+
// закрытии матча исход не решается: таймеры уже сняты, и завершение
|
|
971
|
+
// раунда взвело бы новый и повторило бы finishAllGames/flushAll
|
|
972
|
+
if (!this._isDestroying) {
|
|
973
|
+
this._roundManager.checkRoundOutcome();
|
|
974
|
+
}
|
|
963
975
|
}
|
|
964
976
|
|
|
965
977
|
// обновляет команды (формат wire: 'seq:action:name', указатель —
|
package/src/host/host.worker.js
CHANGED
|
@@ -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
|
|
|
@@ -75,6 +75,9 @@ class RoundManager {
|
|
|
75
75
|
this._scaledMapData = null;
|
|
76
76
|
this._startMapNumber = 0;
|
|
77
77
|
this._isRoundEnding = false;
|
|
78
|
+
// команды, чей вайп уже записан в этом раунде: при 3+ командах раунд
|
|
79
|
+
// идёт дальше, и второй раз `deaths` той же команде не пишется
|
|
80
|
+
this._wipedTeamIds = new Set();
|
|
78
81
|
this._removedPlayersList = []; // gameId игроков для удаления с полотна
|
|
79
82
|
|
|
80
83
|
// эстафета Worker'ов (Этап 5.2): колбэк переноса на границе раунда
|
|
@@ -148,6 +151,7 @@ class RoundManager {
|
|
|
148
151
|
|
|
149
152
|
this._participants.clearActive();
|
|
150
153
|
this._removedPlayersList = [];
|
|
154
|
+
this._wipedTeamIds.clear();
|
|
151
155
|
|
|
152
156
|
this._panel.reset();
|
|
153
157
|
this._stat.reset();
|
|
@@ -264,6 +268,7 @@ class RoundManager {
|
|
|
264
268
|
// запуск нового раунда
|
|
265
269
|
initiateNewRound() {
|
|
266
270
|
this._timerManager.stopRoundTimer();
|
|
271
|
+
this._wipedTeamIds.clear();
|
|
267
272
|
|
|
268
273
|
// граница раунда достигнута — отдать состояние эстафете; новый раунд
|
|
269
274
|
// стартует уже новый Worker после переноса
|
|
@@ -455,6 +460,14 @@ class RoundManager {
|
|
|
455
460
|
|
|
456
461
|
// меняет команду игрока
|
|
457
462
|
changeTeam(gameId, newTeam) {
|
|
463
|
+
this._applyTeamChange(gameId, newTeam);
|
|
464
|
+
|
|
465
|
+
// уход в наблюдатели или в другую команду может оставить живых только в
|
|
466
|
+
// одной команде — раунд, ждавший этого после вайпа, завершается здесь
|
|
467
|
+
this.checkRoundOutcome();
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
_applyTeamChange(gameId, newTeam) {
|
|
458
471
|
const user = this._participants.get(gameId);
|
|
459
472
|
const currentTeam = user.team;
|
|
460
473
|
const respawns = this._scaledMapData.respawns;
|
|
@@ -638,7 +651,7 @@ class RoundManager {
|
|
|
638
651
|
// убийца мог покинуть игру до попадания (например, взрыв его бомбы) —
|
|
639
652
|
// фраг не начисляется, но раунд обязан корректно завершиться
|
|
640
653
|
if (!killerUser) {
|
|
641
|
-
this._checkTeamWipe(victimUser.teamId
|
|
654
|
+
this._checkTeamWipe(victimUser.teamId);
|
|
642
655
|
return;
|
|
643
656
|
}
|
|
644
657
|
|
|
@@ -669,54 +682,108 @@ class RoundManager {
|
|
|
669
682
|
this._chat.pushSystem('REPORT_KILL', [killerUser.name, victimUser.name]);
|
|
670
683
|
|
|
671
684
|
// проверка на уничтожение всей команды противника
|
|
672
|
-
this._checkTeamWipe(victimUser.teamId
|
|
685
|
+
this._checkTeamWipe(victimUser.teamId);
|
|
673
686
|
}
|
|
674
687
|
}
|
|
675
688
|
|
|
676
|
-
// проверяет уничтожение всей
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
if (this.
|
|
689
|
+
// проверяет уничтожение всей команды. Победитель — команда, у которой
|
|
690
|
+
// остались живые, а не команда последнего убийцы: суицид, огонь по своим
|
|
691
|
+
// или ушедший убийца иначе объявляли бы GAME OVER живой команде
|
|
692
|
+
_checkTeamWipe(victimTeamId) {
|
|
693
|
+
if (!this._canResolveRound() || victimTeamId === this._spectatorId) {
|
|
681
694
|
return;
|
|
682
695
|
}
|
|
683
696
|
|
|
684
|
-
|
|
685
|
-
|
|
697
|
+
const aliveTeamIds = this._getAliveTeamIds(victimTeamId);
|
|
698
|
+
|
|
699
|
+
// в команде жертвы есть живой — команда не уничтожена
|
|
700
|
+
if (!aliveTeamIds) {
|
|
686
701
|
return;
|
|
687
702
|
}
|
|
688
703
|
|
|
689
|
-
|
|
704
|
+
// запись поражения команде (один раз за раунд)
|
|
705
|
+
if (!this._wipedTeamIds.has(victimTeamId)) {
|
|
706
|
+
this._wipedTeamIds.add(victimTeamId);
|
|
707
|
+
this._stat.updateHead(victimTeamId, 'deaths', 1);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
this._resolveRound(aliveTeamIds);
|
|
711
|
+
}
|
|
690
712
|
|
|
691
|
-
|
|
692
|
-
|
|
713
|
+
// перепроверяет исход раунда без смерти (уход игрока, смена команды):
|
|
714
|
+
// нужна только раунду, в котором вайп уже был, но живые оставались больше
|
|
715
|
+
// чем в одной команде. Без вайпа уход игрока раунд не завершает
|
|
716
|
+
checkRoundOutcome() {
|
|
717
|
+
if (!this._canResolveRound() || this._wipedTeamIds.size === 0) {
|
|
693
718
|
return;
|
|
694
719
|
}
|
|
695
720
|
|
|
696
|
-
|
|
721
|
+
this._resolveRound(this._getAliveTeamIds());
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
_canResolveRound() {
|
|
725
|
+
// под endlessRound раунд не заканчивается вообще: вычищенная команда —
|
|
726
|
+
// не повод для результата и отложенного перезапуска. Раунд, который уже
|
|
727
|
+
// завершается, второй раз не завершается
|
|
728
|
+
return !this._endlessRound && !this._isRoundEnding;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// id команд с живыми участниками (люди и scripted). null — если живой
|
|
732
|
+
// нашёлся в `victimTeamId`: остальных опрашивать незачем. Наблюдатели
|
|
733
|
+
// отсеиваются до вызова в ядро
|
|
734
|
+
_getAliveTeamIds(victimTeamId = null) {
|
|
735
|
+
const aliveTeamIds = new Set();
|
|
736
|
+
|
|
697
737
|
for (const participant of this._participants.getAll()) {
|
|
698
|
-
// если нашелся живой участник, команда не уничтожена
|
|
699
738
|
if (
|
|
700
|
-
participant.teamId ===
|
|
701
|
-
this._game.isAlive(participant.gameId)
|
|
739
|
+
participant.teamId === this._spectatorId ||
|
|
740
|
+
!this._game.isAlive(participant.gameId)
|
|
702
741
|
) {
|
|
703
|
-
|
|
742
|
+
continue;
|
|
704
743
|
}
|
|
744
|
+
|
|
745
|
+
if (participant.teamId === victimTeamId) {
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
aliveTeamIds.add(participant.teamId);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
return aliveTeamIds;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// завершает раунд, если живые остались не больше чем в одной команде:
|
|
756
|
+
// она побеждает, нет выживших — ничья
|
|
757
|
+
_resolveRound(aliveTeamIds) {
|
|
758
|
+
// при 3+ командах раунд идёт, пока живые есть больше чем в одной
|
|
759
|
+
if (aliveTeamIds.size > 1) {
|
|
760
|
+
return;
|
|
705
761
|
}
|
|
706
762
|
|
|
707
763
|
// активация флага завершения раунда
|
|
708
764
|
this._isRoundEnding = true;
|
|
709
765
|
|
|
710
|
-
|
|
711
|
-
this.
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
if (killerTeamId && killerTeamId !== victimTeamId) {
|
|
715
|
-
this._stat.updateHead(killerTeamId, 'score', 1);
|
|
766
|
+
const [winnerTeamId] = aliveTeamIds;
|
|
767
|
+
const winnerTeam = Object.keys(this._teams).find(
|
|
768
|
+
key => this._teams[key] === winnerTeamId,
|
|
769
|
+
);
|
|
716
770
|
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
771
|
+
// счёт и объявление решает одно условие: команда вне `teams` не может
|
|
772
|
+
// получить очко, которого клиенты не увидят
|
|
773
|
+
if (winnerTeam) {
|
|
774
|
+
this._stat.updateHead(winnerTeamId, 'score', 1);
|
|
775
|
+
} else {
|
|
776
|
+
// ничья: последние игроки нескольких команд погибли в одном тике, а
|
|
777
|
+
// смерти после первой приходят уже в завершающийся раунд — поражение
|
|
778
|
+
// пишется всем вымершим командам сразу
|
|
779
|
+
for (const participant of this._participants.getAll()) {
|
|
780
|
+
const teamId = participant.teamId;
|
|
781
|
+
|
|
782
|
+
if (teamId !== this._spectatorId && !this._wipedTeamIds.has(teamId)) {
|
|
783
|
+
this._wipedTeamIds.add(teamId);
|
|
784
|
+
this._stat.updateHead(teamId, 'deaths', 1);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
720
787
|
}
|
|
721
788
|
|
|
722
789
|
// синхронизация накопленных профилей на мастер по итогам раунда
|
|
@@ -725,26 +792,25 @@ class RoundManager {
|
|
|
725
792
|
this._playerDataSync?.finishAllGames?.();
|
|
726
793
|
this._playerDataSync?.flushAll();
|
|
727
794
|
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
795
|
+
this._participants.getHumans().forEach(user => {
|
|
796
|
+
const socketId = user.socketId;
|
|
797
|
+
// наблюдатель не проигрывает: ему victory при любом исходе, ничья
|
|
798
|
+
// включительно
|
|
799
|
+
const isWinner =
|
|
800
|
+
user.teamId === this._spectatorId ||
|
|
801
|
+
(winnerTeam && user.teamId === winnerTeamId);
|
|
802
|
+
|
|
803
|
+
this._socketManager.sendSoundCue(
|
|
804
|
+
socketId,
|
|
805
|
+
isWinner ? 'victory' : 'defeat',
|
|
806
|
+
);
|
|
737
807
|
|
|
808
|
+
if (winnerTeam) {
|
|
738
809
|
this._socketManager.sendRoundEnd(socketId, winnerTeam);
|
|
739
|
-
}
|
|
740
|
-
} else {
|
|
741
|
-
this._participants.getHumans().forEach(user => {
|
|
742
|
-
const socketId = user.socketId;
|
|
743
|
-
|
|
744
|
-
this._socketManager.sendSoundCue(socketId, 'defeat');
|
|
810
|
+
} else {
|
|
745
811
|
this._socketManager.sendRoundEnd(socketId);
|
|
746
|
-
}
|
|
747
|
-
}
|
|
812
|
+
}
|
|
813
|
+
});
|
|
748
814
|
|
|
749
815
|
this._timerManager.stopRoundTimer();
|
|
750
816
|
this._timerManager.startRoundRestartDelay(); // отложенный перезапуск раунда
|
package/src/lib/capabilities.js
CHANGED
|
@@ -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`
|
package/src/lib/clientIp.js
CHANGED
|
@@ -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
|
+
}
|
package/src/standalone/index.js
CHANGED
|
@@ -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
|
// конфиг загрузки задан — дальше работает обычный клиент движка в
|