vimp-engine 0.7.2 → 0.9.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 (74) hide show
  1. package/package.json +11 -4
  2. package/src/client/InputListener.js +36 -0
  3. package/src/client/SoundManager.js +498 -0
  4. package/src/client/boot.js +113 -0
  5. package/src/client/components/controller/Auth.js +44 -0
  6. package/src/client/components/controller/CanvasManager.js +26 -0
  7. package/src/client/components/controller/Chat.js +56 -0
  8. package/src/client/components/controller/Controls.js +51 -0
  9. package/src/client/components/controller/Game.js +37 -0
  10. package/src/client/components/controller/Lobby.js +81 -0
  11. package/src/client/components/controller/LobbyAuth.js +41 -0
  12. package/src/client/components/controller/Panel.js +23 -0
  13. package/src/client/components/controller/Stat.js +33 -0
  14. package/src/client/components/controller/Vote.js +55 -0
  15. package/src/client/components/model/Auth.js +92 -0
  16. package/src/client/components/model/CanvasManager.js +319 -0
  17. package/src/client/components/model/Chat.js +119 -0
  18. package/src/client/components/model/Controls.js +120 -0
  19. package/src/client/components/model/Game.js +172 -0
  20. package/src/client/components/model/Lobby.js +226 -0
  21. package/src/client/components/model/LobbyAuth.js +161 -0
  22. package/src/client/components/model/Panel.js +70 -0
  23. package/src/client/components/model/Stat.js +77 -0
  24. package/src/client/components/model/Vote.js +216 -0
  25. package/src/client/components/view/Auth.js +167 -0
  26. package/src/client/components/view/CanvasManager.js +44 -0
  27. package/src/client/components/view/Chat.js +90 -0
  28. package/src/client/components/view/Controls.js +30 -0
  29. package/src/client/components/view/Game.js +48 -0
  30. package/src/client/components/view/Lobby.js +303 -0
  31. package/src/client/components/view/LobbyAuth.js +111 -0
  32. package/src/client/components/view/Panel.js +314 -0
  33. package/src/client/components/view/Stat.js +238 -0
  34. package/src/client/components/view/Vote.js +108 -0
  35. package/src/client/debug.js +158 -0
  36. package/src/client/lib/autostart.js +61 -0
  37. package/src/client/lib/contextTracker.js +46 -0
  38. package/src/client/lib/formBuilder.js +280 -0
  39. package/src/client/lib/hostGate.js +16 -0
  40. package/src/client/main.js +2016 -0
  41. package/src/client/network/HostConnectionManager.js +196 -0
  42. package/src/client/network/HostController.js +422 -0
  43. package/src/client/network/InlineHostBridge.js +152 -0
  44. package/src/client/network/LoopbackTransport.js +51 -0
  45. package/src/client/network/SignalingClient.js +146 -0
  46. package/src/client/network/WebRtcManager.js +147 -0
  47. package/src/client/network/WebSocketTransport.js +78 -0
  48. package/src/client/network/policyClose.js +54 -0
  49. package/src/client/network/workerSupport.js +34 -0
  50. package/src/client/providers/BakingProvider.js +88 -0
  51. package/src/client/providers/DependencyProvider.js +41 -0
  52. package/src/client/style.css +898 -0
  53. package/src/client/views/gameShell.js +190 -0
  54. package/src/client/views/includes/auth.pug +20 -0
  55. package/src/client/views/includes/chat.pug +5 -0
  56. package/src/client/views/includes/informer.pug +2 -0
  57. package/src/client/views/includes/lobby.pug +48 -0
  58. package/src/client/views/includes/lobbyAuth.pug +19 -0
  59. package/src/client/views/includes/panel.pug +3 -0
  60. package/src/client/views/includes/stat.pug +2 -0
  61. package/src/client/views/index.pug +8 -0
  62. package/src/config/closeCodes.js +18 -0
  63. package/src/config/env.js +61 -0
  64. package/src/devtools/ScenarioRunner.js +3 -8
  65. package/src/devtools/pluginLoader.js +8 -93
  66. package/src/host/HostGame.js +41 -3
  67. package/src/host/PortMachine.js +294 -0
  68. package/src/host/host.worker.js +29 -246
  69. package/src/host/identity.js +100 -0
  70. package/src/lib/clientIp.js +39 -0
  71. package/src/lib/loadGamePackage.js +122 -0
  72. package/src/lib/offlinePlayerData.js +18 -0
  73. package/src/standalone/index.js +125 -0
  74. package/tests/fixtures/miniGame/config/game.js +4 -1
@@ -0,0 +1,190 @@
1
+ // DOM-каркас игрового интерфейса в коде (Этап 2 плана standalone-sdk).
2
+ // В прод-сборке движка разметку даёт pug (./includes/*.pug), но standalone
3
+ // SDK встраивается в чужую страницу репозитория игры, где никакого pug нет —
4
+ // а движковые модули (Auth, Chat, Panel, Stat, информеры) ищут элементы по
5
+ // фиксированным id. Поэтому каркас должен уметь собираться и без сборщика.
6
+ //
7
+ // Функция идемпотентна: существующие элементы не трогает и не переносит,
8
+ // поэтому в lobby-режиме (разметка уже в документе) не делает ничего.
9
+ // Второй источник разметки обязан совпадать с pug — расхождение ловит
10
+ // тест паритета (tests/client/gameShell.test.js).
11
+ //
12
+ // Здесь нет #vote (его создаёт в рантайме components/view/Vote.js) и нет
13
+ // канвасов: их размеры приходят в CONFIG_DATA — см. ensureCanvas().
14
+
15
+ const SHELL = [
16
+ {
17
+ tag: 'div',
18
+ id: 'panel',
19
+ children: [{ tag: 'h1', id: 'logo', text: 'VIMP' }],
20
+ },
21
+ {
22
+ tag: 'div',
23
+ id: 'chat',
24
+ children: [
25
+ { tag: 'div', id: 'chat-box' },
26
+ // maxlength — UX-подсказка; авторитетный лимит на хосте (chatMaxLength)
27
+ {
28
+ tag: 'input',
29
+ id: 'cmd',
30
+ attrs: { type: 'text', maxlength: '60', autocomplete: 'off' },
31
+ },
32
+ ],
33
+ },
34
+ { tag: 'div', id: 'stat' },
35
+ {
36
+ tag: 'div',
37
+ id: 'auth',
38
+ children: [
39
+ {
40
+ tag: 'div',
41
+ id: 'auth-form',
42
+ attrs: { class: 'card' },
43
+ children: [
44
+ // заголовок и help-секции заполняет AuthView из authSchema.texts
45
+ { tag: 'h2', id: 'auth-title' },
46
+ { tag: 'div', id: 'auth-error' },
47
+ { tag: 'div', id: 'auth-fields' },
48
+ {
49
+ tag: 'input',
50
+ id: 'auth-enter',
51
+ attrs: { type: 'button', autofocus: '', value: 'Start' },
52
+ },
53
+ { tag: 'div', id: 'auth-informs' },
54
+ {
55
+ tag: 'div',
56
+ id: 'auth-link',
57
+ children: [
58
+ {
59
+ tag: 'p',
60
+ children: [
61
+ {
62
+ tag: 'a',
63
+ attrs: { href: 'https://github.com/lgick/vimp' },
64
+ text: 'GitHub',
65
+ },
66
+ ],
67
+ },
68
+ { tag: 'p', text: `© ${new Date().getFullYear()} VIMP` },
69
+ ],
70
+ },
71
+ ],
72
+ },
73
+ ],
74
+ },
75
+ { tag: 'div', id: 'game-informer' },
76
+ { tag: 'div', id: 'tech-informer' },
77
+ ];
78
+
79
+ // класс-маркер: правило `.vimp-shell > *` в style.css держит экраны скрытыми
80
+ // до того, как их покажет свой модуль. В прод-сборке контейнер — body, и
81
+ // правило совпадает с прежним `body > *`; в SDK это чужой div
82
+ export const SHELL_CLASS = 'vimp-shell';
83
+
84
+ /**
85
+ * Достраивает недостающие контейнеры игрового интерфейса.
86
+ *
87
+ * Контейнер обязан быть полноэкранным и позиционированным
88
+ * (`position: relative`): #panel, #stat и создаваемый в рантайме #vote —
89
+ * `position: absolute`, и их containing block — ближайший позиционированный
90
+ * предок.
91
+ * @param {HTMLElement} [container] - Точка монтирования (по умолчанию body).
92
+ * @returns {HTMLElement} Контейнер.
93
+ */
94
+ export function ensureGameShell(container = document.body) {
95
+ container.classList.add(SHELL_CLASS);
96
+ buildNodes(SHELL, container, false);
97
+
98
+ return container;
99
+ }
100
+
101
+ /**
102
+ * Полотно игры по id из конфига канвасов (CONFIG_DATA). Уже размещённый в
103
+ * документе элемент переиспользуется как есть — если игра положила
104
+ * <canvas id="vimp"> в свою разметку, движок его не переносит.
105
+ * @param {string} canvasId
106
+ * @param {Object} size - { width, height } из конфига канвасов.
107
+ * @param {HTMLElement} [container] - Точка монтирования (по умолчанию body).
108
+ * @returns {HTMLCanvasElement}
109
+ */
110
+ export function ensureCanvas(canvasId, size, container = document.body) {
111
+ const canvas =
112
+ document.getElementById(canvasId) ?? document.createElement('canvas');
113
+
114
+ if (!canvas.parentNode) {
115
+ canvas.setAttribute('id', canvasId);
116
+ canvas.width = size.width;
117
+ canvas.height = size.height;
118
+ container.appendChild(canvas);
119
+ }
120
+
121
+ return canvas;
122
+ }
123
+
124
+ /**
125
+ * Набор id, которые собирает каркас. Нужен тесту паритета с pug.
126
+ * @returns {Set<string>}
127
+ */
128
+ export function shellIds() {
129
+ const ids = new Set();
130
+ const walk = nodes => {
131
+ for (const node of nodes) {
132
+ if (node.id) {
133
+ ids.add(node.id);
134
+ }
135
+
136
+ walk(node.children ?? []);
137
+ }
138
+ };
139
+
140
+ walk(SHELL);
141
+
142
+ return ids;
143
+ }
144
+
145
+ // рекурсивно достраивает узлы: элемент с таким id уже есть — берём его и
146
+ // спускаемся внутрь (каркас мог быть собран частично), иначе создаём.
147
+ // Безымянные узлы (декоративные p/a внутри #auth-link) достраиваются только
148
+ // вместе со своим родителем: по ним нельзя проверить, есть ли они уже, и в
149
+ // готовой разметке они бы удвоились
150
+ function buildNodes(nodes, parent, parentCreated) {
151
+ for (const node of nodes) {
152
+ if (!node.id) {
153
+ if (parentCreated) {
154
+ const elem = createNode(node);
155
+
156
+ parent.appendChild(elem);
157
+ buildNodes(node.children ?? [], elem, true);
158
+ }
159
+
160
+ continue;
161
+ }
162
+
163
+ const existing = document.getElementById(node.id);
164
+ const elem = existing ?? createNode(node);
165
+
166
+ if (!existing) {
167
+ parent.appendChild(elem);
168
+ }
169
+
170
+ buildNodes(node.children ?? [], elem, !existing);
171
+ }
172
+ }
173
+
174
+ function createNode({ tag, id, attrs, text }) {
175
+ const elem = document.createElement(tag);
176
+
177
+ if (id) {
178
+ elem.setAttribute('id', id);
179
+ }
180
+
181
+ for (const [name, value] of Object.entries(attrs ?? {})) {
182
+ elem.setAttribute(name, value);
183
+ }
184
+
185
+ if (text !== undefined) {
186
+ elem.textContent = text;
187
+ }
188
+
189
+ return elem;
190
+ }
@@ -0,0 +1,20 @@
1
+ //- нейтральный каркас формы авторизации: заголовок и help-секции
2
+ //- заполняет AuthView из authSchema.texts игры (AUTH_DATA, порт 1)
3
+ div#auth
4
+
5
+ div#auth-form.card
6
+
7
+ h2#auth-title
8
+
9
+ div#auth-error
10
+
11
+ div#auth-fields
12
+
13
+ input#auth-enter(type='button', autofocus, value='Start')
14
+
15
+ div#auth-informs
16
+
17
+ div#auth-link
18
+ p
19
+ a(href='https://github.com/lgick/vimp') GitHub
20
+ p &copy; #{new Date().getFullYear()} VIMP
@@ -0,0 +1,5 @@
1
+ div#chat
2
+ div#chat-box
3
+ //- maxlength — лишь UX-подсказка; авторитетный лимит на сервере
4
+ //- (chatMaxLength в src/config/hostDefaults.js). Значения держать синхронными.
5
+ input#cmd(type='text', maxlength='60' autocomplete="off")
@@ -0,0 +1,2 @@
1
+ div#game-informer
2
+ div#tech-informer
@@ -0,0 +1,48 @@
1
+ //- скрыт по умолчанию: открывается из main.js только после успешного логина
2
+ //- в #lobby-auth (Этап B2, LobbyAuthCtrl 'authenticated')
3
+ div#lobby(style='display:none')
4
+ div.lobby-grid
5
+ //- левая панель: профиль + форма создания комнаты (lobby-page-plan)
6
+ div#lobby-setup-panel.card
7
+ h2= 'VIMP'
8
+ //- бейдж входа (Этап B2): управляется LobbyAuthView, показывается
9
+ //- вместе с #lobby после успешной авторизации
10
+ div#lobby-user
11
+ div
12
+ span= 'Signed in as '
13
+ b#lobby-user-nick
14
+ input#lobby-user-logout(type='button', value='Sign out')
15
+ hr.lobby-divider
16
+ h3.lobby-section-title= 'HOST A GAME'
17
+ div#lobby-create
18
+ //- селектор игры (lobby-page-plan): рабочий с первой же игрой в
19
+ //- каталоге мастера, а не только когда их несколько
20
+ div.form-row
21
+ label.form-label(for='lobby-game')= 'Select Game'
22
+ select#lobby-game.field-select
23
+ div.form-row
24
+ label.form-label(for='lobby-name')= 'Server name'
25
+ input#lobby-name.field-text(type='text', maxlength='30')
26
+ //- поля настроек комнаты генерируются по явной схеме roomForm
27
+ //- манифеста активной игры (docs/en/plugin-api.md "Form schema")
28
+ div#lobby-fields
29
+ input#lobby-host(type='button', value='Create server')
30
+ //- правая панель: вкладки Active Servers / Leaderboard (lobby-page-plan)
31
+ div#lobby-browser-panel.card
32
+ //- абсолютный слой: высоту .card задаёт форма слева (stretch), а
33
+ //- список внутри скроллится, не раздувая карточку (lobby-page-plan)
34
+ div.lobby-browser-inner
35
+ div.lobby-tabs-header
36
+ input#btn-show-servers.lobby-tab-btn.active(type='button', value='Active Servers')
37
+ input#btn-show-leaderboard.lobby-tab-btn(type='button', value='Leaderboard')
38
+ div#lobby-servers-content
39
+ input#lobby-search.field-text(type='text', placeholder='Search servers…')
40
+ ul#lobby-list
41
+ p#lobby-empty= 'No servers available'
42
+ input#lobby-more(type='button', value='Load more')
43
+ div#lobby-leaderboard-content(style='display:none')
44
+ div.lobby-leaderboard-header
45
+ span#leaderboard-title
46
+ span#leaderboard-total
47
+ ol#lobby-leaderboard-list
48
+ p#lobby-my-placement
@@ -0,0 +1,19 @@
1
+ //- логин-гейт лобби (Этап B2): показывается до #lobby, пока клиент не
2
+ //- авторизован через central auth-сервис (packages/auth). Кнопки провайдеров
3
+ //- фильтруются JS по authClient.providers; неактивные скрыты
4
+ div#lobby-auth
5
+
6
+ div#lobby-auth-login.card
7
+ h2= 'VIMP'
8
+ p= 'Sign in to play'
9
+ div#lobby-auth-login-buttons
10
+ input.lobby-auth-provider#lobby-auth-login-github(type='button', data-provider='github', value='Sign in with GitHub')
11
+ input.lobby-auth-provider#lobby-auth-login-google(type='button', data-provider='google', value='Sign in with Google')
12
+ input.lobby-auth-provider#lobby-auth-login-apple(type='button', data-provider='apple', value='Sign in with Apple')
13
+ div#lobby-auth-login-error
14
+
15
+ div#lobby-auth-nick.card(style='display:none')
16
+ h2= 'Choose your nick'
17
+ input#lobby-auth-nick-input.field-text(type='text', maxlength='14', placeholder='YourNick')
18
+ div#lobby-auth-nick-error
19
+ input#lobby-auth-nick-submit(type='button', value='Continue')
@@ -0,0 +1,3 @@
1
+ //- Каркас панели: ячейки генерирует PanelView по схеме игры (CONFIG_DATA)
2
+ div#panel
3
+ h1#logo= 'VIMP'
@@ -0,0 +1,2 @@
1
+ //- Каркас scoreboard: шапку и таблицы генерирует StatView по схеме игры
2
+ div#stat
@@ -0,0 +1,8 @@
1
+ //- Канвасы генерирует main.js из конфига канвасов игры (CONFIG_DATA)
2
+ include includes/panel
3
+ include includes/chat
4
+ include includes/stat
5
+ include includes/lobbyAuth
6
+ include includes/lobby
7
+ include includes/auth
8
+ include includes/informer
@@ -0,0 +1,18 @@
1
+ // коды закрытия транспорта — общий контракт серверных контуров (мастер,
2
+ // dedicated, Worker-хост) и клиента, тот же приём, что в gameCodes.js:
3
+ // значение не должно расходиться между ними молча. Отказы политики — те, что
4
+ // клиент не лечит перезагрузкой, — перечислены в
5
+ // `client/network/policyClose.js`, и тест там требует явного решения по
6
+ // каждому коду из этой карты
7
+ export default {
8
+ staleHost: 4000, // master/SignalingServer.js — сигнальный WS хоста протух
9
+ invalidOrigin: 4001, // master/SignalingServer.js, dedicated/main.js
10
+ blocked: 4002, // master/SignalingServer.js — хостер заблокирован рейтингом
11
+ kickForMaxLatency: 4003, // host/HostGame.js
12
+ kickForMissedPings: 4004, // host/HostGame.js
13
+ kickIdle: 4005, // host/HostGame.js
14
+ roomFull: 4006, // host/PortMachine.js
15
+ // 4007 свободен
16
+ handshakeTimeout: 4008, // dedicated/main.js
17
+ tooManyConnections: 4009, // dedicated/main.js
18
+ };
@@ -0,0 +1,61 @@
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-сервиса (Этап B2) — попадает в CSP connect-src, т.к.
24
+ // лобби делает туда прямой fetch (POST /nick)
25
+ if (env.VIMP_AUTH_SERVICE_URL) {
26
+ config.set('master:security:authServiceUrl', env.VIMP_AUTH_SERVICE_URL);
27
+ }
28
+
29
+ // список игр-плагинов мастера (Этап A2), по образцу остальных *_MATRIX
30
+ // env-переопределений — JSON-массив {id, package, version}
31
+ if (env.GAMES_MATRIX) {
32
+ config.set('master:games', JSON.parse(env.GAMES_MATRIX));
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Настройки комнаты dedicated-сервера: VIMP_DEDICATED_ROOM — JSON-объект
38
+ * (map, maxPlayers, roundTime, mapTime, friendlyFire, seed). Мусор в
39
+ * переменной не должен ронять процесс молча — отказ именованный.
40
+ * @param {Object} [env] - Окружение (по умолчанию process.env).
41
+ * @returns {Object} Переопределения комнаты.
42
+ */
43
+ export function readDedicatedRoom(env = process.env) {
44
+ if (!env.VIMP_DEDICATED_ROOM) {
45
+ return {};
46
+ }
47
+
48
+ let room;
49
+
50
+ try {
51
+ room = JSON.parse(env.VIMP_DEDICATED_ROOM);
52
+ } catch (e) {
53
+ throw new Error(`VIMP_DEDICATED_ROOM: invalid JSON — ${e.message}`);
54
+ }
55
+
56
+ if (!room || typeof room !== 'object' || Array.isArray(room)) {
57
+ throw new Error('VIMP_DEDICATED_ROOM: expected a JSON object');
58
+ }
59
+
60
+ return room;
61
+ }
@@ -1,4 +1,5 @@
1
1
  import { createHostRuntime } from '../lib/createHostRuntime.js';
2
+ import { offlinePlayerData } from '../lib/offlinePlayerData.js';
2
3
  import VirtualClock, { flushMicrotasks } from './VirtualClock.js';
3
4
  import RecordingSocketManager from './RecordingSocketManager.js';
4
5
  import VirtualClient from './VirtualClient.js';
@@ -32,14 +33,8 @@ const RECORDED_PORTS = {
32
33
  };
33
34
 
34
35
  // В headless-контуре нет ни мастера, ни auth-сервиса — участник стартует с
35
- // пустым профилем. Это не заглушка ради тишины: пустой профиль и есть
36
- // корректное состояние прогона, а настоящий fetch по относительному URL в
37
- // Node просто не разрешается.
38
- const emptyProfileFetch = async () => ({
39
- ok: true,
40
- status: 200,
41
- json: async () => ({ rank: 0, state: null }),
42
- });
36
+ // пустым профилем (lib/offlinePlayerData.js, общее со standalone/dedicated).
37
+ const emptyProfileFetch = offlinePlayerData();
43
38
 
44
39
  /**
45
40
  * Приводит сценарий к полной форме и проверяет обязательные поля.
@@ -1,10 +1,7 @@
1
- import { access, readFile } from 'node:fs/promises';
2
1
  import path from 'node:path';
3
2
  import { pathToFileURL } from 'node:url';
4
- import {
5
- assertEngineApiCompatible,
6
- assertGameConfigShape,
7
- } from '../lib/gamePlugin.js';
3
+ import { assertGameConfigShape } from '../lib/gamePlugin.js';
4
+ import { loadGamePackage } from '../lib/loadGamePackage.js';
8
5
 
9
6
  // Поиск игры для headless-прогона. В браузере плагин грузится по URL из
10
7
  // GameManifest мастера; в Node URL-ов нет, поэтому источников три, по
@@ -56,64 +53,15 @@ export async function loadGameForSim({ game = null, core = null } = {}) {
56
53
  return plugin;
57
54
  }
58
55
 
56
+ // путь --game указывает либо на пакет игры, либо прямо на манифест; сама
57
+ // загрузка общая с dedicated-сервером (lib/loadGamePackage.js)
59
58
  async function loadFromManifest(game, core) {
60
- const manifestPath = game.endsWith('.json')
59
+ const target = game.endsWith('.json')
61
60
  ? path.resolve(game)
62
- : path.resolve(game, 'dist/manifest.json');
63
- const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
61
+ : path.resolve(game, 'dist');
62
+ const pkg = await loadGamePackage(target, { core });
64
63
 
65
- assertEngineApiCompatible(manifest);
66
-
67
- const baseDir = path.dirname(manifestPath);
68
- const { assetsBase } = manifest;
69
- const hostPlugin = await importDefault(
70
- baseDir,
71
- manifest.entries.host,
72
- assetsBase,
73
- );
74
- const clientPlugin = await importDefault(
75
- baseDir,
76
- manifest.entries.client,
77
- assetsBase,
78
- );
79
-
80
- assertPluginMatchesManifest(manifest, {
81
- host: hostPlugin,
82
- client: clientPlugin,
83
- });
84
-
85
- const nodeCore = core ?? manifest.entries.wasmNode ?? null;
86
-
87
- if (!nodeCore) {
88
- throw new Error(
89
- `${manifestPath}: no node build of the core — add entries.wasmNode ` +
90
- `to the manifest (core/pkg-node) or pass --core <path>`,
91
- );
92
- }
93
-
94
- const corePath = path.resolve(baseDir, nodeCore);
95
-
96
- // манифест объявляет поле, но опубликованный пакет мог не довезти файл
97
- // (ignore-правила срезают каталог внутри files) — без этой проверки отказ
98
- // приходит сырым ERR_MODULE_NOT_FOUND из резолвера
99
- try {
100
- await access(corePath);
101
- } catch {
102
- throw new Error(
103
- `${manifestPath}: entries.wasmNode points at '${nodeCore}', but ` +
104
- `${corePath} does not exist — the game package was published ` +
105
- `without its node core (npm run core:build:node) or pass --core <path>`,
106
- );
107
- }
108
-
109
- return {
110
- id: manifest.id,
111
- manifest,
112
- hostPlugin,
113
- clientPlugin,
114
- wasmUrl: pathToFileURL(path.resolve(baseDir, nodeCore)).href,
115
- source: manifestPath,
116
- };
64
+ return { ...pkg, source: pkg.manifestPath };
117
65
  }
118
66
 
119
67
  // фикстура даёт рабочий контур сразу, ещё до того как игра соберёт pkg-node:
@@ -133,36 +81,3 @@ async function loadFixture(core) {
133
81
  source: FIXTURE_SOURCE,
134
82
  };
135
83
  }
136
-
137
- // зеркало lib/gamePlugin.js:loadClientPlugin — манифест мог быть пересобран
138
- // без dist/: прогон на старом плагине даёт зелёный вердикт о том, чего в
139
- // сборке уже нет
140
- function assertPluginMatchesManifest(manifest, plugins) {
141
- for (const [half, plugin] of Object.entries(plugins)) {
142
- if (plugin.engineApi !== manifest.engineApi) {
143
- throw new Error(
144
- `game "${manifest.id}": ${half} plugin engineApi ` +
145
- `v${plugin.engineApi} does not match manifest engineApi ` +
146
- `v${manifest.engineApi} — stale dist/`,
147
- );
148
- }
149
- }
150
- }
151
-
152
- function importDefault(baseDir, entry, assetsBase) {
153
- const file = path.resolve(baseDir, stripBase(entry, assetsBase));
154
-
155
- return import(pathToFileURL(file).href).then(module => module.default);
156
- }
157
-
158
- // entries.host/client — URL-ы, какими их видит браузер (`assetsBase` + путь
159
- // внутри пакета). На диске этой базе соответствует каталог манифеста,
160
- // поэтому абсолютный URL сначала обрезается до пути внутри пакета — иначе
161
- // path.resolve увёл бы в корень файловой системы.
162
- function stripBase(entry, assetsBase) {
163
- if (assetsBase && entry.startsWith(assetsBase)) {
164
- return entry.slice(assetsBase.length);
165
- }
166
-
167
- return entry.replace(/^\/+/, '');
168
- }
@@ -11,6 +11,7 @@ import VoteCoordinator from './meta/core/VoteCoordinator.js';
11
11
  import RoundManager from './meta/core/RoundManager.js';
12
12
  import CommandProcessor from './meta/core/CommandProcessor.js';
13
13
  import { sanitizeMessage } from '../lib/sanitizers.js';
14
+ import closeCodes from '../config/closeCodes.js';
14
15
  import clock from '../lib/clock.js';
15
16
  import GameCoreAdapter from './GameCoreAdapter.js';
16
17
  import DebugRecorder from './DebugRecorder.js';
@@ -279,7 +280,11 @@ export default class HostGame {
279
280
 
280
281
  if (user && !this._isHostPlayer(user)) {
281
282
  console.warn(`[RTT] Kick ${user.name} — pong latency exceeded`);
282
- this._socketManager.close(user.socketId, 4003, 'kickForMaxLatency');
283
+ this._socketManager.close(
284
+ user.socketId,
285
+ closeCodes.kickForMaxLatency,
286
+ 'kickForMaxLatency',
287
+ );
283
288
  this.removeUser(gameId);
284
289
  }
285
290
  }
@@ -290,7 +295,11 @@ export default class HostGame {
290
295
 
291
296
  if (user && !this._isHostPlayer(user)) {
292
297
  console.warn(`[RTT] Kick ${user.name} — no response to pings`);
293
- this._socketManager.close(user.socketId, 4004, 'kickForMissedPings');
298
+ this._socketManager.close(
299
+ user.socketId,
300
+ closeCodes.kickForMissedPings,
301
+ 'kickForMissedPings',
302
+ );
294
303
  this.removeUser(gameId);
295
304
  }
296
305
  }
@@ -439,7 +448,7 @@ export default class HostGame {
439
448
  }
440
449
 
441
450
  usersToKick.forEach(user => {
442
- this._socketManager.close(user.socketId, 4005, 'kickIdle');
451
+ this._socketManager.close(user.socketId, closeCodes.kickIdle, 'kickIdle');
443
452
  this.removeUser(user.gameId);
444
453
  });
445
454
  }
@@ -599,6 +608,35 @@ export default class HostGame {
599
608
  }
600
609
  }
601
610
 
611
+ /**
612
+ * Публичный teardown матча: останов таймеров, финальная синхронизация
613
+ * профилей и снятие всех участников. Во вкладке матч умирает вместе с
614
+ * Worker'ом, а долгоживущему процессу (dedicated-сервер) нужен graceful
615
+ * shutdown — иначе таймеры держат процесс, а rank/state теряются.
616
+ * @returns {Promise} Завершение финальной синхронизации профилей.
617
+ */
618
+ async destroy() {
619
+ this._timerManager.stopGameTimers();
620
+ this._timerManager.stopIdleCheckTimer();
621
+ this._timerManager.stopAllVoteTimers();
622
+ this._timerManager.stopAllBlockedVoteTimers();
623
+
624
+ // flushAll до снятия участников: removeUser чистит запись PlayerDataSync,
625
+ // и после него синхронизировать было бы уже нечего. Ждём здесь же —
626
+ // иначе removeUser стартует второй flush с той же накопленной дельтой
627
+ // (двойной зачёт рейтинга), а destroy разрешился бы раньше, чем эти
628
+ // запросы уйдут
629
+ await this._playerDataSync.flushAll();
630
+
631
+ // getAll() отдаёт копию — снятие внутри цикла реестр не ломает
632
+ for (const user of this._participants.getAll()) {
633
+ // запись уже синхронизирована — финальный flush внутри removeUser
634
+ // не нужен и был бы повтором
635
+ this._playerDataSync.removeUser(user.gameId);
636
+ this.removeUser(user.gameId);
637
+ }
638
+ }
639
+
602
640
  // ***** эстафета Worker'ов (Этап 5.2) ***** //
603
641
 
604
642
  // запрашивает перенос: на ближайшей границе раунда игра останавливается