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,84 @@
1
+ /**
2
+ * @class AbstractTimer
3
+ * @description Базовый класс для управления таймерами (setTimeout, setInterval)
4
+ * Унифицированные методы для запуска и остановки таймеров по ключу
5
+ */
6
+ class AbstractTimer {
7
+ constructor() {
8
+ // хранилище для всех активных таймеров по их ключам
9
+ this._timers = new Map();
10
+ }
11
+
12
+ /**
13
+ * Централизованно запускает таймер и сохраняет его в Map,
14
+ * если таймер с таким ключом уже существует, он будет сперва остановлен
15
+ * Для setTimeout ключ удаляется автоматически после срабатывания.
16
+ * @protected
17
+ * @param {string} key - уникальный ключ для идентификации таймера
18
+ * @param {function} callback - функция по завершению времени
19
+ * @param {number} duration - длительность в миллисекундах
20
+ * @param {boolean} [isInterval=false] - setInterval или setTimeout
21
+ */
22
+ _startTimer(key, callback, duration, isInterval = false) {
23
+ // остановка существующего таймера с тем же ключом
24
+ this._stopTimer(key);
25
+
26
+ if (isInterval) {
27
+ // setInterval живёт, пока его не остановят
28
+ const timerId = setInterval(callback, duration);
29
+ this._timers.set(key, { timerId, isInterval });
30
+ } else {
31
+ // setTimeout удаляется сразу после выполнения
32
+ const wrappedCallback = () => {
33
+ this._timers.delete(key);
34
+ callback();
35
+ };
36
+
37
+ const timerId = setTimeout(wrappedCallback, duration);
38
+ this._timers.set(key, { timerId, isInterval });
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Централизованно останавливает таймер по его ключу
44
+ * @protected
45
+ * @param {string} key - ключ таймера, который нужно остановить
46
+ */
47
+ _stopTimer(key) {
48
+ if (this._timers.has(key)) {
49
+ const { timerId, isInterval } = this._timers.get(key);
50
+ const handler = isInterval ? clearInterval : clearTimeout;
51
+
52
+ handler(timerId);
53
+
54
+ this._timers.delete(key);
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Проверяет наличие активного таймера по ключу
60
+ * @protected
61
+ * @param {string} key - ключ для проверки
62
+ * @returns {boolean} - true, если таймер существует, иначе false
63
+ */
64
+ _hasTimer(key) {
65
+ return this._timers.has(key);
66
+ }
67
+
68
+ /**
69
+ * Останавливает и удаляет все активные таймеры, управляемые этим экземпляром
70
+ * @protected
71
+ */
72
+ _clearAllTimers() {
73
+ for (const timerData of this._timers.values()) {
74
+ const { timerId, isInterval } = timerData;
75
+ const handler = isInterval ? clearInterval : clearTimeout;
76
+
77
+ handler(timerId);
78
+ }
79
+
80
+ this._timers.clear();
81
+ }
82
+ }
83
+
84
+ export default AbstractTimer;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Observer pattern (Publisher)
3
+ * on: добавляет слушателя (подписчика)
4
+ * emit: рассылает событие подписчикам
5
+ */
6
+ class Publisher {
7
+ constructor() {
8
+ // Объект для хранения подписчиков по типу события
9
+ this.subs = {};
10
+ }
11
+
12
+ /**
13
+ * Добавляет подписчика для указанного типа события.
14
+ * @param {string} type - Тип события.
15
+ * @param {Function|string} fn - Функция-обработчик или имя метода.
16
+ * @param {Object} [context] - Контекст, в котором будет вызвана функция.
17
+ */
18
+ on(type, fn, context) {
19
+ this.subs[type] = this.subs[type] || [];
20
+
21
+ if (typeof fn !== 'function') {
22
+ // Если передано не функция, предполагаем, что это имя метода в context.
23
+ fn = context[fn];
24
+ }
25
+
26
+ this.subs[type].push({
27
+ fn,
28
+ context: context || this,
29
+ });
30
+ }
31
+
32
+ /**
33
+ * Рассылает событие указанного типа всем подписчикам.
34
+ * @param {string} type - Тип события.
35
+ * @param {*} data - Данные, которые передаются подписчикам.
36
+ */
37
+ emit(type, data) {
38
+ const subscribers = this.subs[type] || [];
39
+ subscribers.forEach(({ fn, context }) => fn.call(context, data));
40
+ }
41
+ }
42
+
43
+ export default Publisher;
@@ -0,0 +1,48 @@
1
+ // Собирает конфиг игры (движковые дефолты + игровая половина) и применяет
2
+ // пользовательские настройки комнаты. Используется host.worker.js; вынесено
3
+ // в lib для тестируемости (worker вешает self.onmessage при импорте)
4
+ import hostDefaults from '../config/hostDefaults.js';
5
+
6
+ export function applyRoomOverrides(room = {}, plugin) {
7
+ const game = structuredClone({ ...hostDefaults, ...plugin.gameConfig });
8
+
9
+ // Этап 5.1: актуальные карты мастера (фетчит главный поток) вместо бандла
10
+ if (room.maps && Object.keys(room.maps).length) {
11
+ game.maps = room.maps;
12
+
13
+ // дефолтная карта бандла могла уйти из каталога мастера
14
+ if (!game.maps[game.currentMap]) {
15
+ game.currentMap = Object.keys(game.maps)[0];
16
+ }
17
+ }
18
+
19
+ if (Number.isFinite(room.maxPlayers)) {
20
+ game.maxPlayers = Math.max(
21
+ 1,
22
+ Math.min(game.roomDefaults.maxPlayers, room.maxPlayers),
23
+ );
24
+ }
25
+
26
+ if (room.map && game.maps[room.map]) {
27
+ game.currentMap = room.map;
28
+ }
29
+
30
+ // форма лобби — не серверная граница: клампим здесь
31
+ const { roomTimeMin, roomTimeMax } = game.timers;
32
+ const clampTime = ms =>
33
+ Math.min(roomTimeMax, Math.max(roomTimeMin, Math.floor(ms)));
34
+
35
+ if (Number.isFinite(room.roundTime)) {
36
+ game.timers.roundTime = clampTime(room.roundTime);
37
+ }
38
+
39
+ if (Number.isFinite(room.mapTime)) {
40
+ game.timers.mapTime = clampTime(room.mapTime);
41
+ }
42
+
43
+ if (typeof room.friendlyFire === 'boolean') {
44
+ game.parts.friendlyFire = room.friendlyFire;
45
+ }
46
+
47
+ return game;
48
+ }
@@ -0,0 +1,48 @@
1
+ // Сборка клиентского CONFIG_DATA (порт 0): merge движковых дефолтов
2
+ // (src/config/clientDefaults.js) с игровым client-конфигом
3
+ // (@vimp/tanks/config/client.js) + время голосования и данные client-side
4
+ // prediction из game-конфига. Используется Worker'ом хоста
5
+ // (src/host/host.worker.js).
6
+
7
+ const isPlainObject = value =>
8
+ value !== null && typeof value === 'object' && !Array.isArray(value);
9
+
10
+ // рекурсивный merge: объекты сливаются, массивы и скаляры заменяются
11
+ const deepMerge = (base, extra) => {
12
+ const result = { ...base };
13
+
14
+ for (const [key, value] of Object.entries(extra)) {
15
+ result[key] =
16
+ isPlainObject(result[key]) && isPlainObject(value)
17
+ ? deepMerge(result[key], value)
18
+ : value;
19
+ }
20
+
21
+ return result;
22
+ };
23
+
24
+ // Возвращает новый объект, не мутируя переданные конфиги.
25
+ export const buildClientConfig = (game, defaults, gameClient) => {
26
+ const config = deepMerge(
27
+ structuredClone(defaults),
28
+ structuredClone(gameClient),
29
+ );
30
+
31
+ // время ожидания vote-модуля
32
+ config.modules.vote.params.time = game.timers.voteTime;
33
+
34
+ // данные для client-side prediction (реплика движения своего танка
35
+ // и визуального спавна его снарядов)
36
+ config.prediction = {
37
+ timeStep: game.timers.timeStep,
38
+ playerKeys: game.playerKeys,
39
+ models: game.parts.models,
40
+ weapons: game.parts.weapons,
41
+ };
42
+
43
+ // снапшот-схема игры: клиент собирает по ней конфиг клиентского ядра и
44
+ // читает hot-буфер — бандл клиента не обязан совпадать с бандлом хоста
45
+ config.snapshot = game.snapshot;
46
+
47
+ return config;
48
+ };
@@ -0,0 +1,59 @@
1
+ import { SNAPSHOT_FORMAT_VERSION } from '../config/opcodes.js';
2
+ import wsports from '../config/wsports.js';
3
+
4
+ // Сборка JSON-конфига клиентского ядра (ClientCore, срез 2.6): данные
5
+ // prediction/interpolation из CONFIG_DATA хоста + бандловый реестр
6
+ // снапшот-ключей. Отдельный модуль (не coreConfig.js): тот тянет
7
+ // game.js/models.js/weapons.js, которым не место в клиентском бандле —
8
+ // клиент получает параметры по порту 0.
9
+
10
+ /**
11
+ * Собирает объект конфигурации клиентского ядра — форма {engine, game}
12
+ * (PLAN.md §3.4): движковая половина (timeStepMs/snapshot/interpolation) +
13
+ * игровая (models/weapons/playerKeys/seed трассеров).
14
+ * @param {Object} options
15
+ * @param {Object} options.prediction - Секция prediction CONFIG_DATA
16
+ * (timeStep в мс, playerKeys, models, weapons).
17
+ * @param {Object} options.interpolation - Секция interpolation CONFIG_DATA
18
+ * (delay, maxFrameAge в мс).
19
+ * @param {Object} options.snapshot - Секция snapshot CONFIG_DATA —
20
+ * игровая схема ключей (гоняется хостом, не из бандла клиента).
21
+ * @param {Object} [overrides] - Переопределения плоским объектом (например,
22
+ * seed для воспроизводимых прогонов) — распределяются автоматически.
23
+ * @returns {Object} Конфиг для `new ClientCore(JSON.stringify(config))`.
24
+ */
25
+ export const buildClientCoreConfig = (
26
+ { prediction, interpolation, snapshot },
27
+ overrides = {},
28
+ ) => {
29
+ const flat = {
30
+ // имя поля фиксирует единицы: prediction.timeStep приходит в мс
31
+ timeStepMs: prediction.timeStep,
32
+ playerKeys: prediction.playerKeys,
33
+ models: prediction.models,
34
+ weapons: prediction.weapons,
35
+ // keys — игровая схема из CONFIG_DATA; version/port — движковые
36
+ snapshot: {
37
+ version: SNAPSHOT_FORMAT_VERSION,
38
+ port: wsports.server.SHOT_DATA,
39
+ keys: snapshot,
40
+ },
41
+ interpolation,
42
+ seed: undefined,
43
+ ...overrides,
44
+ };
45
+
46
+ return {
47
+ engine: {
48
+ timeStepMs: flat.timeStepMs,
49
+ snapshot: flat.snapshot,
50
+ interpolation: flat.interpolation,
51
+ },
52
+ game: {
53
+ playerKeys: flat.playerKeys,
54
+ models: flat.models,
55
+ weapons: flat.weapons,
56
+ seed: flat.seed,
57
+ },
58
+ };
59
+ };
@@ -0,0 +1,83 @@
1
+ const config = {};
2
+
3
+ // добавляет новое значение в config
4
+ //
5
+ // keys - может иметь несколько вложенностей,
6
+ // вложенности разделены ':', например:
7
+ // key1:key2:key3 будет значением config[key1][key2][key3]
8
+ //
9
+ // если структура вложенностей изменилась,
10
+ // новые пути перезапишут старый вариант
11
+ //
12
+ // value - может быть значением любого типа
13
+ function set(keys, value) {
14
+ if (!keys) {
15
+ console.error(`Error: The "keys" argument cannot be empty.`);
16
+ return;
17
+ }
18
+
19
+ const arr = keys.split(':');
20
+
21
+ // проверка на пустые сегменты
22
+ if (arr.includes('')) {
23
+ console.error(
24
+ `Error: The path '${keys}' is invalid (contains empty segments).`,
25
+ );
26
+ return;
27
+ }
28
+
29
+ const lastKey = arr.pop(); // последний ключ для записи значения
30
+ let currentLevel = config;
31
+
32
+ for (const key of arr) {
33
+ // если не является объектом, создание пустого объекта,
34
+ // стирая вложенность, если она была
35
+ if (typeof currentLevel[key] !== 'object' || currentLevel[key] === null) {
36
+ currentLevel[key] = {};
37
+ }
38
+
39
+ currentLevel = currentLevel[key];
40
+ }
41
+
42
+ // запись финального значения
43
+ currentLevel[lastKey] = value;
44
+ }
45
+
46
+ // возвращает значение по ключу или undefined
47
+ // ключ может иметь несколько вложенностей
48
+ // вложенности разделены ':',
49
+ // например: key1:key2:key3 вернет значение config[key1][key2][key3]
50
+ // вызов без аргументов вернет весь конфиг
51
+ function get(keys) {
52
+ if (!keys) {
53
+ return config;
54
+ }
55
+
56
+ const arr = keys.split(':');
57
+ let currentLevel = config;
58
+
59
+ for (let i = 0, len = arr.length; i < len; i += 1) {
60
+ const key = arr[i];
61
+
62
+ // если поиск свойства не в объекте
63
+ if (typeof currentLevel !== 'object' || currentLevel === null) {
64
+ console.error(keys, `${currentLevel} is not object`);
65
+ return;
66
+ }
67
+
68
+ // если свойство есть в объекте
69
+ if (key in currentLevel) {
70
+ currentLevel = currentLevel[key];
71
+ } else {
72
+ console.error(keys, `${key} is not property`);
73
+ return;
74
+ }
75
+ }
76
+
77
+ return currentLevel;
78
+ }
79
+
80
+ export default {
81
+ set,
82
+ get,
83
+ };
@@ -0,0 +1,60 @@
1
+ import { SNAPSHOT_FORMAT_VERSION } from '../config/opcodes.js';
2
+ import hostDefaults from '../config/hostDefaults.js';
3
+ import wsports from '../config/wsports.js';
4
+
5
+ // Сборка JSON-конфига Rust-ядра (packages/engine/core + core/ в репозитории
6
+ // игры, например vimp-tanks): движковая половина
7
+ // (timeStep/mapScale/mapSetId/snapshot/seed) + игровая
8
+ // (models/weapons/playerKeys/panel/friendlyFire) — форма {engine, game} из
9
+ // PLAN.md §3.4. Единственная точка соответствия JS-конфигов и ABI ядра
10
+ // (см. docs/core.md).
11
+
12
+ /**
13
+ * Собирает объект конфигурации ядра.
14
+ * @param {Object} gameConfig - HostPlugin.gameConfig игры, загруженной
15
+ * динамически по GameManifest (Этап 6.4) — движок больше не знает игру
16
+ * статически.
17
+ * @param {Object} [overrides] - Переопределения плоским объектом (например,
18
+ * seed для воспроизводимых прогонов или friendlyFire) — распределяются
19
+ * по движковой/игровой половине автоматически.
20
+ * @returns {Object} Конфиг для `hostPlugin.createCore(JSON.stringify(config))`.
21
+ */
22
+ export const buildCoreConfig = (gameConfig, overrides = {}) => {
23
+ const { models, weapons } = gameConfig.parts;
24
+
25
+ const flat = {
26
+ timeStep: hostDefaults.timers.timeStep / 1000,
27
+ friendlyFire: gameConfig.parts.friendlyFire,
28
+ mapScale: gameConfig.mapScale,
29
+ mapSetId: gameConfig.mapSetId,
30
+ models,
31
+ weapons,
32
+ playerKeys: gameConfig.playerKeys,
33
+ panel: gameConfig.panel.fields,
34
+ // keys — игровая схема (gameConfig.snapshot); version/port — движковые
35
+ snapshot: {
36
+ version: SNAPSHOT_FORMAT_VERSION,
37
+ port: wsports.server.SHOT_DATA,
38
+ keys: gameConfig.snapshot,
39
+ },
40
+ seed: undefined,
41
+ ...overrides,
42
+ };
43
+
44
+ return {
45
+ engine: {
46
+ timeStep: flat.timeStep,
47
+ mapScale: flat.mapScale,
48
+ mapSetId: flat.mapSetId,
49
+ snapshot: flat.snapshot,
50
+ seed: flat.seed,
51
+ },
52
+ game: {
53
+ friendlyFire: flat.friendlyFire,
54
+ models: flat.models,
55
+ weapons: flat.weapons,
56
+ playerKeys: flat.playerKeys,
57
+ panel: flat.panel,
58
+ },
59
+ };
60
+ };
@@ -0,0 +1,21 @@
1
+ // фабрика для строительства объектов игры
2
+ // создает объект игры указанного типа
3
+ // по заданным параметрам
4
+ const Factory = (name, ...args) => {
5
+ const Constructor = Factory.constructors[name];
6
+
7
+ if (typeof Constructor !== 'function') {
8
+ throw new Error(`Constructor for ${name} not found.`);
9
+ }
10
+
11
+ return new Constructor(...args);
12
+ };
13
+
14
+ Factory.constructors = {};
15
+
16
+ // добавление конструкторов
17
+ Factory.add = data => {
18
+ Object.assign(Factory.constructors, data);
19
+ };
20
+
21
+ export default Factory;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Заменяет плейсхолдеры в строке на значения из массива.
3
+ * Плейсхолдеры имеют вид `{0}`, `{1}` и т.д.
4
+ * @param {string} [message=''] - Исходное сообщение с плейсхолдерами.
5
+ * @param {string[]} [arr=[]] - Массив значений для подстановки.
6
+ * @returns {string} Сообщение с подставленными значениями.
7
+ * @example
8
+ * formatMessage('Hello, {0} {1}!', ['John', 'Doe']);
9
+ * // возвращает "Hello, John Doe!"
10
+ */
11
+ export const formatMessage = (message = '', arr = []) => {
12
+ if (!message || !arr.length) {
13
+ return message;
14
+ }
15
+
16
+ // замена плейсхолдеров
17
+ return message.replace(/\{(\d+)\}/g, (match, index) => {
18
+ // index — это номер, захваченный из скобок в RegExp
19
+ const value = arr[index];
20
+ // если значение для индекса существует, подставляем его.
21
+ return typeof value !== 'undefined' ? value : match;
22
+ });
23
+ };
24
+
25
+ /**
26
+ * Округляет число до 2-х знаков после запятой.
27
+ * @param {number} value - Число для округления.
28
+ * @returns {number}
29
+ * @example
30
+ * round(10.567); // 10.57
31
+ * round(10.567, 1); // 10.6
32
+ */
33
+ export const roundTo2Decimals = value => Math.round(value * 100) / 100;
@@ -0,0 +1,95 @@
1
+ import { ENGINE_API_VERSION } from '../config/opcodes.js';
2
+
3
+ // Динамическая загрузка игры по GameManifest мастера (Этап 6.3): клиент
4
+ // больше не импортирует игру статически (gameRegistry.static.js) — вместо
5
+ // этого он читает каталог игр мастера и подгружает ClientPlugin по
6
+ // entries.client из манифеста.
7
+
8
+ // каталог всех игр мастера (GameCatalog, см. Этап 6.2) — массив манифестов
9
+ export async function fetchGamesManifest(url = '/games/manifest.json') {
10
+ const res = await fetch(url);
11
+
12
+ if (!res.ok) {
13
+ throw new Error(`games manifest: HTTP ${res.status}`);
14
+ }
15
+
16
+ return res.json();
17
+ }
18
+
19
+ // манифест одной игры (GameCatalog::getManifest, см. Этап 6.2) — объект,
20
+ // не массив; используется при повторном фетче активной игры (Этап 6.5)
21
+ export async function fetchGameManifest(url) {
22
+ const res = await fetch(url);
23
+
24
+ if (!res.ok) {
25
+ throw new Error(`game manifest: HTTP ${res.status}`);
26
+ }
27
+
28
+ return res.json();
29
+ }
30
+
31
+ // несовпадение engineApi — плагин собран под другую версию контрактов
32
+ // движка (§3.7 PLAN.md); загружать его небезопасно
33
+ export function assertEngineApiCompatible(manifest) {
34
+ if (manifest.engineApi !== ENGINE_API_VERSION) {
35
+ throw new Error(
36
+ `game "${manifest.id}" requires engine API v${manifest.engineApi}, ` +
37
+ `this engine build is v${ENGINE_API_VERSION}`,
38
+ );
39
+ }
40
+ }
41
+
42
+ // поля gameConfig, которые движок читает до какой-либо игровой логики
43
+ // (applyRoomOverrides/coreConfig/buildClientConfig) — недостающее валится
44
+ // непрозрачной ошибкой глубоко в onInit; проверяем контракт §HostPlugin API
45
+ // (docs/en/plugin-api.md) сразу после import, рядом с engineApi-гейтом
46
+ const REQUIRED_GAME_CONFIG_PATHS = [
47
+ 'roomDefaults.maxPlayers',
48
+ 'snapshot',
49
+ 'parts.models',
50
+ 'parts.weapons',
51
+ 'parts.friendlyFire',
52
+ 'panel.fields',
53
+ 'playerKeys',
54
+ ];
55
+
56
+ function getPath(obj, dottedPath) {
57
+ return dottedPath
58
+ .split('.')
59
+ .reduce((value, key) => value?.[key], obj);
60
+ }
61
+
62
+ // бросает при отсутствии обязательных полей HostPlugin.gameConfig
63
+ export function assertGameConfigShape(hostPlugin) {
64
+ const missing = REQUIRED_GAME_CONFIG_PATHS.filter(
65
+ p => getPath(hostPlugin.gameConfig, p) === undefined,
66
+ );
67
+
68
+ if (missing.length > 0) {
69
+ throw new Error(
70
+ `game "${hostPlugin.id}": gameConfig is missing required field(s): ` +
71
+ missing.join(', '),
72
+ );
73
+ }
74
+ }
75
+
76
+ // динамический import ClientPlugin игры (client-entry её сборки). Манифест и
77
+ // плагин собираются одной сборкой (build-game-manifest.js читает то же
78
+ // entries.client) и их engineApi всегда совпадает — проверяем только
79
+ // манифест (дешевле: до сетевого import), плагин сверяем после загрузки как
80
+ // защиту от рассинхрона сборки, а не как отдельный путь отказа
81
+ export async function loadClientPlugin(manifest) {
82
+ assertEngineApiCompatible(manifest);
83
+
84
+ const module = await import(/* @vite-ignore */ manifest.entries.client);
85
+ const plugin = module.default;
86
+
87
+ if (plugin.engineApi !== manifest.engineApi) {
88
+ throw new Error(
89
+ `game "${manifest.id}": plugin engineApi v${plugin.engineApi} ` +
90
+ `does not match manifest engineApi v${manifest.engineApi}`,
91
+ );
92
+ }
93
+
94
+ return plugin;
95
+ }
package/src/lib/jwt.js ADDED
@@ -0,0 +1,103 @@
1
+ // декодирует base64url-сегмент JWT в строку (без проверки подписи)
2
+ function base64UrlToString(segment) {
3
+ const base64 = segment.replace(/-/g, '+').replace(/_/g, '/');
4
+
5
+ return atob(base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '='));
6
+ }
7
+
8
+ // декодирует base64url-сегмент JWT в байты (подпись — для crypto.subtle.verify)
9
+ function base64UrlToBytes(segment) {
10
+ const binary = base64UrlToString(segment);
11
+ const bytes = new Uint8Array(binary.length);
12
+
13
+ for (let i = 0; i < binary.length; i += 1) {
14
+ bytes[i] = binary.charCodeAt(i);
15
+ }
16
+
17
+ return bytes;
18
+ }
19
+
20
+ // Разбор payload JWT без проверки подписи — только для чтения claims на
21
+ // клиенте (отображение ника в лобби). Подпись авторитетно проверяет хост по
22
+ // /jwks (Этап B3); эта функция не является средством аутентификации.
23
+ export function decodeJwtPayload(token) {
24
+ if (typeof token !== 'string') {
25
+ return null;
26
+ }
27
+
28
+ const parts = token.split('.');
29
+
30
+ if (parts.length !== 3) {
31
+ return null;
32
+ }
33
+
34
+ try {
35
+ return JSON.parse(base64UrlToString(parts[1]));
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ // Авторитетная проверка identity-токена (Этап B3): подпись RS256 по JWKS
42
+ // central auth-сервиса (packages/auth), issuer и срок годности. Работает через
43
+ // Web Crypto API (crypto.subtle) — доступен в браузере, Worker'е хоста и в
44
+ // Node ≥19 глобально, отдельной JWT-библиотеки не требует. Возвращает
45
+ // проверенный payload ({ sub, nick, iss, exp, ... }) или бросает исключение.
46
+ export async function verifyIdentityToken(token, { jwks, issuer } = {}) {
47
+ if (typeof token !== 'string') {
48
+ throw new Error('token must be a string');
49
+ }
50
+
51
+ const parts = token.split('.');
52
+
53
+ if (parts.length !== 3) {
54
+ throw new Error('malformed token');
55
+ }
56
+
57
+ const [headerSeg, payloadSeg, signatureSeg] = parts;
58
+ const header = JSON.parse(base64UrlToString(headerSeg));
59
+ const payload = JSON.parse(base64UrlToString(payloadSeg));
60
+
61
+ if (header.alg !== 'RS256') {
62
+ throw new Error(`unsupported alg: ${header.alg}`);
63
+ }
64
+
65
+ if (issuer && payload.iss !== issuer) {
66
+ throw new Error('unknown issuer');
67
+ }
68
+
69
+ if (typeof payload.exp !== 'number' || Date.now() >= payload.exp * 1000) {
70
+ throw new Error('token expired');
71
+ }
72
+
73
+ if (typeof payload.nick !== 'string' || !payload.nick) {
74
+ throw new Error('token has no nick');
75
+ }
76
+
77
+ const jwk = jwks?.keys?.find(k => k.kid === header.kid);
78
+
79
+ if (!jwk) {
80
+ throw new Error('unknown key id');
81
+ }
82
+
83
+ const key = await crypto.subtle.importKey(
84
+ 'jwk',
85
+ jwk,
86
+ { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
87
+ false,
88
+ ['verify'],
89
+ );
90
+
91
+ const valid = await crypto.subtle.verify(
92
+ 'RSASSA-PKCS1-v1_5',
93
+ key,
94
+ base64UrlToBytes(signatureSeg),
95
+ new TextEncoder().encode(`${headerSeg}.${payloadSeg}`),
96
+ );
97
+
98
+ if (!valid) {
99
+ throw new Error('invalid signature');
100
+ }
101
+
102
+ return payload;
103
+ }