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,152 @@
1
+ import wsports from '../../config/wsports.js';
2
+ import { createHostRuntime } from '../../lib/createHostRuntime.js';
3
+ import { offlinePlayerData } from '../../lib/offlinePlayerData.js';
4
+ import PortMachine from '../../host/PortMachine.js';
5
+ import { createGuestIdentity } from '../../host/identity.js';
6
+
7
+ // Замена HostController для solo-режима (Этап 2 плана standalone-sdk): тот же
8
+ // интерфейс, который дёргает LoopbackTransport (open/send/disconnect), но
9
+ // вместо Worker'а хост крутится в этом же потоке.
10
+ //
11
+ // Почему inline, а не Worker: HostPlugin принципиально непередаваем в Worker
12
+ // (postMessage не несёт функции), а standalone SDK получает от репозитория
13
+ // игры именно живой объект плагина. Прод-путь (хост в Worker'е) не меняется —
14
+ // расхождение dev/prod осознанное, см. plan/standalone-sdk/README.md.
15
+ //
16
+ // Мастера в этом контуре нет: личность гостевая (ник из формы, identity.js),
17
+ // профиль offline-заглушкой (lib/offlinePlayerData.js).
18
+
19
+ const PS_TECH_INFORM_DATA = wsports.server.TECH_INFORM_DATA;
20
+
21
+ export default class InlineHostBridge {
22
+ /**
23
+ * @param {Object} room - Описание комнаты (game.wasmUrl, hostSocketId, …).
24
+ * @param {Object} deps
25
+ * @param {Object} deps.hostPlugin - Живой HostPlugin игры.
26
+ * @param {Function} [deps.onMapChange] - Уведомление о смене карты.
27
+ */
28
+ constructor(room, { hostPlugin, onMapChange } = {}) {
29
+ // socketId → { onMessage, onClose } транспорта
30
+ this._clients = new Map();
31
+ this._runtime = null;
32
+ this._portMachine = null;
33
+ this._destroyed = false;
34
+
35
+ // хост поднимается асинхронно (ядро игры + WASM): open() до готовности
36
+ // потерял бы хендшейк, поэтому вызывающий обязан дождаться ready
37
+ this.ready = this._init(room, { hostPlugin, onMapChange });
38
+ }
39
+
40
+ /**
41
+ * Новое соединение клиента: регистрация обработчиков и старт хендшейка.
42
+ * @param {string} socketId
43
+ * @param {Object} handlers - { onMessage, onClose }.
44
+ */
45
+ open(socketId, handlers) {
46
+ this._assertReady();
47
+ this._clients.set(socketId, handlers);
48
+ this._portMachine.connect(socketId);
49
+ }
50
+
51
+ /**
52
+ * Кадр клиента хосту (wire-строка [port, payload]).
53
+ * @param {string} socketId
54
+ * @param {string} data
55
+ */
56
+ send(socketId, data) {
57
+ this._assertReady();
58
+ this._portMachine.message(socketId, data);
59
+ }
60
+
61
+ /**
62
+ * Отключение клиента.
63
+ * @param {string} socketId
64
+ */
65
+ disconnect(socketId) {
66
+ if (!this._clients.delete(socketId)) {
67
+ return;
68
+ }
69
+
70
+ this._portMachine.disconnect(socketId);
71
+ }
72
+
73
+ /**
74
+ * Останов матча: таймеры хоста держали бы вкладку живой и после выхода.
75
+ * @returns {Promise} Завершение teardown хоста.
76
+ */
77
+ destroy() {
78
+ this._destroyed = true;
79
+ this._clients.clear();
80
+
81
+ // ready может быть ещё в полёте — без ожидания хост поднялся бы уже
82
+ // после teardown и остался бы во вкладке со своим игровым циклом
83
+ return this.ready.catch(() => {}).then(() => this._runtime?.host.destroy());
84
+ }
85
+
86
+ async _init(room, { hostPlugin, onMapChange }) {
87
+ const runtime = await createHostRuntime(room, {
88
+ // плагин уже живой объект — динамического import'а по URL нет
89
+ loadHostPlugin: async () => hostPlugin,
90
+ hostOptions: {
91
+ playerDataFetch: offlinePlayerData(),
92
+ onMapChange,
93
+ },
94
+ });
95
+
96
+ // stop() успел прийти раньше готовности: поднятый хост нужно погасить
97
+ // здесь, иначе его таймеры останутся во вкладке навсегда
98
+ if (this._destroyed) {
99
+ await runtime.host.destroy();
100
+ return;
101
+ }
102
+
103
+ this._runtime = runtime;
104
+
105
+ this._portMachine = new PortMachine({
106
+ host: runtime.host,
107
+ socketManager: runtime.socketManager,
108
+ clientCfg: runtime.clientCfg,
109
+ authSchema: runtime.hostPlugin.authSchema,
110
+ makeSocket: socketId => this._makeSocket(socketId),
111
+ identity: createGuestIdentity(),
112
+ });
113
+ }
114
+
115
+ // хост поднимается асинхронно: без ready порт-машины ещё нет, и вызов
116
+ // упал бы сырым TypeError на null. После destroy() порт-машина остаётся, но
117
+ // хост уже погашен — кадр создал бы участника без таймеров матча
118
+ _assertReady() {
119
+ if (this._destroyed) {
120
+ throw new Error('InlineHostBridge: the host is destroyed');
121
+ }
122
+
123
+ if (!this._portMachine) {
124
+ throw new Error('InlineHostBridge: await ready first');
125
+ }
126
+ }
127
+
128
+ // wire-сокет пользователя: кладёт кадр прямо в onMessage транспорта —
129
+ // ровно те же форматы, что makeWorkerSocket отправляет постмесседжем
130
+ _makeSocket(socketId) {
131
+ return {
132
+ send: (port, data) =>
133
+ this._deliver(socketId, JSON.stringify([port, data])),
134
+
135
+ sendBinary: buffer => this._deliver(socketId, buffer),
136
+
137
+ // причина закрытия доставляется отдельным TECH_INFORM до закрытия —
138
+ // как в Worker'е (порядок гарантирован синхронной доставкой)
139
+ close: (code, data) => {
140
+ if (data !== undefined) {
141
+ this._deliver(socketId, JSON.stringify([PS_TECH_INFORM_DATA, data]));
142
+ }
143
+
144
+ this._clients.get(socketId)?.onClose();
145
+ },
146
+ };
147
+ }
148
+
149
+ _deliver(socketId, payload) {
150
+ this._clients.get(socketId)?.onMessage(payload);
151
+ }
152
+ }
@@ -0,0 +1,51 @@
1
+ import Publisher from '../../lib/Publisher.js';
2
+
3
+ // Транспорт хоста-игрока: та же вкладка играет в собственной комнате без
4
+ // WebRTC. Реализует интерфейс WebRtcManager (publisher c 'message'/'close',
5
+ // send/close), но данные ходят через HostController → Worker постмесседжами
6
+ // (postMessage-loopback). Для клиентского кода транспорт прозрачен.
7
+ //
8
+ // Флаг reliable игнорируется: loopback доставляет всё надёжно и по порядку
9
+ // (разделение meta/state актуально только для реального WebRTC).
10
+ export default class LoopbackTransport {
11
+ /**
12
+ * @param {HostController} controller - мост к Worker'у.
13
+ * @param {string} [socketId] - id соединения хоста-игрока в Worker'е.
14
+ */
15
+ constructor(controller, socketId = 'local') {
16
+ this._controller = controller;
17
+ this._socketId = socketId;
18
+ this._closed = false;
19
+
20
+ this.publisher = new Publisher();
21
+ }
22
+
23
+ // поднимает loopback-соединение (аналог WebRtcManager.connect)
24
+ connect() {
25
+ this._controller.open(this._socketId, {
26
+ onMessage: payload => this.publisher.emit('message', payload),
27
+ onClose: () => this._emitClose(),
28
+ });
29
+ }
30
+
31
+ // отправляет данные хосту (в Worker через роутер главного потока)
32
+ send(data) {
33
+ if (!this._closed) {
34
+ this._controller.send(this._socketId, data);
35
+ }
36
+ }
37
+
38
+ close() {
39
+ this._emitClose();
40
+ }
41
+
42
+ _emitClose() {
43
+ if (this._closed) {
44
+ return;
45
+ }
46
+
47
+ this._closed = true;
48
+ this._controller.disconnect(this._socketId);
49
+ this.publisher.emit('close');
50
+ }
51
+ }
@@ -0,0 +1,146 @@
1
+ import Publisher from '../../lib/Publisher.js';
2
+
3
+ // Клиент сигнального WebSocket мастер-сервера (src/master/SignalingServer.js).
4
+ // Только координация установки P2P: приём welcome (id соединения + iceServers),
5
+ // обмен SDP-офферами/ответами и ICE-кандидатами, сигнальный ping/pong, жалобы.
6
+ // Игровой трафик идёт по WebRTC-каналам (WebRtcManager), не через мастер.
7
+ //
8
+ // Входящие сообщения ретранслируются подписчикам через Publisher по полю type;
9
+ // welcome дополнительно кэширует id/iceServers. Транспорт WebSocket инъекций
10
+ // ради тестируемости — фабрика по умолчанию использует глобальный WebSocket.
11
+ export default class SignalingClient {
12
+ constructor(url, socketFactory = u => new WebSocket(u)) {
13
+ this._url = url;
14
+ this._socketFactory = socketFactory;
15
+
16
+ this._ws = null;
17
+ this._id = null;
18
+ this._iceServers = [];
19
+
20
+ this.publisher = new Publisher();
21
+ }
22
+
23
+ get id() {
24
+ return this._id;
25
+ }
26
+
27
+ get iceServers() {
28
+ return this._iceServers;
29
+ }
30
+
31
+ get connected() {
32
+ return this._ws !== null && this._ws.readyState === this._ws.OPEN;
33
+ }
34
+
35
+ // открывает соединение; событие 'welcome' — после приёма welcome от мастера
36
+ connect() {
37
+ if (this._ws) {
38
+ return;
39
+ }
40
+
41
+ const ws = this._socketFactory(this._url);
42
+
43
+ this._ws = ws;
44
+
45
+ ws.onopen = () => this.publisher.emit('open');
46
+ ws.onerror = () => this.publisher.emit('socketError');
47
+
48
+ ws.onclose = event => {
49
+ this._ws = null;
50
+ this.publisher.emit('close', event);
51
+ };
52
+
53
+ ws.onmessage = event => this._onMessage(event.data);
54
+ }
55
+
56
+ // клиент → SDP-оффер конкретному хосту
57
+ sendOffer(hostId, sdp) {
58
+ this._send({ type: 'webrtc_offer', hostId, sdp });
59
+ }
60
+
61
+ // хост → регистрация комнаты у мастера (ответ — событие 'host_registered').
62
+ // token — Bearer identity-токен хостера (server-rating этап 2): без него
63
+ // мастер отклоняет регистрацию, т.к. не может атрибутировать комнату и
64
+ // проверить рейтинг хостера
65
+ registerHost({ name, maxPlayers, mapName, gameId, gameVersion, token }) {
66
+ this._send({
67
+ type: 'register_host',
68
+ name,
69
+ maxPlayers,
70
+ mapName,
71
+ gameId,
72
+ gameVersion,
73
+ token,
74
+ });
75
+ }
76
+
77
+ // хост → актуализация комнаты (заодно heartbeat)
78
+ updateHost({ currentPlayers, mapName } = {}) {
79
+ this._send({ type: 'update_host', currentPlayers, mapName });
80
+ }
81
+
82
+ // хост → SDP-ответ конкретному клиенту
83
+ sendAnswer(clientId, sdp) {
84
+ this._send({ type: 'webrtc_answer', clientId, sdp });
85
+ }
86
+
87
+ // хост → pong на сигнальный ping клиента (замер задержки в лобби)
88
+ pongHost(clientId, pingId) {
89
+ this._send({ type: 'pong_host', clientId, pingId });
90
+ }
91
+
92
+ // обмен ICE-кандидатами (targetId — hostId со стороны клиента)
93
+ sendIceCandidate(targetId, candidate) {
94
+ this._send({ type: 'ice_candidate', targetId, candidate });
95
+ }
96
+
97
+ // сигнальный ping хосту (замер приблизительный: клиент→мастер→хост)
98
+ pingHost(hostId, pingId) {
99
+ this._send({ type: 'ping_host', hostId, pingId });
100
+ }
101
+
102
+ // /like·/unlike напрямую мастеру (минуя хоста-читера), server-rating
103
+ // этап 2: reason — причина (обязательна), token — Bearer identity-токен
104
+ // голосующего
105
+ likeHost(hostId, reason, token) {
106
+ this._send({ type: 'like_host', hostId, reason, token });
107
+ }
108
+
109
+ unlikeHost(hostId, reason, token) {
110
+ this._send({ type: 'unlike_host', hostId, reason, token });
111
+ }
112
+
113
+ close() {
114
+ if (this._ws) {
115
+ this._ws.close();
116
+ this._ws = null;
117
+ }
118
+ }
119
+
120
+ _onMessage(raw) {
121
+ let msg;
122
+
123
+ try {
124
+ msg = JSON.parse(raw);
125
+ } catch (e) {
126
+ return;
127
+ }
128
+
129
+ if (!msg || typeof msg.type !== 'string') {
130
+ return;
131
+ }
132
+
133
+ if (msg.type === 'welcome') {
134
+ this._id = msg.id;
135
+ this._iceServers = msg.iceServers || [];
136
+ }
137
+
138
+ this.publisher.emit(msg.type, msg);
139
+ }
140
+
141
+ _send(message) {
142
+ if (this.connected) {
143
+ this._ws.send(JSON.stringify(message));
144
+ }
145
+ }
146
+ }
@@ -0,0 +1,147 @@
1
+ import Publisher from '../../lib/Publisher.js';
2
+
3
+ // Транспорт P2P-соединения клиента с браузерным хостом. Заменяет игровой
4
+ // WebSocket: два RTCDataChannel вместо одного сокета.
5
+ //
6
+ // - meta (reliable-ordered) — весь JSON-протокол [portId, payload] и
7
+ // бинарные кадры с одноразовыми событиями;
8
+ // - state (unreliable-unordered) — чисто позиционные бинарные кадры, их
9
+ // потерю компенсирует следующий кадр.
10
+ //
11
+ // Классификация кадров meta/state — на стороне хоста при упаковке. Клиент лишь
12
+ // принимает данные из обоих каналов и отдаёт их одним потоком (событие
13
+ // 'message'), как раньше делал ws.onmessage. Исходящие сообщения клиента —
14
+ // управляющие, идут по reliable-каналу meta.
15
+ //
16
+ // Клиент — инициатор (offerer): создаёт каналы и оффер, обменивается с хостом
17
+ // SDP/ICE через сигнальный WebSocket (SignalingClient). RTCPeerConnection
18
+ // инъектируется фабрикой ради тестируемости.
19
+ export default class WebRtcManager {
20
+ constructor(signaling, { iceServers, peerFactory } = {}) {
21
+ this._signaling = signaling;
22
+ this._iceServers = iceServers || signaling.iceServers;
23
+ this._peerFactory =
24
+ peerFactory || (config => new RTCPeerConnection(config));
25
+
26
+ this._pc = null;
27
+ this._meta = null;
28
+ this._state = null;
29
+ this._hostId = null;
30
+ this._openChannels = 0;
31
+ this._closed = false;
32
+
33
+ this.publisher = new Publisher();
34
+
35
+ // подписки на сигнальные ответы конкретно этого соединения
36
+ this._signaling.publisher.on('webrtc_answer', 'onAnswer', this);
37
+ this._signaling.publisher.on('ice_candidate', 'onRemoteCandidate', this);
38
+ }
39
+
40
+ // инициирует установку соединения с хостом
41
+ async connect(hostId) {
42
+ this._hostId = hostId;
43
+
44
+ const pc = this._peerFactory({ iceServers: this._iceServers });
45
+
46
+ this._pc = pc;
47
+
48
+ // meta открывает канал, отправляемый по надёжному упорядоченному потоку
49
+ this._meta = pc.createDataChannel('meta', { ordered: true });
50
+ this._meta.binaryType = 'arraybuffer';
51
+
52
+ // state — ненадёжный неупорядоченный (позиционные кадры)
53
+ this._state = pc.createDataChannel('state', {
54
+ ordered: false,
55
+ maxRetransmits: 0,
56
+ });
57
+ this._state.binaryType = 'arraybuffer';
58
+
59
+ this._wireChannel(this._meta);
60
+ this._wireChannel(this._state);
61
+
62
+ pc.onicecandidate = event => {
63
+ if (event.candidate) {
64
+ this._signaling.sendIceCandidate(hostId, event.candidate);
65
+ }
66
+ };
67
+
68
+ pc.onconnectionstatechange = () => {
69
+ const st = pc.connectionState;
70
+
71
+ // 'disconnected' транзиентен (может восстановиться) — не рвём;
72
+ // реальный обрыв доведёт до 'failed' или закроет каналы (onclose)
73
+ if (st === 'failed' || st === 'closed') {
74
+ this._emitClose();
75
+ }
76
+ };
77
+
78
+ const offer = await pc.createOffer();
79
+
80
+ await pc.setLocalDescription(offer);
81
+
82
+ this._signaling.sendOffer(hostId, pc.localDescription);
83
+ }
84
+
85
+ // приём SDP-ответа хоста (подписка на сигнальный канал)
86
+ async onAnswer(msg) {
87
+ if (!this._pc || msg.hostId !== this._hostId) {
88
+ return;
89
+ }
90
+
91
+ await this._pc.setRemoteDescription(msg.sdp);
92
+ }
93
+
94
+ // приём удалённого ICE-кандидата (от хоста)
95
+ async onRemoteCandidate(msg) {
96
+ if (!this._pc || msg.fromId !== this._hostId) {
97
+ return;
98
+ }
99
+
100
+ try {
101
+ await this._pc.addIceCandidate(msg.candidate);
102
+ } catch (e) {
103
+ // кандидат до setRemoteDescription или дубль — не критично
104
+ }
105
+ }
106
+
107
+ // отправляет данные хосту; управляющие сообщения клиента — по meta
108
+ send(data, reliable = true) {
109
+ const channel = reliable ? this._meta : this._state;
110
+
111
+ if (channel && channel.readyState === 'open') {
112
+ channel.send(data);
113
+ }
114
+ }
115
+
116
+ close() {
117
+ this._emitClose();
118
+ }
119
+
120
+ _wireChannel(channel) {
121
+ channel.onopen = () => {
122
+ this._openChannels += 1;
123
+
124
+ // оба канала открыты — транспорт готов (как ws.onopen)
125
+ if (this._openChannels === 2) {
126
+ this.publisher.emit('open');
127
+ }
128
+ };
129
+
130
+ channel.onmessage = event => this.publisher.emit('message', event.data);
131
+ channel.onclose = () => this._emitClose();
132
+ }
133
+
134
+ _emitClose() {
135
+ if (this._closed) {
136
+ return;
137
+ }
138
+
139
+ this._closed = true;
140
+
141
+ if (this._pc) {
142
+ this._pc.close();
143
+ }
144
+
145
+ this.publisher.emit('close');
146
+ }
147
+ }
@@ -0,0 +1,78 @@
1
+ import Publisher from '../../lib/Publisher.js';
2
+
3
+ // Третий транспорт клиента (Этап 2 плана standalone-sdk): прямой WebSocket к
4
+ // dedicated-серверу. Интерфейсный близнец WebRtcManager и LoopbackTransport —
5
+ // publisher с 'message'/'close', connect/send/close, — поэтому клиентский код
6
+ // про него не знает.
7
+ //
8
+ // Отличия от WebRTC, которые надо держать в голове (см. docs/en/network.md):
9
+ // разделения meta/state тут нет, флаг reliable игнорируется. Значит PING/PONG
10
+ // идут надёжным каналом (замер RTT становится замером TCP-пути), а бэкпрешер
11
+ // вместо дропа позиционных кадров живёт на серверной стороне.
12
+
13
+ // WebSocket.OPEN: константа класса, а не инстанса — у фейкового сокета в
14
+ // тестах её может не быть вовсе
15
+ const WS_OPEN = 1;
16
+
17
+ export default class WebSocketTransport {
18
+ /**
19
+ * @param {string} url - Адрес игрового WebSocket.
20
+ * @param {Object} [options]
21
+ * @param {Function} [options.socketFactory] - Фабрика сокета (тесты).
22
+ */
23
+ constructor(url, { socketFactory = u => new WebSocket(u) } = {}) {
24
+ this._url = url;
25
+ this._socketFactory = socketFactory;
26
+ this._ws = null;
27
+ this._closed = false;
28
+
29
+ this.publisher = new Publisher();
30
+ }
31
+
32
+ connect() {
33
+ this._ws = this._socketFactory(this._url);
34
+
35
+ // обязательно: клиент различает кадр снапшота и JSON-порт по
36
+ // `data instanceof ArrayBuffer`, а дефолтный браузерный WS отдаёт Blob
37
+ this._ws.binaryType = 'arraybuffer';
38
+
39
+ this._ws.onopen = () => this.publisher.emit('open');
40
+ this._ws.onmessage = event => this.publisher.emit('message', event.data);
41
+ // код закрытия уезжает подписчику: политические отказы сервера (лимит
42
+ // подключений, таймаут хендшейка) клиент лечить перезагрузкой не должен —
43
+ // см. handleDisconnect в main.js
44
+ this._ws.onclose = event => this._emitClose(event?.code);
45
+ // ошибка сокета всегда сопровождается close — отдельного события наружу
46
+ // не даём, иначе клиент погасил бы матч дважды
47
+ this._ws.onerror = () => {};
48
+ }
49
+
50
+ /**
51
+ * @param {string|ArrayBuffer} data - Кадр.
52
+ * @param {boolean} [reliable] - Игнорируется: у WebSocket нет уровней
53
+ * надёжности, всё едет надёжно и по порядку.
54
+ */
55
+ send(data) {
56
+ if (this._ws && this._ws.readyState === WS_OPEN) {
57
+ this._ws.send(data);
58
+ }
59
+ }
60
+
61
+ close() {
62
+ if (this._closed) {
63
+ return;
64
+ }
65
+
66
+ this._ws?.close();
67
+ this._emitClose();
68
+ }
69
+
70
+ _emitClose(code) {
71
+ if (this._closed) {
72
+ return;
73
+ }
74
+
75
+ this._closed = true;
76
+ this.publisher.emit('close', code);
77
+ }
78
+ }
@@ -0,0 +1,54 @@
1
+ // Отказы по политике: коды закрытия, которыми dedicated-сервер отбивает
2
+ // соединение (`src/dedicated/main.js` — invalidOrigin, handshakeTimeout,
3
+ // tooManyConnections) и которыми порт-машина отбивает полную комнату
4
+ // (`src/host/PortMachine.js` — roomFull). Числа берутся из общей карты
5
+ // `src/config/closeCodes.js`, чтобы серверные контуры и клиент не разъехались
6
+ // молча; тест модуля требует явного решения по каждому коду карты.
7
+ //
8
+ // Клиенту от кода нужны два разных ответа, и совпадают они не всегда, поэтому
9
+ // знание разведено на два экспорта (review-4.md, R4-2):
10
+ //
11
+ // - перезагружаться ли. Ни на одном из этих кодов перезагрузка не помогает:
12
+ // лимит частоты подключений съел бы ей же очередное соединение, таймаут
13
+ // хендшейка запустился бы заново, origin страницы не меняется вовсе, а
14
+ // слот в полной комнате перезагрузка не освобождает. Брошенная вкладка
15
+ // перезагружалась бы вечно, а ждущий слот игрок за 30 витков сам упёрся бы
16
+ // в лимит подключений и получил бы чужую причину отказа;
17
+ // - какой текст показать. Тексты запасные: `handleDisconnect` берёт их
18
+ // только тогда, когда сервер причину не прислал сам (`terminalInformShown`
19
+ // — `client/main.js`). Полная комната, например, приезжает с причиной от
20
+ // порт-машины (TECH_INFORM перед `close`), и она всегда побеждает —
21
+ // запись 4006 ниже отработает, только если тот кадр не доехал. Тексты
22
+ // здесь, а не в `techInformList`: список переопределяет игра, и движковый
23
+ // индекс дал бы у неё «Unknown error».
24
+
25
+ import closeCodes from '../../config/closeCodes.js';
26
+
27
+ const { invalidOrigin, roomFull, handshakeTimeout, tooManyConnections } =
28
+ closeCodes;
29
+
30
+ const NO_RELOAD_CLOSE_CODES = new Set([
31
+ invalidOrigin,
32
+ roomFull,
33
+ handshakeTimeout,
34
+ tooManyConnections,
35
+ ]);
36
+
37
+ export const POLICY_CLOSE_INFORMS = {
38
+ [invalidOrigin]:
39
+ 'This page is not allowed by the game server. Check the address you opened.',
40
+ [roomFull]: 'The room is full. Try again later.',
41
+ [handshakeTimeout]:
42
+ 'Idle connection closed before the match started. Reload the page when you are ready to play.',
43
+ [tooManyConnections]:
44
+ 'Too many connections from your address. Wait a minute and reload the page.',
45
+ };
46
+
47
+ /**
48
+ * @param {number} [closeCode] - Код закрытия транспорта. Его отдаёт только
49
+ * WebSocket-транспорт (dedicated); в P2P и solo кода нет вовсе.
50
+ * @returns {boolean} Уместна ли перезагрузка страницы после разрыва.
51
+ */
52
+ export function shouldReloadAfterClose(closeCode) {
53
+ return !NO_RELOAD_CLOSE_CODES.has(closeCode);
54
+ }
@@ -0,0 +1,34 @@
1
+ // Фича-детект модульных Worker'ов (Этап 6.3): нет classic-фолбэка (запретил
2
+ // бы ESM и потребовал инлайн WASM, см. PLAN.md риск №5) — вместо него честная
3
+ // ошибка при «Создать сервер», join остаётся доступен. Конструктор Worker не
4
+ // бросает исключение на неизвестной опции `type`, поэтому используется
5
+ // классический трюк: геттер `type` читают только реализации, которые умеют
6
+ // module-воркеры — на остальных `supported` останется false. URL пробного
7
+ // воркера — `blob:`, а не `data:`, чтобы не нарушать CSP `worker-src 'self'
8
+ // blob:'` (data: там не разрешён и браузер блокирует создание воркера).
9
+ export function supportsModuleWorker() {
10
+ let supported = false;
11
+ let probeUrl;
12
+
13
+ try {
14
+ probeUrl = URL.createObjectURL(new Blob([''], { type: 'text/javascript' }));
15
+
16
+ const worker = new Worker(probeUrl, {
17
+ get type() {
18
+ supported = true;
19
+
20
+ return 'module';
21
+ },
22
+ });
23
+
24
+ worker.terminate();
25
+ } catch (e) {
26
+ supported = false;
27
+ } finally {
28
+ if (probeUrl) {
29
+ URL.revokeObjectURL(probeUrl);
30
+ }
31
+ }
32
+
33
+ return supported;
34
+ }