vimp-engine 0.19.0 → 0.20.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
  }
@@ -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
- this._leaderboardTitle.textContent = `${this._gameTitle.toUpperCase()} TOP-${this._leaderboardLimit}`;
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 = '';
@@ -1790,10 +1790,13 @@ async function fetchServers({ offset, limit, search }) {
1790
1790
 
1791
1791
  // топ-N рейтинга игры (lobby-page-plan) — публичный эндпоинт, доступен и до
1792
1792
  // логина, поэтому без Authorization
1793
- async function fetchLeaderboard(gameId) {
1793
+ async function fetchLeaderboard(gameId, period) {
1794
1794
  const params = new URLSearchParams({
1795
1795
  game: gameId,
1796
1796
  limit: lobbyConfig.leaderboardLimit,
1797
+ // rank-periods: срез времени. Мастер отвечает 400 на незнакомый —
1798
+ // значение всегда из lobbyConfig.leaderboardPeriods
1799
+ period,
1797
1800
  });
1798
1801
 
1799
1802
  try {
@@ -1807,7 +1810,7 @@ async function fetchLeaderboard(gameId) {
1807
1810
 
1808
1811
  // позиция вызывающего в рейтинге игры (lobby-page-plan) — требует identity-
1809
1812
  // токена, как и остальные /auth/* запросы игрока (rank/state)
1810
- async function fetchPlacement(gameId) {
1813
+ async function fetchPlacement(gameId, period) {
1811
1814
  const token = lobbyAuthModel.getToken();
1812
1815
 
1813
1816
  if (!token) {
@@ -1816,7 +1819,9 @@ async function fetchPlacement(gameId) {
1816
1819
 
1817
1820
  try {
1818
1821
  const res = await fetch(
1819
- `${lobbyConfig.placementUrl}?${new URLSearchParams({ game: gameId })}`,
1822
+ // тот же срез, что и у списка рядом: плашка позиции, посчитанная за
1823
+ // всё время под заголовком «сегодня», противоречила бы списку
1824
+ `${lobbyConfig.placementUrl}?${new URLSearchParams({ game: gameId, period })}`,
1820
1825
  { headers: { authorization: `Bearer ${token}` } },
1821
1826
  );
1822
1827
 
@@ -1954,6 +1959,13 @@ function initLobby() {
1954
1959
 
1955
1960
  lobby = new LobbyCtrl(lobbyModel, lobbyView);
1956
1961
 
1962
+ // срезы рейтинга (rank-periods) — сразу после создания контроллера и до
1963
+ // первого gameChanged: тот уже несёт открытый срез в запросе
1964
+ lobby.setPeriods(
1965
+ lobbyConfig.leaderboardPeriods,
1966
+ lobbyConfig.defaultLeaderboardPeriod,
1967
+ );
1968
+
1957
1969
  // список серверов — REST-запросом к мастеру
1958
1970
  lobbyModel.publisher.on('fetch', async query => {
1959
1971
  const list = await fetchServers(query);
@@ -1989,17 +2001,17 @@ function initLobby() {
1989
2001
  // под заголовком новой, пока не пришёл ответ (и остаются навсегда при
1990
2002
  // сетевом сбое); reqId отбрасывает ответ устаревшего запроса, если игру
1991
2003
  // переключили быстрее, чем пришёл ответ (latest-wins)
1992
- lobby.publisher.on('leaderboard-needed', async gameId => {
2004
+ lobby.publisher.on('leaderboard-needed', async ({ gameId, period }) => {
1993
2005
  lobbyModel.clearLeaderboard();
1994
2006
 
1995
2007
  const reqId = ++leaderboardReqId;
1996
2008
  const [leaderboard, placement] = await Promise.all([
1997
- fetchLeaderboard(gameId),
1998
- fetchPlacement(gameId),
2009
+ fetchLeaderboard(gameId, period),
2010
+ fetchPlacement(gameId, period),
1999
2011
  ]);
2000
2012
 
2001
2013
  if (reqId !== leaderboardReqId) {
2002
- return; // игру уже переключили ещё раз — этот ответ устарел
2014
+ return; // игру или срез уже переключили ещё раз — ответ устарел
2003
2015
  }
2004
2016
 
2005
2017
  if (leaderboard) {
@@ -908,6 +908,32 @@ body.hide-cursor {
908
908
  border-bottom: 2px solid #fff;
909
909
  }
910
910
 
911
+ /* срезы рейтинга (rank-periods): день/месяц/всё время. Ряд под вкладками
912
+ правой панели — это выбор ДАННЫХ, а не вкладка, поэтому кнопки выглядят
913
+ как переключатель, а не как заголовок раздела */
914
+ .lobby-period-tabs {
915
+ display: flex;
916
+ gap: 4px;
917
+ margin-bottom: 10px;
918
+ }
919
+
920
+ .lobby-period-btn {
921
+ flex: 1 1 0;
922
+ background: rgb(255 255 255 / 6%);
923
+ border: none;
924
+ color: #888;
925
+ font-size: 12px;
926
+ font-weight: bold;
927
+ padding: 5px 4px;
928
+ margin: 0;
929
+ text-shadow: none;
930
+ }
931
+
932
+ .lobby-period-btn.active {
933
+ background: rgb(255 255 255 / 18%);
934
+ color: #fff;
935
+ }
936
+
911
937
  .lobby-leaderboard-header {
912
938
  display: flex;
913
939
  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
@@ -50,6 +50,17 @@ export default {
50
50
  placementUrl: '/auth/placement',
51
51
  leaderboardLimit: 10,
52
52
 
53
+ // rank-periods: срезы рейтинга и тот, что открыт по умолчанию. Порядок
54
+ // здесь — порядок кнопок; `id` едет в auth как ?period=, `title` идёт в
55
+ // заголовок списка. Значения должны совпадать с RANK_PERIODS auth-сервиса:
56
+ // на всё прочее он отвечает 400
57
+ leaderboardPeriods: [
58
+ { id: 'day', title: 'TODAY' },
59
+ { id: 'month', title: 'THIS MONTH' },
60
+ { id: 'all', title: 'ALL-TIME' },
61
+ ],
62
+ defaultLeaderboardPeriod: 'all',
63
+
53
64
  // переподключение сигнального WS хоста (комната без него выпадает из
54
65
  // выдачи мастера): экспоненциальный бэкофф от baseDelay до maxDelay (мс)
55
66
  reconnect: {
@@ -93,6 +104,12 @@ export default {
93
104
  serversContentId: 'lobby-servers-content',
94
105
  leaderboardContentId: 'lobby-leaderboard-content',
95
106
  leaderboardListId: 'lobby-leaderboard-list',
107
+ // кнопки срезов (rank-periods): id периода -> id элемента
108
+ periodBtnIds: {
109
+ day: 'btn-period-day',
110
+ month: 'btn-period-month',
111
+ all: 'btn-period-all',
112
+ },
96
113
  leaderboardTitleId: 'leaderboard-title',
97
114
  leaderboardTotalId: 'leaderboard-total',
98
115
  myPlacementId: 'lobby-my-placement',
@@ -1063,6 +1063,19 @@ export default class HostGame {
1063
1063
  this._playerDataSync.addRank(gameId, delta);
1064
1064
  }
1065
1065
 
1066
+ // синхронизация профилей (rank/state) всех участников на мастер прямо
1067
+ // сейчас — для игр, у которых нет ни конца раунда, ни смены карты
1068
+ // (`endlessRound` + карта, пересобираемая через overrideMapData): обе
1069
+ // штатные границы flushAll живут в RoundManager, и такая игра не проходит
1070
+ // ни через одну из них. Без этого накопленный за матч ранг уезжает в auth
1071
+ // только на выходе участника, а закрытая вкладка хоста теряет его вовсе.
1072
+ //
1073
+ // Best-effort, как и весь PlayerDataSync: промис не отвергается, сбой
1074
+ // логируется в `[playerData]` и повторится следующим flush'ем.
1075
+ flushPlayerData() {
1076
+ return this._playerDataSync.flushAll();
1077
+ }
1078
+
1066
1079
  // hostId + per-room секрет комнаты, подтверждённые мастером при
1067
1080
  // register_host (кодревью №1) — не известны при создании HostGame (Worker
1068
1081
  // стартует раньше ответа мастера); нужны PlayerDataSync для атрибуции