vimp-engine 0.38.1 → 0.38.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 lgick
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,9 +1,15 @@
1
1
  # vimp-engine
2
2
 
3
- VIMP — движок-приложение: мастер (лобби, WebRTC-сигналинг), P2P-транспорт,
4
- Worker-хост, мета-модули матча, MVC-каркас клиента. Игровые правила
5
- (например, `@vimp-games/tanks`) подключаются динамически через
6
- [plugin-api](https://github.com/lgick/vimp/blob/main/docs/en/plugin-api.md)
7
- — этот пакет их не содержит.
3
+ The VIMP engine SDK for game plugins: the browser host and client runtime,
4
+ the standalone mode (`vimp-engine/standalone`) and the CLI tools `vimp-sim`
5
+ and `vimp-contract`. Game rules live in separate plugin packages that the
6
+ engine loads at runtime through the
7
+ [plugin contract](https://docs.vimp.lgick.dev/en/plugin-api).
8
8
 
9
- Документация: https://github.com/lgick/vimp/tree/main/docs/en
9
+ Start a game:
10
+
11
+ ```bash
12
+ npm create vimp-game my-game
13
+ ```
14
+
15
+ Documentation: https://docs.vimp.lgick.dev
package/bin/vimp-sim.js CHANGED
@@ -40,8 +40,8 @@ const BUILTIN_NOTICE =
40
40
  'notice: the built-in smoke scenario is running against --game. It only\n' +
41
41
  'notice: proves the loop closes on this plugin: key coverage (invariant 2)\n' +
42
42
  'notice: and prediction drift (invariant 9) are skipped — both need a\n' +
43
- 'notice: scenario written for your game. See § Scenario format in the\n' +
44
- "notice: engine's docs/en/debugging.md (github.com/lgick/vimp), then run\n" +
43
+ 'notice: scenario written for your game. See § Scenario format at\n' +
44
+ 'notice: https://docs.vimp.lgick.dev/en/debugging#scenario-format, then run\n' +
45
45
  'notice: vimp-sim --scenario <file>.\n\n';
46
46
 
47
47
  // типичная опечатка «--core без --game»: фикстурное ядро — обычный JS и
package/core/Cargo.toml CHANGED
@@ -1,10 +1,11 @@
1
1
  [package]
2
2
  name = "vimp-engine-core"
3
- version = "0.23.2"
3
+ version = "0.23.3"
4
4
  edition = "2024"
5
- description = "VIMP — движковый каркас симуляции (физика, карта, снапшот-фрейминг, интерполяция/предикт/raycast-примитивы, нав-утилиты). Без wasm-bindgen — обёртки для WASM-ABI собирает game-crate."
5
+ description = "VIMP engine simulation core: physics, map, snapshot framing, interpolation/prediction/raycast primitives, navigation utilities. No wasm-bindgen — the game crate builds the WASM ABI wrappers."
6
6
  license = "MIT"
7
- repository = "https://github.com/lgick/vimp"
7
+ homepage = "https://docs.vimp.lgick.dev/en/core"
8
+ documentation = "https://docs.rs/vimp-engine-core"
8
9
  readme = "README.md"
9
10
 
10
11
  [lib]
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.38.1",
4
- "description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
3
+ "version": "0.38.2",
4
+ "description": "VIMP engine SDK: browser host and client runtime, standalone mode and CLI tools (vimp-sim, vimp-contract) for VIMP game plugins",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
8
- "url": "git+ssh://git@github.com/lgick/vimp.git"
8
+ "url": "git+ssh://git@github.com/lgick/vimp.git",
9
+ "directory": "packages/engine"
9
10
  },
10
- "homepage": "https://github.com/lgick/vimp#readme",
11
+ "homepage": "https://docs.vimp.lgick.dev",
11
12
  "type": "module",
12
13
  "engines": {
13
14
  "node": ">=20.11.0"
@@ -23,7 +24,12 @@
23
24
  },
24
25
  "files": [
25
26
  "src/lib",
27
+ "!src/lib/clientIp.js",
28
+ "!src/lib/rateLimiter.js",
29
+ "!src/lib/security.js",
26
30
  "src/config",
31
+ "!src/config/env.js",
32
+ "!src/config/master.js",
27
33
  "src/host",
28
34
  "src/client",
29
35
  "!src/client/_*",
@@ -31,7 +37,8 @@
31
37
  "src/devtools",
32
38
  "tests/fixtures",
33
39
  "core/Cargo.toml",
34
- "bin"
40
+ "bin",
41
+ "!bin/vimp-surface.js"
35
42
  ],
36
43
  "scripts": {
37
44
  "predev": "node scripts/sync-pixi-vendor.mjs",
@@ -13,7 +13,8 @@ export default class AuthView {
13
13
  // texts — игровые тексты формы (authSchema.texts: title, sections);
14
14
  // сам каркас (auth.pug) нейтрален и текстов игры не содержит.
15
15
  // params — та же дескрипторная схема формы, что и у room-формы
16
- // (docs/en/plugin-api.md "Form schema"), едет по сети в PS_AUTH_DATA
16
+ // (docs.vimp.lgick.dev/en/plugin-api "Form schema"), едет по сети в
17
+ // PS_AUTH_DATA
17
18
  constructor(model, elems, texts = null, params = null) {
18
19
  if (authView) {
19
20
  return authView;
@@ -63,7 +64,8 @@ export default class AuthView {
63
64
  }
64
65
 
65
66
  // строит контролы формы игрока из той же дескрипторной схемы, что и
66
- // room-форма (единый formBuilder, docs/en/plugin-api.md "Form schema")
67
+ // room-форма (единый formBuilder, docs.vimp.lgick.dev/en/plugin-api "Form
68
+ // schema")
67
69
  _renderFields(elems, params) {
68
70
  if (!Array.isArray(params)) {
69
71
  return;
@@ -4,9 +4,9 @@ import { resolveDescriptor } from '../../lib/formControls.js';
4
4
  import { toDisplay, toStored, isNumericField } from '../../lib/formUnit.js';
5
5
 
6
6
  // Общий билдер полей форм (room-форма и auth-форма используют один
7
- // контракт дескрипторов — docs/en/plugin-api.md, раздел "Form schema").
8
- // control: 'select'|'text'|'checkbox'|'radio'; все контролы — нативные
9
- // элементы формы, без визуальной кастомизации.
7
+ // контракт дескрипторов — docs.vimp.lgick.dev/en/plugin-api, раздел "Form
8
+ // schema"). control: 'select'|'text'|'checkbox'|'radio'; все контролы —
9
+ // нативные элементы формы, без визуальной кастомизации.
10
10
  //
11
11
  // Имя контрола приезжает от игры и разрешается через append-only реестр
12
12
  // (lib/formControls.js): имена, выведенные из эксплуатации в v3 ('range',
@@ -270,7 +270,8 @@ const builders = {
270
270
  // control» на имени, которое реестр считает живым
271
271
  export const FORM_CONTROLS = Object.keys(builders);
272
272
 
273
- // строит одно поле формы по дескриптору (docs/en/plugin-api.md, "Form schema")
273
+ // строит одно поле формы по дескриптору (docs.vimp.lgick.dev/en/plugin-api,
274
+ // "Form schema")
274
275
  export function buildField(descriptor, ctx = {}) {
275
276
  // алиас выведенного контрола ('range' → numeric 'text') разрешается
276
277
  // здесь же, где строится поле, и теми же правилами, что в валидации
@@ -291,10 +292,10 @@ export function buildField(descriptor, ctx = {}) {
291
292
  }
292
293
 
293
294
  // засеивает default каждого дескриптора room-формы значением из roomDefaults
294
- // (docs/en/plugin-api.md "Form schema": roomDefaults остаётся единственным
295
- // источником значений по умолчанию); явный descriptor.default, если он есть
296
- // в схеме, побеждает. Вынесено из populateRoomForm (main.js), чтобы этот же
297
- // код был накрыт тестом, а не продублирован в нём
295
+ // (docs.vimp.lgick.dev/en/plugin-api "Form schema": roomDefaults остаётся
296
+ // единственным источником значений по умолчанию); явный descriptor.default,
297
+ // если он есть в схеме, побеждает. Вынесено из populateRoomForm (main.js),
298
+ // чтобы этот же код был накрыт тестом, а не продублирован в нём
298
299
  export function mergeRoomDefaults(descriptors, roomDefaults) {
299
300
  return descriptors.map(descriptor => ({
300
301
  default: roomDefaults[descriptor.name],
@@ -934,7 +934,8 @@ socketMethods[PS_GAME_INFORM_DATA] = data => {
934
934
  };
935
935
 
936
936
  // проигрывает shimmer-волну по логотипу в начале раунда; логотип живёт вне
937
- // PanelView.containerId, поэтому панель им не управляет (см. docs/en/client.md)
937
+ // PanelView.containerId, поэтому панель им не управляет
938
+ // (см. docs.vimp.lgick.dev/en/client-api)
938
939
  const playLogoRoundStart = () => {
939
940
  const logo = document.getElementById('logo');
940
941
 
@@ -2182,8 +2183,8 @@ let roomFormFields = new Map();
2182
2183
  let roomFormDescriptors = [];
2183
2184
 
2184
2185
  // генерирует форму создания комнаты по явной схеме манифеста (roomForm,
2185
- // docs/en/plugin-api.md "Form schema") — движок не выводит контролы из типа
2186
- // значения, схема плагина полностью описывает форму
2186
+ // docs.vimp.lgick.dev/en/plugin-api "Form schema") — движок не выводит контролы
2187
+ // из типа значения, схема плагина полностью описывает форму
2187
2188
  function populateRoomForm(manifest) {
2188
2189
  const container = document.getElementById(lobbyConfig.elems.fieldsId);
2189
2190
 
@@ -2569,13 +2570,13 @@ function initLobby() {
2569
2570
  hostBtn?.addEventListener('click', async () => {
2570
2571
  // валидация (pattern/required/min/max) — единственная граница
2571
2572
  // room-формы: она едет клиенту как JSON манифеста, JS-валидаторы
2572
- // (как в auth-форме) туда не сериализуются (docs/en/plugin-api.md
2573
- // "Form schema"); авторитетный клампинг всё равно в applyRoomOverrides.js
2574
- // (вызывается из host.worker.js при создании комнаты). Проверяем до
2575
- // активации игры: неверная форма не должна стоить загрузки плагина.
2576
- // arm() и рисует ошибки, и снимает фильтр «только тронутые поля»: клик —
2577
- // это ответ за форму целиком, включая поля, которых игрок не касался.
2578
- // Он же чистит блок, когда ошибок нет
2573
+ // (как в auth-форме) туда не сериализуются
2574
+ // (docs.vimp.lgick.dev/en/plugin-api "Form schema"); авторитетный клампинг
2575
+ // всё равно в applyRoomOverrides.js (вызывается из host.worker.js при
2576
+ // создании комнаты). Проверяем до активации игры: неверная форма не должна
2577
+ // стоить загрузки плагина. arm() и рисует ошибки, и снимает фильтр «только
2578
+ // тронутые поля»: клик — это ответ за форму целиком, включая поля, которых
2579
+ // игрок не касался. Он же чистит блок, когда ошибок нет
2579
2580
  if (liveErrors.arm().length) {
2580
2581
  return;
2581
2582
  }
@@ -5,7 +5,7 @@ import Publisher from '../../lib/Publisher.js';
5
5
  // publisher с 'message'/'close', connect/send/close, — поэтому клиентский код
6
6
  // про него не знает.
7
7
  //
8
- // Отличия от WebRTC, которые надо держать в голове (см. docs/en/network.md):
8
+ // Отличия от WebRTC, которые надо держать в голове:
9
9
  // разделения meta/state тут нет, флаг reliable игнорируется. Значит PING/PONG
10
10
  // идут надёжным каналом (замер RTT становится замером TCP-пути), а бэкпрешер
11
11
  // вместо дропа позиционных кадров живёт на серверной стороне.
@@ -32,7 +32,8 @@ div#lobby(style='display:none')
32
32
  label.form-label(for='lobby-game')= 'Select game'
33
33
  select#lobby-game.field-select
34
34
  //- поля настроек комнаты генерируются по явной схеме roomForm
35
- //- манифеста активной игры (docs/en/plugin-api.md "Form schema")
35
+ //- манифеста активной игры (docs.vimp.lgick.dev/en/plugin-api "Form
36
+ //- schema")
36
37
  div#lobby-fields
37
38
  //- ошибки формы (formBuilder.renderFormErrors) и отказ загрузки
38
39
  //- плагина выбранной игры — строкой в форме, а не в #tech-informer:
@@ -1,7 +1,6 @@
1
1
  // Конфиг клиента профиля лобби. Бандлится в клиентскую сборку, как
2
2
  // config/lobby.js — auth-сервис живёт на отдельном домене/порте, поэтому
3
- // serviceUrl подставляется под конкретный деплой при сборке (см.
4
- // docs/en/auth.md).
3
+ // serviceUrl подставляется под конкретный деплой при сборке.
5
4
  export default {
6
5
  // базовый URL центрального auth-сервиса (packages/auth). Дефолт — dev-порт
7
6
  // из packages/auth/src/config/auth.js; в проде подставляется Vite'ом из
@@ -1,6 +1,6 @@
1
1
  // коды игровых информ-сообщений (порт GAME_INFORM_DATA, см.
2
- // docs/en/network.md) — общий контракт хоста (SocketManager) и клиента
3
- // (main.js): значение не должно расходиться между ними молча
2
+ // docs.vimp.lgick.dev/en/snapshot) — общий контракт хоста (SocketManager) и
3
+ // клиента (main.js): значение не должно расходиться между ними молча
4
4
  export default {
5
5
  winnerTeam: [0],
6
6
  roundStart: [1],
@@ -1,5 +1,5 @@
1
- // Номера портов JSON-канала (docs/en/network.md). Реестр append-only, как и
2
- // остальные словари плагинной поверхности (этап 3 плана
1
+ // Номера портов JSON-канала (docs.vimp.lgick.dev/en/snapshot). Реестр
2
+ // append-only, как и остальные словари плагинной поверхности (этап 3 плана
3
3
  // plugin-forward-compat): номер НИКОГДА не переиспользуется и не
4
4
  // перенумеровывается — уже опубликованная сборка игры шлёт и слушает те
5
5
  // номера, что видела при сборке, и её dist больше никто не тронет.
@@ -13,8 +13,8 @@ import { isFixture } from './pluginLoader.js';
13
13
  // Ввод начинается на 40-м тике, а не сразу: кадр спавна с force_reset
14
14
  // приходит примерно на interpolation.delay (движковый дефолт — 100 мс, тут
15
15
  // ~3× запаса) позже входа и чистит удержанные клавиши предиктора
16
- // (docs/en/debugging.md) — встроенный сценарий не должен демонстрировать
17
- // ровно ту ловушку, от которой предостерегает документация
16
+ // (docs.vimp.lgick.dev/en/debugging) — встроенный сценарий не должен
17
+ // демонстрировать ровно ту ловушку, от которой предостерегает документация
18
18
  const INPUT_DOWN_TICK = 40;
19
19
  const INPUT_UP_TICK = 100;
20
20
  const TICKS = 120;
@@ -26,7 +26,7 @@ const FIXTURE_UNUSED_KEYS = ['e1'];
26
26
  const firstKey = dict => Object.keys(dict ?? {})[0];
27
27
 
28
28
  // Клавиша для смоука обязана быть удерживаемой: по конвенции playerKeys
29
- // (docs/en/plugin-api.md) `type: 1` — триггер, у которого 'up'
29
+ // (docs.vimp.lgick.dev/en/plugin-api) `type: 1` — триггер, у которого 'up'
30
30
  // игнорируется, и «нажал — отпустил» не даёт ни удержания, ни движения.
31
31
  // Порядок объявления в playerKeys контрактом не задан, поэтому берём первую
32
32
  // без type; если игра объявила одни триггеры — первую любую, смоук всё равно
@@ -1,8 +1,9 @@
1
1
  import { ERROR, skip, verdict } from '../result.js';
2
2
 
3
- // Набор npm-скриптов пакета игры (docs/en/packaging.md). Это не вкусовое
4
- // требование: инструкции движка и CI сгенерированной игры зовут их по имени,
5
- // а отсутствующий core:build:node тихо оставляет headless-прогон без ядра.
3
+ // Набор npm-скриптов пакета игры (docs.vimp.lgick.dev/en/packaging). Это не
4
+ // вкусовое требование: инструкции движка и CI сгенерированной игры зовут их по
5
+ // имени, а отсутствующий core:build:node тихо оставляет headless-прогон без
6
+ // ядра.
6
7
  const REQUIRED = [
7
8
  'build',
8
9
  'build:client',
@@ -2,9 +2,10 @@ import { ERROR, skip, verdict } from '../result.js';
2
2
 
3
3
  // vite.config.js проверяется как текст, а не импортом: конфиг —
4
4
  // функция от mode и вне Vite не исполняется. Каждый пункт здесь ловит
5
- // молчаливую поломку сборки, а не стилистику (docs/en/packaging.md):
6
- // без preserveEntrySignatures default-экспорт плагина вытряхивает
7
- // tree-shaking, а build.lib всегда инлайнит ассеты.
5
+ // молчаливую поломку сборки, а не стилистику
6
+ // (docs.vimp.lgick.dev/en/packaging): без preserveEntrySignatures
7
+ // default-экспорт плагина вытряхивает tree-shaking, а build.lib всегда инлайнит
8
+ // ассеты.
8
9
  const REQUIRED = [
9
10
  [/emptyOutDir\s*:\s*false/, 'emptyOutDir: false (two builds share dist/)'],
10
11
  [/assetsInlineLimit\s*:\s*0/, 'assetsInlineLimit: 0'],
@@ -1,16 +1,17 @@
1
1
  import { WARN, skip, verdict } from '../result.js';
2
2
  import { resolveProjectUrl } from '../../../lib/packageLink.js';
3
3
 
4
- // Игра без объявленного репозитория собирается и играется — это не отказ.
5
- // Но движок показывает ссылку на проект в футере формы входа, и берёт он её
6
- // только отсюда (фолбэка нет, см. lib/packageLink.js): без поля ячейка
7
- // футера у игроков останется пустой, а заметить это без подсказки негде.
4
+ // Игра без объявленного homepage/repository собирается и играется — это не
5
+ // отказ. Но движок показывает ссылку на проект в футере формы входа, и берёт
6
+ // он её только из этих полей (фолбэка нет, см. lib/packageLink.js): без них
7
+ // ячейка футера у игроков останется пустой, а заметить это без подсказки негде.
8
8
  // Отсюда warn — под --strict он становится ошибкой.
9
9
  export default {
10
10
  id: 'A7',
11
11
  name: 'packageRepository',
12
12
  level: WARN,
13
- title: 'package.json declares repository (the #auth footer links to it)',
13
+ title:
14
+ 'package.json declares homepage or repository (the #auth footer links to it)',
14
15
 
15
16
  check(ctx) {
16
17
  if (!ctx.pkg) {
@@ -4,11 +4,11 @@ import { SERVICES } from '../../../config/clientServices.js';
4
4
  // Движковый пул сервисов держит реестр (config/clientServices.js), но пул
5
5
  // ими не исчерпывается: игра доливает туда свои через
6
6
  // ClientPlugin.hooks.services(core) — например геометрию предсказанной
7
- // динамики карты (docs/en/plugin-api.md «hooks.services»). Незнакомое имя не
8
- // ошибка для движка: part получает undefined и рисует пустоту — карта без
9
- // assetsBase выглядит как чистый холст без единой строки в консоли, ради
10
- // этого правило и существует. Правило остаётся ERROR: оно работает на этапе
11
- // разработки игры, а не в рантайме.
7
+ // динамики карты (docs.vimp.lgick.dev/en/plugin-api «hooks.services»).
8
+ // Незнакомое имя не ошибка для движка: part получает undefined и рисует пустоту
9
+ // — карта без assetsBase выглядит как чистый холст без единой строки в консоли,
10
+ // ради этого правило и существует. Правило остаётся ERROR: оно работает на
11
+ // этапе разработки игры, а не в рантайме.
12
12
  //
13
13
  // Что именно возвращает `hooks.services(core)`, статически не видно — хук
14
14
  // требует живого ядра. Поэтому у плагина есть необязательное поле
@@ -484,7 +484,7 @@ function predictionDrift({ clients, scenario }) {
484
484
  // Компоненты player-блока — игровая раскладка, движок знает только их
485
485
  // порядок, поэтому нарушение адресуется индексом. Исключение — уровень 0
486
486
  // (source 'camera'): там движок сравнивает ровно мировые x/y, и называть их
487
- // «#0/#1» значит прятать причину (см. docs/en/debugging.md).
487
+ // «#0/#1» значит прятать причину (см. docs.vimp.lgick.dev/en/debugging).
488
488
  const CAMERA_COMPONENTS = ['x', 'y'];
489
489
 
490
490
  function formatDivergence(record) {
@@ -12,9 +12,9 @@ import { loadGamePackage } from '../lib/loadGamePackage.js';
12
12
  // 3. ничего — фикстура miniGame из этого репозитория.
13
13
  //
14
14
  // entries.wasmNode — новое ОПЦИОНАЛЬНОЕ поле манифеста: конвенция
15
- // core/pkg-node/ уже описана в docs/en/packaging.md, не хватало только
16
- // пути в манифесте. Его отсутствие не ошибка — просто игра не прогоняется
17
- // headless своим настоящим ядром.
15
+ // core/pkg-node/ уже описана в docs.vimp.lgick.dev/en/packaging, не хватало
16
+ // только пути в манифесте. Его отсутствие не ошибка — просто игра не
17
+ // прогоняется headless своим настоящим ядром.
18
18
 
19
19
  const FIXTURE_DIR = new URL('../../tests/fixtures/miniGame/', import.meta.url);
20
20
 
@@ -1,9 +1,10 @@
1
1
  // Хендшейк-машина клиента, вынутая из host.worker.js: автомат по клиентским
2
- // портам 0–10 (см. docs/en/network.md). Изоморфна — ни self, ни postMessage,
3
- // ни DOM: всё, что знает про транспорт, приходит через makeSocket. Поэтому её
4
- // крутит и Worker браузерного хоста, и inline-хост standalone SDK, и
5
- // Node-процесс dedicated-сервера: копий автомата быть не должно, иначе они
6
- // разъедутся ровно так, как разъезжались бы копии createHostRuntime.
2
+ // портам (таблица портов — docs.vimp.lgick.dev/en/snapshot). Изоморфна — ни
3
+ // self, ни postMessage, ни DOM: всё, что знает про транспорт, приходит через
4
+ // makeSocket. Поэтому её крутит и Worker браузерного хоста, и inline-хост
5
+ // standalone SDK, и Node-процесс dedicated-сервера: копий автомата быть не
6
+ // должно, иначе они разъедутся ровно так, как разъезжались бы копии
7
+ // createHostRuntime.
7
8
 
8
9
  import wsports from '../config/wsports.js';
9
10
  import closeCodes from '../config/closeCodes.js';
@@ -12,7 +12,7 @@ import { ENGINE_CAPABILITIES } from './capabilities.js';
12
12
  // должен зависеть от отладочного контура (граница из CLAUDE.md).
13
13
  //
14
14
  // entries.wasmNode — node-сборка ядра игры (конвенция core/pkg-node,
15
- // docs/en/packaging.md). Для боевой игры она обязательна: без ядра
15
+ // docs.vimp.lgick.dev/en/packaging). Для боевой игры она обязательна: без ядра
16
16
  // авторитетную симуляцию крутить нечем.
17
17
 
18
18
  /**
@@ -1,7 +1,7 @@
1
1
  // Ссылка на проект для футеров лобби и формы входа: движок показывает рядом
2
- // npm-версию, и клик по соседней ячейке ведёт в репозиторий этого пакета.
2
+ // npm-версию, и клик по соседней ячейке ведёт на страницу проекта этого пакета.
3
3
  //
4
- // Источник — `repository` его package.json (иначе `homepage`). Фолбэка нет:
4
+ // Источник — `homepage` его package.json (иначе `repository`). Фолбэка нет:
5
5
  // пакет, не объявивший ни того, ни другого, ссылки не получает, и ячейка
6
6
  // футера остаётся пустой. Это сознательно — прежний фолбэк на страницу пакета
7
7
  // в npm давал разнобой («GitHub» у одной игры, «npm» у другой) и молча
@@ -16,8 +16,8 @@
16
16
  // (client/lib/engineVersion.js).
17
17
 
18
18
  /**
19
- * Адрес проекта из package.json, приведённый к https: repository, иначе
20
- * homepage.
19
+ * Адрес проекта из package.json, приведённый к https: homepage, иначе
20
+ * repository.
21
21
  * @param {Object} pkg - разобранный package.json
22
22
  * @returns {string|null} https-URL либо null, если поля нет или это не адрес
23
23
  */
@@ -32,10 +32,10 @@ export function resolveProjectUrl(pkg) {
32
32
  const declared =
33
33
  typeof repository === 'string' ? repository : repository?.url;
34
34
 
35
- // объявленный, но не приводящийся к http(s) repository (пустая строка,
36
- // внутренний git-хост) не повод терять валидный homepage: поле проверяется
37
- // по результату, а не по факту наличия
38
- return normalizeRepository(declared) ?? normalize(homepage);
35
+ // homepage важнее repository: поле repository обязано называть
36
+ // репозиторий публикации (его сверяет npm Trusted Publishing), и у пакета
37
+ // из закрытого репозитория такая ссылка отдаёт игроку 404
38
+ return normalize(homepage) ?? normalizeRepository(declared);
39
39
  }
40
40
 
41
41
  /**
@@ -94,8 +94,9 @@ function normalize(raw) {
94
94
 
95
95
  url = url.replace(/^git\+/, '');
96
96
 
97
- // scp-форма git@host:user/repo — двоеточие здесь разделитель, не порт
98
- const scp = /^git@([^:/]+):(.+)$/.exec(url);
97
+ // scp-форма git@host:user/repo (в том числе с лишним ssh://, как её
98
+ // нередко пишет npm) — двоеточие здесь разделитель, не порт
99
+ const scp = /^(?:ssh:\/\/)?git@([^:/]+):(.+)$/.exec(url);
99
100
 
100
101
  if (scp) {
101
102
  url = `https://${scp[1]}/${scp[2]}`;
@@ -44,7 +44,7 @@ import { ensureGameShell } from '../client/views/gameShell.js';
44
44
  * @param {boolean} [options.devMode] - room.isDevMode: рекордер и хостовый
45
45
  * CONSOLE-лог.
46
46
  * @param {string|null} [options.reportUrl] - Адрес приёма журнала клиентских
47
- * ошибок (`POST`, формат — docs/en/client.md, «Error reporting»). По
47
+ * ошибок (`POST`, формат — docs.vimp.lgick.dev/en/standalone). По
48
48
  * умолчанию `null`: в solo нет бокса, и отчёты не отправляются.
49
49
  * @returns {Promise<Object>} `{ stop() }` — останов матча.
50
50
  */
@@ -71,10 +71,10 @@ export async function startStandaloneGame({
71
71
  // возможность, которой в этой сборке движка нет, иначе всплыла бы где-то
72
72
  // в середине хендшейка непрозрачной ошибкой. Мастера тут нет, а значит нет
73
73
  // и GameManifest с его `requires` — источником служат сами половины
74
- // плагина (необязательное поле `requires`, docs/en/plugin-api.md) либо
75
- // опция вызова, если встраивающий знает лучше. Одна проверка вместо двух:
76
- // сообщение адресовано разработчику встраивания, делить его по половинам
77
- // незачем
74
+ // плагина (необязательное поле `requires`, docs.vimp.lgick.dev/en/plugin-api)
75
+ // либо опция вызова, если встраивающий знает лучше. Одна проверка вместо
76
+ // двух: сообщение адресовано разработчику встраивания, делить его по
77
+ // половинам незачем
78
78
  requireCompatible({
79
79
  id: hostPlugin.id,
80
80
  // mergeRequires, а не спред: строка вместо массива ('accolades' —
@@ -1,88 +0,0 @@
1
- #!/usr/bin/env node
2
- import { readFile, writeFile, mkdir } from 'node:fs/promises';
3
- import path from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
- import {
6
- collectSurface,
7
- formatSurface,
8
- diffSurface,
9
- } from '../src/devtools/surface/collect.js';
10
-
11
- // CLI слепка плагинной поверхности (этап 1 плана plugin-forward-compat):
12
- // без флага печатает расхождение с закоммиченным contract/surface.json и
13
- // возвращает 1 при нарушении И1/И3, с --write перезаписывает слепок.
14
- // Внутренний инструмент репозитория движка: в поле `bin` публикуемого
15
- // пакета он не значится.
16
-
17
- const SURFACE_PATH = fileURLToPath(
18
- new URL('../contract/surface.json', import.meta.url),
19
- );
20
-
21
- const USAGE = `Usage: vimp-surface [options]
22
-
23
- --write rewrite contract/surface.json with the collected surface
24
- --help
25
- `;
26
-
27
- async function main(argv) {
28
- if (argv.includes('--help')) {
29
- process.stdout.write(USAGE);
30
- return 0;
31
- }
32
-
33
- const surface = await collectSurface();
34
-
35
- if (argv.includes('--write')) {
36
- await mkdir(path.dirname(SURFACE_PATH), { recursive: true });
37
- await writeFile(SURFACE_PATH, formatSurface(surface));
38
- process.stdout.write(`surface: written ${SURFACE_PATH}\n`);
39
-
40
- return 0;
41
- }
42
-
43
- let committed;
44
-
45
- try {
46
- committed = JSON.parse(await readFile(SURFACE_PATH, 'utf8'));
47
- } catch {
48
- process.stderr.write(
49
- `surface: no snapshot at ${SURFACE_PATH} — run ` +
50
- '`npm run surface:update` to create it\n',
51
- );
52
-
53
- return 1;
54
- }
55
-
56
- const { violations, additions } = diffSurface(committed, surface);
57
-
58
- for (const addition of additions) {
59
- process.stdout.write(`surface: добавлено ${addition}\n`);
60
- }
61
-
62
- if (additions.length > 0 && violations.length === 0) {
63
- process.stdout.write(
64
- 'слепок устарел, запусти `npm run surface:update` (добавление ' +
65
- 'поверхности совместимость не ломает)\n',
66
- );
67
- }
68
-
69
- for (const violation of violations) {
70
- process.stderr.write(`${violation}\n\n`);
71
- }
72
-
73
- if (violations.length === 0 && additions.length === 0) {
74
- process.stdout.write('surface: слепок совпадает\n');
75
- }
76
-
77
- return violations.length > 0 ? 1 : 0;
78
- }
79
-
80
- main(process.argv.slice(2)).then(
81
- code => {
82
- process.exitCode = code;
83
- },
84
- err => {
85
- process.stderr.write(`${err.message}\n`);
86
- process.exitCode = 1;
87
- },
88
- );
package/src/config/env.js DELETED
@@ -1,104 +0,0 @@
1
- // Env-переопределения серверного конфига (Этап 4 плана standalone-sdk).
2
- // Раньше этот блок жил в master/main.js под условием NODE_ENV=production:
3
- // лобби-мастер в dev работает на дефолтах конфига. Dedicated-серверу так
4
- // нельзя — игру, порт и настройки комнаты он получает только из окружения,
5
- // в том числе локально, — поэтому чтение вынесено сюда и применяется каждым
6
- // входом по своим правилам.
7
-
8
- /**
9
- * Применяет env-переопределения к конфигу мастера.
10
- * @param {Object} config - Синглтон lib/config.js.
11
- * @param {Object} [env] - Окружение (по умолчанию process.env).
12
- */
13
- export function applyMasterEnv(config, env = process.env) {
14
- if (env.VIMP_DOMAIN) {
15
- config.set('master:domain', env.VIMP_DOMAIN);
16
- }
17
-
18
- // порт серверного процесса
19
- if (env.VIMP_MASTER_PORT) {
20
- config.set('master:port', Number(env.VIMP_MASTER_PORT));
21
- }
22
-
23
- // домен central auth-сервиса — попадает в CSP connect-src, т.к. лобби
24
- // делает туда прямые fetch (ручки профиля)
25
- if (env.VIMP_AUTH_SERVICE_URL) {
26
- config.set('master:security:authServiceUrl', env.VIMP_AUTH_SERVICE_URL);
27
- }
28
-
29
- // корень хранилища игровых пакетов (направление master-game-registry) —
30
- // в проде это смонтированный том, переживающий пересоздание контейнера
31
- if (env.VIMP_GAMES_DIR) {
32
- config.set('master:gameStore:dir', env.VIMP_GAMES_DIR);
33
- }
34
-
35
- // общий секрет бокса и auth-сервиса для пересылки журнала клиентских
36
- // ошибок (plan/client-reports)
37
- if (env.VIMP_CLIENT_REPORTS_TOKEN) {
38
- config.set('master:clientReports:token', env.VIMP_CLIENT_REPORTS_TOKEN);
39
- }
40
- }
41
-
42
- // минимальная длина VIMP_ROOM_SECRET_KEY — ключ HMAC-SHA256
43
- const ROOM_SECRET_KEY_MIN_BYTES = 32;
44
-
45
- /**
46
- * Ключ секрета комнаты (host-migration, этап 2): roomSecret = HMAC(ключ,
47
- * roomId:epoch:hostUserId) — им хост доказывает комнату после рестарта
48
- * мастера. В production обязателен; в dev без него генерируется случайный на
49
- * время процесса (комнаты после рестарта dev-мастера не восстанавливаются).
50
- * @param {Object} [env] - Окружение (по умолчанию process.env).
51
- * @param {Object} opts
52
- * @param {boolean} opts.isProduction
53
- * @param {Function} opts.randomBytes - crypto.randomBytes.
54
- * @returns {{ key: string|Buffer, ephemeral: boolean }}
55
- */
56
- export function readRoomSecretKey(
57
- env = process.env,
58
- { isProduction, randomBytes },
59
- ) {
60
- const key = env.VIMP_ROOM_SECRET_KEY;
61
-
62
- if (key) {
63
- if (Buffer.byteLength(key) < ROOM_SECRET_KEY_MIN_BYTES) {
64
- throw new Error(
65
- `VIMP_ROOM_SECRET_KEY: at least ${ROOM_SECRET_KEY_MIN_BYTES} bytes required`,
66
- );
67
- }
68
-
69
- return { key, ephemeral: false };
70
- }
71
-
72
- if (isProduction) {
73
- throw new Error('VIMP_ROOM_SECRET_KEY must be set in production');
74
- }
75
-
76
- return { key: randomBytes(ROOM_SECRET_KEY_MIN_BYTES), ephemeral: true };
77
- }
78
-
79
- /**
80
- * Настройки комнаты dedicated-сервера: VIMP_DEDICATED_ROOM — JSON-объект
81
- * (map, maxPlayers, roundTime, mapTime, friendlyFire, seed). Мусор в
82
- * переменной не должен ронять процесс молча — отказ именованный.
83
- * @param {Object} [env] - Окружение (по умолчанию process.env).
84
- * @returns {Object} Переопределения комнаты.
85
- */
86
- export function readDedicatedRoom(env = process.env) {
87
- if (!env.VIMP_DEDICATED_ROOM) {
88
- return {};
89
- }
90
-
91
- let room;
92
-
93
- try {
94
- room = JSON.parse(env.VIMP_DEDICATED_ROOM);
95
- } catch (e) {
96
- throw new Error(`VIMP_DEDICATED_ROOM: invalid JSON — ${e.message}`);
97
- }
98
-
99
- if (!room || typeof room !== 'object' || Array.isArray(room)) {
100
- throw new Error('VIMP_DEDICATED_ROOM: expected a JSON object');
101
- }
102
-
103
- return room;
104
- }
@@ -1,288 +0,0 @@
1
- import path from 'path';
2
- import { fileURLToPath } from 'url';
3
-
4
- // корень репозитория — якорь от расположения файла, не от cwd
5
- const rootDir = path.resolve(
6
- fileURLToPath(import.meta.url),
7
- '..',
8
- '..',
9
- '..',
10
- '..',
11
- '..',
12
- );
13
-
14
- export default {
15
- name: 'VIMP Master Server',
16
- protocol: 'https:',
17
- domain: 'localhost',
18
- // 3000 — игровой сервер, 3001 — Vite HMR (vite.config.js)
19
- port: 3002,
20
-
21
- // сертификаты для локальной разработки — в .certs корня репозитория
22
- // (в продакшене обычный HTTP за Nginx)
23
- httpsOptions: {
24
- key: path.join(rootDir, '.certs', 'key.pem'),
25
- cert: path.join(rootDir, '.certs', 'cert.pem'),
26
- },
27
-
28
- // Статический каталог игр-плагинов. ПУСТ по умолчанию
29
- // (master-game-registry, этап 3): штатный источник каталога — реестр игр
30
- // auth-сервиса, откуда мастер скачивает одобренные пакеты сам
31
- // (master/GameSync.js). Движок игр не знает и зависимостями их не тянет.
32
- //
33
- // Массив остаётся двум потребителям, и env-переопределения у него нет:
34
- // локальной разработке (каталог наполняют собранные пакеты @vimp-games/*
35
- // из node_modules, master/localGames.js — прилинкованная игра важнее
36
- // реестра) и dedicated-серверу, который берёт отсюда игру, названную
37
- // VIMP_DEDICATED_GAME. `package` — имя npm-пакета игры, резолвится через
38
- // node_modules; версию задаёт сама установленная зависимость.
39
- // maxGameScore (snakes-v3 этап 3.3, необязательное поле рядом с
40
- // id/package) — потолок результата ОДНОЙ игры для этой игры; мастер
41
- // клампит им `best`/`points` PUT /auth/rank. Для игр реестра его задаёт
42
- // админ при модерации, здесь — только запасной путь. Не задан — дефолт
43
- // master:playerData:maxGameScore
44
- games: [],
45
-
46
- // Хранилище игровых пакетов (направление master-game-registry): мастер
47
- // качает одобренные игры из npm registry и раздаёт их с диска, вместо того
48
- // чтобы получать их npm-зависимостью на этапе сборки образа.
49
- gameStore: {
50
- // корень хранилища; null → <repoRoot>/.games (локальная разработка).
51
- // В проде задаётся VIMP_GAMES_DIR и монтируется томом
52
- dir: null,
53
- registryUrl: 'https://registry.npmjs.org',
54
- // период опроса реестра auth за изменениями каталога
55
- refreshInterval: 60000,
56
- // потолки распаковки недоверенного архива
57
- maxTarballBytes: 64 * 1024 * 1024,
58
- maxFiles: 5000,
59
- // сколько версий одной игры держать на диске: активная + стейджевая
60
- keepVersions: 2,
61
- // потолок ожидания ответа реестра
62
- timeout: 30000,
63
- },
64
-
65
- // список серверов (GET /servers)
66
- servers: {
67
- // если всего комнат <= порога — региональный фильтр
68
- // и пагинация отключаются, отдаётся весь список
69
- regionThreshold: 15,
70
- defaultLimit: 10, // размер страницы по умолчанию
71
- maxLimit: 50, // максимальный размер страницы
72
- },
73
-
74
- // ограничения регистрируемых комнат
75
- host: {
76
- // санитарная рамка вместимости комнаты для случая, когда игра комнаты
77
- // неизвестна мастеру (gameId: null у старых хостов или id не из
78
- // каталога). Потолок известной игры задаёт её манифест
79
- // (roomDefaults.maxPlayers) — движок его не ограничивает
80
- maxPlayersLimit: 8,
81
- heartbeatTimeout: 30000, // нет heartbeat дольше — хост потерян
82
- sweepInterval: 10000, // период уборки реестра комнат
83
- },
84
-
85
- // жизнь комнаты (host-migration, этап 2): комната живёт, пока в ней есть
86
- // участник
87
- room: {
88
- // отсоединённый участник ещё считается в комнате (реконнект сигналинга)
89
- memberGraceMs: 15000,
90
- // обрыв WS хоста запускает миграцию не сразу, а через столько, если хост
91
- // не вернулся reclaim_host'ом и ни один гость не сообщил host_unreachable
92
- // (host_leaving мигрирует сразу); reclaim_host после начала миграции, до
93
- // регистрации преемника, её отменяет (этап 7.0)
94
- hostDisconnectGraceMs: 2000,
95
- // повышать некого — комната ждёт reclaim_host столько, потом уборка
96
- // мигрирует или закрывает её
97
- hostReclaimGraceMs: 10000,
98
- // потолок строки карточки комнаты в лобби (gameConfig.lobbyInfo игры)
99
- maxInfoLength: 48,
100
- // RTT участников (host-migration этап 6): ws.ping() каждой сессии с
101
- // этим периодом; EMA RTT и джиттера — единая линейка связности для
102
- // хоста и кандидатов
103
- rttProbeIntervalMs: 5000,
104
- // сессия без pong дольше — terminate(): «тихо умерший» хост находится
105
- // быстрее, чем по heartbeatTimeout
106
- wsDeadAfterMs: 12000,
107
- // преемник (бета): кандидат должен пробыть в комнате не меньше этого
108
- minMemberAgeMs: 10000,
109
- // плановый пересчёт преемника (помимо join/leave/member_update)
110
- successorReviewMs: 15000,
111
- // гистерезис смены беты: лучший кандидат держит score не хуже
112
- // successorSwitchRatio × score текущего дольше successorSwitchSustainMs
113
- successorSwitchSustainMs: 30000,
114
- successorSwitchRatio: 0.65,
115
- // аварийная миграция (этап 7): бета — преемник с точкой, если она
116
- // получила её не раньше checkpointMaxAgeMs назад (по standby_status.ageMs;
117
- // статус идёт раз в 5 с — окно больше двух периодов), иначе cold-промоушен любого
118
- // способного; преемник не занял комнату за promotionTimeoutMs
119
- // (coldPromotionTimeoutMs — он перезагружает страницу) — следующий
120
- checkpointMaxAgeMs: 12000,
121
- promotionTimeoutMs: 10000,
122
- coldPromotionTimeoutMs: 25000,
123
- // отчёты host_unreachable: хост не ответил на probe за probeTimeoutMs —
124
- // потерян; ответил, но за reportWindowMs отчиталась половина гостей —
125
- // принудительная миграция, не чаще forcedMigrationCooldownMs на комнату.
126
- // Гости и отчёты считаются по аккаунтам; кворум — не меньше
127
- // minUnreachableReporters разных пользователей (в паре с плохим P2P один
128
- // гость не снимает хоста — иначе роль «пинг-понгом» ходит по кругу)
129
- probeTimeoutMs: 2000,
130
- reportWindowMs: 5000,
131
- forcedMigrationCooldownMs: 30000,
132
- minUnreachableReporters: 2,
133
- // плановая передача (этап 8): преемник не занял комнату за
134
- // handoffTimeoutMs — передача отменяется, хост размораживает матч
135
- handoffTimeoutMs: 8000,
136
- // host_migrating.waitMs — дедлайн текущей попытки промоушена (передачи)
137
- // плюс migrationNoticeMarginMs: столько гость ждёт host_changed
138
- migrationNoticeMarginMs: 5000,
139
- // автотриггеры (этап 9c). Сетевой лаг хоста: медиана его RTT до гостей
140
- // (host_health) выше lagRttThresholdMs непрерывно lagSustainMs, а score
141
- // беты лучше score хоста хотя бы на lagImprovementRatio — хосту
142
- // request_handoff. Не раньше autoMigrationCooldownMs с прошлой
143
- // авто-смены хоста в комнате (overload/hidden/network — кулдаун общий)
144
- // и с получения роли текущим хостом
145
- lagRttThresholdMs: 250,
146
- lagSustainMs: 10000,
147
- lagImprovementRatio: 0.35,
148
- autoMigrationCooldownMs: 90000,
149
- // бета — не ниже этого FPS рендера (caps.fps гостя; неизвестный FPS не
150
- // отсеивает). Аварийный промоушен порог не применяет
151
- minSuccessorFps: 30,
152
- // голосование «Change host» (этап 10, /changehost; только лобби-режим):
153
- // голоса считает мастер. Длительность голосования; повторный старт в
154
- // комнате и тем же пользователем — не раньше кулдаунов; прошедшее
155
- // голосование просит хоста отдать роль, не начал за voteForceAfterMs —
156
- // принудительная миграция; снятый хост не бета и не хост комнаты
157
- // demotedCooldownMs (кроме случая, когда больше принять некому).
158
- // Один голос на аккаунт; голосует и начинает участник, пробывший в
159
- // комнате не меньше minVoterAgeMs (свежие вкладки не накручивают голоса).
160
- // После смены хоста голосование ждёт первого room_peers нового хоста —
161
- // до него подтверждёнными считаются все, и фантомы попали бы в число
162
- // голосующих, — но не дольше peersReportGraceMs (больше
163
- // lobby.migration.peersReportIntervalMs: потерянный первый отчёт
164
- // повторится внутри окна; хост старше room_peers отчёта не пришлёт)
165
- vote: {
166
- hostVoteDurationMs: 15000,
167
- roomVoteCooldownMs: 120000,
168
- userStartCooldownMs: 60000,
169
- voteForceAfterMs: 5000,
170
- demotedCooldownMs: 600000,
171
- minVoterAgeMs: 30000,
172
- peersReportGraceMs: 20000,
173
- },
174
- // GET /rooms/:roomId (прямая ссылка, этап 3) на IP: открытие ссылки —
175
- // один запрос, лимит только против перебора roomId
176
- lookupRateLimit: {
177
- limit: 20,
178
- windowMs: 1000,
179
- },
180
- },
181
-
182
- // GET /auth/leaderboard (code review L2): TTL кэша на мастере (мс) и
183
- // верхняя граница ?limit= — публичный анонимный эндпоинт, самый частый
184
- // запрос лобби, выборка меняется медленно
185
- leaderboard: {
186
- cacheTtl: 15000,
187
- maxLimit: 100,
188
- },
189
-
190
- // GET /auth/placement + агрегирующий GET /auth/placements (snakes-v3
191
- // этап 3.3): место меняется медленно, а каждый вход участника стоит трёх
192
- // срезов. Кэш здесь про round-trip до auth; стоимость самого запроса
193
- // снята на стороне auth (RankDistribution)
194
- placement: {
195
- cacheTtl: 30000,
196
- },
197
-
198
- // пределы записи профилей в БД (snakes-v3 этап 3, решение пользователя 9):
199
- // «игр сотни, серверов сотни» — минимальный интервал держит движок на
200
- // стороне хоста, а мастер держит потолок для сломанного или злонамеренного
201
- // сервера, который этот интервал обошёл
202
- playerData: {
203
- // PUT /auth/rank + /auth/state на комнату (проверенный roomId) в минуту.
204
- // Честная комната на 32 при lobbyConfig.playerData.minFlushInterval в
205
- // 5 минут пишет 64 запроса за эти 5 минут, то есть ~13/мин; остальное —
206
- // запас на срочные границы (уход участника обходит интервал), и его
207
- // хватает даже комнате, полностью сменившей состав дважды за минуту.
208
- // Потолок держит не честную комнату, а сломанную или злонамеренную,
209
- // поэтому запас считается от честной, а не «пусть будет побольше»
210
- writesPerMinute: 120,
211
- // потолок результата ОДНОЙ игры, если игра не объявила свой
212
- // (master:games[].maxGameScore): обоснование — plan/snakes-v3/stage_2.md
213
- maxGameScore: 10000,
214
- },
215
-
216
- // заголовки безопасности (гигиена среды, Этап 5.4). CSP на статику/.wasm в
217
- // проде ставит Nginx (см. docs/deployment.md) — здесь single source of truth
218
- // политики; мастер применяет её к своим ответам только в проде (в dev CSP
219
- // сломала бы Vite HMR). WASM требует 'wasm-unsafe-eval', Worker — 'blob:';
220
- // connect-src data: — PixiJS фетчит тестовый data:-URL для проверки ImageBitmap.
221
- // authServiceUrl — домен central auth-сервиса (packages/auth): лобби
222
- // делает туда прямой fetch (браузерные ручки auth-сервиса), поэтому
223
- // connect-src должен его разрешать; сам OAuth-редирект (location.href на
224
- // auth-сервис/провайдера) CSP не ограничивает — это навигация верхнего
225
- // уровня, не fetch/XHR. challenges.cloudflare.com в script-src и frame-src
226
- // — скрипт и iframe виджета Turnstile на экране регистрации.
227
- // script-src несёт sha256-хэш инлайнового importmap из index.html (для
228
- // pixi.js — src="..." на <script type="importmap"> браузеры не
229
- // поддерживают, инлайн обязателен). Хэш посчитан по факту собранного
230
- // packages/engine/dist/index.html скриптом
231
- // scripts/check-importmap-csp-hash.mjs (запускается postbuild) — vite
232
- // build минифицирует HTML и может изменить байты скрипта, поэтому хэш
233
- // нельзя брать из исходника/консоли браузера; при правке importmap или
234
- // апгрейде Vite смотреть на вывод postbuild-проверки.
235
- security: {
236
- authServiceUrl: 'http://localhost:3010',
237
- csp: authServiceUrl =>
238
- [
239
- "default-src 'self'",
240
- "script-src 'self' 'wasm-unsafe-eval' 'sha256-XJmzkFBLHYpcM8KgGRFztTJTwfMb5xIFKAmqlgTpobo=' https://challenges.cloudflare.com",
241
- "worker-src 'self' blob:",
242
- 'frame-src https://challenges.cloudflare.com',
243
- `connect-src 'self' wss: data:${authServiceUrl ? ` ${authServiceUrl}` : ''}`,
244
- "img-src 'self' data: blob:",
245
- "style-src 'self' 'unsafe-inline'",
246
- "object-src 'none'",
247
- "base-uri 'self'",
248
- "frame-ancestors 'none'",
249
- ].join('; '),
250
- referrerPolicy: 'no-referrer',
251
- },
252
-
253
- // журнал клиентских ошибок (plan/client-reports): приём POST
254
- // /client-reports на боксе и пересылка пачками в auth-сервис. Пустой
255
- // token — пересылка выключена, остаётся строка в журнале процесса
256
- clientReports: {
257
- token: '',
258
- flushIntervalMs: 30000,
259
- forwardBatch: 50,
260
- forwardTimeoutMs: 5000,
261
- maxPending: 500, // потолок разных отпечатков в буфере
262
- logSeenMax: 5000, // сколько отпечатков процесс помнит «уже печатал»
263
- // бюджет НОВЫХ отпечатков на бокс (решение 9 плана): распределённый спам
264
- // обходит лимит по IP, а новая строка в auth — главный ресурс. Повторы
265
- // известных отпечатков бюджет не тратят
266
- newFingerprintsPerMinute: 60,
267
- // ключ — адрес, для IPv6 — подсеть /64 (lib/clientIp.js → rateLimitKey)
268
- // клиент шлёт не чаще раза в 10 с — запас на несколько вкладок за одним
269
- // адресом (NAT); главная защита — бюджет новых отпечатков
270
- rateLimit: { limit: 30, windowMs: 60000 },
271
- bodyLimit: '16kb',
272
- maxItemsPerRequest: 10,
273
- },
274
-
275
- // заголовок с регионом хоста от Nginx/CDN (например, CF-IPCountry);
276
- // выбран вместо geoip-lite — бесплатнее по памяти
277
- regionHeader: 'x-region',
278
-
279
- // лимит сигнальных ping-запросов с одного IP (защита от DDOS)
280
- pingRateLimit: {
281
- limit: 10,
282
- windowMs: 1000,
283
- },
284
-
285
- // ICE-конфигурация для установки P2P-соединений:
286
- // STUN обязателен; TURN — опциональный релей по итогам Этапа 0
287
- iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
288
- };
@@ -1,96 +0,0 @@
1
- // Адрес клиента для rate-limit'ов серверных контуров.
2
- //
3
- // `X-Forwarded-For` для этого не годится: Nginx деплоя ставит его через
4
- // `$proxy_add_x_forwarded_for` (.github/deployment/install-system.sh), то есть
5
- // ДОПИСЫВАЕТ реальный адрес к тому, что прислал клиент. Первый элемент списка
6
- // задаёт сам клиент — на нём лимит и обходится (свой заголовок на каждое
7
- // соединение), и выбирается чужой бакет (заголовок с адресом жертвы закрывает
8
- // ей вход). Тот же Nginx перезаписывает `X-Real-IP` значением `$remote_addr`,
9
- // поэтому за прокси доверяем только ему, а без прокси — адресу сокета.
10
-
11
- // один раз на процесс: предупреждение о прокси без X-Real-IP на каждое
12
- // соединение превратилось бы в лог-флуд
13
- let proxyHeaderWarned = false;
14
-
15
- /**
16
- * @param {Object} req - Запрос (http.IncomingMessage или express Request).
17
- * @param {Object} [options]
18
- * @param {boolean} [options.trustProxy] - Стоит ли перед процессом обратный
19
- * прокси, перезаписывающий `X-Real-IP` (в этом репозитории — прод-Nginx).
20
- * @returns {string} Адрес клиента или '' (сокет уже разорван).
21
- */
22
- export function clientIp(req, { trustProxy = false } = {}) {
23
- const header = trustProxy ? req.headers['x-real-ip'] : undefined;
24
-
25
- // за прокси без X-Real-IP ключом станет адрес самого прокси — один общий
26
- // бакет на всех клиентов сразу: правило «одна комната на IP» пустило бы
27
- // одну комнату на весь мастер, а лимит пингов стал бы общим. Падать на
28
- // первом запросе нельзя (сервер должен подняться), молчать — тоже
29
- if (trustProxy && !header && !proxyHeaderWarned) {
30
- proxyHeaderWarned = true;
31
- console.warn(
32
- '[clientIp] trustProxy is on but no X-Real-IP header arrived: every ' +
33
- "client now keys on the proxy's own address, one shared rate-limit " +
34
- 'bucket. Check `proxy_set_header X-Real-IP $remote_addr`.',
35
- );
36
- }
37
-
38
- return String(header || req.socket?.remoteAddress || '').trim();
39
- }
40
-
41
- // Ключ rate-limit'а по адресу: IPv4 — сам адрес, IPv6 — подсеть /64.
42
- // Провайдер выдаёт абоненту /64 целиком, то есть у одного человека
43
- // 2^64 адресов, и лимит «на адрес» для IPv6 не лимит вовсе
44
- export function rateLimitKey(ip) {
45
- const addr = String(ip ?? '').trim();
46
-
47
- if (!addr) {
48
- return '';
49
- }
50
-
51
- const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(addr);
52
-
53
- if (mapped) {
54
- return mapped[1];
55
- }
56
-
57
- if (!addr.includes(':')) {
58
- return addr;
59
- }
60
-
61
- const groups = expandIpv6(addr.split('%')[0]);
62
-
63
- if (!groups) {
64
- return addr;
65
- }
66
-
67
- return `v6:${groups
68
- .slice(0, 4)
69
- .map(g => parseInt(g, 16).toString(16))
70
- .join(':')}::/64`;
71
- }
72
-
73
- // '2001:db8::1' → 8 hex-групп или null, если запись непарсима
74
- function expandIpv6(addr) {
75
- const halves = addr.split('::');
76
-
77
- if (halves.length > 2) {
78
- return null;
79
- }
80
-
81
- const head = halves[0] ? halves[0].split(':') : [];
82
- const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
83
- const missing = 8 - head.length - tail.length;
84
-
85
- if (halves.length === 1 ? missing !== 0 : missing < 1) {
86
- return null;
87
- }
88
-
89
- const groups = [
90
- ...head,
91
- ...Array(halves.length === 2 ? missing : 0).fill('0'),
92
- ...tail,
93
- ];
94
-
95
- return groups.every(g => /^[0-9a-f]{1,4}$/i.test(g)) ? groups : null;
96
- }
@@ -1,40 +0,0 @@
1
- // Rate limiter с фиксированным окном: не более `limit` событий
2
- // на ключ (например, IP) за `windowMs` миллисекунд.
3
- export default class RateLimiter {
4
- constructor({ limit, windowMs }) {
5
- this._limit = limit;
6
- this._windowMs = windowMs;
7
- this._buckets = new Map(); // key -> { count, windowStart }
8
- }
9
-
10
- // регистрирует событие; false — лимит для ключа исчерпан
11
- consume(key, now = Date.now()) {
12
- const bucket = this._buckets.get(key);
13
-
14
- if (!bucket || now - bucket.windowStart >= this._windowMs) {
15
- this._buckets.set(key, { count: 1, windowStart: now });
16
- return true;
17
- }
18
-
19
- if (bucket.count >= this._limit) {
20
- return false;
21
- }
22
-
23
- bucket.count += 1;
24
-
25
- return true;
26
- }
27
-
28
- // удаляет истёкшие окна (периодическая уборка памяти)
29
- sweep(now = Date.now()) {
30
- for (const [key, bucket] of this._buckets) {
31
- if (now - bucket.windowStart >= this._windowMs) {
32
- this._buckets.delete(key);
33
- }
34
- }
35
- }
36
-
37
- clear() {
38
- this._buckets.clear();
39
- }
40
- }
@@ -1,45 +0,0 @@
1
- import config from './config.js';
2
-
3
- // создаёт проверку origin для заданных protocol/domain/port
4
- // (используется и игровым сервером, и мастер-сервером)
5
- const createOriginValidator = function ({ protocol, domain, port }) {
6
- return function (requestOrigin, cb) {
7
- const isProduction = process.env.NODE_ENV === 'production';
8
-
9
- const allowedOrigins = [
10
- // origin'ы для локальной разработки
11
- `${protocol}//localhost:${port}`,
12
- `${protocol}//127.0.0.1:${port}`,
13
- ];
14
-
15
- if (isProduction) {
16
- // в продакшене разрешен только настоящий домен по стандартному HTTPS
17
- // порт не требуется, так как браузер не добавляет его
18
- // для стандартного порта 443
19
- allowedOrigins.push(`https://${domain}`);
20
- }
21
-
22
- let err = null;
23
-
24
- if (!allowedOrigins.includes(requestOrigin)) {
25
- err = `Blocked connection from invalid origin: ${requestOrigin}`;
26
- }
27
-
28
- process.nextTick(() => cb(err));
29
- };
30
- };
31
-
32
- const origin = function (requestOrigin, cb) {
33
- const validator = createOriginValidator({
34
- protocol: config.get('server:protocol'),
35
- domain: config.get('server:domain'),
36
- port: config.get('server:port'),
37
- });
38
-
39
- validator(requestOrigin, cb);
40
- };
41
-
42
- export default {
43
- origin,
44
- createOriginValidator,
45
- };