vimp-engine 0.19.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.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -27,6 +27,7 @@ export default class LobbyCtrl {
27
27
  this._model = model;
28
28
  this._view = view;
29
29
  this._clock = clock;
30
+ this._periods = [];
30
31
 
31
32
  this.publisher = new Publisher();
32
33
 
@@ -37,6 +38,12 @@ export default class LobbyCtrl {
37
38
  vp.on('visible', 'pingHost', this);
38
39
  vp.on('join', 'join', this);
39
40
  vp.on('show-tab', 'showTab', this);
41
+ vp.on('show-period', 'showPeriod', this);
42
+
43
+ // активная игра и открытый срез рейтинга (rank-periods): запрос за
44
+ // лидербордом всегда несёт обе координаты, а меняется каждый раз одна
45
+ this._gameId = null;
46
+ this._period = null;
40
47
  }
41
48
 
42
49
  // показывает лобби и запрашивает первую страницу
@@ -69,13 +76,47 @@ export default class LobbyCtrl {
69
76
  this._view.showTab(tab);
70
77
  }
71
78
 
79
+ // срез рейтинга: подсветить кнопку и перезапросить список. Данных за день
80
+ // и за месяц у клиента нет — это другой ответ сервера, а не другая
81
+ // сортировка того же (rank-periods)
82
+ showPeriod(period) {
83
+ if (period === this._period) {
84
+ return;
85
+ }
86
+
87
+ this._period = period;
88
+ this._view.setPeriod(period, this._titleOf(period));
89
+
90
+ if (this._gameId !== null) {
91
+ this.publisher.emit('leaderboard-needed', {
92
+ gameId: this._gameId,
93
+ period,
94
+ });
95
+ }
96
+ }
97
+
98
+ // подпись среза для заголовка списка; неизвестный id остаётся без подписи
99
+ _titleOf(period) {
100
+ return this._periods.find(item => item.id === period)?.title ?? '';
101
+ }
102
+
72
103
  // вызывается main.js при смене #lobby-game (селектор игры вне этого MVC),
73
104
  // а также один раз при открытии лобби для активной по умолчанию игры —
74
105
  // Leaderboard всегда готов заранее, до того как пользователь откроет
75
106
  // вкладку (см. класс-комментарий выше)
76
107
  gameChanged(gameId, title) {
108
+ this._gameId = gameId;
77
109
  this._view.setGameTitle(title);
78
110
 
79
- this.publisher.emit('leaderboard-needed', gameId);
111
+ this.publisher.emit('leaderboard-needed', { gameId, period: this._period });
112
+ }
113
+
114
+ // срезы рейтинга и открытый по умолчанию (main.js передаёт их из
115
+ // lobbyConfig): контроллер не знает их наизусть, чтобы список срезов
116
+ // правился в конфиге, а не здесь
117
+ setPeriods(periods, defaultPeriod) {
118
+ this._periods = periods || [];
119
+ this._period = defaultPeriod;
120
+ this._view.setPeriod(defaultPeriod, this._titleOf(defaultPeriod));
80
121
  }
81
122
  }
@@ -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
  }
@@ -36,6 +36,15 @@ export default class LobbyView {
36
36
  this._leaderboardTotal = document.getElementById(elems.leaderboardTotalId);
37
37
  this._myPlacement = document.getElementById(elems.myPlacementId);
38
38
 
39
+ // кнопки срезов рейтинга (rank-periods): id периода -> элемент. Кнопки
40
+ // объявлены конфигом, а не захардкожены здесь, чтобы срез можно было
41
+ // убрать или добавить в одном месте
42
+ this._periodBtns = new Map(
43
+ Object.entries(elems.periodBtnIds || {})
44
+ .map(([period, id]) => [period, document.getElementById(id)])
45
+ .filter(([, el]) => el),
46
+ );
47
+
39
48
  // футер лобби: содержимое статично на всё время жизни вкладки, поэтому
40
49
  // пишется здесь, а не через модель и publisher
41
50
  const version = document.getElementById(elems.versionId);
@@ -56,6 +65,10 @@ export default class LobbyView {
56
65
  // игроков, чем лимит)
57
66
  this._leaderboardLimit = 0;
58
67
 
68
+ // заголовок открытого среза ("<TITLE> TOP-N — TODAY"): подпись, а не
69
+ // источник правды, срез хранит модель
70
+ this._periodTitle = '';
71
+
59
72
  // ник вызывающего (lobby-page-plan, code review M4-остаток) — задаётся
60
73
  // main.js один раз при открытии лобби; используется, чтобы решить,
61
74
  // виден ли вызывающий уже в отрисованном топе (по нику, не по числу
@@ -86,6 +99,10 @@ export default class LobbyView {
86
99
  this._tabServersBtn.onclick = () => this.publisher.emit('show-tab', 'servers');
87
100
  this._tabLeaderboardBtn.onclick = () => this.publisher.emit('show-tab', 'leaderboard');
88
101
 
102
+ for (const [period, btn] of this._periodBtns) {
103
+ btn.onclick = () => this.publisher.emit('show-period', period);
104
+ }
105
+
89
106
  this._mPublic = model.publisher;
90
107
  this._mPublic.on('list', 'renderList', this);
91
108
  this._mPublic.on('ping-update', 'updatePing', this);
@@ -118,6 +135,17 @@ export default class LobbyView {
118
135
  this._selfNick = nick || '';
119
136
  }
120
137
 
138
+ // подсвечивает открытый срез рейтинга и запоминает его подпись
139
+ // (rank-periods). Рисовать список здесь нечем: данные среза приезжают
140
+ // отдельным запросом, и до ответа список чистится моделью
141
+ setPeriod(period, title) {
142
+ this._periodTitle = title || '';
143
+
144
+ for (const [id, btn] of this._periodBtns) {
145
+ btn.classList.toggle('active', id === period);
146
+ }
147
+ }
148
+
121
149
  // переключает вкладки Active Servers / Leaderboard (lobby-page-plan)
122
150
  showTab(tab) {
123
151
  const showLeaderboard = tab === 'leaderboard';
@@ -130,7 +158,10 @@ export default class LobbyView {
130
158
 
131
159
  // рендер топ-N + позиции вызывающего (lobby-page-plan)
132
160
  renderLeaderboard({ leaderboard, total, myPlacement, loaded = true }) {
133
- this._leaderboardTitle.textContent = `${this._gameTitle.toUpperCase()} TOP-${this._leaderboardLimit}`;
161
+ const period = this._periodTitle ? ` — ${this._periodTitle}` : '';
162
+
163
+ this._leaderboardTitle.textContent =
164
+ `${this._gameTitle.toUpperCase()} TOP-${this._leaderboardLimit}${period}`;
134
165
  this._leaderboardTotal.textContent = `Total: ${total} players`;
135
166
 
136
167
  this._leaderboardList.textContent = '';
@@ -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,13 +1813,21 @@ async function fetchServers({ offset, limit, search }) {
1789
1813
  }
1790
1814
 
1791
1815
  // топ-N рейтинга игры (lobby-page-plan) — публичный эндпоинт, доступен и до
1792
- // логина, поэтому без Authorization
1793
- async function fetchLeaderboard(gameId) {
1816
+ // логина, поэтому без Authorization.
1817
+ //
1818
+ // Зовётся только ЛОББИ. Из матча за топом ходит хост комнаты и раздаёт его
1819
+ // портом ACCOLADES_DATA (host/meta/modules/Accolades.js): 80 000 игроков,
1820
+ // спрашивающих мастер лично, и 10 000 комнат, спрашивающих за них, — разные
1821
+ // порядки величин. Там же живёт и If-None-Match: валидатор имеет смысл
1822
+ // рядом с повторяющимся запросом, а лобби открывают по одному разу
1823
+ async function fetchLeaderboard(gameId, period) {
1794
1824
  const params = new URLSearchParams({
1795
1825
  game: gameId,
1796
1826
  limit: lobbyConfig.leaderboardLimit,
1827
+ // rank-periods: срез времени. Мастер отвечает 400 на незнакомый —
1828
+ // значение всегда из lobbyConfig.leaderboardPeriods
1829
+ period,
1797
1830
  });
1798
-
1799
1831
  try {
1800
1832
  const res = await fetch(`${lobbyConfig.leaderboardUrl}?${params}`);
1801
1833
 
@@ -1807,8 +1839,8 @@ async function fetchLeaderboard(gameId) {
1807
1839
 
1808
1840
  // позиция вызывающего в рейтинге игры (lobby-page-plan) — требует identity-
1809
1841
  // токена, как и остальные /auth/* запросы игрока (rank/state)
1810
- async function fetchPlacement(gameId) {
1811
- const token = lobbyAuthModel.getToken();
1842
+ async function fetchPlacement(gameId, period) {
1843
+ const token = lobbyAuthModel?.getToken();
1812
1844
 
1813
1845
  if (!token) {
1814
1846
  return null;
@@ -1816,7 +1848,9 @@ async function fetchPlacement(gameId) {
1816
1848
 
1817
1849
  try {
1818
1850
  const res = await fetch(
1819
- `${lobbyConfig.placementUrl}?${new URLSearchParams({ game: gameId })}`,
1851
+ // тот же срез, что и у списка рядом: плашка позиции, посчитанная за
1852
+ // всё время под заголовком «сегодня», противоречила бы списку
1853
+ `${lobbyConfig.placementUrl}?${new URLSearchParams({ game: gameId, period })}`,
1820
1854
  { headers: { authorization: `Bearer ${token}` } },
1821
1855
  );
1822
1856
 
@@ -1954,6 +1988,13 @@ function initLobby() {
1954
1988
 
1955
1989
  lobby = new LobbyCtrl(lobbyModel, lobbyView);
1956
1990
 
1991
+ // срезы рейтинга (rank-periods) — сразу после создания контроллера и до
1992
+ // первого gameChanged: тот уже несёт открытый срез в запросе
1993
+ lobby.setPeriods(
1994
+ lobbyConfig.leaderboardPeriods,
1995
+ lobbyConfig.defaultLeaderboardPeriod,
1996
+ );
1997
+
1957
1998
  // список серверов — REST-запросом к мастеру
1958
1999
  lobbyModel.publisher.on('fetch', async query => {
1959
2000
  const list = await fetchServers(query);
@@ -1989,17 +2030,17 @@ function initLobby() {
1989
2030
  // под заголовком новой, пока не пришёл ответ (и остаются навсегда при
1990
2031
  // сетевом сбое); reqId отбрасывает ответ устаревшего запроса, если игру
1991
2032
  // переключили быстрее, чем пришёл ответ (latest-wins)
1992
- lobby.publisher.on('leaderboard-needed', async gameId => {
2033
+ lobby.publisher.on('leaderboard-needed', async ({ gameId, period }) => {
1993
2034
  lobbyModel.clearLeaderboard();
1994
2035
 
1995
2036
  const reqId = ++leaderboardReqId;
1996
2037
  const [leaderboard, placement] = await Promise.all([
1997
- fetchLeaderboard(gameId),
1998
- fetchPlacement(gameId),
2038
+ fetchLeaderboard(gameId, period),
2039
+ fetchPlacement(gameId, period),
1999
2040
  ]);
2000
2041
 
2001
2042
  if (reqId !== leaderboardReqId) {
2002
- return; // игру уже переключили ещё раз — этот ответ устарел
2043
+ return; // игру или срез уже переключили ещё раз — ответ устарел
2003
2044
  }
2004
2045
 
2005
2046
  if (leaderboard) {
@@ -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 {
@@ -908,6 +927,32 @@ body.hide-cursor {
908
927
  border-bottom: 2px solid #fff;
909
928
  }
910
929
 
930
+ /* срезы рейтинга (rank-periods): день/месяц/всё время. Ряд под вкладками
931
+ правой панели — это выбор ДАННЫХ, а не вкладка, поэтому кнопки выглядят
932
+ как переключатель, а не как заголовок раздела */
933
+ .lobby-period-tabs {
934
+ display: flex;
935
+ gap: 4px;
936
+ margin-bottom: 10px;
937
+ }
938
+
939
+ .lobby-period-btn {
940
+ flex: 1 1 0;
941
+ background: rgb(255 255 255 / 6%);
942
+ border: none;
943
+ color: #888;
944
+ font-size: 12px;
945
+ font-weight: bold;
946
+ padding: 5px 4px;
947
+ margin: 0;
948
+ text-shadow: none;
949
+ }
950
+
951
+ .lobby-period-btn.active {
952
+ background: rgb(255 255 255 / 18%);
953
+ color: #fff;
954
+ }
955
+
911
956
  .lobby-leaderboard-header {
912
957
  display: flex;
913
958
  justify-content: space-between;
@@ -50,6 +50,12 @@ div#lobby(style='display:none')
50
50
  p#lobby-empty= 'No servers available'
51
51
  input#lobby-more(type='button', value='Load more')
52
52
  div#lobby-leaderboard-content(style='display:none')
53
+ //- срез рейтинга (rank-periods): день/месяц считаются из леджера
54
+ //- дельт, all-time — из кэша ratings
55
+ div.lobby-period-tabs
56
+ input#btn-period-day.lobby-period-btn(type='button', value='Daily')
57
+ input#btn-period-month.lobby-period-btn(type='button', value='Monthly')
58
+ input#btn-period-all.lobby-period-btn.active(type='button', value='All-Time')
53
59
  div.lobby-leaderboard-header
54
60
  span#leaderboard-title
55
61
  span#leaderboard-total