vimp-engine 0.40.0 → 0.42.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.
Files changed (49) hide show
  1. package/core/Cargo.toml +1 -1
  2. package/package.json +1 -1
  3. package/src/client/components/controller/RoomMenu.js +38 -10
  4. package/src/client/components/controller/Vote.js +26 -0
  5. package/src/client/components/model/Controls.js +9 -1
  6. package/src/client/components/model/RoomMenu.js +42 -57
  7. package/src/client/components/model/Vote.js +202 -0
  8. package/src/client/components/view/Panel.js +176 -6
  9. package/src/client/components/view/RoomMenu.js +78 -24
  10. package/src/client/components/view/Voice.js +6 -1
  11. package/src/client/components/view/Vote.js +71 -2
  12. package/src/client/lib/autoRejoin.js +161 -0
  13. package/src/client/lib/hostUnloadGuard.js +40 -15
  14. package/src/client/lib/roomLink.js +32 -0
  15. package/src/client/main.js +120 -36
  16. package/src/client/network/HostConnectionManager.js +27 -1
  17. package/src/client/network/HostController.js +1 -30
  18. package/src/client/network/HostHealthPolicy.js +14 -59
  19. package/src/client/network/PlannedHandoff.js +15 -100
  20. package/src/client/network/SessionSupervisor.js +58 -9
  21. package/src/client/network/SignalingClient.js +1 -1
  22. package/src/client/network/WebRtcManager.js +38 -3
  23. package/src/client/session/GuestSession.js +42 -7
  24. package/src/client/session/HandoffFlow.js +26 -51
  25. package/src/client/session/HostRole.js +8 -6
  26. package/src/client/session/PromotionFlow.js +28 -1
  27. package/src/client/session/RouteBoot.js +28 -9
  28. package/src/client/style.css +112 -20
  29. package/src/client/views/includes/roomMenu.pug +7 -8
  30. package/src/client/voice/MicCapture.js +3 -1
  31. package/src/config/abiOps.js +42 -0
  32. package/src/config/chatMessages.js +1 -1
  33. package/src/config/lobby.js +22 -23
  34. package/src/config/menuName.js +3 -0
  35. package/src/devtools/ScenarioRunner.js +1 -0
  36. package/src/devtools/invariants.js +24 -2
  37. package/src/devtools/report.js +2 -2
  38. package/src/host/GameCoreAdapter.js +83 -4
  39. package/src/host/HostGame.js +103 -39
  40. package/src/host/PortMachine.js +34 -17
  41. package/src/host/host.worker.js +1 -13
  42. package/src/host/meta/core/RoundManager.js +8 -22
  43. package/src/host/meta/core/VoteCoordinator.js +56 -1
  44. package/src/host/meta/modules/TimerManager.js +16 -2
  45. package/src/host/meta/modules/Vote.js +65 -0
  46. package/src/host/meta/modules/chat/systemMessages.js +1 -1
  47. package/src/host/meta/player/ParticipantManager.js +19 -3
  48. package/src/lib/capabilities.js +19 -4
  49. package/src/lib/gameConfigView.js +6 -5
package/core/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vimp-engine-core"
3
- version = "0.23.5"
3
+ version = "0.24.0"
4
4
  edition = "2024"
5
5
  description = "VIMP engine simulation core: physics, map, snapshot framing, interpolation/prediction/raycast primitives, navigation utilities. No wasm-bindgen — the game crate builds the WASM ABI wrappers."
6
6
  license = "MIT"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.40.0",
3
+ "version": "0.42.0",
4
4
  "description": "VIMP engine SDK: browser host and client runtime, standalone mode and CLI tools (vimp-sim, vimp-contract) for VIMP game plugins",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -3,7 +3,8 @@
3
3
  let roomMenuCtrl;
4
4
 
5
5
  export default class RoomMenuCtrl {
6
- // actions — { onLeave, onHandover }: действия пунктов (client/main.js)
6
+ // actions — { onExit, onConfirmOpen, onConfirmClose } (client/main.js):
7
+ // выход подтверждён; окно открылось; окно закрыто без выхода
7
8
  constructor(model, view, actions = {}) {
8
9
  if (roomMenuCtrl) {
9
10
  return roomMenuCtrl;
@@ -13,22 +14,41 @@ export default class RoomMenuCtrl {
13
14
 
14
15
  this._model = model;
15
16
  this._view = view;
17
+ this._actions = actions;
16
18
 
17
19
  this._vPublic = view.publisher;
18
- this._vPublic.on('toggle', () => model.toggle());
19
- // меню остаётся открытым: на месте пунктов — статус передачи
20
- this._vPublic.on('leave', () => actions.onLeave?.());
21
- this._vPublic.on('handover', () => actions.onHandover?.());
20
+ this._vPublic.on('exit', () => {
21
+ if (model.openConfirm()) {
22
+ actions.onConfirmOpen?.();
23
+ }
24
+ });
25
+ this._vPublic.on('yes', () => {
26
+ if (!model.isConfirmOpen()) {
27
+ return;
28
+ }
29
+
30
+ model.closeConfirm();
31
+ actions.onExit?.();
32
+ });
33
+ this._vPublic.on('cancel', () => {
34
+ if (!model.isConfirmOpen()) {
35
+ return;
36
+ }
37
+
38
+ model.closeConfirm();
39
+ actions.onConfirmClose?.();
40
+ });
22
41
  }
23
42
 
24
- // абсолютная ссылка на комнату, в которой вкладка; null — скрыть меню
43
+ // абсолютная ссылка на комнату, в которой вкладка; null — скрыть кнопку
25
44
  setLink(link) {
26
45
  this._model.setLink(link);
27
46
  }
28
47
 
29
- // { role, othersPresent, hasSuccessor } — см. RoomMenuModel.setRole
30
- setRole(state) {
31
- this._model.setRole(state);
48
+ // закрыть окно без выхода и без возврата клавиш: пауза сессии сама ими
49
+ // управляет
50
+ closeConfirm() {
51
+ this._model.closeConfirm();
32
52
  }
33
53
 
34
54
  // true — кнопка внутри панели (игра её показывает), false — в углу
@@ -36,8 +56,16 @@ export default class RoomMenuCtrl {
36
56
  this._view.placeInPanel(inPanel);
37
57
  }
38
58
 
39
- // null | 'pending' | 'slow' | 'failed'
59
+ // null | 'pending' | 'switching' | 'slow'. Передача закрывает открытое
60
+ // окно — как Cancel: клавиши, выключенные окном, возвращает onConfirmClose
61
+ // (иначе срыв передачи до заморозки оставил бы хоста без управления)
40
62
  setHandoff(status) {
63
+ const wasOpen = this._model.isConfirmOpen();
64
+
41
65
  this._model.setHandoff(status);
66
+
67
+ if (wasOpen && !this._model.isConfirmOpen()) {
68
+ this._actions.onConfirmClose?.();
69
+ }
42
70
  }
43
71
  }
@@ -24,6 +24,27 @@ export default class VoteCtrl {
24
24
  // {name: 'templateName', params: ['p1',..], values: ['v1',..] || 'values'}
25
25
  // ['val1', 'val2'];
26
26
  open(data) {
27
+ // личное меню хоста: проверяется раньше шаблона голосования (тоже объект)
28
+ if (typeof data === 'object' && data !== null && !Array.isArray(data)) {
29
+ if (Object.hasOwn(data, 'menu')) {
30
+ this._model.openPersonal(data.menu);
31
+
32
+ return;
33
+ }
34
+
35
+ if (Object.hasOwn(data, 'menuClose')) {
36
+ this._model.closePersonal(data.menuClose);
37
+
38
+ return;
39
+ }
40
+
41
+ if (Object.hasOwn(data, 'menuHint')) {
42
+ this._model.setHint(data.menuHint?.name, data.menuHint?.on);
43
+
44
+ return;
45
+ }
46
+ }
47
+
27
48
  // если данные - массив (values для созданного голосования)
28
49
  if (Array.isArray(data)) {
29
50
  this._model.updateValues(data);
@@ -64,6 +85,11 @@ export default class VoteCtrl {
64
85
  this._model.closeEngineVote(name);
65
86
  }
66
87
 
88
+ // CLEAR: личное меню закрывается, подсказки гаснут
89
+ clearPersonal() {
90
+ this._model.clearPersonal();
91
+ }
92
+
67
93
  // голосования хоста не пережили переподключение
68
94
  removeHostVotes() {
69
95
  this._model.removeHostVotes();
@@ -1,4 +1,5 @@
1
1
  import Publisher from '../../../lib/Publisher.js';
2
+ import { VOTE_ANSWER_KEYS } from '../../../config/voteKeys.js';
2
3
 
3
4
  // Singleton ControlsModel
4
5
 
@@ -120,8 +121,15 @@ export default class ControlsModel {
120
121
  return;
121
122
  }
122
123
 
124
+ // цифры открытого окна голосования/меню — только ответ окну, в игру
125
+ // не уходят; прочие клавиши идут и в игру (танк ездит при меню)
126
+ const isVoteAnswer =
127
+ this._currentModes.vote &&
128
+ keyCode >= VOTE_ANSWER_KEYS[0] &&
129
+ keyCode <= VOTE_ANSWER_KEYS[1];
130
+
123
131
  // если клавиша ещё не зажата
124
- if (!this._pressedKeys[keyCode]) {
132
+ if (!isVoteAnswer && !this._pressedKeys[keyCode]) {
125
133
  const name = this._currentKeySet[keyCode];
126
134
 
127
135
  if (name) {
@@ -2,19 +2,18 @@ import Publisher from '../../../lib/Publisher.js';
2
2
 
3
3
  // Singleton RoomMenuModel
4
4
  //
5
- // Меню комнаты внутри матча (host-migration, этапы 3 и 8d). Ссылка на
6
- // текущую комнату — признак «вкладка в комнате». Пункты:
7
- // - «Leave server» — у всех;
8
- // - «Hand over host» — только у текущего хоста, когда в комнате есть другие
9
- // люди и мастер назначил бету (иначе передавать некому).
10
- // Пока идёт передача, пункты заблокированы, на их месте статус. Только
5
+ // Выход из комнаты внутри матча: одна кнопка Exit у всех ролей, видна, пока
6
+ // вкладка в комнате (ссылка на комнату — признак «в комнате»). Клик
7
+ // открывает окно подтверждения «Leave the game?». Пока идёт плановая
8
+ // передача хоста, кнопка заблокирована, на её месте статус. Только
11
9
  // лобби-режим: в solo ссылки на комнату нет
12
10
 
13
- // handoff: null | 'pending' | 'slow' | 'failed'
11
+ // handoff: null | 'pending' (Exit хоста) | 'switching' (автопередача,
12
+ // вкладка остаётся) | 'slow' (бета долго не отвечает)
14
13
  const STATUS_TEXT = {
15
- pending: 'Handing over…',
14
+ pending: 'Leaving…',
15
+ switching: 'Switching host…',
16
16
  slow: 'Slow connection…',
17
- failed: 'Host handover failed',
18
17
  };
19
18
 
20
19
  let roomMenuModel;
@@ -28,20 +27,18 @@ export default class RoomMenuModel {
28
27
  roomMenuModel = this;
29
28
 
30
29
  this._link = null;
31
- this._open = false;
32
- this._role = null; // null | 'host' | 'guest'
33
- this._othersPresent = false;
34
- this._hasSuccessor = false;
35
30
  this._handoff = null;
31
+ // окно подтверждения открыто
32
+ this._confirm = false;
36
33
  this.publisher = new Publisher();
37
34
  }
38
35
 
39
- // абсолютная ссылка на комнату; null — вкладка не в комнате, меню скрыто
36
+ // абсолютная ссылка на комнату; null — вкладка не в комнате, кнопка скрыта
40
37
  setLink(link) {
41
38
  this._link = link || null;
42
39
 
43
40
  if (!this._link) {
44
- this.setOpen(false);
41
+ this._confirm = false;
45
42
  }
46
43
 
47
44
  this._emitState();
@@ -51,70 +48,58 @@ export default class RoomMenuModel {
51
48
  return this._link;
52
49
  }
53
50
 
54
- /**
55
- * Роль вкладки в комнате.
56
- * @param {Object} state
57
- * @param {'host'|'guest'|null} state.role
58
- * @param {boolean} [state.othersPresent] - в комнате есть другие люди.
59
- * @param {boolean} [state.hasSuccessor] - мастер назначил бету.
60
- */
61
- setRole({ role, othersPresent = false, hasSuccessor = false }) {
62
- this._role = role ?? null;
63
- this._othersPresent = othersPresent === true;
64
- this._hasSuccessor = hasSuccessor === true;
65
- this._emitState();
66
- }
67
-
68
- // ход плановой передачи: null | 'pending' | 'slow' | 'failed'
51
+ // ход плановой передачи: null | 'pending' | 'switching' | 'slow'. Идущая
52
+ // передача закрывает окно: Yes посреди неё сорвал бы плановую передачу в
53
+ // аварийную
69
54
  setHandoff(status) {
70
55
  this._handoff = status ?? null;
56
+
57
+ if (this._handoff !== null) {
58
+ this._confirm = false;
59
+ }
60
+
71
61
  this._emitState();
72
62
  }
73
63
 
74
- setOpen(open) {
75
- const next = Boolean(open) && this._link !== null;
64
+ isConfirmOpen() {
65
+ return this._confirm;
66
+ }
76
67
 
77
- if (next === this._open) {
78
- return;
68
+ // окно открывается только у доступной кнопки; true — открылось сейчас
69
+ openConfirm() {
70
+ if (this._confirm || this._link === null || this._busy()) {
71
+ return false;
79
72
  }
80
73
 
81
- this._open = next;
82
- this.publisher.emit('open', next);
74
+ this._confirm = true;
75
+ this._emitState();
83
76
 
84
- // ошибка передачи показана — закрытое меню её забывает
85
- if (!next && this._handoff === 'failed') {
86
- this.setHandoff(null);
87
- }
77
+ return true;
88
78
  }
89
79
 
90
- toggle() {
91
- this.setOpen(!this._open);
80
+ closeConfirm() {
81
+ this._confirm = false;
82
+ this._emitState();
92
83
  }
93
84
 
94
85
  /**
95
86
  * Что показывать.
96
- * @returns {{ visible: boolean, leave: Object, handover: Object,
97
- * status: string|null }} leave/handover — { visible, disabled }.
87
+ * @returns {{ visible: boolean, exit: { disabled: boolean },
88
+ * status: string|null, confirm: boolean }}
98
89
  */
99
90
  getState() {
100
- const inRoom = this._link !== null;
101
- const busy = this._handoff === 'pending' || this._handoff === 'slow';
102
-
103
91
  return {
104
- visible: inRoom,
105
- leave: { visible: inRoom, disabled: busy },
106
- handover: {
107
- visible:
108
- inRoom &&
109
- this._role === 'host' &&
110
- this._othersPresent &&
111
- this._hasSuccessor,
112
- disabled: busy,
113
- },
92
+ visible: this._link !== null,
93
+ exit: { disabled: this._busy() },
114
94
  status: STATUS_TEXT[this._handoff] ?? null,
95
+ confirm: this._confirm,
115
96
  };
116
97
  }
117
98
 
99
+ _busy() {
100
+ return this._handoff !== null;
101
+ }
102
+
118
103
  _emitState() {
119
104
  this.publisher.emit('state', this.getState());
120
105
  }
@@ -1,5 +1,6 @@
1
1
  import Publisher from '../../../lib/Publisher.js';
2
2
  import { VOTE_ANSWER_KEYS } from '../../../config/voteKeys.js';
3
+ import { MENU_NAME_PATTERN } from '../../../config/menuName.js';
3
4
 
4
5
  // Singleton VoteModel
5
6
 
@@ -7,6 +8,44 @@ import { VOTE_ANSWER_KEYS } from '../../../config/voteKeys.js';
7
8
  // его ведёт мастер, а не хост, и игра таких имён не объявляет
8
9
  const isEngineVote = name => typeof name === 'string' && name.startsWith('@');
9
10
 
11
+ // ограничения данных личного меню: хост недоверенный
12
+ const PERSONAL_MAX_ITEMS = 7;
13
+ const PERSONAL_MAX_DEPTH = 2;
14
+ const PERSONAL_MAX_TEXT = 64;
15
+
16
+ // узлы личного меню: усечённые копии, глубина ≤ 2, пунктов ≤ 7
17
+ function sanitizeNodes(nodes, depth) {
18
+ if (!Array.isArray(nodes)) {
19
+ return [];
20
+ }
21
+
22
+ if (nodes.length > PERSONAL_MAX_ITEMS) {
23
+ console.warn(
24
+ `[vote] personal menu level has ${nodes.length} items, max ${PERSONAL_MAX_ITEMS}`,
25
+ );
26
+ }
27
+
28
+ return nodes.slice(0, PERSONAL_MAX_ITEMS).map(node => {
29
+ const out = {
30
+ text: String(node?.text ?? '').slice(0, PERSONAL_MAX_TEXT),
31
+ disabled: node?.disabled === true,
32
+ };
33
+
34
+ if (depth < PERSONAL_MAX_DEPTH) {
35
+ const children = sanitizeNodes(node?.children, depth + 1);
36
+
37
+ if (children.length > 0) {
38
+ out.children = children;
39
+ } else if (node?.children !== undefined) {
40
+ // объявленная группа без валидных детей не должна стать листом
41
+ out.disabled = true;
42
+ }
43
+ }
44
+
45
+ return out;
46
+ });
47
+ }
48
+
10
49
  let voteModel;
11
50
 
12
51
  export default class VoteModel {
@@ -41,6 +80,11 @@ export default class VoteModel {
41
80
  // затирает чужое и не затирается им (этап 10)
42
81
  this._queue = [];
43
82
 
83
+ // личное меню хоста (type 'personal'): { name, style, title, items,
84
+ // path }, path — индексы открытых групп; подсказки — имя → горит ли
85
+ this._personal = null;
86
+ this._hints = new Map();
87
+
44
88
  this._title = null; // заголовок голосования
45
89
  this._values = []; // все значения голосования
46
90
 
@@ -124,6 +168,137 @@ export default class VoteModel {
124
168
  if (!(this._type === 'vote' && isEngineVote(this._voteName))) {
125
169
  this.complete();
126
170
  }
171
+
172
+ this._clearHints();
173
+ }
174
+
175
+ /**
176
+ * Личное меню хоста: заменяет любое содержимое окна и начинается с
177
+ * 1-го уровня; открытое голосование движка уходит в начало очереди.
178
+ * @param {Object} menu - { name, style, title, items }.
179
+ * @returns {boolean} данные приняты.
180
+ */
181
+ openPersonal(menu) {
182
+ if (
183
+ !menu ||
184
+ !MENU_NAME_PATTERN.test(menu.name) ||
185
+ !MENU_NAME_PATTERN.test(menu.style)
186
+ ) {
187
+ return false;
188
+ }
189
+
190
+ if (
191
+ this._showing &&
192
+ this._type === 'vote' &&
193
+ isEngineVote(this._voteName)
194
+ ) {
195
+ this._queue.unshift({
196
+ engine: {
197
+ name: this._voteName,
198
+ title: this._title,
199
+ values: this._values,
200
+ deadline: this._deadline,
201
+ },
202
+ });
203
+ }
204
+
205
+ this._waitingValues = false;
206
+ this._timeOff = true;
207
+ this._deadline = null;
208
+ this._type = 'personal';
209
+ this._voteName = '';
210
+ this._personal = {
211
+ name: menu.name,
212
+ style: menu.style,
213
+ title: String(menu.title ?? '').slice(0, PERSONAL_MAX_TEXT),
214
+ items: sanitizeNodes(menu.items, 1),
215
+ path: [],
216
+ };
217
+
218
+ this.show();
219
+ this.open();
220
+
221
+ return true;
222
+ }
223
+
224
+ // закрывает окно, если оно показывает личное меню `name`
225
+ closePersonal(name) {
226
+ if (
227
+ this._showing &&
228
+ this._type === 'personal' &&
229
+ this._personal.name === name
230
+ ) {
231
+ this.complete();
232
+ }
233
+ }
234
+
235
+ // зажигает/гасит подсказку меню
236
+ setHint(name, on) {
237
+ if (!MENU_NAME_PATTERN.test(name)) {
238
+ return;
239
+ }
240
+
241
+ this._hints.set(name, on === true);
242
+ this.publisher.emit('hint', { name, on: on === true });
243
+ }
244
+
245
+ // CLEAR: личное меню закрывается, подсказки гаснут
246
+ clearPersonal() {
247
+ if (this._showing && this._type === 'personal') {
248
+ this.complete();
249
+ }
250
+
251
+ this._clearHints();
252
+ }
253
+
254
+ _clearHints() {
255
+ for (const [name, on] of this._hints) {
256
+ if (on) {
257
+ this.setHint(name, false);
258
+ }
259
+ }
260
+ }
261
+
262
+ // пункты текущего уровня личного меню
263
+ _personalLevel() {
264
+ let nodes = this._personal.items;
265
+
266
+ for (const index of this._personal.path) {
267
+ nodes = nodes[index].children;
268
+ }
269
+
270
+ return nodes;
271
+ }
272
+
273
+ // цифра в личном меню: 0 — выход, 8/9 — страниц нет, 1–7 — пункт
274
+ _updatePersonal(number) {
275
+ if (number === 0) {
276
+ this.complete();
277
+
278
+ return;
279
+ }
280
+
281
+ const node = this._personalLevel()[number - 1];
282
+
283
+ if (number >= 8 || !node || node.disabled) {
284
+ return;
285
+ }
286
+
287
+ if (node.children) {
288
+ this._personal.path.push(number - 1);
289
+ this.show();
290
+
291
+ return;
292
+ }
293
+
294
+ const answer = [...this._personal.path, number - 1]
295
+ .map(index => index + 1)
296
+ .join('.');
297
+ const name = this._personal.name;
298
+
299
+ // окно закрывается ДО отправки (синхронный транспорт, см. ниже)
300
+ this.complete();
301
+ this.publisher.emit('socket', [`menu:${name}`, answer]);
127
302
  }
128
303
 
129
304
  _startEngineVote({ name, title, values, deadline }) {
@@ -220,6 +395,12 @@ export default class VoteModel {
220
395
  number = String.fromCharCode(keyCode);
221
396
  number = parseInt(number, 10);
222
397
 
398
+ if (this._type === 'personal') {
399
+ this._updatePersonal(number);
400
+
401
+ return;
402
+ }
403
+
223
404
  // exit
224
405
  if (number === 0) {
225
406
  this.complete();
@@ -277,6 +458,27 @@ export default class VoteModel {
277
458
 
278
459
  // отображает голосование
279
460
  show() {
461
+ if (this._type === 'personal') {
462
+ this._showing = true;
463
+ this.publisher.emit('clear', this._timerId);
464
+
465
+ this.publisher.emit('vote', {
466
+ title: this._personal.title,
467
+ list: this._personalLevel().map(node => ({
468
+ text: node.text,
469
+ disabled: node.disabled,
470
+ group: Boolean(node.children),
471
+ })),
472
+ back: false,
473
+ more: false,
474
+ time: null,
475
+ style: this._personal.style,
476
+ level: this._personal.path.length + 1,
477
+ });
478
+
479
+ return;
480
+ }
481
+
280
482
  const begin = this._currentPage * 7;
281
483
  const max = begin + 7;
282
484
  let currentValues = [];