vimp-engine 0.35.6 → 0.36.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 (130) hide show
  1. package/bin/vimp-sim.js +19 -1
  2. package/core/Cargo.toml +1 -1
  3. package/package.json +1 -1
  4. package/src/client/components/controller/Controls.js +5 -0
  5. package/src/client/components/controller/Lobby.js +4 -4
  6. package/src/client/components/controller/RoomMenu.js +43 -0
  7. package/src/client/components/controller/Vote.js +18 -2
  8. package/src/client/components/model/ClientReports.js +6 -2
  9. package/src/client/components/model/Controls.js +27 -0
  10. package/src/client/components/model/Games.js +5 -1
  11. package/src/client/components/model/Lobby.js +32 -24
  12. package/src/client/components/model/LobbyAuth.js +20 -4
  13. package/src/client/components/model/RoomMenu.js +121 -0
  14. package/src/client/components/model/Stat.js +2 -1
  15. package/src/client/components/model/Vote.js +122 -4
  16. package/src/client/components/view/Auth.js +20 -10
  17. package/src/client/components/view/ClientReports.js +16 -5
  18. package/src/client/components/view/Games.js +39 -14
  19. package/src/client/components/view/Lobby.js +90 -43
  20. package/src/client/components/view/LobbyAuth.js +13 -7
  21. package/src/client/components/view/Panel.js +1 -2
  22. package/src/client/components/view/RoomMenu.js +69 -0
  23. package/src/client/debug.js +24 -1
  24. package/src/client/lib/FpsMeter.js +46 -0
  25. package/src/client/lib/JoinRetry.js +73 -0
  26. package/src/client/lib/diagnostics.js +28 -5
  27. package/src/client/lib/hostCaps.js +82 -0
  28. package/src/client/lib/hostUnloadGuard.js +108 -0
  29. package/src/client/lib/hostVoteCommand.js +92 -0
  30. package/src/client/lib/pageReload.js +29 -0
  31. package/src/client/lib/roomLink.js +257 -0
  32. package/src/client/lib/signalingErrors.js +74 -0
  33. package/src/client/main.js +674 -451
  34. package/src/client/network/HiddenHostHealthLog.js +76 -0
  35. package/src/client/network/HostConnectionManager.js +62 -8
  36. package/src/client/network/HostController.js +376 -23
  37. package/src/client/network/HostHealthPolicy.js +307 -0
  38. package/src/client/network/HostHealthReporter.js +54 -0
  39. package/src/client/network/HostPrewarm.js +235 -0
  40. package/src/client/network/LoopbackTransport.js +16 -1
  41. package/src/client/network/PlannedHandoff.js +366 -0
  42. package/src/client/network/Promotion.js +420 -0
  43. package/src/client/network/RoomPeersReporter.js +81 -0
  44. package/src/client/network/SessionSupervisor.js +730 -0
  45. package/src/client/network/SignalingClient.js +183 -30
  46. package/src/client/network/StandbyReceiver.js +287 -0
  47. package/src/client/network/StandbySender.js +319 -0
  48. package/src/client/network/TokenHandoffTimer.js +81 -0
  49. package/src/client/network/WebRtcManager.js +338 -11
  50. package/src/client/network/policyClose.js +32 -2
  51. package/src/client/network/standbyChunks.js +185 -0
  52. package/src/client/session/GuestSession.js +428 -0
  53. package/src/client/session/HandoffFlow.js +399 -0
  54. package/src/client/session/HostRole.js +708 -0
  55. package/src/client/session/Membership.js +179 -0
  56. package/src/client/session/PromotionFlow.js +325 -0
  57. package/src/client/session/RouteBoot.js +432 -0
  58. package/src/client/session/StandbyRole.js +195 -0
  59. package/src/client/session/hostRoomPrep.js +152 -0
  60. package/src/client/session/roomContext.js +32 -0
  61. package/src/client/style.css +83 -22
  62. package/src/client/views/gameShell.js +7 -0
  63. package/src/client/views/includes/informer.pug +4 -0
  64. package/src/client/views/includes/lobby.pug +0 -3
  65. package/src/client/views/includes/roomMenu.pug +9 -0
  66. package/src/client/views/index.pug +1 -0
  67. package/src/config/closeCodes.js +1 -1
  68. package/src/config/env.js +37 -0
  69. package/src/config/hostDefaults.js +24 -0
  70. package/src/config/lobby.js +154 -3
  71. package/src/config/master.js +104 -21
  72. package/src/config/wsports.js +8 -0
  73. package/src/devtools/RecordingSocketManager.js +3 -2
  74. package/src/devtools/ScenarioRunner.js +123 -8
  75. package/src/devtools/VirtualClient.js +20 -0
  76. package/src/devtools/contract/format.js +4 -1
  77. package/src/devtools/contract/index.js +1 -2
  78. package/src/devtools/contract/rules/a2-package-scripts.js +1 -3
  79. package/src/devtools/contract/rules/b8-system-messages.js +1 -1
  80. package/src/devtools/contract/rules/b9-vote-names.js +25 -1
  81. package/src/devtools/contract/rules/c11-stat-mode.js +4 -1
  82. package/src/devtools/contract/rules/c6-stat-columns.js +1 -1
  83. package/src/devtools/contract/rules/c7-key-sets.js +8 -4
  84. package/src/devtools/contract/rules/d3-snapshot-interp.js +2 -1
  85. package/src/devtools/contract/rules/e1-sound-pairs.js +3 -1
  86. package/src/devtools/contract/rules/e4-map-layers.js +13 -2
  87. package/src/devtools/contract/rules/e5-map-radar-walls.js +7 -1
  88. package/src/devtools/contract/rules/index.js +38 -5
  89. package/src/devtools/invariants.js +10 -4
  90. package/src/devtools/pluginLoader.js +1 -4
  91. package/src/devtools/report.js +17 -1
  92. package/src/host/DebugRecorder.js +6 -1
  93. package/src/host/GameCoreAdapter.js +45 -0
  94. package/src/host/HostGame.js +928 -59
  95. package/src/host/PortMachine.js +268 -20
  96. package/src/host/host.worker.js +222 -16
  97. package/src/host/meta/SocketManager.js +20 -0
  98. package/src/host/meta/core/RoundManager.js +89 -14
  99. package/src/host/meta/modules/Accolades.js +4 -1
  100. package/src/host/meta/modules/Panel.js +30 -0
  101. package/src/host/meta/modules/PlayerDataSync.js +255 -46
  102. package/src/host/meta/modules/RTTManager.js +34 -0
  103. package/src/host/meta/modules/TimerManager.js +215 -18
  104. package/src/host/meta/modules/chat/systemMessages.js +22 -0
  105. package/src/host/meta/player/HumanParticipant.js +10 -0
  106. package/src/host/meta/player/ParticipantManager.js +98 -5
  107. package/src/lib/AbstractTimer.js +63 -1
  108. package/src/lib/Publisher.js +26 -0
  109. package/src/lib/capabilities.js +5 -0
  110. package/src/lib/checkpointCodec.js +125 -0
  111. package/src/lib/clientIp.js +9 -2
  112. package/src/lib/createHostRuntime.js +95 -6
  113. package/src/lib/formOptions.js +3 -1
  114. package/src/lib/gameConfigView.js +10 -0
  115. package/src/lib/gameId.js +13 -0
  116. package/src/lib/jwt.js +3 -1
  117. package/src/lib/loadGamePackage.js +2 -2
  118. package/src/lib/resumeKey.js +38 -0
  119. package/src/lib/roomId.js +39 -0
  120. package/src/lib/roomSettings.js +59 -0
  121. package/src/lib/validators.js +7 -0
  122. package/tests/fixtures/generations/gen-api3/host/ScriptedManager.js +2 -3
  123. package/tests/fixtures/generations/gen-api3/host/fakeCore.js +31 -5
  124. package/tests/fixtures/generations/gen-api3/manifest.json +1 -3
  125. package/tests/fixtures/generations/gen-api4/host/ScriptedManager.js +2 -3
  126. package/tests/fixtures/generations/gen-api4/host/fakeCore.js +31 -5
  127. package/tests/fixtures/generations/gen-api4/manifest.json +1 -3
  128. package/tests/fixtures/miniGame/config/client.js +15 -0
  129. package/tests/fixtures/miniGame/host/ScriptedManager.js +2 -3
  130. package/tests/fixtures/miniGame/host/fakeCore.js +31 -5
@@ -0,0 +1,125 @@
1
+ // Кодек контрольной точки хоста (host-migration этап 5). Изоморфен: Worker
2
+ // браузера, Node ≥ 18 (CompressionStream/DecompressionStream — глобалы) и
3
+ // headless-runner кодируют одинаково.
4
+ //
5
+ // Контейнер до сжатия: [u32 metaLen LE][meta JSON utf-8][байты дампа ядра];
6
+ // весь буфер — gzip. Дамп ядра сам по себе JSON (serialize_state), и без
7
+ // сжатия точка tanks весит ~420 КБ, с ним ~21 КБ (plan/host-migration,
8
+ // spike-results).
9
+
10
+ import hostDefaults from '../config/hostDefaults.js';
11
+
12
+ const HEADER_BYTES = 4;
13
+
14
+ // поток → один Uint8Array; maxBytes — предел накопленного (распаковка чужой
15
+ // точки — защита от «zip-бомбы»: превышение обрывает чтение, а не память)
16
+ async function readAll(stream, maxBytes = Infinity) {
17
+ const reader = stream.getReader();
18
+ const chunks = [];
19
+ let total = 0;
20
+
21
+ for (;;) {
22
+ const { done, value } = await reader.read();
23
+
24
+ if (done) {
25
+ break;
26
+ }
27
+
28
+ total += value.byteLength;
29
+
30
+ if (total > maxBytes) {
31
+ await reader.cancel();
32
+ throw new Error(`checkpoint exceeds ${maxBytes} bytes unpacked`);
33
+ }
34
+
35
+ chunks.push(value);
36
+ }
37
+
38
+ // новый буфер целиком принадлежит результату: его можно отдать в список
39
+ // переноса postMessage без копии
40
+ const out = new Uint8Array(total);
41
+ let offset = 0;
42
+
43
+ for (const chunk of chunks) {
44
+ out.set(chunk, offset);
45
+ offset += chunk.byteLength;
46
+ }
47
+
48
+ return out;
49
+ }
50
+
51
+ function pipeBytes(bytes, transform) {
52
+ return new Blob([bytes]).stream().pipeThrough(transform);
53
+ }
54
+
55
+ /**
56
+ * Упаковывает контрольную точку.
57
+ * @param {Object} meta - JSON-сериализуемая мета (HANDOFF_VERSION 4).
58
+ * @param {Uint8Array|null} [core] - Дамп ядра; null — мягкий режим.
59
+ * @returns {Promise<Uint8Array>} gzip-буфер, владеющий своим ArrayBuffer.
60
+ */
61
+ export async function encodeCheckpoint(meta, core = null) {
62
+ const metaBytes = new TextEncoder().encode(JSON.stringify(meta));
63
+ const coreBytes = core ?? new Uint8Array(0);
64
+ const raw = new Uint8Array(
65
+ HEADER_BYTES + metaBytes.byteLength + coreBytes.byteLength,
66
+ );
67
+
68
+ new DataView(raw.buffer).setUint32(0, metaBytes.byteLength, true);
69
+ raw.set(metaBytes, HEADER_BYTES);
70
+ raw.set(coreBytes, HEADER_BYTES + metaBytes.byteLength);
71
+
72
+ return readAll(pipeBytes(raw, new CompressionStream('gzip')));
73
+ }
74
+
75
+ /**
76
+ * Распаковывает контрольную точку.
77
+ * @param {Uint8Array|ArrayBuffer} bytes - Результат encodeCheckpoint.
78
+ * @param {Object} [options]
79
+ * @param {number} [options.maxBytes] - Предел распакованного размера.
80
+ * @returns {Promise<{meta: Object, core: Uint8Array|null}>}
81
+ * @throws {Error} Битый буфер, превышение предела, нечитаемая мета.
82
+ */
83
+ export async function decodeCheckpoint(
84
+ bytes,
85
+ { maxBytes = hostDefaults.maxCheckpointBytes } = {},
86
+ ) {
87
+ let raw;
88
+
89
+ try {
90
+ raw = await readAll(
91
+ pipeBytes(bytes, new DecompressionStream('gzip')),
92
+ maxBytes,
93
+ );
94
+ } catch (e) {
95
+ throw new Error(`checkpoint is corrupted: ${e && e.message}`);
96
+ }
97
+
98
+ if (raw.byteLength < HEADER_BYTES) {
99
+ throw new Error('checkpoint is corrupted: no header');
100
+ }
101
+
102
+ const metaLen = new DataView(raw.buffer).getUint32(0, true);
103
+
104
+ if (HEADER_BYTES + metaLen > raw.byteLength) {
105
+ throw new Error('checkpoint is corrupted: meta length out of range');
106
+ }
107
+
108
+ let meta;
109
+
110
+ try {
111
+ meta = JSON.parse(
112
+ new TextDecoder().decode(
113
+ raw.subarray(HEADER_BYTES, HEADER_BYTES + metaLen),
114
+ ),
115
+ );
116
+ } catch (e) {
117
+ throw new Error(`checkpoint is corrupted: meta is not JSON`);
118
+ }
119
+
120
+ const core = raw.subarray(HEADER_BYTES + metaLen);
121
+
122
+ return { meta, core: core.byteLength ? core.slice() : null };
123
+ }
124
+
125
+ export default { encode: encodeCheckpoint, decode: decodeCheckpoint };
@@ -64,7 +64,10 @@ export function rateLimitKey(ip) {
64
64
  return addr;
65
65
  }
66
66
 
67
- return `v6:${groups.slice(0, 4).map(g => parseInt(g, 16).toString(16)).join(':')}::/64`;
67
+ return `v6:${groups
68
+ .slice(0, 4)
69
+ .map(g => parseInt(g, 16).toString(16))
70
+ .join(':')}::/64`;
68
71
  }
69
72
 
70
73
  // '2001:db8::1' → 8 hex-групп или null, если запись непарсима
@@ -83,7 +86,11 @@ function expandIpv6(addr) {
83
86
  return null;
84
87
  }
85
88
 
86
- const groups = [...head, ...Array(halves.length === 2 ? missing : 0).fill('0'), ...tail];
89
+ const groups = [
90
+ ...head,
91
+ ...Array(halves.length === 2 ? missing : 0).fill('0'),
92
+ ...tail,
93
+ ];
87
94
 
88
95
  return groups.every(g => /^[0-9a-f]{1,4}$/i.test(g)) ? groups : null;
89
96
  }
@@ -17,13 +17,15 @@ import HostGame from '../host/HostGame.js';
17
17
  /**
18
18
  * Поднимает HostPlugin игры, ядро, SocketManager и HostGame.
19
19
  * @param {Object} room - Описание комнаты (game.hostEntryUrl, game.wasmUrl,
20
- * game.version, hostSocketId, hostId/hostSecret, seed, переопределения).
20
+ * game.version, hostSocketId, roomId/roomSecret/epoch, seed,
21
+ * переопределения).
21
22
  * @param {Object} [options]
22
23
  * @param {Function} [options.loadHostPlugin] - Загрузчик плагина; по умолчанию
23
24
  * динамический import по room.game.hostEntryUrl.
24
25
  * @param {Function} [options.createSocketManager] - Фабрика транспорта; по
25
26
  * умолчанию боевой SocketManager (runner подставляет записывающий).
26
- * @param {Object} [options.hostOptions] - Опции HostGame (onMapChange, handoff).
27
+ * @param {Object} [options.hostOptions] - Опции HostGame (onMapChange,
28
+ * handoff, checkpoint + seqFloor).
27
29
  * @param {Function} [options.overrideGameConfig] - Правка собранного конфига
28
30
  * игры перед созданием ядра; в проде не задаётся, нужна отладочному
29
31
  * прогону (сценарий подкручивает networkSendRate и таймеры).
@@ -81,22 +83,109 @@ export async function createHostRuntime(room, options = {}) {
81
83
  const host = new HostGame(game, socketManager, core, hostPlugin, {
82
84
  hostSocketId: room?.hostSocketId ?? null,
83
85
  gameVersion: room.game?.version ?? null,
86
+ // настройки комнаты едут в контрольной точке (host-migration этап 5):
87
+ // преемник собирает из них тот же конфиг ядра
88
+ roomSettings: pickRoomSettings(room),
89
+ mapsVersion: room?.mapsVersion ?? null,
84
90
  // рекордер (этап 6 плана plan/done/ai-debug) кладёт seed в сценарий — без него
85
91
  // записанный матч невоспроизводим
86
92
  seed,
87
93
  ...hostOptions,
88
94
  });
89
95
 
90
- // эстафета Worker'ов несёт уже известные hostId+секрет в room — новый
96
+ // эстафета Worker'ов несёт уже известные roomId+секрет в room — новый
91
97
  // Worker не должен ждать повторного register_host, чтобы возобновить
92
- // атрибуцию rank/state-flush; при холодном старте они придут позже
93
- if (room?.hostId) {
94
- host.setHostId(room.hostId, room.hostSecret);
98
+ // атрибуцию rank/state-flush; при холодном старте они придут позже.
99
+ // hostId/hostSecret — те же поля от главного потока до host-migration
100
+ // этапа 2
101
+ if (room?.roomId) {
102
+ host.setRoom({
103
+ roomId: room.roomId,
104
+ roomSecret: room.roomSecret,
105
+ epoch: room.epoch,
106
+ });
107
+ } else if (room?.hostId) {
108
+ host.setRoom({ roomId: room.hostId, roomSecret: room.hostSecret });
95
109
  }
96
110
 
97
111
  return { hostPlugin, game, seed, core, clientCfg, socketManager, host };
98
112
  }
99
113
 
114
+ /**
115
+ * Прогрев Worker'а преемника (host-migration этап 6): плагин импортирован и
116
+ * проверен, wasm скомпилирован — матч не создаётся. Повторный import того же
117
+ * URL в том же Worker'е берётся из кэша модулей, а скачанный wasm — из
118
+ * HTTP-кэша, поэтому промоушен (этап 7) не ждёт сети.
119
+ * @param {Object} room - { game: { id, version, hostEntryUrl, wasmUrl } }.
120
+ * @param {Object} [options]
121
+ * @param {Function} [options.loadHostPlugin]
122
+ * @param {Function} [options.compileWasm] - (url) → Promise; по умолчанию
123
+ * WebAssembly.compileStreaming с фолбэком на compile(arrayBuffer).
124
+ * @returns {Promise<{ hostPlugin: Object, wasmCompiled: boolean }>}
125
+ */
126
+ export async function preloadHostRuntime(room, options = {}) {
127
+ const {
128
+ loadHostPlugin = defaultLoadHostPlugin,
129
+ compileWasm = defaultCompileWasm,
130
+ } = options;
131
+
132
+ const hostPlugin = await loadHostPlugin(room);
133
+
134
+ // та же проверка формы, что при init: битый плагин виден до аварии
135
+ assertGameConfigShape(hostPlugin);
136
+
137
+ let wasmCompiled = false;
138
+
139
+ if (room.game?.wasmUrl) {
140
+ await compileWasm(room.game.wasmUrl);
141
+ wasmCompiled = true;
142
+ }
143
+
144
+ return { hostPlugin, wasmCompiled };
145
+ }
146
+
147
+ async function defaultCompileWasm(url) {
148
+ if (typeof WebAssembly.compileStreaming === 'function') {
149
+ try {
150
+ return await WebAssembly.compileStreaming(fetch(url));
151
+ } catch {
152
+ // сервер отдал wasm не с application/wasm — компиляция из буфера
153
+ }
154
+ }
155
+
156
+ const res = await fetch(url);
157
+
158
+ if (!res.ok) {
159
+ throw new Error(`wasm: HTTP ${res.status}`);
160
+ }
161
+
162
+ return WebAssembly.compile(await res.arrayBuffer());
163
+ }
164
+
165
+ // ровно то, что читают applyRoomOverrides и buildCoreConfig. Без
166
+ // isDevMode: dev-режим решает сборка вкладки, которая поднимает Worker
167
+ // (prepareHostRoom в client/session/hostRoomPrep.js), в точку он не
168
+ // едет
169
+ const ROOM_SETTING_KEYS = [
170
+ 'map',
171
+ 'maxPlayers',
172
+ 'roundTime',
173
+ 'mapTime',
174
+ 'friendlyFire',
175
+ ];
176
+
177
+ function pickRoomSettings(room) {
178
+ const settings = {};
179
+
180
+ for (const key of ROOM_SETTING_KEYS) {
181
+ if (room?.[key] !== undefined) {
182
+ settings[key] = room[key];
183
+ }
184
+ }
185
+
186
+ return settings;
187
+ }
188
+
100
189
  // до onInit движок игру не знает вовсе — только URL её host-бандла из
101
190
  // GameManifest
102
191
  function defaultLoadHostPlugin(room) {
@@ -11,6 +11,8 @@
11
11
  */
12
12
  export function normalizeOptions(list) {
13
13
  return (list || []).map(opt =>
14
- opt !== null && typeof opt === 'object' ? opt : { value: opt, label: String(opt) },
14
+ opt !== null && typeof opt === 'object'
15
+ ? opt
16
+ : { value: opt, label: String(opt) },
15
17
  );
16
18
  }
@@ -24,6 +24,10 @@ const FIELDS = {
24
24
  // карты везёт мастер (room.maps), у игры их может не быть вовсе
25
25
  maps: { default: {} },
26
26
  currentMap: { default: null },
27
+ // строка карточки комнаты в лобби: 'map' — имя текущей карты; без поля
28
+ // карточка ничего не показывает (модули могут задать своё через
29
+ // deps.lobby.setInfo)
30
+ lobbyInfo: { default: null },
27
31
  // сколько карт попадает в голосование; 1 — минимальный осмысленный набор
28
32
  mapsInVote: { default: 1 },
29
33
  mapScale: { default: 1 },
@@ -61,6 +65,12 @@ const FIELDS = {
61
65
  endlessRound: { default: false },
62
66
 
63
67
  spectatorTeam: { derive: deriveSpectatorTeam },
68
+
69
+ // смена хоста посреди раунда (host-migration этап 5): true — игра
70
+ // гарантирует, что GameSim::serialize/deserialize и хуки модулей
71
+ // serializeState/restoreState переносят всё её состояние. Без поля —
72
+ // мягкий режим: мета переносится, раунд начинается заново
73
+ 'migration.midRound': { default: false },
64
74
  };
65
75
 
66
76
  // Все пути gameConfig, которые движок читает: объявленные игрой поля плюс
@@ -0,0 +1,13 @@
1
+ // Формат id игры — изоморфно: его проверяет мастер (реестр, раздача, каталог)
2
+ // и клиентский роутер ссылок (client/lib/roomLink.js) до похода в сеть.
3
+ // Значение обязано совпадать с packages/auth/src/config/auth.js:games
4
+ // (см. master/gameRefs.js — он реэкспортирует отсюда)
5
+ export const GAME_ID_PATTERN = /^[a-z][a-z0-9-]{1,30}$/;
6
+
7
+ /**
8
+ * @param {*} value
9
+ * @returns {boolean} Является ли значение корректным id игры.
10
+ */
11
+ export function isValidGameId(value) {
12
+ return typeof value === 'string' && GAME_ID_PATTERN.test(value);
13
+ }
package/src/lib/jwt.js CHANGED
@@ -2,7 +2,9 @@
2
2
  function base64UrlToString(segment) {
3
3
  const base64 = segment.replace(/-/g, '+').replace(/_/g, '/');
4
4
 
5
- return atob(base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '='));
5
+ return atob(
6
+ base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '='),
7
+ );
6
8
  }
7
9
 
8
10
  // декодирует base64url-сегмент JWT в байты (подпись — для crypto.subtle.verify)
@@ -205,8 +205,8 @@ function warnOnRequiresMismatch(manifest, plugins) {
205
205
  // правка ломала бы каждый уже опубликованный пакет. Объявленное пустым
206
206
  // (`requires: []`) — это утверждение «ничего не нужно», и оно расходится
207
207
  // с манифестом наравне с неполным списком
208
- const halves = Object.values(plugins).filter(
209
- plugin => Array.isArray(plugin.requires),
208
+ const halves = Object.values(plugins).filter(plugin =>
209
+ Array.isArray(plugin.requires),
210
210
  );
211
211
 
212
212
  if (halves.length === 0) {
@@ -0,0 +1,38 @@
1
+ // Секрет возобновления сессии участника (host-migration этап 4): хост выдаёт
2
+ // его после входа в матч (SESSION_DATA), клиент предъявляет в RESUME_REQUEST
3
+ // после обрыва транспорта. Изоморфен — крутится и в Worker'е, и в Node.
4
+
5
+ // 128 бит — угадать ключ чужого места в разумное время нельзя
6
+ const KEY_BYTES = 16;
7
+
8
+ /**
9
+ * @returns {string} Случайный ключ (32 hex-символа).
10
+ */
11
+ export function createResumeKey() {
12
+ const bytes = new Uint8Array(KEY_BYTES);
13
+
14
+ globalThis.crypto.getRandomValues(bytes);
15
+
16
+ return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
17
+ }
18
+
19
+ /**
20
+ * Сравнение за время, не зависящее от позиции первого расхождения: по
21
+ * таймингу ответов ключ не подобрать посимвольно.
22
+ * @param {*} a
23
+ * @param {*} b
24
+ * @returns {boolean}
25
+ */
26
+ export function resumeKeysEqual(a, b) {
27
+ if (typeof a !== 'string' || typeof b !== 'string' || a.length !== b.length) {
28
+ return false;
29
+ }
30
+
31
+ let diff = 0;
32
+
33
+ for (let i = 0; i < a.length; i += 1) {
34
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
35
+ }
36
+
37
+ return diff === 0;
38
+ }
@@ -0,0 +1,39 @@
1
+ // Публичный id комнаты (host-migration, этап 2): короткий, стабильный за всю
2
+ // жизнь комнаты (переживает реконнект сигналинга хоста и смену хоста),
3
+ // виден в карточке лобби и в прямой ссылке. Модуль изоморфный: id выдаёт
4
+ // мастер, а клиентский роутер проверяет им ссылку до похода в сеть.
5
+
6
+ // crockford-base32 в нижнем регистре: без i, l, o, u — их путают с 1, 0 и v
7
+ // при наборе ссылки руками
8
+ const ALPHABET = '0123456789abcdefghjkmnpqrstvwxyz';
9
+
10
+ // длина — константа модуля, не конфиг: её же проверяет роутер ссылок
11
+ export const ROOM_ID_LENGTH = 8;
12
+
13
+ const ROOM_ID_PATTERN = /^[0-9a-hjkmnp-tv-z]{8}$/;
14
+
15
+ /**
16
+ * Генерирует roomId.
17
+ * @param {Function} randomBytes - Источник случайных байт (мастер передаёт
18
+ * crypto.randomBytes): (n) => Uint8Array|Buffer длиной n.
19
+ * @returns {string}
20
+ */
21
+ export function generateRoomId(randomBytes) {
22
+ const bytes = randomBytes(ROOM_ID_LENGTH);
23
+ let id = '';
24
+
25
+ // 256 делится на 32 нацело — остаток не смещает распределение
26
+ for (let i = 0; i < ROOM_ID_LENGTH; i += 1) {
27
+ id += ALPHABET[bytes[i] % ALPHABET.length];
28
+ }
29
+
30
+ return id;
31
+ }
32
+
33
+ /**
34
+ * @param {*} value
35
+ * @returns {boolean} Является ли значение корректным roomId.
36
+ */
37
+ export function isValidRoomId(value) {
38
+ return typeof value === 'string' && ROOM_ID_PATTERN.test(value);
39
+ }
@@ -0,0 +1,59 @@
1
+ // Настройки комнаты для холодного перезапуска (host-migration этап 7.6):
2
+ // то, что форма создания комнаты передаёт в HostRole.createRoom
3
+ // (client/session/HostRole.js) и что читает lib/applyRoomOverrides.js. Хост шлёт их в register_host/reclaim_host,
4
+ // мастер хранит санированными и отдаёт преемнику в promote {mode: 'cold'}.
5
+ // Модуль изоморфный: им пользуются и клиент (выборка из room), и мастер
6
+ // (санитизация недоверенного сообщения)
7
+
8
+ // потолок сериализованных настроек на мастере
9
+ export const ROOM_SETTINGS_MAX_BYTES = 4096;
10
+
11
+ const MAX_MAP_NAME = 64;
12
+ const MAX_PLAYERS = 64;
13
+ // таймеры раунда/карты (мс): applyRoomOverrides клампит их ещё раз по
14
+ // рамкам игры, здесь — только санитарная граница
15
+ const MAX_TIME_MS = 24 * 60 * 60 * 1000;
16
+
17
+ const toInt = (value, min, max) =>
18
+ Number.isFinite(value)
19
+ ? Math.min(Math.max(Math.trunc(value), min), max)
20
+ : undefined;
21
+
22
+ /**
23
+ * Известные ключи настроек комнаты из недоверенного объекта: числа
24
+ * клампятся, строки обрезаются, остальное отбрасывается.
25
+ * @param {*} source
26
+ * @returns {{ maxPlayers?: number, map?: string, roundTime?: number,
27
+ * mapTime?: number, friendlyFire?: boolean }}
28
+ */
29
+ export function sanitizeRoomSettings(source) {
30
+ if (!source || typeof source !== 'object') {
31
+ return {};
32
+ }
33
+
34
+ const settings = {
35
+ maxPlayers: toInt(source.maxPlayers, 1, MAX_PLAYERS),
36
+ map:
37
+ typeof source.map === 'string' && source.map !== ''
38
+ ? source.map.slice(0, MAX_MAP_NAME)
39
+ : undefined,
40
+ roundTime: toInt(source.roundTime, 0, MAX_TIME_MS),
41
+ mapTime: toInt(source.mapTime, 0, MAX_TIME_MS),
42
+ friendlyFire:
43
+ typeof source.friendlyFire === 'boolean'
44
+ ? source.friendlyFire
45
+ : undefined,
46
+ };
47
+
48
+ for (const key of Object.keys(settings)) {
49
+ if (settings[key] === undefined) {
50
+ delete settings[key];
51
+ }
52
+ }
53
+
54
+ // известных ключей мало и все короткие — потолок держит формат, если
55
+ // список когда-нибудь пополнится
56
+ return JSON.stringify(settings).length > ROOM_SETTINGS_MAX_BYTES
57
+ ? {}
58
+ : settings;
59
+ }
@@ -300,6 +300,13 @@ export const clampGameResult = (points, best, maxGameScore) => {
300
300
  };
301
301
  };
302
302
 
303
+ // номер записи результата игры (host-migration 7.7): положительное
304
+ // безопасное целое, иначе null — запись без номера проксируется как раньше,
305
+ // без защиты от повтора. Отклонять её незачем: отказ по содержанию хост
306
+ // считает неустранимым и выбросил бы честные очки
307
+ export const readWriteSeq = value =>
308
+ Number.isSafeInteger(value) && value > 0 ? value : null;
309
+
303
310
  // Ключ сопоставления ника с глобальным топом. Уникальность users.nick в auth
304
311
  // регистронезависимая (миграция 002), поэтому и сравнивать надо так же —
305
312
  // иначе «Alice» и «alice» разъедутся. Правило одно, а мест, где по нику
@@ -112,9 +112,8 @@ export default class ScriptedManager {
112
112
  }
113
113
 
114
114
  getCountForTeam(teamName) {
115
- return this._participants
116
- .getScripted()
117
- .filter(p => p.team === teamName).length;
115
+ return this._participants.getScripted().filter(p => p.team === teamName)
116
+ .length;
118
117
  }
119
118
 
120
119
  getCountsPerTeam() {
@@ -52,7 +52,12 @@ export default class FakeGameCore {
52
52
  lastInputSeq: 0,
53
53
  });
54
54
 
55
- this._events.push({ type: 'panelSet', id: gameId, field: 'energy', value: 100 });
55
+ this._events.push({
56
+ type: 'panelSet',
57
+ id: gameId,
58
+ field: 'energy',
59
+ value: 100,
60
+ });
56
61
  }
57
62
 
58
63
  remove_actor(gameId) {
@@ -63,7 +68,15 @@ export default class FakeGameCore {
63
68
  const actor = this._actors.get(gameId);
64
69
 
65
70
  if (actor) {
66
- Object.assign(actor, { x, y, angle, team: teamId, alive: true, vx: 0, vy: 0 });
71
+ Object.assign(actor, {
72
+ x,
73
+ y,
74
+ angle,
75
+ team: teamId,
76
+ alive: true,
77
+ vx: 0,
78
+ vy: 0,
79
+ });
67
80
  }
68
81
  }
69
82
 
@@ -206,11 +219,22 @@ export default class FakeGameCore {
206
219
  return false;
207
220
  }
208
221
 
209
- pack_frame(serverTime, seq, hasCamera, camX, camY, forceReset, shake, playerId) {
222
+ pack_frame(
223
+ serverTime,
224
+ seq,
225
+ hasCamera,
226
+ camX,
227
+ camY,
228
+ forceReset,
229
+ shake,
230
+ playerId,
231
+ ) {
210
232
  this._lastFrame = {
211
233
  serverTime,
212
234
  seq,
213
- camera: hasCamera ? [camX, camY, Boolean(forceReset), shake ?? null] : null,
235
+ camera: hasCamera
236
+ ? [camX, camY, Boolean(forceReset), shake ?? null]
237
+ : null,
214
238
  playerId,
215
239
  body: this._lastBody,
216
240
  };
@@ -252,7 +276,9 @@ export default class FakeGameCore {
252
276
  // ***** handoff (задел, PLAN.md §6 «открытые вопросы») ***** //
253
277
 
254
278
  serialize_state() {
255
- return new TextEncoder().encode(JSON.stringify([...this._actors.entries()]));
279
+ return new TextEncoder().encode(
280
+ JSON.stringify([...this._actors.entries()]),
281
+ );
256
282
  }
257
283
 
258
284
  deserialize_state(bytes) {
@@ -11,9 +11,7 @@
11
11
  "roomDefaults": {
12
12
  "maxPlayers": 4
13
13
  },
14
- "maps": [
15
- "arena"
16
- ],
14
+ "maps": ["arena"],
17
15
  "roomForm": [
18
16
  {
19
17
  "name": "maxPlayers",
@@ -112,9 +112,8 @@ export default class ScriptedManager {
112
112
  }
113
113
 
114
114
  getCountForTeam(teamName) {
115
- return this._participants
116
- .getScripted()
117
- .filter(p => p.team === teamName).length;
115
+ return this._participants.getScripted().filter(p => p.team === teamName)
116
+ .length;
118
117
  }
119
118
 
120
119
  getCountsPerTeam() {