vimp-engine 0.40.0 → 0.42.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/core/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/client/components/controller/RoomMenu.js +38 -10
- package/src/client/components/controller/Vote.js +26 -0
- package/src/client/components/model/Controls.js +9 -1
- package/src/client/components/model/RoomMenu.js +42 -57
- package/src/client/components/model/Vote.js +202 -0
- package/src/client/components/view/Panel.js +176 -6
- package/src/client/components/view/RoomMenu.js +78 -24
- package/src/client/components/view/Voice.js +6 -1
- package/src/client/components/view/Vote.js +71 -2
- package/src/client/lib/autoRejoin.js +161 -0
- package/src/client/lib/hostUnloadGuard.js +40 -15
- package/src/client/lib/roomLink.js +32 -0
- package/src/client/main.js +120 -36
- package/src/client/network/HostConnectionManager.js +27 -1
- package/src/client/network/HostController.js +1 -30
- package/src/client/network/HostHealthPolicy.js +14 -59
- package/src/client/network/PlannedHandoff.js +15 -100
- package/src/client/network/SessionSupervisor.js +58 -9
- package/src/client/network/SignalingClient.js +1 -1
- package/src/client/network/WebRtcManager.js +38 -3
- package/src/client/session/GuestSession.js +42 -7
- package/src/client/session/HandoffFlow.js +26 -51
- package/src/client/session/HostRole.js +8 -6
- package/src/client/session/PromotionFlow.js +28 -1
- package/src/client/session/RouteBoot.js +28 -9
- package/src/client/style.css +112 -20
- package/src/client/views/includes/roomMenu.pug +7 -8
- package/src/client/voice/MicCapture.js +3 -1
- package/src/config/abiOps.js +42 -0
- package/src/config/chatMessages.js +1 -1
- package/src/config/lobby.js +22 -23
- package/src/config/menuName.js +3 -0
- package/src/devtools/ScenarioRunner.js +1 -0
- package/src/devtools/invariants.js +24 -2
- package/src/devtools/report.js +2 -2
- package/src/host/GameCoreAdapter.js +83 -4
- package/src/host/HostGame.js +103 -39
- package/src/host/PortMachine.js +34 -17
- package/src/host/host.worker.js +1 -13
- package/src/host/meta/core/RoundManager.js +8 -22
- package/src/host/meta/core/VoteCoordinator.js +56 -1
- package/src/host/meta/modules/TimerManager.js +16 -2
- package/src/host/meta/modules/Vote.js +65 -0
- package/src/host/meta/modules/chat/systemMessages.js +1 -1
- package/src/host/meta/player/ParticipantManager.js +19 -3
- package/src/lib/capabilities.js +19 -4
- package/src/lib/gameConfigView.js +6 -5
package/src/host/PortMachine.js
CHANGED
|
@@ -145,6 +145,8 @@ export default class PortMachine {
|
|
|
145
145
|
gameId: undefined,
|
|
146
146
|
enabled: new Array(PORT_COUNT).fill(false),
|
|
147
147
|
refreshing: false,
|
|
148
|
+
// соединение входило в матч (первый кадр или RESUME)
|
|
149
|
+
entered: false,
|
|
148
150
|
};
|
|
149
151
|
|
|
150
152
|
this._clients.set(socketId, state);
|
|
@@ -160,6 +162,8 @@ export default class PortMachine {
|
|
|
160
162
|
gameId: undefined,
|
|
161
163
|
enabled: new Array(PORT_COUNT).fill(false),
|
|
162
164
|
refreshing: false,
|
|
165
|
+
// соединение входило в матч (первый кадр или RESUME)
|
|
166
|
+
entered: false,
|
|
163
167
|
resumeTimer: null,
|
|
164
168
|
};
|
|
165
169
|
|
|
@@ -218,6 +222,7 @@ export default class PortMachine {
|
|
|
218
222
|
gameId,
|
|
219
223
|
enabled: new Array(PORT_COUNT).fill(false),
|
|
220
224
|
refreshing: false,
|
|
225
|
+
entered: true,
|
|
221
226
|
};
|
|
222
227
|
|
|
223
228
|
state.methods = this._buildPortMethods(socketId, state);
|
|
@@ -281,8 +286,14 @@ export default class PortMachine {
|
|
|
281
286
|
}
|
|
282
287
|
|
|
283
288
|
// обрыв без LEAVE (host-migration этап 4): место держится resumeGraceMs,
|
|
284
|
-
// если
|
|
285
|
-
|
|
289
|
+
// если соединение уже входило в матч — только из матча клиент умеет
|
|
290
|
+
// вернуться (SessionSupervisor). Ключ у грузящего карту есть ради точки
|
|
291
|
+
// смены хоста, но его обрыв до первого кадра снимает место сразу
|
|
292
|
+
if (
|
|
293
|
+
this._resumeGraceMs > 0 &&
|
|
294
|
+
state.entered &&
|
|
295
|
+
this._host.detachUser(state.gameId)
|
|
296
|
+
) {
|
|
286
297
|
this._startGrace(state.gameId);
|
|
287
298
|
return;
|
|
288
299
|
}
|
|
@@ -389,6 +400,7 @@ export default class PortMachine {
|
|
|
389
400
|
this._stopGrace(gameId);
|
|
390
401
|
|
|
391
402
|
state.gameId = gameId;
|
|
403
|
+
state.entered = true;
|
|
392
404
|
state.enabled[PC_RESUME_REQUEST] = false;
|
|
393
405
|
this._enableInGame(state);
|
|
394
406
|
|
|
@@ -397,6 +409,23 @@ export default class PortMachine {
|
|
|
397
409
|
.catch(() => reject('auth'));
|
|
398
410
|
}
|
|
399
411
|
|
|
412
|
+
// участник вошёл — с этого момента ему есть куда вернуться: секрет места
|
|
413
|
+
// (SESSION_DATA). Выдаётся при принятии авторизации, а не на первом
|
|
414
|
+
// кадре: смена хоста посреди загрузки карты не должна стоить места
|
|
415
|
+
_issueSession(socketId, gameId) {
|
|
416
|
+
if (this._resumeGraceMs <= 0 || !this._clients.has(socketId)) {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const resumeKey = this._host.issueResumeKey(gameId);
|
|
421
|
+
|
|
422
|
+
// gameId — клиенту для RESUME_REQUEST: наблюдатель свой id больше
|
|
423
|
+
// ниоткуда не узнаёт
|
|
424
|
+
if (resumeKey) {
|
|
425
|
+
this._socketManager.sendSessionData(socketId, { resumeKey, gameId });
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
400
429
|
// TOKEN_REFRESH { token }: токен принимается, только если он подписан
|
|
401
430
|
// auth-сервисом и выдан на тот же ник, под которым участник вошёл
|
|
402
431
|
_refreshToken(socketId, state, data) {
|
|
@@ -526,6 +555,7 @@ export default class PortMachine {
|
|
|
526
555
|
|
|
527
556
|
host.createUser({ ...data, name }, socketId, createdId => {
|
|
528
557
|
state.gameId = createdId;
|
|
558
|
+
this._issueSession(socketId, createdId);
|
|
529
559
|
});
|
|
530
560
|
|
|
531
561
|
socketManager.sendTechInform(socketId, 'loading');
|
|
@@ -549,23 +579,10 @@ export default class PortMachine {
|
|
|
549
579
|
// 3: map ready
|
|
550
580
|
() => host.mapReady(state.gameId),
|
|
551
581
|
|
|
552
|
-
// 4: first shot ready
|
|
553
|
-
// есть куда вернуться после обрыва: секрет места (SESSION_DATA)
|
|
582
|
+
// 4: first shot ready: участник в матче — обрыв держит место
|
|
554
583
|
() => {
|
|
584
|
+
state.entered = true;
|
|
555
585
|
host.firstShotReady(state.gameId);
|
|
556
|
-
|
|
557
|
-
if (this._resumeGraceMs > 0) {
|
|
558
|
-
const resumeKey = host.issueResumeKey(state.gameId);
|
|
559
|
-
|
|
560
|
-
// gameId — клиенту для RESUME_REQUEST: наблюдатель свой id
|
|
561
|
-
// больше ниоткуда не узнаёт
|
|
562
|
-
if (resumeKey) {
|
|
563
|
-
socketManager.sendSessionData(socketId, {
|
|
564
|
-
resumeKey,
|
|
565
|
-
gameId: state.gameId,
|
|
566
|
-
});
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
586
|
},
|
|
570
587
|
|
|
571
588
|
// 5: keys data ('seq:action:name'; указатель — 'seq:aim:x:y:flags')
|
package/src/host/host.worker.js
CHANGED
|
@@ -402,18 +402,6 @@ self.onmessage = async event => {
|
|
|
402
402
|
}
|
|
403
403
|
break;
|
|
404
404
|
|
|
405
|
-
// плановая передача хоста (этап 8d): дождаться границы раунда (у игры
|
|
406
|
-
// с migration.midRound — ответ сразу)
|
|
407
|
-
case 'round_boundary_wait':
|
|
408
|
-
host?.awaitRoundBoundary(() =>
|
|
409
|
-
self.postMessage({ type: 'round_boundary' }),
|
|
410
|
-
);
|
|
411
|
-
break;
|
|
412
|
-
|
|
413
|
-
case 'round_boundary_cancel':
|
|
414
|
-
host?.cancelRoundBoundary();
|
|
415
|
-
break;
|
|
416
|
-
|
|
417
405
|
case 'freeze':
|
|
418
406
|
host?.freeze();
|
|
419
407
|
break;
|
|
@@ -422,7 +410,7 @@ self.onmessage = async event => {
|
|
|
422
410
|
host?.unfreeze();
|
|
423
411
|
break;
|
|
424
412
|
|
|
425
|
-
// хост закрывает комнату (
|
|
413
|
+
// хост закрывает комнату (Exit последнего человека): закрыть
|
|
426
414
|
// игры участников и дождаться записи профилей — не дольше timeoutMs
|
|
427
415
|
case 'shutdown': {
|
|
428
416
|
const timeoutMs =
|
|
@@ -84,8 +84,6 @@ class RoundManager {
|
|
|
84
84
|
|
|
85
85
|
// эстафета Worker'ов (Этап 5.2): колбэк переноса на границе раунда
|
|
86
86
|
this._handoffCallback = null;
|
|
87
|
-
// плановая передача хоста ждёт границы раунда (host-migration этап 8d)
|
|
88
|
-
this._boundaryCallback = null;
|
|
89
87
|
}
|
|
90
88
|
|
|
91
89
|
get currentMap() {
|
|
@@ -325,18 +323,6 @@ class RoundManager {
|
|
|
325
323
|
this._handoffCallback = null;
|
|
326
324
|
}
|
|
327
325
|
|
|
328
|
-
// плановая передача хоста в игре без migration.midRound (host-migration
|
|
329
|
-
// этап 8d): колбэк сработает один раз, когда стартует следующий раунд.
|
|
330
|
-
// Раунд не придерживается — бета всё равно начнёт его заново из мягкой
|
|
331
|
-
// точки, а сорвавшаяся передача не должна оставить комнату без раунда
|
|
332
|
-
onRoundBoundary(cb) {
|
|
333
|
-
this._boundaryCallback = cb;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
cancelRoundBoundary() {
|
|
337
|
-
this._boundaryCallback = null;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
326
|
// запуск нового раунда
|
|
341
327
|
initiateNewRound() {
|
|
342
328
|
this._timerManager.stopRoundTimer();
|
|
@@ -354,13 +340,6 @@ class RoundManager {
|
|
|
354
340
|
|
|
355
341
|
this._startRound();
|
|
356
342
|
this._timerManager.startRoundTimer();
|
|
357
|
-
|
|
358
|
-
if (this._boundaryCallback) {
|
|
359
|
-
const cb = this._boundaryCallback;
|
|
360
|
-
|
|
361
|
-
this._boundaryCallback = null;
|
|
362
|
-
cb();
|
|
363
|
-
}
|
|
364
343
|
}
|
|
365
344
|
|
|
366
345
|
// начало раунда
|
|
@@ -398,6 +377,10 @@ class RoundManager {
|
|
|
398
377
|
this._game.createMap(this._scaledMapData);
|
|
399
378
|
|
|
400
379
|
for (const user of this._participants.getHumans()) {
|
|
380
|
+
// CLEAR уходит синхронно, личная очередь — по сообщению на кадр:
|
|
381
|
+
// меню и подсказка прошлого раунда не должны прийти после CLEAR
|
|
382
|
+
this._voteCoordinator.dropPersonal(user.gameId);
|
|
383
|
+
|
|
401
384
|
if (user.isReady === false) {
|
|
402
385
|
continue;
|
|
403
386
|
}
|
|
@@ -579,6 +562,9 @@ class RoundManager {
|
|
|
579
562
|
.filter(u => u.teamId !== this._spectatorId && u.gameId !== gameId)
|
|
580
563
|
.length < 2
|
|
581
564
|
) {
|
|
565
|
+
// новая игра: ядро сбрасывает накопленное между раундами до
|
|
566
|
+
// removePlayersAndShots, который зовёт initiateNewRound
|
|
567
|
+
this._game.resetMatch?.();
|
|
582
568
|
this._stat.reset();
|
|
583
569
|
this.initiateNewRound();
|
|
584
570
|
this._chat.pushSystemByUser(gameId, 'TEAMS_NEW_TEAM', [newTeam]);
|
|
@@ -649,7 +635,7 @@ class RoundManager {
|
|
|
649
635
|
|
|
650
636
|
user.status = 'spectator';
|
|
651
637
|
user.isWatching = true;
|
|
652
|
-
user.watchedGameId = this._participants.
|
|
638
|
+
user.watchedGameId = this._participants.firstWatchable();
|
|
653
639
|
user.forceCameraReset = true;
|
|
654
640
|
// слот освобождается вместе с актором, иначе он держится за наблюдателем
|
|
655
641
|
user.respawnIndex = null;
|
|
@@ -2,14 +2,36 @@
|
|
|
2
2
|
// Инфраструктура поверх Vote + TimerManager; не знает о round/map-логике
|
|
3
3
|
// (вызывающие передают готовые payload и resultFunc).
|
|
4
4
|
class VoteCoordinator {
|
|
5
|
-
constructor({ vote, chat, timerManager }) {
|
|
5
|
+
constructor({ vote, chat, timerManager, voteHoldTime = 0 }) {
|
|
6
6
|
this._vote = vote;
|
|
7
7
|
this._chat = chat;
|
|
8
8
|
this._timerManager = timerManager;
|
|
9
|
+
this._voteHoldTime =
|
|
10
|
+
Number.isFinite(voteHoldTime) && voteHoldTime > 0 ? voteHoldTime : 0;
|
|
11
|
+
this._deferred = new Map(); // voteCategory → data голосования игры
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// идут ли первые `voteHoldTime` мс раунда (удержание голосований)
|
|
15
|
+
isHeld() {
|
|
16
|
+
return (
|
|
17
|
+
this._voteHoldTime > 0 &&
|
|
18
|
+
this._timerManager.roundElapsed() < this._voteHoldTime
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// убирает неотправленные личные сообщения меню игрока (старт раунда)
|
|
23
|
+
dropPersonal(gameId) {
|
|
24
|
+
this._vote.dropPersonal(gameId);
|
|
9
25
|
}
|
|
10
26
|
|
|
11
27
|
// проверяет возможность создать голосование в данной категории
|
|
12
28
|
canCreateVote(voteCategory, gameId) {
|
|
29
|
+
if (gameId && this.isHeld()) {
|
|
30
|
+
this._chat.pushSystemByUser(gameId, 'VOTE_UNAVAILABLE');
|
|
31
|
+
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
13
35
|
if (
|
|
14
36
|
this._timerManager.isVoteBlocked(voteCategory) ||
|
|
15
37
|
this._vote.hasVoteCategory(voteCategory)
|
|
@@ -29,6 +51,14 @@ class VoteCoordinator {
|
|
|
29
51
|
const { voteName, voteCategory, payload, resultFunc, userList, gameId } =
|
|
30
52
|
data;
|
|
31
53
|
|
|
54
|
+
// голосование игры откладывается до конца удержания; новое того же
|
|
55
|
+
// раздела заменяет отложенное
|
|
56
|
+
if (!gameId && this.isHeld()) {
|
|
57
|
+
this._defer(voteCategory, data);
|
|
58
|
+
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
32
62
|
// если голосование инициировано пользователем
|
|
33
63
|
if (gameId) {
|
|
34
64
|
this._chat.pushSystemByUser(gameId, 'VOTE_CREATED');
|
|
@@ -60,8 +90,33 @@ class VoteCoordinator {
|
|
|
60
90
|
});
|
|
61
91
|
}
|
|
62
92
|
|
|
93
|
+
// откладывает голосование игры на остаток удержания
|
|
94
|
+
_defer(voteCategory, data) {
|
|
95
|
+
this._deferred.set(voteCategory, data);
|
|
96
|
+
|
|
97
|
+
this._timerManager.startVoteHoldTimer(
|
|
98
|
+
() => this._releaseDeferred(),
|
|
99
|
+
this._voteHoldTime - this._timerManager.roundElapsed(),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// запускает отложенные голосования по окончании удержания
|
|
104
|
+
_releaseDeferred() {
|
|
105
|
+
const list = [...this._deferred];
|
|
106
|
+
|
|
107
|
+
this._deferred.clear();
|
|
108
|
+
|
|
109
|
+
for (const [category, data] of list) {
|
|
110
|
+
if (this.canCreateVote(category)) {
|
|
111
|
+
this.createVote(data);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
63
116
|
// сбрасывает активные и запланированные опросы и таймеры по ним
|
|
64
117
|
reset() {
|
|
118
|
+
this._deferred.clear();
|
|
119
|
+
this._timerManager.stopVoteHoldTimer();
|
|
65
120
|
this._timerManager.stopAllVoteTimers();
|
|
66
121
|
this._timerManager.stopAllBlockedVoteTimers();
|
|
67
122
|
this._vote.reset();
|
|
@@ -82,6 +82,7 @@ class TimerManager extends AbstractTimer {
|
|
|
82
82
|
this._stopRoundRestartDelay();
|
|
83
83
|
this._stopMapChangeDelay();
|
|
84
84
|
this._stopRttPingTimer();
|
|
85
|
+
this.stopVoteHoldTimer();
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
// запускает таймер до конца текущей карты (duration — остаток времени
|
|
@@ -141,9 +142,12 @@ class TimerManager extends AbstractTimer {
|
|
|
141
142
|
|
|
142
143
|
// проверяет возможно сменить команду игроку в текущем раунде
|
|
143
144
|
canChangeTeamInCurrentRound() {
|
|
144
|
-
|
|
145
|
+
return this.roundElapsed() <= this._teamChangeGracePeriod;
|
|
146
|
+
}
|
|
145
147
|
|
|
146
|
-
|
|
148
|
+
// миллисекунды от старта раунда
|
|
149
|
+
roundElapsed() {
|
|
150
|
+
return this._now() - this._startRoundTime;
|
|
147
151
|
}
|
|
148
152
|
|
|
149
153
|
// логика одного "тика" игрового цикла
|
|
@@ -230,6 +234,16 @@ class TimerManager extends AbstractTimer {
|
|
|
230
234
|
this._startTimer(`vote:${voteName}`, onEndCallback, this._voteTime);
|
|
231
235
|
}
|
|
232
236
|
|
|
237
|
+
// запускает таймер конца удержания голосований (A18)
|
|
238
|
+
startVoteHoldTimer(onEndCallback, duration) {
|
|
239
|
+
this._startTimer('voteHold', onEndCallback, duration);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// останавливает таймер удержания голосований
|
|
243
|
+
stopVoteHoldTimer() {
|
|
244
|
+
this._stopTimer('voteHold');
|
|
245
|
+
}
|
|
246
|
+
|
|
233
247
|
// останавливает все активные таймеры голосования
|
|
234
248
|
stopAllVoteTimers() {
|
|
235
249
|
for (const key of this._timers.keys()) {
|
|
@@ -56,6 +56,43 @@ class Vote {
|
|
|
56
56
|
this._userList[gameId].push(arr);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
// добавляет личное сообщение меню/подсказки (`menu`, `menuClose`,
|
|
60
|
+
// `menuHint`). Ещё не отправленные сообщения того же вида и имени
|
|
61
|
+
// вытесняются: очередь не растёт от частых обновлений, порядок
|
|
62
|
+
// «закрыть → открыть» сохраняется. Списки `teams`/`maps` не трогаются
|
|
63
|
+
pushPersonal(gameId, payload) {
|
|
64
|
+
const queue = this._userList[gameId];
|
|
65
|
+
|
|
66
|
+
if (!queue) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const key = personalKey(payload);
|
|
71
|
+
const name = personalName(payload);
|
|
72
|
+
|
|
73
|
+
this._userList[gameId] = queue.filter(item => {
|
|
74
|
+
const itemKey = personalKey(item);
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
itemKey === null ||
|
|
78
|
+
personalName(item) !== name ||
|
|
79
|
+
personalGroup(itemKey) !== personalGroup(key)
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
this._userList[gameId].push(payload);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// убирает неотправленные личные сообщения: CLEAR уходит синхронно, а
|
|
87
|
+
// очередь — по сообщению на кадр, и меню прошлого раунда пришло бы после
|
|
88
|
+
dropPersonal(gameId) {
|
|
89
|
+
const queue = this._userList[gameId];
|
|
90
|
+
|
|
91
|
+
if (queue) {
|
|
92
|
+
this._userList[gameId] = queue.filter(item => personalKey(item) === null);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
59
96
|
// возвращает данные
|
|
60
97
|
shift() {
|
|
61
98
|
return this._list.shift();
|
|
@@ -178,6 +215,34 @@ class Vote {
|
|
|
178
215
|
}
|
|
179
216
|
}
|
|
180
217
|
|
|
218
|
+
const PERSONAL_KEYS = ['menu', 'menuClose', 'menuHint'];
|
|
219
|
+
|
|
220
|
+
// ключ личного сообщения (`menu`/`menuClose`/`menuHint`) или null — это
|
|
221
|
+
// список голосования (массив или шаблон)
|
|
222
|
+
function personalKey(item) {
|
|
223
|
+
if (item === null || typeof item !== 'object' || Array.isArray(item)) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return PERSONAL_KEYS.find(key => Object.hasOwn(item, key)) ?? null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// имя меню, к которому относится личное сообщение
|
|
231
|
+
function personalName(item) {
|
|
232
|
+
const key = personalKey(item);
|
|
233
|
+
|
|
234
|
+
if (key === 'menu') {
|
|
235
|
+
return item.menu.name;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return key === 'menuHint' ? item.menuHint.name : item[key];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// `menu` и `menuClose` вытесняют друг друга, `menuHint` — только себя
|
|
242
|
+
function personalGroup(key) {
|
|
243
|
+
return key === 'menuHint' ? 'hint' : 'menu';
|
|
244
|
+
}
|
|
245
|
+
|
|
181
246
|
// Сброс синглтона. Нужен только тем, кто крутит больше одного матча в
|
|
182
247
|
// процессе — headless-runner (devtools/resetHostSingletons.js) и тесты;
|
|
183
248
|
// в браузерной вкладке матч всегда один, поэтому прод его не зовёт.
|
|
@@ -30,7 +30,7 @@ export const ENGINE_MESSAGE_CODES = Object.freeze({
|
|
|
30
30
|
// голосование «Change host» (host-migration этап 10): ведёт мастер,
|
|
31
31
|
// сообщения добавляет сам клиент, по проводу они не ходят
|
|
32
32
|
HOST_VOTE_USAGE: 'v:6', // Usage: /changehost
|
|
33
|
-
// You are the host —
|
|
33
|
+
// You are the host — the room hands the host role over automatically when needed
|
|
34
34
|
HOST_VOTE_IS_HOST: 'v:7',
|
|
35
35
|
HOST_VOTE_OFFLINE: 'v:8', // No connection to the master server
|
|
36
36
|
HOST_VOTE_RECENT: 'v:9', // A host vote was held recently
|
|
@@ -24,6 +24,8 @@ class ParticipantManager {
|
|
|
24
24
|
this._participants = new Map(); // gameId -> Participant
|
|
25
25
|
this._teamSizes = {}; // team -> Set<gameId>
|
|
26
26
|
this._activePlayersList = []; // gameId[]
|
|
27
|
+
// жив ли игрок в ядре; ядро появляется позже менеджера (setAliveCheck)
|
|
28
|
+
this._isAlive = () => true;
|
|
27
29
|
|
|
28
30
|
this.resetTeamSizes();
|
|
29
31
|
}
|
|
@@ -62,7 +64,7 @@ class ParticipantManager {
|
|
|
62
64
|
team: this._joinTeam,
|
|
63
65
|
teamId: this._joinTeamId,
|
|
64
66
|
socketId,
|
|
65
|
-
watchedGameId: this.
|
|
67
|
+
watchedGameId: this.firstWatchable(),
|
|
66
68
|
token: params.token,
|
|
67
69
|
identityName: params.name,
|
|
68
70
|
});
|
|
@@ -113,7 +115,7 @@ class ParticipantManager {
|
|
|
113
115
|
team,
|
|
114
116
|
teamId,
|
|
115
117
|
socketId,
|
|
116
|
-
watchedGameId: this.
|
|
118
|
+
watchedGameId: this.firstWatchable(),
|
|
117
119
|
token: record.token ?? null,
|
|
118
120
|
identityName: record.identityName ?? null,
|
|
119
121
|
});
|
|
@@ -335,11 +337,25 @@ class ParticipantManager {
|
|
|
335
337
|
// перецепление наблюдателей на другого активного игрока
|
|
336
338
|
for (const participant of this._participants.values()) {
|
|
337
339
|
if (participant.watchedGameId === gameId) {
|
|
338
|
-
participant.watchedGameId = this.
|
|
340
|
+
participant.watchedGameId = this.firstWatchable();
|
|
339
341
|
}
|
|
340
342
|
}
|
|
341
343
|
}
|
|
342
344
|
|
|
345
|
+
setAliveCheck(isAlive) {
|
|
346
|
+
this._isAlive = isAlive;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// первая цель наблюдателя: живой активный игрок; живых нет — первый из
|
|
350
|
+
// списка (остов), списка нет — null
|
|
351
|
+
firstWatchable() {
|
|
352
|
+
return (
|
|
353
|
+
this._activePlayersList.find(id => this._isAlive(id)) ??
|
|
354
|
+
this._activePlayersList[0] ??
|
|
355
|
+
null
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
343
359
|
clearActive() {
|
|
344
360
|
this._activePlayersList = [];
|
|
345
361
|
}
|
package/src/lib/capabilities.js
CHANGED
|
@@ -46,15 +46,30 @@ export const ENGINE_CAPABILITIES = createRegistry('engine-capabilities', [
|
|
|
46
46
|
// сервис пула `diagnostics` (журнал клиентских ошибок);
|
|
47
47
|
// опционален — игра не пишет его в `requires`
|
|
48
48
|
{ value: 'diagnostics', since: '0.35.0' },
|
|
49
|
-
// контрольная точка хоста посреди раунда (host-migration этап 5):
|
|
50
|
-
//
|
|
51
|
-
// Писать в `requires` не нужно: старый движок флаг просто игнорирует, и
|
|
52
|
-
// игра на нём мигрирует мягко
|
|
49
|
+
// контрольная точка хоста посреди раунда (host-migration этап 5): хуки
|
|
50
|
+
// модулей serializeState/restoreState. Писать в `requires` не нужно
|
|
53
51
|
{ value: 'host.migration', since: '0.36.0' },
|
|
54
52
|
// голосовая связь (gameConfig.voice, порты VOICE_*, сервис пула `voice`,
|
|
55
53
|
// хуки `hooks.voiceSounds`/`hooks.voiceFilter`); опциональна — игра не
|
|
56
54
|
// пишет её в `requires`
|
|
57
55
|
{ value: 'voice', since: '0.39.0' },
|
|
56
|
+
// личное меню игрока в окне `#vote` и подсказка под значком рации:
|
|
57
|
+
// события ядра menu/menuClose/menuHint, опкод `menu.answer`
|
|
58
|
+
{ value: 'menu.personal', since: '0.41.0' },
|
|
59
|
+
// опкод `actor.forget`: участник покинул матч (в том числе наблюдатель);
|
|
60
|
+
// без него id ушедшего наследует новичок вместе с профилем игры
|
|
61
|
+
{ value: 'actor.forget', since: '0.41.0' },
|
|
62
|
+
// опкод `match.reset`: новая игра (движок обнулил счёт); игра, чей баланс
|
|
63
|
+
// сбрасывается вместе со счётом, объявляет её в `requires`
|
|
64
|
+
{ value: 'match.reset', since: '0.41.0' },
|
|
65
|
+
// опкоды `state.persist`/`state.restore`: состояние игры между раундами
|
|
66
|
+
// переживает мягкую миграцию хоста и эстафету Worker'а (мета `persist`)
|
|
67
|
+
{ value: 'state.persist', since: '0.41.0' },
|
|
68
|
+
// поле конфига игры `voteHoldTime`: удержание голосований в начале раунда
|
|
69
|
+
{ value: 'vote.hold', since: '0.41.0' },
|
|
70
|
+
// свойства ячейки панели (modules.panel.fields[]): label, labelFrom,
|
|
71
|
+
// hidden, format, prefix, suffix, emptyWhen, flashOnChange, countdown
|
|
72
|
+
{ value: 'panel.cells', since: '0.41.0' },
|
|
58
73
|
]);
|
|
59
74
|
|
|
60
75
|
// плоский список имён — то, чем правило контракта B2 проверяет `requires`
|
|
@@ -68,11 +68,12 @@ const FIELDS = {
|
|
|
68
68
|
|
|
69
69
|
spectatorTeam: { derive: deriveSpectatorTeam },
|
|
70
70
|
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
71
|
+
// мс от старта раунда, в которые голосования удерживаются: запущенное
|
|
72
|
+
// игроком для других отклоняется, запущенное игрой откладывается; 0 — без
|
|
73
|
+
// удержания. Поле верхнего уровня, а не `timers.*`: applyRoomOverrides
|
|
74
|
+
// сливает конфиг поверхностно, и игра, задавшая `timers`, потеряла бы
|
|
75
|
+
// остальные таймеры движка
|
|
76
|
+
voteHoldTime: { default: 0 },
|
|
76
77
|
};
|
|
77
78
|
|
|
78
79
|
// Все пути gameConfig, которые движок читает: объявленные игрой поля плюс
|