vimp-engine 0.3.0 → 0.5.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/bin/vimp-sim.js +140 -0
- package/package.json +10 -3
- package/src/config/gameCodes.js +8 -0
- package/src/config/lobby.js +23 -1
- package/src/config/master.js +8 -0
- package/src/devtools/RecordingSocketManager.js +143 -0
- package/src/devtools/ScenarioRunner.js +580 -0
- package/src/devtools/VirtualClient.js +411 -0
- package/src/devtools/VirtualClock.js +169 -0
- package/src/devtools/inspectHost.js +31 -0
- package/src/devtools/invariants.js +685 -0
- package/src/devtools/pluginLoader.js +145 -0
- package/src/devtools/report.js +204 -0
- package/src/devtools/resetHostSingletons.js +23 -0
- package/src/host/DebugRecorder.js +227 -0
- package/src/host/GameCoreAdapter.js +17 -0
- package/src/host/HostGame.js +129 -7
- package/src/host/host.worker.js +46 -47
- package/src/host/meta/SocketManager.js +15 -7
- package/src/host/meta/modules/Panel.js +7 -0
- package/src/host/meta/modules/PlayerDataSync.js +29 -5
- package/src/host/meta/modules/RTTManager.js +4 -2
- package/src/host/meta/modules/Stat.js +7 -0
- package/src/host/meta/modules/TimerManager.js +15 -7
- package/src/host/meta/modules/Vote.js +10 -1
- package/src/host/meta/modules/chat/Chat.js +7 -0
- package/src/host/meta/modules/chat/index.js +1 -1
- package/src/host/meta/player/HumanParticipant.js +2 -1
- package/src/lib/AbstractTimer.js +16 -6
- package/src/lib/applyRoomOverrides.js +6 -0
- package/src/lib/clientCoreConfig.js +4 -0
- package/src/lib/clock.js +53 -0
- package/src/lib/createHostRuntime.js +108 -0
- package/src/lib/reconstructHot.js +78 -0
- package/src/lib/validators.js +10 -0
- package/tests/fixtures/miniGame/client/fakeClientCore.js +304 -0
- package/tests/fixtures/miniGame/client/index.js +37 -0
- package/tests/fixtures/miniGame/client/parts/Actor.js +18 -0
- package/tests/fixtures/miniGame/client/parts/ActorRadar.js +7 -0
- package/tests/fixtures/miniGame/config/auth.js +41 -0
- package/tests/fixtures/miniGame/config/client.js +139 -0
- package/tests/fixtures/miniGame/config/game.js +148 -0
- package/tests/fixtures/miniGame/host/ScriptedManager.js +129 -0
- package/tests/fixtures/miniGame/host/createModules.js +6 -0
- package/tests/fixtures/miniGame/host/fakeCore.js +233 -0
- package/tests/fixtures/miniGame/host/index.js +29 -0
- package/tests/fixtures/miniGame/host/spawnCommand.js +14 -0
- package/tests/fixtures/miniGame/host/systemMessages.js +6 -0
- package/tests/fixtures/miniGame.contract.test.js +76 -0
package/bin/vimp-sim.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { runScenario } from '../src/devtools/ScenarioRunner.js';
|
|
4
|
+
import { loadGameForSim } from '../src/devtools/pluginLoader.js';
|
|
5
|
+
import {
|
|
6
|
+
checkDeterminism,
|
|
7
|
+
summarize,
|
|
8
|
+
FAIL,
|
|
9
|
+
} from '../src/devtools/invariants.js';
|
|
10
|
+
import { writeReport, formatMarkdown } from '../src/devtools/report.js';
|
|
11
|
+
|
|
12
|
+
// CLI headless-прогона: правка → npm run sim → текстовый вердикт, без
|
|
13
|
+
// браузера и без человека.
|
|
14
|
+
|
|
15
|
+
const USAGE = `Usage: vimp-sim [options]
|
|
16
|
+
|
|
17
|
+
--scenario <path> scenario JSON (default: built-in smoke scenario)
|
|
18
|
+
--game <path> game package directory or dist/manifest.json
|
|
19
|
+
--core <path> node build of the game core (overrides entries.wasmNode)
|
|
20
|
+
--out <dir> report root (default: .debug)
|
|
21
|
+
--no-write print the report to stdout instead of writing files
|
|
22
|
+
--determinism run the scenario twice and compare the frame streams
|
|
23
|
+
--help
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
// минимальный сценарий на фикстуре: один игрок заходит, едет вперёд,
|
|
27
|
+
// отпускает клавишу — этого хватает, чтобы контур доказал, что он замкнут
|
|
28
|
+
const DEFAULT_SCENARIO = {
|
|
29
|
+
version: 1,
|
|
30
|
+
seed: 3812,
|
|
31
|
+
participants: [{ id: 'p1', name: 'P1', model: 'm1' }],
|
|
32
|
+
timeline: [
|
|
33
|
+
{ tick: 0, op: 'join', who: 'p1', team: 'team1' },
|
|
34
|
+
{ tick: 10, op: 'key', who: 'p1', action: 'down', name: 'forward' },
|
|
35
|
+
{ tick: 60, op: 'key', who: 'p1', action: 'up', name: 'forward' },
|
|
36
|
+
],
|
|
37
|
+
// событийный ключ фикстуры в этом сценарии не стреляет — объявлено явно,
|
|
38
|
+
// иначе инвариант 2 честно посчитает это «сущность не спавнится»
|
|
39
|
+
unusedSnapshotKeys: ['e1'],
|
|
40
|
+
ticks: 120,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
async function main(argv) {
|
|
44
|
+
const args = parseArgs(argv);
|
|
45
|
+
|
|
46
|
+
if (args.help) {
|
|
47
|
+
process.stdout.write(USAGE);
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const scenario = args.scenario
|
|
52
|
+
? JSON.parse(await readFile(args.scenario, 'utf8'))
|
|
53
|
+
: DEFAULT_SCENARIO;
|
|
54
|
+
|
|
55
|
+
// плагин грузится один раз: второй прогон самопроверки детерминизма
|
|
56
|
+
// обязан идти на том же ядре, иначе он проверял бы загрузчик, а не мир
|
|
57
|
+
const plugin = await loadGameForSim({ game: args.game, core: args.core });
|
|
58
|
+
// хеши потока кадров собираются только под --determinism: их единственный
|
|
59
|
+
// потребитель — сравнение двух прогонов, а объём линеен по длине матча
|
|
60
|
+
const captureFrames = args.determinism === true;
|
|
61
|
+
const report = await runScenario(scenario, { plugin, captureFrames });
|
|
62
|
+
|
|
63
|
+
if (args.determinism) {
|
|
64
|
+
const second = await runScenario(scenario, { plugin, captureFrames });
|
|
65
|
+
const check = checkDeterminism(report, second);
|
|
66
|
+
|
|
67
|
+
report.invariants = report.invariants.map(item =>
|
|
68
|
+
item.name === check.name ? check : item,
|
|
69
|
+
);
|
|
70
|
+
report.invariantSummary = summarize(report.invariants);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (args.write === false) {
|
|
74
|
+
process.stdout.write(formatMarkdown(report));
|
|
75
|
+
} else {
|
|
76
|
+
const runDir = await writeReport(report, { outDir: args.out });
|
|
77
|
+
|
|
78
|
+
process.stdout.write(formatMarkdown(report));
|
|
79
|
+
process.stdout.write(`\nReport written to ${runDir}\n`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const failed = report.invariants.filter(check => check.status === FAIL);
|
|
83
|
+
|
|
84
|
+
if (failed.length) {
|
|
85
|
+
process.stderr.write(
|
|
86
|
+
`\n${failed.length} broken contract(s): ` +
|
|
87
|
+
`${failed.map(check => check.name).join(', ')}\n`,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseArgs(argv) {
|
|
97
|
+
const args = { write: true };
|
|
98
|
+
|
|
99
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
100
|
+
const arg = argv[i];
|
|
101
|
+
|
|
102
|
+
switch (arg) {
|
|
103
|
+
case '--help':
|
|
104
|
+
case '-h':
|
|
105
|
+
args.help = true;
|
|
106
|
+
break;
|
|
107
|
+
|
|
108
|
+
case '--no-write':
|
|
109
|
+
args.write = false;
|
|
110
|
+
break;
|
|
111
|
+
|
|
112
|
+
case '--determinism':
|
|
113
|
+
args.determinism = true;
|
|
114
|
+
break;
|
|
115
|
+
|
|
116
|
+
case '--scenario':
|
|
117
|
+
case '--game':
|
|
118
|
+
case '--core':
|
|
119
|
+
case '--out':
|
|
120
|
+
i += 1;
|
|
121
|
+
args[arg.slice(2)] = argv[i];
|
|
122
|
+
break;
|
|
123
|
+
|
|
124
|
+
default:
|
|
125
|
+
throw new Error(`unknown option '${arg}'\n\n${USAGE}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return args;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
main(process.argv.slice(2)).then(
|
|
133
|
+
code => {
|
|
134
|
+
process.exitCode = code;
|
|
135
|
+
},
|
|
136
|
+
error => {
|
|
137
|
+
process.stderr.write(`${error.stack ?? error.message}\n`);
|
|
138
|
+
process.exitCode = 1;
|
|
139
|
+
},
|
|
140
|
+
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vimp-engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -15,12 +15,16 @@
|
|
|
15
15
|
"exports": {
|
|
16
16
|
"./lib/*": "./src/lib/*",
|
|
17
17
|
"./config/*": "./src/config/*",
|
|
18
|
-
"./host/*": "./src/host/*"
|
|
18
|
+
"./host/*": "./src/host/*",
|
|
19
|
+
"./devtools/*": "./src/devtools/*"
|
|
19
20
|
},
|
|
20
21
|
"files": [
|
|
21
22
|
"src/lib",
|
|
22
23
|
"src/config",
|
|
23
|
-
"src/host"
|
|
24
|
+
"src/host",
|
|
25
|
+
"src/devtools",
|
|
26
|
+
"tests/fixtures",
|
|
27
|
+
"bin"
|
|
24
28
|
],
|
|
25
29
|
"scripts": {
|
|
26
30
|
"predev": "node scripts/sync-pixi-vendor.mjs",
|
|
@@ -43,5 +47,8 @@
|
|
|
43
47
|
"nodemon": "^3.1.10",
|
|
44
48
|
"vite": "^7.1.11",
|
|
45
49
|
"vite-plugin-pug": "^0.4.1"
|
|
50
|
+
},
|
|
51
|
+
"bin": {
|
|
52
|
+
"vimp-sim": "bin/vimp-sim.js"
|
|
46
53
|
}
|
|
47
54
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// коды игровых информ-сообщений (порт GAME_INFORM_DATA, см.
|
|
2
|
+
// docs/en/network.md) — общий контракт хоста (SocketManager) и клиента
|
|
3
|
+
// (main.js): значение не должно расходиться между ними молча
|
|
4
|
+
export default {
|
|
5
|
+
winnerTeam: [0],
|
|
6
|
+
roundStart: [1],
|
|
7
|
+
gameOver: [2],
|
|
8
|
+
};
|
package/src/config/lobby.js
CHANGED
|
@@ -44,6 +44,12 @@ export default {
|
|
|
44
44
|
stateUrl: '/auth/state',
|
|
45
45
|
},
|
|
46
46
|
|
|
47
|
+
// рейтинг игры (lobby-page-plan): публичный топ-N и позиция вызывающего,
|
|
48
|
+
// проксируемые мастером под тем же origin — правки CSP не нужны
|
|
49
|
+
leaderboardUrl: '/auth/leaderboard',
|
|
50
|
+
placementUrl: '/auth/placement',
|
|
51
|
+
leaderboardLimit: 10,
|
|
52
|
+
|
|
47
53
|
// переподключение сигнального WS хоста (комната без него выпадает из
|
|
48
54
|
// выдачи мастера): экспоненциальный бэкофф от baseDelay до maxDelay (мс)
|
|
49
55
|
reconnect: {
|
|
@@ -51,6 +57,10 @@ export default {
|
|
|
51
57
|
maxDelay: 30000,
|
|
52
58
|
},
|
|
53
59
|
|
|
60
|
+
// приёмник выгрузок отладочного контура (этап 6 плана plan/done/ai-debug):
|
|
61
|
+
// маршрут поднимается мастером только в dev, в проде вернёт 404
|
|
62
|
+
debugReportUrl: '/debug/report',
|
|
63
|
+
|
|
54
64
|
// размер страницы для «Загрузить ещё» (offset/limit к мастеру)
|
|
55
65
|
pageSize: 10,
|
|
56
66
|
|
|
@@ -67,11 +77,23 @@ export default {
|
|
|
67
77
|
emptyId: 'lobby-empty',
|
|
68
78
|
nameId: 'lobby-name',
|
|
69
79
|
hostBtnId: 'lobby-host',
|
|
70
|
-
// селектор
|
|
80
|
+
// селектор игры (lobby-page-plan): заполняется всем каталогом мастера,
|
|
81
|
+
// выбор меняет активную форму/leaderboard, но не активную игру для host
|
|
82
|
+
// (граница задачи — см. plan/lobby-page-plan.md)
|
|
71
83
|
gameId: 'lobby-game',
|
|
72
84
|
// контейнер полей комнаты: генерируются по ключам roomDefaults
|
|
73
85
|
// манифеста активной игры (Д7) — движок не знает игровых полей
|
|
74
86
|
fieldsId: 'lobby-fields',
|
|
87
|
+
|
|
88
|
+
// вкладки правой панели (lobby-page-plan)
|
|
89
|
+
tabServersBtnId: 'btn-show-servers',
|
|
90
|
+
tabLeaderboardBtnId: 'btn-show-leaderboard',
|
|
91
|
+
serversContentId: 'lobby-servers-content',
|
|
92
|
+
leaderboardContentId: 'lobby-leaderboard-content',
|
|
93
|
+
leaderboardListId: 'lobby-leaderboard-list',
|
|
94
|
+
leaderboardTitleId: 'leaderboard-title',
|
|
95
|
+
leaderboardTotalId: 'leaderboard-total',
|
|
96
|
+
myPlacementId: 'lobby-my-placement',
|
|
75
97
|
},
|
|
76
98
|
|
|
77
99
|
// создание комнаты (хост в этой же вкладке); лимит игроков/время
|
package/src/config/master.js
CHANGED
|
@@ -45,6 +45,14 @@ export default {
|
|
|
45
45
|
sweepInterval: 10000, // период проверки протухших комнат
|
|
46
46
|
},
|
|
47
47
|
|
|
48
|
+
// GET /auth/leaderboard (code review L2): TTL кэша на мастере (мс) и
|
|
49
|
+
// верхняя граница ?limit= — публичный анонимный эндпоинт, самый частый
|
|
50
|
+
// запрос лобби, выборка меняется медленно
|
|
51
|
+
leaderboard: {
|
|
52
|
+
cacheTtl: 15000,
|
|
53
|
+
maxLimit: 100,
|
|
54
|
+
},
|
|
55
|
+
|
|
48
56
|
// рейтинг хостера комнаты (server-rating этап 2, plan/server-rating/
|
|
49
57
|
// stage_2.md): /like·/unlike гостей комнаты вместо соц-модерации /ban.
|
|
50
58
|
// Дефолт движка для всех игр; auth-сервис хранит и клампит рейтинг
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import wsports from '../config/wsports.js';
|
|
2
|
+
import SocketManager from '../host/meta/SocketManager.js';
|
|
3
|
+
|
|
4
|
+
// Транспорт хоста, который вместо сети пишет исходящие вызовы в память.
|
|
5
|
+
// Наследует боевой SocketManager, а не копирует его отправителей: составные
|
|
6
|
+
// отправители (sendFirstShot и компания), нагрузка портов и их номера
|
|
7
|
+
// остаются одни на прод и на headless — ручная копия уже расходилась с
|
|
8
|
+
// оригиналом и стоила прогону keySet, панели и первого снапшота мира.
|
|
9
|
+
//
|
|
10
|
+
// Записывается сам вызов (метод + аргументы, как их видит хост), а нагрузка
|
|
11
|
+
// транспорта — тем, что настоящий код успел передать в _send/_sendBinary:
|
|
12
|
+
// проверки инвариантов рассуждают о семантике («roundEnd объявил победителя»),
|
|
13
|
+
// а VirtualClient — о том, что реально уехало бы в data channel.
|
|
14
|
+
|
|
15
|
+
// имена отправителей берутся из прототипа: ручной список неизбежно отстаёт
|
|
16
|
+
// от боевого класса, что уже случалось
|
|
17
|
+
const SENDER_METHODS = Object.getOwnPropertyNames(SocketManager.prototype)
|
|
18
|
+
.filter(name => name.startsWith('send'));
|
|
19
|
+
|
|
20
|
+
class RecordingSocketManager extends SocketManager {
|
|
21
|
+
/**
|
|
22
|
+
* @param {Object} [ports] - Карта портов host→client; по умолчанию боевая
|
|
23
|
+
* (движковые тесты меты создают транспорт без аргументов).
|
|
24
|
+
* @param {Object} [gameOpts] - Игровая параметризация ({ soundCues,
|
|
25
|
+
* initialVote }) — та же, что получает боевой SocketManager.
|
|
26
|
+
* @param {Object} [options]
|
|
27
|
+
* @param {Function} [options.onFrame] - Вызывается на каждый исходящий кадр
|
|
28
|
+
* ({ method, socketId, args, tick, sent }) — runner раздаёт их
|
|
29
|
+
* VirtualClient'ам, не дожидаясь конца прогона.
|
|
30
|
+
*/
|
|
31
|
+
constructor(ports = wsports.server, gameOpts = {}, { onFrame = null } = {}) {
|
|
32
|
+
super(ports, gameOpts);
|
|
33
|
+
|
|
34
|
+
this.frames = []; // [{ method, socketId, args, tick, sent }]
|
|
35
|
+
|
|
36
|
+
// номер тика прогона: runner двигает его перед каждым шагом, кадры
|
|
37
|
+
// получают метку — без неё проверки жизненного цикла раунда не могут
|
|
38
|
+
// сказать «кому именно не доехал roundEnd на этом тике»
|
|
39
|
+
this.tick = 0;
|
|
40
|
+
this._onFrame = onFrame;
|
|
41
|
+
|
|
42
|
+
// кадр, чьё тело сейчас исполняется: в него уходит нагрузка _send;
|
|
43
|
+
// составные отправители вкладываются друг в друга, поэтому это стек
|
|
44
|
+
// через локальную переменную в _record
|
|
45
|
+
this._current = null;
|
|
46
|
+
|
|
47
|
+
for (const method of SENDER_METHODS) {
|
|
48
|
+
this[method] = (socketId, ...args) => {
|
|
49
|
+
this._record(method, socketId, args, () =>
|
|
50
|
+
SocketManager.prototype[method].call(this, socketId, ...args),
|
|
51
|
+
);
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
close(socketId, code, key, arr) {
|
|
57
|
+
this._record('close', socketId, [code, key, arr], () =>
|
|
58
|
+
super.close(socketId, code, key, arr),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// все кадры указанного метода
|
|
63
|
+
framesOf(method) {
|
|
64
|
+
return this.frames.filter(frame => frame.method === method);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
clearFrames() {
|
|
68
|
+
this.frames.length = 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// сети нет: всё, что боевой код отдал бы сокету, оседает в текущем кадре
|
|
72
|
+
_send(socketId, port, data, reliable = true) {
|
|
73
|
+
this._payload({ port, data, reliable });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
_sendBinary(socketId, buffer, reliable) {
|
|
77
|
+
this._payload({ port: this._PORT_SHOT_DATA, data: buffer, reliable });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
_close(socketId, code, data) {
|
|
81
|
+
this._payload({ port: null, data: { code, data } });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Кадр публикуется в момент первой отправки — это и есть порядок провода:
|
|
85
|
+
// составной отправитель отдаёт свою нагрузку раньше вложенных вызовов
|
|
86
|
+
// (FIRST_SHOT_DATA → STAT → PANEL → KEYSET), и клиент прогона видит ту же
|
|
87
|
+
// последовательность, что и браузер. Публикация после invoke() отдавала бы
|
|
88
|
+
// родителя последним.
|
|
89
|
+
//
|
|
90
|
+
// Отсюда несущее допущение: у отправителя не больше ОДНОЙ собственной
|
|
91
|
+
// нагрузки (составные раскладываются во вложенные вызовы, а два _send в
|
|
92
|
+
// sendClear/sendTechInform — это ветки if/else). Нарушь его — и подписчик
|
|
93
|
+
// получит кадр, собранный наполовину, уже после решения о маршрутизации.
|
|
94
|
+
// Поэтому не молчим, а называем контракт.
|
|
95
|
+
_payload(entry) {
|
|
96
|
+
const frame = this._current;
|
|
97
|
+
|
|
98
|
+
if (!frame) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (frame.sent.length) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`RecordingSocketManager: '${frame.method}' sent a second payload ` +
|
|
105
|
+
`(port ${entry.port}) — a sender may hand the socket at most one, ` +
|
|
106
|
+
'otherwise the subscriber already routed a half-built frame; ' +
|
|
107
|
+
'split it into nested senders like sendFirstShot does',
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
frame.sent.push(entry);
|
|
112
|
+
this._emit(frame);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
_record(method, socketId, args, invoke) {
|
|
116
|
+
const frame = { method, socketId, args, tick: this.tick, sent: [] };
|
|
117
|
+
const parent = this._current;
|
|
118
|
+
|
|
119
|
+
this.frames.push(frame);
|
|
120
|
+
this._current = frame;
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
invoke();
|
|
124
|
+
} finally {
|
|
125
|
+
this._current = parent;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// отправитель без собственной нагрузки (составной вроде
|
|
129
|
+
// sendPlayerDefaultShot либо отфильтрованный прод-кодом незамапленный
|
|
130
|
+
// soundCue) всё равно доезжает до подписчика — но ровно один раз
|
|
131
|
+
if (!frame.sent.length) {
|
|
132
|
+
this._emit(frame);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
_emit(frame) {
|
|
137
|
+
if (this._onFrame) {
|
|
138
|
+
this._onFrame(frame);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export default RecordingSocketManager;
|