vimp-engine 0.35.6 → 0.37.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/Chat.js +19 -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 +676 -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/chatMessages.js +45 -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 +2 -2
- 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 +29 -3
- 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 +0 -20
- package/tests/fixtures/miniGame/host/ScriptedManager.js +2 -3
- package/tests/fixtures/miniGame/host/fakeCore.js +31 -5
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { encodeStandbyChunks } from './standbyChunks.js';
|
|
2
|
+
|
|
3
|
+
// Хост → преемник (host-migration этап 6): канал standby к назначенной
|
|
4
|
+
// мастером бете и поток контрольных точек по нему.
|
|
5
|
+
//
|
|
6
|
+
// Канал открывается поверх уже живого RTCPeerConnection гостя
|
|
7
|
+
// (createDataChannel без ре-согласования SDP: SCTP-ассоциация уже есть —
|
|
8
|
+
// каналы meta/state). Пока бета есть, Worker снимает периодические точки
|
|
9
|
+
// (HostController.startCheckpoints); точка режется на куски ≤ chunkBytes.
|
|
10
|
+
//
|
|
11
|
+
// Backpressure: канал забит выше highWaterBytes — очередная периодическая
|
|
12
|
+
// точка пропускается (следующая всё равно свежее); финальная (этап 8) не
|
|
13
|
+
// пропускается, а ждёт bufferedamountlow. Приоритет финальной: пока она ждёт
|
|
14
|
+
// отправки, периодические не уходят, а снятые до неё (seq не новее —
|
|
15
|
+
// кодирование в Worker'е асинхронно) отбрасываются: бета не должна принять
|
|
16
|
+
// устаревшую точку поверх финальной.
|
|
17
|
+
//
|
|
18
|
+
// Канал закрылся сам при живом пире беты (ревью F8) — без повторного открытия
|
|
19
|
+
// поток точек встал бы до смены состава пиров, а бета докладывала бы мастеру
|
|
20
|
+
// всё более старую точку. Повтор — с экспоненциальной задержкой
|
|
21
|
+
// reopenDelayMs…reopenMaxDelayMs; открытие сбрасывает её.
|
|
22
|
+
export default class StandbySender {
|
|
23
|
+
/**
|
|
24
|
+
* @param {Object} options
|
|
25
|
+
* @param {Object} options.controller - HostController (startCheckpoints,
|
|
26
|
+
* stopCheckpoints, onCheckpoint).
|
|
27
|
+
* @param {Object} options.connections - HostConnectionManager
|
|
28
|
+
* (peerConnectionOf).
|
|
29
|
+
* @param {number} options.intervalMs - период периодических точек.
|
|
30
|
+
* @param {number} options.chunkBytes - потолок куска с заголовком.
|
|
31
|
+
* @param {number} options.highWaterBytes - порог bufferedAmount.
|
|
32
|
+
* @param {Object} [options.diagnostics] - журнал (пропуски точек).
|
|
33
|
+
* @param {Function} [options.onStats] - после каждой отправки/пропуска.
|
|
34
|
+
* @param {number} [options.reopenDelayMs] - первая пауза перед повторным
|
|
35
|
+
* открытием закрывшегося канала.
|
|
36
|
+
* @param {number} [options.reopenMaxDelayMs] - потолок паузы.
|
|
37
|
+
* @param {Object} [options.timers] - { setTimeout, clearTimeout } (тесты).
|
|
38
|
+
* @param {Function} [options.now] - часы (мс).
|
|
39
|
+
*/
|
|
40
|
+
constructor({
|
|
41
|
+
controller,
|
|
42
|
+
connections,
|
|
43
|
+
intervalMs,
|
|
44
|
+
chunkBytes,
|
|
45
|
+
highWaterBytes,
|
|
46
|
+
diagnostics = null,
|
|
47
|
+
onStats = null,
|
|
48
|
+
reopenDelayMs = 1000,
|
|
49
|
+
reopenMaxDelayMs = 10000,
|
|
50
|
+
timers = globalThis,
|
|
51
|
+
now = () => Date.now(),
|
|
52
|
+
}) {
|
|
53
|
+
this._controller = controller;
|
|
54
|
+
this._connections = connections;
|
|
55
|
+
this._intervalMs = intervalMs;
|
|
56
|
+
this._chunkBytes = chunkBytes;
|
|
57
|
+
this._highWaterBytes = highWaterBytes;
|
|
58
|
+
this._diagnostics = diagnostics;
|
|
59
|
+
this._onStats = onStats;
|
|
60
|
+
this._reopenDelayMs = reopenDelayMs;
|
|
61
|
+
this._reopenMaxDelayMs = reopenMaxDelayMs;
|
|
62
|
+
this._timers = timers;
|
|
63
|
+
this._now = now;
|
|
64
|
+
|
|
65
|
+
this._memberId = null;
|
|
66
|
+
this._pc = null;
|
|
67
|
+
this._channel = null;
|
|
68
|
+
this._checkpointsOn = false;
|
|
69
|
+
this._wireId = 0;
|
|
70
|
+
// финальная точка, ждущая открытия канала или bufferedamountlow
|
|
71
|
+
this._pendingFinal = null;
|
|
72
|
+
// seq последней финальной точки (null — не было)
|
|
73
|
+
this._finalSeq = null;
|
|
74
|
+
this._lastSentAt = null;
|
|
75
|
+
// повторное открытие закрывшегося канала: таймер и следующая пауза
|
|
76
|
+
this._reopenTimer = null;
|
|
77
|
+
this._reopenDelay = reopenDelayMs;
|
|
78
|
+
|
|
79
|
+
this._stats = {
|
|
80
|
+
sent: 0,
|
|
81
|
+
skipped: 0,
|
|
82
|
+
lastBytes: 0,
|
|
83
|
+
lastChunks: 0,
|
|
84
|
+
lastIntervalMs: null,
|
|
85
|
+
lastLatencyMs: null,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
this._unsubscribe = controller.onCheckpoint(checkpoint =>
|
|
89
|
+
this._onCheckpoint(checkpoint),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// метрики для настройки интервала/порогов (размер, интервал, пропуски)
|
|
94
|
+
get stats() {
|
|
95
|
+
return { ...this._stats };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* successor_assigned мастера: открыть канал новой бете, закрыть прежний.
|
|
100
|
+
* @param {string|null} memberId
|
|
101
|
+
*/
|
|
102
|
+
setSuccessor(memberId) {
|
|
103
|
+
if (memberId === this._memberId) {
|
|
104
|
+
this.refresh();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
this._cancelReopen();
|
|
109
|
+
this._reopenDelay = this._reopenDelayMs;
|
|
110
|
+
this._closeChannel();
|
|
111
|
+
this._memberId = memberId ?? null;
|
|
112
|
+
this.refresh();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Пересматривает канал: бета подключилась позже назначения, переподключила
|
|
117
|
+
* WebRTC (новый pc) или канал закрылся. Зовётся и на смену состава пиров.
|
|
118
|
+
*/
|
|
119
|
+
refresh() {
|
|
120
|
+
if (!this._memberId) {
|
|
121
|
+
this._setCheckpoints(false);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const pc = this._connections.peerConnectionOf(this._memberId);
|
|
126
|
+
|
|
127
|
+
if (pc !== this._pc) {
|
|
128
|
+
this._closeChannel();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (pc && !this._channel) {
|
|
132
|
+
this._openChannel(pc);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
this._setCheckpoints(Boolean(this._channel));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
destroy() {
|
|
139
|
+
this._unsubscribe?.();
|
|
140
|
+
this._unsubscribe = null;
|
|
141
|
+
this._cancelReopen();
|
|
142
|
+
this._setCheckpoints(false);
|
|
143
|
+
this._closeChannel();
|
|
144
|
+
this._memberId = null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
_openChannel(pc) {
|
|
148
|
+
let channel;
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
channel = pc.createDataChannel('standby', { ordered: true });
|
|
152
|
+
} catch {
|
|
153
|
+
return; // pc уже закрыт — следующий refresh найдёт новый
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
channel.binaryType = 'arraybuffer';
|
|
157
|
+
channel.bufferedAmountLowThreshold = this._highWaterBytes;
|
|
158
|
+
channel.onopen = () => {
|
|
159
|
+
this._reopenDelay = this._reopenDelayMs;
|
|
160
|
+
this._flushFinal();
|
|
161
|
+
};
|
|
162
|
+
channel.onbufferedamountlow = () => this._flushFinal();
|
|
163
|
+
channel.onclose = () => {
|
|
164
|
+
if (this._channel === channel) {
|
|
165
|
+
this._dropChannel();
|
|
166
|
+
this._setCheckpoints(false);
|
|
167
|
+
this._scheduleReopen();
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
this._cancelReopen();
|
|
172
|
+
this._pc = pc;
|
|
173
|
+
this._channel = channel;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// пира нет — refresh() канал не откроет, и повторов больше не будет
|
|
177
|
+
_scheduleReopen() {
|
|
178
|
+
this._cancelReopen();
|
|
179
|
+
|
|
180
|
+
const delay = this._reopenDelay;
|
|
181
|
+
|
|
182
|
+
this._reopenDelay = Math.min(delay * 2, this._reopenMaxDelayMs);
|
|
183
|
+
this._reopenTimer = this._timers.setTimeout(() => {
|
|
184
|
+
this._reopenTimer = null;
|
|
185
|
+
this.refresh();
|
|
186
|
+
}, delay);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
_cancelReopen() {
|
|
190
|
+
if (this._reopenTimer !== null) {
|
|
191
|
+
this._timers.clearTimeout(this._reopenTimer);
|
|
192
|
+
this._reopenTimer = null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
_closeChannel() {
|
|
197
|
+
const channel = this._channel;
|
|
198
|
+
|
|
199
|
+
this._dropChannel();
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
channel?.close();
|
|
203
|
+
} catch {
|
|
204
|
+
// уже закрыт
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
_dropChannel() {
|
|
209
|
+
if (this._channel) {
|
|
210
|
+
this._channel.onopen = null;
|
|
211
|
+
this._channel.onclose = null;
|
|
212
|
+
this._channel.onbufferedamountlow = null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
this._channel = null;
|
|
216
|
+
this._pc = null;
|
|
217
|
+
this._pendingFinal = null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
_setCheckpoints(on) {
|
|
221
|
+
if (on === this._checkpointsOn) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
this._checkpointsOn = on;
|
|
226
|
+
|
|
227
|
+
if (on) {
|
|
228
|
+
this._controller.startCheckpoints(this._intervalMs);
|
|
229
|
+
} else {
|
|
230
|
+
this._controller.stopCheckpoints();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
_onCheckpoint(checkpoint) {
|
|
235
|
+
const channel = this._channel;
|
|
236
|
+
|
|
237
|
+
if (checkpoint.final) {
|
|
238
|
+
// финальная точка не теряется: ждёт открытия канала и разгрузки
|
|
239
|
+
this._pendingFinal = checkpoint;
|
|
240
|
+
this._finalSeq = Number.isInteger(checkpoint.seq) ? checkpoint.seq : null;
|
|
241
|
+
this._flushFinal();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (
|
|
246
|
+
this._pendingFinal ||
|
|
247
|
+
(this._finalSeq !== null &&
|
|
248
|
+
Number.isInteger(checkpoint.seq) &&
|
|
249
|
+
checkpoint.seq <= this._finalSeq)
|
|
250
|
+
) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (!channel || channel.readyState !== 'open') {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (channel.bufferedAmount > this._highWaterBytes) {
|
|
259
|
+
this._stats.skipped += 1;
|
|
260
|
+
this._diagnostics?.warn(
|
|
261
|
+
'engine.standby.skipped',
|
|
262
|
+
{ bufferedAmount: channel.bufferedAmount },
|
|
263
|
+
{ source: 'host' },
|
|
264
|
+
);
|
|
265
|
+
this._onStats?.(this.stats);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
this._send(channel, checkpoint);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
_flushFinal() {
|
|
273
|
+
const channel = this._channel;
|
|
274
|
+
const checkpoint = this._pendingFinal;
|
|
275
|
+
|
|
276
|
+
if (
|
|
277
|
+
!checkpoint ||
|
|
278
|
+
!channel ||
|
|
279
|
+
channel.readyState !== 'open' ||
|
|
280
|
+
channel.bufferedAmount > this._highWaterBytes
|
|
281
|
+
) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
this._pendingFinal = null;
|
|
286
|
+
this._send(channel, checkpoint);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
_send(channel, checkpoint) {
|
|
290
|
+
this._wireId = (this._wireId + 1) >>> 0;
|
|
291
|
+
|
|
292
|
+
const chunks = encodeStandbyChunks(
|
|
293
|
+
checkpoint,
|
|
294
|
+
this._wireId,
|
|
295
|
+
this._chunkBytes,
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
for (const chunk of chunks) {
|
|
300
|
+
channel.send(chunk);
|
|
301
|
+
}
|
|
302
|
+
} catch {
|
|
303
|
+
return; // канал закрылся посреди отправки — onclose его уберёт
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const now = this._now();
|
|
307
|
+
|
|
308
|
+
this._stats.sent += 1;
|
|
309
|
+
this._stats.lastBytes = checkpoint.bytes.byteLength;
|
|
310
|
+
this._stats.lastChunks = chunks.length;
|
|
311
|
+
this._stats.lastIntervalMs =
|
|
312
|
+
this._lastSentAt === null ? null : now - this._lastSentAt;
|
|
313
|
+
this._stats.lastLatencyMs = Number.isFinite(checkpoint.createdAt)
|
|
314
|
+
? now - checkpoint.createdAt
|
|
315
|
+
: null;
|
|
316
|
+
this._lastSentAt = now;
|
|
317
|
+
this._onStats?.(this.stats);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Передача роли хоста по сроку входа (host-migration-review этап 15, R2):
|
|
2
|
+
// хост с истекающим токеном не вернёт комнату reclaim_host после моргания
|
|
3
|
+
// сигналинга, поэтому за leadMs до истечения роль заранее уходит бете.
|
|
4
|
+
// Одна попытка теряется (беты ещё нет, идёт эстафета Worker'ов, передача
|
|
5
|
+
// сорвалась) — повтор через retryMs, пока вход не истёк.
|
|
6
|
+
//
|
|
7
|
+
// Без DOM: срок, попытку и таймеры инъектирует владелец
|
|
8
|
+
// (client/session/HandoffFlow.js).
|
|
9
|
+
|
|
10
|
+
export default class TokenHandoffTimer {
|
|
11
|
+
/**
|
|
12
|
+
* @param {Object} options
|
|
13
|
+
* @param {Function} options.getExpiresAt - () → мс эпохи | null.
|
|
14
|
+
* @param {number} options.leadMs
|
|
15
|
+
* @param {number} options.retryMs
|
|
16
|
+
* @param {Function} options.tryStart - () → true, если передача началась.
|
|
17
|
+
* @param {Function} [options.now]
|
|
18
|
+
* @param {Object} [options.timers] - { setTimeout, clearTimeout } (тесты).
|
|
19
|
+
*/
|
|
20
|
+
constructor({
|
|
21
|
+
getExpiresAt,
|
|
22
|
+
leadMs,
|
|
23
|
+
retryMs,
|
|
24
|
+
tryStart,
|
|
25
|
+
now = () => Date.now(),
|
|
26
|
+
timers = globalThis,
|
|
27
|
+
}) {
|
|
28
|
+
this._getExpiresAt = getExpiresAt;
|
|
29
|
+
this._leadMs = leadMs;
|
|
30
|
+
this._retryMs = retryMs;
|
|
31
|
+
this._tryStart = tryStart;
|
|
32
|
+
this._now = now;
|
|
33
|
+
this._timers = timers;
|
|
34
|
+
this._timer = null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// срок по текущему входу (роль хоста, новый вход, назначена бета): в окне
|
|
38
|
+
// передачи — попытка сразу
|
|
39
|
+
arm() {
|
|
40
|
+
this._schedule(0);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// попытка не удалась или передача сорвалась: в окне передачи — повтор
|
|
44
|
+
// через retryMs, раньше окна — обычный срок
|
|
45
|
+
retry() {
|
|
46
|
+
this._schedule(this._retryMs);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
cancel() {
|
|
50
|
+
if (this._timer !== null) {
|
|
51
|
+
this._timers.clearTimeout(this._timer);
|
|
52
|
+
this._timer = null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
_schedule(minDelay) {
|
|
57
|
+
this.cancel();
|
|
58
|
+
|
|
59
|
+
const expiresAt = this._getExpiresAt();
|
|
60
|
+
|
|
61
|
+
if (expiresAt === null) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const now = this._now();
|
|
66
|
+
const delay = Math.max(minDelay, expiresAt - this._leadMs - now);
|
|
67
|
+
|
|
68
|
+
// по истёкшему входу передача уже ничего не спасает
|
|
69
|
+
if (now + delay >= expiresAt) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
this._timer = this._timers.setTimeout(() => {
|
|
74
|
+
this._timer = null;
|
|
75
|
+
|
|
76
|
+
if (this._tryStart() !== true) {
|
|
77
|
+
this.retry();
|
|
78
|
+
}
|
|
79
|
+
}, delay);
|
|
80
|
+
}
|
|
81
|
+
}
|