123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572 |
- import fetch from 'node-fetch';
- import {osu_fetch} from './api.js';
- import bancho from './bancho.js';
- import db from './database.js';
- import {save_game_and_update_rating} from './elo.js';
- import Config from './util/config.js';
- import {capture_sentry_exception} from './util/helpers.js';
- import {get_map_info} from './map_scanner.js';
- import {auto_rejoin_lobbies} from './supervisor.js';
- function get_new_title(lobby) {
- let new_title = lobby.data.title;
- // Min stars: we prefer not displaying the decimals whenever possible
- let fancy_min_stars;
- if (Math.abs(lobby.data.min_stars - Math.round(lobby.data.min_stars)) <= 0.1) {
- fancy_min_stars = Math.round(lobby.data.min_stars);
- } else {
- fancy_min_stars = Math.round(lobby.data.min_stars * 100) / 100;
- }
- // Max stars: we prefer displaying .99 whenever possible
- let fancy_max_stars;
- if (lobby.data.max_stars > 11) {
- // ...unless it's a ridiculously big number
- fancy_max_stars = Math.round(lobby.data.max_stars);
- } else {
- if (Math.abs(lobby.data.max_stars - Math.round(lobby.data.max_stars)) <= 0.1) {
- fancy_max_stars = (Math.round(lobby.data.max_stars) - 0.01).toFixed(2);
- } else {
- fancy_max_stars = Math.round(lobby.data.max_stars * 100) / 100;
- }
- }
- let stars;
- if (lobby.data.max_stars - lobby.data.min_stars == 1 && lobby.data.min_stars % 1 == 0) {
- // Simplify "4-4.99*" lobbies as "4*"
- stars = `${lobby.data.min_stars}`;
- } else {
- stars = `${fancy_min_stars}-${fancy_max_stars}`;
- }
- new_title = new_title.replaceAll('$min_stars', fancy_min_stars);
- new_title = new_title.replaceAll('$avg_stars', Math.round(lobby.data.avg_stars * 10) / 10);
- new_title = new_title.replaceAll('$max_stars', fancy_max_stars);
- new_title = new_title.replaceAll('$min_elo', Math.round(lobby.data.min_elo));
- new_title = new_title.replaceAll('$avg_elo', Math.round(lobby.data.avg_elo));
- new_title = new_title.replaceAll('$max_elo', Math.round(lobby.data.max_elo));
- new_title = new_title.replaceAll('$elo', Math.round(lobby.data.avg_elo));
- new_title = new_title.replaceAll('$min_pp', Math.round(lobby.data.min_pp));
- new_title = new_title.replaceAll('$avg_pp', Math.round(lobby.data.avg_pp));
- new_title = new_title.replaceAll('$max_pp', Math.round(lobby.data.max_pp));
- new_title = new_title.replaceAll('$pp', Math.round(lobby.data.avg_pp));
- new_title = new_title.replaceAll('$stars', stars);
- return new_title;
- }
- async function set_new_title(lobby) {
- let new_title = get_new_title(lobby);
- if (!Config.IS_PRODUCTION) {
- new_title = 'test lobby';
- }
- if (lobby.name != new_title) {
- await lobby.send(`!mp name ${new_title}`);
- lobby.name = new_title;
- }
- }
- // Updates the map selection query to account for lobby's current elo/pp.
- // Also updates min/avg/max elo/pp/star values for use in lobby title.
- function update_map_selection_query(lobby) {
- let median_pp = 0;
- let min_elo = 9999;
- let median_elo = 1500;
- let max_elo = 0;
- if (lobby.players.length > 0) {
- const pps = [];
- const elos = [];
- for (const player of lobby.players) {
- if (typeof player.pps === 'undefined') continue;
- pps.push(Math.min(600, player.pps[lobby.data.ruleset]));
- const elo = player.ratings[lobby.data.ruleset].elo;
- if (elo < min_elo) min_elo = elo;
- if (elo > max_elo) max_elo = elo;
- elos.push(elo);
- }
- const middle = Math.floor(pps.length / 2);
- if (pps.length % 2 == 0) {
- median_pp = (pps[middle - 1] + pps[middle]) / 2;
- median_elo = (elos[middle - 1] + elos[middle]) / 2;
- } else {
- median_pp = pps[middle];
- median_elo = elos[middle];
- }
- }
- const get_query = (type) => {
- if (type == 'random') {
- return {
- query: `SELECT * FROM pool_${lobby.id} pool
- INNER JOIN pp ON pp.map_id = pool.map_id
- WHERE ${lobby.data.filter_query} AND mods = ?`,
- args: [lobby.data.mods],
- };
- }
- if (type == 'pp') {
- return {
- query: `SELECT *, ABS(? - pp) AS pick_accuracy FROM pool_${lobby.id} pool
- INNER JOIN pp ON pp.map_id = pool.map_id
- WHERE ${lobby.data.filter_query} AND mods = ?
- ORDER BY pick_accuracy ASC LIMIT ?`,
- args: [median_pp, lobby.data.mods, lobby.data.pp_closeness],
- };
- }
- throw new Error('Unknown map selection type');
- };
- lobby.map_query = get_query(lobby.data.map_selection_algo);
- const query_stats = db.prepare(
- `SELECT AVG(stars) AS avg_stars,
- MIN(pp) AS min_pp, AVG(pp) AS avg_pp, MAX(pp) AS max_pp
- FROM (${lobby.map_query.query})`,
- ).get(...lobby.map_query.args);
- lobby.data.avg_stars = query_stats.avg_stars;
- lobby.data.min_pp = query_stats.min_pp;
- lobby.data.avg_pp = query_stats.avg_pp;
- lobby.data.max_pp = query_stats.max_pp;
- lobby.data.min_elo = min_elo;
- lobby.data.avg_elo = median_elo; // ok it's median, not avg, but better this way
- lobby.data.max_elo = max_elo;
- }
- async function push_map(lobby, new_map) {
- const MAP_TYPES = {
- 1: 'graveyard',
- 2: 'wip',
- 3: 'pending',
- 4: 'ranked',
- 5: 'approved',
- 6: 'qualified',
- 7: 'loved',
- };
- lobby.data.recent_mapids.push(new_map.map_id);
- lobby.data.recent_mapsets.push(new_map.set_id);
- try {
- const flavor = `${MAP_TYPES[new_map.ranked] || 'graveyard'} ${Math.round(new_map.pp)}pp`;
- const map_name = `[https://osu.ppy.sh/beatmaps/${new_map.map_id} ${new_map.name}]`;
- const osu_direct_link = `[https://api.osu.direct/d/${new_map.set_id} [1]]`;
- const chimu_link = `[https://chimu.moe/d/${new_map.set_id} [2]]`;
- const nerina_link = `[https://api.nerinyan.moe/d/${new_map.set_id} [3]]`;
- const sayobot_link = `[https://osu.sayobot.cn/osu.php?s=${new_map.set_id} [4]]`;
- 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}`);
- lobby.map = new_map;
- await set_new_title(lobby);
- } catch (e) {
- console.error(`${lobby.channel} Failed to switch to map ${new_map.map_id} ${new_map.name}:`, e);
- }
- }
- async function select_next_map() {
- clearTimeout(this.countdown);
- this.countdown = -1;
- if (this.data.recent_mapsets.length >= this.data.nb_non_repeating) {
- this.data.recent_mapids.shift();
- this.data.recent_mapsets.shift();
- }
- update_map_selection_query(this);
- let new_map = null;
- for (let i = 0; i < 10; i++) {
- new_map = db.prepare(`
- SELECT * FROM (${this.map_query.query})
- ORDER BY RANDOM() LIMIT 1`,
- ).get(...this.map_query.args);
- if (!new_map) break;
- if (!this.data.recent_mapsets.includes(new_map.set_id)) {
- break;
- }
- }
- if (!new_map) {
- await this.send(`Couldn't find a map with the current lobby settings :/`);
- return;
- }
- await push_map(this, new_map);
- }
- function generate_map_pool_table(lobby) {
- // Vary map attributes based on selected mods
- let ar = 1.0;
- let cs = 1.0;
- let od = 1.0;
- let hp = 1.0;
- let bpm = 1.0;
- let length = 1.0;
- if (lobby.data.mods & (1 << 1)) {
- // EZ
- ar /= 2;
- cs /= 2;
- hp /= 2;
- od /= 2;
- } else if (lobby.data.mods & (1 << 4)) {
- // HR
- ar *= 1.4;
- if (ar > 10) ar = 10;
- cs *= 1.3;
- hp *= 1.4;
- od *= 1.4;
- }
- if (lobby.data.mods & (1 << 6)) {
- // DT
- bpm *= 1.5;
- length *= 0.66;
- } else if (lobby.data.mods & (1 << 8)) {
- // HT
- bpm *= 0.75;
- length *= 1.33;
- }
- if (lobby.data.map_pool == 'leaderboarded') {
- db.prepare(`
- CREATE TEMPORARY TABLE pool_${lobby.id} AS
- SELECT map_id, set_id, mode, name, ar * ${ar} AS ar, cs * ${cs} AS cs, hp * ${hp} AS hp, od * ${od} AS od,
- bpm * ${bpm} AS bpm, length * ${length} AS length, ranked FROM map
- WHERE ranked >= 3 AND dmca = 0 AND mode = ?
- `).run(lobby.data.ruleset);
- } else {
- db.prepare(`CREATE TEMPORARY TABLE pool_${lobby.id} (map_id, set_id, mode, name, ar, cs, hp, od, bpm, length, ranked)`).run();
- const insert_map = db.prepare(`
- INSERT INTO pool_${lobby.id} (map_id, set_id, mode, name, ar, cs, hp, od, bpm, length, ranked)
- SELECT map_id, set_id, mode, name, ar * ${ar}, cs * ${cs}, hp * ${hp}, od * ${od}, bpm * ${bpm}, length * ${length}, ranked
- FROM map WHERE map_id = ?`,
- );
- for (const mapset of lobby.data.collection.beatmapsets) {
- for (const map of mapset.beatmaps) {
- insert_map.run(map.id);
- }
- }
- }
- }
- async function pick_new_host(lobby) {
- if (lobby.data.hostless) return;
- let best_player = lobby.players[0];
- for (const player of lobby.players) {
- if (player.user_id == lobby.data.creator_id) {
- best_player = player;
- break;
- }
- if (player.total_scores > best_player.total_scores) {
- best_player = player;
- }
- }
- await lobby.send(`!mp host ${best_player.irc_username}`);
- }
- async function init_lobby(lobby, data, created_just_now) {
- lobby.created_just_now = created_just_now;
- // Defaults for old lobbies
- if (!data.min_stars) data.min_stars = 0;
- if (!data.max_stars) data.max_stars = 11;
- if (!data.ruleset) data.ruleset = 0;
- if (!data.map_selection_algo) data.map_selection_algo = 'pp';
- if (!data.map_pool) data.map_pool = 'leaderboarded';
- if (!data.mods) data.mods = 0;
- if (!data.mod_list) data.mod_list = [];
- if (!data.filter_query) {
- if (data.ruleset == 3) {
- data.filter_query = 'cs = 4';
- } else {
- data.filter_query = 1;
- }
- }
- if (!data.nb_non_repeating) data.nb_non_repeating = 100;
- if (!data.pp_closeness) data.pp_closeness = 50;
- if (!data.title) data.title = '$avg_stars* | o!RL (!info)';
- if (!data.recent_mapids) data.recent_mapids = [];
- if (!data.recent_mapsets) data.recent_mapsets = [];
- // Save every lobby.data update to the database
- lobby.data = new Proxy(data, {
- set(obj, prop, value) {
- obj[prop] = value;
- db.prepare(`UPDATE match SET data = ? WHERE match_id = ?`).run(JSON.stringify(obj), lobby.id);
- if (prop == 'ruleset') {
- db.prepare(`UPDATE match SET ruleset = ? WHERE match_id = ?`).run(value, lobby.id);
- }
- return true;
- },
- });
- if (created_just_now) {
- // Should be saved later in this method, but just in case, save lobby data now
- db.prepare(`UPDATE match SET data = ? WHERE match_id = ?`).run(JSON.stringify(data), lobby.id);
- }
- lobby.afk_kicked = [];
- lobby.dodgers = [];
- lobby.match_participants = [];
- lobby.votekicks = [];
- lobby.countdown = -1;
- lobby.select_next_map = select_next_map;
- lobby.match_end_timeout = -1;
- if (lobby.data.collection_id && !lobby.data.collection) {
- try {
- const res = await fetch(`https://osucollector.com/api/collections/${lobby.data.collection_id}`);
- if (res.status == 404) {
- throw new Error('Collection not found.');
- }
- if (!res.ok) {
- throw new Error(await res.text());
- }
- lobby.data.collection = await res.json();
- } catch (err) {
- await lobby.send(`Failed to load collection: ${err.message}`);
- throw err;
- }
- }
- // generate_map_pool_table() must be called after fetching lobby.data.collection!
- generate_map_pool_table(lobby);
- lobby.on('close', () => {
- db.prepare(`DROP TABLE temp.pool_${lobby.id}`).run();
- if (!lobby.dont_reopen && bancho.joined_lobbies.length == 0) auto_rejoin_lobbies();
- });
- lobby.on('settings', async () => {
- for (const player of lobby.players) {
- if (lobby.playing && player.state != 'No Map') {
- lobby.match_participants.push(player);
- }
- }
- // Cannot select a map until we fetched the player IDs via !mp settings.
- if (lobby.created_just_now) {
- await lobby.select_next_map();
- lobby.created_just_now = false;
- }
- });
- lobby.on('playerJoined', async (player) => {
- player.join_time = Date.now();
- if (lobby.host == null || player.user_id == lobby.data.creator_id) {
- pick_new_host(lobby);
- }
- });
- lobby.on('playerLeft', async (player) => {
- if (lobby.match_participants.includes(player)) {
- lobby.dodgers.push(player);
- }
- if (lobby.players.length == 0) {
- await set_new_title(lobby);
- } else {
- // Pick a new host if current host left the lobby
- if (player.is_host) {
- pick_new_host(lobby);
- }
- }
- });
- const kick_afk_players = async () => {
- const players_to_kick = [];
- for (const user of lobby.match_participants) {
- // If the player hasn't scored after 10 seconds, they should get kicked
- if (!lobby.scores.some((s) => s.player == user)) {
- players_to_kick.push(user);
- }
- }
- // It never is more than 1 player who is causing issues. To make sure we
- // don't kick the whole lobby, let's wait a bit more.
- if (players_to_kick.length > 1) {
- lobby.match_end_timeout = setTimeout(kick_afk_players, 10000);
- return;
- }
- lobby.match_participants = lobby.match_participants.filter((p) => p != players_to_kick[0]);
- lobby.afk_kicked.push(players_to_kick[0]);
- await lobby.send(`!mp kick ${players_to_kick[0].username}`);
- };
- lobby.on('score', (score) => {
- // Sometimes players prevent the match from ending. Bancho will only end
- // the match after ~2 minutes of players waiting, which is very
- // frustrating. To avoid having to close the game or wait an eternity, we
- // kick the offending player.
- if (score.score > 0 && lobby.match_end_timeout == -1) {
- lobby.match_end_timeout = setTimeout(kick_afk_players, 10000);
- }
- });
- // After the host finishes playing, their client resets the map to the one they played.
- // Because we change the map *before* they rejoin the lobby, we need to re-select our map.
- lobby.on('playerChangedBeatmap', async () => {
- if (lobby.data.recent_mapids.includes(lobby.beatmap_id)) {
- await lobby.send(`!mp map ${lobby.data.recent_mapids[lobby.data.recent_mapids.length - 1]} ${lobby.data.ruleset}`);
- } else {
- try {
- await get_map_info(lobby.beatmap_id);
- const new_map = db.prepare(`
- SELECT * FROM map INNER JOIN pp ON pp.map_id = map.map_id
- WHERE map.map_id = ? AND mode = ? AND mods = ?`,
- ).get(lobby.beatmap_id, lobby.data.ruleset, lobby.data.mods);
- if (new_map) {
- await push_map(lobby, new_map);
- }
- } catch (err) {
- console.error(err);
- await lobby.send(`Sorry, failed to get information on [https://osu.ppy.sh/beatmaps/${lobby.beatmap_id} Map ${lobby.beatmap_id}]... Reason: ${err.message}`);
- }
- }
- });
- lobby.on('matchFinished', async (scores) => {
- clearTimeout(lobby.match_end_timeout);
- lobby.match_end_timeout = -1;
- await lobby.select_next_map();
- const fetch_last_match = async (tries) => {
- if (tries > 5) {
- console.error('Failed to get game results from API in lobby ' + lobby.id);
- return;
- }
- let match = null;
- let game = null;
- try {
- match = await osu_fetch(`https://osu.ppy.sh/api/v2/matches/${lobby.id}`);
- for (const event of match.events) {
- if (event.game && event.game.end_time) {
- game = event.game;
- }
- }
- if (game == null || game == lobby.data.last_game_id) {
- setTimeout(() => fetch_last_match(tries++), 5000);
- return;
- }
- } catch (err) {
- if (err.name == 'SyntaxError') {
- await lobby.send('osu!api is having issues, scores ignored. More info: https://status.ppy.sh/');
- } else {
- capture_sentry_exception(err);
- }
- return;
- }
- // Handle dodgers
- lobby.data.last_game_id = game.id;
- for (const afk of lobby.afk_kicked) {
- game.scores = game.scores.filter((s) => s.user_id != afk.user_id);
- }
- for (const dodger of lobby.dodgers) {
- if (lobby.afk_kicked.includes(dodger)) continue;
- game.scores = game.scores.filter((s) => s.user_id != dodger.user_id);
- game.scores.push({
- accuracy: null,
- max_combo: null,
- mods: [],
- statistics: {
- count_50: null,
- count_100: null,
- count_300: null,
- count_miss: null,
- count_geki: null,
- count_katu: null,
- },
- perfect: null,
- created_at: new Date().toISOString(),
- score: 0,
- user_id: dodger.user_id,
- dodged: true,
- });
- }
- // Handle unloaded players (99% of the time, banned from bancho)
- game.scores = game.scores.filter((score) => lobby.players.some((player) => player.user_id == score.user_id));
- save_game_and_update_rating(lobby, game);
- };
- setTimeout(() => fetch_last_match(0), 5000);
- });
- lobby.on('allPlayersReady', async () => {
- // Players can spam the Ready button and due to lag, this command could
- // be spammed before the match actually got started.
- if (!lobby.playing) {
- lobby.playing = true;
- await lobby.send(`!mp start .${Math.random().toString(36).substring(2, 6)}`);
- }
- });
- lobby.on('matchStarted', async () => {
- clearTimeout(lobby.countdown);
- lobby.countdown = -1;
- lobby.afk_kicked = [];
- lobby.dodgers = [];
- lobby.match_participants = [];
- await lobby.send(`!mp settings ${Math.random().toString(36).substring(2, 6)}`);
- });
- if (lobby.created_just_now) {
- await lobby.send(`!mp settings ${Math.random().toString(36).substring(2, 6)}`);
- await lobby.send('!mp password');
- if (lobby.data.mods == 0) {
- if (lobby.data.freemod) {
- await lobby.send('!mp mods freemod');
- } else {
- await lobby.send('!mp mods none');
- }
- } else {
- await lobby.send(`!mp mods ${lobby.data.mod_list.join(' ')} ${lobby.data.freemod ? 'freemod' : ''}`);
- }
- // Lobbies are ScoreV1 - but we ignore the results and get the full score info from osu's API.
- await lobby.send(`!mp set 0 0 16`);
- await lobby.send(`!mp addref #${lobby.data.creator_id}`);
- update_map_selection_query(lobby);
- await set_new_title(lobby);
- } else {
- let restart_msg = 'restarted';
- if (lobby.data.restart_msg) {
- restart_msg = lobby.data.restart_msg;
- lobby.data.restart_msg = null;
- }
- await lobby.send(`!mp settings (${restart_msg}) ${Math.random().toString(36).substring(2, 6)}`);
- }
- bancho.joined_lobbies.push(lobby);
- }
- export {
- init_lobby,
- get_new_title,
- };
|