vimp-engine 0.39.0 → 0.41.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/README.md +2 -2
- package/bin/vimp-sim.js +2 -2
- package/core/Cargo.toml +2 -2
- package/package.json +3 -4
- package/src/client/boot.js +11 -87
- 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 +1 -1
- package/src/client/components/model/Vote.js +202 -0
- package/src/client/components/view/Auth.js +3 -3
- package/src/client/components/view/Panel.js +176 -6
- package/src/client/components/view/Voice.js +6 -1
- package/src/client/components/view/Vote.js +71 -2
- package/src/client/lib/formBuilder.js +7 -6
- package/src/client/lib/hostVoteCommand.js +2 -2
- package/src/client/lib/pickActiveGame.js +3 -12
- package/src/client/lib/retryOnNetworkError.js +0 -9
- package/src/client/main.js +47 -90
- package/src/client/network/SessionSupervisor.js +14 -14
- package/src/client/network/WebRtcManager.js +1 -2
- package/src/client/network/policyClose.js +10 -77
- package/src/client/style.css +85 -10
- package/src/client/views/gameShell.js +2 -4
- package/src/client/views/includes/auth.pug +1 -4
- package/src/client/views/includes/lobby.pug +3 -6
- package/src/client/voice/MicCapture.js +3 -1
- package/src/config/abiOps.js +42 -0
- package/src/config/closeCodes.js +7 -9
- package/src/config/gameCodes.js +1 -1
- package/src/config/hostDefaults.js +2 -2
- package/src/config/lobby.js +3 -4
- package/src/config/menuName.js +3 -0
- package/src/config/wsports.js +1 -1
- package/src/devtools/ScenarioRunner.js +1 -1
- package/src/devtools/builtinScenario.js +2 -2
- package/src/devtools/contract/rules/a2-package-scripts.js +1 -1
- package/src/devtools/contract/rules/a4-vite-config.js +1 -1
- package/src/devtools/contract/rules/c4-component-dependencies.js +1 -1
- package/src/devtools/invariants.js +1 -1
- package/src/devtools/pluginLoader.js +2 -2
- package/src/host/GameCoreAdapter.js +83 -4
- package/src/host/HostGame.js +93 -17
- package/src/host/PortMachine.js +4 -21
- package/src/host/commandFailure.js +2 -2
- package/src/host/identity.js +3 -3
- package/src/host/meta/core/CommandProcessor.js +2 -2
- package/src/host/meta/core/RoundManager.js +8 -1
- package/src/host/meta/core/VoteCoordinator.js +56 -1
- package/src/host/meta/modules/Accolades.js +1 -1
- package/src/host/meta/modules/TimerManager.js +16 -2
- package/src/host/meta/modules/Vote.js +65 -0
- package/src/host/meta/player/ParticipantManager.js +19 -3
- package/src/lib/capabilities.js +17 -0
- package/src/lib/createHostRuntime.js +1 -1
- package/src/lib/gameConfigView.js +7 -0
- package/src/lib/loadGamePackage.js +7 -9
- package/src/lib/offlinePlayerData.js +1 -1
- package/src/lib/validators.js +2 -2
- package/src/lib/voicePacket.js +1 -1
- package/src/standalone/index.js +3 -2
- package/src/client/lib/footerLink.js +0 -30
- package/src/client/network/WebSocketTransport.js +0 -78
package/src/host/HostGame.js
CHANGED
|
@@ -15,6 +15,7 @@ import CommandProcessor from './meta/core/CommandProcessor.js';
|
|
|
15
15
|
import { sanitizeMessage } from '../lib/sanitizers.js';
|
|
16
16
|
import { parseFloorMessage } from '../lib/voiceMessages.js';
|
|
17
17
|
import closeCodes from '../config/closeCodes.js';
|
|
18
|
+
import { MENU_NAME_PATTERN } from '../config/menuName.js';
|
|
18
19
|
import clock from '../lib/clock.js';
|
|
19
20
|
import { createResumeKey } from '../lib/resumeKey.js';
|
|
20
21
|
import GameCoreAdapter from './GameCoreAdapter.js';
|
|
@@ -224,6 +225,7 @@ export default class HostGame {
|
|
|
224
225
|
participants: this._participants,
|
|
225
226
|
onCoreEvent: hostPlugin.onCoreEvent,
|
|
226
227
|
});
|
|
228
|
+
this._participants.setAliveCheck(id => this._game.isAlive(id));
|
|
227
229
|
|
|
228
230
|
this._panel = new Panel(data.panel);
|
|
229
231
|
this._stat = new Stat(data.stat, this._teams);
|
|
@@ -307,6 +309,7 @@ export default class HostGame {
|
|
|
307
309
|
vote: this._vote,
|
|
308
310
|
chat: this._chat,
|
|
309
311
|
timerManager: this._timerManager,
|
|
312
|
+
voteHoldTime: data.voteHoldTime,
|
|
310
313
|
});
|
|
311
314
|
|
|
312
315
|
this._roundManager = new RoundManager({
|
|
@@ -394,7 +397,11 @@ export default class HostGame {
|
|
|
394
397
|
|
|
395
398
|
// внедрение зависимостей (ядро отдаёт панель/фасад события через адаптер)
|
|
396
399
|
this._socketManager.injectServices(this._game, this._panel, this._stat);
|
|
397
|
-
this._game.injectServices({
|
|
400
|
+
this._game.injectServices({
|
|
401
|
+
vimp: this,
|
|
402
|
+
panel: this._panel,
|
|
403
|
+
vote: this._vote,
|
|
404
|
+
});
|
|
398
405
|
this._panel.injectTimerManager(this._timerManager);
|
|
399
406
|
|
|
400
407
|
this._timerManager.startIdleCheckTimer();
|
|
@@ -858,9 +865,9 @@ export default class HostGame {
|
|
|
858
865
|
|
|
859
866
|
/**
|
|
860
867
|
* Публичный teardown матча: останов таймеров, финальная синхронизация
|
|
861
|
-
* профилей и снятие всех участников.
|
|
862
|
-
*
|
|
863
|
-
*
|
|
868
|
+
* профилей и снятие всех участников. Его зовут Worker при закрытии
|
|
869
|
+
* комнаты и inline-хост solo при останове матча: без него таймеры
|
|
870
|
+
* переживают матч, а rank/state теряются.
|
|
864
871
|
* @returns {Promise} Завершение финальной синхронизации профилей.
|
|
865
872
|
*/
|
|
866
873
|
async destroy() {
|
|
@@ -1264,6 +1271,20 @@ export default class HostGame {
|
|
|
1264
1271
|
// преемник снимает их сам — иначе в мире остались бы бесхозные тела
|
|
1265
1272
|
participants.dropped = dropped;
|
|
1266
1273
|
|
|
1274
|
+
// малое состояние игры между раундами (опкод state.persist): ядро в
|
|
1275
|
+
// точку не попало, а профили игры должны пережить мягкий перезапуск.
|
|
1276
|
+
// Эстафета стоит на границе раунда — игра подводит закончившийся раунд
|
|
1277
|
+
let persist = null;
|
|
1278
|
+
|
|
1279
|
+
if (core === null) {
|
|
1280
|
+
try {
|
|
1281
|
+
persist = this._game.persistState({ roundOver: kind === 'boundary' });
|
|
1282
|
+
} catch (e) {
|
|
1283
|
+
console.warn(`[checkpoint] persisted state skipped: ${e.message}`);
|
|
1284
|
+
persist = null;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1267
1288
|
const meta = {
|
|
1268
1289
|
version: HANDOFF_VERSION,
|
|
1269
1290
|
kind,
|
|
@@ -1303,6 +1324,7 @@ export default class HostGame {
|
|
|
1303
1324
|
panel: this._panel.serialize(),
|
|
1304
1325
|
playerData: this._playerDataSync.serialize(),
|
|
1305
1326
|
plugin: plugin ?? {},
|
|
1327
|
+
persist,
|
|
1306
1328
|
// текст карточки лобби, выставленный модулем игры (lobby.setInfo):
|
|
1307
1329
|
// модули нового Worker'а создаются с нуля и сами его не повторят
|
|
1308
1330
|
lobbyInfo: this._lobbyInfoOverride,
|
|
@@ -1463,6 +1485,29 @@ export default class HostGame {
|
|
|
1463
1485
|
this._restoreModules(meta.plugin);
|
|
1464
1486
|
}
|
|
1465
1487
|
|
|
1488
|
+
if (!midRound && meta.persist !== undefined && meta.persist !== null) {
|
|
1489
|
+
try {
|
|
1490
|
+
this._game.restorePersisted?.(meta.persist);
|
|
1491
|
+
} catch (e) {
|
|
1492
|
+
console.warn(`[checkpoint] persisted state skipped: ${e.message}`);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
// участник точки, которого восстановление не вернуло (не попал в точку,
|
|
1497
|
+
// restoreHuman/restoreScripted отказали), не должен оставить профиль в
|
|
1498
|
+
// ядре: его унаследовал бы новичок с тем же gameId
|
|
1499
|
+
const listed = [
|
|
1500
|
+
...meta.participants.humans.map(record => record.gameId),
|
|
1501
|
+
...meta.participants.scripted.map(record => record.gameId),
|
|
1502
|
+
...(meta.participants.dropped ?? []),
|
|
1503
|
+
];
|
|
1504
|
+
|
|
1505
|
+
for (const gameId of listed) {
|
|
1506
|
+
if (!this._participants.get(gameId)) {
|
|
1507
|
+
this._forgetActor(gameId);
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1466
1511
|
this._restoreMode = midRound ? 'midRound' : 'soft';
|
|
1467
1512
|
this._restoredTimers = meta.timers ?? {};
|
|
1468
1513
|
this._restorePending = true;
|
|
@@ -1614,25 +1659,27 @@ export default class HostGame {
|
|
|
1614
1659
|
return this._frozen;
|
|
1615
1660
|
}
|
|
1616
1661
|
|
|
1617
|
-
//
|
|
1662
|
+
// gameId следующего (или предыдущего) наблюдаемого игрока по кругу. Остовы
|
|
1663
|
+
// погибших в списке активных остаются до конца раунда, но камера на них не
|
|
1664
|
+
// переключается; живых нет — наблюдатель остаётся на текущем
|
|
1618
1665
|
_getNextActivePlayerForUser(gameId, back) {
|
|
1619
1666
|
const currentId = this._participants.get(gameId)?.watchedGameId;
|
|
1620
1667
|
const activeList = this._participants.getActiveList();
|
|
1621
|
-
|
|
1668
|
+
const size = activeList.length;
|
|
1669
|
+
const found = activeList.indexOf(currentId);
|
|
1670
|
+
// текущего нет в списке: счёт идёт с края, в нужную сторону
|
|
1671
|
+
const start = found === -1 ? (back ? 0 : -1) : found;
|
|
1672
|
+
const step = back ? -1 : 1;
|
|
1622
1673
|
|
|
1623
|
-
|
|
1624
|
-
key =
|
|
1674
|
+
for (let i = 1; i <= size; i += 1) {
|
|
1675
|
+
const key = (((start + step * i) % size) + size) % size;
|
|
1625
1676
|
|
|
1626
|
-
if (key
|
|
1627
|
-
|
|
1628
|
-
} else if (key >= activeList.length) {
|
|
1629
|
-
key = 0;
|
|
1677
|
+
if (this._game.isAlive(activeList[key])) {
|
|
1678
|
+
return activeList[key];
|
|
1630
1679
|
}
|
|
1631
|
-
|
|
1632
|
-
return activeList[key];
|
|
1633
1680
|
}
|
|
1634
1681
|
|
|
1635
|
-
return
|
|
1682
|
+
return currentId ?? null;
|
|
1636
1683
|
}
|
|
1637
1684
|
|
|
1638
1685
|
// активирует тряску камеры у игрока (из события ядра)
|
|
@@ -1736,6 +1783,10 @@ export default class HostGame {
|
|
|
1736
1783
|
this._game.removePlayer(gameId);
|
|
1737
1784
|
}
|
|
1738
1785
|
|
|
1786
|
+
// профиль игры (деньги и т.п.) живёт дольше танка: id ушедшего сразу
|
|
1787
|
+
// достаётся новичку
|
|
1788
|
+
this._forgetActor(gameId);
|
|
1789
|
+
|
|
1739
1790
|
this._participants.remove(gameId);
|
|
1740
1791
|
|
|
1741
1792
|
this._chat.pushSystem('USER_LEFT', [user.name]);
|
|
@@ -1869,7 +1920,9 @@ export default class HostGame {
|
|
|
1869
1920
|
} else if (typeof data === 'object' && data !== null) {
|
|
1870
1921
|
const [type, value] = data;
|
|
1871
1922
|
|
|
1872
|
-
if (type === '
|
|
1923
|
+
if (typeof type === 'string' && type.startsWith('menu:')) {
|
|
1924
|
+
this._menuAnswer(gameId, type.slice(5), value);
|
|
1925
|
+
} else if (type === 'mapChange') {
|
|
1873
1926
|
if (this._participants.getHumans().length === 1) {
|
|
1874
1927
|
this._roundManager.forceChangeMap(value);
|
|
1875
1928
|
} else {
|
|
@@ -1884,6 +1937,29 @@ export default class HostGame {
|
|
|
1884
1937
|
}
|
|
1885
1938
|
}
|
|
1886
1939
|
|
|
1940
|
+
// исключение ядра не должно прервать уход участника или миграцию
|
|
1941
|
+
_forgetActor(gameId) {
|
|
1942
|
+
try {
|
|
1943
|
+
this._game.forgetActor?.(gameId);
|
|
1944
|
+
} catch (e) {
|
|
1945
|
+
console.warn(`[actor.forget] skipped: ${e.message}`);
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
// ответ игрока на личное меню: проверка формы и передача ядру игры;
|
|
1950
|
+
// `id` — всегда отправитель, клиент его не указывает
|
|
1951
|
+
_menuAnswer(gameId, name, path) {
|
|
1952
|
+
if (
|
|
1953
|
+
!MENU_NAME_PATTERN.test(name) ||
|
|
1954
|
+
typeof path !== 'string' ||
|
|
1955
|
+
!/^[1-9](\.[1-9])?$/.test(path)
|
|
1956
|
+
) {
|
|
1957
|
+
return;
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
this._game.menuAnswer(gameId, name, path);
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1887
1963
|
// rank/state участника (Этап B4) — для игровых модулей и чат-команды /rank
|
|
1888
1964
|
// (Этап B5, CommandProcessor)
|
|
1889
1965
|
getPlayerRank(gameId) {
|
|
@@ -2052,7 +2128,7 @@ export default class HostGame {
|
|
|
2052
2128
|
};
|
|
2053
2129
|
}
|
|
2054
2130
|
|
|
2055
|
-
// есть ли у участника identity-токен: в standalone
|
|
2131
|
+
// есть ли у участника identity-токен: в standalone личность
|
|
2056
2132
|
// гостевая, и обновлять там нечего
|
|
2057
2133
|
hasToken(gameId) {
|
|
2058
2134
|
const token = this._participants.get(gameId)?.token;
|
package/src/host/PortMachine.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
// Хендшейк-машина клиента, вынутая из host.worker.js: автомат по клиентским
|
|
2
|
-
// портам (таблица портов — docs.
|
|
2
|
+
// портам (таблица портов — VIMP docs: snapshot.md). Изоморфна — ни
|
|
3
3
|
// self, ни postMessage, ни DOM: всё, что знает про транспорт, приходит через
|
|
4
4
|
// makeSocket. Поэтому её крутит и Worker браузерного хоста, и inline-хост
|
|
5
|
-
// standalone SDK, и
|
|
6
|
-
//
|
|
7
|
-
// createHostRuntime.
|
|
5
|
+
// standalone SDK, и headless-прогон: копий автомата быть не должно, иначе
|
|
6
|
+
// они разъедутся ровно так, как разъезжались бы копии createHostRuntime.
|
|
8
7
|
|
|
9
8
|
import wsports from '../config/wsports.js';
|
|
10
9
|
import closeCodes from '../config/closeCodes.js';
|
|
@@ -57,7 +56,7 @@ export default class PortMachine {
|
|
|
57
56
|
* @param {Object} deps.identity - стратегия идентичности (./identity.js).
|
|
58
57
|
* @param {number} [deps.resumeGraceMs] - сколько держать место участника,
|
|
59
58
|
* чей транспорт оборвался (host-migration этап 4); 0 — снимать сразу
|
|
60
|
-
* (
|
|
59
|
+
* (standalone).
|
|
61
60
|
* @param {number} [deps.resumeRequestTimeoutMs] - сколько ждать
|
|
62
61
|
* RESUME_REQUEST от возобновляющего соединения.
|
|
63
62
|
*/
|
|
@@ -466,22 +465,6 @@ export default class PortMachine {
|
|
|
466
465
|
}
|
|
467
466
|
}
|
|
468
467
|
|
|
469
|
-
/**
|
|
470
|
-
* @param {string} socketId
|
|
471
|
-
* @returns {boolean} Есть ли порт-машина у этого соединения.
|
|
472
|
-
*/
|
|
473
|
-
has(socketId) {
|
|
474
|
-
return this._clients.has(socketId);
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
/**
|
|
478
|
-
* @param {string} socketId
|
|
479
|
-
* @returns {boolean} Дошёл ли клиент до созданного участника матча.
|
|
480
|
-
*/
|
|
481
|
-
hasParticipant(socketId) {
|
|
482
|
-
return this._clients.get(socketId)?.gameId !== undefined;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
468
|
/**
|
|
486
469
|
* @returns {Iterator<string>} socketId живых соединений (для
|
|
487
470
|
* host.completeHandoff(new Set(...))).
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Отчёт о сбое чат-команды игры для приёмника диагностики
|
|
2
2
|
// (HostGame.setDiagnosticSink): сообщение в одну строку и стек без текста
|
|
3
|
-
// ошибки. Один отчёт на все среды — Worker (журнал платформы),
|
|
4
|
-
// (
|
|
3
|
+
// ошибки. Один отчёт на все среды — Worker (журнал платформы), standalone
|
|
4
|
+
// (консоль вкладки), headless-прогон (лог процесса). Чистые функции, без DOM и
|
|
5
5
|
// Node API: модуль работает в Worker'е
|
|
6
6
|
|
|
7
7
|
// символы, подделывающие строки лога: управляющие (перевод строки, ESC),
|
package/src/host/identity.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Стратегии идентичности хоста: как порт-машина (PortMachine.js) узнаёт,
|
|
2
2
|
// под каким ником участник входит в комнату. Прод-лобби доверяет только
|
|
3
|
-
// подписанному identity-токену центрального auth-сервиса; standalone
|
|
4
|
-
//
|
|
3
|
+
// подписанному identity-токену центрального auth-сервиса; standalone-контур
|
|
4
|
+
// мастера не имеет вовсе и пускает гостей по нику из формы.
|
|
5
5
|
//
|
|
6
6
|
// Контракт стратегии:
|
|
7
7
|
// { params: Array, errorField: string, resolve(data, socketId): Promise<string> }
|
|
@@ -61,7 +61,7 @@ export function createTokenIdentity({ jwksUrl, issuer }) {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
|
-
* Гостевой вход (standalone
|
|
64
|
+
* Гостевой вход (standalone): ник — поле формы, которое хост
|
|
65
65
|
* валидирует сам движковым isValidName, без кода игры.
|
|
66
66
|
*
|
|
67
67
|
* Ограничение (принимается осознанно): гостевые ники не уникальны и не
|
|
@@ -51,8 +51,8 @@ class CommandProcessor {
|
|
|
51
51
|
return;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
// в Worker'е сбой стал бы необработанным reject'ом,
|
|
55
|
-
//
|
|
54
|
+
// в Worker'е сбой стал бы необработанным reject'ом, то есть падением
|
|
55
|
+
// матча всех игроков
|
|
56
56
|
try {
|
|
57
57
|
const result = handler(this._ctx, gameId, arr);
|
|
58
58
|
|
|
@@ -398,6 +398,10 @@ class RoundManager {
|
|
|
398
398
|
this._game.createMap(this._scaledMapData);
|
|
399
399
|
|
|
400
400
|
for (const user of this._participants.getHumans()) {
|
|
401
|
+
// CLEAR уходит синхронно, личная очередь — по сообщению на кадр:
|
|
402
|
+
// меню и подсказка прошлого раунда не должны прийти после CLEAR
|
|
403
|
+
this._voteCoordinator.dropPersonal(user.gameId);
|
|
404
|
+
|
|
401
405
|
if (user.isReady === false) {
|
|
402
406
|
continue;
|
|
403
407
|
}
|
|
@@ -579,6 +583,9 @@ class RoundManager {
|
|
|
579
583
|
.filter(u => u.teamId !== this._spectatorId && u.gameId !== gameId)
|
|
580
584
|
.length < 2
|
|
581
585
|
) {
|
|
586
|
+
// новая игра: ядро сбрасывает накопленное между раундами до
|
|
587
|
+
// removePlayersAndShots, который зовёт initiateNewRound
|
|
588
|
+
this._game.resetMatch?.();
|
|
582
589
|
this._stat.reset();
|
|
583
590
|
this.initiateNewRound();
|
|
584
591
|
this._chat.pushSystemByUser(gameId, 'TEAMS_NEW_TEAM', [newTeam]);
|
|
@@ -649,7 +656,7 @@ class RoundManager {
|
|
|
649
656
|
|
|
650
657
|
user.status = 'spectator';
|
|
651
658
|
user.isWatching = true;
|
|
652
|
-
user.watchedGameId = this._participants.
|
|
659
|
+
user.watchedGameId = this._participants.firstWatchable();
|
|
653
660
|
user.forceCameraReset = true;
|
|
654
661
|
// слот освобождается вместе с актором, иначе он держится за наблюдателем
|
|
655
662
|
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();
|
|
@@ -234,7 +234,7 @@ export default class Accolades {
|
|
|
234
234
|
// payload.nick, и PortMachine передаёт в createUser именно его), а не
|
|
235
235
|
// то, что игрок написал в форме.
|
|
236
236
|
//
|
|
237
|
-
// Меняет это в гостевом контуре (standalone
|
|
237
|
+
// Меняет это в гостевом контуре (standalone), где ник —
|
|
238
238
|
// поле формы и «не защищён от подмены» по прямому признанию
|
|
239
239
|
// createGuestIdentity. Токена у гостя нет, и сопоставление по нику
|
|
240
240
|
// выдало бы ему чужую корону — стоит она ровно того, что значит.
|
|
@@ -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
|
// в браузерной вкладке матч всегда один, поэтому прод его не зовёт.
|
|
@@ -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
|
@@ -55,6 +55,23 @@ export const ENGINE_CAPABILITIES = createRegistry('engine-capabilities', [
|
|
|
55
55
|
// хуки `hooks.voiceSounds`/`hooks.voiceFilter`); опциональна — игра не
|
|
56
56
|
// пишет её в `requires`
|
|
57
57
|
{ value: 'voice', since: '0.39.0' },
|
|
58
|
+
// личное меню игрока в окне `#vote` и подсказка под значком рации:
|
|
59
|
+
// события ядра menu/menuClose/menuHint, опкод `menu.answer`
|
|
60
|
+
{ value: 'menu.personal', since: '0.41.0' },
|
|
61
|
+
// опкод `actor.forget`: участник покинул матч (в том числе наблюдатель);
|
|
62
|
+
// без него id ушедшего наследует новичок вместе с профилем игры
|
|
63
|
+
{ value: 'actor.forget', since: '0.41.0' },
|
|
64
|
+
// опкод `match.reset`: новая игра (движок обнулил счёт); игра, чей баланс
|
|
65
|
+
// сбрасывается вместе со счётом, объявляет её в `requires`
|
|
66
|
+
{ value: 'match.reset', since: '0.41.0' },
|
|
67
|
+
// опкоды `state.persist`/`state.restore`: состояние игры между раундами
|
|
68
|
+
// переживает мягкую миграцию хоста и эстафету Worker'а (мета `persist`)
|
|
69
|
+
{ value: 'state.persist', since: '0.41.0' },
|
|
70
|
+
// поле конфига игры `voteHoldTime`: удержание голосований в начале раунда
|
|
71
|
+
{ value: 'vote.hold', since: '0.41.0' },
|
|
72
|
+
// свойства ячейки панели (modules.panel.fields[]): label, labelFrom,
|
|
73
|
+
// hidden, format, prefix, suffix, emptyWhen, flashOnChange, countdown
|
|
74
|
+
{ value: 'panel.cells', since: '0.41.0' },
|
|
58
75
|
]);
|
|
59
76
|
|
|
60
77
|
// плоский список имён — то, чем правило контракта B2 проверяет `requires`
|
|
@@ -31,7 +31,7 @@ import HostGame from '../host/HostGame.js';
|
|
|
31
31
|
* прогону (сценарий подкручивает networkSendRate и таймеры).
|
|
32
32
|
* @param {boolean} [options.voiceRelay] - У контура есть ретранслятор голоса
|
|
33
33
|
* на транспортном уровне (браузерный хост лобби); без него связь
|
|
34
|
-
* выключается, даже если игра её объявила (standalone, headless
|
|
34
|
+
* выключается, даже если игра её объявила (standalone, headless).
|
|
35
35
|
* @returns {Promise<Object>} { hostPlugin, game, seed, core, clientCfg,
|
|
36
36
|
* socketManager, host }.
|
|
37
37
|
*/
|
|
@@ -68,6 +68,13 @@ const FIELDS = {
|
|
|
68
68
|
|
|
69
69
|
spectatorTeam: { derive: deriveSpectatorTeam },
|
|
70
70
|
|
|
71
|
+
// мс от старта раунда, в которые голосования удерживаются: запущенное
|
|
72
|
+
// игроком для других отклоняется, запущенное игрой откладывается; 0 — без
|
|
73
|
+
// удержания. Поле верхнего уровня, а не `timers.*`: applyRoomOverrides
|
|
74
|
+
// сливает конфиг поверхностно, и игра, задавшая `timers`, потеряла бы
|
|
75
|
+
// остальные таймеры движка
|
|
76
|
+
voteHoldTime: { default: 0 },
|
|
77
|
+
|
|
71
78
|
// смена хоста посреди раунда (host-migration этап 5): true — игра
|
|
72
79
|
// гарантирует, что GameSim::serialize/deserialize и хуки модулей
|
|
73
80
|
// serializeState/restoreState переносят всё её состояние. Без поля —
|