vimp-engine 0.1.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 (45) hide show
  1. package/README.md +9 -0
  2. package/package.json +34 -0
  3. package/src/config/authClient.js +47 -0
  4. package/src/config/clientDefaults.js +113 -0
  5. package/src/config/hostDefaults.js +79 -0
  6. package/src/config/lobby.js +104 -0
  7. package/src/config/master.js +103 -0
  8. package/src/config/opcodes.js +29 -0
  9. package/src/config/wsports.js +35 -0
  10. package/src/host/GameCoreAdapter.js +206 -0
  11. package/src/host/HostGame.js +857 -0
  12. package/src/host/host.worker.js +438 -0
  13. package/src/host/meta/SocketManager.js +431 -0
  14. package/src/host/meta/core/CommandProcessor.js +99 -0
  15. package/src/host/meta/core/RoundManager.js +646 -0
  16. package/src/host/meta/core/VoteCoordinator.js +71 -0
  17. package/src/host/meta/modules/Panel.js +181 -0
  18. package/src/host/meta/modules/PlayerDataSync.js +176 -0
  19. package/src/host/meta/modules/RTTManager.js +168 -0
  20. package/src/host/meta/modules/Stat.js +294 -0
  21. package/src/host/meta/modules/TimerManager.js +277 -0
  22. package/src/host/meta/modules/Vote.js +179 -0
  23. package/src/host/meta/modules/chat/Chat.js +66 -0
  24. package/src/host/meta/modules/chat/index.js +1 -0
  25. package/src/host/meta/modules/chat/systemMessages.js +51 -0
  26. package/src/host/meta/player/HumanParticipant.js +36 -0
  27. package/src/host/meta/player/Participant.js +22 -0
  28. package/src/host/meta/player/ParticipantManager.js +247 -0
  29. package/src/host/meta/player/ScriptedParticipant.js +19 -0
  30. package/src/lib/AbstractTimer.js +84 -0
  31. package/src/lib/Publisher.js +43 -0
  32. package/src/lib/applyRoomOverrides.js +48 -0
  33. package/src/lib/buildClientConfig.js +48 -0
  34. package/src/lib/clientCoreConfig.js +59 -0
  35. package/src/lib/config.js +83 -0
  36. package/src/lib/coreConfig.js +60 -0
  37. package/src/lib/factory.js +21 -0
  38. package/src/lib/formatters.js +33 -0
  39. package/src/lib/gamePlugin.js +95 -0
  40. package/src/lib/jwt.js +103 -0
  41. package/src/lib/math.js +75 -0
  42. package/src/lib/rateLimiter.js +40 -0
  43. package/src/lib/sanitizers.js +17 -0
  44. package/src/lib/security.js +45 -0
  45. package/src/lib/validators.js +52 -0
@@ -0,0 +1,179 @@
1
+ // Singleton Vote
2
+ let vote;
3
+
4
+ class Vote {
5
+ constructor() {
6
+ if (vote) {
7
+ return vote;
8
+ }
9
+
10
+ vote = this;
11
+
12
+ this._list = []; // данные для всех игроков
13
+ this._userList = {}; // данные для игрока
14
+
15
+ this._activeVoteName = null; // имя активного голосования
16
+ this._activeVoteCategory = null; // категория активного голосования
17
+ this._activeVoteData = null; // данные активного голосования
18
+ this._voteQueue = []; // очередь для голосований
19
+ }
20
+
21
+ // сбрасывает данные
22
+ reset() {
23
+ this._list = [];
24
+
25
+ for (const gameId in this._userList) {
26
+ if (Object.hasOwn(this._userList, gameId)) {
27
+ this._userList[gameId] = [];
28
+ }
29
+ }
30
+
31
+ this._activeVoteName = null;
32
+ this._activeVoteCategory = null;
33
+ this._activeVoteData = null;
34
+ this._voteQueue = [];
35
+ }
36
+
37
+ // добавляет пользователя
38
+ addUser(gameId) {
39
+ this._userList[gameId] = [];
40
+ }
41
+
42
+ // удаляет пользователя
43
+ removeUser(gameId) {
44
+ delete this._userList[gameId];
45
+ }
46
+
47
+ // добавляет данные голосования
48
+ _push(arr) {
49
+ this._list.push(arr);
50
+ }
51
+
52
+ // добавляет данные голосования для пользователя
53
+ pushByUser(gameId, arr) {
54
+ this._userList[gameId].push(arr);
55
+ }
56
+
57
+ // возвращает данные
58
+ shift() {
59
+ return this._list.shift();
60
+ }
61
+
62
+ // возвращает данные для пользователя
63
+ shiftByUser(gameId) {
64
+ return this._userList[gameId].shift();
65
+ }
66
+
67
+ // запуск голосования и рассылки данных
68
+ _startVote({ name, category, payload, userList, onStartCallback }) {
69
+ this._activeVoteName = name;
70
+ this._activeVoteCategory = category;
71
+ this._activeVoteData = {};
72
+
73
+ if (userList) {
74
+ for (let i = 0, len = userList.length; i < len; i += 1) {
75
+ this.pushByUser(userList[i], payload);
76
+ }
77
+ } else {
78
+ this._push(payload);
79
+ }
80
+
81
+ if (onStartCallback) {
82
+ onStartCallback();
83
+ }
84
+ }
85
+
86
+ // проверяет использование категории голосования
87
+ hasVoteCategory(categoryName) {
88
+ if (this._activeVoteCategory === categoryName) {
89
+ return true;
90
+ }
91
+
92
+ return (
93
+ this._voteQueue.find(voteData => voteData.category === categoryName) !==
94
+ undefined
95
+ );
96
+ }
97
+
98
+ // создает голосование или ставит его в очередь
99
+ createVote(data) {
100
+ // если есть активное голосование, добавляем новое в очередь
101
+ if (this._activeVoteName) {
102
+ this._voteQueue.push(data);
103
+ return;
104
+ }
105
+
106
+ // если активного голосования нет, запускаем это
107
+ this._startVote(data);
108
+ }
109
+
110
+ // добавляет голос в голосование
111
+ addInVote(name, value) {
112
+ // если голосование не активное
113
+ if (this._activeVoteName !== name) {
114
+ return;
115
+ }
116
+
117
+ if (this._activeVoteData[value]) {
118
+ this._activeVoteData[value] += 1;
119
+ } else {
120
+ this._activeVoteData[value] = 1;
121
+ }
122
+ }
123
+
124
+ // возвращает результат голосования, обрабатывает ничью и запускает следующее
125
+ getResult(name) {
126
+ // если голосование не активное
127
+ if (this._activeVoteName !== name) {
128
+ return;
129
+ }
130
+
131
+ const results = this._activeVoteData;
132
+ let maxVotes = 0;
133
+ let winners = []; // массив для хранения победителей
134
+
135
+ // удаление завершенного голосования перед подсчетом
136
+ this._activeVoteName = null;
137
+ this._activeVoteCategory = null;
138
+ this._activeVoteData = null;
139
+
140
+ // если есть следующее голосование из очереди
141
+ if (this._voteQueue.length > 0) {
142
+ const nextVote = this._voteQueue.shift();
143
+ this._startVote(nextVote);
144
+ }
145
+
146
+ // если никто не проголосовал
147
+ if (!results || Object.keys(results).length === 0) {
148
+ return;
149
+ }
150
+
151
+ // поиск победителей
152
+ for (const option in results) {
153
+ if (Object.hasOwn(results, option)) {
154
+ const currentVotes = results[option];
155
+
156
+ // если текущий вариант набрал больше голосов,
157
+ // он становится единственным лидером
158
+ if (currentVotes > maxVotes) {
159
+ maxVotes = currentVotes;
160
+ winners = [option];
161
+ // если голосов столько же, добавляем в список победителей (ничья)
162
+ } else if (currentVotes === maxVotes) {
163
+ winners.push(option);
164
+ }
165
+ }
166
+ }
167
+
168
+ // если победитель один
169
+ if (winners.length === 1) {
170
+ return winners[0];
171
+ }
172
+
173
+ // случайный выбор, если победителей несколько
174
+ const randomIndex = Math.floor(Math.random() * winners.length);
175
+ return winners[randomIndex];
176
+ }
177
+ }
178
+
179
+ export default Vote;
@@ -0,0 +1,66 @@
1
+ import { buildSystemMessage } from './systemMessages.js';
2
+
3
+ // Singleton Chat
4
+
5
+ let chat;
6
+
7
+ class Chat {
8
+ constructor() {
9
+ if (chat) {
10
+ return chat;
11
+ }
12
+
13
+ chat = this;
14
+
15
+ this._list = [];
16
+ this._userList = {};
17
+ }
18
+
19
+ // добавляет пользователя
20
+ addUser(gameId) {
21
+ this._userList[gameId] = [];
22
+ }
23
+
24
+ // удаляет пользователя
25
+ removeUser(gameId) {
26
+ delete this._userList[gameId];
27
+ }
28
+
29
+ // добавляет сообщение
30
+ push(message, name, teamId) {
31
+ this._list.push([message, name, teamId]);
32
+ }
33
+
34
+ // добавляет системное сообщение для всех
35
+ // message может быть:
36
+ // - шаблонным сообщением '<группа шаблонов>:<номер шаблона>:<параметры>'
37
+ // - сообщением в виде массива [<текст сообщения>]
38
+ pushSystem(message, params) {
39
+ if (typeof message === 'string') {
40
+ this._list.push(buildSystemMessage(message, params));
41
+ } else {
42
+ this._list.push(message);
43
+ }
44
+ }
45
+
46
+ // добавляет системное сообщение для пользователя
47
+ pushSystemByUser(gameId, message, params) {
48
+ if (typeof message === 'string') {
49
+ this._userList[gameId].push(buildSystemMessage(message, params));
50
+ } else {
51
+ this._userList[gameId].push(message);
52
+ }
53
+ }
54
+
55
+ // возвращает сообщение
56
+ shift() {
57
+ return this._list.shift();
58
+ }
59
+
60
+ // возвращает сообщение для пользователя
61
+ shiftByUser(gameId) {
62
+ return this._userList[gameId].shift();
63
+ }
64
+ }
65
+
66
+ export default Chat;
@@ -0,0 +1 @@
1
+ export { default } from './Chat.js';
@@ -0,0 +1,51 @@
1
+ // Реестр кодов системных сообщений чата. Движковые группы: s (статусы),
2
+ // v (голосования), m (карты), c (команды), n (имена). Игровые коды
3
+ // (у танков — группа b:*) добавляются через registerCodes и не должны
4
+ // пересекаться с движковыми группами. Тексты шаблонов — на клиенте.
5
+ const MESSAGE_CODES = {
6
+ TEAMS_TEAM_FULL: 's:0', // Team {0} is full. Your current team: {1}
7
+ TEAMS_YOUR_TEAM: 's:1', // Your team: {0}
8
+ TEAMS_NEW_TEAM: 's:2', // Your new team: {0}
9
+ TEAMS_NOW_SPECTATOR: 's:3', // Your new status: spectator
10
+ REPORT_KILL: 's:4', // ⚔️ {0} killed {1}!
11
+ USER_JOINED: 's:5', // ⚡ {0} joined the game
12
+ USER_LEFT: 's:6', // 👋 {0} left the game
13
+
14
+ VOTE_CREATED: 'v:0', // A vote has been created
15
+ VOTE_STARTED: 'v:1', // Voting has started
16
+ VOTE_ACCEPTED: 'v:2', // Your vote has been accepted
17
+ VOTE_UNAVAILABLE: 'v:3', // Voting is temporarily unavailable
18
+ VOTE_PASSED: 'v:4', // Vote passed
19
+ VOTE_FAILED: 'v:5', // Vote failed
20
+
21
+ MAP_CURRENT: 'm:0', // Current map: {0}
22
+ MAP_NEXT: 'm:1', // Next map: {0}
23
+
24
+ COMMANDS_NOT_FOUND: 'c:0', // Command not found
25
+ RANK: 'c:1', // Your rank: {0}
26
+
27
+ NAME_INVALID: 'n:0', // Invalid name
28
+ NAME_CHANGED: 'n:1', // {0} changed name to {1}
29
+ };
30
+
31
+ /**
32
+ * Регистрирует игровые коды системных сообщений (merge в реестр движка).
33
+ * Идемпотентна: повторная регистрация тех же кодов безопасна.
34
+ * @param {Object} codes - { KEY: '<группа>:<номер>' }
35
+ */
36
+ export function registerCodes(codes) {
37
+ Object.assign(MESSAGE_CODES, codes);
38
+ }
39
+
40
+ /**
41
+ * Собирает финальную строку системного сообщения из ключа и параметров.
42
+ * @param {string} messageKey - Ключ из объекта MESSAGE_CODES
43
+ * @param {Array<string>} [params=[]] - Массив с параметрами для сообщения.
44
+ * @returns {string|null} Готовая строка для отправки клиенту
45
+ * (например, 'n:1:Player1,Player2')
46
+ */
47
+ export function buildSystemMessage(key, params = []) {
48
+ return params.length
49
+ ? `${MESSAGE_CODES[key]}:${params.join(',')}`
50
+ : MESSAGE_CODES[key];
51
+ }
@@ -0,0 +1,36 @@
1
+ import Participant from './Participant.js';
2
+
3
+ // Участник-человек: поля и поведение, специфичные для реального игрока
4
+ class HumanParticipant extends Participant {
5
+ constructor({
6
+ gameId,
7
+ name,
8
+ model,
9
+ team,
10
+ teamId,
11
+ socketId,
12
+ watchedGameId,
13
+ token = null,
14
+ }) {
15
+ super({ gameId, name, model, team, teamId });
16
+
17
+ this.socketId = socketId;
18
+ // identity-токен участника (Этап B4): переиспользуется для
19
+ // авторизованной синхронизации rank/state с мастером
20
+ this.token = token;
21
+ this.isReady = false;
22
+ this.currentMap = null;
23
+ this.isWatching = true;
24
+ this.watchedGameId = watchedGameId ?? null;
25
+ this.forceCameraReset = true;
26
+ this.pendingShake = null;
27
+ this.lastActionTime = Date.now();
28
+ this.lastInputSeq = 0; // номер последнего обработанного ввода (предикшен)
29
+ }
30
+
31
+ get isNetworked() {
32
+ return true;
33
+ }
34
+ }
35
+
36
+ export default HumanParticipant;
@@ -0,0 +1,22 @@
1
+ // Базовый класс участника — источник истины полей, общих для людей и ботов
2
+ class Participant {
3
+ constructor({ gameId, name, model, team, teamId }) {
4
+ this.gameId = gameId;
5
+ this.name = name;
6
+ this.model = model;
7
+ this.team = team;
8
+ this.teamId = teamId;
9
+ this.status = 'spectator'; // 'spectator' | 'active' | 'dead'
10
+ }
11
+
12
+ // участник, управляемый кодом (ИИ), а не сетевым игроком
13
+ get isScripted() {
14
+ return false;
15
+ }
16
+
17
+ get isNetworked() {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ export default Participant;
@@ -0,0 +1,247 @@
1
+ import HumanParticipant from './HumanParticipant.js';
2
+ import ScriptedParticipant from './ScriptedParticipant.js';
3
+
4
+ // Единый источник истины об участниках игры (люди + scripted-участники).
5
+ // Владеет реестром, размерами команд, списком активных игроков,
6
+ // генерацией id (единое числовое пространство) и проверкой имён.
7
+ class ParticipantManager {
8
+ // scripted — параметры scripted-участников из конфига игры:
9
+ // { namePrefix, defaultModel }
10
+ constructor(teams, spectatorTeam, maxPlayers, scripted = {}) {
11
+ this._teams = teams; // { team1: 1, team2: 2, spectators: 3 }
12
+ this._spectatorTeam = spectatorTeam;
13
+ this._spectatorId = teams[spectatorTeam];
14
+ this._maxPlayers = maxPlayers;
15
+ this._scripted = scripted;
16
+
17
+ this._participants = new Map(); // gameId -> Participant
18
+ this._teamSizes = {}; // team -> Set<gameId>
19
+ this._activePlayersList = []; // gameId[]
20
+
21
+ this.resetTeamSizes();
22
+ }
23
+
24
+ // наименьший свободный числовой id (единое пространство людей и scripted)
25
+ _nextGameId() {
26
+ let counter = 0;
27
+
28
+ while (this._participants.has(counter.toString(10))) {
29
+ counter += 1;
30
+ }
31
+
32
+ return counter.toString(10);
33
+ }
34
+
35
+ // создаёт участника-человека (спектатор), возвращает gameId
36
+ createHuman(params, socketId) {
37
+ const gameId = this._nextGameId();
38
+ const name = this.checkName(params.name);
39
+
40
+ const participant = new HumanParticipant({
41
+ gameId,
42
+ name,
43
+ model: params.model,
44
+ team: this._spectatorTeam,
45
+ teamId: this._spectatorId,
46
+ socketId,
47
+ watchedGameId: this._activePlayersList[0] || null,
48
+ token: params.token,
49
+ });
50
+
51
+ this._participants.set(gameId, participant);
52
+ this._teamSizes[this._spectatorTeam].add(gameId);
53
+
54
+ return gameId;
55
+ }
56
+
57
+ // создаёт scripted-участника в команде, возвращает gameId
58
+ createScripted({ team, model }) {
59
+ const gameId = this._nextGameId();
60
+ const name = this.checkName(`${this._scripted.namePrefix}${gameId}`);
61
+ const teamId = this._teams[team];
62
+
63
+ const participant = new ScriptedParticipant({
64
+ gameId,
65
+ name,
66
+ model: model ?? this._scripted.defaultModel,
67
+ team,
68
+ teamId,
69
+ });
70
+
71
+ this._participants.set(gameId, participant);
72
+ this._teamSizes[team].add(gameId);
73
+
74
+ return gameId;
75
+ }
76
+
77
+ // восстанавливает человека с исходным gameId (эстафета Worker'ов, Этап 5.2);
78
+ // занятый id или неизвестная команда — null (запись пропускается)
79
+ restoreHuman({ gameId, socketId, name, model, team, teamId }) {
80
+ if (this._participants.has(gameId) || !this._teamSizes[team]) {
81
+ return null;
82
+ }
83
+
84
+ const participant = new HumanParticipant({
85
+ gameId,
86
+ name,
87
+ model,
88
+ team,
89
+ teamId,
90
+ socketId,
91
+ watchedGameId: this._activePlayersList[0] || null,
92
+ });
93
+
94
+ this._participants.set(gameId, participant);
95
+ this._teamSizes[team].add(gameId);
96
+
97
+ return participant;
98
+ }
99
+
100
+ // восстанавливает scripted-участника с исходным gameId
101
+ // (эстафета Worker'ов, Этап 5.2)
102
+ restoreScripted({ gameId, name, model, team, teamId }) {
103
+ if (this._participants.has(gameId) || !this._teamSizes[team]) {
104
+ return null;
105
+ }
106
+
107
+ const participant = new ScriptedParticipant({
108
+ gameId,
109
+ name,
110
+ model,
111
+ team,
112
+ teamId,
113
+ });
114
+
115
+ this._participants.set(gameId, participant);
116
+ this._teamSizes[team].add(gameId);
117
+
118
+ return participant;
119
+ }
120
+
121
+ // полностью удаляет участника из реестра (команда + список активных)
122
+ remove(gameId) {
123
+ const participant = this._participants.get(gameId);
124
+
125
+ if (!participant) {
126
+ return;
127
+ }
128
+
129
+ this.removeActive(gameId);
130
+ this._teamSizes[participant.team]?.delete(gameId);
131
+ this._participants.delete(gameId);
132
+ }
133
+
134
+ get(gameId) {
135
+ return this._participants.get(gameId);
136
+ }
137
+
138
+ getAll() {
139
+ return [...this._participants.values()];
140
+ }
141
+
142
+ getHumans() {
143
+ return this.getAll().filter(p => p.isNetworked);
144
+ }
145
+
146
+ getScripted() {
147
+ return this.getAll().filter(p => p.isScripted);
148
+ }
149
+
150
+ // люди, готовые к игре (получатели сетевого кадра)
151
+ getNetworkedReady() {
152
+ return this.getHumans().filter(p => p.isReady);
153
+ }
154
+
155
+ // проверяет уникальность имени по всему реестру (люди + scripted)
156
+ checkName(name, number = 1) {
157
+ for (const participant of this._participants.values()) {
158
+ if (participant.name === name) {
159
+ if (number > 1) {
160
+ name = name.slice(0, name.lastIndexOf('#')) + '#' + number;
161
+ } else {
162
+ name = name + '#' + number;
163
+ }
164
+
165
+ return this.checkName(name, number + 1);
166
+ }
167
+ }
168
+
169
+ return name;
170
+ }
171
+
172
+ // команды
173
+ getPlayableTeams() {
174
+ return Object.keys(this._teams).filter(t => t !== this._spectatorTeam);
175
+ }
176
+
177
+ getTeamSize(team) {
178
+ return this._teamSizes[team].size;
179
+ }
180
+
181
+ addToTeam(gameId, team) {
182
+ this._teamSizes[team].add(gameId);
183
+ }
184
+
185
+ removeFromTeam(gameId, team) {
186
+ this._teamSizes[team].delete(gameId);
187
+ }
188
+
189
+ resetTeamSizes() {
190
+ this._teamSizes = Object.keys(this._teams).reduce((acc, key) => {
191
+ acc[key] = new Set();
192
+ return acc;
193
+ }, {});
194
+ }
195
+
196
+ // активные игроки (на полотне, для наблюдения)
197
+ addActive(gameId) {
198
+ if (!this._activePlayersList.includes(gameId)) {
199
+ this._activePlayersList.push(gameId);
200
+ }
201
+ }
202
+
203
+ removeActive(gameId) {
204
+ this._activePlayersList = this._activePlayersList.filter(
205
+ id => id !== gameId,
206
+ );
207
+
208
+ // перецепление наблюдателей на другого активного игрока
209
+ for (const participant of this._participants.values()) {
210
+ if (participant.watchedGameId === gameId) {
211
+ participant.watchedGameId = this._activePlayersList[0] || null;
212
+ }
213
+ }
214
+ }
215
+
216
+ clearActive() {
217
+ this._activePlayersList = [];
218
+ }
219
+
220
+ getActiveList() {
221
+ return this._activePlayersList;
222
+ }
223
+
224
+ // заменяет наблюдаемого игрока (victimId) на killerId
225
+ replaceWatched(victimId, killerId) {
226
+ if (!this._activePlayersList.includes(killerId)) {
227
+ return;
228
+ }
229
+
230
+ for (const participant of this._participants.values()) {
231
+ if (participant.watchedGameId === victimId) {
232
+ participant.watchedGameId = killerId;
233
+ }
234
+ }
235
+ }
236
+
237
+ // суммарно люди + scripted (для лимита maxPlayers)
238
+ get totalCount() {
239
+ return this._participants.size;
240
+ }
241
+
242
+ get isFull() {
243
+ return this._maxPlayers ? this.totalCount >= this._maxPlayers : false;
244
+ }
245
+ }
246
+
247
+ export default ParticipantManager;
@@ -0,0 +1,19 @@
1
+ import Participant from './Participant.js';
2
+
3
+ // Scripted-участник: управляется игровым модулем, всегда active-или-удалён,
4
+ // наблюдателем не бывает
5
+ class ScriptedParticipant extends Participant {
6
+ constructor({ gameId, name, model, team, teamId }) {
7
+ super({ gameId, name, model, team, teamId });
8
+
9
+ // в статистику добавляется как 'dead' до старта раунда
10
+ this.status = 'dead';
11
+ this.controller = null;
12
+ }
13
+
14
+ get isScripted() {
15
+ return true;
16
+ }
17
+ }
18
+
19
+ export default ScriptedParticipant;