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.
@@ -67,6 +67,19 @@ function styledColumns(styles, total) {
67
67
  return covered;
68
68
  }
69
69
 
70
+ // Режим 'leaderboard' (snakes-v3 этап 4): движок даёт списку только
71
+ // каркас, внешний вид — дело игры. Достаточно любого правила плагина про
72
+ // `.stat-leaderboard` — раскладка колонок там на контейнере, а не на ячейках
73
+ function styledLeaderboard(styles) {
74
+ for (const [, selector] of String(styles || '').matchAll(RULE)) {
75
+ if (selector.includes('.stat-leaderboard')) {
76
+ return true;
77
+ }
78
+ }
79
+
80
+ return false;
81
+ }
82
+
70
83
  export default {
71
84
  id: 'C6',
72
85
  name: 'statColumns',
@@ -74,12 +87,27 @@ export default {
74
87
  title: 'stat columns past the engine layout are styled by the plugin',
75
88
 
76
89
  check(ctx) {
77
- const columns = ctx.clientConfig?.modules?.stat?.params?.columns;
90
+ const params = ctx.clientConfig?.modules?.stat?.params;
91
+ const columns = params?.columns;
78
92
 
79
93
  if (!columns) {
80
94
  return skip('no client stat columns');
81
95
  }
82
96
 
97
+ // snakes-v3 этап 4: в режиме 'leaderboard' движковых таблиц нет вовсе —
98
+ // рисуется один список `.stat-leaderboard`, и число колонок движковой
99
+ // раскладке ничего не говорит. Проверять здесь надо другое: привезла
100
+ // ли игра свои стили под этот список
101
+ if (params.mode === 'leaderboard') {
102
+ return styledLeaderboard(ctx.clientPlugin?.styles)
103
+ ? verdict([], 'leaderboard list, laid out by the plugin\'s own styles')
104
+ : verdict([
105
+ "stat declares mode 'leaderboard', but ClientPlugin.styles says " +
106
+ 'nothing about #stat .stat-leaderboard: the engine CSS gives ' +
107
+ 'the list only a bare three-column skeleton',
108
+ ]);
109
+ }
110
+
83
111
  // меньше пяти — движковых ширин просто хватает с запасом
84
112
  if (columns.length <= ENGINE_COLUMNS) {
85
113
  return verdict([]);
@@ -25,6 +25,7 @@ import c7 from './c7-key-sets.js';
25
25
  import c8 from './c8-baked-assets.js';
26
26
  import c9 from './c9-chat-messages.js';
27
27
  import c10 from './c10-auth-schema.js';
28
+ import c11 from './c11-stat-mode.js';
28
29
  import d1 from './d1-snapshot-ids.js';
29
30
  import d2 from './d2-snapshot-classes.js';
30
31
  import d3 from './d3-snapshot-interp.js';
@@ -37,7 +38,7 @@ import e3 from './e3-sound-registry.js';
37
38
  export const rules = [
38
39
  a1, a2, a3, a4, a5, a6, a7,
39
40
  b1, b2, b3, b4, b5, b6, b7, b8, b9, b10,
40
- c1, c2, c3, c4, c5, c6, c7, c8, c9, c10,
41
+ c1, c2, c3, c4, c5, c6, c7, c8, c9, c10, c11,
41
42
  d1, d2, d3,
42
43
  e1, e2, e3,
43
44
  ];
@@ -1,4 +1,5 @@
1
1
  import Panel from './meta/modules/Panel.js';
2
+ import Accolades from './meta/modules/Accolades.js';
2
3
  import PlayerDataSync from './meta/modules/PlayerDataSync.js';
3
4
  import Stat from './meta/modules/Stat.js';
4
5
  import Chat from './meta/modules/chat/index.js';
@@ -117,6 +118,10 @@ export default class HostGame {
117
118
  // endlessRound — раунд не перезапускается сам. Флаги независимы
118
119
  this._noSpectators = data.noSpectators === true;
119
120
  this._endlessRound = data.endlessRound === true;
121
+ // statMode — чем игра занимает экран по Tab. 'leaderboard' значит, что
122
+ // клиент рисует глобальный топ, который тянет сам: движковый stat в
123
+ // этом режиме никто не рисует, и платить за его рассылку незачем
124
+ this._statLeaderboard = data.statMode === 'leaderboard';
120
125
  this._spectatorTeam = this._noSpectators ? null : data.spectatorTeam;
121
126
  this._spectatorId = this._noSpectators
122
127
  ? null
@@ -146,6 +151,18 @@ export default class HostGame {
146
151
  // мастера) — подменяется только там, в проде остаётся глобальный fetch
147
152
  ...(playerDataFetch ? { fetchImpl: playerDataFetch } : {}),
148
153
  });
154
+ // места участников в глобальном топе (snakes-v3 этап 4): косметика для
155
+ // parts игры, движок не знает, какой знак игра нарисует за место
156
+ this._accolades = new Accolades({
157
+ participants: this._participants,
158
+ gameId: this._gameId,
159
+ // место и очки самого участника: их привозит PlayerDataSync на входе,
160
+ // и без них игрок вне топа-10 не увидел бы по Tab собственной строки
161
+ getRating: (id, period) => this._playerDataSync.getRating(id, period),
162
+ // headless-прогону некуда ходить за топом — тот же подмен, что и у
163
+ // PlayerDataSync
164
+ ...(playerDataFetch ? { fetchImpl: playerDataFetch } : {}),
165
+ });
149
166
  this._chat = new Chat();
150
167
  this._vote = new Vote();
151
168
 
@@ -344,9 +361,20 @@ export default class HostGame {
344
361
  removedPlayersList.pop();
345
362
  }
346
363
 
364
+ // фоновый опрос глобального топа: сам решает, пора ли (refreshInterval)
365
+ this._accolades.tick();
366
+
347
367
  const userList = this._participants.getNetworkedReady();
348
368
  const panelUpdates = this._panel.processUpdates();
349
- const stat = this._stat.getLast();
369
+ // getLast() ЕДИНСТВЕННЫЙ дренаж Stat: reset() в _lastBody только
370
+ // дописывает. Поэтому буфер осушается всегда, даже когда рассылки не
371
+ // будет: пропуск вызова в режиме leaderboard растил бы эти массивы всё
372
+ // время жизни комнаты (endlessRound — она живёт часами), а StatBridge
373
+ // пишет в них на каждый подобранный кристалл. Гейтится ОТПРАВКА
374
+ const lastStat = this._stat.getLast();
375
+ // в режиме leaderboard движковый stat не рисуется вовсе — не шлём
376
+ const stat = this._statLeaderboard ? null : lastStat;
377
+ const accolades = this._accolades.shift();
350
378
  const chat = this._chat.shift();
351
379
  const vote = this._vote.shift();
352
380
 
@@ -421,6 +449,10 @@ export default class HostGame {
421
449
  this._socketManager.sendStat(socketId, stat);
422
450
  }
423
451
 
452
+ if (accolades) {
453
+ this._socketManager.sendAccolades(socketId, accolades);
454
+ }
455
+
424
456
  const chatUser = chat || this._chat.shiftByUser(gameId);
425
457
  if (chatUser) {
426
458
  this._socketManager.sendChat(socketId, chatUser);
@@ -510,6 +542,20 @@ export default class HostGame {
510
542
  user.isReady = true;
511
543
  this._socketManager.sendTechInform(socketId);
512
544
  this._socketManager.sendFirstVote(socketId);
545
+
546
+ // топ и места в нём — целиком и лично, как и первый кадр stat.
547
+ // Периодическая рассылка (shift() в игровом цикле) уходит только тем,
548
+ // кто УЖЕ готов, а места новичка посчитаны на его входе, за всю загрузку
549
+ // карты до этой строки: та рассылка ушла бы без него и не повторилась
550
+ // бы — места с тех пор не менялись, — и ни знак, ни таблица по Tab не
551
+ // появились бы до первого чужого входа. В комнате, куда больше никто не
552
+ // заходит, никогда
553
+ const accoladesNow = this._accolades.current();
554
+
555
+ if (Object.keys(accoladesNow.places).length) {
556
+ this._socketManager.sendAccolades(socketId, accoladesNow);
557
+ }
558
+
513
559
  this._chat.pushSystem('USER_JOINED', [user.name]);
514
560
 
515
561
  // noSpectators: голосования за вход нет — участник уже в играющей
@@ -643,7 +689,11 @@ export default class HostGame {
643
689
  // иначе removeUser стартует второй flush с той же накопленной дельтой
644
690
  // (двойной зачёт рейтинга), а destroy разрешился бы раньше, чем эти
645
691
  // запросы уйдут
646
- await this._playerDataSync.flushAll();
692
+ // незакрытые игры участников закрываются здесь: их очки иначе не
693
+ // попали бы ни в сумму, ни в максимум и пропали бы вместе с комнатой
694
+ // (правило 7 snakes-v3 этап 3 — запись гарантирована в destroy())
695
+ this._playerDataSync.finishAllGames();
696
+ await this._playerDataSync.flushAll({ urgent: true });
647
697
 
648
698
  // getAll() отдаёт копию — снятие внутри цикла реестр не ломает
649
699
  for (const user of this._participants.getAll()) {
@@ -863,6 +913,11 @@ export default class HostGame {
863
913
  // вход; сбой auth-сервиса оставляет участника с дефолтами
864
914
  this._playerDataSync.load(gameId, params.token);
865
915
 
916
+ // место новичка в глобальном топе: знак должен появиться сразу, а не
917
+ // на следующем периодическом опросе. Это пересчёт по уже известным
918
+ // срезам, а не поход за ними — за топом ходит только tick()
919
+ this._accolades.noteRoster();
920
+
866
921
  queueMicrotask(() => {
867
922
  cb(gameId);
868
923
  });
@@ -886,9 +941,12 @@ export default class HostGame {
886
941
  this._vote.removeUser(gameId);
887
942
  this._panel.removeUser(gameId);
888
943
 
889
- // финальная синхронизация rank/state перед уходом участника (Этап B4)
944
+ // финальная синхронизация профиля перед уходом участника (Этап B4):
945
+ // незавершённая игра закрывается — второго шанса записать её очки не
946
+ // будет, — и интервал синхронизации эта граница обходит (urgent)
947
+ this._playerDataSync.finishGame(gameId);
890
948
  this._playerDataSync
891
- .flush(gameId)
949
+ .flush(gameId, { urgent: true })
892
950
  .catch(err =>
893
951
  console.warn('[playerData] final flush failed:', err.message),
894
952
  )
@@ -1058,11 +1116,44 @@ export default class HostGame {
1058
1116
 
1059
1117
  // прибавка к рангу для игр, которые не эмитят CoreEvent::Death и потому
1060
1118
  // никогда не проходят через RoundManager.reportKill: прямая прокладка к
1061
- // PlayerDataSync, без раунд-логики и без проверки team-wipe
1119
+ // PlayerDataSync, без раунд-логики и без проверки team-wipe.
1120
+ // @deprecated snakes-v3: алиас addPlayerPoints
1062
1121
  addPlayerRank(gameId, delta) {
1063
1122
  this._playerDataSync.addRank(gameId, delta);
1064
1123
  }
1065
1124
 
1125
+ // ***** результат игры (snakes-v3 этап 3) ***** //
1126
+
1127
+ // очки ТЕКУЩЕЙ игры участника: жизнь, раунд, матч — что игра называет
1128
+ // игрой. В рейтинги они попадают только на finishPlayerGame
1129
+ addPlayerPoints(gameId, delta) {
1130
+ this._playerDataSync.addPoints(gameId, delta);
1131
+ }
1132
+
1133
+ // игра участника закончилась: накопленные очки уходят в сумму (месячный
1134
+ // рейтинг) и в максимум (дневной). Игра без раундов зовёт это сама — у
1135
+ // игры с раундами обе границы закрывает RoundManager
1136
+ finishPlayerGame(gameId) {
1137
+ this._playerDataSync.finishGame(gameId);
1138
+ }
1139
+
1140
+ // значения среза для показа: { value, placement, total } или null
1141
+ getPlayerRating(gameId, period) {
1142
+ return this._playerDataSync.getRating(gameId, period);
1143
+ }
1144
+
1145
+ // приехал ли СРЕЗ с мастера: ноль незагруженного рейтинга и настоящий
1146
+ // ноль — разные вещи, и игре, которая на них смотрит, надо их различать
1147
+ isPlayerRatingLoaded(gameId, period) {
1148
+ return this._playerDataSync.isRatingLoaded(gameId, period);
1149
+ }
1150
+
1151
+ // точечный перезапрос места в срезе (чат-команда /rank): место меняют
1152
+ // чужие игры, локально его не пересчитать. Троттлинг — в PlayerDataSync
1153
+ refreshPlayerPlacement(gameId, period) {
1154
+ return this._playerDataSync.refreshPlacement(gameId, period);
1155
+ }
1156
+
1066
1157
  // синхронизация профилей (rank/state) всех участников на мастер прямо
1067
1158
  // сейчас — для игр, у которых нет ни конца раунда, ни смены карты
1068
1159
  // (`endlessRound` + карта, пересобираемая через overrideMapData): обе
@@ -1072,8 +1163,13 @@ export default class HostGame {
1072
1163
  //
1073
1164
  // Best-effort, как и весь PlayerDataSync: промис не отвергается, сбой
1074
1165
  // логируется в `[playerData]` и повторится следующим flush'ем.
1075
- flushPlayerData() {
1076
- return this._playerDataSync.flushAll();
1166
+ //
1167
+ // snakes-v3 этап 3: это просьба, а не команда — участник, у которого с
1168
+ // прошлой синхронизации прошло меньше lobbyConfig.playerData.
1169
+ // minFlushInterval, пропускается. Срочные границы (уход участника,
1170
+ // destroy комнаты) интервал обходят, игре они недоступны
1171
+ flushPlayerData({ urgent = false } = {}) {
1172
+ return this._playerDataSync.flushAll({ urgent });
1077
1173
  }
1078
1174
 
1079
1175
  // hostId + per-room секрет комнаты, подтверждённые мастером при
@@ -39,6 +39,7 @@ export default class SocketManager {
39
39
  this._PORT_CHAT_DATA = ports.CHAT_DATA;
40
40
  this._PORT_VOTE_DATA = ports.VOTE_DATA;
41
41
  this._PORT_KEYSET_DATA = ports.KEYSET_DATA;
42
+ this._PORT_ACCOLADES_DATA = ports.ACCOLADES_DATA;
42
43
 
43
44
  this._soundCues = soundCues;
44
45
  this._initialVote = initialVote;
@@ -337,6 +338,16 @@ export default class SocketManager {
337
338
  this._send(socketId, this._PORT_STAT_DATA, data);
338
339
  }
339
340
 
341
+ /**
342
+ * Отправка мест участников в глобальном топе (snakes-v3 этап 4). Движок
343
+ * раздаёт числа, знак за место рисует part игры.
344
+ * @param {string} socketId
345
+ * @param {Object} data - { [gameId]: { daily, monthly } }
346
+ */
347
+ sendAccolades(socketId, data) {
348
+ this._send(socketId, this._PORT_ACCOLADES_DATA, data);
349
+ }
350
+
340
351
  /**
341
352
  * Отправка сообщения чата.
342
353
  * @param {string} socketId
@@ -146,8 +146,11 @@ class RoundManager {
146
146
  this._stat.reset();
147
147
  this._voteCoordinator.reset();
148
148
 
149
- // синхронизация накопленных rank/state на мастер перед сменой карты
150
- // (Этап B4) — естественная граница жизненного цикла
149
+ // синхронизация накопленных профилей на мастер перед сменой карты
150
+ // (Этап B4) — естественная граница жизненного цикла. Смена карты
151
+ // заканчивает игру каждого участника (snakes-v3 этап 3): без этого у
152
+ // игры с раундами дневной максимум навсегда остался бы нулём
153
+ this._playerDataSync?.finishAllGames?.();
151
154
  this._playerDataSync?.flushAll();
152
155
 
153
156
  this._snapshotManager.reset();
@@ -696,8 +699,10 @@ class RoundManager {
696
699
  );
697
700
  }
698
701
 
699
- // синхронизация накопленных rank/state на мастер по итогам раунда
700
- // (Этап B4) — естественная граница жизненного цикла
702
+ // синхронизация накопленных профилей на мастер по итогам раунда
703
+ // (Этап B4) — естественная граница жизненного цикла. Конец раунда и
704
+ // есть конец игры участника (snakes-v3 этап 3)
705
+ this._playerDataSync?.finishAllGames?.();
701
706
  this._playerDataSync?.flushAll();
702
707
 
703
708
  if (winnerTeam) {
@@ -0,0 +1,283 @@
1
+ import lobbyConfig from '../../../config/lobby.js';
2
+ import { nickKey } from '../../../lib/validators.js';
3
+
4
+ // Кто из участников комнаты сейчас в глобальном топ-10 — дневном и
5
+ // месячном (snakes-v3 этап 4). Источник — тот же публичный топ, что рисует
6
+ // лобби, поэтому награда одинакова на любом сервере: она про игрока, а не
7
+ // про комнату. Движок при этом не знает, ЧТО игра нарисует за место, — он
8
+ // раздаёт только числа, а знак рисует part игры.
9
+ //
10
+ // Ник глобально уникален (users.nick в auth) и это единственный ключ, по
11
+ // которому глобальный топ сопоставим с участником комнаты. Боты и гости в
12
+ // топ не попадают никогда — у них нет записи в auth, их место null, и это
13
+ // нормальный, а не аварийный случай.
14
+ //
15
+ // Запросы идут с If-None-Match: топ меняется медленно, совпал валидатор —
16
+ // 304 без тела, схлопнутый TTL-кэшем мастера на всю сеть.
17
+ //
18
+ // ***** ЗА ТОПОМ ХОДИТ КОМНАТА, А НЕ ИГРОК *****
19
+ //
20
+ // Тот же топ рисуется клиенту по Tab (`modules.stat.params.mode:
21
+ // 'leaderboard'`), и рисуется он ИЗ ЭТОЙ РАССЫЛКИ, а не собственным запросом
22
+ // клиента. Так это и должно быть: игрок разговаривает со своим игровым
23
+ // сервером, а не с мастером. Арифметика на целевом масштабе (100 игр × 100
24
+ // серверов × 8 игроков = 80 000 игроков) объясняет, почему это не вкусовщина:
25
+ //
26
+ // клиент сам — 80 000 игроков / 15 с троттлинга = 5300 запросов/с,
27
+ // причём место игрока (`/auth/placement`) персонально и
28
+ // общим кэшем мастера не схлопывается вовсе;
29
+ // комната за всех — 10 000 комнат × 2 среза / 45 с = 440 запросов/с, и все
30
+ // они попадают в один TTL-кэш мастера на (игру, срез),
31
+ // то есть в БД доходит 100 игр × 2 среза за TTL.
32
+ //
33
+ // Разница — четыре порядка на самом дорогом запросе схемы.
34
+ export default class Accolades {
35
+ // fetchImpl обёрнут стрелкой по той же причине, что и в PlayerDataSync:
36
+ // голый `fetch` из поля объекта вызывался бы с чужим `this` и падал на
37
+ // brand-check ещё до сети
38
+ constructor({
39
+ participants,
40
+ gameId,
41
+ // место и очки самого участника в срезе: их привозит PlayerDataSync на
42
+ // входе игрока (GET /auth/placements). Нужны для строки «я» в списке —
43
+ // игрок вне топа-10 видит собственное место вместо десятой строки
44
+ getRating = () => null,
45
+ fetchImpl = (...args) => fetch(...args),
46
+ config = lobbyConfig,
47
+ now = () => Date.now(),
48
+ }) {
49
+ this._participants = participants;
50
+ this._gameId = gameId;
51
+ this._getRating = getRating;
52
+ this._fetch = fetchImpl;
53
+ this._url = config.leaderboardUrl;
54
+ this._limit = config.leaderboardLimit;
55
+ this._interval = config.accolades.refreshInterval;
56
+ this._periods = config.accolades.periods;
57
+ this._now = now;
58
+
59
+ // последний известный топ каждого среза: ключ награды -> Map(ник в
60
+ // нижнем регистре -> место). 304 оставляет прошлый
61
+ this._tops = new Map();
62
+ // и он же строками, как их рисует клиент: ключ СРЕЗА ('day'/'month') ->
63
+ // [{ place, nick, score }]. Два представления одного ответа: по нику
64
+ // ищут знаки, по порядку — таблица
65
+ this._boards = {};
66
+ // ETag прошлого ответа среза — валидатор следующего запроса
67
+ this._etags = new Map();
68
+
69
+ this._payload = { places: {}, boards: {}, self: {} };
70
+ // слепок ПУСТОЙ рассылки, а не '{}': иначе первый же _recompute в
71
+ // пустой комнате считал бы её изменившейся и слал бы пустоту
72
+ this._serialized = JSON.stringify(this._payload);
73
+ this._dirty = false;
74
+
75
+ // null, а не 0: первый опрос обязан пройти, каким бы ни было начало
76
+ // отсчёта времени
77
+ this._lastRefreshAt = null;
78
+ this._inFlight = false;
79
+ }
80
+
81
+ // периодический опрос: зовётся каждый игровой тик, работает не чаще
82
+ // refreshInterval. Промис наружу не отдаётся — это фон, а не шаг кадра, —
83
+ // но именно поэтому он обязан быть пойман здесь: непойманный reject в
84
+ // игровом цикле воркера кладёт комнату целиком
85
+ tick() {
86
+ this.refresh().catch(err =>
87
+ console.warn('[accolades] tick failed:', err?.message),
88
+ );
89
+ }
90
+
91
+ // состав комнаты изменился (вход участника): места пересчитываются
92
+ // ЛОКАЛЬНО — срезы уже лежат в _tops, и ходить за ними ради новичка
93
+ // незачем. Ходили бы: по два запроса на каждый вход, а при уже летящем
94
+ // опросе новичок ещё и терялся бы до следующего refreshInterval, потому
95
+ // что _inFlight глушит вызов целиком
96
+ noteRoster() {
97
+ this._recompute();
98
+ }
99
+
100
+ async refresh() {
101
+ const now = this._now();
102
+
103
+ if (this._inFlight) {
104
+ return;
105
+ }
106
+
107
+ if (this._lastRefreshAt !== null && now - this._lastRefreshAt < this._interval) {
108
+ return;
109
+ }
110
+
111
+ this._lastRefreshAt = now;
112
+ this._inFlight = true;
113
+
114
+ try {
115
+ await Promise.all(
116
+ Object.entries(this._periods).map(([award, period]) =>
117
+ this._load(award, period),
118
+ ),
119
+ );
120
+ this._recompute();
121
+ } finally {
122
+ this._inFlight = false;
123
+ }
124
+ }
125
+
126
+ // Текущая рассылка целиком — для участника, который ТОЛЬКО ЧТО стал готов
127
+ // принимать данные. Рассылка через shift() ему не досталась бы: она
128
+ // уходит только тем, кто уже в getNetworkedReady(), а места новичка
129
+ // считаются на входе, за всю загрузку карты до его готовности. Один раз
130
+ // отданный shift() второй раз не повторится (места с тех пор не менялись),
131
+ // и ни знак, ни таблица не появились бы до первого чужого входа
132
+ current() {
133
+ return this._payload;
134
+ }
135
+
136
+ // Рассылка или null, если с прошлого вызова ничего не изменилось. Обычно
137
+ // именно null — и сообщения не будет вовсе.
138
+ //
139
+ // places — { [gameId]: { daily, monthly } }: место участника в топе-10
140
+ // каждого среза или null. По ним part игры рисует знак;
141
+ // boards — { day: [{ place, nick, score }], month: [...] }: сам топ, как
142
+ // его рисует по Tab режим stat 'leaderboard';
143
+ // self — { [gameId]: { day: { place, score }, month: {...} } }: место и
144
+ // очки самого участника, чтобы игрок ВНЕ топа видел свою строку.
145
+ //
146
+ // Всё три пересчитываются только в _recompute (опрос раз в refreshInterval
147
+ // и вход участника), поэтому очки в `self` отстают от только что законченной
148
+ // игры не больше чем на один интервал опроса. Это осознанно: они меняются с
149
+ // каждой смертью, и рассылать их сразу значило бы слать сообщение на каждую
150
+ // смерть каждого из восьми — ровно то, что «не изменилось — не отправляем»
151
+ // и запрещает. Свой ТЕКУЩИЙ счёт игрок и так видит на HUD
152
+ shift() {
153
+ if (!this._dirty) {
154
+ return null;
155
+ }
156
+
157
+ this._dirty = false;
158
+
159
+ return this._payload;
160
+ }
161
+
162
+ async _load(award, period) {
163
+ const query = new URLSearchParams({
164
+ game: this._gameId,
165
+ limit: this._limit,
166
+ period,
167
+ });
168
+ const etag = this._etags.get(award);
169
+
170
+ try {
171
+ const res = await this._fetch(`${this._url}?${query}`, {
172
+ headers: etag ? { 'if-none-match': etag } : {},
173
+ });
174
+
175
+ // 304 — топ тот же: прошлый срез остаётся как есть
176
+ if (res.status === 304) {
177
+ return;
178
+ }
179
+
180
+ if (!res.ok) {
181
+ console.warn(`[accolades] GET leaderboard ${res.status} (${period})`);
182
+
183
+ return;
184
+ }
185
+
186
+ const { leaderboard } = (await res.json()) ?? {};
187
+ const places = new Map();
188
+ const rows = [];
189
+
190
+ for (const row of leaderboard ?? []) {
191
+ // уникальность ника в auth регистронезависимая (миграция 002) —
192
+ // сопоставлять надо так же, иначе «Alice» и «alice» разъедутся
193
+ if (row?.nick) {
194
+ places.set(nickKey(row.nick), Number(row.place));
195
+ rows.push({
196
+ place: Number(row.place),
197
+ nick: row.nick,
198
+ score: Number(row.rank) || 0,
199
+ });
200
+ }
201
+ }
202
+
203
+ this._tops.set(award, places);
204
+ this._boards[period] = rows;
205
+ this._etags.set(award, res.headers?.get?.('etag') ?? null);
206
+ } catch (err) {
207
+ // недоступность мастера — остаёмся на прошлых местах: пропавший на
208
+ // одну минуту знак хуже, чем чуть устаревший
209
+ console.warn(`[accolades] refresh failed (${period}):`, err.message);
210
+ }
211
+ }
212
+
213
+ _recompute() {
214
+ const places = {};
215
+ const self = {};
216
+ // срезы, за которыми ходит хост, в терминах auth ('day'/'month') — по
217
+ // ним же клиент выбирает таблицу под свой stat.params.period
218
+ const periods = Object.values(this._periods);
219
+
220
+ for (const participant of this._participants.getAll()) {
221
+ const entry = {};
222
+ const gameId = String(participant.gameId);
223
+
224
+ for (const award of Object.keys(this._periods)) {
225
+ entry[award] = null;
226
+ }
227
+
228
+ // Знак — только участнику с проверенной личностью. В лобби это ничего
229
+ // не меняет: там `name` и есть claim проверенного identity-токена
230
+ // (host/identity.js: createTokenIdentity.resolve возвращает
231
+ // payload.nick, и PortMachine передаёт в createUser именно его), а не
232
+ // то, что игрок написал в форме.
233
+ //
234
+ // Меняет это в гостевом контуре (standalone/dedicated), где ник —
235
+ // поле формы и «не защищён от подмены» по прямому признанию
236
+ // createGuestIdentity. Токена у гостя нет, и сопоставление по нику
237
+ // выдало бы ему чужую корону — стоит она ровно того, что значит.
238
+ if (participant.token) {
239
+ const nick = nickKey(participant.name);
240
+
241
+ for (const award of Object.keys(this._periods)) {
242
+ entry[award] = this._tops.get(award)?.get(nick) ?? null;
243
+ }
244
+
245
+ // место и очки этого участника в каждом срезе: их привёз
246
+ // PlayerDataSync на входе. Игрок вне топа-10 видит по Tab свою
247
+ // строку вместо десятой, и взять её больше неоткуда — топ его не
248
+ // содержит по определению
249
+ const mine = {};
250
+
251
+ for (const period of periods) {
252
+ const rating = this._getRating(gameId, period);
253
+
254
+ if (rating) {
255
+ mine[period] = {
256
+ place: rating.placement ?? null,
257
+ score: rating.value ?? 0,
258
+ };
259
+ }
260
+ }
261
+
262
+ if (Object.keys(mine).length) {
263
+ self[gameId] = mine;
264
+ }
265
+ }
266
+
267
+ places[gameId] = entry;
268
+ }
269
+
270
+ const payload = { places, boards: this._boards, self };
271
+ const serialized = JSON.stringify(payload);
272
+
273
+ // «не изменилось — не отправляем»: и 304, и неизменившийся состав
274
+ // комнаты оставляют рассылку пустой
275
+ if (serialized === this._serialized) {
276
+ return;
277
+ }
278
+
279
+ this._serialized = serialized;
280
+ this._payload = payload;
281
+ this._dirty = true;
282
+ }
283
+ }