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 +1 -1
- package/src/client/components/controller/Lobby.js +42 -1
- package/src/client/components/model/Stat.js +123 -2
- package/src/client/components/view/Lobby.js +32 -1
- package/src/client/components/view/Stat.js +43 -0
- package/src/client/lib/accolades.js +56 -0
- package/src/client/main.js +52 -11
- package/src/client/style.css +45 -0
- package/src/client/views/includes/lobby.pug +6 -0
- package/src/config/lobby.js +81 -0
- package/src/config/master.js +30 -0
- package/src/config/opcodes.js +3 -1
- package/src/config/wsports.js +3 -0
- package/src/devtools/contract/rules/c11-stat-mode.js +60 -0
- package/src/devtools/contract/rules/c4-component-dependencies.js +9 -2
- package/src/devtools/contract/rules/c6-stat-columns.js +29 -1
- package/src/devtools/contract/rules/index.js +2 -1
- package/src/host/HostGame.js +114 -5
- package/src/host/meta/SocketManager.js +11 -0
- package/src/host/meta/core/RoundManager.js +9 -4
- package/src/host/meta/modules/Accolades.js +283 -0
- package/src/host/meta/modules/PlayerDataSync.js +484 -85
- package/src/lib/validators.js +47 -0
|
@@ -1,21 +1,46 @@
|
|
|
1
1
|
import lobbyConfig from '../../../config/lobby.js';
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// подгружаются с мастера (прокси central
|
|
6
|
-
// синхронизируются обратно по естественным
|
|
7
|
-
// (RoundManager: смена карты, конец раунда).
|
|
8
|
-
// игра — здесь это чёрный ящик.
|
|
3
|
+
// Профиль участника (Этап B4 + snakes-v3 этап 3). Игрок приходит в игру с
|
|
4
|
+
// ником из JWT — рейтинги (три среза: day/month/all) и state (непрозрачный
|
|
5
|
+
// для движка JSON, "скиллы") подгружаются с мастера (прокси central
|
|
6
|
+
// auth-сервиса) при входе и синхронизируются обратно по естественным
|
|
7
|
+
// границам жизненного цикла (RoundManager: смена карты, конец раунда).
|
|
8
|
+
// Схему/дефолты state объявляет игра — здесь это чёрный ящик.
|
|
9
|
+
//
|
|
10
|
+
// snakes-v3 этап 3: движок знает общее для всех игр понятие «результат
|
|
11
|
+
// игры» — жизнь, раунд, матч, что бы игра ни называла игрой. Очки текущей
|
|
12
|
+
// игры копятся в `currentGamePoints`, `finishGame` переводит их в сумму
|
|
13
|
+
// (`pendingPoints`, месячный рейтинг) и максимум (`pendingBest`, дневной).
|
|
14
|
+
// Пределы записи в БД держит движок, а не игра: грязные флаги, один запрос
|
|
15
|
+
// в полёте на участника, минимальный интервал с джиттером, очередь с
|
|
16
|
+
// потолком запросов в секунду и бэкофф комнаты (см. lobbyConfig.playerData).
|
|
17
|
+
|
|
18
|
+
const PERIODS = ['day', 'month', 'all'];
|
|
19
|
+
|
|
9
20
|
export default class PlayerDataSync {
|
|
10
21
|
// дефолт fetchImpl обёрнут стрелкой, а не взят как голый `fetch`: из поля
|
|
11
22
|
// объекта он вызывался бы с `this` экземпляра, а в браузере/воркере у
|
|
12
23
|
// fetch brand-check на глобальный scope — это синхронный TypeError ещё до
|
|
13
24
|
// обращения к сети (тесты подставляют обычную функцию и мимо этого ходят)
|
|
14
|
-
constructor(
|
|
25
|
+
constructor(
|
|
26
|
+
gameId,
|
|
27
|
+
{
|
|
28
|
+
fetchImpl = (...args) => fetch(...args),
|
|
29
|
+
defaultState = {},
|
|
30
|
+
// now/sleep/random — впрыск для тестов: интервал, очередь и бэкофф
|
|
31
|
+
// это время, и проверять их реальными таймерами значит проверять их
|
|
32
|
+
// секундами ожидания
|
|
33
|
+
now = () => Date.now(),
|
|
34
|
+
sleep = ms => new Promise(resolve => setTimeout(resolve, ms)),
|
|
35
|
+
random = () => Math.random(),
|
|
36
|
+
} = {},
|
|
37
|
+
) {
|
|
15
38
|
this._gameId = gameId;
|
|
16
39
|
this._fetch = fetchImpl;
|
|
17
40
|
this._defaultState = defaultState;
|
|
18
|
-
this.
|
|
41
|
+
this._now = now;
|
|
42
|
+
this._sleep = sleep;
|
|
43
|
+
this._entries = new Map(); // participantId -> запись профиля (_makeEntry)
|
|
19
44
|
// hostId + per-room секрет комнаты (кодревью №1, plan/server-rating/
|
|
20
45
|
// review.md): неизвестны при создании (назначаются мастером на
|
|
21
46
|
// register_host, после запуска Worker'а) — проставляются позже через
|
|
@@ -24,6 +49,27 @@ export default class PlayerDataSync {
|
|
|
24
49
|
// hostId из тела напрямую (иначе можно было бы подставить чужую комнату)
|
|
25
50
|
this._hostId = null;
|
|
26
51
|
this._hostSecret = null;
|
|
52
|
+
|
|
53
|
+
const settings = lobbyConfig.playerData;
|
|
54
|
+
|
|
55
|
+
// джиттер считается один раз на комнату: сотни серверов, синхронно
|
|
56
|
+
// проснувшихся по круглому минутному таймеру, дают мастеру пик
|
|
57
|
+
this._flushInterval = Math.round(
|
|
58
|
+
settings.minFlushInterval * (1 + (random() * 2 - 1) * settings.flushJitter),
|
|
59
|
+
);
|
|
60
|
+
// вверх, а не как получится: слот, округлённый вниз, выпускал бы чуть
|
|
61
|
+
// больше запросов в секунду, чем объявленный потолок
|
|
62
|
+
this._slotMs = Math.ceil(1000 / settings.maxRequestsPerSecond);
|
|
63
|
+
// очередь комнаты: запросы уходят последовательно, не чаще одного за
|
|
64
|
+
// _slotMs — flush комнаты на 32 участника растягивается на секунды
|
|
65
|
+
// вместо залпа
|
|
66
|
+
this._queue = Promise.resolve();
|
|
67
|
+
this._nextSlotAt = 0;
|
|
68
|
+
// бэкофф комнаты: 5xx/429/сеть — экспоненциальная пауза, первый успех
|
|
69
|
+
// сбрасывает её. Без этого сотня серверов синхронно молотит лежащий
|
|
70
|
+
// сервис следующим же flush'ем
|
|
71
|
+
this._backoffMs = 0;
|
|
72
|
+
this._backoffUntil = 0;
|
|
27
73
|
}
|
|
28
74
|
|
|
29
75
|
// вызывается HostGame, когда мастер подтвердил регистрацию комнаты
|
|
@@ -33,8 +79,42 @@ export default class PlayerDataSync {
|
|
|
33
79
|
this._hostSecret = hostSecret;
|
|
34
80
|
}
|
|
35
81
|
|
|
36
|
-
|
|
37
|
-
return
|
|
82
|
+
_makeEntry(token) {
|
|
83
|
+
return {
|
|
84
|
+
token,
|
|
85
|
+
state: structuredClone(this._defaultState),
|
|
86
|
+
stateLoaded: false,
|
|
87
|
+
// сериализованный слепок последнего успешно отправленного state:
|
|
88
|
+
// «не изменилось — не отправляем» (решение пользователя 9)
|
|
89
|
+
lastSyncedState: null,
|
|
90
|
+
// значения на момент входа + локальные правки finishGame
|
|
91
|
+
ratings: Object.fromEntries(
|
|
92
|
+
PERIODS.map(period => [period, { value: 0, placement: null, total: 0 }]),
|
|
93
|
+
),
|
|
94
|
+
// ПО СРЕЗАМ, а не общим флагом: агрегирующий /auth/placements отдаёт
|
|
95
|
+
// 200, если приехал хоть один срез, и кладёт провалившийся как null.
|
|
96
|
+
// Общий флаг помечал бы такой срез загруженным нулём НАВСЕГДА — а
|
|
97
|
+
// ноль дневного среза игра читает как «рекорд побит» (см.
|
|
98
|
+
// isRatingLoaded)
|
|
99
|
+
ratingsLoaded: Object.fromEntries(PERIODS.map(period => [period, false])),
|
|
100
|
+
currentGamePoints: 0, // очки незавершённой игры
|
|
101
|
+
pendingPoints: 0, // сумма завершённых игр, ещё не отправленная
|
|
102
|
+
pendingBest: 0, // лучшая завершённая игра, ещё не отправленная
|
|
103
|
+
placementRefreshedAt: Object.fromEntries(PERIODS.map(period => [period, 0])),
|
|
104
|
+
inFlight: false,
|
|
105
|
+
// промис текущей серии flush: его отдают повторные вызовы, чтобы
|
|
106
|
+
// граница, которая ждёт записи (destroy, уход участника), ждала её
|
|
107
|
+
// по-настоящему
|
|
108
|
+
inFlightPromise: null,
|
|
109
|
+
flushAgain: false,
|
|
110
|
+
lastFlushAt: 0,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
_authedFetch(url, token, { method = 'GET', body, params } = {}) {
|
|
115
|
+
const query = new URLSearchParams({ game: this._gameId, ...params });
|
|
116
|
+
|
|
117
|
+
return this._fetch(`${url}?${query}`, {
|
|
38
118
|
method,
|
|
39
119
|
headers: {
|
|
40
120
|
authorization: `Bearer ${token}`,
|
|
@@ -44,90 +124,315 @@ export default class PlayerDataSync {
|
|
|
44
124
|
});
|
|
45
125
|
}
|
|
46
126
|
|
|
47
|
-
//
|
|
48
|
-
|
|
127
|
+
// очередь комнаты с потолком запросов в секунду (правило 5)
|
|
128
|
+
_enqueue(task) {
|
|
129
|
+
const run = this._queue.then(async () => {
|
|
130
|
+
const wait = this._nextSlotAt - this._now();
|
|
131
|
+
|
|
132
|
+
if (wait > 0) {
|
|
133
|
+
await this._sleep(wait);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
this._nextSlotAt = this._now() + this._slotMs;
|
|
137
|
+
|
|
138
|
+
return task();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// хвост очереди не должен обрываться на отказе задачи
|
|
142
|
+
this._queue = run.then(
|
|
143
|
+
() => {},
|
|
144
|
+
() => {},
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
return run;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 5xx/429/сетевой сбой — пауза комнаты (правило 6)
|
|
151
|
+
_noteFailure() {
|
|
152
|
+
const { baseMs, maxMs } = lobbyConfig.playerData.backoff;
|
|
153
|
+
|
|
154
|
+
this._backoffMs = Math.min(this._backoffMs ? this._backoffMs * 2 : baseMs, maxMs);
|
|
155
|
+
this._backoffUntil = this._now() + this._backoffMs;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
_noteSuccess() {
|
|
159
|
+
this._backoffMs = 0;
|
|
160
|
+
this._backoffUntil = 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// отказ отказу рознь: 4xx (кроме 429) — это «так и будет», повтор
|
|
164
|
+
// ничего не изменит и паузы комнаты не заслуживает
|
|
165
|
+
_noteStatus(status) {
|
|
166
|
+
if (status === 429 || status >= 500) {
|
|
167
|
+
this._noteFailure();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// подгружает рейтинги+state с мастера при входе игрока. Сбой auth-сервиса
|
|
172
|
+
// не должен блокировать вход — участник стартует с дефолтами (нули, пустой
|
|
49
173
|
// state) и попробует синхронизироваться на следующем flush.
|
|
50
|
-
//
|
|
51
|
-
// серверный
|
|
52
|
-
//
|
|
174
|
+
// ratingsLoaded (ПО СРЕЗАМ) и stateLoaded (F4 кодревью) отражают, был ли
|
|
175
|
+
// реально получен серверный ответ: flush не должен PUT'ить дефолтный state
|
|
176
|
+
// поверх настоящего сохранённого, если загрузка не удалась. Результат игры
|
|
177
|
+
// отправляется независимо от них — он строка в леджер, а не замещение
|
|
178
|
+
// (см. _sync)
|
|
53
179
|
async load(participantId, token) {
|
|
54
|
-
const entry = this._entries.get(participantId) ??
|
|
55
|
-
token,
|
|
56
|
-
rank: 0,
|
|
57
|
-
// server-rating этап 1: PUT /auth/rank шлёт дельту матча, не абсолют
|
|
58
|
-
// (auth ведёт леджер) — pendingRankDelta копит то, что ещё не flush'нуто
|
|
59
|
-
pendingRankDelta: 0,
|
|
60
|
-
state: structuredClone(this._defaultState),
|
|
61
|
-
rankLoaded: false,
|
|
62
|
-
stateLoaded: false,
|
|
63
|
-
};
|
|
180
|
+
const entry = this._entries.get(participantId) ?? this._makeEntry(token);
|
|
64
181
|
|
|
65
182
|
entry.token = token;
|
|
66
183
|
this._entries.set(participantId, entry);
|
|
67
184
|
|
|
68
185
|
try {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
186
|
+
// ***** ЗАГРУЗКА ИДЁТ МИМО ОЧЕРЕДИ, И ЭТО НАМЕРЕННО *****
|
|
187
|
+
//
|
|
188
|
+
// Очередь комнаты держит потолок ЗАПИСЕЙ: он и объявлен на записи
|
|
189
|
+
// (master:playerData:writesPerMinute считает только PUT rank/state), и
|
|
190
|
+
// растягивать по ней чтение значило бы платить за это входом — профиль
|
|
191
|
+
// участника приезжал бы через слот очереди после его появления, а в
|
|
192
|
+
// комнате на 32 последний ждал бы секунды. Чтение защищено там, где
|
|
193
|
+
// оно дорого: TTL-кэшем мастера (master/PlacementCache.js) и, с
|
|
194
|
+
// миграции 008, агрегатом вместо свёртки леджера.
|
|
195
|
+
//
|
|
196
|
+
// Что этот путь обязан делать — замечать отказ (см. ниже): срочный
|
|
197
|
+
// flush бэкофф обходит, а тянет за собой именно повторный load()
|
|
198
|
+
const [ratingsRes, stateRes] = await Promise.all([
|
|
199
|
+
this._authedFetch(lobbyConfig.playerData.placementsUrl, token),
|
|
200
|
+
this._authedFetch(lobbyConfig.playerData.stateUrl, token),
|
|
72
201
|
]);
|
|
73
202
|
|
|
74
|
-
// неуспешный ответ
|
|
75
|
-
//
|
|
76
|
-
// выключает
|
|
77
|
-
if (!
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
203
|
+
// неуспешный ответ логируется и ставит паузу комнаты: stateLoaded
|
|
204
|
+
// гейтит синхронизацию state, поэтому молчаливый 401/404 здесь
|
|
205
|
+
// навсегда выключает её и выглядит снаружи как «данных просто нет»
|
|
206
|
+
if (!ratingsRes.ok) {
|
|
207
|
+
this._noteStatus(ratingsRes.status);
|
|
208
|
+
console.warn(`[playerData] GET placements ${ratingsRes.status} for ${participantId}`);
|
|
209
|
+
} else {
|
|
210
|
+
const json = (await ratingsRes.json()) ?? {};
|
|
211
|
+
const at = this._now();
|
|
212
|
+
|
|
213
|
+
this._noteSuccess();
|
|
214
|
+
|
|
215
|
+
for (const period of PERIODS) {
|
|
216
|
+
const slice = json[period];
|
|
217
|
+
const rating = entry.ratings[period];
|
|
218
|
+
|
|
219
|
+
// срез уже загружен — второй load() (повтор после сбоя другого
|
|
220
|
+
// среза) не должен применять его снова: месяц СКЛАДЫВАЕТСЯ, и
|
|
221
|
+
// повторное применение удвоило бы его
|
|
222
|
+
if (entry.ratingsLoaded[period]) {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// null — этот срез не приехал (роут отдаёт 200, если приехал хоть
|
|
227
|
+
// один). Загруженным его помечать нельзя: следующий _sync
|
|
228
|
+
// повторит load() и доберёт именно его
|
|
229
|
+
if (!slice) {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const server = Number(slice.rank) || 0;
|
|
234
|
+
|
|
235
|
+
// F9: во время await мог накопиться finishGame поверх стартового
|
|
236
|
+
// нуля — перетирать его серверным значением нельзя. Но и складывать
|
|
237
|
+
// с ним можно не всегда: СРЕЗЫ АГРЕГИРУЮТСЯ ПО-РАЗНОМУ, ровно как в
|
|
238
|
+
// finishGame — день это МАКСИМУМ одной игры, месяц сумма, all-time
|
|
239
|
+
// локально не двигается вовсе (суточный снимок).
|
|
240
|
+
//
|
|
241
|
+
// Путь этот не только про гонку на входе: _sync повторяет load()
|
|
242
|
+
// после неудачной первой загрузки, и к тому моменту игрок успел
|
|
243
|
+
// доиграть несколько жизней — сложение дало бы дневному срезу
|
|
244
|
+
// сумму вместо максимума, и refreshPlacement её бы не исправил
|
|
245
|
+
// (при непустом pending он берёт Math.max с показанным)
|
|
246
|
+
rating.value =
|
|
247
|
+
period === 'month' ? rating.value + server : Math.max(rating.value, server);
|
|
248
|
+
rating.placement = slice.placement ?? null;
|
|
249
|
+
rating.total = Number(slice.total) || 0;
|
|
250
|
+
entry.placementRefreshedAt[period] = at;
|
|
251
|
+
entry.ratingsLoaded[period] = true;
|
|
252
|
+
}
|
|
84
253
|
}
|
|
85
254
|
|
|
86
255
|
if (!stateRes.ok) {
|
|
256
|
+
this._noteStatus(stateRes.status);
|
|
87
257
|
console.warn(`[playerData] GET state ${stateRes.status} for ${participantId}`);
|
|
88
258
|
} else if (!entry.stateLoaded) {
|
|
259
|
+
this._noteSuccess();
|
|
260
|
+
|
|
89
261
|
const { state } = await stateRes.json();
|
|
90
262
|
|
|
91
263
|
entry.state =
|
|
92
264
|
state && Object.keys(state).length ? state : structuredClone(this._defaultState);
|
|
93
265
|
entry.stateLoaded = true;
|
|
266
|
+
// то, что только что приехало, отправлять обратно незачем
|
|
267
|
+
entry.lastSyncedState = JSON.stringify(entry.state);
|
|
94
268
|
}
|
|
95
269
|
} catch (err) {
|
|
96
270
|
// недоступность auth-сервиса — остаёмся на дефолтах, следующий
|
|
97
|
-
// flush повторит load() перед синхронизацией (см. flush)
|
|
271
|
+
// flush повторит load() перед синхронизацией (см. flush).
|
|
272
|
+
//
|
|
273
|
+
// Пауза комнаты ставится и здесь: без неё лежащий сервис получал бы с
|
|
274
|
+
// этого пути полную нагрузку — срочный flush (уход участника, рекорд
|
|
275
|
+
// в игре) бэкофф обходит, а именно он и тянет за собой повторный
|
|
276
|
+
// load(), пока рейтинги не загружены
|
|
277
|
+
this._noteFailure();
|
|
98
278
|
console.warn(`[playerData] load failed for ${participantId}:`, err.message);
|
|
99
279
|
}
|
|
100
280
|
|
|
101
281
|
return entry;
|
|
102
282
|
}
|
|
103
283
|
|
|
104
|
-
|
|
105
|
-
|
|
284
|
+
// ***** рейтинги ***** //
|
|
285
|
+
|
|
286
|
+
// значения для показа: { value, placement, total } среза или null для
|
|
287
|
+
// незнакомого участника (и незнакомого периода)
|
|
288
|
+
getRating(participantId, period = 'all') {
|
|
289
|
+
const entry = this._entries.get(participantId);
|
|
290
|
+
|
|
291
|
+
if (!entry || !PERIODS.includes(period)) {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const { value, placement, total } = entry.ratings[period];
|
|
296
|
+
|
|
297
|
+
return { value, placement, total };
|
|
106
298
|
}
|
|
107
299
|
|
|
108
|
-
// приехал ли
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
300
|
+
// приехал ли СРЕЗ с мастера. getRating отдаёт нули и знакомому участнику,
|
|
301
|
+
// чей load() ещё не вернулся, — а игре разницу знать надо: ноль дневного
|
|
302
|
+
// среза значит «сегодня ещё не играл», и игра, сравнивающая с ним счёт
|
|
303
|
+
// законченной игры, прочитает «данных нет» как «побит рекорд» и потратит
|
|
304
|
+
// срочную запись на каждую смерть (src/host/StatBridge.js в snakes)
|
|
305
|
+
isRatingLoaded(participantId, period = 'all') {
|
|
306
|
+
return this._entries.get(participantId)?.ratingsLoaded[period] === true;
|
|
113
307
|
}
|
|
114
308
|
|
|
115
|
-
|
|
116
|
-
|
|
309
|
+
// все ли три среза приехали — внутренний вопрос _sync, не игры
|
|
310
|
+
_ratingsComplete(entry) {
|
|
311
|
+
return PERIODS.every(period => entry.ratingsLoaded[period]);
|
|
117
312
|
}
|
|
118
313
|
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
|
|
314
|
+
// точечный перезапрос одного среза (чат-команда /rank): место меняется от
|
|
315
|
+
// чужих игр, поэтому локальными значениями его не пересчитать. Троттлинг
|
|
316
|
+
// placementTtl — команду можно звать в чате сколько угодно часто
|
|
317
|
+
async refreshPlacement(participantId, period = 'all') {
|
|
318
|
+
const entry = this._entries.get(participantId);
|
|
319
|
+
|
|
320
|
+
if (!entry || !PERIODS.includes(period)) {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const now = this._now();
|
|
325
|
+
|
|
326
|
+
if (now - entry.placementRefreshedAt[period] < lobbyConfig.playerData.placementTtl) {
|
|
327
|
+
return this.getRating(participantId, period);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// метка ставится ДО запроса и на сбое не сбрасывается: это анти-флуд, а
|
|
331
|
+
// не кэш. `/rank` в чате может звать кто угодно и сколько угодно, и
|
|
332
|
+
// лежащий мастер не должен превращать это в отправку запроса на каждое
|
|
333
|
+
// сообщение — цена в том, что после сбоя игрок ждёт TTL
|
|
334
|
+
entry.placementRefreshedAt[period] = now;
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
const res = await this._enqueue(() =>
|
|
338
|
+
this._authedFetch(lobbyConfig.playerData.placementUrl, entry.token, {
|
|
339
|
+
params: { period },
|
|
340
|
+
}),
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
if (!res.ok) {
|
|
344
|
+
this._noteStatus(res.status);
|
|
345
|
+
console.warn(`[playerData] GET placement ${res.status} for ${participantId}`);
|
|
346
|
+
} else {
|
|
347
|
+
const { placement, total, rank } = (await res.json()) ?? {};
|
|
348
|
+
const rating = entry.ratings[period];
|
|
349
|
+
const server = Number(rank) || 0;
|
|
350
|
+
|
|
351
|
+
this._noteSuccess();
|
|
352
|
+
// всё отправленное — серверное значение и есть правда (в том числе
|
|
353
|
+
// ноль после смены суток); есть неотправленное — оно ещё не учтено
|
|
354
|
+
// сервером, и показывать меньше уже показанного нельзя
|
|
355
|
+
rating.value =
|
|
356
|
+
entry.pendingPoints > 0 || entry.pendingBest > 0
|
|
357
|
+
? Math.max(rating.value, server)
|
|
358
|
+
: server;
|
|
359
|
+
rating.placement = placement ?? null;
|
|
360
|
+
rating.total = Number(total) || 0;
|
|
361
|
+
}
|
|
362
|
+
} catch (err) {
|
|
363
|
+
this._noteFailure();
|
|
364
|
+
console.warn(`[playerData] placement failed for ${participantId}:`, err.message);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return this.getRating(participantId, period);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// очки ТЕКУЩЕЙ игры участника (жизнь, раунд, матч — что игра называет
|
|
371
|
+
// игрой). В сумму и в максимум они попадают только на finishGame
|
|
372
|
+
addPoints(participantId, delta) {
|
|
123
373
|
const entry = this._entries.get(participantId);
|
|
124
374
|
|
|
125
375
|
if (entry) {
|
|
126
|
-
entry.
|
|
127
|
-
|
|
376
|
+
entry.currentGamePoints += delta;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// @deprecated snakes-v3 этап 3: алиас addPoints. Зовётся
|
|
381
|
+
// RoundManager.reportKill (килл = 1 очко текущей игры) и старыми играми
|
|
382
|
+
// через HostGame.addPlayerRank
|
|
383
|
+
addRank(participantId, delta) {
|
|
384
|
+
this.addPoints(participantId, delta);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// игра участника закончилась: накопленное уходит в сумму (месяц) и в
|
|
388
|
+
// максимум (день)
|
|
389
|
+
finishGame(participantId) {
|
|
390
|
+
const entry = this._entries.get(participantId);
|
|
391
|
+
|
|
392
|
+
if (!entry || entry.currentGamePoints <= 0) {
|
|
393
|
+
// пустых записей в леджере не бывает: отрицательный счёт (огонь по
|
|
394
|
+
// своим) — это не результат, а ноль
|
|
395
|
+
if (entry) {
|
|
396
|
+
entry.currentGamePoints = 0;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const points = entry.currentGamePoints;
|
|
403
|
+
|
|
404
|
+
entry.currentGamePoints = 0;
|
|
405
|
+
entry.pendingPoints += points;
|
|
406
|
+
entry.pendingBest = Math.max(entry.pendingBest, points);
|
|
407
|
+
|
|
408
|
+
// локальные значения — чтобы игрок видел правду до синхронизации
|
|
409
|
+
entry.ratings.day.value = Math.max(entry.ratings.day.value, points);
|
|
410
|
+
entry.ratings.month.value += points;
|
|
411
|
+
// all-time НЕ двигаем: он суточный снимок (решение пользователя 5)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// «игра закончилась у всех» — границы раунда/карты (RoundManager) и
|
|
415
|
+
// закрытие комнаты
|
|
416
|
+
finishAllGames() {
|
|
417
|
+
for (const participantId of this._entries.keys()) {
|
|
418
|
+
this.finishGame(participantId);
|
|
128
419
|
}
|
|
129
420
|
}
|
|
130
421
|
|
|
422
|
+
// ***** совместимость со старыми играми (all-срез) ***** //
|
|
423
|
+
|
|
424
|
+
getRank(participantId) {
|
|
425
|
+
return this._entries.get(participantId)?.ratings.all.value ?? 0;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
isRankLoaded(participantId) {
|
|
429
|
+
return this.isRatingLoaded(participantId);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
getState(participantId) {
|
|
433
|
+
return this._entries.get(participantId)?.state ?? this._defaultState;
|
|
434
|
+
}
|
|
435
|
+
|
|
131
436
|
setState(participantId, state) {
|
|
132
437
|
const entry = this._entries.get(participantId);
|
|
133
438
|
|
|
@@ -140,69 +445,163 @@ export default class PlayerDataSync {
|
|
|
140
445
|
this._entries.delete(participantId);
|
|
141
446
|
}
|
|
142
447
|
|
|
143
|
-
// синхронизирует
|
|
448
|
+
// синхронизирует накопленный результат+state участника на мастер. Сбой не
|
|
144
449
|
// бросается дальше — следующий flush попробует снова с уже накопленными
|
|
145
|
-
// (не потерянными) данными.
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
450
|
+
// (не потерянными) данными.
|
|
451
|
+
//
|
|
452
|
+
// urgent — срочная граница (уход участника, destroy комнаты): такой вызов
|
|
453
|
+
// обходит и минимальный интервал, и паузу бэкоффа, потому что второго
|
|
454
|
+
// шанса записать эти очки не будет
|
|
455
|
+
async flush(participantId, { urgent = false } = {}) {
|
|
456
|
+
const entry = this._entries.get(participantId);
|
|
150
457
|
|
|
151
458
|
if (!entry) {
|
|
152
459
|
return;
|
|
153
460
|
}
|
|
154
461
|
|
|
155
|
-
if (!
|
|
462
|
+
if (!urgent) {
|
|
463
|
+
const now = this._now();
|
|
464
|
+
|
|
465
|
+
// предел владеет движком: просьба игры синхронизироваться чаще
|
|
466
|
+
// minFlushInterval — это просьба, а не команда (решение пользователя 9)
|
|
467
|
+
if (now - entry.lastFlushAt < this._flushInterval || now < this._backoffUntil) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// один запрос в полёте на участника: повторный вызов ставит флаг, а не
|
|
473
|
+
// стартует второй запрос — по завершении делается один повтор, и
|
|
474
|
+
// добавленное во время await уходит им.
|
|
475
|
+
//
|
|
476
|
+
// Наружу при этом отдаётся промис ТЕКУЩЕЙ серии, а не resolved: destroy()
|
|
477
|
+
// комнаты ждёт именно flushAll, и «нечего ждать, запрос уже летит» было
|
|
478
|
+
// бы враньём — Worker сняли бы раньше, чем ушёл повтор с последними
|
|
479
|
+
// очками
|
|
480
|
+
if (entry.inFlight) {
|
|
481
|
+
entry.flushAgain = true;
|
|
482
|
+
|
|
483
|
+
return entry.inFlightPromise;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
entry.inFlightPromise = (async () => {
|
|
487
|
+
entry.inFlight = true;
|
|
488
|
+
|
|
489
|
+
try {
|
|
490
|
+
do {
|
|
491
|
+
entry.flushAgain = false;
|
|
492
|
+
await this._sync(participantId, entry);
|
|
493
|
+
} while (entry.flushAgain);
|
|
494
|
+
} finally {
|
|
495
|
+
entry.inFlight = false;
|
|
496
|
+
entry.inFlightPromise = null;
|
|
497
|
+
}
|
|
498
|
+
})();
|
|
499
|
+
|
|
500
|
+
return entry.inFlightPromise;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// F4: если исходная load() не удалась, PUT дефолтом затёр бы реальные
|
|
504
|
+
// сохранённые рейтинг/state — вместо этого повторяем load() и шлём PUT
|
|
505
|
+
// только для того, что реально загрузилось
|
|
506
|
+
async _sync(participantId, stale) {
|
|
507
|
+
let entry = stale;
|
|
508
|
+
|
|
509
|
+
entry.lastFlushAt = this._now();
|
|
510
|
+
|
|
511
|
+
if (!this._ratingsComplete(entry) || !entry.stateLoaded) {
|
|
156
512
|
entry = await this.load(participantId, entry.token);
|
|
157
513
|
}
|
|
158
514
|
|
|
159
|
-
const { token
|
|
515
|
+
const { token } = entry;
|
|
160
516
|
const requests = [];
|
|
517
|
+
// отправляем именно накопленное на этот момент и вычитаем его же после
|
|
518
|
+
// успеха — finishGame во время await не теряется (тот же паттерн, что
|
|
519
|
+
// F9 в load())
|
|
520
|
+
const points = entry.pendingPoints;
|
|
521
|
+
const best = entry.pendingBest;
|
|
161
522
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
523
|
+
// РЕЗУЛЬТАТ ИГРЫ ОТПРАВЛЯЕТСЯ БЕЗУСЛОВНО. Гейт по загруженности здесь
|
|
524
|
+
// был бы вреден: это не абсолютное значение, а строка в леджер (auth
|
|
525
|
+
// складывает её сам), затереть ею нечего — а недоступный на входе
|
|
526
|
+
// /auth/placements держал бы очки в pending до конца жизни комнаты и
|
|
527
|
+
// терял бы их вместе с ней. Гейт нужен ровно state ниже: тот PUT
|
|
528
|
+
// ЗАМЕЩАЕТ сохранённый JSON, и дефолтом поверх настоящего нельзя (F4)
|
|
529
|
+
if (points > 0 || best > 0) {
|
|
168
530
|
requests.push(
|
|
169
|
-
this.
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
531
|
+
this._enqueue(() =>
|
|
532
|
+
this._authedFetch(lobbyConfig.playerData.rankUrl, token, {
|
|
533
|
+
method: 'PUT',
|
|
534
|
+
body: { points, best, hostId: this._hostId, hostSecret: this._hostSecret },
|
|
535
|
+
}),
|
|
536
|
+
).then(res => {
|
|
537
|
+
// pendingBest только растёт: если он тот же — отправленное учтено
|
|
538
|
+
// целиком; если больше — во время запроса закончилась игра лучше,
|
|
539
|
+
// и её максимум ещё не отправлен
|
|
540
|
+
const drop = () => {
|
|
541
|
+
entry.pendingPoints -= points;
|
|
542
|
+
|
|
543
|
+
if (entry.pendingBest === best) {
|
|
544
|
+
entry.pendingBest = 0;
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
|
|
173
548
|
if (res.ok) {
|
|
174
|
-
|
|
549
|
+
this._noteSuccess();
|
|
550
|
+
drop();
|
|
175
551
|
} else {
|
|
552
|
+
this._noteStatus(res.status);
|
|
176
553
|
console.warn(`[playerData] PUT rank ${res.status} for ${participantId}`);
|
|
554
|
+
|
|
555
|
+
// 4xx (кроме 429) — отказ ПО СОДЕРЖАНИЮ: повтор того же тела
|
|
556
|
+
// получит тот же ответ. Оставить его в pending значит слать
|
|
557
|
+
// заведомый мусор на каждом flush до конца жизни комнаты, а
|
|
558
|
+
// накопленное сверху — сверх него. Очки теряются, и это честнее
|
|
559
|
+
// вечного цикла (сам отказ уже в логе выше)
|
|
560
|
+
if (res.status >= 400 && res.status < 500 && res.status !== 429) {
|
|
561
|
+
drop();
|
|
562
|
+
}
|
|
177
563
|
}
|
|
178
564
|
}),
|
|
179
565
|
);
|
|
180
566
|
}
|
|
181
567
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
568
|
+
const state = JSON.stringify(entry.state);
|
|
569
|
+
|
|
570
|
+
if (entry.stateLoaded && state !== entry.lastSyncedState) {
|
|
571
|
+
requests.push(
|
|
572
|
+
this._enqueue(() =>
|
|
573
|
+
this._authedFetch(lobbyConfig.playerData.stateUrl, token, {
|
|
574
|
+
method: 'PUT',
|
|
575
|
+
body: { state: entry.state, hostId: this._hostId, hostSecret: this._hostSecret },
|
|
576
|
+
}),
|
|
577
|
+
).then(res => {
|
|
578
|
+
if (res.ok) {
|
|
579
|
+
this._noteSuccess();
|
|
580
|
+
entry.lastSyncedState = state;
|
|
581
|
+
} else {
|
|
582
|
+
this._noteStatus(res.status);
|
|
583
|
+
console.warn(`[playerData] PUT state ${res.status} for ${participantId}`);
|
|
584
|
+
}
|
|
585
|
+
}),
|
|
586
|
+
);
|
|
191
587
|
}
|
|
192
588
|
|
|
193
589
|
// allSettled глушит отказы по замыслу (сбой не должен ронять раунд), но
|
|
194
590
|
// не должен глушить их след — иначе потеря синхронизации не диагностируема
|
|
195
591
|
for (const result of await Promise.allSettled(requests)) {
|
|
196
592
|
if (result.status === 'rejected') {
|
|
593
|
+
this._noteFailure();
|
|
197
594
|
console.warn(`[playerData] flush failed for ${participantId}:`, result.reason?.message);
|
|
198
595
|
}
|
|
199
596
|
}
|
|
200
597
|
}
|
|
201
598
|
|
|
202
|
-
// синхронизирует всех текущих участников (границы раунда/карты)
|
|
203
|
-
|
|
599
|
+
// синхронизирует всех текущих участников (границы раунда/карты). Просьба,
|
|
600
|
+
// а не команда: участник, у которого с прошлой синхронизации прошло
|
|
601
|
+
// меньше минимального интервала, пропускается — кроме urgent
|
|
602
|
+
flushAll({ urgent = false } = {}) {
|
|
204
603
|
return Promise.allSettled(
|
|
205
|
-
[...this._entries.keys()].map(id => this.flush(id)),
|
|
604
|
+
[...this._entries.keys()].map(id => this.flush(id, { urgent })),
|
|
206
605
|
);
|
|
207
606
|
}
|
|
208
607
|
}
|