ranked.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  1. import fetch from 'node-fetch';
  2. import {osu_fetch} from './api.js';
  3. import bancho from './bancho.js';
  4. import db from './database.js';
  5. import {save_game_and_update_rating} from './elo.js';
  6. import Config from './util/config.js';
  7. import {capture_sentry_exception} from './util/helpers.js';
  8. import {get_map_info} from './map_scanner.js';
  9. import {auto_rejoin_lobbies} from './supervisor.js';
  10. function get_new_title(lobby) {
  11. let new_title = lobby.data.title;
  12. // Min stars: we prefer not displaying the decimals whenever possible
  13. let fancy_min_stars;
  14. if (Math.abs(lobby.data.min_stars - Math.round(lobby.data.min_stars)) <= 0.1) {
  15. fancy_min_stars = Math.round(lobby.data.min_stars);
  16. } else {
  17. fancy_min_stars = Math.round(lobby.data.min_stars * 100) / 100;
  18. }
  19. // Max stars: we prefer displaying .99 whenever possible
  20. let fancy_max_stars;
  21. if (lobby.data.max_stars > 11) {
  22. // ...unless it's a ridiculously big number
  23. fancy_max_stars = Math.round(lobby.data.max_stars);
  24. } else {
  25. if (Math.abs(lobby.data.max_stars - Math.round(lobby.data.max_stars)) <= 0.1) {
  26. fancy_max_stars = (Math.round(lobby.data.max_stars) - 0.01).toFixed(2);
  27. } else {
  28. fancy_max_stars = Math.round(lobby.data.max_stars * 100) / 100;
  29. }
  30. }
  31. let stars;
  32. if (lobby.data.max_stars - lobby.data.min_stars == 1 && lobby.data.min_stars % 1 == 0) {
  33. // Simplify "4-4.99*" lobbies as "4*"
  34. stars = `${lobby.data.min_stars}`;
  35. } else {
  36. stars = `${fancy_min_stars}-${fancy_max_stars}`;
  37. }
  38. new_title = new_title.replaceAll('$min_stars', fancy_min_stars);
  39. new_title = new_title.replaceAll('$avg_stars', Math.round(lobby.data.avg_stars * 10) / 10);
  40. new_title = new_title.replaceAll('$max_stars', fancy_max_stars);
  41. new_title = new_title.replaceAll('$min_elo', Math.round(lobby.data.min_elo));
  42. new_title = new_title.replaceAll('$avg_elo', Math.round(lobby.data.avg_elo));
  43. new_title = new_title.replaceAll('$max_elo', Math.round(lobby.data.max_elo));
  44. new_title = new_title.replaceAll('$elo', Math.round(lobby.data.avg_elo));
  45. new_title = new_title.replaceAll('$min_pp', Math.round(lobby.data.min_pp));
  46. new_title = new_title.replaceAll('$avg_pp', Math.round(lobby.data.avg_pp));
  47. new_title = new_title.replaceAll('$max_pp', Math.round(lobby.data.max_pp));
  48. new_title = new_title.replaceAll('$pp', Math.round(lobby.data.avg_pp));
  49. new_title = new_title.replaceAll('$stars', stars);
  50. return new_title;
  51. }
  52. async function set_new_title(lobby) {
  53. let new_title = get_new_title(lobby);
  54. if (!Config.IS_PRODUCTION) {
  55. new_title = 'test lobby';
  56. }
  57. if (lobby.name != new_title) {
  58. await lobby.send(`!mp name ${new_title}`);
  59. lobby.name = new_title;
  60. }
  61. }
  62. // Updates the map selection query to account for lobby's current elo/pp.
  63. // Also updates min/avg/max elo/pp/star values for use in lobby title.
  64. function update_map_selection_query(lobby) {
  65. let median_pp = 0;
  66. let min_elo = 9999;
  67. let median_elo = 1500;
  68. let max_elo = 0;
  69. if (lobby.players.length > 0) {
  70. const pps = [];
  71. const elos = [];
  72. for (const player of lobby.players) {
  73. pps.push(Math.min(600, player.pps[lobby.data.ruleset]));
  74. const elo = player.ratings[lobby.data.ruleset].elo;
  75. if (elo < min_elo) min_elo = elo;
  76. if (elo > max_elo) max_elo = elo;
  77. elos.push(elo);
  78. }
  79. const middle = Math.floor(pps.length / 2);
  80. if (pps.length % 2 == 0) {
  81. median_pp = (pps[middle - 1] + pps[middle]) / 2;
  82. median_elo = (elos[middle - 1] + elos[middle]) / 2;
  83. } else {
  84. median_pp = pps[middle];
  85. median_elo = elos[middle];
  86. }
  87. }
  88. const get_query = (type) => {
  89. if (type == 'random') {
  90. return {
  91. query: `SELECT * FROM pool_${lobby.id} pool
  92. INNER JOIN pp ON pp.map_id = pool.map_id
  93. WHERE ${lobby.data.filter_query} AND mods = ?`,
  94. args: [lobby.data.mods],
  95. };
  96. }
  97. if (type == 'pp') {
  98. return {
  99. query: `SELECT *, ABS(? - pp) AS pick_accuracy FROM pool_${lobby.id} pool
  100. INNER JOIN pp ON pp.map_id = pool.map_id
  101. WHERE ${lobby.data.filter_query} AND mods = ?
  102. ORDER BY pick_accuracy ASC LIMIT ?`,
  103. args: [median_pp, lobby.data.mods, lobby.data.pp_closeness],
  104. };
  105. }
  106. throw new Error('Unknown map selection type');
  107. };
  108. lobby.map_query = get_query(lobby.data.map_selection_algo);
  109. const query_stats = db.prepare(
  110. `SELECT AVG(stars) AS avg_stars,
  111. MIN(pp) AS min_pp, AVG(pp) AS avg_pp, MAX(pp) AS max_pp
  112. FROM (${lobby.map_query.query})`,
  113. ).get(...lobby.map_query.args);
  114. lobby.data.avg_stars = query_stats.avg_stars;
  115. lobby.data.min_pp = query_stats.min_pp;
  116. lobby.data.avg_pp = query_stats.avg_pp;
  117. lobby.data.max_pp = query_stats.max_pp;
  118. lobby.data.min_elo = min_elo;
  119. lobby.data.avg_elo = median_elo; // ok it's median, not avg, but better this way
  120. lobby.data.max_elo = max_elo;
  121. }
  122. async function push_map(lobby, new_map) {
  123. const MAP_TYPES = {
  124. 1: 'graveyard',
  125. 2: 'wip',
  126. 3: 'pending',
  127. 4: 'ranked',
  128. 5: 'approved',
  129. 6: 'qualified',
  130. 7: 'loved',
  131. };
  132. lobby.data.recent_mapids.push(new_map.map_id);
  133. lobby.data.recent_mapsets.push(new_map.set_id);
  134. try {
  135. const flavor = `${MAP_TYPES[new_map.ranked] || 'graveyard'} ${Math.round(new_map.pp)}pp`;
  136. const map_name = `[https://osu.ppy.sh/beatmaps/${new_map.map_id} ${new_map.name}]`;
  137. const osu_direct_link = `[https://api.osu.direct/d/${new_map.set_id} [1]]`;
  138. const chimu_link = `[https://chimu.moe/d/${new_map.set_id} [2]]`;
  139. const nerina_link = `[https://api.nerinyan.moe/d/${new_map.set_id} [3]]`;
  140. const sayobot_link = `[https://osu.sayobot.cn/osu.php?s=${new_map.set_id} [4]]`;
  141. await lobby.send(`!mp map ${new_map.map_id} ${new_map.mode} | ${map_name} (${flavor}) Downloads: ${osu_direct_link} ${chimu_link} ${nerina_link} ${sayobot_link}`);
  142. lobby.map = new_map;
  143. await set_new_title(lobby);
  144. } catch (e) {
  145. console.error(`${lobby.channel} Failed to switch to map ${new_map.map_id} ${new_map.name}:`, e);
  146. }
  147. }
  148. async function select_next_map() {
  149. clearTimeout(this.countdown);
  150. this.countdown = -1;
  151. if (this.data.recent_mapsets.length >= this.data.nb_non_repeating) {
  152. this.data.recent_mapids.shift();
  153. this.data.recent_mapsets.shift();
  154. }
  155. update_map_selection_query(this);
  156. let new_map = null;
  157. for (let i = 0; i < 10; i++) {
  158. new_map = db.prepare(`
  159. SELECT * FROM (${this.map_query.query})
  160. ORDER BY RANDOM() LIMIT 1`,
  161. ).get(...this.map_query.args);
  162. if (!new_map) break;
  163. if (!this.data.recent_mapsets.includes(new_map.set_id)) {
  164. break;
  165. }
  166. }
  167. if (!new_map) {
  168. await this.send(`Couldn't find a map with the current lobby settings :/`);
  169. return;
  170. }
  171. await push_map(this, new_map);
  172. }
  173. function generate_map_pool_table(lobby) {
  174. // Vary map attributes based on selected mods
  175. let ar = 1.0;
  176. let cs = 1.0;
  177. let od = 1.0;
  178. let hp = 1.0;
  179. let bpm = 1.0;
  180. let length = 1.0;
  181. if (lobby.data.mods & (1 << 1)) {
  182. // EZ
  183. ar /= 2;
  184. cs /= 2;
  185. hp /= 2;
  186. od /= 2;
  187. } else if (lobby.data.mods & (1 << 4)) {
  188. // HR
  189. ar *= 1.4;
  190. if (ar > 10) ar = 10;
  191. cs *= 1.3;
  192. hp *= 1.4;
  193. od *= 1.4;
  194. }
  195. if (lobby.data.mods & (1 << 6)) {
  196. // DT
  197. bpm *= 1.5;
  198. length *= 0.66;
  199. } else if (lobby.data.mods & (1 << 8)) {
  200. // HT
  201. bpm *= 0.75;
  202. length *= 1.33;
  203. }
  204. if (lobby.data.map_pool == 'leaderboarded') {
  205. db.prepare(`
  206. CREATE TEMPORARY TABLE pool_${lobby.id} AS
  207. SELECT map_id, set_id, mode, name, ar * ${ar} AS ar, cs * ${cs} AS cs, hp * ${hp} AS hp, od * ${od} AS od,
  208. bpm * ${bpm} AS bpm, length * ${length} AS length, ranked FROM map
  209. WHERE ranked >= 3 AND dmca = 0 AND mode = ?
  210. `).run(lobby.data.ruleset);
  211. } else {
  212. db.prepare(`CREATE TEMPORARY TABLE pool_${lobby.id} (map_id, set_id, mode, name, ar, cs, hp, od, bpm, length, ranked)`).run();
  213. const insert_map = db.prepare(`
  214. INSERT INTO pool_${lobby.id} (map_id, set_id, mode, name, ar, cs, hp, od, bpm, length, ranked)
  215. SELECT map_id, set_id, mode, name, ar * ${ar}, cs * ${cs}, hp * ${hp}, od * ${od}, bpm * ${bpm}, length * ${length}, ranked
  216. FROM map WHERE map_id = ?`,
  217. );
  218. for (const mapset of lobby.data.collection.beatmapsets) {
  219. for (const map of mapset.beatmaps) {
  220. insert_map.run(map.id);
  221. }
  222. }
  223. }
  224. }
  225. async function pick_new_host(lobby) {
  226. let best_player = lobby.players[0];
  227. for (const player of lobby.players) {
  228. if (player.user_id == lobby.data.creator_id) {
  229. best_player = player;
  230. break;
  231. }
  232. if (player.total_scores > best_player.total_scores) {
  233. best_player = player;
  234. }
  235. }
  236. await lobby.send(`!mp host ${best_player.irc_username}`);
  237. }
  238. async function init_lobby(lobby, data, created_just_now) {
  239. lobby.created_just_now = created_just_now;
  240. // Defaults for old lobbies
  241. if (!data.min_stars) data.min_stars = 0;
  242. if (!data.max_stars) data.max_stars = 11;
  243. if (!data.ruleset) data.ruleset = 0;
  244. if (!data.map_selection_algo) data.map_selection_algo = 'pp';
  245. if (!data.map_pool) data.map_pool = 'leaderboarded';
  246. if (!data.mods) data.mods = 0;
  247. if (!data.mod_list) data.mod_list = [];
  248. if (!data.filter_query) {
  249. if (data.ruleset == 3) {
  250. data.filter_query = 'cs = 4';
  251. } else {
  252. data.filter_query = 1;
  253. }
  254. }
  255. if (!data.nb_non_repeating) data.nb_non_repeating = 100;
  256. if (!data.pp_closeness) data.pp_closeness = 50;
  257. if (!data.title) data.title = '$avg_stars* | o!RL (!info)';
  258. if (!data.recent_mapids) data.recent_mapids = [];
  259. if (!data.recent_mapsets) data.recent_mapsets = [];
  260. // Save every lobby.data update to the database
  261. lobby.data = new Proxy(data, {
  262. set(obj, prop, value) {
  263. obj[prop] = value;
  264. db.prepare(`UPDATE match SET data = ? WHERE match_id = ?`).run(JSON.stringify(obj), lobby.id);
  265. if (prop == 'ruleset') {
  266. db.prepare(`UPDATE match SET ruleset = ? WHERE match_id = ?`).run(value, lobby.id);
  267. }
  268. return true;
  269. },
  270. });
  271. if (created_just_now) {
  272. // Should be saved later in this method, but just in case, save lobby data now
  273. db.prepare(`UPDATE match SET data = ? WHERE match_id = ?`).run(JSON.stringify(data), lobby.id);
  274. }
  275. lobby.afk_kicked = [];
  276. lobby.dodgers = [];
  277. lobby.match_participants = [];
  278. lobby.votekicks = [];
  279. lobby.countdown = -1;
  280. lobby.select_next_map = select_next_map;
  281. lobby.match_end_timeout = -1;
  282. if (lobby.data.collection_id && !lobby.data.collection) {
  283. try {
  284. const res = await fetch(`https://osucollector.com/api/collections/${lobby.data.collection_id}`);
  285. if (res.status == 404) {
  286. throw new Error('Collection not found.');
  287. }
  288. if (!res.ok) {
  289. throw new Error(await res.text());
  290. }
  291. lobby.data.collection = await res.json();
  292. } catch (err) {
  293. await lobby.send(`Failed to load collection: ${err.message}`);
  294. throw err;
  295. }
  296. }
  297. // generate_map_pool_table() must be called after fetching lobby.data.collection!
  298. generate_map_pool_table(lobby);
  299. lobby.on('close', () => {
  300. db.prepare(`DROP TABLE temp.pool_${lobby.id}`).run();
  301. if (!lobby.dont_reopen && bancho.joined_lobbies.length == 0) auto_rejoin_lobbies();
  302. });
  303. lobby.on('settings', async () => {
  304. for (const player of lobby.players) {
  305. if (lobby.playing && player.state != 'No Map') {
  306. lobby.match_participants.push(player);
  307. }
  308. }
  309. // Cannot select a map until we fetched the player IDs via !mp settings.
  310. if (lobby.created_just_now) {
  311. await lobby.select_next_map();
  312. lobby.created_just_now = false;
  313. }
  314. });
  315. lobby.on('playerJoined', async (player) => {
  316. player.join_time = Date.now();
  317. if (lobby.host == null || player.user_id == lobby.data.creator_id) {
  318. pick_new_host(lobby);
  319. }
  320. });
  321. lobby.on('playerLeft', async (player) => {
  322. if (lobby.match_participants.includes(player)) {
  323. lobby.dodgers.push(player);
  324. }
  325. if (lobby.players.length == 0) {
  326. await set_new_title(lobby);
  327. } else {
  328. // Pick a new host if current host left the lobby
  329. if (player.is_host) {
  330. pick_new_host(lobby);
  331. }
  332. }
  333. });
  334. const kick_afk_players = async () => {
  335. const players_to_kick = [];
  336. for (const user of lobby.match_participants) {
  337. // If the player hasn't scored after 10 seconds, they should get kicked
  338. if (!lobby.scores.some((s) => s.player == user)) {
  339. players_to_kick.push(user);
  340. }
  341. }
  342. // It never is more than 1 player who is causing issues. To make sure we
  343. // don't kick the whole lobby, let's wait a bit more.
  344. if (players_to_kick.length > 1) {
  345. lobby.match_end_timeout = setTimeout(kick_afk_players, 10000);
  346. return;
  347. }
  348. lobby.match_participants = lobby.match_participants.filter((p) => p != players_to_kick[0]);
  349. lobby.afk_kicked.push(players_to_kick[0]);
  350. await lobby.send(`!mp kick ${players_to_kick[0].username}`);
  351. };
  352. lobby.on('score', (score) => {
  353. // Sometimes players prevent the match from ending. Bancho will only end
  354. // the match after ~2 minutes of players waiting, which is very
  355. // frustrating. To avoid having to close the game or wait an eternity, we
  356. // kick the offending player.
  357. if (score.score > 0 && lobby.match_end_timeout == -1) {
  358. lobby.match_end_timeout = setTimeout(kick_afk_players, 10000);
  359. }
  360. });
  361. // After the host finishes playing, their client resets the map to the one they played.
  362. // Because we change the map *before* they rejoin the lobby, we need to re-select our map.
  363. lobby.on('playerChangedBeatmap', async () => {
  364. if (lobby.data.recent_mapids.includes(lobby.beatmap_id)) {
  365. await lobby.send(`!mp map ${lobby.data.recent_mapids[lobby.data.recent_mapids.length - 1]} ${lobby.data.ruleset}`);
  366. } else {
  367. try {
  368. await get_map_info(lobby.beatmap_id);
  369. const new_map = db.prepare(`
  370. SELECT * FROM map INNER JOIN pp ON pp.map_id = map.map_id
  371. WHERE map.map_id = ? AND mode = ? AND mods = ?`,
  372. ).get(lobby.beatmap_id, lobby.data.ruleset, lobby.data.mods);
  373. if (new_map) {
  374. await push_map(lobby, new_map);
  375. }
  376. } catch (err) {
  377. console.error(err);
  378. await lobby.send(`Sorry, failed to get information on [https://osu.ppy.sh/beatmaps/${lobby.beatmap_id} Map ${lobby.beatmap_id}]... Reason: ${err.message}`);
  379. }
  380. }
  381. });
  382. lobby.on('matchFinished', async (scores) => {
  383. clearTimeout(lobby.match_end_timeout);
  384. lobby.match_end_timeout = -1;
  385. await lobby.select_next_map();
  386. const fetch_last_match = async (tries) => {
  387. if (tries > 5) {
  388. console.error('Failed to get game results from API in lobby ' + lobby.id);
  389. return;
  390. }
  391. let match = null;
  392. let game = null;
  393. try {
  394. match = await osu_fetch(`https://osu.ppy.sh/api/v2/matches/${lobby.id}`);
  395. for (const event of match.events) {
  396. if (event.game && event.game.end_time) {
  397. game = event.game;
  398. }
  399. }
  400. if (game == null || game == lobby.data.last_game_id) {
  401. setTimeout(() => fetch_last_match(tries++), 5000);
  402. return;
  403. }
  404. } catch (err) {
  405. if (err.name == 'SyntaxError') {
  406. await lobby.send('osu!api is having issues, scores ignored. More info: https://status.ppy.sh/');
  407. } else {
  408. capture_sentry_exception(err);
  409. }
  410. return;
  411. }
  412. // Handle dodgers
  413. lobby.data.last_game_id = game.id;
  414. for (const afk of lobby.afk_kicked) {
  415. game.scores = game.scores.filter((s) => s.user_id != afk.user_id);
  416. }
  417. for (const dodger of lobby.dodgers) {
  418. if (lobby.afk_kicked.includes(dodger)) continue;
  419. game.scores = game.scores.filter((s) => s.user_id != dodger.user_id);
  420. game.scores.push({
  421. accuracy: null,
  422. max_combo: null,
  423. mods: [],
  424. statistics: {
  425. count_50: null,
  426. count_100: null,
  427. count_300: null,
  428. count_miss: null,
  429. count_geki: null,
  430. count_katu: null,
  431. },
  432. perfect: null,
  433. created_at: new Date().toISOString(),
  434. score: 0,
  435. user_id: dodger.user_id,
  436. dodged: true,
  437. });
  438. }
  439. // Handle unloaded players (99% of the time, banned from bancho)
  440. game.scores = game.scores.filter((score) => lobby.players.some((player) => player.user_id == score.user_id));
  441. save_game_and_update_rating(lobby, game);
  442. };
  443. setTimeout(() => fetch_last_match(0), 5000);
  444. });
  445. lobby.on('allPlayersReady', async () => {
  446. // Players can spam the Ready button and due to lag, this command could
  447. // be spammed before the match actually got started.
  448. if (!lobby.playing) {
  449. lobby.playing = true;
  450. await lobby.send(`!mp start .${Math.random().toString(36).substring(2, 6)}`);
  451. }
  452. });
  453. lobby.on('matchStarted', async () => {
  454. clearTimeout(lobby.countdown);
  455. lobby.countdown = -1;
  456. lobby.afk_kicked = [];
  457. lobby.dodgers = [];
  458. lobby.match_participants = [];
  459. await lobby.send(`!mp settings ${Math.random().toString(36).substring(2, 6)}`);
  460. });
  461. if (lobby.created_just_now) {
  462. await lobby.send(`!mp settings ${Math.random().toString(36).substring(2, 6)}`);
  463. await lobby.send('!mp password');
  464. if (lobby.data.mods == 0) {
  465. if (lobby.data.freemod) {
  466. await lobby.send('!mp mods freemod');
  467. } else {
  468. await lobby.send('!mp mods none');
  469. }
  470. } else {
  471. await lobby.send(`!mp mods ${lobby.data.mod_list.join(' ')} ${lobby.data.freemod ? 'freemod' : ''}`);
  472. }
  473. // Lobbies are ScoreV1 - but we ignore the results and get the full score info from osu's API.
  474. await lobby.send(`!mp set 0 0 16`);
  475. update_map_selection_query(lobby);
  476. await set_new_title(lobby);
  477. } else {
  478. let restart_msg = 'restarted';
  479. if (lobby.data.restart_msg) {
  480. restart_msg = lobby.data.restart_msg;
  481. lobby.data.restart_msg = null;
  482. }
  483. await lobby.send(`!mp settings (${restart_msg}) ${Math.random().toString(36).substring(2, 6)}`);
  484. }
  485. bancho.joined_lobbies.push(lobby);
  486. }
  487. export {
  488. init_lobby,
  489. get_new_title,
  490. };