vimp-engine 0.20.0 → 0.22.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.
@@ -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
+ }