vimp-engine 0.20.0 → 0.22.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/package.json +1 -1
- package/src/client/components/model/Chat.js +2 -1
- package/src/client/components/model/Stat.js +123 -2
- package/src/client/components/view/Chat.js +8 -0
- package/src/client/components/view/Stat.js +43 -0
- package/src/client/lib/accolades.js +56 -0
- package/src/client/main.js +33 -4
- package/src/client/style.css +25 -4
- package/src/config/lobby.js +64 -0
- package/src/config/master.js +30 -0
- package/src/config/opcodes.js +3 -1
- package/src/config/wsports.js +3 -0
- package/src/devtools/contract/rules/c11-stat-mode.js +60 -0
- package/src/devtools/contract/rules/c4-component-dependencies.js +9 -2
- package/src/devtools/contract/rules/c6-stat-columns.js +29 -1
- package/src/devtools/contract/rules/index.js +2 -1
- package/src/host/HostGame.js +104 -8
- package/src/host/meta/SocketManager.js +11 -0
- package/src/host/meta/core/RoundManager.js +9 -4
- package/src/host/meta/modules/Accolades.js +283 -0
- package/src/host/meta/modules/PlayerDataSync.js +484 -85
- package/src/host/meta/modules/chat/Chat.js +9 -3
- package/src/host/meta/player/Participant.js +6 -0
- package/src/host/meta/player/ParticipantManager.js +16 -0
- package/src/lib/validators.js +47 -0
package/package.json
CHANGED
|
@@ -51,7 +51,8 @@ export default class ChatModel {
|
|
|
51
51
|
|
|
52
52
|
// обновляет чат-лист. Данные могут быть 2-х видов:
|
|
53
53
|
// - в виде строки '<группа шаблонов>:<номер шаблона>:<параметры>'
|
|
54
|
-
// - в виде массива [<текст сообщения>,<имя автора>,<тип для
|
|
54
|
+
// - в виде массива [<текст сообщения>,<имя автора>,<тип для класса>,
|
|
55
|
+
// <цвет ника>?]
|
|
55
56
|
updateChat(arr) {
|
|
56
57
|
// если данные - строка
|
|
57
58
|
if (typeof arr === 'string') {
|
|
@@ -1,27 +1,67 @@
|
|
|
1
1
|
import Publisher from '../../../lib/Publisher.js';
|
|
2
|
+
import { nickKey } from '../../../lib/validators.js';
|
|
2
3
|
|
|
3
4
|
// Singleton StatModel
|
|
4
5
|
|
|
5
6
|
let statModel;
|
|
6
7
|
|
|
7
8
|
export default class StatModel {
|
|
8
|
-
|
|
9
|
+
// data — params схемы игры; deps — то, чего у модуля своего нет: топ,
|
|
10
|
+
// привезённый хостом, свой gameId и свой ник (режим 'leaderboard')
|
|
11
|
+
constructor(data, deps = {}) {
|
|
12
|
+
// Синглтон переиспользуется, но НЕ замораживается: runModules зовётся на
|
|
13
|
+
// каждый вход в матч (порт CONFIG_DATA), и вернуть старый экземпляр
|
|
14
|
+
// как есть значило бы играть в snakes с параметрами tanks — режим,
|
|
15
|
+
// период и число строк приезжают из схемы КОНКРЕТНОЙ игры
|
|
9
16
|
if (statModel) {
|
|
17
|
+
statModel._configure(data, deps);
|
|
18
|
+
|
|
10
19
|
return statModel;
|
|
11
20
|
}
|
|
12
21
|
|
|
13
22
|
statModel = this;
|
|
14
23
|
|
|
24
|
+
this.publisher = new Publisher();
|
|
25
|
+
this._configure(data, deps);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// всё, что зависит от схемы игры и её сервисов: выделено из конструктора
|
|
29
|
+
// ровно затем, чтобы переиспользованный синглтон обновлялся, а не врал
|
|
30
|
+
_configure(data, deps = {}) {
|
|
15
31
|
this._heads = data.heads;
|
|
16
32
|
this._bodies = data.bodies;
|
|
17
33
|
this._sortList = data.sortList;
|
|
18
|
-
|
|
34
|
+
|
|
35
|
+
// snakes-v3 этап 4: режим 'leaderboard' — по Tab показывается не
|
|
36
|
+
// таблица комнаты, а глобальный топ игры. Данные хоста в этом режиме не
|
|
37
|
+
// рисуются (хост их и не шлёт), список приезжает портом ACCOLADES_DATA
|
|
38
|
+
this._mode = data.mode ?? null;
|
|
39
|
+
this._period = data.period ?? 'day';
|
|
40
|
+
this._limit = data.limit ?? 10;
|
|
41
|
+
|
|
42
|
+
// сервисы движка, а не сеть: топ уже привезён хостом (см.
|
|
43
|
+
// client/lib/accolades.js), свой gameId знает клиентское ядро
|
|
44
|
+
this._accolades = deps.accolades ?? null;
|
|
45
|
+
this._localPlayer = deps.localPlayer ?? null;
|
|
46
|
+
this._getNick = deps.getNick ?? (() => null);
|
|
47
|
+
|
|
48
|
+
this._rows = [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get isLeaderboard() {
|
|
52
|
+
return this._mode === 'leaderboard';
|
|
19
53
|
}
|
|
20
54
|
|
|
21
55
|
// открывает статистику
|
|
22
56
|
open() {
|
|
23
57
|
this.publisher.emit('open');
|
|
24
58
|
this.publisher.emit('mode', { name: 'stat', status: 'opened' });
|
|
59
|
+
|
|
60
|
+
if (this.isLeaderboard) {
|
|
61
|
+
// сети здесь нет вовсе: рисуется последняя рассылка хоста, а
|
|
62
|
+
// следующая перерисует список сама (см. applyAccolades)
|
|
63
|
+
this.refreshLeaderboard();
|
|
64
|
+
}
|
|
25
65
|
}
|
|
26
66
|
|
|
27
67
|
// закрывает статистику
|
|
@@ -32,6 +72,11 @@ export default class StatModel {
|
|
|
32
72
|
|
|
33
73
|
// обновляет данные статистики
|
|
34
74
|
update(data) {
|
|
75
|
+
// в режиме 'leaderboard' данные комнаты не рисуются вовсе
|
|
76
|
+
if (this.isLeaderboard) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
35
80
|
const tBodiesData = data[0];
|
|
36
81
|
const tHeadData = data[1];
|
|
37
82
|
const fullStatFlag = data[2];
|
|
@@ -74,4 +119,80 @@ export default class StatModel {
|
|
|
74
119
|
}
|
|
75
120
|
}
|
|
76
121
|
}
|
|
122
|
+
|
|
123
|
+
// пришла новая рассылка мест и топа (порт ACCOLADES_DATA). Перерисовывать
|
|
124
|
+
// список стоит только пока статистика открыта? — нет: сборка строк это
|
|
125
|
+
// десяток объектов, а держать модель в актуальном виде дешевле, чем
|
|
126
|
+
// объяснять, почему первый Tab показывает вчерашнее
|
|
127
|
+
applyAccolades() {
|
|
128
|
+
if (this.isLeaderboard) {
|
|
129
|
+
this.refreshLeaderboard();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Пересобирает список из того, что привёз хост. Ни одного запроса: в
|
|
134
|
+
// матче клиент с мастером не разговаривает (см. client/lib/accolades.js).
|
|
135
|
+
// Пустой список до первой рассылки — нормальное состояние
|
|
136
|
+
refreshLeaderboard() {
|
|
137
|
+
if (!this._accolades) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const board = this._accolades.boardOf(this._period);
|
|
142
|
+
const rows = board.slice(0, this._limit).map(row => ({
|
|
143
|
+
place: row.place,
|
|
144
|
+
nick: row.nick,
|
|
145
|
+
score: row.score,
|
|
146
|
+
isSelf: false,
|
|
147
|
+
}));
|
|
148
|
+
|
|
149
|
+
this._rows = this._withSelf(rows);
|
|
150
|
+
|
|
151
|
+
this.publisher.emit('leaderboard', this._rows);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Своя строка: если игрок в топе — подсвечивается на своём месте, если
|
|
155
|
+
// нет — заменяет последнюю (решение пользователя 7). Неранжированный за
|
|
156
|
+
// период получает прочерк вместо места и свои очки (то есть 0).
|
|
157
|
+
//
|
|
158
|
+
// `rows` здесь ВСЕГДА чистый топ от хоста, а не результат прошлого прохода:
|
|
159
|
+
// подставленная своя строка в него не попадает, и сравнение по нику
|
|
160
|
+
// поэтому честное. Раньше список пересобирался поверх самого себя, и
|
|
161
|
+
// синтетическая строка выдавала себя за строку из топа — своё место потом
|
|
162
|
+
// не обновлялось никогда
|
|
163
|
+
_withSelf(rows) {
|
|
164
|
+
const nick = this._getNick();
|
|
165
|
+
const lower = nick ? nickKey(nick) : null;
|
|
166
|
+
const marked = rows.map(row => ({
|
|
167
|
+
...row,
|
|
168
|
+
isSelf: lower !== null && nickKey(row.nick) === lower,
|
|
169
|
+
}));
|
|
170
|
+
|
|
171
|
+
if (marked.some(row => row.isSelf)) {
|
|
172
|
+
return marked;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// в топе игрока нет — его собственное место знает только рассылка хоста
|
|
176
|
+
const myId = this._localPlayer?.id ?? null;
|
|
177
|
+
const mine = myId === null ? null : this._accolades.selfOf(myId, this._period);
|
|
178
|
+
|
|
179
|
+
if (!mine || !nick) {
|
|
180
|
+
return marked;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const self = {
|
|
184
|
+
place: mine.place ?? null,
|
|
185
|
+
nick,
|
|
186
|
+
score: mine.score ?? 0,
|
|
187
|
+
isSelf: true,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
if (marked.length < this._limit) {
|
|
191
|
+
marked.push(self);
|
|
192
|
+
} else {
|
|
193
|
+
marked[this._limit - 1] = self;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return marked;
|
|
197
|
+
}
|
|
77
198
|
}
|
|
@@ -52,12 +52,20 @@ export default class ChatView {
|
|
|
52
52
|
const text = message[0];
|
|
53
53
|
const name = message[1] || 'System';
|
|
54
54
|
const type = typeof message[2] === 'number' ? message[2] : '';
|
|
55
|
+
// цвет ника от игры (необязательный четвёртый элемент). CSS красит
|
|
56
|
+
// `:before` через var(--chat-name-color, <цвет команды>), поэтому без
|
|
57
|
+
// этого свойства строка выглядит ровно как раньше
|
|
58
|
+
const color = typeof message[3] === 'string' ? message[3] : '';
|
|
55
59
|
|
|
56
60
|
line.id = `line_${id}`;
|
|
57
61
|
line.className = `line${type}`;
|
|
58
62
|
line.setAttribute('data-name', `${name}: `);
|
|
59
63
|
line.textContent = text;
|
|
60
64
|
|
|
65
|
+
if (color) {
|
|
66
|
+
line.style.setProperty('--chat-name-color', color);
|
|
67
|
+
}
|
|
68
|
+
|
|
61
69
|
this._chat.appendChild(line);
|
|
62
70
|
}
|
|
63
71
|
|
|
@@ -17,6 +17,8 @@ export default class StatView {
|
|
|
17
17
|
const { elems, params } = config;
|
|
18
18
|
|
|
19
19
|
this._stat = document.getElementById(elems.stat);
|
|
20
|
+
// контейнер списка глобального топа — только в режиме 'leaderboard'
|
|
21
|
+
this._leaderboard = null;
|
|
20
22
|
|
|
21
23
|
// DOM scoreboard генерируется по схеме игры: произвольное число
|
|
22
24
|
// команд (bodies) и колонок (columns)
|
|
@@ -31,12 +33,24 @@ export default class StatView {
|
|
|
31
33
|
this._mPublic.on('tHead', 'updateTableHead', this);
|
|
32
34
|
this._mPublic.on('tBody', 'updateTableBody', this);
|
|
33
35
|
this._mPublic.on('clearBodies', 'clearBodies', this);
|
|
36
|
+
this._mPublic.on('leaderboard', 'updateLeaderboard', this);
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
// генерирует шапку и таблицы по схеме (замена хардкода stat.pug)
|
|
37
40
|
_buildStat(params) {
|
|
38
41
|
const { columns, bodies } = params;
|
|
39
42
|
|
|
43
|
+
// snakes-v3 этап 4: режим 'leaderboard' — один список глобального топа
|
|
44
|
+
// вместо шапки и таблиц команд. Строки рисует updateLeaderboard, здесь
|
|
45
|
+
// заводится только контейнер
|
|
46
|
+
if (params.mode === 'leaderboard') {
|
|
47
|
+
this._leaderboard = document.createElement('div');
|
|
48
|
+
this._leaderboard.className = 'stat-leaderboard';
|
|
49
|
+
this._stat.appendChild(this._leaderboard);
|
|
50
|
+
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
40
54
|
const head = document.createElement('div');
|
|
41
55
|
|
|
42
56
|
head.className = 'stat-head';
|
|
@@ -77,6 +91,35 @@ export default class StatView {
|
|
|
77
91
|
this._stat.appendChild(tables);
|
|
78
92
|
}
|
|
79
93
|
|
|
94
|
+
// перерисовывает список глобального топа (режим 'leaderboard'):
|
|
95
|
+
// место · ник · очки, своя строка помечена is-self. Список короткий и
|
|
96
|
+
// приходит целиком — точечное обновление строк тут не окупается
|
|
97
|
+
updateLeaderboard(rows) {
|
|
98
|
+
if (!this._leaderboard) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
this._leaderboard.textContent = '';
|
|
103
|
+
|
|
104
|
+
for (const row of rows) {
|
|
105
|
+
const line = document.createElement('div');
|
|
106
|
+
|
|
107
|
+
line.className = row.isSelf ? 'stat-row is-self' : 'stat-row';
|
|
108
|
+
|
|
109
|
+
// неранжированный за период игрок места не имеет — прочерк вместо
|
|
110
|
+
// числа честнее, чем выдуманная позиция
|
|
111
|
+
for (const value of [row.place ?? '—', row.nick, row.score]) {
|
|
112
|
+
const cell = document.createElement('span');
|
|
113
|
+
|
|
114
|
+
// String(): ноль очков — это «0», а не пустая ячейка
|
|
115
|
+
cell.textContent = String(value);
|
|
116
|
+
line.appendChild(cell);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
this._leaderboard.appendChild(line);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
80
123
|
// открывает статистику
|
|
81
124
|
open() {
|
|
82
125
|
this._stat.style.display = 'block';
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Сервис пула зависимостей `accolades` (snakes-v3 этап 4): всё, что комната
|
|
2
|
+
// знает о глобальном топе игры.
|
|
3
|
+
//
|
|
4
|
+
// Отвечает на три вопроса:
|
|
5
|
+
// placeOf(id) — какое место у этой сущности в топе-10 (для part'а,
|
|
6
|
+
// который рисует за место знак);
|
|
7
|
+
// boardOf(period) — сам топ строками (для режима stat 'leaderboard');
|
|
8
|
+
// selfOf(id, period) — место и очки самого игрока, чтобы он видел свою
|
|
9
|
+
// строку, даже когда в топ не входит.
|
|
10
|
+
//
|
|
11
|
+
// ЗА ТОПОМ ХОДИТ ХОСТ, А НЕ КЛИЕНТ. Места и таблицу считает
|
|
12
|
+
// host/meta/modules/Accolades.js по тому же публичному топу, что рисует
|
|
13
|
+
// лобби, и шлёт их портом ACCOLADES_DATA только когда они изменились. Клиент
|
|
14
|
+
// в матче не обращается к мастеру вовсе: игрок разговаривает со своим игровым
|
|
15
|
+
// сервером. На целевом масштабе (100 игр × 100 серверов × 8 игроков) разница
|
|
16
|
+
// между «спрашивает каждый клиент» и «спрашивает комната» — четыре порядка на
|
|
17
|
+
// самом дорогом запросе схемы, см. комментарий в хостовом модуле.
|
|
18
|
+
//
|
|
19
|
+
// Движок не знает, что игра нарисует за место: он раздаёт числа, а знак —
|
|
20
|
+
// дело part'а.
|
|
21
|
+
//
|
|
22
|
+
// Ключи приходят строками (gameId на стороне движка — строка), поэтому
|
|
23
|
+
// placeOf/selfOf приводят запрошенный id к строке: у part'а id экземпляра
|
|
24
|
+
// может быть числом ключа кадра.
|
|
25
|
+
export function createAccolades() {
|
|
26
|
+
let places = {};
|
|
27
|
+
let boards = {};
|
|
28
|
+
let self = {};
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
// применяет рассылку хоста целиком: она всегда полная, а не дельта
|
|
32
|
+
apply(data) {
|
|
33
|
+
places = data?.places || {};
|
|
34
|
+
boards = data?.boards || {};
|
|
35
|
+
self = data?.self || {};
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
// { daily, monthly } — всегда объект: незнакомый id (бот, гость,
|
|
39
|
+
// сущность без игрока) не в топе, и это не аварийный случай
|
|
40
|
+
placeOf(id) {
|
|
41
|
+
return places[String(id)] || { daily: null, monthly: null };
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
// [{ place, nick, score }] среза ('day'/'month'); пустой массив, пока
|
|
45
|
+
// первая рассылка не пришла — это нормальное состояние, а не сбой
|
|
46
|
+
boardOf(period) {
|
|
47
|
+
return boards[period] || [];
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
// { place, score } игрока в срезе или null: место есть только у
|
|
51
|
+
// ранжированного за период участника с проверенной личностью
|
|
52
|
+
selfOf(id, period) {
|
|
53
|
+
return self[String(id)]?.[period] ?? null;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
package/src/client/main.js
CHANGED
|
@@ -35,6 +35,7 @@ import { getBootConfig, resolveBootConfig } from './boot.js';
|
|
|
35
35
|
import { ensureGameShell, ensureCanvas } from './views/gameShell.js';
|
|
36
36
|
import { createContextTracker } from './lib/contextTracker.js';
|
|
37
37
|
import { createLocalPlayer } from './lib/localPlayer.js';
|
|
38
|
+
import { createAccolades } from './lib/accolades.js';
|
|
38
39
|
import { createDebugApi, debugLog, DEBUG_PREFIX } from './debug.js';
|
|
39
40
|
import { buildClientCoreConfig } from '../lib/clientCoreConfig.js';
|
|
40
41
|
import {
|
|
@@ -181,6 +182,7 @@ const PS_STAT_DATA = wsports.server.STAT_DATA;
|
|
|
181
182
|
const PS_CHAT_DATA = wsports.server.CHAT_DATA;
|
|
182
183
|
const PS_VOTE_DATA = wsports.server.VOTE_DATA;
|
|
183
184
|
const PS_KEYSET_DATA = wsports.server.KEYSET_DATA;
|
|
185
|
+
const PS_ACCOLADES_DATA = wsports.server.ACCOLADES_DATA;
|
|
184
186
|
|
|
185
187
|
// PC (client ports): порты получения данных от клиента
|
|
186
188
|
const PC_CONFIG_READY = wsports.client.CONFIG_READY;
|
|
@@ -279,6 +281,9 @@ let wasm = null;
|
|
|
279
281
|
// сервис пула зависимостей: «эта сущность моя или чужая?». Ядро читается
|
|
280
282
|
// геттером — оно создаётся позже пула сервисов (см. lib/localPlayer.js)
|
|
281
283
|
const localPlayer = createLocalPlayer(() => clientCore);
|
|
284
|
+
// сервис пула зависимостей: «какое место у этой сущности в глобальном
|
|
285
|
+
// топе?». Места считает хост, part рисует за них знак (см. lib/accolades.js)
|
|
286
|
+
const accolades = createAccolades();
|
|
282
287
|
let inputSeq = 0; // номер отправленного ввода (KEYS_DATA)
|
|
283
288
|
|
|
284
289
|
// обратный индекс снапшот-схемы игры (CONFIG_DATA.snapshot):
|
|
@@ -376,6 +381,9 @@ socketMethods[PS_CONFIG_DATA] = async data => {
|
|
|
376
381
|
// аргумент конструктора) со своим gameId. Игра без этого сервиса звучит
|
|
377
382
|
// одинаково за всех — чужие подборы и чужие смерти вперемешку со своими
|
|
378
383
|
localPlayer,
|
|
384
|
+
// места в глобальном топе: знак за место рисует part игры, движок
|
|
385
|
+
// раздаёт только числа (см. lib/accolades.js)
|
|
386
|
+
accolades,
|
|
379
387
|
// база ассетов игры — тем же каналом, что и путь к звукам (см. выше):
|
|
380
388
|
// картинки карт живут в пакете игры (dist/img/), движок их не раздаёт.
|
|
381
389
|
// part, объявивший сервис в componentDependencies, строит URL сам —
|
|
@@ -639,6 +647,16 @@ socketMethods[PS_STAT_DATA] = data => {
|
|
|
639
647
|
modules.stat.update(data);
|
|
640
648
|
};
|
|
641
649
|
|
|
650
|
+
// accolades data: места участников в глобальном топе, сам топ и место
|
|
651
|
+
// игрока в нём. Рассылка приходит от ХОСТА и только когда что-то из этого
|
|
652
|
+
// изменилось — в матче клиент за топом к мастеру не ходит (см.
|
|
653
|
+
// client/lib/accolades.js)
|
|
654
|
+
socketMethods[PS_ACCOLADES_DATA] = data => {
|
|
655
|
+
accolades.apply(data);
|
|
656
|
+
// stat в режиме 'leaderboard' рисует ровно эту таблицу
|
|
657
|
+
modules.stat?.applyAccolades?.();
|
|
658
|
+
};
|
|
659
|
+
|
|
642
660
|
// chat data
|
|
643
661
|
socketMethods[PS_CHAT_DATA] = data => {
|
|
644
662
|
modules.chat.add(data);
|
|
@@ -917,7 +935,13 @@ function runModules(data) {
|
|
|
917
935
|
// Stat Module
|
|
918
936
|
//==========================================//
|
|
919
937
|
|
|
920
|
-
|
|
938
|
+
// режим 'leaderboard' (snakes-v3 этап 4) рисует топ, привезённый хостом:
|
|
939
|
+
// ни одного запроса из матча — сервисы, а не сеть
|
|
940
|
+
const statModel = new StatModel(statData.params, {
|
|
941
|
+
accolades,
|
|
942
|
+
localPlayer,
|
|
943
|
+
getNick: () => lobbyAuthModel?.getNick() ?? null,
|
|
944
|
+
});
|
|
921
945
|
|
|
922
946
|
// StatView генерирует шапку и таблицы по схеме игры ({ elems, params })
|
|
923
947
|
const statView = new StatView(statModel, statData);
|
|
@@ -1789,7 +1813,13 @@ async function fetchServers({ offset, limit, search }) {
|
|
|
1789
1813
|
}
|
|
1790
1814
|
|
|
1791
1815
|
// топ-N рейтинга игры (lobby-page-plan) — публичный эндпоинт, доступен и до
|
|
1792
|
-
// логина, поэтому без Authorization
|
|
1816
|
+
// логина, поэтому без Authorization.
|
|
1817
|
+
//
|
|
1818
|
+
// Зовётся только ЛОББИ. Из матча за топом ходит хост комнаты и раздаёт его
|
|
1819
|
+
// портом ACCOLADES_DATA (host/meta/modules/Accolades.js): 80 000 игроков,
|
|
1820
|
+
// спрашивающих мастер лично, и 10 000 комнат, спрашивающих за них, — разные
|
|
1821
|
+
// порядки величин. Там же живёт и If-None-Match: валидатор имеет смысл
|
|
1822
|
+
// рядом с повторяющимся запросом, а лобби открывают по одному разу
|
|
1793
1823
|
async function fetchLeaderboard(gameId, period) {
|
|
1794
1824
|
const params = new URLSearchParams({
|
|
1795
1825
|
game: gameId,
|
|
@@ -1798,7 +1828,6 @@ async function fetchLeaderboard(gameId, period) {
|
|
|
1798
1828
|
// значение всегда из lobbyConfig.leaderboardPeriods
|
|
1799
1829
|
period,
|
|
1800
1830
|
});
|
|
1801
|
-
|
|
1802
1831
|
try {
|
|
1803
1832
|
const res = await fetch(`${lobbyConfig.leaderboardUrl}?${params}`);
|
|
1804
1833
|
|
|
@@ -1811,7 +1840,7 @@ async function fetchLeaderboard(gameId, period) {
|
|
|
1811
1840
|
// позиция вызывающего в рейтинге игры (lobby-page-plan) — требует identity-
|
|
1812
1841
|
// токена, как и остальные /auth/* запросы игрока (rank/state)
|
|
1813
1842
|
async function fetchPlacement(gameId, period) {
|
|
1814
|
-
const token = lobbyAuthModel
|
|
1843
|
+
const token = lobbyAuthModel?.getToken();
|
|
1815
1844
|
|
|
1816
1845
|
if (!token) {
|
|
1817
1846
|
return null;
|
package/src/client/style.css
CHANGED
|
@@ -286,6 +286,25 @@ body.hide-cursor {
|
|
|
286
286
|
text-align: right;
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
+
/* snakes-v3 этап 4: режим stat 'leaderboard' — не таблицы комнаты, а один
|
|
290
|
+
список глобального топа. Движок даёт только каркас (три колонки:
|
|
291
|
+
место, ник, очки), внешний вид игра доопределяет своим ClientPlugin.styles */
|
|
292
|
+
|
|
293
|
+
#stat .stat-leaderboard {
|
|
294
|
+
width: 92%;
|
|
295
|
+
margin: 3% auto;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
#stat .stat-row {
|
|
299
|
+
display: grid;
|
|
300
|
+
grid-template-columns: 15% 1fr 20%;
|
|
301
|
+
padding: 1%;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
#stat .stat-row span:nth-child(3) {
|
|
305
|
+
text-align: right;
|
|
306
|
+
}
|
|
307
|
+
|
|
289
308
|
/*vote*/
|
|
290
309
|
|
|
291
310
|
#vote {
|
|
@@ -377,20 +396,22 @@ body.hide-cursor {
|
|
|
377
396
|
font-weight: bold;
|
|
378
397
|
}
|
|
379
398
|
|
|
399
|
+
/* --chat-name-color — цвет ника, который игра задала на конкретную строку
|
|
400
|
+
(ParticipantManager.setChatColor). Его нет — остаётся цвет команды */
|
|
380
401
|
#chat-box .line:before {
|
|
381
|
-
color: #f00;
|
|
402
|
+
color: var(--chat-name-color, #f00);
|
|
382
403
|
}
|
|
383
404
|
|
|
384
405
|
#chat-box .line1:before {
|
|
385
|
-
color: #f56;
|
|
406
|
+
color: var(--chat-name-color, #f56);
|
|
386
407
|
}
|
|
387
408
|
|
|
388
409
|
#chat-box .line2:before {
|
|
389
|
-
color: #7ed;
|
|
410
|
+
color: var(--chat-name-color, #7ed);
|
|
390
411
|
}
|
|
391
412
|
|
|
392
413
|
#chat-box .line3:before {
|
|
393
|
-
color: #fe5;
|
|
414
|
+
color: var(--chat-name-color, #fe5);
|
|
394
415
|
}
|
|
395
416
|
|
|
396
417
|
#cmd {
|
package/src/config/lobby.js
CHANGED
|
@@ -44,12 +44,76 @@ export default {
|
|
|
44
44
|
stateUrl: '/auth/state',
|
|
45
45
|
},
|
|
46
46
|
|
|
47
|
+
// синхронизация профилей участников с мастером (snakes-v3 этап 3):
|
|
48
|
+
// пределами записи в БД владеет движок, а не игра — «игр сотни, серверов
|
|
49
|
+
// сотни», и одна игра, зовущая flushPlayerData() каждую секунду, не должна
|
|
50
|
+
// мочь положить auth-сервис. Игра может попросить синхронизацию, но не
|
|
51
|
+
// может участить её сверх minFlushInterval (кроме срочных границ:
|
|
52
|
+
// уход участника и destroy() комнаты)
|
|
53
|
+
playerData: {
|
|
54
|
+
// результат игры (PUT { points, best }); GET рангов больше не ходит
|
|
55
|
+
// сюда — три среза приезжают одним запросом на placementsUrl
|
|
56
|
+
rankUrl: '/auth/rank',
|
|
57
|
+
stateUrl: '/auth/state',
|
|
58
|
+
// агрегирующий роут мастера: { day, month, all } за один поход хоста
|
|
59
|
+
placementsUrl: '/auth/placements',
|
|
60
|
+
// точечный перезапрос одного среза (refreshPlacement)
|
|
61
|
+
placementUrl: '/auth/placement',
|
|
62
|
+
// ***** ОТКУДА ЭТО ЧИСЛО *****
|
|
63
|
+
//
|
|
64
|
+
// Целевой масштаб — 100 игр × 100 серверов × 8 игроков = 80 000 игроков
|
|
65
|
+
// одновременно. Участник со свежим результатом стоит двух запросов
|
|
66
|
+
// (PUT rank + PUT state) за интервал, поэтому весь мир пишет
|
|
67
|
+
//
|
|
68
|
+
// 80 000 × 2 / интервал.
|
|
69
|
+
//
|
|
70
|
+
// 60 с → 2700 запросов/с — на каждый пишущая транзакция, и это без
|
|
71
|
+
// единого всплеска: столько auth-сервис держать не обязан;
|
|
72
|
+
// 300 с → 530 запросов/с — с запасом, и запас этот нужен на всплески
|
|
73
|
+
// (конец раунда синхронизирует комнату целиком).
|
|
74
|
+
//
|
|
75
|
+
// Платится за это СВЕЖЕСТЬЮ ГЛОБАЛЬНЫХ рейтингов, и только их: очки
|
|
76
|
+
// склеиваются в памяти комнаты (сумма складывается, максимум берётся
|
|
77
|
+
// максимумом — finishGame), поэтому не теряется ничего, а свои значения
|
|
78
|
+
// игрок видит сразу, локально. Срочные границы (уход участника, destroy
|
|
79
|
+
// комнаты) интервал обходят, так что «ушёл и потерял» тоже не про это.
|
|
80
|
+
minFlushInterval: 300000, // мс на участника
|
|
81
|
+
flushJitter: 0.2, // ±20 % на комнату: сотни серверов по круглому таймеру
|
|
82
|
+
// потолок очереди запросов комнаты. Держится СТРОГО НИЖЕ потолка
|
|
83
|
+
// мастера (master:playerData:writesPerMinute / 60 = 2/с): очередь должна
|
|
84
|
+
// тормозить сама, а не через 429 — отказ стоит round-trip'а и уводит в
|
|
85
|
+
// бэкофф всю комнату, то есть задерживает и тех, кто ни при чём.
|
|
86
|
+
// Комната на 32 при интервале в 5 минут выпускает 64 запроса — при 1/с
|
|
87
|
+
// это чуть больше минуты, вчетверо быстрее следующего интервала
|
|
88
|
+
maxRequestsPerSecond: 1,
|
|
89
|
+
// Пауза комнаты после 5xx/429/сетевого сбоя, экспоненциальная. Обе
|
|
90
|
+
// границы соразмерны minFlushInterval, и это не украшение: потолок НИЖЕ
|
|
91
|
+
// интервала не значил бы ничего — обычный flush и так ждёт интервал,
|
|
92
|
+
// пауза короче него не отложила бы ни одного запроса, и бэкофф оказался
|
|
93
|
+
// бы мёртвым кодом. Поэтому 30 с (заметно, но не наказание за одну
|
|
94
|
+
// осечку) → 15 минут (втрое дольше интервала: лежащий сервис комната
|
|
95
|
+
// перестаёт трогать почти совсем)
|
|
96
|
+
backoff: { baseMs: 30000, maxMs: 900000 },
|
|
97
|
+
placementTtl: 30000, // троттлинг refreshPlacement, мс
|
|
98
|
+
},
|
|
99
|
+
|
|
47
100
|
// рейтинг игры (lobby-page-plan): публичный топ-N и позиция вызывающего,
|
|
48
101
|
// проксируемые мастером под тем же origin — правки CSP не нужны
|
|
49
102
|
leaderboardUrl: '/auth/leaderboard',
|
|
50
103
|
placementUrl: '/auth/placement',
|
|
51
104
|
leaderboardLimit: 10,
|
|
52
105
|
|
|
106
|
+
// награды за место в глобальном топе (snakes-v3 этап 4): хост комнаты
|
|
107
|
+
// периодически спрашивает тот же публичный топ, что рисует лобби, и
|
|
108
|
+
// рассылает участникам их места. Награда про игрока, а не про комнату,
|
|
109
|
+
// поэтому источник глобальный, а сопоставление — по нику. Запрос идёт с
|
|
110
|
+
// If-None-Match: неизменившийся топ стоит 304 и ни одного обращения к БД
|
|
111
|
+
accolades: {
|
|
112
|
+
refreshInterval: 45000, // мс между опросами топа
|
|
113
|
+
// срезы, за которыми ходит хост: ключ ответа -> ?period=
|
|
114
|
+
periods: { daily: 'day', monthly: 'month' },
|
|
115
|
+
},
|
|
116
|
+
|
|
53
117
|
// rank-periods: срезы рейтинга и тот, что открыт по умолчанию. Порядок
|
|
54
118
|
// здесь — порядок кнопок; `id` едет в auth как ?period=, `title` идёт в
|
|
55
119
|
// заголовок списка. Значения должны совпадать с RANK_PERIODS auth-сервиса:
|
package/src/config/master.js
CHANGED
|
@@ -29,6 +29,10 @@ export default {
|
|
|
29
29
|
// пакетами @vimp-games/* из node_modules (master/localGames.js), чтобы
|
|
30
30
|
// прилинкованная игра попадала в лобби без правки этого массива: он
|
|
31
31
|
// публикуется вместе с пакетом vimp-engine
|
|
32
|
+
// maxGameScore (snakes-v3 этап 3.3, необязательное поле рядом с
|
|
33
|
+
// id/package) — потолок результата ОДНОЙ игры для этой игры; мастер
|
|
34
|
+
// клампит им `best`/`points` PUT /auth/rank. Не задан — дефолт
|
|
35
|
+
// master:playerData:maxGameScore
|
|
32
36
|
games: [{ id: 'tanks', package: '@vimp-games/tanks' }],
|
|
33
37
|
|
|
34
38
|
// список серверов (GET /servers)
|
|
@@ -60,6 +64,32 @@ export default {
|
|
|
60
64
|
maxLimit: 100,
|
|
61
65
|
},
|
|
62
66
|
|
|
67
|
+
// GET /auth/placement + агрегирующий GET /auth/placements (snakes-v3
|
|
68
|
+
// этап 3.3): место меняется медленно, а каждый вход участника стоит трёх
|
|
69
|
+
// срезов. Кэш здесь про round-trip до auth; стоимость самого запроса
|
|
70
|
+
// снята на стороне auth (RankDistribution)
|
|
71
|
+
placement: {
|
|
72
|
+
cacheTtl: 30000,
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
// пределы записи профилей в БД (snakes-v3 этап 3, решение пользователя 9):
|
|
76
|
+
// «игр сотни, серверов сотни» — минимальный интервал держит движок на
|
|
77
|
+
// стороне хоста, а мастер держит потолок для сломанного или злонамеренного
|
|
78
|
+
// сервера, который этот интервал обошёл
|
|
79
|
+
playerData: {
|
|
80
|
+
// PUT /auth/rank + /auth/state на комнату (проверенный hostId) в минуту.
|
|
81
|
+
// Честная комната на 32 при lobbyConfig.playerData.minFlushInterval в
|
|
82
|
+
// 5 минут пишет 64 запроса за эти 5 минут, то есть ~13/мин; остальное —
|
|
83
|
+
// запас на срочные границы (уход участника обходит интервал), и его
|
|
84
|
+
// хватает даже комнате, полностью сменившей состав дважды за минуту.
|
|
85
|
+
// Потолок держит не честную комнату, а сломанную или злонамеренную,
|
|
86
|
+
// поэтому запас считается от честной, а не «пусть будет побольше»
|
|
87
|
+
writesPerMinute: 120,
|
|
88
|
+
// потолок результата ОДНОЙ игры, если игра не объявила свой
|
|
89
|
+
// (master:games[].maxGameScore): обоснование — plan/snakes-v3/stage_2.md
|
|
90
|
+
maxGameScore: 10000,
|
|
91
|
+
},
|
|
92
|
+
|
|
63
93
|
// рейтинг хостера комнаты (server-rating этап 2, plan/server-rating/
|
|
64
94
|
// stage_2.md): /like·/unlike гостей комнаты вместо соц-модерации /ban.
|
|
65
95
|
// Дефолт движка для всех игр; auth-сервис хранит и клампит рейтинг
|
package/src/config/opcodes.js
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
// в plugin-api.md), движок больше не выводит контролы из типа значения
|
|
10
10
|
// v3: набор control сокращён до нативных элементов — 'select'|'text'|
|
|
11
11
|
// 'checkbox'|'radio' (были ещё 'range'/'number'/'toggle'/'segmented')
|
|
12
|
-
|
|
12
|
+
// v4: порт ACCOLADES_DATA (места участников в глобальном топе) и пятый
|
|
13
|
+
// сервис пула зависимостей клиента (`accolades`) — snakes-v3 этап 4
|
|
14
|
+
export const ENGINE_API_VERSION = 4;
|
|
13
15
|
|
|
14
16
|
// версия формата кадра: первый байт после порта;
|
|
15
17
|
// увеличивать при любом изменении байтовой раскладки в ядре
|
package/src/config/wsports.js
CHANGED
|
@@ -19,6 +19,9 @@ export default {
|
|
|
19
19
|
CHAT_DATA: 15,
|
|
20
20
|
VOTE_DATA: 16,
|
|
21
21
|
KEYSET_DATA: 17,
|
|
22
|
+
// места участников в глобальном топе (snakes-v3 этап 4): косметика
|
|
23
|
+
// для parts игры, в снапшот не идёт
|
|
24
|
+
ACCOLADES_DATA: 18,
|
|
22
25
|
},
|
|
23
26
|
// порты получения данных от клиента
|
|
24
27
|
client: {
|