vimp-engine 0.19.0 → 0.21.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/controller/Lobby.js +42 -1
- package/src/client/components/model/Stat.js +123 -2
- package/src/client/components/view/Lobby.js +32 -1
- package/src/client/components/view/Stat.js +43 -0
- package/src/client/lib/accolades.js +56 -0
- package/src/client/main.js +52 -11
- package/src/client/style.css +45 -0
- package/src/client/views/includes/lobby.pug +6 -0
- package/src/config/lobby.js +81 -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 +114 -5
- 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/lib/validators.js +47 -0
package/package.json
CHANGED
|
@@ -27,6 +27,7 @@ export default class LobbyCtrl {
|
|
|
27
27
|
this._model = model;
|
|
28
28
|
this._view = view;
|
|
29
29
|
this._clock = clock;
|
|
30
|
+
this._periods = [];
|
|
30
31
|
|
|
31
32
|
this.publisher = new Publisher();
|
|
32
33
|
|
|
@@ -37,6 +38,12 @@ export default class LobbyCtrl {
|
|
|
37
38
|
vp.on('visible', 'pingHost', this);
|
|
38
39
|
vp.on('join', 'join', this);
|
|
39
40
|
vp.on('show-tab', 'showTab', this);
|
|
41
|
+
vp.on('show-period', 'showPeriod', this);
|
|
42
|
+
|
|
43
|
+
// активная игра и открытый срез рейтинга (rank-periods): запрос за
|
|
44
|
+
// лидербордом всегда несёт обе координаты, а меняется каждый раз одна
|
|
45
|
+
this._gameId = null;
|
|
46
|
+
this._period = null;
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
// показывает лобби и запрашивает первую страницу
|
|
@@ -69,13 +76,47 @@ export default class LobbyCtrl {
|
|
|
69
76
|
this._view.showTab(tab);
|
|
70
77
|
}
|
|
71
78
|
|
|
79
|
+
// срез рейтинга: подсветить кнопку и перезапросить список. Данных за день
|
|
80
|
+
// и за месяц у клиента нет — это другой ответ сервера, а не другая
|
|
81
|
+
// сортировка того же (rank-periods)
|
|
82
|
+
showPeriod(period) {
|
|
83
|
+
if (period === this._period) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
this._period = period;
|
|
88
|
+
this._view.setPeriod(period, this._titleOf(period));
|
|
89
|
+
|
|
90
|
+
if (this._gameId !== null) {
|
|
91
|
+
this.publisher.emit('leaderboard-needed', {
|
|
92
|
+
gameId: this._gameId,
|
|
93
|
+
period,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// подпись среза для заголовка списка; неизвестный id остаётся без подписи
|
|
99
|
+
_titleOf(period) {
|
|
100
|
+
return this._periods.find(item => item.id === period)?.title ?? '';
|
|
101
|
+
}
|
|
102
|
+
|
|
72
103
|
// вызывается main.js при смене #lobby-game (селектор игры вне этого MVC),
|
|
73
104
|
// а также один раз при открытии лобби для активной по умолчанию игры —
|
|
74
105
|
// Leaderboard всегда готов заранее, до того как пользователь откроет
|
|
75
106
|
// вкладку (см. класс-комментарий выше)
|
|
76
107
|
gameChanged(gameId, title) {
|
|
108
|
+
this._gameId = gameId;
|
|
77
109
|
this._view.setGameTitle(title);
|
|
78
110
|
|
|
79
|
-
this.publisher.emit('leaderboard-needed', gameId);
|
|
111
|
+
this.publisher.emit('leaderboard-needed', { gameId, period: this._period });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// срезы рейтинга и открытый по умолчанию (main.js передаёт их из
|
|
115
|
+
// lobbyConfig): контроллер не знает их наизусть, чтобы список срезов
|
|
116
|
+
// правился в конфиге, а не здесь
|
|
117
|
+
setPeriods(periods, defaultPeriod) {
|
|
118
|
+
this._periods = periods || [];
|
|
119
|
+
this._period = defaultPeriod;
|
|
120
|
+
this._view.setPeriod(defaultPeriod, this._titleOf(defaultPeriod));
|
|
80
121
|
}
|
|
81
122
|
}
|
|
@@ -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
|
}
|
|
@@ -36,6 +36,15 @@ export default class LobbyView {
|
|
|
36
36
|
this._leaderboardTotal = document.getElementById(elems.leaderboardTotalId);
|
|
37
37
|
this._myPlacement = document.getElementById(elems.myPlacementId);
|
|
38
38
|
|
|
39
|
+
// кнопки срезов рейтинга (rank-periods): id периода -> элемент. Кнопки
|
|
40
|
+
// объявлены конфигом, а не захардкожены здесь, чтобы срез можно было
|
|
41
|
+
// убрать или добавить в одном месте
|
|
42
|
+
this._periodBtns = new Map(
|
|
43
|
+
Object.entries(elems.periodBtnIds || {})
|
|
44
|
+
.map(([period, id]) => [period, document.getElementById(id)])
|
|
45
|
+
.filter(([, el]) => el),
|
|
46
|
+
);
|
|
47
|
+
|
|
39
48
|
// футер лобби: содержимое статично на всё время жизни вкладки, поэтому
|
|
40
49
|
// пишется здесь, а не через модель и publisher
|
|
41
50
|
const version = document.getElementById(elems.versionId);
|
|
@@ -56,6 +65,10 @@ export default class LobbyView {
|
|
|
56
65
|
// игроков, чем лимит)
|
|
57
66
|
this._leaderboardLimit = 0;
|
|
58
67
|
|
|
68
|
+
// заголовок открытого среза ("<TITLE> TOP-N — TODAY"): подпись, а не
|
|
69
|
+
// источник правды, срез хранит модель
|
|
70
|
+
this._periodTitle = '';
|
|
71
|
+
|
|
59
72
|
// ник вызывающего (lobby-page-plan, code review M4-остаток) — задаётся
|
|
60
73
|
// main.js один раз при открытии лобби; используется, чтобы решить,
|
|
61
74
|
// виден ли вызывающий уже в отрисованном топе (по нику, не по числу
|
|
@@ -86,6 +99,10 @@ export default class LobbyView {
|
|
|
86
99
|
this._tabServersBtn.onclick = () => this.publisher.emit('show-tab', 'servers');
|
|
87
100
|
this._tabLeaderboardBtn.onclick = () => this.publisher.emit('show-tab', 'leaderboard');
|
|
88
101
|
|
|
102
|
+
for (const [period, btn] of this._periodBtns) {
|
|
103
|
+
btn.onclick = () => this.publisher.emit('show-period', period);
|
|
104
|
+
}
|
|
105
|
+
|
|
89
106
|
this._mPublic = model.publisher;
|
|
90
107
|
this._mPublic.on('list', 'renderList', this);
|
|
91
108
|
this._mPublic.on('ping-update', 'updatePing', this);
|
|
@@ -118,6 +135,17 @@ export default class LobbyView {
|
|
|
118
135
|
this._selfNick = nick || '';
|
|
119
136
|
}
|
|
120
137
|
|
|
138
|
+
// подсвечивает открытый срез рейтинга и запоминает его подпись
|
|
139
|
+
// (rank-periods). Рисовать список здесь нечем: данные среза приезжают
|
|
140
|
+
// отдельным запросом, и до ответа список чистится моделью
|
|
141
|
+
setPeriod(period, title) {
|
|
142
|
+
this._periodTitle = title || '';
|
|
143
|
+
|
|
144
|
+
for (const [id, btn] of this._periodBtns) {
|
|
145
|
+
btn.classList.toggle('active', id === period);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
121
149
|
// переключает вкладки Active Servers / Leaderboard (lobby-page-plan)
|
|
122
150
|
showTab(tab) {
|
|
123
151
|
const showLeaderboard = tab === 'leaderboard';
|
|
@@ -130,7 +158,10 @@ export default class LobbyView {
|
|
|
130
158
|
|
|
131
159
|
// рендер топ-N + позиции вызывающего (lobby-page-plan)
|
|
132
160
|
renderLeaderboard({ leaderboard, total, myPlacement, loaded = true }) {
|
|
133
|
-
|
|
161
|
+
const period = this._periodTitle ? ` — ${this._periodTitle}` : '';
|
|
162
|
+
|
|
163
|
+
this._leaderboardTitle.textContent =
|
|
164
|
+
`${this._gameTitle.toUpperCase()} TOP-${this._leaderboardLimit}${period}`;
|
|
134
165
|
this._leaderboardTotal.textContent = `Total: ${total} players`;
|
|
135
166
|
|
|
136
167
|
this._leaderboardList.textContent = '';
|
|
@@ -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,13 +1813,21 @@ async function fetchServers({ offset, limit, search }) {
|
|
|
1789
1813
|
}
|
|
1790
1814
|
|
|
1791
1815
|
// топ-N рейтинга игры (lobby-page-plan) — публичный эндпоинт, доступен и до
|
|
1792
|
-
// логина, поэтому без Authorization
|
|
1793
|
-
|
|
1816
|
+
// логина, поэтому без Authorization.
|
|
1817
|
+
//
|
|
1818
|
+
// Зовётся только ЛОББИ. Из матча за топом ходит хост комнаты и раздаёт его
|
|
1819
|
+
// портом ACCOLADES_DATA (host/meta/modules/Accolades.js): 80 000 игроков,
|
|
1820
|
+
// спрашивающих мастер лично, и 10 000 комнат, спрашивающих за них, — разные
|
|
1821
|
+
// порядки величин. Там же живёт и If-None-Match: валидатор имеет смысл
|
|
1822
|
+
// рядом с повторяющимся запросом, а лобби открывают по одному разу
|
|
1823
|
+
async function fetchLeaderboard(gameId, period) {
|
|
1794
1824
|
const params = new URLSearchParams({
|
|
1795
1825
|
game: gameId,
|
|
1796
1826
|
limit: lobbyConfig.leaderboardLimit,
|
|
1827
|
+
// rank-periods: срез времени. Мастер отвечает 400 на незнакомый —
|
|
1828
|
+
// значение всегда из lobbyConfig.leaderboardPeriods
|
|
1829
|
+
period,
|
|
1797
1830
|
});
|
|
1798
|
-
|
|
1799
1831
|
try {
|
|
1800
1832
|
const res = await fetch(`${lobbyConfig.leaderboardUrl}?${params}`);
|
|
1801
1833
|
|
|
@@ -1807,8 +1839,8 @@ async function fetchLeaderboard(gameId) {
|
|
|
1807
1839
|
|
|
1808
1840
|
// позиция вызывающего в рейтинге игры (lobby-page-plan) — требует identity-
|
|
1809
1841
|
// токена, как и остальные /auth/* запросы игрока (rank/state)
|
|
1810
|
-
async function fetchPlacement(gameId) {
|
|
1811
|
-
const token = lobbyAuthModel
|
|
1842
|
+
async function fetchPlacement(gameId, period) {
|
|
1843
|
+
const token = lobbyAuthModel?.getToken();
|
|
1812
1844
|
|
|
1813
1845
|
if (!token) {
|
|
1814
1846
|
return null;
|
|
@@ -1816,7 +1848,9 @@ async function fetchPlacement(gameId) {
|
|
|
1816
1848
|
|
|
1817
1849
|
try {
|
|
1818
1850
|
const res = await fetch(
|
|
1819
|
-
|
|
1851
|
+
// тот же срез, что и у списка рядом: плашка позиции, посчитанная за
|
|
1852
|
+
// всё время под заголовком «сегодня», противоречила бы списку
|
|
1853
|
+
`${lobbyConfig.placementUrl}?${new URLSearchParams({ game: gameId, period })}`,
|
|
1820
1854
|
{ headers: { authorization: `Bearer ${token}` } },
|
|
1821
1855
|
);
|
|
1822
1856
|
|
|
@@ -1954,6 +1988,13 @@ function initLobby() {
|
|
|
1954
1988
|
|
|
1955
1989
|
lobby = new LobbyCtrl(lobbyModel, lobbyView);
|
|
1956
1990
|
|
|
1991
|
+
// срезы рейтинга (rank-periods) — сразу после создания контроллера и до
|
|
1992
|
+
// первого gameChanged: тот уже несёт открытый срез в запросе
|
|
1993
|
+
lobby.setPeriods(
|
|
1994
|
+
lobbyConfig.leaderboardPeriods,
|
|
1995
|
+
lobbyConfig.defaultLeaderboardPeriod,
|
|
1996
|
+
);
|
|
1997
|
+
|
|
1957
1998
|
// список серверов — REST-запросом к мастеру
|
|
1958
1999
|
lobbyModel.publisher.on('fetch', async query => {
|
|
1959
2000
|
const list = await fetchServers(query);
|
|
@@ -1989,17 +2030,17 @@ function initLobby() {
|
|
|
1989
2030
|
// под заголовком новой, пока не пришёл ответ (и остаются навсегда при
|
|
1990
2031
|
// сетевом сбое); reqId отбрасывает ответ устаревшего запроса, если игру
|
|
1991
2032
|
// переключили быстрее, чем пришёл ответ (latest-wins)
|
|
1992
|
-
lobby.publisher.on('leaderboard-needed', async gameId => {
|
|
2033
|
+
lobby.publisher.on('leaderboard-needed', async ({ gameId, period }) => {
|
|
1993
2034
|
lobbyModel.clearLeaderboard();
|
|
1994
2035
|
|
|
1995
2036
|
const reqId = ++leaderboardReqId;
|
|
1996
2037
|
const [leaderboard, placement] = await Promise.all([
|
|
1997
|
-
fetchLeaderboard(gameId),
|
|
1998
|
-
fetchPlacement(gameId),
|
|
2038
|
+
fetchLeaderboard(gameId, period),
|
|
2039
|
+
fetchPlacement(gameId, period),
|
|
1999
2040
|
]);
|
|
2000
2041
|
|
|
2001
2042
|
if (reqId !== leaderboardReqId) {
|
|
2002
|
-
return; // игру уже переключили ещё раз —
|
|
2043
|
+
return; // игру или срез уже переключили ещё раз — ответ устарел
|
|
2003
2044
|
}
|
|
2004
2045
|
|
|
2005
2046
|
if (leaderboard) {
|
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 {
|
|
@@ -908,6 +927,32 @@ body.hide-cursor {
|
|
|
908
927
|
border-bottom: 2px solid #fff;
|
|
909
928
|
}
|
|
910
929
|
|
|
930
|
+
/* срезы рейтинга (rank-periods): день/месяц/всё время. Ряд под вкладками
|
|
931
|
+
правой панели — это выбор ДАННЫХ, а не вкладка, поэтому кнопки выглядят
|
|
932
|
+
как переключатель, а не как заголовок раздела */
|
|
933
|
+
.lobby-period-tabs {
|
|
934
|
+
display: flex;
|
|
935
|
+
gap: 4px;
|
|
936
|
+
margin-bottom: 10px;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
.lobby-period-btn {
|
|
940
|
+
flex: 1 1 0;
|
|
941
|
+
background: rgb(255 255 255 / 6%);
|
|
942
|
+
border: none;
|
|
943
|
+
color: #888;
|
|
944
|
+
font-size: 12px;
|
|
945
|
+
font-weight: bold;
|
|
946
|
+
padding: 5px 4px;
|
|
947
|
+
margin: 0;
|
|
948
|
+
text-shadow: none;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
.lobby-period-btn.active {
|
|
952
|
+
background: rgb(255 255 255 / 18%);
|
|
953
|
+
color: #fff;
|
|
954
|
+
}
|
|
955
|
+
|
|
911
956
|
.lobby-leaderboard-header {
|
|
912
957
|
display: flex;
|
|
913
958
|
justify-content: space-between;
|
|
@@ -50,6 +50,12 @@ div#lobby(style='display:none')
|
|
|
50
50
|
p#lobby-empty= 'No servers available'
|
|
51
51
|
input#lobby-more(type='button', value='Load more')
|
|
52
52
|
div#lobby-leaderboard-content(style='display:none')
|
|
53
|
+
//- срез рейтинга (rank-periods): день/месяц считаются из леджера
|
|
54
|
+
//- дельт, all-time — из кэша ratings
|
|
55
|
+
div.lobby-period-tabs
|
|
56
|
+
input#btn-period-day.lobby-period-btn(type='button', value='Daily')
|
|
57
|
+
input#btn-period-month.lobby-period-btn(type='button', value='Monthly')
|
|
58
|
+
input#btn-period-all.lobby-period-btn.active(type='button', value='All-Time')
|
|
53
59
|
div.lobby-leaderboard-header
|
|
54
60
|
span#leaderboard-title
|
|
55
61
|
span#leaderboard-total
|