vimp-engine 0.20.0 → 0.21.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,27 +1,67 @@
1
1
  import Publisher from '../../../lib/Publisher.js';
2
+ import { nickKey } from '../../../lib/validators.js';
2
3
 
3
4
  // Singleton StatModel
4
5
 
5
6
  let statModel;
6
7
 
7
8
  export default class StatModel {
8
- constructor(data) {
9
+ // data — params схемы игры; deps — то, чего у модуля своего нет: топ,
10
+ // привезённый хостом, свой gameId и свой ник (режим 'leaderboard')
11
+ constructor(data, deps = {}) {
12
+ // Синглтон переиспользуется, но НЕ замораживается: runModules зовётся на
13
+ // каждый вход в матч (порт CONFIG_DATA), и вернуть старый экземпляр
14
+ // как есть значило бы играть в snakes с параметрами tanks — режим,
15
+ // период и число строк приезжают из схемы КОНКРЕТНОЙ игры
9
16
  if (statModel) {
17
+ statModel._configure(data, deps);
18
+
10
19
  return statModel;
11
20
  }
12
21
 
13
22
  statModel = this;
14
23
 
24
+ this.publisher = new Publisher();
25
+ this._configure(data, deps);
26
+ }
27
+
28
+ // всё, что зависит от схемы игры и её сервисов: выделено из конструктора
29
+ // ровно затем, чтобы переиспользованный синглтон обновлялся, а не врал
30
+ _configure(data, deps = {}) {
15
31
  this._heads = data.heads;
16
32
  this._bodies = data.bodies;
17
33
  this._sortList = data.sortList;
18
- this.publisher = new Publisher();
34
+
35
+ // snakes-v3 этап 4: режим 'leaderboard' — по Tab показывается не
36
+ // таблица комнаты, а глобальный топ игры. Данные хоста в этом режиме не
37
+ // рисуются (хост их и не шлёт), список приезжает портом ACCOLADES_DATA
38
+ this._mode = data.mode ?? null;
39
+ this._period = data.period ?? 'day';
40
+ this._limit = data.limit ?? 10;
41
+
42
+ // сервисы движка, а не сеть: топ уже привезён хостом (см.
43
+ // client/lib/accolades.js), свой gameId знает клиентское ядро
44
+ this._accolades = deps.accolades ?? null;
45
+ this._localPlayer = deps.localPlayer ?? null;
46
+ this._getNick = deps.getNick ?? (() => null);
47
+
48
+ this._rows = [];
49
+ }
50
+
51
+ get isLeaderboard() {
52
+ return this._mode === 'leaderboard';
19
53
  }
20
54
 
21
55
  // открывает статистику
22
56
  open() {
23
57
  this.publisher.emit('open');
24
58
  this.publisher.emit('mode', { name: 'stat', status: 'opened' });
59
+
60
+ if (this.isLeaderboard) {
61
+ // сети здесь нет вовсе: рисуется последняя рассылка хоста, а
62
+ // следующая перерисует список сама (см. applyAccolades)
63
+ this.refreshLeaderboard();
64
+ }
25
65
  }
26
66
 
27
67
  // закрывает статистику
@@ -32,6 +72,11 @@ export default class StatModel {
32
72
 
33
73
  // обновляет данные статистики
34
74
  update(data) {
75
+ // в режиме 'leaderboard' данные комнаты не рисуются вовсе
76
+ if (this.isLeaderboard) {
77
+ return;
78
+ }
79
+
35
80
  const tBodiesData = data[0];
36
81
  const tHeadData = data[1];
37
82
  const fullStatFlag = data[2];
@@ -74,4 +119,80 @@ export default class StatModel {
74
119
  }
75
120
  }
76
121
  }
122
+
123
+ // пришла новая рассылка мест и топа (порт ACCOLADES_DATA). Перерисовывать
124
+ // список стоит только пока статистика открыта? — нет: сборка строк это
125
+ // десяток объектов, а держать модель в актуальном виде дешевле, чем
126
+ // объяснять, почему первый Tab показывает вчерашнее
127
+ applyAccolades() {
128
+ if (this.isLeaderboard) {
129
+ this.refreshLeaderboard();
130
+ }
131
+ }
132
+
133
+ // Пересобирает список из того, что привёз хост. Ни одного запроса: в
134
+ // матче клиент с мастером не разговаривает (см. client/lib/accolades.js).
135
+ // Пустой список до первой рассылки — нормальное состояние
136
+ refreshLeaderboard() {
137
+ if (!this._accolades) {
138
+ return;
139
+ }
140
+
141
+ const board = this._accolades.boardOf(this._period);
142
+ const rows = board.slice(0, this._limit).map(row => ({
143
+ place: row.place,
144
+ nick: row.nick,
145
+ score: row.score,
146
+ isSelf: false,
147
+ }));
148
+
149
+ this._rows = this._withSelf(rows);
150
+
151
+ this.publisher.emit('leaderboard', this._rows);
152
+ }
153
+
154
+ // Своя строка: если игрок в топе — подсвечивается на своём месте, если
155
+ // нет — заменяет последнюю (решение пользователя 7). Неранжированный за
156
+ // период получает прочерк вместо места и свои очки (то есть 0).
157
+ //
158
+ // `rows` здесь ВСЕГДА чистый топ от хоста, а не результат прошлого прохода:
159
+ // подставленная своя строка в него не попадает, и сравнение по нику
160
+ // поэтому честное. Раньше список пересобирался поверх самого себя, и
161
+ // синтетическая строка выдавала себя за строку из топа — своё место потом
162
+ // не обновлялось никогда
163
+ _withSelf(rows) {
164
+ const nick = this._getNick();
165
+ const lower = nick ? nickKey(nick) : null;
166
+ const marked = rows.map(row => ({
167
+ ...row,
168
+ isSelf: lower !== null && nickKey(row.nick) === lower,
169
+ }));
170
+
171
+ if (marked.some(row => row.isSelf)) {
172
+ return marked;
173
+ }
174
+
175
+ // в топе игрока нет — его собственное место знает только рассылка хоста
176
+ const myId = this._localPlayer?.id ?? null;
177
+ const mine = myId === null ? null : this._accolades.selfOf(myId, this._period);
178
+
179
+ if (!mine || !nick) {
180
+ return marked;
181
+ }
182
+
183
+ const self = {
184
+ place: mine.place ?? null,
185
+ nick,
186
+ score: mine.score ?? 0,
187
+ isSelf: true,
188
+ };
189
+
190
+ if (marked.length < this._limit) {
191
+ marked.push(self);
192
+ } else {
193
+ marked[this._limit - 1] = self;
194
+ }
195
+
196
+ return marked;
197
+ }
77
198
  }
@@ -17,6 +17,8 @@ export default class StatView {
17
17
  const { elems, params } = config;
18
18
 
19
19
  this._stat = document.getElementById(elems.stat);
20
+ // контейнер списка глобального топа — только в режиме 'leaderboard'
21
+ this._leaderboard = null;
20
22
 
21
23
  // DOM scoreboard генерируется по схеме игры: произвольное число
22
24
  // команд (bodies) и колонок (columns)
@@ -31,12 +33,24 @@ export default class StatView {
31
33
  this._mPublic.on('tHead', 'updateTableHead', this);
32
34
  this._mPublic.on('tBody', 'updateTableBody', this);
33
35
  this._mPublic.on('clearBodies', 'clearBodies', this);
36
+ this._mPublic.on('leaderboard', 'updateLeaderboard', this);
34
37
  }
35
38
 
36
39
  // генерирует шапку и таблицы по схеме (замена хардкода stat.pug)
37
40
  _buildStat(params) {
38
41
  const { columns, bodies } = params;
39
42
 
43
+ // snakes-v3 этап 4: режим 'leaderboard' — один список глобального топа
44
+ // вместо шапки и таблиц команд. Строки рисует updateLeaderboard, здесь
45
+ // заводится только контейнер
46
+ if (params.mode === 'leaderboard') {
47
+ this._leaderboard = document.createElement('div');
48
+ this._leaderboard.className = 'stat-leaderboard';
49
+ this._stat.appendChild(this._leaderboard);
50
+
51
+ return;
52
+ }
53
+
40
54
  const head = document.createElement('div');
41
55
 
42
56
  head.className = 'stat-head';
@@ -77,6 +91,35 @@ export default class StatView {
77
91
  this._stat.appendChild(tables);
78
92
  }
79
93
 
94
+ // перерисовывает список глобального топа (режим 'leaderboard'):
95
+ // место · ник · очки, своя строка помечена is-self. Список короткий и
96
+ // приходит целиком — точечное обновление строк тут не окупается
97
+ updateLeaderboard(rows) {
98
+ if (!this._leaderboard) {
99
+ return;
100
+ }
101
+
102
+ this._leaderboard.textContent = '';
103
+
104
+ for (const row of rows) {
105
+ const line = document.createElement('div');
106
+
107
+ line.className = row.isSelf ? 'stat-row is-self' : 'stat-row';
108
+
109
+ // неранжированный за период игрок места не имеет — прочерк вместо
110
+ // числа честнее, чем выдуманная позиция
111
+ for (const value of [row.place ?? '—', row.nick, row.score]) {
112
+ const cell = document.createElement('span');
113
+
114
+ // String(): ноль очков — это «0», а не пустая ячейка
115
+ cell.textContent = String(value);
116
+ line.appendChild(cell);
117
+ }
118
+
119
+ this._leaderboard.appendChild(line);
120
+ }
121
+ }
122
+
80
123
  // открывает статистику
81
124
  open() {
82
125
  this._stat.style.display = 'block';
@@ -0,0 +1,56 @@
1
+ // Сервис пула зависимостей `accolades` (snakes-v3 этап 4): всё, что комната
2
+ // знает о глобальном топе игры.
3
+ //
4
+ // Отвечает на три вопроса:
5
+ // placeOf(id) — какое место у этой сущности в топе-10 (для part'а,
6
+ // который рисует за место знак);
7
+ // boardOf(period) — сам топ строками (для режима stat 'leaderboard');
8
+ // selfOf(id, period) — место и очки самого игрока, чтобы он видел свою
9
+ // строку, даже когда в топ не входит.
10
+ //
11
+ // ЗА ТОПОМ ХОДИТ ХОСТ, А НЕ КЛИЕНТ. Места и таблицу считает
12
+ // host/meta/modules/Accolades.js по тому же публичному топу, что рисует
13
+ // лобби, и шлёт их портом ACCOLADES_DATA только когда они изменились. Клиент
14
+ // в матче не обращается к мастеру вовсе: игрок разговаривает со своим игровым
15
+ // сервером. На целевом масштабе (100 игр × 100 серверов × 8 игроков) разница
16
+ // между «спрашивает каждый клиент» и «спрашивает комната» — четыре порядка на
17
+ // самом дорогом запросе схемы, см. комментарий в хостовом модуле.
18
+ //
19
+ // Движок не знает, что игра нарисует за место: он раздаёт числа, а знак —
20
+ // дело part'а.
21
+ //
22
+ // Ключи приходят строками (gameId на стороне движка — строка), поэтому
23
+ // placeOf/selfOf приводят запрошенный id к строке: у part'а id экземпляра
24
+ // может быть числом ключа кадра.
25
+ export function createAccolades() {
26
+ let places = {};
27
+ let boards = {};
28
+ let self = {};
29
+
30
+ return {
31
+ // применяет рассылку хоста целиком: она всегда полная, а не дельта
32
+ apply(data) {
33
+ places = data?.places || {};
34
+ boards = data?.boards || {};
35
+ self = data?.self || {};
36
+ },
37
+
38
+ // { daily, monthly } — всегда объект: незнакомый id (бот, гость,
39
+ // сущность без игрока) не в топе, и это не аварийный случай
40
+ placeOf(id) {
41
+ return places[String(id)] || { daily: null, monthly: null };
42
+ },
43
+
44
+ // [{ place, nick, score }] среза ('day'/'month'); пустой массив, пока
45
+ // первая рассылка не пришла — это нормальное состояние, а не сбой
46
+ boardOf(period) {
47
+ return boards[period] || [];
48
+ },
49
+
50
+ // { place, score } игрока в срезе или null: место есть только у
51
+ // ранжированного за период участника с проверенной личностью
52
+ selfOf(id, period) {
53
+ return self[String(id)]?.[period] ?? null;
54
+ },
55
+ };
56
+ }
@@ -35,6 +35,7 @@ import { getBootConfig, resolveBootConfig } from './boot.js';
35
35
  import { ensureGameShell, ensureCanvas } from './views/gameShell.js';
36
36
  import { createContextTracker } from './lib/contextTracker.js';
37
37
  import { createLocalPlayer } from './lib/localPlayer.js';
38
+ import { createAccolades } from './lib/accolades.js';
38
39
  import { createDebugApi, debugLog, DEBUG_PREFIX } from './debug.js';
39
40
  import { buildClientCoreConfig } from '../lib/clientCoreConfig.js';
40
41
  import {
@@ -181,6 +182,7 @@ const PS_STAT_DATA = wsports.server.STAT_DATA;
181
182
  const PS_CHAT_DATA = wsports.server.CHAT_DATA;
182
183
  const PS_VOTE_DATA = wsports.server.VOTE_DATA;
183
184
  const PS_KEYSET_DATA = wsports.server.KEYSET_DATA;
185
+ const PS_ACCOLADES_DATA = wsports.server.ACCOLADES_DATA;
184
186
 
185
187
  // PC (client ports): порты получения данных от клиента
186
188
  const PC_CONFIG_READY = wsports.client.CONFIG_READY;
@@ -279,6 +281,9 @@ let wasm = null;
279
281
  // сервис пула зависимостей: «эта сущность моя или чужая?». Ядро читается
280
282
  // геттером — оно создаётся позже пула сервисов (см. lib/localPlayer.js)
281
283
  const localPlayer = createLocalPlayer(() => clientCore);
284
+ // сервис пула зависимостей: «какое место у этой сущности в глобальном
285
+ // топе?». Места считает хост, part рисует за них знак (см. lib/accolades.js)
286
+ const accolades = createAccolades();
282
287
  let inputSeq = 0; // номер отправленного ввода (KEYS_DATA)
283
288
 
284
289
  // обратный индекс снапшот-схемы игры (CONFIG_DATA.snapshot):
@@ -376,6 +381,9 @@ socketMethods[PS_CONFIG_DATA] = async data => {
376
381
  // аргумент конструктора) со своим gameId. Игра без этого сервиса звучит
377
382
  // одинаково за всех — чужие подборы и чужие смерти вперемешку со своими
378
383
  localPlayer,
384
+ // места в глобальном топе: знак за место рисует part игры, движок
385
+ // раздаёт только числа (см. lib/accolades.js)
386
+ accolades,
379
387
  // база ассетов игры — тем же каналом, что и путь к звукам (см. выше):
380
388
  // картинки карт живут в пакете игры (dist/img/), движок их не раздаёт.
381
389
  // part, объявивший сервис в componentDependencies, строит URL сам —
@@ -639,6 +647,16 @@ socketMethods[PS_STAT_DATA] = data => {
639
647
  modules.stat.update(data);
640
648
  };
641
649
 
650
+ // accolades data: места участников в глобальном топе, сам топ и место
651
+ // игрока в нём. Рассылка приходит от ХОСТА и только когда что-то из этого
652
+ // изменилось — в матче клиент за топом к мастеру не ходит (см.
653
+ // client/lib/accolades.js)
654
+ socketMethods[PS_ACCOLADES_DATA] = data => {
655
+ accolades.apply(data);
656
+ // stat в режиме 'leaderboard' рисует ровно эту таблицу
657
+ modules.stat?.applyAccolades?.();
658
+ };
659
+
642
660
  // chat data
643
661
  socketMethods[PS_CHAT_DATA] = data => {
644
662
  modules.chat.add(data);
@@ -917,7 +935,13 @@ function runModules(data) {
917
935
  // Stat Module
918
936
  //==========================================//
919
937
 
920
- const statModel = new StatModel(statData.params);
938
+ // режим 'leaderboard' (snakes-v3 этап 4) рисует топ, привезённый хостом:
939
+ // ни одного запроса из матча — сервисы, а не сеть
940
+ const statModel = new StatModel(statData.params, {
941
+ accolades,
942
+ localPlayer,
943
+ getNick: () => lobbyAuthModel?.getNick() ?? null,
944
+ });
921
945
 
922
946
  // StatView генерирует шапку и таблицы по схеме игры ({ elems, params })
923
947
  const statView = new StatView(statModel, statData);
@@ -1789,7 +1813,13 @@ async function fetchServers({ offset, limit, search }) {
1789
1813
  }
1790
1814
 
1791
1815
  // топ-N рейтинга игры (lobby-page-plan) — публичный эндпоинт, доступен и до
1792
- // логина, поэтому без Authorization
1816
+ // логина, поэтому без Authorization.
1817
+ //
1818
+ // Зовётся только ЛОББИ. Из матча за топом ходит хост комнаты и раздаёт его
1819
+ // портом ACCOLADES_DATA (host/meta/modules/Accolades.js): 80 000 игроков,
1820
+ // спрашивающих мастер лично, и 10 000 комнат, спрашивающих за них, — разные
1821
+ // порядки величин. Там же живёт и If-None-Match: валидатор имеет смысл
1822
+ // рядом с повторяющимся запросом, а лобби открывают по одному разу
1793
1823
  async function fetchLeaderboard(gameId, period) {
1794
1824
  const params = new URLSearchParams({
1795
1825
  game: gameId,
@@ -1798,7 +1828,6 @@ async function fetchLeaderboard(gameId, period) {
1798
1828
  // значение всегда из lobbyConfig.leaderboardPeriods
1799
1829
  period,
1800
1830
  });
1801
-
1802
1831
  try {
1803
1832
  const res = await fetch(`${lobbyConfig.leaderboardUrl}?${params}`);
1804
1833
 
@@ -1811,7 +1840,7 @@ async function fetchLeaderboard(gameId, period) {
1811
1840
  // позиция вызывающего в рейтинге игры (lobby-page-plan) — требует identity-
1812
1841
  // токена, как и остальные /auth/* запросы игрока (rank/state)
1813
1842
  async function fetchPlacement(gameId, period) {
1814
- const token = lobbyAuthModel.getToken();
1843
+ const token = lobbyAuthModel?.getToken();
1815
1844
 
1816
1845
  if (!token) {
1817
1846
  return null;
@@ -286,6 +286,25 @@ body.hide-cursor {
286
286
  text-align: right;
287
287
  }
288
288
 
289
+ /* snakes-v3 этап 4: режим stat 'leaderboard' — не таблицы комнаты, а один
290
+ список глобального топа. Движок даёт только каркас (три колонки:
291
+ место, ник, очки), внешний вид игра доопределяет своим ClientPlugin.styles */
292
+
293
+ #stat .stat-leaderboard {
294
+ width: 92%;
295
+ margin: 3% auto;
296
+ }
297
+
298
+ #stat .stat-row {
299
+ display: grid;
300
+ grid-template-columns: 15% 1fr 20%;
301
+ padding: 1%;
302
+ }
303
+
304
+ #stat .stat-row span:nth-child(3) {
305
+ text-align: right;
306
+ }
307
+
289
308
  /*vote*/
290
309
 
291
310
  #vote {
@@ -44,12 +44,76 @@ export default {
44
44
  stateUrl: '/auth/state',
45
45
  },
46
46
 
47
+ // синхронизация профилей участников с мастером (snakes-v3 этап 3):
48
+ // пределами записи в БД владеет движок, а не игра — «игр сотни, серверов
49
+ // сотни», и одна игра, зовущая flushPlayerData() каждую секунду, не должна
50
+ // мочь положить auth-сервис. Игра может попросить синхронизацию, но не
51
+ // может участить её сверх minFlushInterval (кроме срочных границ:
52
+ // уход участника и destroy() комнаты)
53
+ playerData: {
54
+ // результат игры (PUT { points, best }); GET рангов больше не ходит
55
+ // сюда — три среза приезжают одним запросом на placementsUrl
56
+ rankUrl: '/auth/rank',
57
+ stateUrl: '/auth/state',
58
+ // агрегирующий роут мастера: { day, month, all } за один поход хоста
59
+ placementsUrl: '/auth/placements',
60
+ // точечный перезапрос одного среза (refreshPlacement)
61
+ placementUrl: '/auth/placement',
62
+ // ***** ОТКУДА ЭТО ЧИСЛО *****
63
+ //
64
+ // Целевой масштаб — 100 игр × 100 серверов × 8 игроков = 80 000 игроков
65
+ // одновременно. Участник со свежим результатом стоит двух запросов
66
+ // (PUT rank + PUT state) за интервал, поэтому весь мир пишет
67
+ //
68
+ // 80 000 × 2 / интервал.
69
+ //
70
+ // 60 с → 2700 запросов/с — на каждый пишущая транзакция, и это без
71
+ // единого всплеска: столько auth-сервис держать не обязан;
72
+ // 300 с → 530 запросов/с — с запасом, и запас этот нужен на всплески
73
+ // (конец раунда синхронизирует комнату целиком).
74
+ //
75
+ // Платится за это СВЕЖЕСТЬЮ ГЛОБАЛЬНЫХ рейтингов, и только их: очки
76
+ // склеиваются в памяти комнаты (сумма складывается, максимум берётся
77
+ // максимумом — finishGame), поэтому не теряется ничего, а свои значения
78
+ // игрок видит сразу, локально. Срочные границы (уход участника, destroy
79
+ // комнаты) интервал обходят, так что «ушёл и потерял» тоже не про это.
80
+ minFlushInterval: 300000, // мс на участника
81
+ flushJitter: 0.2, // ±20 % на комнату: сотни серверов по круглому таймеру
82
+ // потолок очереди запросов комнаты. Держится СТРОГО НИЖЕ потолка
83
+ // мастера (master:playerData:writesPerMinute / 60 = 2/с): очередь должна
84
+ // тормозить сама, а не через 429 — отказ стоит round-trip'а и уводит в
85
+ // бэкофф всю комнату, то есть задерживает и тех, кто ни при чём.
86
+ // Комната на 32 при интервале в 5 минут выпускает 64 запроса — при 1/с
87
+ // это чуть больше минуты, вчетверо быстрее следующего интервала
88
+ maxRequestsPerSecond: 1,
89
+ // Пауза комнаты после 5xx/429/сетевого сбоя, экспоненциальная. Обе
90
+ // границы соразмерны minFlushInterval, и это не украшение: потолок НИЖЕ
91
+ // интервала не значил бы ничего — обычный flush и так ждёт интервал,
92
+ // пауза короче него не отложила бы ни одного запроса, и бэкофф оказался
93
+ // бы мёртвым кодом. Поэтому 30 с (заметно, но не наказание за одну
94
+ // осечку) → 15 минут (втрое дольше интервала: лежащий сервис комната
95
+ // перестаёт трогать почти совсем)
96
+ backoff: { baseMs: 30000, maxMs: 900000 },
97
+ placementTtl: 30000, // троттлинг refreshPlacement, мс
98
+ },
99
+
47
100
  // рейтинг игры (lobby-page-plan): публичный топ-N и позиция вызывающего,
48
101
  // проксируемые мастером под тем же origin — правки CSP не нужны
49
102
  leaderboardUrl: '/auth/leaderboard',
50
103
  placementUrl: '/auth/placement',
51
104
  leaderboardLimit: 10,
52
105
 
106
+ // награды за место в глобальном топе (snakes-v3 этап 4): хост комнаты
107
+ // периодически спрашивает тот же публичный топ, что рисует лобби, и
108
+ // рассылает участникам их места. Награда про игрока, а не про комнату,
109
+ // поэтому источник глобальный, а сопоставление — по нику. Запрос идёт с
110
+ // If-None-Match: неизменившийся топ стоит 304 и ни одного обращения к БД
111
+ accolades: {
112
+ refreshInterval: 45000, // мс между опросами топа
113
+ // срезы, за которыми ходит хост: ключ ответа -> ?period=
114
+ periods: { daily: 'day', monthly: 'month' },
115
+ },
116
+
53
117
  // rank-periods: срезы рейтинга и тот, что открыт по умолчанию. Порядок
54
118
  // здесь — порядок кнопок; `id` едет в auth как ?period=, `title` идёт в
55
119
  // заголовок списка. Значения должны совпадать с RANK_PERIODS auth-сервиса:
@@ -29,6 +29,10 @@ export default {
29
29
  // пакетами @vimp-games/* из node_modules (master/localGames.js), чтобы
30
30
  // прилинкованная игра попадала в лобби без правки этого массива: он
31
31
  // публикуется вместе с пакетом vimp-engine
32
+ // maxGameScore (snakes-v3 этап 3.3, необязательное поле рядом с
33
+ // id/package) — потолок результата ОДНОЙ игры для этой игры; мастер
34
+ // клампит им `best`/`points` PUT /auth/rank. Не задан — дефолт
35
+ // master:playerData:maxGameScore
32
36
  games: [{ id: 'tanks', package: '@vimp-games/tanks' }],
33
37
 
34
38
  // список серверов (GET /servers)
@@ -60,6 +64,32 @@ export default {
60
64
  maxLimit: 100,
61
65
  },
62
66
 
67
+ // GET /auth/placement + агрегирующий GET /auth/placements (snakes-v3
68
+ // этап 3.3): место меняется медленно, а каждый вход участника стоит трёх
69
+ // срезов. Кэш здесь про round-trip до auth; стоимость самого запроса
70
+ // снята на стороне auth (RankDistribution)
71
+ placement: {
72
+ cacheTtl: 30000,
73
+ },
74
+
75
+ // пределы записи профилей в БД (snakes-v3 этап 3, решение пользователя 9):
76
+ // «игр сотни, серверов сотни» — минимальный интервал держит движок на
77
+ // стороне хоста, а мастер держит потолок для сломанного или злонамеренного
78
+ // сервера, который этот интервал обошёл
79
+ playerData: {
80
+ // PUT /auth/rank + /auth/state на комнату (проверенный hostId) в минуту.
81
+ // Честная комната на 32 при lobbyConfig.playerData.minFlushInterval в
82
+ // 5 минут пишет 64 запроса за эти 5 минут, то есть ~13/мин; остальное —
83
+ // запас на срочные границы (уход участника обходит интервал), и его
84
+ // хватает даже комнате, полностью сменившей состав дважды за минуту.
85
+ // Потолок держит не честную комнату, а сломанную или злонамеренную,
86
+ // поэтому запас считается от честной, а не «пусть будет побольше»
87
+ writesPerMinute: 120,
88
+ // потолок результата ОДНОЙ игры, если игра не объявила свой
89
+ // (master:games[].maxGameScore): обоснование — plan/snakes-v3/stage_2.md
90
+ maxGameScore: 10000,
91
+ },
92
+
63
93
  // рейтинг хостера комнаты (server-rating этап 2, plan/server-rating/
64
94
  // stage_2.md): /like·/unlike гостей комнаты вместо соц-модерации /ban.
65
95
  // Дефолт движка для всех игр; auth-сервис хранит и клампит рейтинг
@@ -9,7 +9,9 @@
9
9
  // в plugin-api.md), движок больше не выводит контролы из типа значения
10
10
  // v3: набор control сокращён до нативных элементов — 'select'|'text'|
11
11
  // 'checkbox'|'radio' (были ещё 'range'/'number'/'toggle'/'segmented')
12
- export const ENGINE_API_VERSION = 3;
12
+ // v4: порт ACCOLADES_DATA (места участников в глобальном топе) и пятый
13
+ // сервис пула зависимостей клиента (`accolades`) — snakes-v3 этап 4
14
+ export const ENGINE_API_VERSION = 4;
13
15
 
14
16
  // версия формата кадра: первый байт после порта;
15
17
  // увеличивать при любом изменении байтовой раскладки в ядре
@@ -19,6 +19,9 @@ export default {
19
19
  CHAT_DATA: 15,
20
20
  VOTE_DATA: 16,
21
21
  KEYSET_DATA: 17,
22
+ // места участников в глобальном топе (snakes-v3 этап 4): косметика
23
+ // для parts игры, в снапшот не идёт
24
+ ACCOLADES_DATA: 18,
22
25
  },
23
26
  // порты получения данных от клиента
24
27
  client: {
@@ -0,0 +1,60 @@
1
+ import { ERROR, skip, verdict } from '../result.js';
2
+
3
+ // Режим статистики по Tab объявляется ДВАЖДЫ, и обе половины обязаны
4
+ // совпадать (snakes-v3 этап 4):
5
+ //
6
+ // gameConfig.statMode — читает хост (host/HostGame.js);
7
+ // modules.stat.params.mode — читает клиент (client/…/model/Stat.js).
8
+ //
9
+ // Рассинхронизация не падает и ничем себя не выдаёт, поэтому её ловит
10
+ // правило, а не рантайм:
11
+ //
12
+ // объявлен только клиент — хост каждый тик собирает и рассылает таблицу
13
+ // комнаты, которую клиент выбрасывает первой же строкой update();
14
+ // объявлен только хост — хост не шлёт таблицу вовсе, а клиент рисует ту
15
+ // самую таблицу комнаты и она навсегда остаётся пустой.
16
+ //
17
+ // Первое стоит трафика, второе — пустого экрана; оба варианта — дефект
18
+ // конфигурации, а не выбор.
19
+ export default {
20
+ id: 'C11',
21
+ name: 'statMode',
22
+ level: ERROR,
23
+ title: 'gameConfig.statMode and modules.stat.params.mode agree',
24
+
25
+ check(ctx) {
26
+ const params = ctx.clientConfig?.modules?.stat?.params;
27
+
28
+ if (!params || !ctx.gameConfig) {
29
+ return skip('no client stat params');
30
+ }
31
+
32
+ const client = params.mode ?? null;
33
+ const host = ctx.gameConfig.statMode ?? null;
34
+
35
+ if (client === host) {
36
+ return verdict([], client ? `both declare '${client}'` : 'the room table');
37
+ }
38
+
39
+ if (client && !host) {
40
+ return verdict([
41
+ `modules.stat.params.mode is '${client}', but gameConfig.statMode is ` +
42
+ 'not set: the host keeps broadcasting the room table every tick and ' +
43
+ 'the client throws every one of those messages away',
44
+ ]);
45
+ }
46
+
47
+ if (host && !client) {
48
+ return verdict([
49
+ `gameConfig.statMode is '${host}', but modules.stat.params.mode is ` +
50
+ 'not set: the host sends no stat at all and the client draws the ' +
51
+ 'room table, which stays empty forever',
52
+ ]);
53
+ }
54
+
55
+ return verdict([
56
+ `gameConfig.statMode is '${host}' and modules.stat.params.mode is ` +
57
+ `'${client}': the two halves of the same decision disagree`,
58
+ ]);
59
+ },
60
+ };
@@ -1,13 +1,20 @@
1
1
  import { ERROR, WARN, skip, verdict } from '../result.js';
2
2
 
3
- // Движковых сервисов ровно четыре (client/main.js), но пул ими не
3
+ // Движковых сервисов ровно пять (client/main.js), но пул ими не
4
4
  // исчерпывается: игра доливает туда свои через ClientPlugin.hooks.services(core)
5
5
  // — например геометрию предсказанной динамики карты
6
6
  // (docs/en/plugin-api.md «hooks.services»). Незнакомое имя не ошибка для
7
7
  // движка: part получает undefined и рисует пустоту — карта без assetsBase
8
8
  // выглядит как чистый холст без единой строки в консоли, ради этого правило
9
9
  // и существует.
10
- const SERVICES = ['renderer', 'soundManager', 'assetsBase', 'localPlayer'];
10
+ const SERVICES = [
11
+ 'renderer',
12
+ 'soundManager',
13
+ 'assetsBase',
14
+ 'localPlayer',
15
+ // места в глобальном топе (snakes-v3 этап 4)
16
+ 'accolades',
17
+ ];
11
18
 
12
19
  export default {
13
20
  id: 'C4',