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
package/src/config/master.js
CHANGED
|
@@ -2,7 +2,14 @@ import path from 'path';
|
|
|
2
2
|
import { fileURLToPath } from 'url';
|
|
3
3
|
|
|
4
4
|
// корень репозитория — якорь от расположения файла, не от cwd
|
|
5
|
-
const rootDir = path.resolve(
|
|
5
|
+
const rootDir = path.resolve(
|
|
6
|
+
fileURLToPath(import.meta.url),
|
|
7
|
+
'..',
|
|
8
|
+
'..',
|
|
9
|
+
'..',
|
|
10
|
+
'..',
|
|
11
|
+
'..',
|
|
12
|
+
);
|
|
6
13
|
|
|
7
14
|
export default {
|
|
8
15
|
name: 'VIMP Master Server',
|
|
@@ -66,14 +73,106 @@ export default {
|
|
|
66
73
|
|
|
67
74
|
// ограничения регистрируемых комнат
|
|
68
75
|
host: {
|
|
69
|
-
maxNameLength: 30, // длина имени комнаты
|
|
70
76
|
// санитарная рамка вместимости комнаты для случая, когда игра комнаты
|
|
71
77
|
// неизвестна мастеру (gameId: null у старых хостов или id не из
|
|
72
78
|
// каталога). Потолок известной игры задаёт её манифест
|
|
73
79
|
// (roomDefaults.maxPlayers) — движок его не ограничивает
|
|
74
80
|
maxPlayersLimit: 8,
|
|
75
|
-
heartbeatTimeout: 30000, // нет heartbeat дольше —
|
|
76
|
-
sweepInterval: 10000, // период
|
|
81
|
+
heartbeatTimeout: 30000, // нет heartbeat дольше — хост потерян
|
|
82
|
+
sweepInterval: 10000, // период уборки реестра комнат
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
// жизнь комнаты (host-migration, этап 2): комната живёт, пока в ней есть
|
|
86
|
+
// участник
|
|
87
|
+
room: {
|
|
88
|
+
// отсоединённый участник ещё считается в комнате (реконнект сигналинга)
|
|
89
|
+
memberGraceMs: 15000,
|
|
90
|
+
// обрыв WS хоста запускает миграцию сразу (reclaim_host до регистрации
|
|
91
|
+
// преемника её отменяет, этап 7.0); если повысить некого — комната
|
|
92
|
+
// ждёт reclaim_host столько, потом уборка мигрирует или закрывает её
|
|
93
|
+
hostReclaimGraceMs: 10000,
|
|
94
|
+
// потолок строки карточки комнаты в лобби (gameConfig.lobbyInfo игры)
|
|
95
|
+
maxInfoLength: 48,
|
|
96
|
+
// RTT участников (host-migration этап 6): ws.ping() каждой сессии с
|
|
97
|
+
// этим периодом; EMA RTT и джиттера — единая линейка связности для
|
|
98
|
+
// хоста и кандидатов
|
|
99
|
+
rttProbeIntervalMs: 5000,
|
|
100
|
+
// сессия без pong дольше — terminate(): «тихо умерший» хост находится
|
|
101
|
+
// быстрее, чем по heartbeatTimeout
|
|
102
|
+
wsDeadAfterMs: 12000,
|
|
103
|
+
// преемник (бета): кандидат должен пробыть в комнате не меньше этого
|
|
104
|
+
minMemberAgeMs: 10000,
|
|
105
|
+
// плановый пересчёт преемника (помимо join/leave/member_update)
|
|
106
|
+
successorReviewMs: 15000,
|
|
107
|
+
// гистерезис смены беты: лучший кандидат держит score не хуже
|
|
108
|
+
// successorSwitchRatio × score текущего дольше successorSwitchSustainMs
|
|
109
|
+
successorSwitchSustainMs: 30000,
|
|
110
|
+
successorSwitchRatio: 0.65,
|
|
111
|
+
// аварийная миграция (этап 7): бета — преемник с точкой, если она
|
|
112
|
+
// получила её не раньше checkpointMaxAgeMs назад (по standby_status.ageMs;
|
|
113
|
+
// статус идёт раз в 5 с — окно больше двух периодов), иначе cold-промоушен любого
|
|
114
|
+
// способного; преемник не занял комнату за promotionTimeoutMs
|
|
115
|
+
// (coldPromotionTimeoutMs — он перезагружает страницу) — следующий
|
|
116
|
+
checkpointMaxAgeMs: 12000,
|
|
117
|
+
promotionTimeoutMs: 10000,
|
|
118
|
+
coldPromotionTimeoutMs: 25000,
|
|
119
|
+
// отчёты host_unreachable: хост не ответил на probe за probeTimeoutMs —
|
|
120
|
+
// потерян; ответил, но за reportWindowMs отчиталась половина гостей —
|
|
121
|
+
// принудительная миграция, не чаще forcedMigrationCooldownMs на комнату.
|
|
122
|
+
// Гости и отчёты считаются по аккаунтам; кворум — не меньше
|
|
123
|
+
// minUnreachableReporters разных пользователей (в паре с плохим P2P один
|
|
124
|
+
// гость не снимает хоста — иначе роль «пинг-понгом» ходит по кругу)
|
|
125
|
+
probeTimeoutMs: 2000,
|
|
126
|
+
reportWindowMs: 5000,
|
|
127
|
+
forcedMigrationCooldownMs: 30000,
|
|
128
|
+
minUnreachableReporters: 2,
|
|
129
|
+
// плановая передача (этап 8): преемник не занял комнату за
|
|
130
|
+
// handoffTimeoutMs — передача отменяется, хост размораживает матч
|
|
131
|
+
handoffTimeoutMs: 8000,
|
|
132
|
+
// host_migrating.waitMs — дедлайн текущей попытки промоушена (передачи)
|
|
133
|
+
// плюс migrationNoticeMarginMs: столько гость ждёт host_changed
|
|
134
|
+
migrationNoticeMarginMs: 5000,
|
|
135
|
+
// автотриггеры (этап 9c). Сетевой лаг хоста: медиана его RTT до гостей
|
|
136
|
+
// (host_health) выше lagRttThresholdMs непрерывно lagSustainMs, а score
|
|
137
|
+
// беты лучше score хоста хотя бы на lagImprovementRatio — хосту
|
|
138
|
+
// request_handoff. Не раньше autoMigrationCooldownMs с прошлой
|
|
139
|
+
// авто-смены хоста в комнате (overload/hidden/network — кулдаун общий)
|
|
140
|
+
// и с получения роли текущим хостом
|
|
141
|
+
lagRttThresholdMs: 250,
|
|
142
|
+
lagSustainMs: 10000,
|
|
143
|
+
lagImprovementRatio: 0.35,
|
|
144
|
+
autoMigrationCooldownMs: 90000,
|
|
145
|
+
// бета — не ниже этого FPS рендера (caps.fps гостя; неизвестный FPS не
|
|
146
|
+
// отсеивает). Аварийный промоушен порог не применяет
|
|
147
|
+
minSuccessorFps: 30,
|
|
148
|
+
// голосование «Change host» (этап 10, /changehost; только лобби-режим):
|
|
149
|
+
// голоса считает мастер. Длительность голосования; повторный старт в
|
|
150
|
+
// комнате и тем же пользователем — не раньше кулдаунов; прошедшее
|
|
151
|
+
// голосование просит хоста отдать роль, не начал за voteForceAfterMs —
|
|
152
|
+
// принудительная миграция; снятый хост не бета и не хост комнаты
|
|
153
|
+
// demotedCooldownMs (кроме случая, когда больше принять некому).
|
|
154
|
+
// Один голос на аккаунт; голосует и начинает участник, пробывший в
|
|
155
|
+
// комнате не меньше minVoterAgeMs (свежие вкладки не накручивают голоса).
|
|
156
|
+
// После смены хоста голосование ждёт первого room_peers нового хоста —
|
|
157
|
+
// до него подтверждёнными считаются все, и фантомы попали бы в число
|
|
158
|
+
// голосующих, — но не дольше peersReportGraceMs (больше
|
|
159
|
+
// lobby.migration.peersReportIntervalMs: потерянный первый отчёт
|
|
160
|
+
// повторится внутри окна; хост старше room_peers отчёта не пришлёт)
|
|
161
|
+
vote: {
|
|
162
|
+
hostVoteDurationMs: 15000,
|
|
163
|
+
roomVoteCooldownMs: 120000,
|
|
164
|
+
userStartCooldownMs: 60000,
|
|
165
|
+
voteForceAfterMs: 5000,
|
|
166
|
+
demotedCooldownMs: 600000,
|
|
167
|
+
minVoterAgeMs: 30000,
|
|
168
|
+
peersReportGraceMs: 20000,
|
|
169
|
+
},
|
|
170
|
+
// GET /rooms/:roomId (прямая ссылка, этап 3) на IP: открытие ссылки —
|
|
171
|
+
// один запрос, лимит только против перебора roomId
|
|
172
|
+
lookupRateLimit: {
|
|
173
|
+
limit: 20,
|
|
174
|
+
windowMs: 1000,
|
|
175
|
+
},
|
|
77
176
|
},
|
|
78
177
|
|
|
79
178
|
// GET /auth/leaderboard (code review L2): TTL кэша на мастере (мс) и
|
|
@@ -97,7 +196,7 @@ export default {
|
|
|
97
196
|
// стороне хоста, а мастер держит потолок для сломанного или злонамеренного
|
|
98
197
|
// сервера, который этот интервал обошёл
|
|
99
198
|
playerData: {
|
|
100
|
-
// PUT /auth/rank + /auth/state на комнату (проверенный
|
|
199
|
+
// PUT /auth/rank + /auth/state на комнату (проверенный roomId) в минуту.
|
|
101
200
|
// Честная комната на 32 при lobbyConfig.playerData.minFlushInterval в
|
|
102
201
|
// 5 минут пишет 64 запроса за эти 5 минут, то есть ~13/мин; остальное —
|
|
103
202
|
// запас на срочные границы (уход участника обходит интервал), и его
|
|
@@ -110,22 +209,6 @@ export default {
|
|
|
110
209
|
maxGameScore: 10000,
|
|
111
210
|
},
|
|
112
211
|
|
|
113
|
-
// рейтинг хостера комнаты (server-rating этап 2, plan/server-rating/
|
|
114
|
-
// stage_2.md): /like·/unlike гостей комнаты вместо соц-модерации /ban.
|
|
115
|
-
// Дефолт движка для всех игр; auth-сервис хранит и клампит рейтинг
|
|
116
|
-
// персистентно и глобально (нужно и для аннулирования rank/skills, этап 4) —
|
|
117
|
-
// это значение зеркалируется в packages/auth/src/config/auth.js:rating,
|
|
118
|
-
// фактический кламп/blocked считает auth, не мастер
|
|
119
|
-
rating: {
|
|
120
|
-
min: -10,
|
|
121
|
-
max: 10,
|
|
122
|
-
blockAt: -10,
|
|
123
|
-
// период опроса auth за актуальным рейтингом активных хостеров (этап 3,
|
|
124
|
-
// stage_3.md) — держит кэш GET /servers свежим (счёт мог измениться на
|
|
125
|
-
// другом мастере), голос/регистрация на этом мастере обновляют кэш сразу
|
|
126
|
-
refreshInterval: 30000,
|
|
127
|
-
},
|
|
128
|
-
|
|
129
212
|
// заголовки безопасности (гигиена среды, Этап 5.4). CSP на статику/.wasm в
|
|
130
213
|
// проде ставит Nginx (см. docs/deployment.md) — здесь single source of truth
|
|
131
214
|
// политики; мастер применяет её к своим ответам только в проде (в dev CSP
|
package/src/config/wsports.js
CHANGED
|
@@ -31,6 +31,10 @@ export default {
|
|
|
31
31
|
// места участников в глобальном топе (snakes-v3 этап 4): косметика
|
|
32
32
|
// для parts игры, в снапшот не идёт
|
|
33
33
|
ACCOLADES_DATA: 18,
|
|
34
|
+
// возобновление сессии (host-migration этап 4): секрет участника
|
|
35
|
+
// { resumeKey, gameId } и ответ на RESUME_REQUEST
|
|
36
|
+
SESSION_DATA: 19,
|
|
37
|
+
RESUME_RESULT: 20,
|
|
34
38
|
},
|
|
35
39
|
// порты получения данных от клиента
|
|
36
40
|
client: {
|
|
@@ -43,5 +47,9 @@ export default {
|
|
|
43
47
|
CHAT_DATA: 6,
|
|
44
48
|
VOTE_DATA: 7,
|
|
45
49
|
PONG: 8,
|
|
50
|
+
// возобновление места после обрыва транспорта (host-migration этап 4)
|
|
51
|
+
RESUME_REQUEST: 9,
|
|
52
|
+
// игрок уходит сам: хост снимает его сразу, без ожидания возврата
|
|
53
|
+
LEAVE: 10,
|
|
46
54
|
},
|
|
47
55
|
};
|
|
@@ -14,8 +14,9 @@ import SocketManager from '../host/meta/SocketManager.js';
|
|
|
14
14
|
|
|
15
15
|
// имена отправителей берутся из прототипа: ручной список неизбежно отстаёт
|
|
16
16
|
// от боевого класса, что уже случалось
|
|
17
|
-
const SENDER_METHODS = Object.getOwnPropertyNames(
|
|
18
|
-
.
|
|
17
|
+
const SENDER_METHODS = Object.getOwnPropertyNames(
|
|
18
|
+
SocketManager.prototype,
|
|
19
|
+
).filter(name => name.startsWith('send'));
|
|
19
20
|
|
|
20
21
|
class RecordingSocketManager extends SocketManager {
|
|
21
22
|
/**
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createHostRuntime } from '../lib/createHostRuntime.js';
|
|
2
|
+
import { encodeCheckpoint, decodeCheckpoint } from '../lib/checkpointCodec.js';
|
|
2
3
|
import { offlinePlayerData } from '../lib/offlinePlayerData.js';
|
|
4
|
+
import { resumeKeysEqual } from '../lib/resumeKey.js';
|
|
3
5
|
import VirtualClock, { flushMicrotasks } from './VirtualClock.js';
|
|
4
6
|
import RecordingSocketManager from './RecordingSocketManager.js';
|
|
5
7
|
import VirtualClient from './VirtualClient.js';
|
|
@@ -51,7 +53,9 @@ export function parseScenario(raw) {
|
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
if (raw.version !== 1) {
|
|
54
|
-
throw new Error(
|
|
56
|
+
throw new Error(
|
|
57
|
+
`scenario: unsupported version ${raw.version} (expected 1)`,
|
|
58
|
+
);
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
if (!Array.isArray(raw.participants) || !raw.participants.length) {
|
|
@@ -77,6 +81,15 @@ export function parseScenario(raw) {
|
|
|
77
81
|
);
|
|
78
82
|
}
|
|
79
83
|
|
|
84
|
+
const checkpointEvery = raw.checkpointEvery ?? null;
|
|
85
|
+
|
|
86
|
+
if (
|
|
87
|
+
checkpointEvery !== null &&
|
|
88
|
+
!(Number.isFinite(checkpointEvery) && checkpointEvery > 0)
|
|
89
|
+
) {
|
|
90
|
+
throw new Error('scenario: checkpointEvery must be a positive number (ms)');
|
|
91
|
+
}
|
|
92
|
+
|
|
80
93
|
return {
|
|
81
94
|
version: 1,
|
|
82
95
|
seed: raw.seed ?? 1,
|
|
@@ -96,6 +109,9 @@ export function parseScenario(raw) {
|
|
|
96
109
|
ticks: raw.ticks ?? 600,
|
|
97
110
|
dumpTicks: raw.dumpTicks ?? null,
|
|
98
111
|
room: raw.room ?? {},
|
|
112
|
+
// смена хоста через контрольную точку каждые N мс матча (host-migration
|
|
113
|
+
// этап 5); то же разово — шаг таймлайна { op: 'checkpointRestore' }
|
|
114
|
+
checkpointEvery,
|
|
99
115
|
};
|
|
100
116
|
}
|
|
101
117
|
|
|
@@ -169,7 +185,13 @@ async function execute(scenario, plugin, virtualClock, { captureFrames }) {
|
|
|
169
185
|
},
|
|
170
186
|
};
|
|
171
187
|
|
|
172
|
-
const
|
|
188
|
+
const hostOptions = {
|
|
189
|
+
onMapChange: mapName =>
|
|
190
|
+
mapChanges.push({ tick: currentTick, map: mapName }),
|
|
191
|
+
playerDataFetch: emptyProfileFetch,
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
let runtime = await createHostRuntime(room, {
|
|
173
195
|
loadHostPlugin: () => plugin.hostPlugin,
|
|
174
196
|
// транспорт собирается той же фабрикой, что и боевой: наследник обязан
|
|
175
197
|
// получить те же порты и ту же игровую параметризацию
|
|
@@ -182,11 +204,7 @@ async function execute(scenario, plugin, virtualClock, { captureFrames }) {
|
|
|
182
204
|
return socketManager;
|
|
183
205
|
},
|
|
184
206
|
overrideGameConfig: game => mergeConfig(game, scenario.config),
|
|
185
|
-
hostOptions
|
|
186
|
-
onMapChange: mapName =>
|
|
187
|
-
mapChanges.push({ tick: currentTick, map: mapName }),
|
|
188
|
-
playerDataFetch: emptyProfileFetch,
|
|
189
|
-
},
|
|
207
|
+
hostOptions,
|
|
190
208
|
});
|
|
191
209
|
|
|
192
210
|
const { clientCfg, game, seed } = runtime;
|
|
@@ -194,6 +212,78 @@ async function execute(scenario, plugin, virtualClock, { captureFrames }) {
|
|
|
194
212
|
|
|
195
213
|
host = runtime.host;
|
|
196
214
|
|
|
215
|
+
// смены хоста через контрольную точку (host-migration этап 5)
|
|
216
|
+
const checkpointRestores = [];
|
|
217
|
+
|
|
218
|
+
// Старый хост замораживается и отдаёт финальную точку; точка проходит
|
|
219
|
+
// боевой кодек (сжатие → распаковка), из неё поднимается НОВЫЙ runtime на
|
|
220
|
+
// том же транспорте, и каждый клиент возвращается в своё место RESUME'ом —
|
|
221
|
+
// ровно как к преемнику в проде. Сбой любого шага — исключение: прогон
|
|
222
|
+
// красный, а не тихо продолжившийся на старом хосте
|
|
223
|
+
const restoreFromCheckpoint = async tick => {
|
|
224
|
+
let taken = null;
|
|
225
|
+
|
|
226
|
+
host.setCheckpointSink(checkpoint => {
|
|
227
|
+
taken = checkpoint;
|
|
228
|
+
});
|
|
229
|
+
host.freeze();
|
|
230
|
+
host.requestCheckpoint({ final: true });
|
|
231
|
+
host.setCheckpointSink(null);
|
|
232
|
+
|
|
233
|
+
if (!taken) {
|
|
234
|
+
throw new Error(`scenario: no checkpoint taken at tick ${tick}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const bytes = await encodeCheckpoint(taken.meta, taken.core);
|
|
238
|
+
const checkpoint = await decodeCheckpoint(bytes);
|
|
239
|
+
|
|
240
|
+
resetHostSingletons();
|
|
241
|
+
|
|
242
|
+
runtime = await createHostRuntime(room, {
|
|
243
|
+
loadHostPlugin: () => plugin.hostPlugin,
|
|
244
|
+
createSocketManager: () => socketManager,
|
|
245
|
+
overrideGameConfig: next => mergeConfig(next, scenario.config),
|
|
246
|
+
hostOptions: { ...hostOptions, checkpoint, seqFloor: taken.meta.seq },
|
|
247
|
+
});
|
|
248
|
+
host = runtime.host;
|
|
249
|
+
|
|
250
|
+
for (const client of clients.values()) {
|
|
251
|
+
const target = host.getResumeTarget(client.gameId);
|
|
252
|
+
const request = client.resumeRequest();
|
|
253
|
+
|
|
254
|
+
if (!target || !resumeKeysEqual(request.resumeKey, target.resumeKey)) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`scenario: participant ${client.gameId} cannot resume after ` +
|
|
257
|
+
`checkpoint at tick ${tick}`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
host.resumeUser(client.gameId, client.socketId, null);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
host.startAfterRestore();
|
|
265
|
+
checkpointRestores.push({
|
|
266
|
+
tick,
|
|
267
|
+
mode: checkpoint.meta.mode,
|
|
268
|
+
bytes: bytes.byteLength,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
await flushMicrotasks();
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const checkpointTicks = new Set();
|
|
275
|
+
|
|
276
|
+
if (scenario.checkpointEvery) {
|
|
277
|
+
for (let tick = 1; tick <= scenario.ticks; tick += 1) {
|
|
278
|
+
if (
|
|
279
|
+
Math.floor((tick * stepMs) / scenario.checkpointEvery) >
|
|
280
|
+
Math.floor(((tick - 1) * stepMs) / scenario.checkpointEvery)
|
|
281
|
+
) {
|
|
282
|
+
checkpointTicks.add(tick);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
197
287
|
// очередь операций сценария, разложенная по номеру тика: ввод хост
|
|
198
288
|
// применяет синхронно при приходе сообщения (HostGame.updateKeys), очереди
|
|
199
289
|
// у него нет — значит сценарий обязан задавать именно границу тика
|
|
@@ -216,7 +306,16 @@ async function execute(scenario, plugin, virtualClock, { captureFrames }) {
|
|
|
216
306
|
currentTick = tick;
|
|
217
307
|
socketManager.tick = tick;
|
|
218
308
|
|
|
309
|
+
if (checkpointTicks.has(tick)) {
|
|
310
|
+
await restoreFromCheckpoint(tick);
|
|
311
|
+
}
|
|
312
|
+
|
|
219
313
|
for (const op of opsByTick.get(tick) ?? []) {
|
|
314
|
+
if (op.op === 'checkpointRestore') {
|
|
315
|
+
await restoreFromCheckpoint(tick);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
|
|
220
319
|
await applyOp(op, {
|
|
221
320
|
host,
|
|
222
321
|
clientCfg,
|
|
@@ -269,6 +368,7 @@ async function execute(scenario, plugin, virtualClock, { captureFrames }) {
|
|
|
269
368
|
seed,
|
|
270
369
|
stepMs,
|
|
271
370
|
host,
|
|
371
|
+
checkpointRestores,
|
|
272
372
|
core: runtime.core,
|
|
273
373
|
game,
|
|
274
374
|
clientCfg,
|
|
@@ -336,6 +436,11 @@ function routeFrame(frame, clients, pendingShots, virtualClock, host) {
|
|
|
336
436
|
return;
|
|
337
437
|
}
|
|
338
438
|
|
|
439
|
+
if (frame.method === 'sendSessionData') {
|
|
440
|
+
client.noteSession(frame.args[0]);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
339
444
|
const bucket = RECORDED_PORTS[frame.method];
|
|
340
445
|
|
|
341
446
|
if (bucket) {
|
|
@@ -463,7 +568,9 @@ async function joinParticipant(op, ctx) {
|
|
|
463
568
|
const participant = scenario.participants.find(p => p.id === op.who);
|
|
464
569
|
|
|
465
570
|
if (!participant) {
|
|
466
|
-
throw new Error(
|
|
571
|
+
throw new Error(
|
|
572
|
+
`scenario: join references unknown participant '${op.who}'`,
|
|
573
|
+
);
|
|
467
574
|
}
|
|
468
575
|
|
|
469
576
|
const socketId = participant.socketId ?? `sock-${participant.id}`;
|
|
@@ -502,6 +609,11 @@ async function joinParticipant(op, ctx) {
|
|
|
502
609
|
host.sendMap(gameId);
|
|
503
610
|
host.mapReady(gameId);
|
|
504
611
|
host.firstShotReady(gameId);
|
|
612
|
+
|
|
613
|
+
// секрет места — как SESSION_DATA порт-машины лобби на первом кадре:
|
|
614
|
+
// с ним клиент вернётся к новому хосту после смены (checkpointRestore)
|
|
615
|
+
client.noteSession({ resumeKey: host.issueResumeKey(gameId), gameId });
|
|
616
|
+
|
|
505
617
|
byParticipant.set(op.who, { socketId, gameId });
|
|
506
618
|
participantLog.push({
|
|
507
619
|
who: op.who,
|
|
@@ -550,6 +662,7 @@ function buildReport(ctx) {
|
|
|
550
662
|
seed,
|
|
551
663
|
stepMs,
|
|
552
664
|
host,
|
|
665
|
+
checkpointRestores,
|
|
553
666
|
core,
|
|
554
667
|
game,
|
|
555
668
|
clientCfg,
|
|
@@ -599,6 +712,8 @@ function buildReport(ctx) {
|
|
|
599
712
|
durationMs: stepMs * scenario.ticks,
|
|
600
713
|
currentMap: host.currentMap,
|
|
601
714
|
mapChanges,
|
|
715
|
+
// смены хоста через контрольную точку: тик, режим, размер сжатой точки
|
|
716
|
+
checkpointRestores,
|
|
602
717
|
participants: [...byParticipant.entries()].map(([id, entry]) => ({
|
|
603
718
|
id,
|
|
604
719
|
...entry,
|
|
@@ -98,6 +98,10 @@ class VirtualClient {
|
|
|
98
98
|
this.frameCount = 0;
|
|
99
99
|
this.decodeErrors = [];
|
|
100
100
|
|
|
101
|
+
// секрет места (SESSION_DATA): с ним клиент возвращается к новому хосту
|
|
102
|
+
// после смены (RESUME, host-migration этапы 4–5)
|
|
103
|
+
this.resumeKey = null;
|
|
104
|
+
|
|
101
105
|
// материал проверок инвариантов (devtools/invariants.js): что реально
|
|
102
106
|
// доехало до сцены за весь прогон, а не только к финальному тику —
|
|
103
107
|
// сущность могла появиться и исчезнуть между дампами
|
|
@@ -116,6 +120,22 @@ class VirtualClient {
|
|
|
116
120
|
this.truncated = { nonFinite: 0, hotLayoutErrors: 0, divergence: 0 };
|
|
117
121
|
}
|
|
118
122
|
|
|
123
|
+
// SESSION_DATA { resumeKey, gameId }: хост выдал (или ротировал) секрет
|
|
124
|
+
noteSession(data) {
|
|
125
|
+
if (data && typeof data.resumeKey === 'string') {
|
|
126
|
+
this.resumeKey = data.resumeKey;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (data && data.gameId !== undefined) {
|
|
130
|
+
this.gameId = data.gameId;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// тело RESUME_REQUEST — то же, что шлёт браузерный клиент
|
|
135
|
+
resumeRequest() {
|
|
136
|
+
return { v: 1, gameId: this.gameId, resumeKey: this.resumeKey };
|
|
137
|
+
}
|
|
138
|
+
|
|
119
139
|
// байты кадра из sendShot — ровно то, что ушло бы в data channel
|
|
120
140
|
pushFrame(bytes, localNow) {
|
|
121
141
|
const data = toUint8(bytes);
|
|
@@ -13,7 +13,10 @@ const MARKS = { [PASS]: '✅', [FAIL]: '❌', [SKIP]: '⏭️' };
|
|
|
13
13
|
*/
|
|
14
14
|
export function formatContract(report, { quiet = false } = {}) {
|
|
15
15
|
const { summary } = report;
|
|
16
|
-
const lines = [
|
|
16
|
+
const lines = [
|
|
17
|
+
`# Contract report — ${report.game.id ?? '(unknown game)'}`,
|
|
18
|
+
'',
|
|
19
|
+
];
|
|
17
20
|
|
|
18
21
|
lines.push(`- game: \`${report.game.dir}\``);
|
|
19
22
|
lines.push(`- engine API: v${report.game.engineApi}`);
|
|
@@ -86,7 +86,6 @@ export function runRules(ctx, ruleList = rules) {
|
|
|
86
86
|
*/
|
|
87
87
|
export function hasBlockingFailure(report, strict = false) {
|
|
88
88
|
return report.results.some(
|
|
89
|
-
result =>
|
|
90
|
-
result.status === FAIL && (strict || result.level === ERROR),
|
|
89
|
+
result => result.status === FAIL && (strict || result.level === ERROR),
|
|
91
90
|
);
|
|
92
91
|
}
|
|
@@ -29,8 +29,6 @@ export default {
|
|
|
29
29
|
const scripts = ctx.pkg.scripts ?? {};
|
|
30
30
|
const missing = REQUIRED.filter(name => !scripts[name]);
|
|
31
31
|
|
|
32
|
-
return verdict(
|
|
33
|
-
missing.map(name => `script "${name}" is missing`),
|
|
34
|
-
);
|
|
32
|
+
return verdict(missing.map(name => `script "${name}" is missing`));
|
|
35
33
|
},
|
|
36
34
|
};
|
|
@@ -4,7 +4,7 @@ import { ERROR, skip, verdict } from '../result.js';
|
|
|
4
4
|
// реестра: код в зарезервированном диапазоне затирает движковое сообщение
|
|
5
5
|
// без предупреждения, и вместо «Vote passed» игрок видит игровой текст.
|
|
6
6
|
// Диапазоны — длины движковых групп (client/config/chat messages).
|
|
7
|
-
const RESERVED = { s:
|
|
7
|
+
const RESERVED = { s: 11, v: 15, m: 1, c: 1, n: 1 };
|
|
8
8
|
|
|
9
9
|
export default {
|
|
10
10
|
id: 'B8',
|
|
@@ -8,11 +8,19 @@ import { ERROR, skip, verdict } from '../result.js';
|
|
|
8
8
|
const ENGINE_MENU = ['teamChange', 'mapChange'];
|
|
9
9
|
const ENGINE_TEMPLATES = ['teamChange', 'mapChangeBySystem', 'mapChangeByUser'];
|
|
10
10
|
|
|
11
|
+
// Имена с '@' — голосования самого движка (host-migration этап 10:
|
|
12
|
+
// '@changeHost' — окно «Change host?» от мастера). Ответ на голосование с
|
|
13
|
+
// таким именем клиент перехватывает до хоста — игровое голосование под ним
|
|
14
|
+
// не дошло бы до игры никогда
|
|
15
|
+
const ENGINE_PREFIX = '@';
|
|
16
|
+
const isEngineName = name =>
|
|
17
|
+
typeof name === 'string' && name.startsWith(ENGINE_PREFIX);
|
|
18
|
+
|
|
11
19
|
export default {
|
|
12
20
|
id: 'B9',
|
|
13
21
|
name: 'voteNames',
|
|
14
22
|
level: ERROR,
|
|
15
|
-
title: 'custom votes do not reuse the reserved vote names',
|
|
23
|
+
title: 'custom votes do not reuse the reserved vote names or the @ prefix',
|
|
16
24
|
|
|
17
25
|
check(ctx) {
|
|
18
26
|
const vote = ctx.clientConfig?.modules?.vote?.params;
|
|
@@ -25,6 +33,14 @@ export default {
|
|
|
25
33
|
const violations = [];
|
|
26
34
|
|
|
27
35
|
for (const name of templates) {
|
|
36
|
+
if (isEngineName(name)) {
|
|
37
|
+
violations.push(
|
|
38
|
+
`vote template "${name}" starts with "${ENGINE_PREFIX}" — names ` +
|
|
39
|
+
`with this prefix are reserved for engine votes`,
|
|
40
|
+
);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
28
44
|
if (ENGINE_MENU.includes(name) && !ENGINE_TEMPLATES.includes(name)) {
|
|
29
45
|
violations.push(
|
|
30
46
|
`vote template "${name}" reuses a reserved vote name — the engine ` +
|
|
@@ -36,6 +52,14 @@ export default {
|
|
|
36
52
|
for (const entry of vote.menu ?? []) {
|
|
37
53
|
const name = Array.isArray(entry) ? entry[0] : entry;
|
|
38
54
|
|
|
55
|
+
if (isEngineName(name)) {
|
|
56
|
+
violations.push(
|
|
57
|
+
`vote menu entry "${name}" starts with "${ENGINE_PREFIX}" — names ` +
|
|
58
|
+
`with this prefix are reserved for engine votes`,
|
|
59
|
+
);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
39
63
|
if (!ENGINE_MENU.includes(name) && !templates.includes(name)) {
|
|
40
64
|
violations.push(
|
|
41
65
|
`vote menu entry "${name}" has no template — the vote renders empty`,
|
|
@@ -33,7 +33,10 @@ export default {
|
|
|
33
33
|
const host = ctx.gameConfig.statMode ?? null;
|
|
34
34
|
|
|
35
35
|
if (client === host) {
|
|
36
|
-
return verdict(
|
|
36
|
+
return verdict(
|
|
37
|
+
[],
|
|
38
|
+
client ? `both declare '${client}'` : 'the room table',
|
|
39
|
+
);
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
if (client && !host) {
|
|
@@ -100,7 +100,7 @@ export default {
|
|
|
100
100
|
// ли игра свои стили под этот список
|
|
101
101
|
if (params.mode === 'leaderboard') {
|
|
102
102
|
return styledLeaderboard(ctx.clientPlugin?.styles)
|
|
103
|
-
? verdict([],
|
|
103
|
+
? verdict([], "leaderboard list, laid out by the plugin's own styles")
|
|
104
104
|
: verdict([
|
|
105
105
|
"stat declares mode 'leaderboard', but ClientPlugin.styles says " +
|
|
106
106
|
'nothing about #stat .stat-leaderboard: the engine CSS gives ' +
|
|
@@ -4,7 +4,13 @@ import { ERROR, skip, verdict } from '../result.js';
|
|
|
4
4
|
// prevPlayer наблюдатель заперт на одной камере; занятый движковый код
|
|
5
5
|
// (чат, голосование, статистика, escape, enter) до игры не доходит;
|
|
6
6
|
// расхождение имён с playerKeys даёт клавишу, которая ничего не шлёт.
|
|
7
|
-
const ENGINE_CODES = {
|
|
7
|
+
const ENGINE_CODES = {
|
|
8
|
+
9: 'stat',
|
|
9
|
+
13: 'enter',
|
|
10
|
+
27: 'escape',
|
|
11
|
+
67: 'chat',
|
|
12
|
+
77: 'vote',
|
|
13
|
+
};
|
|
8
14
|
const SPECTATOR_ACTIONS = ['nextPlayer', 'prevPlayer'];
|
|
9
15
|
|
|
10
16
|
export default {
|
|
@@ -51,9 +57,7 @@ export default {
|
|
|
51
57
|
|
|
52
58
|
for (const action of declared) {
|
|
53
59
|
if (!bound.has(action)) {
|
|
54
|
-
violations.push(
|
|
55
|
-
`playerKeys."${action}" has no key in keySetList[1]`,
|
|
56
|
-
);
|
|
60
|
+
violations.push(`playerKeys."${action}" has no key in keySetList[1]`);
|
|
57
61
|
}
|
|
58
62
|
}
|
|
59
63
|
|
|
@@ -12,7 +12,8 @@ export default {
|
|
|
12
12
|
id: 'D3',
|
|
13
13
|
name: 'snapshotInterp',
|
|
14
14
|
level: ERROR,
|
|
15
|
-
title:
|
|
15
|
+
title:
|
|
16
|
+
"interp only on f32 fields of class 'hot' blocks; role 'state' is a u8 right after the row head",
|
|
16
17
|
|
|
17
18
|
check(ctx) {
|
|
18
19
|
const snapshot = ctx.gameConfig?.snapshot;
|
|
@@ -30,7 +30,9 @@ export default {
|
|
|
30
30
|
'mp3',
|
|
31
31
|
]) {
|
|
32
32
|
if (!ctx.distFiles.has(`sounds/${file}.${codec}`)) {
|
|
33
|
-
violations.push(
|
|
33
|
+
violations.push(
|
|
34
|
+
`sound "${name}": dist/sounds/${file}.${codec} is missing`,
|
|
35
|
+
);
|
|
34
36
|
}
|
|
35
37
|
}
|
|
36
38
|
}
|
|
@@ -133,7 +133,13 @@ function checkMap(name, map, violations) {
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
// 8. тайл рендер-слоя обязан существовать в тайл-листе по индексу
|
|
136
|
-
checkLayerFrames(
|
|
136
|
+
checkLayerFrames(
|
|
137
|
+
at,
|
|
138
|
+
`level ${key}`,
|
|
139
|
+
level?.layers,
|
|
140
|
+
map.spriteSheet,
|
|
141
|
+
violations,
|
|
142
|
+
);
|
|
137
143
|
|
|
138
144
|
// 11. высоты объёмов уровня
|
|
139
145
|
checkVolumes(at, key, level?.layers, level?.volumes, violations);
|
|
@@ -325,7 +331,12 @@ function rampRunExits(grid, tile, dir) {
|
|
|
325
331
|
return out;
|
|
326
332
|
}
|
|
327
333
|
|
|
328
|
-
const NEIGHBOURS = [
|
|
334
|
+
const NEIGHBOURS = [
|
|
335
|
+
[1, 0],
|
|
336
|
+
[-1, 0],
|
|
337
|
+
[0, 1],
|
|
338
|
+
[0, -1],
|
|
339
|
+
];
|
|
329
340
|
|
|
330
341
|
function checkLevelEdges(at, map, violations) {
|
|
331
342
|
for (const [key, level] of Object.entries(map.levels ?? {})) {
|
|
@@ -43,7 +43,13 @@ export default {
|
|
|
43
43
|
function checkMap(at, map, violations) {
|
|
44
44
|
let checked = 0;
|
|
45
45
|
|
|
46
|
-
checked += checkGrid(
|
|
46
|
+
checked += checkGrid(
|
|
47
|
+
at,
|
|
48
|
+
'level 0',
|
|
49
|
+
map?.layers,
|
|
50
|
+
map?.physicsStatic,
|
|
51
|
+
violations,
|
|
52
|
+
);
|
|
47
53
|
|
|
48
54
|
for (const [key, level] of Object.entries(map?.levels ?? {})) {
|
|
49
55
|
checked += checkGrid(
|