vimp-engine 0.37.1 → 0.38.1
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/boot.js +1 -1
- package/src/client/components/controller/ClientReports.js +2 -1
- package/src/client/components/controller/Games.js +1 -1
- package/src/client/components/controller/LobbyAuth.js +101 -15
- package/src/client/components/controller/Profile.js +95 -0
- package/src/client/components/model/Games.js +1 -1
- package/src/client/components/model/LobbyAuth.js +382 -115
- package/src/client/components/model/Profile.js +144 -0
- package/src/client/components/view/LobbyAuth.js +150 -62
- package/src/client/components/view/Profile.js +372 -0
- package/src/client/lib/authApi.js +106 -0
- package/src/client/lib/hostCaps.js +3 -2
- package/src/client/lib/pkce.js +23 -0
- package/src/client/lib/roomLink.js +1 -1
- package/src/client/lib/sessionSync.js +45 -0
- package/src/client/lib/signalingErrors.js +3 -3
- package/src/client/lib/turnstile.js +130 -0
- package/src/client/main.js +132 -26
- package/src/client/network/TokenRenewer.js +99 -0
- package/src/client/session/GuestSession.js +63 -17
- package/src/client/session/Membership.js +2 -1
- package/src/client/style.css +128 -16
- package/src/client/views/includes/lobby.pug +11 -6
- package/src/client/views/includes/lobbyAuth.pug +34 -16
- package/src/client/views/includes/profile.pug +73 -0
- package/src/client/views/index.pug +1 -0
- package/src/config/authClient.js +55 -30
- package/src/config/chatMessages.js +1 -2
- package/src/config/env.js +2 -2
- package/src/config/lobby.js +45 -1
- package/src/config/master.js +8 -5
- package/src/config/wsports.js +4 -1
- package/src/devtools/builtinScenario.js +1 -1
- package/src/devtools/contract/rules/a2-package-scripts.js +1 -1
- package/src/devtools/contract/rules/a4-vite-config.js +1 -1
- package/src/devtools/contract/rules/b8-system-messages.js +1 -1
- package/src/devtools/invariants.js +1 -1
- package/src/devtools/pluginLoader.js +1 -1
- package/src/host/GameCoreAdapter.js +0 -3
- package/src/host/HostGame.js +28 -0
- package/src/host/PortMachine.js +50 -1
- package/src/host/meta/SocketManager.js +0 -13
- package/src/host/meta/core/RoundManager.js +0 -19
- package/src/host/meta/modules/PlayerDataSync.js +29 -3
- package/src/host/meta/modules/chat/systemMessages.js +1 -4
- package/src/lib/loadGamePackage.js +1 -1
- package/src/lib/validators.js +8 -0
- package/src/standalone/index.js +1 -1
|
@@ -26,7 +26,7 @@ const FIXTURE_UNUSED_KEYS = ['e1'];
|
|
|
26
26
|
const firstKey = dict => Object.keys(dict ?? {})[0];
|
|
27
27
|
|
|
28
28
|
// Клавиша для смоука обязана быть удерживаемой: по конвенции playerKeys
|
|
29
|
-
// (docs/
|
|
29
|
+
// (docs/en/plugin-api.md) `type: 1` — триггер, у которого 'up'
|
|
30
30
|
// игнорируется, и «нажал — отпустил» не даёт ни удержания, ни движения.
|
|
31
31
|
// Порядок объявления в playerKeys контрактом не задан, поэтому берём первую
|
|
32
32
|
// без type; если игра объявила одни триггеры — первую любую, смоук всё равно
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ERROR, skip, verdict } from '../result.js';
|
|
2
2
|
|
|
3
|
-
// Набор npm-скриптов пакета игры (docs/
|
|
3
|
+
// Набор npm-скриптов пакета игры (docs/en/packaging.md). Это не вкусовое
|
|
4
4
|
// требование: инструкции движка и CI сгенерированной игры зовут их по имени,
|
|
5
5
|
// а отсутствующий core:build:node тихо оставляет headless-прогон без ядра.
|
|
6
6
|
const REQUIRED = [
|
|
@@ -2,7 +2,7 @@ import { ERROR, skip, verdict } from '../result.js';
|
|
|
2
2
|
|
|
3
3
|
// vite.config.js проверяется как текст, а не импортом: конфиг —
|
|
4
4
|
// функция от mode и вне Vite не исполняется. Каждый пункт здесь ловит
|
|
5
|
-
// молчаливую поломку сборки, а не стилистику (docs/
|
|
5
|
+
// молчаливую поломку сборки, а не стилистику (docs/en/packaging.md):
|
|
6
6
|
// без preserveEntrySignatures default-экспорт плагина вытряхивает
|
|
7
7
|
// tree-shaking, а build.lib всегда инлайнит ассеты.
|
|
8
8
|
const REQUIRED = [
|
|
@@ -4,7 +4,7 @@ import { ERROR, skip, verdict } from '../result.js';
|
|
|
4
4
|
// реестра: код в зарезервированном диапазоне затирает движковое сообщение
|
|
5
5
|
// без предупреждения, и вместо «Vote passed» игрок видит игровой текст.
|
|
6
6
|
// Диапазоны — длины движковых групп (config/chatMessages.js).
|
|
7
|
-
export const RESERVED = { s: 11, v: 15, m: 1, c: 1
|
|
7
|
+
export const RESERVED = { s: 11, v: 15, m: 1, c: 1 };
|
|
8
8
|
|
|
9
9
|
export default {
|
|
10
10
|
id: 'B8',
|
|
@@ -484,7 +484,7 @@ function predictionDrift({ clients, scenario }) {
|
|
|
484
484
|
// Компоненты player-блока — игровая раскладка, движок знает только их
|
|
485
485
|
// порядок, поэтому нарушение адресуется индексом. Исключение — уровень 0
|
|
486
486
|
// (source 'camera'): там движок сравнивает ровно мировые x/y, и называть их
|
|
487
|
-
// «#0/#1» значит прятать причину (см. docs/
|
|
487
|
+
// «#0/#1» значит прятать причину (см. docs/en/debugging.md).
|
|
488
488
|
const CAMERA_COMPONENTS = ['x', 'y'];
|
|
489
489
|
|
|
490
490
|
function formatDivergence(record) {
|
|
@@ -12,7 +12,7 @@ import { loadGamePackage } from '../lib/loadGamePackage.js';
|
|
|
12
12
|
// 3. ничего — фикстура miniGame из этого репозитория.
|
|
13
13
|
//
|
|
14
14
|
// entries.wasmNode — новое ОПЦИОНАЛЬНОЕ поле манифеста: конвенция
|
|
15
|
-
// core/pkg-node/ уже описана в docs/
|
|
15
|
+
// core/pkg-node/ уже описана в docs/en/packaging.md, не хватало только
|
|
16
16
|
// пути в манифесте. Его отсутствие не ошибка — просто игра не прогоняется
|
|
17
17
|
// headless своим настоящим ядром.
|
|
18
18
|
|
|
@@ -106,9 +106,6 @@ export default class GameCoreAdapter {
|
|
|
106
106
|
return JSON.parse(this._core.remove_players_and_shots());
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
// имена — мета (ParticipantManager/Stat), ядру не нужны
|
|
110
|
-
changeName() {}
|
|
111
|
-
|
|
112
109
|
// перемещает танк при смене команды/респауне (Game.changePlayerData)
|
|
113
110
|
changePlayerData(gameId, data) {
|
|
114
111
|
const [x, y, angle] = data.respawnData;
|
package/src/host/HostGame.js
CHANGED
|
@@ -1974,6 +1974,34 @@ export default class HostGame {
|
|
|
1974
1974
|
};
|
|
1975
1975
|
}
|
|
1976
1976
|
|
|
1977
|
+
// есть ли у участника identity-токен: в standalone и dedicated личность
|
|
1978
|
+
// гостевая, и обновлять там нечего
|
|
1979
|
+
hasToken(gameId) {
|
|
1980
|
+
const token = this._participants.get(gameId)?.token;
|
|
1981
|
+
|
|
1982
|
+
return typeof token === 'string' && token !== '';
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
// ник, под которым участник вошёл (тот же, что сверяет RESUME_REQUEST)
|
|
1986
|
+
getIdentityName(gameId) {
|
|
1987
|
+
return this._participants.get(gameId)?.identityName ?? null;
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
// свежий токен участника (порт TOKEN_REFRESH): им пишутся rank/state, и
|
|
1991
|
+
// он же поедет в эстафету Worker'ов
|
|
1992
|
+
refreshToken(gameId, token) {
|
|
1993
|
+
const user = this._participants.get(gameId);
|
|
1994
|
+
|
|
1995
|
+
if (!user) {
|
|
1996
|
+
return false;
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
user.token = token;
|
|
2000
|
+
this._playerDataSync.attachToken(gameId, token);
|
|
2001
|
+
|
|
2002
|
+
return true;
|
|
2003
|
+
}
|
|
2004
|
+
|
|
1977
2005
|
// привязывает участника к новому соединению и шлёт ему всё, что нужно,
|
|
1978
2006
|
// чтобы продолжить с того же места: RESUME_RESULT, затем пакет входа
|
|
1979
2007
|
// (очистка, полный кадр, stat/panel/keyset/accolades) и новый секрет.
|
package/src/host/PortMachine.js
CHANGED
|
@@ -23,8 +23,9 @@ const PC_VOTE_DATA = wsports.client.VOTE_DATA;
|
|
|
23
23
|
const PC_PONG = wsports.client.PONG;
|
|
24
24
|
const PC_RESUME_REQUEST = wsports.client.RESUME_REQUEST;
|
|
25
25
|
const PC_LEAVE = wsports.client.LEAVE;
|
|
26
|
+
const PC_TOKEN_REFRESH = wsports.client.TOKEN_REFRESH;
|
|
26
27
|
|
|
27
|
-
const PORT_COUNT =
|
|
28
|
+
const PORT_COUNT = 12;
|
|
28
29
|
|
|
29
30
|
// версии формата RESUME_REQUEST, которые понимает хост
|
|
30
31
|
const RESUME_VERSION = 1;
|
|
@@ -38,6 +39,7 @@ const IN_GAME_PORTS = [
|
|
|
38
39
|
PC_VOTE_DATA,
|
|
39
40
|
PC_PONG,
|
|
40
41
|
PC_LEAVE,
|
|
42
|
+
PC_TOKEN_REFRESH,
|
|
41
43
|
];
|
|
42
44
|
|
|
43
45
|
export default class PortMachine {
|
|
@@ -139,6 +141,7 @@ export default class PortMachine {
|
|
|
139
141
|
const state = {
|
|
140
142
|
gameId: undefined,
|
|
141
143
|
enabled: new Array(PORT_COUNT).fill(false),
|
|
144
|
+
refreshing: false,
|
|
142
145
|
};
|
|
143
146
|
|
|
144
147
|
this._clients.set(socketId, state);
|
|
@@ -153,6 +156,7 @@ export default class PortMachine {
|
|
|
153
156
|
const state = {
|
|
154
157
|
gameId: undefined,
|
|
155
158
|
enabled: new Array(PORT_COUNT).fill(false),
|
|
159
|
+
refreshing: false,
|
|
156
160
|
resumeTimer: null,
|
|
157
161
|
};
|
|
158
162
|
|
|
@@ -210,6 +214,7 @@ export default class PortMachine {
|
|
|
210
214
|
const state = {
|
|
211
215
|
gameId,
|
|
212
216
|
enabled: new Array(PORT_COUNT).fill(false),
|
|
217
|
+
refreshing: false,
|
|
213
218
|
};
|
|
214
219
|
|
|
215
220
|
state.methods = this._buildPortMethods(socketId, state);
|
|
@@ -379,6 +384,47 @@ export default class PortMachine {
|
|
|
379
384
|
.catch(() => reject('auth'));
|
|
380
385
|
}
|
|
381
386
|
|
|
387
|
+
// TOKEN_REFRESH { token }: токен принимается, только если он подписан
|
|
388
|
+
// auth-сервисом и выдан на тот же ник, под которым участник вошёл
|
|
389
|
+
_refreshToken(socketId, state, data) {
|
|
390
|
+
const { gameId } = state;
|
|
391
|
+
|
|
392
|
+
if (
|
|
393
|
+
typeof data?.token !== 'string' ||
|
|
394
|
+
gameId === undefined ||
|
|
395
|
+
state.refreshing ||
|
|
396
|
+
!this._host.hasToken(gameId)
|
|
397
|
+
) {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// не больше одной проверки подписи в полёте на соединение
|
|
402
|
+
state.refreshing = true;
|
|
403
|
+
|
|
404
|
+
this._identity
|
|
405
|
+
.resolve({ token: data.token }, socketId)
|
|
406
|
+
.then(name => {
|
|
407
|
+
if (!this._clients.has(socketId)) {
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (name !== this._host.getIdentityName(gameId)) {
|
|
412
|
+
console.warn(
|
|
413
|
+
'[host] TOKEN_REFRESH: token of another identity ignored',
|
|
414
|
+
);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
this._host.refreshToken(gameId, data.token);
|
|
419
|
+
})
|
|
420
|
+
.catch(() => {
|
|
421
|
+
// недействительный токен: участник остаётся со старым
|
|
422
|
+
})
|
|
423
|
+
.finally(() => {
|
|
424
|
+
state.refreshing = false;
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
382
428
|
// перехват полуоткрытой сессии: участник ещё числится за прежним
|
|
383
429
|
// соединением (обрыв, которого хост не заметил) — закрыть его, не снимая
|
|
384
430
|
// участника; его поздний disconnect уже ничего не найдёт
|
|
@@ -553,6 +599,9 @@ export default class PortMachine {
|
|
|
553
599
|
|
|
554
600
|
// 10: leave
|
|
555
601
|
() => this._leave(socketId, state),
|
|
602
|
+
|
|
603
|
+
// 11: token refresh
|
|
604
|
+
data => this._refreshToken(socketId, state, data),
|
|
556
605
|
];
|
|
557
606
|
}
|
|
558
607
|
}
|
|
@@ -30,7 +30,6 @@ export default class SocketManager {
|
|
|
30
30
|
this._PORT_SOUND_DATA = ports.SOUND_DATA;
|
|
31
31
|
this._PORT_GAME_INFORM_DATA = ports.GAME_INFORM_DATA;
|
|
32
32
|
this._PORT_TECH_INFORM_DATA = ports.TECH_INFORM_DATA;
|
|
33
|
-
this._PORT_MISC = ports.MISC;
|
|
34
33
|
this._PORT_PING = ports.PING;
|
|
35
34
|
this._PORT_CLEAR = ports.CLEAR;
|
|
36
35
|
this._PORT_CONSOLE = ports.CONSOLE;
|
|
@@ -455,16 +454,4 @@ export default class SocketManager {
|
|
|
455
454
|
sendResumeResult(socketId, data) {
|
|
456
455
|
this._send(socketId, this._PORT_RESUME_RESULT, data);
|
|
457
456
|
}
|
|
458
|
-
|
|
459
|
-
/**
|
|
460
|
-
* Отправка команды смены имени.
|
|
461
|
-
* @param {string} socketId
|
|
462
|
-
* @param {string} name
|
|
463
|
-
*/
|
|
464
|
-
sendName(socketId, name) {
|
|
465
|
-
this._send(socketId, this._PORT_MISC, {
|
|
466
|
-
key: 'localstorageNameReplace',
|
|
467
|
-
value: name,
|
|
468
|
-
});
|
|
469
|
-
}
|
|
470
457
|
}
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { isValidName } from '../../../lib/validators.js';
|
|
2
|
-
|
|
3
1
|
// масштабирование данных карты (физика, респауны) под mapScale
|
|
4
2
|
function scaleMapData(mapData) {
|
|
5
3
|
const scale = mapData.scale;
|
|
@@ -522,23 +520,6 @@ class RoundManager {
|
|
|
522
520
|
return true;
|
|
523
521
|
}
|
|
524
522
|
|
|
525
|
-
// меняет ник игрока
|
|
526
|
-
changeName(gameId, name) {
|
|
527
|
-
const user = this._participants.get(gameId);
|
|
528
|
-
const oldName = user.name;
|
|
529
|
-
|
|
530
|
-
if (isValidName(name)) {
|
|
531
|
-
name = this._participants.checkName(name);
|
|
532
|
-
user.name = name;
|
|
533
|
-
this._game.changeName(gameId, name);
|
|
534
|
-
this._stat.updateUser(gameId, user.teamId, { name });
|
|
535
|
-
this._chat.pushSystem('NAME_CHANGED', [oldName, name]);
|
|
536
|
-
this._socketManager.sendName(user.socketId, name);
|
|
537
|
-
} else {
|
|
538
|
-
this._chat.pushSystemByUser(gameId, 'NAME_INVALID');
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
|
|
542
523
|
// меняет команду игрока
|
|
543
524
|
changeTeam(gameId, newTeam) {
|
|
544
525
|
this._applyTeamChange(gameId, newTeam);
|
|
@@ -663,7 +663,7 @@ export default class PlayerDataSync {
|
|
|
663
663
|
do {
|
|
664
664
|
entry.flushAgain = false;
|
|
665
665
|
await this._sync(participantId, entry);
|
|
666
|
-
} while (entry.flushAgain);
|
|
666
|
+
} while (entry.flushAgain && !entry.awaitingToken);
|
|
667
667
|
} finally {
|
|
668
668
|
entry.inFlight = false;
|
|
669
669
|
entry.inFlightPromise = null;
|
|
@@ -673,6 +673,16 @@ export default class PlayerDataSync {
|
|
|
673
673
|
return entry.inFlightPromise;
|
|
674
674
|
}
|
|
675
675
|
|
|
676
|
+
// 401: запрос ушёл с токеном usedToken. Если за время полёта приложен
|
|
677
|
+
// свежий (attachToken), ждать нечего — повтор уходит в этой же серии
|
|
678
|
+
_onTokenExpired(entry, usedToken) {
|
|
679
|
+
if (entry.token === usedToken) {
|
|
680
|
+
entry.awaitingToken = true;
|
|
681
|
+
} else {
|
|
682
|
+
entry.flushAgain = true;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
676
686
|
// F4: если исходная load() не удалась, PUT дефолтом затёр бы реальные
|
|
677
687
|
// сохранённые рейтинг/state — вместо этого повторяем load() и шлём PUT
|
|
678
688
|
// только для того, что реально загрузилось
|
|
@@ -756,8 +766,19 @@ export default class PlayerDataSync {
|
|
|
756
766
|
// получит тот же ответ. Оставить запись открытой значит слать
|
|
757
767
|
// заведомый мусор на каждом flush до конца жизни комнаты, а
|
|
758
768
|
// накопленное после неё не ушло бы вовсе. Очки теряются, и это
|
|
759
|
-
// честнее вечного цикла (сам отказ уже в логе выше)
|
|
760
|
-
|
|
769
|
+
// честнее вечного цикла (сам отказ уже в логе выше).
|
|
770
|
+
// 401 — отказ не по содержанию, а по сроку токена: запись
|
|
771
|
+
// остаётся открытой и уйдёт со свежим (attachToken)
|
|
772
|
+
if (res.status === 401) {
|
|
773
|
+
this._onTokenExpired(entry, token);
|
|
774
|
+
console.warn(
|
|
775
|
+
`[playerData] PUT rank 401 for ${participantId} — waiting for a fresh token`,
|
|
776
|
+
);
|
|
777
|
+
} else if (
|
|
778
|
+
res.status >= 400 &&
|
|
779
|
+
res.status < 500 &&
|
|
780
|
+
res.status !== 429
|
|
781
|
+
) {
|
|
761
782
|
drop();
|
|
762
783
|
}
|
|
763
784
|
}
|
|
@@ -787,6 +808,11 @@ export default class PlayerDataSync {
|
|
|
787
808
|
console.warn(
|
|
788
809
|
`[playerData] PUT state ${res.status} for ${participantId}`,
|
|
789
810
|
);
|
|
811
|
+
|
|
812
|
+
// токен истёк: состояние уйдёт со свежим (attachToken)
|
|
813
|
+
if (res.status === 401) {
|
|
814
|
+
this._onTokenExpired(entry, token);
|
|
815
|
+
}
|
|
790
816
|
}
|
|
791
817
|
}),
|
|
792
818
|
);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Реестр кодов системных сообщений чата. Движковые группы: s (статусы),
|
|
2
|
-
// v (голосования), m (карты), c (команды)
|
|
2
|
+
// v (голосования), m (карты), c (команды). Игровые коды
|
|
3
3
|
// (у танков — группа b:*) добавляются через registerCodes и не должны
|
|
4
4
|
// пересекаться с движковыми группами. Английские тексты движковых кодов — config/chatMessages.js,
|
|
5
5
|
// игровых — на клиенте (конфиг игры).
|
|
@@ -46,9 +46,6 @@ export const ENGINE_MESSAGE_CODES = Object.freeze({
|
|
|
46
46
|
|
|
47
47
|
COMMANDS_NOT_FOUND: 'c:0', // Command not found
|
|
48
48
|
RANK: 'c:1', // Your rank: {0}
|
|
49
|
-
|
|
50
|
-
NAME_INVALID: 'n:0', // Invalid name
|
|
51
|
-
NAME_CHANGED: 'n:1', // {0} changed name to {1}
|
|
52
49
|
});
|
|
53
50
|
|
|
54
51
|
// изменяемый реестр: движковые коды + игровые из registerCodes
|
|
@@ -12,7 +12,7 @@ import { ENGINE_CAPABILITIES } from './capabilities.js';
|
|
|
12
12
|
// должен зависеть от отладочного контура (граница из CLAUDE.md).
|
|
13
13
|
//
|
|
14
14
|
// entries.wasmNode — node-сборка ядра игры (конвенция core/pkg-node,
|
|
15
|
-
// docs/
|
|
15
|
+
// docs/en/packaging.md). Для боевой игры она обязательна: без ядра
|
|
16
16
|
// авторитетную симуляцию крутить нечем.
|
|
17
17
|
|
|
18
18
|
/**
|
package/src/lib/validators.js
CHANGED
|
@@ -13,6 +13,14 @@ const NAME_REGEXP = new RegExp('^[a-zA-Z]([\\w\\s#]{0,13})[\\w]{1}$');
|
|
|
13
13
|
export const isValidName = name =>
|
|
14
14
|
typeof name === 'string' && NAME_REGEXP.test(name);
|
|
15
15
|
|
|
16
|
+
// ник профиля: копия правила auth-сервиса (packages/auth/src/lib/validators.js)
|
|
17
|
+
// — общей рантайм-зависимости у пакетов нет. Строже isValidName: внутри ника
|
|
18
|
+
// допустим только обычный пробел, не любой \s
|
|
19
|
+
const NICK_REGEXP = new RegExp('^[a-zA-Z]([\\w #]{0,13})[\\w]{1}$');
|
|
20
|
+
|
|
21
|
+
export const isValidNick = nick =>
|
|
22
|
+
typeof nick === 'string' && NICK_REGEXP.test(nick);
|
|
23
|
+
|
|
16
24
|
// потолок длины поля формы, когда maxlength в дескрипторе не объявлен.
|
|
17
25
|
// regExp игры исполняется на хосте против строки, которую прислал клиент, и
|
|
18
26
|
// катастрофический паттерн вроде "(a+)+b" превращает три десятка символов в
|
package/src/standalone/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import { ensureGameShell } from '../client/views/gameShell.js';
|
|
|
8
8
|
|
|
9
9
|
// Standalone SDK (Этап 3 плана standalone-sdk): единственная точка входа для
|
|
10
10
|
// репозитория игры — `npm run dev` открывает вкладку с работающим матчем без
|
|
11
|
-
// мастера, без
|
|
11
|
+
// мастера, без входа и без экрана лобби.
|
|
12
12
|
//
|
|
13
13
|
// Отличия от прода (осознанные, см. plan/standalone-sdk/README.md):
|
|
14
14
|
// - хост крутится в главном потоке (inline), а не в Worker'е: HostPlugin
|