vimp-engine 0.1.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 (45) hide show
  1. package/README.md +9 -0
  2. package/package.json +34 -0
  3. package/src/config/authClient.js +47 -0
  4. package/src/config/clientDefaults.js +113 -0
  5. package/src/config/hostDefaults.js +79 -0
  6. package/src/config/lobby.js +104 -0
  7. package/src/config/master.js +103 -0
  8. package/src/config/opcodes.js +29 -0
  9. package/src/config/wsports.js +35 -0
  10. package/src/host/GameCoreAdapter.js +206 -0
  11. package/src/host/HostGame.js +857 -0
  12. package/src/host/host.worker.js +438 -0
  13. package/src/host/meta/SocketManager.js +431 -0
  14. package/src/host/meta/core/CommandProcessor.js +99 -0
  15. package/src/host/meta/core/RoundManager.js +646 -0
  16. package/src/host/meta/core/VoteCoordinator.js +71 -0
  17. package/src/host/meta/modules/Panel.js +181 -0
  18. package/src/host/meta/modules/PlayerDataSync.js +176 -0
  19. package/src/host/meta/modules/RTTManager.js +168 -0
  20. package/src/host/meta/modules/Stat.js +294 -0
  21. package/src/host/meta/modules/TimerManager.js +277 -0
  22. package/src/host/meta/modules/Vote.js +179 -0
  23. package/src/host/meta/modules/chat/Chat.js +66 -0
  24. package/src/host/meta/modules/chat/index.js +1 -0
  25. package/src/host/meta/modules/chat/systemMessages.js +51 -0
  26. package/src/host/meta/player/HumanParticipant.js +36 -0
  27. package/src/host/meta/player/Participant.js +22 -0
  28. package/src/host/meta/player/ParticipantManager.js +247 -0
  29. package/src/host/meta/player/ScriptedParticipant.js +19 -0
  30. package/src/lib/AbstractTimer.js +84 -0
  31. package/src/lib/Publisher.js +43 -0
  32. package/src/lib/applyRoomOverrides.js +48 -0
  33. package/src/lib/buildClientConfig.js +48 -0
  34. package/src/lib/clientCoreConfig.js +59 -0
  35. package/src/lib/config.js +83 -0
  36. package/src/lib/coreConfig.js +60 -0
  37. package/src/lib/factory.js +21 -0
  38. package/src/lib/formatters.js +33 -0
  39. package/src/lib/gamePlugin.js +95 -0
  40. package/src/lib/jwt.js +103 -0
  41. package/src/lib/math.js +75 -0
  42. package/src/lib/rateLimiter.js +40 -0
  43. package/src/lib/sanitizers.js +17 -0
  44. package/src/lib/security.js +45 -0
  45. package/src/lib/validators.js +52 -0
@@ -0,0 +1,438 @@
1
+ // Web Worker браузерного хоста. Крутит авторитетную часть матча:
2
+ // WASM-ядро симуляции (core/pkg-web из пакета игры, например @vimp/tanks,
3
+ // репозиторий vimp-tanks) + JS-мету (HostGame поверх
4
+ // мета-модулей ./meta/) + игровой цикл ~120 Гц (таймеры Worker'а не
5
+ // троттлятся в фоновой вкладке). RTCPeerConnection живут в главном потоке —
6
+ // сюда приходят уже разобранные пакеты клиентов, обратно уходят wire-кадры
7
+ // (JSON-строки и бинарные ArrayBuffer'ы через Transferable).
8
+
9
+ import authClientConfig from '../config/authClient.js';
10
+ import clientDefaults from '../config/clientDefaults.js';
11
+ import lobbyConfig from '../config/lobby.js';
12
+ import wsports from '../config/wsports.js';
13
+ import { applyRoomOverrides } from '../lib/applyRoomOverrides.js';
14
+ import { buildClientConfig } from '../lib/buildClientConfig.js';
15
+ import { buildCoreConfig } from '../lib/coreConfig.js';
16
+ import { validateAuth } from '../lib/validators.js';
17
+ import { verifyIdentityToken } from '../lib/jwt.js';
18
+ import { assertGameConfigShape } from '../lib/gamePlugin.js';
19
+ import SocketManager from './meta/SocketManager.js';
20
+ import HostGame from './HostGame.js';
21
+
22
+ // PC (client ports): порты получения данных от клиента
23
+ const PC_CONFIG_READY = wsports.client.CONFIG_READY;
24
+ const PC_AUTH_RESPONSE = wsports.client.AUTH_RESPONSE;
25
+ const PC_MODULES_READY = wsports.client.MODULES_READY;
26
+ const PC_MAP_READY = wsports.client.MAP_READY;
27
+ const PC_FIRST_SHOT_READY = wsports.client.FIRST_SHOT_READY;
28
+ const PC_KEYS_DATA = wsports.client.KEYS_DATA;
29
+ const PC_CHAT_DATA = wsports.client.CHAT_DATA;
30
+ const PC_VOTE_DATA = wsports.client.VOTE_DATA;
31
+ const PC_PONG = wsports.client.PONG;
32
+
33
+ // PS (server ports): порты отправки данных клиенту
34
+ const PS_TECH_INFORM_DATA = wsports.server.TECH_INFORM_DATA;
35
+
36
+ let host = null;
37
+ let socketManager = null;
38
+ let clientCfg = null;
39
+
40
+ // HostPlugin игры (Этап 6.4): грузится динамически по entries.host из
41
+ // GameManifest (room.game) — до onInit движок игру не знает вовсе
42
+ let hostPlugin = null;
43
+
44
+ // состояние подключений: socketId → { gameId, methods, enabled }
45
+ const clients = new Map();
46
+
47
+ // эстафета Worker'ов (Этап 5.2): socketId → gameId участников, восстановленных
48
+ // из handoff-меты — их порт-машины поднимаются минуя хендшейк
49
+ let handoffClients = null;
50
+
51
+ // JWKS central auth-сервиса, проксированный мастером (Этап B3), закэширован
52
+ // на время жизни Worker'а — ключ меняется только при ротации
53
+ let jwksPromise = null;
54
+
55
+ function getJwks() {
56
+ if (!jwksPromise) {
57
+ jwksPromise = fetch(lobbyConfig.auth.jwksUrl).then(res => {
58
+ if (!res.ok) {
59
+ throw new Error(`jwks: HTTP ${res.status}`);
60
+ }
61
+
62
+ return res.json();
63
+ });
64
+
65
+ // сбой не должен закэшироваться навсегда — следующий вход попробует снова
66
+ jwksPromise.catch(() => {
67
+ jwksPromise = null;
68
+ });
69
+ }
70
+
71
+ return jwksPromise;
72
+ }
73
+
74
+ // проверяет identity-токен клиента (Этап B3): подпись RS256 по JWKS мастера,
75
+ // issuer, срок годности — возвращает проверенный ник или бросает исключение.
76
+ // Свободный ввод имени в игре больше не источник ника: он берётся из claim
77
+ async function verifyClientToken(token) {
78
+ const jwks = await getJwks();
79
+ const payload = await verifyIdentityToken(token, {
80
+ jwks,
81
+ issuer: authClientConfig.issuer,
82
+ });
83
+
84
+ return payload.nick;
85
+ }
86
+
87
+ // wire-сокет пользователя: пишет кадры в главный поток (роутер WebRTC/loopback)
88
+ function makeWorkerSocket(socketId) {
89
+ return {
90
+ // JSON-сообщение [port, payload] — строкой (как ws.send); reliable
91
+ // решает канал WebRTC (meta/state) — ненадёжен только ping
92
+ send: (port, data, reliable = true) => {
93
+ self.postMessage({
94
+ type: 'to_client',
95
+ socketId,
96
+ payload: JSON.stringify([port, data]),
97
+ reliable,
98
+ });
99
+ },
100
+
101
+ // бинарный кадр — Transferable ArrayBuffer (без копии); reliable решает
102
+ // канал WebRTC (meta/state) в главном потоке
103
+ sendBinary: (buffer, reliable) => {
104
+ self.postMessage(
105
+ { type: 'to_client', socketId, payload: buffer, reliable },
106
+ [buffer],
107
+ );
108
+ },
109
+
110
+ // закрытие соединения. В отличие от ws, закрытие data channel не несёт
111
+ // код/причину — причина (кик и т.п.) доставляется отдельным TECH_INFORM
112
+ // по meta до закрытия (reliable-ordered гарантирует порядок)
113
+ close: (code, data) => {
114
+ if (data !== undefined) {
115
+ self.postMessage({
116
+ type: 'to_client',
117
+ socketId,
118
+ payload: JSON.stringify([PS_TECH_INFORM_DATA, data]),
119
+ reliable: true,
120
+ });
121
+ }
122
+
123
+ self.postMessage({ type: 'close_client', socketId, code, data });
124
+ },
125
+ };
126
+ }
127
+
128
+ // инициализация хоста: HostPlugin игры (динамический import по
129
+ // GameManifest, Этап 6.4), ядро, мета, игровой цикл. handoff — состояние
130
+ // эстафеты Worker'ов (Этап 5.2): комната восстанавливается вместо
131
+ // холодного старта, порт-машины клиентов поднимутся минуя хендшейк
132
+ async function onInit(room, handoff = null) {
133
+ const pluginModule = await import(/* @vite-ignore */ room.game.hostEntryUrl);
134
+
135
+ hostPlugin = pluginModule.default;
136
+ assertGameConfigShape(hostPlugin);
137
+
138
+ const game = applyRoomOverrides(room, hostPlugin);
139
+ const seed = (Math.random() * 2 ** 32) >>> 0;
140
+
141
+ const core = await hostPlugin.createCore(
142
+ JSON.stringify(
143
+ buildCoreConfig(hostPlugin.gameConfig, {
144
+ friendlyFire: game.parts.friendlyFire,
145
+ seed,
146
+ }),
147
+ ),
148
+ { wasmUrl: room.game.wasmUrl },
149
+ );
150
+
151
+ clientCfg = buildClientConfig(
152
+ game,
153
+ clientDefaults,
154
+ hostPlugin.buildClientGameConfig(),
155
+ );
156
+ socketManager = new SocketManager(wsports.server, {
157
+ soundCues: game.soundCues,
158
+ initialVote: game.initialVote,
159
+ });
160
+ host = new HostGame(game, socketManager, core, hostPlugin, {
161
+ hostSocketId: room?.hostSocketId ?? null,
162
+ onMapChange: mapName => self.postMessage({ type: 'map_changed', mapName }),
163
+ handoff,
164
+ gameVersion: room.game.version,
165
+ });
166
+
167
+ // эстафета Worker'ов (Этап 5.2) несёт уже известные hostId+секрет в room —
168
+ // новый Worker не должен ждать повторного register_host, чтобы возобновить
169
+ // атрибуцию rank/state-flush (кодревью №1); при холодном старте они ещё не
170
+ // назначены мастером, придут позже через 'set_host_id'
171
+ if (room?.hostId) {
172
+ host.setHostId(room.hostId, room.hostSecret);
173
+ }
174
+
175
+ if (handoff) {
176
+ handoffClients = new Map(handoff.humans.map(h => [h.socketId, h.gameId]));
177
+ }
178
+
179
+ // мастеру нужна фактическая карта комнаты (после эстафеты — восстановленная)
180
+ self.postMessage({ type: 'ready', mapName: host.currentMap });
181
+ }
182
+
183
+ // порт-обработчики клиента (замыкание над gameId через state)
184
+ function buildPortMethods(socketId, state) {
185
+ return [
186
+ // 0: config ready
187
+ () => {
188
+ state.enabled[PC_AUTH_RESPONSE] = true;
189
+
190
+ // validators — код, по проводу не передаётся (форма клиента берёт
191
+ // игровые валидаторы из своего бандла); texts — заголовок и
192
+ // help-секции игры для нейтрального каркаса auth.pug
193
+ socketManager.sendAuthData(socketId, {
194
+ elems: hostPlugin.authSchema.elems,
195
+ params: hostPlugin.authSchema.params,
196
+ texts: hostPlugin.authSchema.texts,
197
+ });
198
+ state.enabled[PC_CONFIG_READY] = false;
199
+ },
200
+
201
+ // 1: auth response. Ник — не свободный ввод игровой формы, а claim
202
+ // проверенного identity-токена (Этап B3); authSchema.params больше не
203
+ // содержит 'name', только игро-специфичные поля (например 'model')
204
+ data => {
205
+ if (!data || typeof data !== 'object') {
206
+ return;
207
+ }
208
+
209
+ const err = validateAuth(
210
+ data,
211
+ hostPlugin.authSchema.params,
212
+ hostPlugin.authSchema.validators,
213
+ );
214
+
215
+ if (err) {
216
+ socketManager.sendAuthResult(socketId, err);
217
+ return;
218
+ }
219
+
220
+ verifyClientToken(data.token)
221
+ .then(name => {
222
+ // клиент мог отключиться, пока токен проверялся
223
+ if (!clients.has(socketId)) {
224
+ return;
225
+ }
226
+
227
+ state.enabled[PC_AUTH_RESPONSE] = false;
228
+ state.enabled[PC_MODULES_READY] = true;
229
+
230
+ host.createUser({ ...data, name }, socketId, createdId => {
231
+ state.gameId = createdId;
232
+ });
233
+
234
+ socketManager.sendTechInform(socketId, 'loading');
235
+ socketManager.sendAuthResult(socketId, undefined);
236
+ })
237
+ .catch(() => {
238
+ socketManager.sendAuthResult(socketId, [
239
+ { name: 'token', error: 'invalid' },
240
+ ]);
241
+ });
242
+ },
243
+
244
+ // 2: modules ready
245
+ () => {
246
+ state.enabled[PC_MODULES_READY] = false;
247
+ state.enabled[PC_MAP_READY] = true;
248
+ state.enabled[PC_FIRST_SHOT_READY] = true;
249
+ state.enabled[PC_KEYS_DATA] = true;
250
+ state.enabled[PC_CHAT_DATA] = true;
251
+ state.enabled[PC_VOTE_DATA] = true;
252
+ state.enabled[PC_PONG] = true;
253
+
254
+ host.sendMap(state.gameId);
255
+ },
256
+
257
+ // 3: map ready
258
+ () => host.mapReady(state.gameId),
259
+
260
+ // 4: first shot ready
261
+ () => host.firstShotReady(state.gameId),
262
+
263
+ // 5: keys data ('seq:action:name')
264
+ keyEventString => {
265
+ if (typeof keyEventString === 'string') {
266
+ host.updateKeys(state.gameId, keyEventString);
267
+ }
268
+ },
269
+
270
+ // 6: chat data
271
+ message => host.pushMessage(state.gameId, message),
272
+
273
+ // 7: vote data
274
+ data => {
275
+ if (data) {
276
+ host.parseVote(state.gameId, data);
277
+ }
278
+ },
279
+
280
+ // 8: pong
281
+ pingId => host.updateRTT(state.gameId, pingId),
282
+ ];
283
+ }
284
+
285
+ // новое подключение клиента: порт-машина как в socket/index.js
286
+ function onConnect(socketId) {
287
+ if (!host || clients.has(socketId)) {
288
+ return;
289
+ }
290
+
291
+ const socket = makeWorkerSocket(socketId);
292
+
293
+ socketManager.addUser(socketId, socket);
294
+
295
+ // эстафета (Этап 5.2): участник уже восстановлен в HostGame — порт-машина
296
+ // поднимается сразу в игровом состоянии, хендшейк не повторяется
297
+ const restoredGameId = handoffClients?.get(socketId);
298
+
299
+ if (restoredGameId !== undefined) {
300
+ handoffClients.delete(socketId);
301
+
302
+ const state = {
303
+ gameId: restoredGameId,
304
+ enabled: new Array(9).fill(false),
305
+ };
306
+
307
+ state.methods = buildPortMethods(socketId, state);
308
+ state.enabled[PC_MAP_READY] = true;
309
+ state.enabled[PC_FIRST_SHOT_READY] = true;
310
+ state.enabled[PC_KEYS_DATA] = true;
311
+ state.enabled[PC_CHAT_DATA] = true;
312
+ state.enabled[PC_VOTE_DATA] = true;
313
+ state.enabled[PC_PONG] = true;
314
+
315
+ clients.set(socketId, state);
316
+ return;
317
+ }
318
+
319
+ // комната заполнена (люди + боты) — отказ без порт-машины
320
+ // (очереди ожидания легаси-сервера в P2P-комнате нет)
321
+ if (host.isFull) {
322
+ // причину доставит close (TECH_INFORM перед close_client)
323
+ socketManager.close(socketId, 4006, 'roomFull', [host.maxPlayers]);
324
+ socketManager.removeUser(socketId);
325
+ return;
326
+ }
327
+
328
+ const state = {
329
+ gameId: undefined,
330
+ enabled: new Array(9).fill(false),
331
+ };
332
+
333
+ clients.set(socketId, state);
334
+ state.methods = buildPortMethods(socketId, state);
335
+
336
+ state.enabled[PC_CONFIG_READY] = true;
337
+ socketManager.sendConfig(socketId, clientCfg);
338
+ }
339
+
340
+ // входящее сообщение клиента (wire-строка [port, payload])
341
+ function onClientMessage(socketId, data) {
342
+ const state = clients.get(socketId);
343
+
344
+ if (!state) {
345
+ return;
346
+ }
347
+
348
+ let msg;
349
+
350
+ try {
351
+ msg = JSON.parse(data);
352
+ } catch (e) {
353
+ return;
354
+ }
355
+
356
+ if (msg && state.enabled[msg[0]]) {
357
+ state.methods[msg[0]](msg[1]);
358
+ }
359
+ }
360
+
361
+ // отключение клиента
362
+ function onDisconnect(socketId) {
363
+ const state = clients.get(socketId);
364
+
365
+ if (!state) {
366
+ return;
367
+ }
368
+
369
+ socketManager.removeUser(socketId);
370
+
371
+ if (state.gameId !== undefined) {
372
+ host.removeUser(state.gameId);
373
+ }
374
+
375
+ clients.delete(socketId);
376
+ }
377
+
378
+ self.onmessage = async event => {
379
+ const msg = event.data;
380
+
381
+ switch (msg.type) {
382
+ case 'init':
383
+ try {
384
+ await onInit(msg.room, msg.handoff);
385
+ } catch (e) {
386
+ // сбой загрузки WASM/конфига/handoff-меты — сообщить главному
387
+ // потоку, не виснуть (при эстафете тот возобновит старый Worker)
388
+ self.postMessage({
389
+ type: 'error',
390
+ message: e && e.message ? e.message : String(e),
391
+ });
392
+ }
393
+ break;
394
+
395
+ case 'connect':
396
+ onConnect(msg.socketId);
397
+ break;
398
+
399
+ case 'message':
400
+ onClientMessage(msg.socketId, msg.data);
401
+ break;
402
+
403
+ case 'disconnect':
404
+ onDisconnect(msg.socketId);
405
+ break;
406
+
407
+ case 'update_maps':
408
+ host?.updateMaps(msg.maps);
409
+ break;
410
+
411
+ // мастер подтвердил регистрацию комнаты (кодревью №1) — hostId+секрет
412
+ // нужны PlayerDataSync для атрибуции последующих rank/state-flush
413
+ case 'set_host_id':
414
+ host?.setHostId(msg.hostId, msg.hostSecret);
415
+ break;
416
+
417
+ // эстафета Worker'ов (Этап 5.2)
418
+
419
+ // запрос переноса: на ближайшей границе раунда игра остановится и
420
+ // handoff-состояние уедет главному потоку
421
+ case 'prepare_handoff':
422
+ host?.requestHandoff(state =>
423
+ self.postMessage({ type: 'handoff_state', state }),
424
+ );
425
+ break;
426
+
427
+ // новый Worker не поднялся — продолжаем жить на этой версии
428
+ case 'resume':
429
+ host?.resumeAfterHandoff();
430
+ break;
431
+
432
+ // клиенты переподключены главным потоком — завершить перенос
433
+ case 'handoff_complete':
434
+ handoffClients = null;
435
+ host?.completeHandoff(new Set(clients.keys()));
436
+ break;
437
+ }
438
+ };