vimp-engine 0.35.6 → 0.36.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/bin/vimp-sim.js +19 -1
- package/core/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/client/components/controller/Controls.js +5 -0
- package/src/client/components/controller/Lobby.js +4 -4
- package/src/client/components/controller/RoomMenu.js +43 -0
- package/src/client/components/controller/Vote.js +18 -2
- package/src/client/components/model/ClientReports.js +6 -2
- package/src/client/components/model/Controls.js +27 -0
- package/src/client/components/model/Games.js +5 -1
- package/src/client/components/model/Lobby.js +32 -24
- package/src/client/components/model/LobbyAuth.js +20 -4
- package/src/client/components/model/RoomMenu.js +121 -0
- package/src/client/components/model/Stat.js +2 -1
- package/src/client/components/model/Vote.js +122 -4
- package/src/client/components/view/Auth.js +20 -10
- package/src/client/components/view/ClientReports.js +16 -5
- package/src/client/components/view/Games.js +39 -14
- package/src/client/components/view/Lobby.js +90 -43
- package/src/client/components/view/LobbyAuth.js +13 -7
- package/src/client/components/view/Panel.js +1 -2
- package/src/client/components/view/RoomMenu.js +69 -0
- package/src/client/debug.js +24 -1
- package/src/client/lib/FpsMeter.js +46 -0
- package/src/client/lib/JoinRetry.js +73 -0
- package/src/client/lib/diagnostics.js +28 -5
- package/src/client/lib/hostCaps.js +82 -0
- package/src/client/lib/hostUnloadGuard.js +108 -0
- package/src/client/lib/hostVoteCommand.js +92 -0
- package/src/client/lib/pageReload.js +29 -0
- package/src/client/lib/roomLink.js +257 -0
- package/src/client/lib/signalingErrors.js +74 -0
- package/src/client/main.js +674 -451
- package/src/client/network/HiddenHostHealthLog.js +76 -0
- package/src/client/network/HostConnectionManager.js +62 -8
- package/src/client/network/HostController.js +376 -23
- package/src/client/network/HostHealthPolicy.js +307 -0
- package/src/client/network/HostHealthReporter.js +54 -0
- package/src/client/network/HostPrewarm.js +235 -0
- package/src/client/network/LoopbackTransport.js +16 -1
- package/src/client/network/PlannedHandoff.js +366 -0
- package/src/client/network/Promotion.js +420 -0
- package/src/client/network/RoomPeersReporter.js +81 -0
- package/src/client/network/SessionSupervisor.js +730 -0
- package/src/client/network/SignalingClient.js +183 -30
- package/src/client/network/StandbyReceiver.js +287 -0
- package/src/client/network/StandbySender.js +319 -0
- package/src/client/network/TokenHandoffTimer.js +81 -0
- package/src/client/network/WebRtcManager.js +338 -11
- package/src/client/network/policyClose.js +32 -2
- package/src/client/network/standbyChunks.js +185 -0
- package/src/client/session/GuestSession.js +428 -0
- package/src/client/session/HandoffFlow.js +399 -0
- package/src/client/session/HostRole.js +708 -0
- package/src/client/session/Membership.js +179 -0
- package/src/client/session/PromotionFlow.js +325 -0
- package/src/client/session/RouteBoot.js +432 -0
- package/src/client/session/StandbyRole.js +195 -0
- package/src/client/session/hostRoomPrep.js +152 -0
- package/src/client/session/roomContext.js +32 -0
- package/src/client/style.css +83 -22
- package/src/client/views/gameShell.js +7 -0
- package/src/client/views/includes/informer.pug +4 -0
- package/src/client/views/includes/lobby.pug +0 -3
- package/src/client/views/includes/roomMenu.pug +9 -0
- package/src/client/views/index.pug +1 -0
- package/src/config/closeCodes.js +1 -1
- package/src/config/env.js +37 -0
- package/src/config/hostDefaults.js +24 -0
- package/src/config/lobby.js +154 -3
- package/src/config/master.js +104 -21
- package/src/config/wsports.js +8 -0
- package/src/devtools/RecordingSocketManager.js +3 -2
- package/src/devtools/ScenarioRunner.js +123 -8
- package/src/devtools/VirtualClient.js +20 -0
- package/src/devtools/contract/format.js +4 -1
- package/src/devtools/contract/index.js +1 -2
- package/src/devtools/contract/rules/a2-package-scripts.js +1 -3
- package/src/devtools/contract/rules/b8-system-messages.js +1 -1
- package/src/devtools/contract/rules/b9-vote-names.js +25 -1
- package/src/devtools/contract/rules/c11-stat-mode.js +4 -1
- package/src/devtools/contract/rules/c6-stat-columns.js +1 -1
- package/src/devtools/contract/rules/c7-key-sets.js +8 -4
- package/src/devtools/contract/rules/d3-snapshot-interp.js +2 -1
- package/src/devtools/contract/rules/e1-sound-pairs.js +3 -1
- package/src/devtools/contract/rules/e4-map-layers.js +13 -2
- package/src/devtools/contract/rules/e5-map-radar-walls.js +7 -1
- package/src/devtools/contract/rules/index.js +38 -5
- package/src/devtools/invariants.js +10 -4
- package/src/devtools/pluginLoader.js +1 -4
- package/src/devtools/report.js +17 -1
- package/src/host/DebugRecorder.js +6 -1
- package/src/host/GameCoreAdapter.js +45 -0
- package/src/host/HostGame.js +928 -59
- package/src/host/PortMachine.js +268 -20
- package/src/host/host.worker.js +222 -16
- package/src/host/meta/SocketManager.js +20 -0
- package/src/host/meta/core/RoundManager.js +89 -14
- package/src/host/meta/modules/Accolades.js +4 -1
- package/src/host/meta/modules/Panel.js +30 -0
- package/src/host/meta/modules/PlayerDataSync.js +255 -46
- package/src/host/meta/modules/RTTManager.js +34 -0
- package/src/host/meta/modules/TimerManager.js +215 -18
- package/src/host/meta/modules/chat/systemMessages.js +22 -0
- package/src/host/meta/player/HumanParticipant.js +10 -0
- package/src/host/meta/player/ParticipantManager.js +98 -5
- package/src/lib/AbstractTimer.js +63 -1
- package/src/lib/Publisher.js +26 -0
- package/src/lib/capabilities.js +5 -0
- package/src/lib/checkpointCodec.js +125 -0
- package/src/lib/clientIp.js +9 -2
- package/src/lib/createHostRuntime.js +95 -6
- package/src/lib/formOptions.js +3 -1
- package/src/lib/gameConfigView.js +10 -0
- package/src/lib/gameId.js +13 -0
- package/src/lib/jwt.js +3 -1
- package/src/lib/loadGamePackage.js +2 -2
- package/src/lib/resumeKey.js +38 -0
- package/src/lib/roomId.js +39 -0
- package/src/lib/roomSettings.js +59 -0
- package/src/lib/validators.js +7 -0
- package/tests/fixtures/generations/gen-api3/host/ScriptedManager.js +2 -3
- package/tests/fixtures/generations/gen-api3/host/fakeCore.js +31 -5
- package/tests/fixtures/generations/gen-api3/manifest.json +1 -3
- package/tests/fixtures/generations/gen-api4/host/ScriptedManager.js +2 -3
- package/tests/fixtures/generations/gen-api4/host/fakeCore.js +31 -5
- package/tests/fixtures/generations/gen-api4/manifest.json +1 -3
- package/tests/fixtures/miniGame/config/client.js +15 -0
- package/tests/fixtures/miniGame/host/ScriptedManager.js +2 -3
- package/tests/fixtures/miniGame/host/fakeCore.js +31 -5
|
@@ -17,6 +17,17 @@ import lobbyConfig from '../../../config/lobby.js';
|
|
|
17
17
|
|
|
18
18
|
const PERIODS = ['day', 'month', 'all'];
|
|
19
19
|
|
|
20
|
+
// старт номеров записей (host-migration 7.7): 48 случайных бит из CSPRNG
|
|
21
|
+
// (crypto изоморфен — Worker, браузер, Node). Ключ дубля в auth — (игрок,
|
|
22
|
+
// игра, номер) без комнаты, поэтому разные записи профиля одного игрока
|
|
23
|
+
// (перевход, другая комната, хост из точки) обязаны не пересекаться: 2^48
|
|
24
|
+
// диапазонов на это хватает, и до Number.MAX_SAFE_INTEGER далеко
|
|
25
|
+
const randomWriteSeq = () => {
|
|
26
|
+
const [high, low] = crypto.getRandomValues(new Uint32Array(2));
|
|
27
|
+
|
|
28
|
+
return (high % 2 ** 16) * 2 ** 32 + low;
|
|
29
|
+
};
|
|
30
|
+
|
|
20
31
|
export default class PlayerDataSync {
|
|
21
32
|
// дефолт fetchImpl обёрнут стрелкой, а не взят как голый `fetch`: из поля
|
|
22
33
|
// объекта он вызывался бы с `this` экземпляра, а в браузере/воркере у
|
|
@@ -33,6 +44,7 @@ export default class PlayerDataSync {
|
|
|
33
44
|
now = () => Date.now(),
|
|
34
45
|
sleep = ms => new Promise(resolve => setTimeout(resolve, ms)),
|
|
35
46
|
random = () => Math.random(),
|
|
47
|
+
writeSeqStart = randomWriteSeq,
|
|
36
48
|
} = {},
|
|
37
49
|
) {
|
|
38
50
|
this._gameId = gameId;
|
|
@@ -40,22 +52,24 @@ export default class PlayerDataSync {
|
|
|
40
52
|
this._defaultState = defaultState;
|
|
41
53
|
this._now = now;
|
|
42
54
|
this._sleep = sleep;
|
|
55
|
+
this._writeSeqStart = writeSeqStart;
|
|
43
56
|
this._entries = new Map(); // participantId -> запись профиля (_makeEntry)
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
this.
|
|
51
|
-
this.
|
|
57
|
+
// roomId + секрет эпохи комнаты (host-migration, этап 2): неизвестны при
|
|
58
|
+
// создании (назначаются мастером на register_host, после запуска
|
|
59
|
+
// Worker'а) — проставляются позже через setRoom(). Едут в теле PUT
|
|
60
|
+
// rank/state; мастер сверяет секрет с реестром и по нему подставляет
|
|
61
|
+
// проверенную атрибуцию, не доверяя roomId из тела напрямую (иначе можно
|
|
62
|
+
// было бы подставить чужую комнату)
|
|
63
|
+
this._roomId = null;
|
|
64
|
+
this._roomSecret = null;
|
|
52
65
|
|
|
53
66
|
const settings = lobbyConfig.playerData;
|
|
54
67
|
|
|
55
68
|
// джиттер считается один раз на комнату: сотни серверов, синхронно
|
|
56
69
|
// проснувшихся по круглому минутному таймеру, дают мастеру пик
|
|
57
70
|
this._flushInterval = Math.round(
|
|
58
|
-
settings.minFlushInterval *
|
|
71
|
+
settings.minFlushInterval *
|
|
72
|
+
(1 + (random() * 2 - 1) * settings.flushJitter),
|
|
59
73
|
);
|
|
60
74
|
// вверх, а не как получится: слот, округлённый вниз, выпускал бы чуть
|
|
61
75
|
// больше запросов в секунду, чем объявленный потолок
|
|
@@ -74,9 +88,9 @@ export default class PlayerDataSync {
|
|
|
74
88
|
|
|
75
89
|
// вызывается HostGame, когда мастер подтвердил регистрацию комнаты
|
|
76
90
|
// (host_registered) — до этого flush уходит без атрибуции
|
|
77
|
-
|
|
78
|
-
this.
|
|
79
|
-
this.
|
|
91
|
+
setRoom({ roomId = null, roomSecret = null } = {}) {
|
|
92
|
+
this._roomId = roomId;
|
|
93
|
+
this._roomSecret = roomSecret;
|
|
80
94
|
}
|
|
81
95
|
|
|
82
96
|
_makeEntry(token) {
|
|
@@ -89,7 +103,10 @@ export default class PlayerDataSync {
|
|
|
89
103
|
lastSyncedState: null,
|
|
90
104
|
// значения на момент входа + локальные правки finishGame
|
|
91
105
|
ratings: Object.fromEntries(
|
|
92
|
-
PERIODS.map(period => [
|
|
106
|
+
PERIODS.map(period => [
|
|
107
|
+
period,
|
|
108
|
+
{ value: 0, placement: null, total: 0 },
|
|
109
|
+
]),
|
|
93
110
|
),
|
|
94
111
|
// ПО СРЕЗАМ, а не общим флагом: агрегирующий /auth/placements отдаёт
|
|
95
112
|
// 200, если приехал хоть один срез, и кладёт провалившийся как null.
|
|
@@ -100,7 +117,9 @@ export default class PlayerDataSync {
|
|
|
100
117
|
currentGamePoints: 0, // очки незавершённой игры
|
|
101
118
|
pendingPoints: 0, // сумма завершённых игр, ещё не отправленная
|
|
102
119
|
pendingBest: 0, // лучшая завершённая игра, ещё не отправленная
|
|
103
|
-
placementRefreshedAt: Object.fromEntries(
|
|
120
|
+
placementRefreshedAt: Object.fromEntries(
|
|
121
|
+
PERIODS.map(period => [period, 0]),
|
|
122
|
+
),
|
|
104
123
|
inFlight: false,
|
|
105
124
|
// промис текущей серии flush: его отдают повторные вызовы, чтобы
|
|
106
125
|
// граница, которая ждёт записи (destroy, уход участника), ждала её
|
|
@@ -108,6 +127,22 @@ export default class PlayerDataSync {
|
|
|
108
127
|
inFlightPromise: null,
|
|
109
128
|
flushAgain: false,
|
|
110
129
|
lastFlushAt: 0,
|
|
130
|
+
// номер последней записи результата участника (host-migration 7.7),
|
|
131
|
+
// auth по нему отсеивает повтор. Старт случайный (randomWriteSeq), а не
|
|
132
|
+
// с нуля: участник, вышедший и вернувшийся в комнату, получает новую
|
|
133
|
+
// запись — счёт с нуля совпал бы с номерами прошлого входа, и auth
|
|
134
|
+
// выбросил бы честные очки как дубль
|
|
135
|
+
writeSeq: this._writeSeqStart(),
|
|
136
|
+
// записи результата, которым уже выдан номер, по порядку — { writeSeq,
|
|
137
|
+
// points, best }. Сумма вычтена из pending в момент выдачи номера, и
|
|
138
|
+
// до ответа auth запись живёт здесь: повтор (сбой сети, 5xx/429, хост
|
|
139
|
+
// из точки) уходит с ТЕМ ЖЕ номером и той же суммой — дошедший до auth
|
|
140
|
+
// запрос не засчитается дважды. Больше одной бывает только после
|
|
141
|
+
// restore (см. там)
|
|
142
|
+
writes: [],
|
|
143
|
+
// запись восстановлена из контрольной точки без токена (токены по сети
|
|
144
|
+
// не ездят): писать нечем, пока участник не вернётся (attachToken)
|
|
145
|
+
awaitingToken: false,
|
|
111
146
|
};
|
|
112
147
|
}
|
|
113
148
|
|
|
@@ -151,7 +186,10 @@ export default class PlayerDataSync {
|
|
|
151
186
|
_noteFailure() {
|
|
152
187
|
const { baseMs, maxMs } = lobbyConfig.playerData.backoff;
|
|
153
188
|
|
|
154
|
-
this._backoffMs = Math.min(
|
|
189
|
+
this._backoffMs = Math.min(
|
|
190
|
+
this._backoffMs ? this._backoffMs * 2 : baseMs,
|
|
191
|
+
maxMs,
|
|
192
|
+
);
|
|
155
193
|
this._backoffUntil = this._now() + this._backoffMs;
|
|
156
194
|
}
|
|
157
195
|
|
|
@@ -180,6 +218,7 @@ export default class PlayerDataSync {
|
|
|
180
218
|
const entry = this._entries.get(participantId) ?? this._makeEntry(token);
|
|
181
219
|
|
|
182
220
|
entry.token = token;
|
|
221
|
+
entry.awaitingToken = false;
|
|
183
222
|
this._entries.set(participantId, entry);
|
|
184
223
|
|
|
185
224
|
try {
|
|
@@ -205,7 +244,9 @@ export default class PlayerDataSync {
|
|
|
205
244
|
// навсегда выключает её и выглядит снаружи как «данных просто нет»
|
|
206
245
|
if (!ratingsRes.ok) {
|
|
207
246
|
this._noteStatus(ratingsRes.status);
|
|
208
|
-
console.warn(
|
|
247
|
+
console.warn(
|
|
248
|
+
`[playerData] GET placements ${ratingsRes.status} for ${participantId}`,
|
|
249
|
+
);
|
|
209
250
|
} else {
|
|
210
251
|
const json = (await ratingsRes.json()) ?? {};
|
|
211
252
|
const at = this._now();
|
|
@@ -244,7 +285,9 @@ export default class PlayerDataSync {
|
|
|
244
285
|
// сумму вместо максимума, и refreshPlacement её бы не исправил
|
|
245
286
|
// (при непустом pending он берёт Math.max с показанным)
|
|
246
287
|
rating.value =
|
|
247
|
-
period === 'month'
|
|
288
|
+
period === 'month'
|
|
289
|
+
? rating.value + server
|
|
290
|
+
: Math.max(rating.value, server);
|
|
248
291
|
rating.placement = slice.placement ?? null;
|
|
249
292
|
rating.total = Number(slice.total) || 0;
|
|
250
293
|
entry.placementRefreshedAt[period] = at;
|
|
@@ -254,14 +297,18 @@ export default class PlayerDataSync {
|
|
|
254
297
|
|
|
255
298
|
if (!stateRes.ok) {
|
|
256
299
|
this._noteStatus(stateRes.status);
|
|
257
|
-
console.warn(
|
|
300
|
+
console.warn(
|
|
301
|
+
`[playerData] GET state ${stateRes.status} for ${participantId}`,
|
|
302
|
+
);
|
|
258
303
|
} else if (!entry.stateLoaded) {
|
|
259
304
|
this._noteSuccess();
|
|
260
305
|
|
|
261
306
|
const { state } = await stateRes.json();
|
|
262
307
|
|
|
263
308
|
entry.state =
|
|
264
|
-
state && Object.keys(state).length
|
|
309
|
+
state && Object.keys(state).length
|
|
310
|
+
? state
|
|
311
|
+
: structuredClone(this._defaultState);
|
|
265
312
|
entry.stateLoaded = true;
|
|
266
313
|
// то, что только что приехало, отправлять обратно незачем
|
|
267
314
|
entry.lastSyncedState = JSON.stringify(entry.state);
|
|
@@ -275,7 +322,10 @@ export default class PlayerDataSync {
|
|
|
275
322
|
// в игре) бэкофф обходит, а именно он и тянет за собой повторный
|
|
276
323
|
// load(), пока рейтинги не загружены
|
|
277
324
|
this._noteFailure();
|
|
278
|
-
console.warn(
|
|
325
|
+
console.warn(
|
|
326
|
+
`[playerData] load failed for ${participantId}:`,
|
|
327
|
+
err.message,
|
|
328
|
+
);
|
|
279
329
|
}
|
|
280
330
|
|
|
281
331
|
return entry;
|
|
@@ -323,7 +373,10 @@ export default class PlayerDataSync {
|
|
|
323
373
|
|
|
324
374
|
const now = this._now();
|
|
325
375
|
|
|
326
|
-
if (
|
|
376
|
+
if (
|
|
377
|
+
now - entry.placementRefreshedAt[period] <
|
|
378
|
+
lobbyConfig.playerData.placementTtl
|
|
379
|
+
) {
|
|
327
380
|
return this.getRating(participantId, period);
|
|
328
381
|
}
|
|
329
382
|
|
|
@@ -342,7 +395,9 @@ export default class PlayerDataSync {
|
|
|
342
395
|
|
|
343
396
|
if (!res.ok) {
|
|
344
397
|
this._noteStatus(res.status);
|
|
345
|
-
console.warn(
|
|
398
|
+
console.warn(
|
|
399
|
+
`[playerData] GET placement ${res.status} for ${participantId}`,
|
|
400
|
+
);
|
|
346
401
|
} else {
|
|
347
402
|
const { placement, total, rank } = (await res.json()) ?? {};
|
|
348
403
|
const rating = entry.ratings[period];
|
|
@@ -353,7 +408,9 @@ export default class PlayerDataSync {
|
|
|
353
408
|
// ноль после смены суток); есть неотправленное — оно ещё не учтено
|
|
354
409
|
// сервером, и показывать меньше уже показанного нельзя
|
|
355
410
|
rating.value =
|
|
356
|
-
entry.pendingPoints > 0 ||
|
|
411
|
+
entry.pendingPoints > 0 ||
|
|
412
|
+
entry.pendingBest > 0 ||
|
|
413
|
+
entry.writes.length > 0
|
|
357
414
|
? Math.max(rating.value, server)
|
|
358
415
|
: server;
|
|
359
416
|
rating.placement = placement ?? null;
|
|
@@ -361,7 +418,10 @@ export default class PlayerDataSync {
|
|
|
361
418
|
}
|
|
362
419
|
} catch (err) {
|
|
363
420
|
this._noteFailure();
|
|
364
|
-
console.warn(
|
|
421
|
+
console.warn(
|
|
422
|
+
`[playerData] placement failed for ${participantId}:`,
|
|
423
|
+
err.message,
|
|
424
|
+
);
|
|
365
425
|
}
|
|
366
426
|
|
|
367
427
|
return this.getRating(participantId, period);
|
|
@@ -445,6 +505,114 @@ export default class PlayerDataSync {
|
|
|
445
505
|
this._entries.delete(participantId);
|
|
446
506
|
}
|
|
447
507
|
|
|
508
|
+
// свежий токен участника, вернувшегося в матч новым соединением
|
|
509
|
+
// (host-migration этап 4): старый мог истечь за время жизни комнаты, а
|
|
510
|
+
// следующие flush идут с токеном записи. Подгрузку не повторяет —
|
|
511
|
+
// профиль уже в памяти. false — записи участника нет
|
|
512
|
+
attachToken(participantId, token) {
|
|
513
|
+
const entry = this._entries.get(participantId);
|
|
514
|
+
|
|
515
|
+
if (!entry || typeof token !== 'string' || token === '') {
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
entry.token = token;
|
|
520
|
+
entry.awaitingToken = false;
|
|
521
|
+
|
|
522
|
+
return true;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ***** контрольная точка (host-migration этап 5) ***** //
|
|
526
|
+
|
|
527
|
+
// профили участников без токенов: точка уезжает по сети к другому игроку.
|
|
528
|
+
// Записи с номерами едут отдельно (writes) и в pending не входят: новый
|
|
529
|
+
// хост повторит их с теми же номерами, и дошедшая до auth не засчитается
|
|
530
|
+
// второй раз (host-migration 7.7)
|
|
531
|
+
serialize() {
|
|
532
|
+
const out = {};
|
|
533
|
+
|
|
534
|
+
for (const [id, entry] of this._entries) {
|
|
535
|
+
out[id] = {
|
|
536
|
+
ratings: structuredClone(entry.ratings),
|
|
537
|
+
ratingsLoaded: { ...entry.ratingsLoaded },
|
|
538
|
+
currentGamePoints: entry.currentGamePoints,
|
|
539
|
+
pendingPoints: entry.pendingPoints,
|
|
540
|
+
pendingBest: entry.pendingBest,
|
|
541
|
+
state: structuredClone(entry.state),
|
|
542
|
+
stateLoaded: entry.stateLoaded,
|
|
543
|
+
lastSyncedState: entry.lastSyncedState,
|
|
544
|
+
writeSeq: entry.writeSeq,
|
|
545
|
+
writes: entry.writes.map(write => ({ ...write })),
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return out;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// поднимает профили из точки. tokens — { [id]: token } только для эстафеты
|
|
553
|
+
// внутри вкладки; без токена запись ждёт возврата участника (attachToken)
|
|
554
|
+
restore(state = {}, { tokens = {} } = {}) {
|
|
555
|
+
for (const [id, saved] of Object.entries(state)) {
|
|
556
|
+
const token = tokens[id] ?? null;
|
|
557
|
+
const entry = this._makeEntry(token);
|
|
558
|
+
|
|
559
|
+
entry.ratings = structuredClone(saved.ratings ?? entry.ratings);
|
|
560
|
+
entry.ratingsLoaded = { ...entry.ratingsLoaded, ...saved.ratingsLoaded };
|
|
561
|
+
entry.currentGamePoints = Number(saved.currentGamePoints) || 0;
|
|
562
|
+
entry.pendingPoints = Number(saved.pendingPoints) || 0;
|
|
563
|
+
entry.pendingBest = Number(saved.pendingBest) || 0;
|
|
564
|
+
entry.state = structuredClone(saved.state ?? this._defaultState);
|
|
565
|
+
entry.stateLoaded = saved.stateLoaded === true;
|
|
566
|
+
entry.lastSyncedState = saved.lastSyncedState ?? null;
|
|
567
|
+
entry.writes = (Array.isArray(saved.writes) ? saved.writes : [])
|
|
568
|
+
.map(write => this._restoreWrite(write))
|
|
569
|
+
.filter(Boolean);
|
|
570
|
+
|
|
571
|
+
// Номера точки НЕ продолжаются: старый хост мог после неё занять
|
|
572
|
+
// writeSeq+1, +2…, и свежие очки этого хоста под теми же номерами auth
|
|
573
|
+
// выбросил бы как дубль. Накопленное на момент точки закрепляется под
|
|
574
|
+
// writeSeq+1 — этот номер дал бы ему старый хост, и если он его успел
|
|
575
|
+
// отправить, auth отсеет повтор. Всё дальше — из нового случайного
|
|
576
|
+
// диапазона (writeSeq из _makeEntry). Точка без номера (Worker до 7.7)
|
|
577
|
+
// своих записей с номерами не слала — её накопленное остаётся в pending
|
|
578
|
+
if (
|
|
579
|
+
Number.isSafeInteger(saved.writeSeq) &&
|
|
580
|
+
(entry.pendingPoints > 0 || entry.pendingBest > 0)
|
|
581
|
+
) {
|
|
582
|
+
entry.writes.push({
|
|
583
|
+
writeSeq: saved.writeSeq + 1,
|
|
584
|
+
points: entry.pendingPoints,
|
|
585
|
+
best: entry.pendingBest,
|
|
586
|
+
});
|
|
587
|
+
entry.pendingPoints = 0;
|
|
588
|
+
entry.pendingBest = 0;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
entry.awaitingToken = token === null;
|
|
592
|
+
|
|
593
|
+
this._entries.set(id, entry);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
_restoreWrite(saved) {
|
|
598
|
+
if (!saved || !Number.isSafeInteger(saved.writeSeq)) {
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return {
|
|
603
|
+
writeSeq: saved.writeSeq,
|
|
604
|
+
points: Number(saved.points) || 0,
|
|
605
|
+
best: Number(saved.best) || 0,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// всё ли с мастера уже приехало: тогда повторная загрузка не нужна
|
|
610
|
+
isLoaded(participantId) {
|
|
611
|
+
const entry = this._entries.get(participantId);
|
|
612
|
+
|
|
613
|
+
return Boolean(entry && entry.stateLoaded && this._ratingsComplete(entry));
|
|
614
|
+
}
|
|
615
|
+
|
|
448
616
|
// синхронизирует накопленный результат+state участника на мастер. Сбой не
|
|
449
617
|
// бросается дальше — следующий flush попробует снова с уже накопленными
|
|
450
618
|
// (не потерянными) данными.
|
|
@@ -455,7 +623,9 @@ export default class PlayerDataSync {
|
|
|
455
623
|
async flush(participantId, { urgent = false } = {}) {
|
|
456
624
|
const entry = this._entries.get(participantId);
|
|
457
625
|
|
|
458
|
-
|
|
626
|
+
// восстановлен из точки без токена — писать нечем; накопленное ждёт
|
|
627
|
+
// возврата участника
|
|
628
|
+
if (!entry || entry.awaitingToken) {
|
|
459
629
|
return;
|
|
460
630
|
}
|
|
461
631
|
|
|
@@ -464,7 +634,10 @@ export default class PlayerDataSync {
|
|
|
464
634
|
|
|
465
635
|
// предел владеет движком: просьба игры синхронизироваться чаще
|
|
466
636
|
// minFlushInterval — это просьба, а не команда (решение пользователя 9)
|
|
467
|
-
if (
|
|
637
|
+
if (
|
|
638
|
+
now - entry.lastFlushAt < this._flushInterval ||
|
|
639
|
+
now < this._backoffUntil
|
|
640
|
+
) {
|
|
468
641
|
return;
|
|
469
642
|
}
|
|
470
643
|
}
|
|
@@ -514,11 +687,9 @@ export default class PlayerDataSync {
|
|
|
514
687
|
|
|
515
688
|
const { token } = entry;
|
|
516
689
|
const requests = [];
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
|
|
520
|
-
const points = entry.pendingPoints;
|
|
521
|
-
const best = entry.pendingBest;
|
|
690
|
+
// незакрытая запись прошлой попытки (или предшественника — точка,
|
|
691
|
+
// эстафета) уходит первой и как есть: тот же номер, та же сумма
|
|
692
|
+
const carried = entry.writes.length > 0;
|
|
522
693
|
|
|
523
694
|
// РЕЗУЛЬТАТ ИГРЫ ОТПРАВЛЯЕТСЯ БЕЗУСЛОВНО. Гейт по загруженности здесь
|
|
524
695
|
// был бы вреден: это не абсолютное значение, а строка в леджер (auth
|
|
@@ -526,22 +697,49 @@ export default class PlayerDataSync {
|
|
|
526
697
|
// /auth/placements держал бы очки в pending до конца жизни комнаты и
|
|
527
698
|
// терял бы их вместе с ней. Гейт нужен ровно state ниже: тот PUT
|
|
528
699
|
// ЗАМЕЩАЕТ сохранённый JSON, и дефолтом поверх настоящего нельзя (F4)
|
|
529
|
-
if (
|
|
700
|
+
if (!carried && (entry.pendingPoints > 0 || entry.pendingBest > 0)) {
|
|
701
|
+
// накопленное на этот момент получает номер и уходит из pending сразу:
|
|
702
|
+
// finishGame во время await копит уже следующую запись
|
|
703
|
+
entry.writeSeq += 1;
|
|
704
|
+
entry.writes.push({
|
|
705
|
+
writeSeq: entry.writeSeq,
|
|
706
|
+
points: entry.pendingPoints,
|
|
707
|
+
best: entry.pendingBest,
|
|
708
|
+
});
|
|
709
|
+
entry.pendingPoints = 0;
|
|
710
|
+
entry.pendingBest = 0;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const [write] = entry.writes;
|
|
714
|
+
|
|
715
|
+
if (write) {
|
|
530
716
|
requests.push(
|
|
531
717
|
this._enqueue(() =>
|
|
532
718
|
this._authedFetch(lobbyConfig.playerData.rankUrl, token, {
|
|
533
719
|
method: 'PUT',
|
|
534
|
-
body: {
|
|
720
|
+
body: {
|
|
721
|
+
points: write.points,
|
|
722
|
+
best: write.best,
|
|
723
|
+
writeSeq: write.writeSeq,
|
|
724
|
+
roomId: this._roomId,
|
|
725
|
+
roomSecret: this._roomSecret,
|
|
726
|
+
},
|
|
535
727
|
}),
|
|
536
728
|
).then(res => {
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
//
|
|
729
|
+
// запись закрыта: следующая получит новый номер. Закрыта старая
|
|
730
|
+
// (повтор, запись из точки) — оставшиеся записи и накопленное за
|
|
731
|
+
// время сбоев уходят в этой же серии, а не ждут следующей границы:
|
|
732
|
+
// срочный flush ухода участника иначе увёз бы их вместе с записью
|
|
540
733
|
const drop = () => {
|
|
541
|
-
entry.
|
|
734
|
+
if (entry.writes[0] === write) {
|
|
735
|
+
entry.writes.shift();
|
|
736
|
+
}
|
|
542
737
|
|
|
543
|
-
if (
|
|
544
|
-
entry.
|
|
738
|
+
if (
|
|
739
|
+
entry.writes.length > 0 ||
|
|
740
|
+
(carried && (entry.pendingPoints > 0 || entry.pendingBest > 0))
|
|
741
|
+
) {
|
|
742
|
+
entry.flushAgain = true;
|
|
545
743
|
}
|
|
546
744
|
};
|
|
547
745
|
|
|
@@ -550,13 +748,15 @@ export default class PlayerDataSync {
|
|
|
550
748
|
drop();
|
|
551
749
|
} else {
|
|
552
750
|
this._noteStatus(res.status);
|
|
553
|
-
console.warn(
|
|
751
|
+
console.warn(
|
|
752
|
+
`[playerData] PUT rank ${res.status} for ${participantId}`,
|
|
753
|
+
);
|
|
554
754
|
|
|
555
755
|
// 4xx (кроме 429) — отказ ПО СОДЕРЖАНИЮ: повтор того же тела
|
|
556
|
-
// получит тот же ответ. Оставить
|
|
756
|
+
// получит тот же ответ. Оставить запись открытой значит слать
|
|
557
757
|
// заведомый мусор на каждом flush до конца жизни комнаты, а
|
|
558
|
-
// накопленное
|
|
559
|
-
// вечного цикла (сам отказ уже в логе выше)
|
|
758
|
+
// накопленное после неё не ушло бы вовсе. Очки теряются, и это
|
|
759
|
+
// честнее вечного цикла (сам отказ уже в логе выше)
|
|
560
760
|
if (res.status >= 400 && res.status < 500 && res.status !== 429) {
|
|
561
761
|
drop();
|
|
562
762
|
}
|
|
@@ -572,7 +772,11 @@ export default class PlayerDataSync {
|
|
|
572
772
|
this._enqueue(() =>
|
|
573
773
|
this._authedFetch(lobbyConfig.playerData.stateUrl, token, {
|
|
574
774
|
method: 'PUT',
|
|
575
|
-
body: {
|
|
775
|
+
body: {
|
|
776
|
+
state: entry.state,
|
|
777
|
+
roomId: this._roomId,
|
|
778
|
+
roomSecret: this._roomSecret,
|
|
779
|
+
},
|
|
576
780
|
}),
|
|
577
781
|
).then(res => {
|
|
578
782
|
if (res.ok) {
|
|
@@ -580,7 +784,9 @@ export default class PlayerDataSync {
|
|
|
580
784
|
entry.lastSyncedState = state;
|
|
581
785
|
} else {
|
|
582
786
|
this._noteStatus(res.status);
|
|
583
|
-
console.warn(
|
|
787
|
+
console.warn(
|
|
788
|
+
`[playerData] PUT state ${res.status} for ${participantId}`,
|
|
789
|
+
);
|
|
584
790
|
}
|
|
585
791
|
}),
|
|
586
792
|
);
|
|
@@ -591,7 +797,10 @@ export default class PlayerDataSync {
|
|
|
591
797
|
for (const result of await Promise.allSettled(requests)) {
|
|
592
798
|
if (result.status === 'rejected') {
|
|
593
799
|
this._noteFailure();
|
|
594
|
-
console.warn(
|
|
800
|
+
console.warn(
|
|
801
|
+
`[playerData] flush failed for ${participantId}:`,
|
|
802
|
+
result.reason?.message,
|
|
803
|
+
);
|
|
595
804
|
}
|
|
596
805
|
}
|
|
597
806
|
}
|
|
@@ -59,6 +59,7 @@ export default class RTTManager {
|
|
|
59
59
|
addUser(gameId) {
|
|
60
60
|
this._users.set(gameId, {
|
|
61
61
|
rtt: 100, // начальное значение RTT (предположение)
|
|
62
|
+
measured: false, // был ли хоть один pong (иначе rtt — догадка)
|
|
62
63
|
missedPings: 0,
|
|
63
64
|
outstandingPings: new Map(),
|
|
64
65
|
pingIdCounter: 0,
|
|
@@ -150,6 +151,7 @@ export default class RTTManager {
|
|
|
150
151
|
// с помощью экспоненциального скользящего среднего (EMA)
|
|
151
152
|
// (предотвращает резкие скачки RTT из-за единичных сетевых флуктуаций)
|
|
152
153
|
user.rtt = oldRtt * (1 - this._alpha) + newRttSample * this._alpha;
|
|
154
|
+
user.measured = true;
|
|
153
155
|
|
|
154
156
|
// если замер RTT превышает установленный порог
|
|
155
157
|
if (this._maxLatency && user.rtt > this._maxLatency) {
|
|
@@ -167,4 +169,36 @@ export default class RTTManager {
|
|
|
167
169
|
// `pong` пришел для пинга, который уже не отслеживается (маловероятно)
|
|
168
170
|
return null;
|
|
169
171
|
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* @description Медиана сглаженного RTT по измеренным пользователям
|
|
175
|
+
* (host-migration этап 9a: здоровье хоста). Пользователь без единого pong
|
|
176
|
+
* не считается — его rtt лишь стартовая догадка.
|
|
177
|
+
* @param {Function} [include] - (gameId) => boolean; например, без
|
|
178
|
+
* хост-игрока на loopback.
|
|
179
|
+
* @returns {{ median: number|null, count: number }}
|
|
180
|
+
*/
|
|
181
|
+
getRttStats(include = () => true) {
|
|
182
|
+
const values = [];
|
|
183
|
+
|
|
184
|
+
this._users.forEach((user, gameId) => {
|
|
185
|
+
if (user.measured && include(gameId)) {
|
|
186
|
+
values.push(user.rtt);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
if (values.length === 0) {
|
|
191
|
+
return { median: null, count: 0 };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
values.sort((a, b) => a - b);
|
|
195
|
+
|
|
196
|
+
const mid = values.length >> 1;
|
|
197
|
+
const median =
|
|
198
|
+
values.length % 2 === 1
|
|
199
|
+
? values[mid]
|
|
200
|
+
: (values[mid - 1] + values[mid]) / 2;
|
|
201
|
+
|
|
202
|
+
return { median: Math.round(median), count: values.length };
|
|
203
|
+
}
|
|
170
204
|
}
|