vimp-engine 0.24.1 → 0.25.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.
@@ -8,8 +8,14 @@
8
8
  // Вынесено из main.js (бутстрап, тестами не покрывается) отдельным модулем,
9
9
  // чтобы кеш и обработка отказа проверялись юнит-тестом.
10
10
  export function createGameActivator({ gamesById, loadClientPlugin }) {
11
- // gameId -> промис загрузки: кешируется именно промис, иначе два быстрых
12
- // клика подряд запустили бы импорт дважды
11
+ // `${gameId}@${manifest.version}` -> промис загрузки: кешируется именно
12
+ // промис, иначе два быстрых клика подряд запустили бы импорт дважды.
13
+ //
14
+ // Версия в ключе обязательна (master-game-registry, этап 3): в каталоге
15
+ // мастера две версии одной игры живут одновременно (админ стейджит новую,
16
+ // игроки играют в одобренную), и ключ по одному gameId вернул бы уже
17
+ // выполненный import() НЕ ТОГО кода. manifest.version — хеш бандла, то
18
+ // есть идентификатор самого кода
13
19
  const plugins = new Map();
14
20
 
15
21
  return async function activateGame(gameId) {
@@ -19,18 +25,20 @@ export function createGameActivator({ gamesById, loadClientPlugin }) {
19
25
  throw new Error(`unknown game "${gameId}"`);
20
26
  }
21
27
 
22
- let pending = plugins.get(gameId);
28
+ const key = `${gameId}@${manifest.version}`;
29
+
30
+ let pending = plugins.get(key);
23
31
 
24
32
  if (!pending) {
25
33
  pending = loadClientPlugin(manifest).catch(e => {
26
34
  // отказ не кешируем: сеть могла моргнуть, повторный клик обязан
27
35
  // попробовать снова, а не переигрывать ту же ошибку вечно
28
- plugins.delete(gameId);
36
+ plugins.delete(key);
29
37
 
30
38
  throw e;
31
39
  });
32
40
 
33
- plugins.set(gameId, pending);
41
+ plugins.set(key, pending);
34
42
  }
35
43
 
36
44
  return { manifest, plugin: await pending };
@@ -73,6 +73,9 @@ import LobbyCtrl from './components/controller/Lobby.js';
73
73
  import LobbyAuthModel from './components/model/LobbyAuth.js';
74
74
  import LobbyAuthView from './components/view/LobbyAuth.js';
75
75
  import LobbyAuthCtrl from './components/controller/LobbyAuth.js';
76
+ import GamesModel from './components/model/Games.js';
77
+ import GamesView from './components/view/Games.js';
78
+ import GamesCtrl from './components/controller/Games.js';
76
79
  import BakingProvider from './providers/BakingProvider.js';
77
80
  import DependencyProvider from './providers/DependencyProvider.js';
78
81
  import { HOT_FLAGS } from '../config/opcodes.js';
@@ -1718,8 +1721,7 @@ let hostRegistration = null;
1718
1721
  // Этап 5.1/6.4: скачивает каталог карт мастера активной игры (манифест +
1719
1722
  // все карты)
1720
1723
  async function fetchMasterMaps() {
1721
- const gameId = activeGameManifest.id;
1722
- const manifestRes = await fetch(lobbyConfig.maps.manifestUrl(gameId));
1724
+ const manifestRes = await fetch(lobbyConfig.maps.manifestUrl(activeGameManifest));
1723
1725
 
1724
1726
  if (!manifestRes.ok) {
1725
1727
  throw new Error(`maps manifest: HTTP ${manifestRes.status}`);
@@ -1729,7 +1731,7 @@ async function fetchMasterMaps() {
1729
1731
 
1730
1732
  const entries = await Promise.all(
1731
1733
  manifest.maps.map(async name => {
1732
- const url = `${lobbyConfig.maps.baseUrl(gameId)}/${encodeURIComponent(name)}`;
1734
+ const url = `${lobbyConfig.maps.baseUrl(activeGameManifest)}/${encodeURIComponent(name)}`;
1733
1735
  const res = await fetch(url);
1734
1736
 
1735
1737
  if (!res.ok) {
@@ -1840,8 +1842,16 @@ async function fetchServers({ offset, limit, search }) {
1840
1842
  params.set('limit', limit);
1841
1843
  }
1842
1844
 
1845
+ // токен едет и сюда (master-game-registry, этап 4): список серверов
1846
+ // публичный, но админу мастер отдаёт вместе с ним скрытые тестовые
1847
+ // комнаты застейдженных версий
1848
+ const token = lobbyAuthModel?.getToken();
1849
+
1843
1850
  try {
1844
- const res = await fetch(`${lobbyConfig.serversUrl}?${params}`);
1851
+ const res = await fetch(
1852
+ `${lobbyConfig.serversUrl}?${params}`,
1853
+ token ? { headers: { authorization: `Bearer ${token}` } } : undefined,
1854
+ );
1845
1855
 
1846
1856
  return res.ok ? await res.json() : null;
1847
1857
  } catch (e) {
@@ -1939,6 +1949,11 @@ function populateRoomForm(manifest) {
1939
1949
  }
1940
1950
  }
1941
1951
 
1952
+ // роли, которым лобби показывает кнопку «Moderation» (master-game-registry,
1953
+ // этап 4). Это подсказка интерфейсу: доступ к данным проверяет мастер, а
1954
+ // запись — auth-сервис, перечитывая роль из БД
1955
+ const ADMIN_ROLES = ['admin', 'superadmin'];
1956
+
1942
1957
  // каталог манифестов по id: форма и leaderboard селектора игр, а также
1943
1958
  // активация игры перед созданием комнаты и входом в чужую
1944
1959
  const gamesById = new Map(
@@ -1972,7 +1987,10 @@ function clearLobbyError() {
1972
1987
  // активирует игру и перепривязывает манифест/плагин/CSS; false — отказ уже
1973
1988
  // показан игроку, лобби остаётся рабочим
1974
1989
  async function selectActiveGame(gameId) {
1975
- if (gameId === activeGameManifest.id) {
1990
+ // сверка идёт с самим манифестом, а не с его id: каталог вкладки может
1991
+ // пополниться застейдженной версией той же игры (registerGameManifest), и
1992
+ // сравнение по id оставило бы активной одобренную версию
1993
+ if (gameId === activeGameManifest.id && gamesById.get(gameId) === activeGameManifest) {
1976
1994
  return true;
1977
1995
  }
1978
1996
 
@@ -1993,8 +2011,36 @@ async function selectActiveGame(gameId) {
1993
2011
  }
1994
2012
  }
1995
2013
 
2014
+ // id игр, чей манифест в каталоге вкладки подменён застейдженной версией
2015
+ // (master-game-registry, этап 4): в селекторе такая игра помечается, чтобы
2016
+ // админ видел, что поднимет комнату НЕ на одобренной версии
2017
+ const stagedGameIds = new Set();
2018
+
2019
+ /**
2020
+ * Кладёт манифест застейдженной (не одобренной) версии в каталог вкладки —
2021
+ * так админ может поднять по нему комнату, не трогая каталог игроков.
2022
+ * Дальше всё работает существующим путём: createGameActivator грузит
2023
+ * ClientPlugin по entries.client (ключ кеша версионный, поэтому плагин
2024
+ * черновика не подменит одобренный), connectAsHost поднимает комнату, а
2025
+ * мастер помечает её скрытой по версии манифеста.
2026
+ * @param {Object} manifest - Манифест застейдженной версии из /admin/games.
2027
+ * @returns {void}
2028
+ */
2029
+ function registerGameManifest(manifest) {
2030
+ if (!manifest?.id) {
2031
+ return;
2032
+ }
2033
+
2034
+ gamesById.set(manifest.id, manifest);
2035
+ stagedGameIds.add(manifest.id);
2036
+ populateGameSelect();
2037
+ }
2038
+
1996
2039
  // заполняет #lobby-game всем каталогом манифестов мастера — раньше туда
1997
- // попадал только gamesManifest[0], теперь селектор рабочий
2040
+ // попадал только gamesManifest[0], теперь селектор рабочий.
2041
+ // Источник — gamesById, а не gamesManifest: каталог вкладки пополняется на
2042
+ // лету застейдженными версиями (registerGameManifest), и селектор
2043
+ // перерисовывается целиком
1998
2044
  function populateGameSelect() {
1999
2045
  const gameSelect = document.getElementById(lobbyConfig.elems.gameId);
2000
2046
 
@@ -2002,7 +2048,13 @@ function populateGameSelect() {
2002
2048
  return;
2003
2049
  }
2004
2050
 
2005
- gamesManifest.forEach(manifest => {
2051
+ // перерисовка не должна сбрасывать выбор игрока: селектор целиком
2052
+ // пересобирается и при добавлении застейдженной версии
2053
+ const selected = gameSelect.value;
2054
+
2055
+ gameSelect.textContent = '';
2056
+
2057
+ gamesById.forEach(manifest => {
2006
2058
  const option = document.createElement('option');
2007
2059
  const available = isGameAvailable(manifest);
2008
2060
 
@@ -2011,14 +2063,15 @@ function populateGameSelect() {
2011
2063
  // каталога, и игрок видел пустое лобби без причины), но выбрать её
2012
2064
  // нельзя: комнату по ней всё равно не поднять
2013
2065
  option.textContent = available
2014
- ? manifest.title
2066
+ ? `${manifest.title}${stagedGameIds.has(manifest.id) ? lobbyConfig.games.stagedSuffix : ''}`
2015
2067
  : `${manifest.title} — unavailable`;
2016
2068
  option.disabled = !available;
2017
2069
  option.title = available ? '' : manifest.compat.text;
2018
2070
  gameSelect.appendChild(option);
2019
2071
  });
2020
2072
 
2021
- gameSelect.value = activeGameManifest.id;
2073
+ gameSelect.value =
2074
+ selected && gamesById.has(selected) ? selected : activeGameManifest.id;
2022
2075
  }
2023
2076
 
2024
2077
  // поднимает лобби после welcome от мастера (iceServers уже получены);
@@ -2114,6 +2167,22 @@ function initLobby() {
2114
2167
  () => ({ descriptors: roomFormDescriptors, fields: roomFormFields }),
2115
2168
  );
2116
2169
 
2170
+ // реестр игр (master-game-registry, этап 4): заявка разработчика и панель
2171
+ // модерации живут в том же лобби. Триплет поднимается вместе с лобби —
2172
+ // панель доступна любому авторизованному, кнопку модерации показывает роль
2173
+ const gamesModel = new GamesModel(lobbyConfig.games, () => lobbyAuthModel.getToken());
2174
+ const gamesView = new GamesView(gamesModel, lobbyConfig.games);
2175
+ const games = new GamesCtrl(gamesModel, gamesView);
2176
+
2177
+ games.setAdmin(ADMIN_ROLES.includes(lobbyAuthModel.getRole()));
2178
+
2179
+ // «Test»: манифест застейдженной версии кладётся в каталог вкладки, и
2180
+ // админ поднимает по нему комнату обычной кнопкой Create server
2181
+ games.publisher.on('staged', ({ manifest }) => {
2182
+ registerGameManifest(manifest);
2183
+ gamesView.hide();
2184
+ });
2185
+
2117
2186
  // создание комнаты в этой же вкладке (хост-игрок через loopback)
2118
2187
  populateGameSelect();
2119
2188
  populateRoomForm(activeGameManifest);
@@ -1053,3 +1053,104 @@ body.hide-cursor {
1053
1053
  color: #f56;
1054
1054
  margin: 8px 0;
1055
1055
  }
1056
+
1057
+ /*games*/
1058
+
1059
+ /* панель реестра игр (master-game-registry, этап 4): открывается вместо
1060
+ #lobby теми же кнопками бейджа пользователя. Каркас — тот же, что у
1061
+ лобби (.card, .form-row, .lobby-column), собственных цветовых переменных
1062
+ секция не заводит */
1063
+ #games-panel {
1064
+ background-color: #222;
1065
+ position: absolute;
1066
+ width: 100%;
1067
+ min-height: 100%;
1068
+ top: 0;
1069
+ left: 0;
1070
+ z-index: 8;
1071
+ display: flex;
1072
+ align-items: flex-start;
1073
+ justify-content: center;
1074
+ box-sizing: border-box;
1075
+ }
1076
+
1077
+ #games-mine,
1078
+ #games-moderation {
1079
+ width: 700px;
1080
+ padding: 10px;
1081
+ text-align: left;
1082
+ }
1083
+
1084
+ /* заголовок панели и кнопка возврата в одной строке */
1085
+ .games-head {
1086
+ display: flex;
1087
+ align-items: center;
1088
+ justify-content: space-between;
1089
+ }
1090
+
1091
+ .games-list {
1092
+ list-style: none;
1093
+ margin: 0;
1094
+ padding: 0;
1095
+ }
1096
+
1097
+ .games-item {
1098
+ border-bottom: 1px solid #444;
1099
+ padding: 8px;
1100
+ }
1101
+
1102
+ .games-item-title {
1103
+ font-weight: bold;
1104
+ }
1105
+
1106
+ /* поля прямо в карточке заявки: версия и причина отклонения относятся к
1107
+ конкретной строке списка, а не к форме панели */
1108
+ .games-version-input,
1109
+ .games-note-input {
1110
+ width: 200px;
1111
+ text-align: left;
1112
+ margin-right: 8px;
1113
+ }
1114
+
1115
+ /* поля заявки — текстовые, а не счётчики: правило .form-row
1116
+ input[type='text'] рассчитано на «max players» room-формы */
1117
+ #games-submit-form .form-row input[type='text'] {
1118
+ width: 260px;
1119
+ max-width: 100%;
1120
+ text-align: left;
1121
+ }
1122
+
1123
+ .games-filters {
1124
+ display: flex;
1125
+ gap: 12px;
1126
+ border-bottom: 1px solid #444;
1127
+ margin-bottom: 12px;
1128
+ }
1129
+
1130
+ .games-filter-btn {
1131
+ background: none;
1132
+ border: none;
1133
+ color: #888;
1134
+ font-weight: bold;
1135
+ padding: 6px 4px 8px;
1136
+ margin: 0;
1137
+ text-shadow: none;
1138
+ }
1139
+
1140
+ .games-filter-btn.active {
1141
+ color: #eee;
1142
+ }
1143
+
1144
+ /* кнопки входа в панель стоят рядом с Sign out в бейдже пользователя */
1145
+ .lobby-user-actions {
1146
+ display: flex;
1147
+ gap: 8px;
1148
+ align-items: center;
1149
+ }
1150
+
1151
+ @media (max-width: 900px) {
1152
+ #games-mine,
1153
+ #games-moderation {
1154
+ width: 100%;
1155
+ }
1156
+ }
@@ -0,0 +1,36 @@
1
+ //- Реестр игр в лобби (master-game-registry, этап 4): разработчик подаёт
2
+ //- заявку и следит за её статусом, админ модерирует и играет в черновик.
3
+ //- Панель скрыта по умолчанию — открывается кнопками бейджа пользователя
4
+ //- (#lobby-user в lobby.pug); id элементов перечислены в config/lobby.js.
5
+ //- Подписи — по-английски, как и остальное лобби
6
+ div#games-panel(style='display:none')
7
+ div.lobby-column
8
+ div.card#games-mine
9
+ div.games-head
10
+ h3.lobby-section-title= 'My games'
11
+ input#games-close(type='button', value='Back to lobby')
12
+ ul#games-mine-list.games-list
13
+ form#games-submit-form
14
+ div.form-row
15
+ label.form-label(for='games-field-id')= 'Game id'
16
+ input#games-field-id.field-text(type='text', name='id', maxlength='32', autocomplete='off')
17
+ div.form-row
18
+ label.form-label(for='games-field-package')= 'npm package'
19
+ input#games-field-package.field-text(type='text', name='packageName', maxlength='214', autocomplete='off')
20
+ div.form-row
21
+ label.form-label(for='games-field-version')= 'Version'
22
+ input#games-field-version.field-text(type='text', name='version', maxlength='64', autocomplete='off')
23
+ div.form-row
24
+ label.form-label(for='games-field-repo')= 'Repository URL'
25
+ input#games-field-repo.field-text(type='text', name='repoUrl', maxlength='256', autocomplete='off')
26
+ div.form-row
27
+ label.form-label(for='games-field-title')= 'Title'
28
+ input#games-field-title.field-text(type='text', name='title', maxlength='64', autocomplete='off')
29
+ div#games-submit-error.form-error
30
+ input#games-submit(type='submit', value='Submit for review')
31
+
32
+ div.card#games-moderation(style='display:none')
33
+ h3.lobby-section-title= 'Moderation'
34
+ div#games-filters.games-filters
35
+ ul#games-admin-list.games-list
36
+ div#games-admin-error.form-error
@@ -14,7 +14,13 @@ div#lobby(style='display:none')
14
14
  div
15
15
  span= 'Signed in as '
16
16
  b#lobby-user-nick
17
- input#lobby-user-logout(type='button', value='Sign out')
17
+ //- вход в панель реестра игр (master-game-registry, этап 4):
18
+ //- «My games» видит любой авторизованный, «Moderation» — только
19
+ //- админская роль (main.js по LobbyAuthModel.getRole())
20
+ div.lobby-user-actions
21
+ input#games-open-mine(type='button', value='My games')
22
+ input#games-open-moderation(type='button', value='Moderation', style='display:none')
23
+ input#lobby-user-logout(type='button', value='Sign out')
18
24
  hr.lobby-divider
19
25
  h3.lobby-section-title= 'HOST A GAME'
20
26
  div#lobby-create
@@ -4,5 +4,6 @@ include includes/chat
4
4
  include includes/stat
5
5
  include includes/lobbyAuth
6
6
  include includes/lobby
7
+ include includes/games
7
8
  include includes/auth
8
9
  include includes/informer
package/src/config/env.js CHANGED
@@ -31,6 +31,12 @@ export function applyMasterEnv(config, env = process.env) {
31
31
  if (env.GAMES_MATRIX) {
32
32
  config.set('master:games', JSON.parse(env.GAMES_MATRIX));
33
33
  }
34
+
35
+ // корень хранилища игровых пакетов (направление master-game-registry) —
36
+ // в проде это смонтированный том, переживающий пересоздание контейнера
37
+ if (env.VIMP_GAMES_DIR) {
38
+ config.set('master:gameStore:dir', env.VIMP_GAMES_DIR);
39
+ }
34
40
  }
35
41
 
36
42
  /**
@@ -11,10 +11,19 @@ export default {
11
11
 
12
12
  // каталог карт мастера, per-game (Этап 6.4): комната хоста стартует на
13
13
  // актуальных картах активной игры, недоступность каталога — fallback на
14
- // карты из бандла
14
+ // карты из бандла.
15
+ //
16
+ // Аргумент — МАНИФЕСТ, а не gameId (master-game-registry, этап 3): карты
17
+ // клиент берёт не из assetsBase, а по отдельному URL, и версионность
18
+ // каталога обязана доехать и сюда, иначе комната на застейдженной версии
19
+ // играла бы на картах одобренной. `mapsBase` проставляет мастер при
20
+ // ребейзе версионного каталога; его отсутствие (dev, standalone,
21
+ // dedicated, старый мастер) — законный случай, тогда работает прежний
22
+ // путь по id
15
23
  maps: {
16
- manifestUrl: gameId => `/games/${gameId}/maps/manifest.json`,
17
- baseUrl: gameId => `/games/${gameId}/maps`,
24
+ manifestUrl: manifest =>
25
+ `${manifest.mapsBase ?? `/games/${manifest.id}/maps`}/manifest.json`,
26
+ baseUrl: manifest => manifest.mapsBase ?? `/games/${manifest.id}/maps`,
18
27
  },
19
28
 
20
29
  // манифест конкретной игры (Этап 6.5): эстафета Worker'ов перечитывает его
@@ -184,6 +193,76 @@ export default {
184
193
  linkId: 'lobby-link',
185
194
  },
186
195
 
196
+ // реестр игр (master-game-registry, этап 4): заявка разработчика и панель
197
+ // модерации живут в том же лобби, без правки конфигов и рестартов.
198
+ // Все URL и id элементов — здесь: правило репозитория, модули их не
199
+ // хардкодят (games.pug)
200
+ games: {
201
+ urls: {
202
+ // заявки вызывающего со статусами и замечаниями модератора
203
+ mine: '/games/mine',
204
+ // заявка на новую игру платформы (валидируется мастером до записи)
205
+ submit: '/games/submit',
206
+ // заявка на новую версию уже заведённой игры
207
+ version: id => `/games/mine/${encodeURIComponent(id)}/version`,
208
+ // очередь модерации целиком плюс локальное состояние на этом мастере
209
+ admin: '/admin/games',
210
+ // манифесты застейдженных версий — по ним админ поднимает тестовую
211
+ // комнату, не трогая каталог игроков
212
+ staged: '/admin/games/manifest.json',
213
+ // «Test»: скачать версию и положить её в каталог не раздаваемой
214
+ stage: id => `/admin/games/${encodeURIComponent(id)}/stage`,
215
+ // решение модератора
216
+ moderate: id => `/admin/games/${encodeURIComponent(id)}`,
217
+ // что опубликовано в npm — индикатор «есть версия новее»
218
+ versions: id => `/admin/games/${encodeURIComponent(id)}/versions`,
219
+ },
220
+
221
+ // фильтры очереди модерации: id статуса реестра -> подпись кнопки.
222
+ // Значения обязаны совпадать со статусами auth-сервиса
223
+ statuses: [
224
+ { id: 'pending', title: 'Pending' },
225
+ { id: 'approved', title: 'Published' },
226
+ { id: 'rejected', title: 'Rejected' },
227
+ { id: 'disabled', title: 'Disabled' },
228
+ ],
229
+ defaultStatus: 'pending',
230
+
231
+ // суффикс игры, поднятой из застейдженной версии: в селекторе она
232
+ // стоит рядом с одобренной, и различать их обязано быть видно
233
+ stagedSuffix: ' (test)',
234
+
235
+ // DOM-элементы панели (из games.pug)
236
+ elems: {
237
+ panelId: 'games-panel',
238
+ // панель и лобби делят место: открытая панель прячет #lobby целиком
239
+ lobbyId: 'lobby',
240
+ // кнопки в бейдже пользователя (lobby.pug)
241
+ openMineBtnId: 'games-open-mine',
242
+ openModerationBtnId: 'games-open-moderation',
243
+ closeBtnId: 'games-close',
244
+
245
+ // «My games»
246
+ mineListId: 'games-mine-list',
247
+ submitFormId: 'games-submit-form',
248
+ submitErrorId: 'games-submit-error',
249
+ submitBtnId: 'games-submit',
250
+ fieldIds: {
251
+ id: 'games-field-id',
252
+ packageName: 'games-field-package',
253
+ version: 'games-field-version',
254
+ repoUrl: 'games-field-repo',
255
+ title: 'games-field-title',
256
+ },
257
+
258
+ // модерация
259
+ moderationId: 'games-moderation',
260
+ adminListId: 'games-admin-list',
261
+ adminErrorId: 'games-admin-error',
262
+ filtersId: 'games-filters',
263
+ },
264
+ },
265
+
187
266
  // создание комнаты (хост в этой же вкладке); лимит игроков/время
188
267
  // раунда-карты/огонь по своим/карта по умолчанию — из roomDefaults
189
268
  // манифеста активной игры (Этап 6.3), не бандлятся здесь
@@ -18,22 +18,42 @@ export default {
18
18
  cert: path.join(rootDir, '.certs', 'cert.pem'),
19
19
  },
20
20
 
21
- // список игр-плагинов, подключаемых к мастеру (Этап A2 плана разделения):
22
- // `package` имя npm-пакета игры, резолвится через node_modules; пакет
23
- // объявлен зависимостью в корневом package.json репозитория (деплой-уровень),
24
- // не в packages/engine vimp-engine остаётся game-agnostic и не тянет
25
- // конкретную игру за собой (кодревью Этапов A, находка F1). Версию задаёт
26
- // именно эта зависимость, каталог берёт её из dist/manifest.json пакета.
27
- // Список переопределяется переменной окружения GAMES_MATRIX (JSON) — её
28
- // задаёт деплой; вне прода каталог вдобавок дополняется собранными
29
- // пакетами @vimp-games/* из node_modules (master/localGames.js), чтобы
30
- // прилинкованная игра попадала в лобби без правки этого массива: он
31
- // публикуется вместе с пакетом vimp-engine
21
+ // Статический каталог игр-плагинов. ПУСТ по умолчанию
22
+ // (master-game-registry, этап 3): штатный источник каталога реестр игр
23
+ // auth-сервиса, откуда мастер скачивает одобренные пакеты сам
24
+ // (master/GameSync.js). Движок игр не знает и зависимостями их не тянет.
25
+ //
26
+ // Массив и переменная окружения GAMES_MATRIX (JSON) остаются
27
+ // переопределением для двух случаев: локальная разработка (каталог вдобавок
28
+ // дополняется собранными пакетами @vimp-games/* из node_modules,
29
+ // master/localGames.js — прилинкованная игра важнее реестра) и self-hosted
30
+ // мастер без реестра. `package` имя npm-пакета игры, резолвится через
31
+ // node_modules; версию задаёт сама установленная зависимость.
32
32
  // maxGameScore (snakes-v3 этап 3.3, необязательное поле рядом с
33
33
  // id/package) — потолок результата ОДНОЙ игры для этой игры; мастер
34
- // клампит им `best`/`points` PUT /auth/rank. Не задан дефолт
34
+ // клампит им `best`/`points` PUT /auth/rank. Для игр реестра его задаёт
35
+ // админ при модерации, здесь — только запасной путь. Не задан — дефолт
35
36
  // master:playerData:maxGameScore
36
- games: [{ id: 'tanks', package: '@vimp-games/tanks' }],
37
+ games: [],
38
+
39
+ // Хранилище игровых пакетов (направление master-game-registry): мастер
40
+ // качает одобренные игры из npm registry и раздаёт их с диска, вместо того
41
+ // чтобы получать их npm-зависимостью на этапе сборки образа.
42
+ gameStore: {
43
+ // корень хранилища; null → <repoRoot>/.games (локальная разработка).
44
+ // В проде задаётся VIMP_GAMES_DIR и монтируется томом
45
+ dir: null,
46
+ registryUrl: 'https://registry.npmjs.org',
47
+ // период опроса реестра auth за изменениями каталога
48
+ refreshInterval: 60000,
49
+ // потолки распаковки недоверенного архива
50
+ maxTarballBytes: 64 * 1024 * 1024,
51
+ maxFiles: 5000,
52
+ // сколько версий одной игры держать на диске: активная + стейджевая
53
+ keepVersions: 2,
54
+ // потолок ожидания ответа реестра
55
+ timeout: 30000,
56
+ },
37
57
 
38
58
  // список серверов (GET /servers)
39
59
  servers: {
@@ -123,7 +123,7 @@ export default {
123
123
  // исключена: пакет, собранный до появления поля, обязан оставаться
124
124
  // валидным (И1/И2)
125
125
  function halfViolations(ctx, manifestRequires) {
126
- const wanted = new Set(manifestRequires);
126
+ const wanted = new Set(resolveNames(manifestRequires));
127
127
  const violations = [];
128
128
  const halves = [
129
129
  ['HostPlugin', ctx.hostPlugin?.requires],
@@ -139,7 +139,7 @@ function halfViolations(ctx, manifestRequires) {
139
139
  continue;
140
140
  }
141
141
 
142
- const extra = value.filter(name => !wanted.has(name));
142
+ const extra = resolveNames(value).filter(name => !wanted.has(name));
143
143
 
144
144
  if (extra.length) {
145
145
  violations.push(
@@ -158,10 +158,12 @@ function halfViolations(ctx, manifestRequires) {
158
158
  return violations;
159
159
  }
160
160
 
161
- const declared = halves
162
- .map(([, value]) => value)
163
- .filter(Array.isArray)
164
- .flat();
161
+ const declared = resolveNames(
162
+ halves
163
+ .map(([, value]) => value)
164
+ .filter(Array.isArray)
165
+ .flat(),
166
+ );
165
167
 
166
168
  const missing = [...wanted].filter(name => !declared.includes(name));
167
169
 
@@ -176,6 +178,19 @@ function halfViolations(ctx, manifestRequires) {
176
178
  return violations;
177
179
  }
178
180
 
181
+ // Имена сравниваются РЕЗОЛВНУТЫМИ: реестр возможностей append-only, и
182
+ // выведенное алиасом имя движок принимает вечно наравне с активным.
183
+ // Манифест с новым именем и половина, оставшаяся на выведенном, просят одно
184
+ // и то же — считать это расхождением значило бы ругаться на игру за то, что
185
+ // движок переименовал возможность (тот самый отказ по возрасту, который план
186
+ // снимал). Неизвестное имя резолва не имеет и сравнивается как есть: о нём
187
+ // говорит проверка по реестру выше
188
+ function resolveNames(names) {
189
+ return (Array.isArray(names) ? names : []).map(
190
+ name => ENGINE_CAPABILITIES.resolve(name) ?? name,
191
+ );
192
+ }
193
+
179
194
  // то же, что делают B5 и C10: выведенное имя — не отказ, а предупреждение.
180
195
  // Отвергать за него значило бы отвергать игру за возраст (И1)
181
196
  function finish(violations, retired) {
@@ -2,6 +2,7 @@ import { ERROR, WARN, skip, verdict } from '../result.js';
2
2
  import { resolveValidator } from '../../../lib/validators.js';
3
3
  import {
4
4
  formControls,
5
+ resolveDescriptor,
5
6
  ACTIVE_FORM_CONTROLS,
6
7
  } from '../../../lib/formControls.js';
7
8
 
@@ -13,6 +14,10 @@ import {
13
14
  // которого нет в authSchema.validators (поле не проверяется никем).
14
15
  const NICKNAME = /^(name|nick|nickname|player|playername|login|username)$/i;
15
16
 
17
+ // те же имена, что у validateAuth и билдера формы: список вариантов есть
18
+ // только у них, и только им движок резолвит `source`
19
+ const OPTION_CONTROLS = ['select', 'radio'];
20
+
16
21
  export default {
17
22
  id: 'C10',
18
23
  name: 'authSchema',
@@ -76,8 +81,18 @@ export default {
76
81
  // значит список вариантов у такого поля пуст всегда: игрок видит
77
82
  // 'no options available' и войти не может, а хост отвергает любое
78
83
  // значение как не-вариант. Это только для authSchema; в roomForm
79
- // (правило B5) source штатный — там движок отдаёт каталог карт
80
- if (field.options?.source !== undefined) {
84
+ // (правило B5) source штатный — там движок отдаёт каталог карт.
85
+ //
86
+ // Только для контролов со списком: у text/checkbox `source` не читает
87
+ // никто (formBuilder.resolveOptions зовут лишь buildSelect/buildRadio),
88
+ // и такое поле работает — отвергать за лишний ключ значило бы отвергать
89
+ // игру, которая логинит игроков как ни в чём не бывало
90
+ // по резолвнутому контролу, а не по написанному: 'segmented' — та же
91
+ // группа вариантов, что radio, и source в ней так же не резолвится
92
+ if (
93
+ OPTION_CONTROLS.includes(resolveDescriptor(field.options)?.control) &&
94
+ field.options?.source !== undefined
95
+ ) {
81
96
  violations.push(
82
97
  `authSchema param "${field.name}" declares options.source ` +
83
98
  `"${field.options.source}" — the auth form is built without ` +