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
|
@@ -2,6 +2,10 @@ import Publisher from '../../../lib/Publisher.js';
|
|
|
2
2
|
|
|
3
3
|
// Singleton VoteModel
|
|
4
4
|
|
|
5
|
+
// имя с '@' — голосование движка (host-migration этап 10: '@changeHost'):
|
|
6
|
+
// его ведёт мастер, а не хост, и игра таких имён не объявляет
|
|
7
|
+
const isEngineVote = name => typeof name === 'string' && name.startsWith('@');
|
|
8
|
+
|
|
5
9
|
let voteModel;
|
|
6
10
|
|
|
7
11
|
export default class VoteModel {
|
|
@@ -13,6 +17,7 @@ export default class VoteModel {
|
|
|
13
17
|
voteModel = this;
|
|
14
18
|
|
|
15
19
|
this._formatMessage = data.formatMessage;
|
|
20
|
+
this._now = data.now ?? (() => Date.now());
|
|
16
21
|
|
|
17
22
|
// игра может не объявлять голосований вовсе (noSpectators): меню по 'm'
|
|
18
23
|
// тогда пустое, а не падение на длине undefined
|
|
@@ -28,6 +33,12 @@ export default class VoteModel {
|
|
|
28
33
|
this._timeOff = false; // флаг отключения времени жизни голосования
|
|
29
34
|
|
|
30
35
|
this._voteName = ''; // название голосования
|
|
36
|
+
this._deadline = null; // конец голосования движка (окно — на остаток)
|
|
37
|
+
this._showing = false; // окно (голосование или меню) открыто
|
|
38
|
+
|
|
39
|
+
// голосования, ждущие закрытия открытого окна: голосование движка не
|
|
40
|
+
// затирает чужое и не затирается им (этап 10)
|
|
41
|
+
this._queue = [];
|
|
31
42
|
|
|
32
43
|
this._title = null; // заголовок голосования
|
|
33
44
|
this._values = []; // все значения голосования
|
|
@@ -45,7 +56,18 @@ export default class VoteModel {
|
|
|
45
56
|
this.publisher.emit('mode', { name: 'vote', status: 'opened' });
|
|
46
57
|
}
|
|
47
58
|
|
|
59
|
+
// голосование хоста по шаблону; false — встало в очередь за открытым
|
|
60
|
+
// голосованием движка
|
|
48
61
|
createWithTemplate({ name, params, values }) {
|
|
62
|
+
if (
|
|
63
|
+
this._showing &&
|
|
64
|
+
this._type === 'vote' &&
|
|
65
|
+
isEngineVote(this._voteName)
|
|
66
|
+
) {
|
|
67
|
+
this._queue.push({ template: { name, params, values } });
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
49
71
|
const templateArr = this._templates[name];
|
|
50
72
|
|
|
51
73
|
if (templateArr) {
|
|
@@ -59,14 +81,63 @@ export default class VoteModel {
|
|
|
59
81
|
|
|
60
82
|
this.createVote(name, title, values, timeOff);
|
|
61
83
|
}
|
|
84
|
+
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Голосование движка (имя с '@'): окно живёт до deadline; открыто другое
|
|
90
|
+
* окно — ждёт в очереди.
|
|
91
|
+
* @param {Object} vote - { name, title, values, deadline }.
|
|
92
|
+
* @returns {boolean} окно открыто сразу.
|
|
93
|
+
*/
|
|
94
|
+
createEngineVote(vote) {
|
|
95
|
+
if (this._showing || this._waitingValues) {
|
|
96
|
+
this._queue.push({ engine: { ...vote } });
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
this._startEngineVote(vote);
|
|
101
|
+
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Голосование движка завершилось (итог от мастера): окно закрывается или
|
|
107
|
+
* снимается из очереди.
|
|
108
|
+
* @param {string} name
|
|
109
|
+
*/
|
|
110
|
+
closeEngineVote(name) {
|
|
111
|
+
this._queue = this._queue.filter(item => item.engine?.name !== name);
|
|
112
|
+
|
|
113
|
+
if (this._showing && this._type === 'vote' && this._voteName === name) {
|
|
114
|
+
this.complete();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// голосования хоста не пережили переподключение: открытое закрывается,
|
|
119
|
+
// ждущие отбрасываются; голосование движка (его ведёт мастер) остаётся
|
|
120
|
+
removeHostVotes() {
|
|
121
|
+
this._queue = this._queue.filter(item => item.engine);
|
|
122
|
+
|
|
123
|
+
if (!(this._type === 'vote' && isEngineVote(this._voteName))) {
|
|
124
|
+
this.complete();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
_startEngineVote({ name, title, values, deadline }) {
|
|
129
|
+
this.createVote(name, title, values, false, deadline);
|
|
130
|
+
this.open();
|
|
62
131
|
}
|
|
63
132
|
|
|
64
|
-
// создает
|
|
65
|
-
createVote(name, title, values, timeOff) {
|
|
133
|
+
// создает голосование; deadline — только у голосований движка
|
|
134
|
+
createVote(name, title, values, timeOff, deadline = null) {
|
|
66
135
|
if (this._waitingValues) {
|
|
67
136
|
return;
|
|
68
137
|
}
|
|
69
138
|
|
|
139
|
+
this._deadline = deadline;
|
|
140
|
+
|
|
70
141
|
this._type = 'vote';
|
|
71
142
|
this._back = false;
|
|
72
143
|
this._more = false;
|
|
@@ -91,7 +162,24 @@ export default class VoteModel {
|
|
|
91
162
|
return;
|
|
92
163
|
}
|
|
93
164
|
|
|
165
|
+
// меню открывается поверх голосования движка — оно вернётся следом
|
|
166
|
+
if (
|
|
167
|
+
this._showing &&
|
|
168
|
+
this._type === 'vote' &&
|
|
169
|
+
isEngineVote(this._voteName)
|
|
170
|
+
) {
|
|
171
|
+
this._queue.unshift({
|
|
172
|
+
engine: {
|
|
173
|
+
name: this._voteName,
|
|
174
|
+
title: this._title,
|
|
175
|
+
values: this._values,
|
|
176
|
+
deadline: this._deadline,
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
94
181
|
this._timeOff = false;
|
|
182
|
+
this._deadline = null;
|
|
95
183
|
|
|
96
184
|
this._type = 'menu';
|
|
97
185
|
this._back = false;
|
|
@@ -204,6 +292,7 @@ export default class VoteModel {
|
|
|
204
292
|
currentValues = this._currentValues;
|
|
205
293
|
}
|
|
206
294
|
|
|
295
|
+
this._showing = true;
|
|
207
296
|
this.publisher.emit('clear', this._timerId);
|
|
208
297
|
|
|
209
298
|
this.publisher.emit('vote', {
|
|
@@ -211,15 +300,44 @@ export default class VoteModel {
|
|
|
211
300
|
list: currentValues,
|
|
212
301
|
back: this._back,
|
|
213
302
|
more: this._more,
|
|
214
|
-
time: this._timeOff === true ? null : this.
|
|
303
|
+
time: this._timeOff === true ? null : this._windowTime(),
|
|
215
304
|
});
|
|
216
305
|
}
|
|
217
306
|
|
|
218
|
-
//
|
|
307
|
+
// время окна: у голосования движка — остаток до его конца
|
|
308
|
+
_windowTime() {
|
|
309
|
+
return this._deadline === null
|
|
310
|
+
? this._time
|
|
311
|
+
: Math.max(0, this._deadline - this._now());
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// завершает голосование; следом открывается ждущее в очереди
|
|
219
315
|
complete() {
|
|
220
316
|
this._waitingValues = false;
|
|
317
|
+
this._showing = false;
|
|
318
|
+
this._deadline = null;
|
|
221
319
|
this.publisher.emit('clear', this._timerId);
|
|
222
320
|
this.publisher.emit('mode', { name: 'vote', status: 'closed' });
|
|
321
|
+
this._openNext();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// первое ждущее голосование; голосование движка, чьё время вышло, —
|
|
325
|
+
// пропускается
|
|
326
|
+
_openNext() {
|
|
327
|
+
while (this._queue.length > 0) {
|
|
328
|
+
const { engine, template } = this._queue.shift();
|
|
329
|
+
|
|
330
|
+
if (engine) {
|
|
331
|
+
if (engine.deadline - this._now() > 0) {
|
|
332
|
+
this._startEngineVote(engine);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
} else {
|
|
336
|
+
this.createWithTemplate(template);
|
|
337
|
+
this.open();
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
223
341
|
}
|
|
224
342
|
|
|
225
343
|
// добавляет id таймера голосования
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import Publisher from '../../../lib/Publisher.js';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
bindLiveErrors,
|
|
4
|
+
buildForm,
|
|
5
|
+
renderFormErrors,
|
|
6
|
+
} from '../../lib/formBuilder.js';
|
|
3
7
|
|
|
4
8
|
// Singleton AuthView
|
|
5
9
|
|
|
@@ -33,10 +37,14 @@ export default class AuthView {
|
|
|
33
37
|
|
|
34
38
|
// ошибки видны по ходу правки (см. bindLiveErrors), а не по клику:
|
|
35
39
|
// до сабмита — по тронутым полям, после — по всей форме
|
|
36
|
-
this._liveErrors = bindLiveErrors(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
this._liveErrors = bindLiveErrors(
|
|
41
|
+
this._fieldsContainer,
|
|
42
|
+
this._error,
|
|
43
|
+
() => ({
|
|
44
|
+
descriptors: this._descriptors,
|
|
45
|
+
fields: this._fields,
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
40
48
|
|
|
41
49
|
// форма заполнена; клиентская проверка (required/regExp/min/max) —
|
|
42
50
|
// сервер всё равно валидирует своими validators (renderError), это лишь
|
|
@@ -69,11 +77,13 @@ export default class AuthView {
|
|
|
69
77
|
|
|
70
78
|
// param.options — дескриптор-хвост (control/label/min/max/... —
|
|
71
79
|
// "options" здесь ключ протокола PS_AUTH_DATA, не список выбора select
|
|
72
|
-
const descriptors = params.map(
|
|
73
|
-
name,
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
80
|
+
const descriptors = params.map(
|
|
81
|
+
({ name, value, options: descriptorRest }) => ({
|
|
82
|
+
name,
|
|
83
|
+
default: value,
|
|
84
|
+
...descriptorRest,
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
77
87
|
|
|
78
88
|
this._descriptors = descriptors;
|
|
79
89
|
// params сюда приходят чужие (PS_AUTH_DATA, тот же массив main.js
|
|
@@ -169,12 +169,17 @@ export default class ClientReportsView {
|
|
|
169
169
|
[timeAgo(report.lastSeen), 'reports-when'],
|
|
170
170
|
[`×${report.count}`, 'reports-count'],
|
|
171
171
|
[`${report.kind}/${report.source}`, 'reports-kind'],
|
|
172
|
-
[
|
|
172
|
+
[
|
|
173
|
+
truncate(String(report.code ?? report.message ?? ''), MESSAGE_PREVIEW),
|
|
174
|
+
'reports-message',
|
|
175
|
+
],
|
|
173
176
|
[report.gameId ? `${report.gameId}@${report.gameVersion ?? '—'}` : '—'],
|
|
174
177
|
[`engine ${report.engineVersion ?? '—'}`],
|
|
175
178
|
[report.box ?? '—'],
|
|
176
179
|
[report.status, `reports-status reports-status-${report.status}`],
|
|
177
|
-
].forEach(([text, className]) =>
|
|
180
|
+
].forEach(([text, className]) =>
|
|
181
|
+
summary.appendChild(this._span(text, className)),
|
|
182
|
+
);
|
|
178
183
|
|
|
179
184
|
details.style.display = this._expanded.has(report.id) ? '' : 'none';
|
|
180
185
|
summary.onclick = () => {
|
|
@@ -199,7 +204,9 @@ export default class ClientReportsView {
|
|
|
199
204
|
const details = document.createElement('div');
|
|
200
205
|
|
|
201
206
|
details.className = 'reports-details';
|
|
202
|
-
details.appendChild(
|
|
207
|
+
details.appendChild(
|
|
208
|
+
this._line(report.message ?? '', 'reports-full-message'),
|
|
209
|
+
);
|
|
203
210
|
|
|
204
211
|
if (report.stack) {
|
|
205
212
|
details.appendChild(this._pre(report.stack));
|
|
@@ -210,7 +217,9 @@ export default class ClientReportsView {
|
|
|
210
217
|
}
|
|
211
218
|
|
|
212
219
|
details.appendChild(this._line(`User agent: ${report.userAgent ?? '—'}`));
|
|
213
|
-
details.appendChild(
|
|
220
|
+
details.appendChild(
|
|
221
|
+
this._line(`Role: ${report.role ?? '—'}; page: ${report.page ?? '—'}`),
|
|
222
|
+
);
|
|
214
223
|
details.appendChild(
|
|
215
224
|
this._line(
|
|
216
225
|
`First seen: ${report.firstSeen ?? '—'}; last seen: ${report.lastSeen ?? '—'}` +
|
|
@@ -218,7 +227,9 @@ export default class ClientReportsView {
|
|
|
218
227
|
),
|
|
219
228
|
);
|
|
220
229
|
details.appendChild(
|
|
221
|
-
this._line(
|
|
230
|
+
this._line(
|
|
231
|
+
`Fingerprint: ${String(report.fingerprint ?? '').slice(0, 12)}`,
|
|
232
|
+
),
|
|
222
233
|
);
|
|
223
234
|
|
|
224
235
|
if (report.statusAt) {
|
|
@@ -209,7 +209,10 @@ export default class GamesView {
|
|
|
209
209
|
this._submitError.textContent = '';
|
|
210
210
|
|
|
211
211
|
this._preview.appendChild(
|
|
212
|
-
this._line(
|
|
212
|
+
this._line(
|
|
213
|
+
`${title ? `${title} · ` : ''}${id ?? '—'}`,
|
|
214
|
+
'games-item-title',
|
|
215
|
+
),
|
|
213
216
|
);
|
|
214
217
|
this._preview.appendChild(this._line(`Version: ${version ?? '—'}`));
|
|
215
218
|
this._preview.appendChild(this._line(`Engine API: ${engineApi ?? '—'}`));
|
|
@@ -233,9 +236,9 @@ export default class GamesView {
|
|
|
233
236
|
// разрешённая версия в поле меняет `${packageName}@${version}`, и
|
|
234
237
|
// следующий blur прошёл бы как «другой пакет»: запрет повтора обновляется
|
|
235
238
|
// фактическим ответом мастера
|
|
236
|
-
this._lastLookup = `${this._fields.get('packageName').value.trim()}@${
|
|
237
|
-
|
|
238
|
-
|
|
239
|
+
this._lastLookup = `${this._fields.get('packageName').value.trim()}@${this._fields
|
|
240
|
+
.get('version')
|
|
241
|
+
.value.trim()}`;
|
|
239
242
|
|
|
240
243
|
const problems = errors || [];
|
|
241
244
|
|
|
@@ -283,7 +286,10 @@ export default class GamesView {
|
|
|
283
286
|
send.type = 'button';
|
|
284
287
|
send.value = 'Update version';
|
|
285
288
|
send.onclick = () =>
|
|
286
|
-
this.publisher.emit('update-version', {
|
|
289
|
+
this.publisher.emit('update-version', {
|
|
290
|
+
id: game.id,
|
|
291
|
+
version: version.value.trim(),
|
|
292
|
+
});
|
|
287
293
|
|
|
288
294
|
item.appendChild(version);
|
|
289
295
|
item.appendChild(send);
|
|
@@ -344,11 +350,15 @@ export default class GamesView {
|
|
|
344
350
|
// решение по ней приняли. Из всей карточки ей нужны срок полного
|
|
345
351
|
// удаления и возврат — остальные кнопки правили бы то, чего не видно
|
|
346
352
|
if (game.deletedAt) {
|
|
347
|
-
item.appendChild(
|
|
353
|
+
item.appendChild(
|
|
354
|
+
this._line(`Author: ${game.authorNick ?? game.authorUserId ?? '—'}`),
|
|
355
|
+
);
|
|
348
356
|
this._appendRepo(item, game.repoUrl);
|
|
349
357
|
item.appendChild(this._line(this._statusLine(game), 'games-purge-line'));
|
|
350
358
|
item.appendChild(
|
|
351
|
-
this._button('Restore', () =>
|
|
359
|
+
this._button('Restore', () =>
|
|
360
|
+
this.publisher.emit('restore', { id: game.id }),
|
|
361
|
+
),
|
|
352
362
|
);
|
|
353
363
|
|
|
354
364
|
return item;
|
|
@@ -356,7 +366,9 @@ export default class GamesView {
|
|
|
356
366
|
|
|
357
367
|
// у игр, засеянных миграцией, автора нет вовсе: без запасного прочерка
|
|
358
368
|
// в строке печаталось бы литеральное "null"
|
|
359
|
-
item.appendChild(
|
|
369
|
+
item.appendChild(
|
|
370
|
+
this._line(`Author: ${game.authorNick ?? game.authorUserId ?? '—'}`),
|
|
371
|
+
);
|
|
360
372
|
|
|
361
373
|
// переназначение автора: игры платформы засеяны без него, и «My games»
|
|
362
374
|
// у их автора пуст, пока админ не проставит ник здесь. Пустое поле —
|
|
@@ -371,7 +383,10 @@ export default class GamesView {
|
|
|
371
383
|
item.appendChild(author);
|
|
372
384
|
item.appendChild(
|
|
373
385
|
this._button('Set author', () =>
|
|
374
|
-
this.publisher.emit('set-author', {
|
|
386
|
+
this.publisher.emit('set-author', {
|
|
387
|
+
id: game.id,
|
|
388
|
+
nick: author.value.trim(),
|
|
389
|
+
}),
|
|
375
390
|
),
|
|
376
391
|
);
|
|
377
392
|
this._appendRepo(item, game.repoUrl);
|
|
@@ -387,7 +402,9 @@ export default class GamesView {
|
|
|
387
402
|
item.appendChild(
|
|
388
403
|
this._line(
|
|
389
404
|
`On this master: ${game.local.downloaded ? 'downloaded' : 'not downloaded'}` +
|
|
390
|
-
(game.local.stagedVersion
|
|
405
|
+
(game.local.stagedVersion
|
|
406
|
+
? `; staged ${game.local.stagedVersion}`
|
|
407
|
+
: '') +
|
|
391
408
|
(game.local.lastError ? `; error: ${game.local.lastError}` : ''),
|
|
392
409
|
),
|
|
393
410
|
);
|
|
@@ -409,7 +426,9 @@ export default class GamesView {
|
|
|
409
426
|
),
|
|
410
427
|
);
|
|
411
428
|
item.appendChild(
|
|
412
|
-
this._button('Approve', () =>
|
|
429
|
+
this._button('Approve', () =>
|
|
430
|
+
this.publisher.emit('approve', { id: game.id }),
|
|
431
|
+
),
|
|
413
432
|
);
|
|
414
433
|
item.appendChild(
|
|
415
434
|
this._button('Reject', () =>
|
|
@@ -417,7 +436,9 @@ export default class GamesView {
|
|
|
417
436
|
),
|
|
418
437
|
);
|
|
419
438
|
item.appendChild(
|
|
420
|
-
this._button('Disable', () =>
|
|
439
|
+
this._button('Disable', () =>
|
|
440
|
+
this.publisher.emit('disable', { id: game.id }),
|
|
441
|
+
),
|
|
421
442
|
);
|
|
422
443
|
item.appendChild(
|
|
423
444
|
this._button('npm versions', () =>
|
|
@@ -457,14 +478,18 @@ export default class GamesView {
|
|
|
457
478
|
|
|
458
479
|
_statusLine(game) {
|
|
459
480
|
const status = STATUS_TITLES[game.status] ?? game.status;
|
|
460
|
-
const date = game.createdAt
|
|
481
|
+
const date = game.createdAt
|
|
482
|
+
? new Date(game.createdAt).toLocaleDateString()
|
|
483
|
+
: '';
|
|
461
484
|
|
|
462
485
|
// у удалённой игры прежний статус — след того, куда её вернёт Restore, —
|
|
463
486
|
// а вести отсчёт человеку нужно от срока полного удаления: только он
|
|
464
487
|
// ограничен во времени
|
|
465
488
|
if (game.deletedAt) {
|
|
466
489
|
const deleted = new Date(game.deletedAt).toLocaleDateString();
|
|
467
|
-
const purge = game.purgeAt
|
|
490
|
+
const purge = game.purgeAt
|
|
491
|
+
? new Date(game.purgeAt).toLocaleDateString()
|
|
492
|
+
: '—';
|
|
468
493
|
|
|
469
494
|
return `Deleted ${deleted} (was ${status}); removed for good on ${purge}`;
|
|
470
495
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import Publisher from '../../../lib/Publisher.js';
|
|
2
2
|
import { ENGINE_PROJECT_URL, ENGINE_VERSION } from '../../lib/engineVersion.js';
|
|
3
3
|
import { renderProjectLink } from '../../lib/footerLink.js';
|
|
4
|
+
import { absoluteLink, formatRoomLink } from '../../lib/roomLink.js';
|
|
4
5
|
|
|
5
6
|
// Singleton LobbyView
|
|
6
7
|
|
|
@@ -9,9 +10,16 @@ let lobbyView;
|
|
|
9
10
|
// неизвестная задержка сортируется в конец списка
|
|
10
11
|
const UNKNOWN_LATENCY = Infinity;
|
|
11
12
|
|
|
13
|
+
// 'unknown' мастер ставит, когда заголовка региона нет: в карточке это шум
|
|
14
|
+
const knownRegion = region =>
|
|
15
|
+
typeof region === 'string' && region !== '' && region !== 'unknown'
|
|
16
|
+
? region
|
|
17
|
+
: null;
|
|
18
|
+
|
|
12
19
|
// Представление лобби: рендер списка серверов, поиск, «Загрузить ещё» и
|
|
13
20
|
// умный пинг через IntersectionObserver (пинг шлётся только для карточек,
|
|
14
21
|
// попавших в видимую область). Observer инъектируется ради тестируемости.
|
|
22
|
+
|
|
15
23
|
export default class LobbyView {
|
|
16
24
|
constructor(model, elems, observerFactory) {
|
|
17
25
|
if (lobbyView) {
|
|
@@ -28,9 +36,13 @@ export default class LobbyView {
|
|
|
28
36
|
|
|
29
37
|
// вкладки правой панели (lobby-page-plan)
|
|
30
38
|
this._tabServersBtn = document.getElementById(elems.tabServersBtnId);
|
|
31
|
-
this._tabLeaderboardBtn = document.getElementById(
|
|
39
|
+
this._tabLeaderboardBtn = document.getElementById(
|
|
40
|
+
elems.tabLeaderboardBtnId,
|
|
41
|
+
);
|
|
32
42
|
this._serversContent = document.getElementById(elems.serversContentId);
|
|
33
|
-
this._leaderboardContent = document.getElementById(
|
|
43
|
+
this._leaderboardContent = document.getElementById(
|
|
44
|
+
elems.leaderboardContentId,
|
|
45
|
+
);
|
|
34
46
|
this._leaderboardList = document.getElementById(elems.leaderboardListId);
|
|
35
47
|
this._leaderboardTitle = document.getElementById(elems.leaderboardTitleId);
|
|
36
48
|
this._leaderboardTotal = document.getElementById(elems.leaderboardTotalId);
|
|
@@ -53,7 +65,10 @@ export default class LobbyView {
|
|
|
53
65
|
version.textContent = ENGINE_VERSION;
|
|
54
66
|
}
|
|
55
67
|
|
|
56
|
-
renderProjectLink(
|
|
68
|
+
renderProjectLink(
|
|
69
|
+
document.getElementById(elems.linkId),
|
|
70
|
+
ENGINE_PROJECT_URL,
|
|
71
|
+
);
|
|
57
72
|
|
|
58
73
|
// заголовок игры для "<TITLE> TOP-N" (SVG-ориентир) — задаётся controller'ом
|
|
59
74
|
// при выборе игры (сама модель не хранит title манифеста)
|
|
@@ -76,7 +91,7 @@ export default class LobbyView {
|
|
|
76
91
|
// границе LIMIT, см. комментарий в renderLeaderboard)
|
|
77
92
|
this._selfNick = '';
|
|
78
93
|
|
|
79
|
-
this._cards = new Map(); //
|
|
94
|
+
this._cards = new Map(); // roomId -> { card, latencyEl, latency }
|
|
80
95
|
|
|
81
96
|
this.publisher = new Publisher();
|
|
82
97
|
|
|
@@ -84,20 +99,23 @@ export default class LobbyView {
|
|
|
84
99
|
observerFactory ||
|
|
85
100
|
(cb => new IntersectionObserver(cb, { root: this._list }));
|
|
86
101
|
|
|
87
|
-
// видимая карточка → запрос пинга (
|
|
102
|
+
// видимая карточка → запрос пинга (roomId в data-атрибуте)
|
|
88
103
|
this._observer = makeObserver(entries => {
|
|
89
104
|
entries.forEach(entry => {
|
|
90
105
|
if (entry.isIntersecting) {
|
|
91
|
-
this.publisher.emit('visible', entry.target.dataset.
|
|
106
|
+
this.publisher.emit('visible', entry.target.dataset.roomId);
|
|
92
107
|
}
|
|
93
108
|
});
|
|
94
109
|
});
|
|
95
110
|
|
|
96
|
-
this._search.oninput = () =>
|
|
111
|
+
this._search.oninput = () =>
|
|
112
|
+
this.publisher.emit('search', this._search.value);
|
|
97
113
|
this._more.onclick = () => this.publisher.emit('more');
|
|
98
114
|
|
|
99
|
-
this._tabServersBtn.onclick = () =>
|
|
100
|
-
|
|
115
|
+
this._tabServersBtn.onclick = () =>
|
|
116
|
+
this.publisher.emit('show-tab', 'servers');
|
|
117
|
+
this._tabLeaderboardBtn.onclick = () =>
|
|
118
|
+
this.publisher.emit('show-tab', 'leaderboard');
|
|
101
119
|
|
|
102
120
|
for (const [period, btn] of this._periodBtns) {
|
|
103
121
|
btn.onclick = () => this.publisher.emit('show-period', period);
|
|
@@ -160,8 +178,7 @@ export default class LobbyView {
|
|
|
160
178
|
renderLeaderboard({ leaderboard, total, myPlacement, loaded = true }) {
|
|
161
179
|
const period = this._periodTitle ? ` — ${this._periodTitle}` : '';
|
|
162
180
|
|
|
163
|
-
this._leaderboardTitle.textContent =
|
|
164
|
-
`${this._gameTitle.toUpperCase()} TOP-${this._leaderboardLimit}${period}`;
|
|
181
|
+
this._leaderboardTitle.textContent = `${this._gameTitle.toUpperCase()} TOP-${this._leaderboardLimit}${period}`;
|
|
165
182
|
this._leaderboardTotal.textContent = `Total: ${total} players`;
|
|
166
183
|
|
|
167
184
|
this._leaderboardList.textContent = '';
|
|
@@ -217,7 +234,8 @@ export default class LobbyView {
|
|
|
217
234
|
// списке, ни в плашке. Ники уникальны (users_nick_lower_unique_idx),
|
|
218
235
|
// так что membership по нику однозначен
|
|
219
236
|
const inTop =
|
|
220
|
-
this._selfNick !== '' &&
|
|
237
|
+
this._selfNick !== '' &&
|
|
238
|
+
leaderboard.some(entry => entry.nick === this._selfNick);
|
|
221
239
|
|
|
222
240
|
if (inTop) {
|
|
223
241
|
this._myPlacement.textContent = '';
|
|
@@ -252,8 +270,8 @@ export default class LobbyView {
|
|
|
252
270
|
}
|
|
253
271
|
|
|
254
272
|
// обновляет задержку карточки и переставляет её по возрастанию latency
|
|
255
|
-
updatePing({
|
|
256
|
-
const entry = this._cards.get(
|
|
273
|
+
updatePing({ roomId, latency }) {
|
|
274
|
+
const entry = this._cards.get(roomId);
|
|
257
275
|
|
|
258
276
|
if (!entry) {
|
|
259
277
|
return;
|
|
@@ -262,35 +280,34 @@ export default class LobbyView {
|
|
|
262
280
|
entry.latency = latency;
|
|
263
281
|
entry.latencyEl.textContent = `${latency} ms`;
|
|
264
282
|
|
|
265
|
-
this._reorderCard(
|
|
283
|
+
this._reorderCard(roomId);
|
|
266
284
|
}
|
|
267
285
|
|
|
268
286
|
_appendCard(server) {
|
|
269
287
|
const card = document.createElement('li');
|
|
270
288
|
|
|
271
289
|
card.className = 'lobby-card';
|
|
272
|
-
card.dataset.
|
|
290
|
+
card.dataset.roomId = server.roomId;
|
|
273
291
|
|
|
274
292
|
const name = document.createElement('span');
|
|
275
293
|
|
|
276
294
|
name.className = 'lobby-card-name';
|
|
277
|
-
// формат "gameId/
|
|
278
|
-
//
|
|
279
|
-
|
|
280
|
-
name.textContent = `${server.gameId ?? '?'}/${server.name}`;
|
|
295
|
+
// формат "gameId/roomId": серверный поиск понимает ровно то, что видно
|
|
296
|
+
// на карточке; gameId nullable для хостов до Этапа 6.4 (code review L6)
|
|
297
|
+
name.textContent = `${server.gameId ?? '?'}/${server.roomId}`;
|
|
281
298
|
|
|
282
299
|
const info = document.createElement('span');
|
|
283
300
|
|
|
284
301
|
info.className = 'lobby-card-info';
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
302
|
+
// пустые сегменты не выводятся: строку карточки игра может не задавать
|
|
303
|
+
// (gameConfig.lobbyInfo), а регион мастер не знает без заголовка CDN
|
|
304
|
+
info.textContent = [
|
|
305
|
+
server.info,
|
|
306
|
+
`${server.currentPlayers}/${server.maxPlayers}`,
|
|
307
|
+
knownRegion(server.region),
|
|
308
|
+
]
|
|
309
|
+
.filter(Boolean)
|
|
310
|
+
.join(' · ');
|
|
294
311
|
|
|
295
312
|
const latencyEl = document.createElement('span');
|
|
296
313
|
|
|
@@ -299,27 +316,61 @@ export default class LobbyView {
|
|
|
299
316
|
server.latency === null ? '…' : `${server.latency} ms`;
|
|
300
317
|
|
|
301
318
|
card.appendChild(name);
|
|
319
|
+
|
|
320
|
+
// прямая ссылка на комнату (host-migration, этап 3); без gameId (хост до
|
|
321
|
+
// Этапа 6.4) ссылку не собрать
|
|
322
|
+
if (server.gameId) {
|
|
323
|
+
card.appendChild(this._copyLinkButton(server));
|
|
324
|
+
}
|
|
325
|
+
|
|
302
326
|
card.appendChild(info);
|
|
303
|
-
card.appendChild(ratingEl);
|
|
304
327
|
card.appendChild(latencyEl);
|
|
305
328
|
|
|
306
|
-
card.onclick = () => this.publisher.emit('join', server.
|
|
329
|
+
card.onclick = () => this.publisher.emit('join', server.roomId);
|
|
307
330
|
|
|
308
331
|
this._list.appendChild(card);
|
|
309
332
|
this._observer.observe(card);
|
|
310
333
|
|
|
311
|
-
this._cards.set(server.
|
|
334
|
+
this._cards.set(server.roomId, {
|
|
312
335
|
card,
|
|
313
336
|
latencyEl,
|
|
314
337
|
latency: server.latency === null ? UNKNOWN_LATENCY : server.latency,
|
|
315
|
-
rating: server.rating,
|
|
316
338
|
});
|
|
317
339
|
}
|
|
318
340
|
|
|
319
|
-
// вставляет карточку перед первым соседом с большей
|
|
320
|
-
//
|
|
321
|
-
|
|
322
|
-
const
|
|
341
|
+
// вставляет карточку перед первым соседом с большей задержкой
|
|
342
|
+
// «Copy link» карточки: клик не должен всплыть до карточки (вход в комнату)
|
|
343
|
+
_copyLinkButton(server) {
|
|
344
|
+
const btn = document.createElement('input');
|
|
345
|
+
const label = 'Copy link';
|
|
346
|
+
|
|
347
|
+
btn.type = 'button';
|
|
348
|
+
btn.className = 'lobby-card-copy';
|
|
349
|
+
btn.value = label;
|
|
350
|
+
btn.onclick = event => {
|
|
351
|
+
event.stopPropagation();
|
|
352
|
+
|
|
353
|
+
const link = absoluteLink(formatRoomLink(server.gameId, server.roomId));
|
|
354
|
+
const feedback = text => {
|
|
355
|
+
btn.value = text;
|
|
356
|
+
setTimeout(() => {
|
|
357
|
+
btn.value = label;
|
|
358
|
+
}, 1500);
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
Promise.resolve()
|
|
362
|
+
.then(() => navigator.clipboard.writeText(link))
|
|
363
|
+
.then(
|
|
364
|
+
() => feedback('Copied'),
|
|
365
|
+
() => feedback('Copy failed'),
|
|
366
|
+
);
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
return btn;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
_reorderCard(roomId) {
|
|
373
|
+
const entry = this._cards.get(roomId);
|
|
323
374
|
const { card } = entry;
|
|
324
375
|
|
|
325
376
|
let before = null;
|
|
@@ -329,13 +380,9 @@ export default class LobbyView {
|
|
|
329
380
|
continue;
|
|
330
381
|
}
|
|
331
382
|
|
|
332
|
-
const other = this._cards.get(sibling.dataset.
|
|
383
|
+
const other = this._cards.get(sibling.dataset.roomId);
|
|
333
384
|
|
|
334
|
-
if (
|
|
335
|
-
other &&
|
|
336
|
-
(other.latency > entry.latency ||
|
|
337
|
-
(other.latency === entry.latency && other.rating < entry.rating))
|
|
338
|
-
) {
|
|
385
|
+
if (other && other.latency > entry.latency) {
|
|
339
386
|
before = sibling;
|
|
340
387
|
break;
|
|
341
388
|
}
|