vimp-engine 0.7.2 → 0.8.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.
Files changed (73) hide show
  1. package/package.json +11 -4
  2. package/src/client/InputListener.js +36 -0
  3. package/src/client/SoundManager.js +498 -0
  4. package/src/client/boot.js +113 -0
  5. package/src/client/components/controller/Auth.js +44 -0
  6. package/src/client/components/controller/CanvasManager.js +26 -0
  7. package/src/client/components/controller/Chat.js +56 -0
  8. package/src/client/components/controller/Controls.js +51 -0
  9. package/src/client/components/controller/Game.js +37 -0
  10. package/src/client/components/controller/Lobby.js +81 -0
  11. package/src/client/components/controller/LobbyAuth.js +41 -0
  12. package/src/client/components/controller/Panel.js +23 -0
  13. package/src/client/components/controller/Stat.js +33 -0
  14. package/src/client/components/controller/Vote.js +55 -0
  15. package/src/client/components/model/Auth.js +92 -0
  16. package/src/client/components/model/CanvasManager.js +319 -0
  17. package/src/client/components/model/Chat.js +119 -0
  18. package/src/client/components/model/Controls.js +120 -0
  19. package/src/client/components/model/Game.js +172 -0
  20. package/src/client/components/model/Lobby.js +226 -0
  21. package/src/client/components/model/LobbyAuth.js +161 -0
  22. package/src/client/components/model/Panel.js +70 -0
  23. package/src/client/components/model/Stat.js +77 -0
  24. package/src/client/components/model/Vote.js +216 -0
  25. package/src/client/components/view/Auth.js +167 -0
  26. package/src/client/components/view/CanvasManager.js +44 -0
  27. package/src/client/components/view/Chat.js +90 -0
  28. package/src/client/components/view/Controls.js +30 -0
  29. package/src/client/components/view/Game.js +48 -0
  30. package/src/client/components/view/Lobby.js +303 -0
  31. package/src/client/components/view/LobbyAuth.js +111 -0
  32. package/src/client/components/view/Panel.js +314 -0
  33. package/src/client/components/view/Stat.js +238 -0
  34. package/src/client/components/view/Vote.js +108 -0
  35. package/src/client/debug.js +158 -0
  36. package/src/client/lib/autostart.js +61 -0
  37. package/src/client/lib/contextTracker.js +46 -0
  38. package/src/client/lib/formBuilder.js +280 -0
  39. package/src/client/lib/hostGate.js +16 -0
  40. package/src/client/main.js +2011 -0
  41. package/src/client/network/HostConnectionManager.js +196 -0
  42. package/src/client/network/HostController.js +422 -0
  43. package/src/client/network/InlineHostBridge.js +152 -0
  44. package/src/client/network/LoopbackTransport.js +51 -0
  45. package/src/client/network/SignalingClient.js +146 -0
  46. package/src/client/network/WebRtcManager.js +147 -0
  47. package/src/client/network/WebSocketTransport.js +78 -0
  48. package/src/client/network/policyClose.js +54 -0
  49. package/src/client/network/workerSupport.js +34 -0
  50. package/src/client/providers/BakingProvider.js +88 -0
  51. package/src/client/providers/DependencyProvider.js +41 -0
  52. package/src/client/style.css +898 -0
  53. package/src/client/views/gameShell.js +190 -0
  54. package/src/client/views/includes/auth.pug +20 -0
  55. package/src/client/views/includes/chat.pug +5 -0
  56. package/src/client/views/includes/informer.pug +2 -0
  57. package/src/client/views/includes/lobby.pug +48 -0
  58. package/src/client/views/includes/lobbyAuth.pug +19 -0
  59. package/src/client/views/includes/panel.pug +3 -0
  60. package/src/client/views/includes/stat.pug +2 -0
  61. package/src/client/views/index.pug +8 -0
  62. package/src/config/closeCodes.js +18 -0
  63. package/src/config/env.js +61 -0
  64. package/src/devtools/ScenarioRunner.js +3 -8
  65. package/src/devtools/pluginLoader.js +8 -93
  66. package/src/host/HostGame.js +41 -3
  67. package/src/host/PortMachine.js +294 -0
  68. package/src/host/host.worker.js +29 -246
  69. package/src/host/identity.js +100 -0
  70. package/src/lib/clientIp.js +39 -0
  71. package/src/lib/loadGamePackage.js +122 -0
  72. package/src/lib/offlinePlayerData.js +18 -0
  73. package/src/standalone/index.js +125 -0
@@ -0,0 +1,196 @@
1
+ // WebRTC-answerer браузерного хоста (Фаза 2 Этапа 4). Зеркало WebRtcManager:
2
+ // клиент — offerer (создаёт каналы meta/state и оффер), хост здесь — answerer.
3
+ // Живёт в главном потоке (RTCPeerConnection в Worker'е недоступны); входящие
4
+ // кадры каналов уходят в Worker через HostController, исходящие Worker-кадры
5
+ // раскладываются по каналам meta (reliable) / state (unreliable) по флагу
6
+ // reliable из ядра (body_has_events). Медленным пирам state-кадры дропаются
7
+ // по bufferedAmount (бэкпрешер) — meta не дропается никогда.
8
+ export default class HostConnectionManager {
9
+ /**
10
+ * @param {SignalingClient} signaling - сигнальный WS мастера.
11
+ * @param {HostController} controller - мост к Worker'у хоста.
12
+ * @param {Object} [opts]
13
+ * @param {Array} [opts.iceServers]
14
+ * @param {Function} [opts.peerFactory] - фабрика RTCPeerConnection (тесты).
15
+ * @param {number} [opts.backpressureThreshold] - порог bufferedAmount на
16
+ * state-канале, при превышении позиционные кадры дропаются (байты).
17
+ * @param {Function} [opts.onPeersChange] - вызывается с числом активных
18
+ * пиров при подключении/отключении (актуализация currentPlayers).
19
+ */
20
+ constructor(signaling, controller, opts = {}) {
21
+ this._signaling = signaling;
22
+ this._controller = controller;
23
+ this._iceServers = opts.iceServers || signaling.iceServers;
24
+ this._peerFactory =
25
+ opts.peerFactory || (config => new RTCPeerConnection(config));
26
+ this._threshold = opts.backpressureThreshold ?? 262144; // 256 КБ
27
+ this._onPeersChange = opts.onPeersChange;
28
+
29
+ this._peers = new Map(); // clientId → { pc, meta, state, openCount }
30
+
31
+ signaling.publisher.on('webrtc_offer', 'onOffer', this);
32
+ signaling.publisher.on('ice_candidate', 'onRemoteCandidate', this);
33
+ signaling.publisher.on('ping_host', 'onPing', this);
34
+ }
35
+
36
+ // приём SDP-оффера клиента: создаёт peer, отвечает answer
37
+ async onOffer(msg) {
38
+ const { clientId, sdp } = msg;
39
+
40
+ if (this._peers.has(clientId)) {
41
+ return;
42
+ }
43
+
44
+ const pc = this._peerFactory({ iceServers: this._iceServers });
45
+ const peer = { pc, meta: null, state: null, openCount: 0 };
46
+
47
+ this._peers.set(clientId, peer);
48
+
49
+ // каналы создаёт offerer — ловим их здесь
50
+ pc.ondatachannel = event => this._wireChannel(clientId, peer, event.channel);
51
+
52
+ pc.onicecandidate = event => {
53
+ if (event.candidate) {
54
+ this._signaling.sendIceCandidate(clientId, event.candidate);
55
+ }
56
+ };
57
+
58
+ pc.onconnectionstatechange = () => {
59
+ const st = pc.connectionState;
60
+
61
+ // 'disconnected' транзиентен (может восстановиться) — не рвём;
62
+ // реальный обрыв доведёт до 'failed' или закроет каналы (onclose)
63
+ if (st === 'failed' || st === 'closed') {
64
+ this._closePeer(clientId);
65
+ }
66
+ };
67
+
68
+ try {
69
+ await pc.setRemoteDescription(sdp);
70
+
71
+ const answer = await pc.createAnswer();
72
+
73
+ await pc.setLocalDescription(answer);
74
+ } catch (e) {
75
+ // битый SDP или peer уже закрыт — не оставляем осиротевшую запись
76
+ this._closePeer(clientId);
77
+ return;
78
+ }
79
+
80
+ this._signaling.sendAnswer(clientId, pc.localDescription);
81
+ }
82
+
83
+ // приём ICE-кандидата клиента
84
+ async onRemoteCandidate(msg) {
85
+ const peer = this._peers.get(msg.fromId);
86
+
87
+ if (!peer) {
88
+ return;
89
+ }
90
+
91
+ try {
92
+ await peer.pc.addIceCandidate(msg.candidate);
93
+ } catch (e) {
94
+ // кандидат до setRemoteDescription или дубль — не критично
95
+ }
96
+ }
97
+
98
+ // сигнальный ping клиента из лобби → pong (замер приблизительный)
99
+ onPing(msg) {
100
+ this._signaling.pongHost(msg.clientId, msg.pingId);
101
+ }
102
+
103
+ _wireChannel(clientId, peer, channel) {
104
+ channel.binaryType = 'arraybuffer';
105
+
106
+ if (channel.label === 'meta') {
107
+ peer.meta = channel;
108
+ } else if (channel.label === 'state') {
109
+ peer.state = channel;
110
+ }
111
+
112
+ // входящие сообщения клиента (управление) — в Worker
113
+ channel.onmessage = event => this._controller.send(clientId, event.data);
114
+ channel.onclose = () => this._closePeer(clientId);
115
+
116
+ channel.onopen = () => {
117
+ // peer мог закрыться до открытия второго канала (гонка open/close) —
118
+ // фантомное соединение в Worker'е поднимать нельзя
119
+ if (this._peers.get(clientId) !== peer) {
120
+ return;
121
+ }
122
+
123
+ peer.openCount += 1;
124
+
125
+ // оба канала открыты — поднимаем соединение клиента в Worker'е
126
+ // (Worker сразу шлёт CONFIG_DATA по meta)
127
+ if (peer.openCount === 2) {
128
+ this._controller.open(clientId, {
129
+ onMessage: (payload, reliable) => this._deliver(peer, payload, reliable),
130
+ onClose: () => this._closePeer(clientId),
131
+ });
132
+
133
+ this._onPeersChange?.(this._peers.size);
134
+ }
135
+ };
136
+ }
137
+
138
+ // раскладывает исходящий Worker-кадр по каналам meta/state
139
+ _deliver(peer, payload, reliable) {
140
+ // JSON-протокол и событийные кадры — надёжно по meta; позиционные кадры
141
+ // и PING — по state (замер RTT не искажается ретрансмиссиями meta)
142
+ const channel = reliable === false ? peer.state : peer.meta;
143
+
144
+ if (!channel || channel.readyState !== 'open') {
145
+ return;
146
+ }
147
+
148
+ // бэкпрешер: позиционные кадры медленному пиру дропаем (следующий кадр
149
+ // компенсирует потерю); meta не дропается никогда
150
+ if (reliable === false && channel.bufferedAmount > this._threshold) {
151
+ return;
152
+ }
153
+
154
+ channel.send(payload);
155
+ }
156
+
157
+ _closePeer(clientId) {
158
+ const peer = this._peers.get(clientId);
159
+
160
+ if (!peer) {
161
+ return;
162
+ }
163
+
164
+ this._peers.delete(clientId);
165
+ this._controller.disconnect(clientId); // Worker: removeUser
166
+
167
+ // снять обработчики каналов — замыкания не должны удерживать peer
168
+ for (const channel of [peer.meta, peer.state]) {
169
+ if (channel) {
170
+ channel.onopen = null;
171
+ channel.onmessage = null;
172
+ channel.onclose = null;
173
+ }
174
+ }
175
+
176
+ try {
177
+ peer.pc.close();
178
+ } catch (e) {
179
+ // уже закрыт
180
+ }
181
+
182
+ this._onPeersChange?.(this._peers.size);
183
+ }
184
+
185
+ // число активных пиров (для currentPlayers у мастера)
186
+ get peerCount() {
187
+ return this._peers.size;
188
+ }
189
+
190
+ // закрывает все соединения (закрытие комнаты)
191
+ destroy() {
192
+ for (const clientId of [...this._peers.keys()]) {
193
+ this._closePeer(clientId);
194
+ }
195
+ }
196
+ }
@@ -0,0 +1,422 @@
1
+ // Мост главного потока между Worker'ом хоста (авторитетная симуляция) и
2
+ // транспортами клиентов. Worker не имеет доступа к RTCPeerConnection — главный
3
+ // поток роутит пакеты: исходящие кадры Worker'а (to_client) → нужному клиенту,
4
+ // входящие сообщения клиентов → в Worker. В Фазе 1 единственный клиент —
5
+ // хост-игрок через LoopbackTransport; в Фазе 2 сюда же подключается
6
+ // HostConnectionManager (удалённые клиенты по WebRTC).
7
+ //
8
+ // Эстафета Worker'ов (Этап 5.2): swapWorker(url) заменяет Worker на новую
9
+ // версию кода без разрыва P2P — старый Worker отдаёт handoff-состояние на
10
+ // границе раунда, новый поднимается с ним, клиенты переподключаются
11
+ // внутренними connect'ами (WebRTC-каналы живут здесь и не трогаются).
12
+
13
+ // предохранитель от зависшего init нового Worker'а: не дождались ready —
14
+ // своп отменяется, комната продолжает жить на старом Worker'е
15
+ const SWAP_INIT_TIMEOUT = 15000;
16
+
17
+ // кап очереди клиентских сообщений, копящихся за паузу эстафеты
18
+ const SWAP_QUEUE_LIMIT = 2000;
19
+
20
+ // предел ожидания ответа Worker'а на отладочный запрос (dump/запись)
21
+ const DEBUG_TIMEOUT = 5000;
22
+
23
+ export default class HostController {
24
+ /**
25
+ * @param {Object} room - настройки комнаты (имя/карта/лимит/таймеры).
26
+ * @param {Object} [opts]
27
+ * @param {Function} [opts.workerFactory] - фабрика Worker'а (для тестов);
28
+ * вызывается и при эстафете (с url новой версии).
29
+ * @param {string} [opts.workerUrl] - URL worker-бандла из манифеста мастера
30
+ * (Этап 5.2); без него — бандловый URL (dev, обновлений кода нет).
31
+ * @param {Function} [opts.onReady] - вызывается, когда Worker готов
32
+ * (авторитетная часть поднята) — момент регистрации хоста у мастера.
33
+ * При эстафете повторно не вызывается.
34
+ * @param {Function} [opts.onError] - сбой инициализации Worker'а
35
+ * (WASM/конфиг): комната не поднялась, нужно вернуть пользователя в лобби.
36
+ * @param {Function} [opts.onMapChange] - смена карты в комнате (голосование/
37
+ * таймер) — для актуализации mapName у мастера.
38
+ */
39
+ constructor(
40
+ room,
41
+ { workerFactory, workerUrl, onReady, onError, onMapChange } = {},
42
+ ) {
43
+ this._room = room;
44
+ this._workerFactory = workerFactory;
45
+ this._worker = this._createWorker(workerUrl);
46
+
47
+ this._onReady = onReady;
48
+ this._onError = onError;
49
+ this._onMapChange = onMapChange;
50
+ this._ready = false;
51
+ this._deliveries = new Map(); // socketId → { onMessage, onClose }
52
+ this._pendingConnects = []; // socketId, ожидающие готовности Worker'а
53
+
54
+ this._swap = null; // состояние эстафеты (Этап 5.2)
55
+
56
+ // отладочные запросы в Worker (этап 6 плана plan/done/ai-debug):
57
+ // requestId → { resolve, reject }
58
+ this._debugRequests = new Map();
59
+ this._debugRequestId = 0;
60
+
61
+ this._worker.onmessage = e => this._onWorkerMessage(e.data);
62
+
63
+ // старт авторитетной части в Worker'е
64
+ this._worker.postMessage({ type: 'init', room });
65
+ }
66
+
67
+ _createWorker(url) {
68
+ if (this._workerFactory) {
69
+ return this._workerFactory(url);
70
+ }
71
+
72
+ return url
73
+ ? new Worker(url, { type: 'module' })
74
+ : new Worker(new URL('../../host/host.worker.js', import.meta.url), {
75
+ type: 'module',
76
+ });
77
+ }
78
+
79
+ // регистрирует клиента и (при готовности) поднимает его соединение в Worker'е
80
+ open(socketId, { onMessage, onClose }) {
81
+ this._deliveries.set(socketId, { onMessage, onClose });
82
+
83
+ if (!this._ready) {
84
+ this._pendingConnects.push(socketId);
85
+ return;
86
+ }
87
+
88
+ // пауза эстафеты: connect доедет в новый Worker (или в старый при отмене)
89
+ if (this._swap?.paused) {
90
+ this._enqueueSwapMessage({ type: 'connect', socketId });
91
+ return;
92
+ }
93
+
94
+ this._worker.postMessage({ type: 'connect', socketId });
95
+ }
96
+
97
+ // пересылает входящее сообщение клиента в Worker
98
+ send(socketId, data) {
99
+ const msg = { type: 'message', socketId, data };
100
+
101
+ if (this._swap?.paused) {
102
+ this._enqueueSwapMessage(msg);
103
+ return;
104
+ }
105
+
106
+ this._worker.postMessage(msg);
107
+ }
108
+
109
+ // отключает клиента
110
+ disconnect(socketId) {
111
+ this._deliveries.delete(socketId);
112
+
113
+ const msg = { type: 'disconnect', socketId };
114
+
115
+ if (this._swap?.paused) {
116
+ this._enqueueSwapMessage(msg);
117
+ return;
118
+ }
119
+
120
+ this._worker.postMessage(msg);
121
+ }
122
+
123
+ // сообщает Worker'у hostId + per-room секрет, подтверждённые мастером при
124
+ // register_host (кодревью №1, plan/server-rating/review.md) — не известны
125
+ // при создании Worker'а (постится в него раньше ответа мастера).
126
+ // Сохраняются в _room, чтобы эстафета (swapWorker) тоже понесла их в новый
127
+ // Worker через 'init'
128
+ setHostId(hostId, hostSecret) {
129
+ this._room.hostId = hostId;
130
+ this._room.hostSecret = hostSecret;
131
+ this._worker.postMessage({ type: 'set_host_id', hostId, hostSecret });
132
+ }
133
+
134
+ // передаёт обновлённый каталог карт мастера в Worker (Этап 5.1);
135
+ // применится со следующей смены карты
136
+ updateMaps(maps) {
137
+ // новый Worker эстафеты должен подняться на актуальных картах
138
+ this._room.maps = maps;
139
+
140
+ const msg = { type: 'update_maps', maps };
141
+
142
+ if (this._swap?.paused) {
143
+ this._enqueueSwapMessage(msg);
144
+ return;
145
+ }
146
+
147
+ this._worker.postMessage(msg);
148
+ }
149
+
150
+ /**
151
+ * Эстафета Worker'ов (Этап 5.2): заменяет Worker на бандл новой версии.
152
+ * Старый Worker останавливается на ближайшей границе раунда и отдаёт
153
+ * handoff-состояние; новый поднимается с ним, все живые клиенты
154
+ * переподключаются внутренними connect'ами. Сбой нового Worker'а —
155
+ * откат: старый возобновляется, комната живёт на прежней версии.
156
+ * @param {string} url - URL worker-бандла из манифеста мастера.
157
+ * @param {Object} [game] - свежий room.game (Этап 6.5: {id, version,
158
+ * hostEntryUrl, wasmUrl}) — подменяет закэшированный с момента создания
159
+ * комнаты перед init нового Worker'а, чтобы деплой игры тоже подхватывался
160
+ * эстафетой, а не только деплой движка.
161
+ * @returns {Promise<void>}
162
+ */
163
+ swapWorker(url, game) {
164
+ if (this._swap) {
165
+ return Promise.reject(new Error('worker swap already in progress'));
166
+ }
167
+
168
+ if (!this._ready) {
169
+ return Promise.reject(new Error('worker is not ready'));
170
+ }
171
+
172
+ return new Promise((resolve, reject) => {
173
+ this._swap = {
174
+ url,
175
+ game,
176
+ paused: false,
177
+ queue: [],
178
+ next: null,
179
+ timeout: null,
180
+ resolve,
181
+ reject,
182
+ };
183
+
184
+ this._worker.postMessage({ type: 'prepare_handoff' });
185
+ });
186
+ }
187
+
188
+ /**
189
+ * Отладочный контур (этап 6 плана plan/done/ai-debug): начинает запись живого
190
+ * матча в формат сценария headless-runner'а.
191
+ * @returns {Promise<boolean>} false — dev-режим в комнате выключен.
192
+ */
193
+ startRecording() {
194
+ return this._debug('startRecording');
195
+ }
196
+
197
+ /**
198
+ * Останавливает запись и отдаёт сценарий (`npm run sim:replay`).
199
+ * @returns {Promise<Object|null>}
200
+ */
201
+ stopRecording() {
202
+ return this._debug('stopRecording');
203
+ }
204
+
205
+ /**
206
+ * Дамп авторитетной половины: мета хоста + мир ядра (этап 4).
207
+ * @returns {Promise<Object>}
208
+ */
209
+ dump() {
210
+ return this._debug('dump');
211
+ }
212
+
213
+ // запрос/ответ с Worker'ом по requestId: postMessage односторонний, а
214
+ // отладке нужен именно результат, а не факт отправки. Таймаут обязателен:
215
+ // отладка нужна ровно на зависшем Worker'е, а там ответа не будет никогда —
216
+ // молча висящий await в консоли и есть тот отказ, против которого всё это
217
+ // писалось
218
+ _debug(action, timeoutMs = DEBUG_TIMEOUT) {
219
+ if (this._swap?.paused) {
220
+ return Promise.reject(new Error('worker swap in progress'));
221
+ }
222
+
223
+ this._debugRequestId += 1;
224
+
225
+ const requestId = this._debugRequestId;
226
+
227
+ return new Promise((resolve, reject) => {
228
+ const timer = setTimeout(() => {
229
+ this._debugRequests.delete(requestId);
230
+ reject(
231
+ new Error(`debug request '${action}' timed out after ${timeoutMs} ms`),
232
+ );
233
+ }, timeoutMs);
234
+
235
+ this._debugRequests.set(requestId, {
236
+ resolve: value => {
237
+ clearTimeout(timer);
238
+ resolve(value);
239
+ },
240
+ reject: error => {
241
+ clearTimeout(timer);
242
+ reject(error);
243
+ },
244
+ });
245
+ this._worker.postMessage({ type: 'debug', action, requestId });
246
+ });
247
+ }
248
+
249
+ // ответы приходят от конкретного Worker'а: его смерть (destroy, эстафета)
250
+ // означает, что ждать нечего — висящий промис хуже честной ошибки
251
+ _rejectDebugRequests(reason) {
252
+ for (const { reject } of this._debugRequests.values()) {
253
+ reject(new Error(reason));
254
+ }
255
+
256
+ this._debugRequests.clear();
257
+ }
258
+
259
+ // останавливает Worker (закрытие комнаты)
260
+ destroy() {
261
+ this._rejectDebugRequests('host destroyed');
262
+
263
+ if (this._swap) {
264
+ this._swap.next?.terminate();
265
+ this._clearSwapTimeout();
266
+ this._swap = null;
267
+ }
268
+
269
+ this._worker.terminate();
270
+ }
271
+
272
+ _enqueueSwapMessage(msg) {
273
+ if (this._swap.queue.length >= SWAP_QUEUE_LIMIT) {
274
+ return; // пауза затянулась — свежие сообщения дропаются
275
+ }
276
+
277
+ this._swap.queue.push(msg);
278
+ }
279
+
280
+ _clearSwapTimeout() {
281
+ if (this._swap?.timeout) {
282
+ clearTimeout(this._swap.timeout);
283
+ this._swap.timeout = null;
284
+ }
285
+ }
286
+
287
+ // старый Worker достиг границы раунда и отдал состояние: поднять новый
288
+ _onHandoffState(state) {
289
+ if (!this._swap) {
290
+ return; // своп уже отменён (destroy)
291
+ }
292
+
293
+ this._swap.paused = true;
294
+
295
+ // запись/дамп относятся к останавливаемому Worker'у — новый их не знает
296
+ this._rejectDebugRequests('worker swap in progress');
297
+
298
+ // Этап 6.5: своп несёт свежий манифест игры — новый Worker должен
299
+ // грузить актуальный hostEntryUrl/wasmUrl, а не тот, с которым комната
300
+ // стартовала (иначе деплой игры без деплоя движка не подхватился бы)
301
+ if (this._swap.game) {
302
+ this._room.game = this._swap.game;
303
+ }
304
+
305
+ const next = this._createWorker(this._swap.url);
306
+
307
+ this._swap.next = next;
308
+ this._swap.timeout = setTimeout(
309
+ () => this._abortSwap('swap init timeout'),
310
+ SWAP_INIT_TIMEOUT,
311
+ );
312
+
313
+ next.onmessage = e => this._onNextWorkerMessage(e.data);
314
+ next.postMessage({ type: 'init', room: this._room, handoff: state });
315
+ }
316
+
317
+ // сообщения нового Worker'а до завершения свопа: ждём только ready/error
318
+ _onNextWorkerMessage(msg) {
319
+ if (msg.type === 'ready') {
320
+ this._finishSwap();
321
+ } else if (msg.type === 'error') {
322
+ this._abortSwap(msg.message);
323
+ }
324
+ }
325
+
326
+ // новый Worker готов: переподключить клиентов, дослать накопленное,
327
+ // завершить эстафету и погасить старый Worker
328
+ _finishSwap() {
329
+ const { next, queue, resolve } = this._swap;
330
+
331
+ this._clearSwapTimeout();
332
+
333
+ for (const socketId of this._deliveries.keys()) {
334
+ next.postMessage({ type: 'connect', socketId });
335
+ }
336
+
337
+ // накопленное за паузу — после connect'ов (порт-машины уже подняты);
338
+ // дубль connect безвреден (Worker игнорирует повторные)
339
+ for (const msg of queue) {
340
+ next.postMessage(msg);
341
+ }
342
+
343
+ next.postMessage({ type: 'handoff_complete' });
344
+
345
+ this._worker.terminate();
346
+ this._worker = next;
347
+ this._worker.onmessage = e => this._onWorkerMessage(e.data);
348
+ this._swap = null;
349
+
350
+ resolve();
351
+ }
352
+
353
+ // новый Worker не поднялся: вернуть старый к жизни, комната продолжает
354
+ // жить на прежней версии кода
355
+ _abortSwap(reason) {
356
+ const { next, queue, reject } = this._swap;
357
+
358
+ this._clearSwapTimeout();
359
+ next?.terminate();
360
+
361
+ this._worker.postMessage({ type: 'resume' });
362
+
363
+ for (const msg of queue) {
364
+ this._worker.postMessage(msg);
365
+ }
366
+
367
+ this._swap = null;
368
+
369
+ reject(new Error(reason || 'worker swap failed'));
370
+ }
371
+
372
+ _onWorkerMessage(msg) {
373
+ switch (msg.type) {
374
+ case 'ready':
375
+ this._ready = true;
376
+
377
+ for (const socketId of this._pendingConnects) {
378
+ this._worker.postMessage({ type: 'connect', socketId });
379
+ }
380
+
381
+ this._pendingConnects.length = 0;
382
+ this._onReady?.(msg);
383
+ break;
384
+
385
+ case 'error':
386
+ this._onError?.(msg);
387
+ break;
388
+
389
+ case 'map_changed':
390
+ this._onMapChange?.(msg.mapName);
391
+ break;
392
+
393
+ case 'handoff_state':
394
+ this._onHandoffState(msg.state);
395
+ break;
396
+
397
+ case 'debug_result': {
398
+ const pending = this._debugRequests.get(msg.requestId);
399
+
400
+ if (pending) {
401
+ this._debugRequests.delete(msg.requestId);
402
+
403
+ if (msg.error) {
404
+ pending.reject(new Error(msg.error));
405
+ } else {
406
+ pending.resolve(msg.result);
407
+ }
408
+ }
409
+
410
+ break;
411
+ }
412
+
413
+ case 'to_client':
414
+ this._deliveries.get(msg.socketId)?.onMessage(msg.payload, msg.reliable);
415
+ break;
416
+
417
+ case 'close_client':
418
+ this._deliveries.get(msg.socketId)?.onClose(msg.code, msg.data);
419
+ break;
420
+ }
421
+ }
422
+ }