import bancho from './bancho.js'; import db from './database.js'; import Config from './util/config.js'; import {capture_sentry_exception, gen_url} from './util/helpers.js'; import {get_division_from_elo, get_rankup_progress, update_the_one} from './elo_cache.js'; const get_rating_stmt = db.prepare(`SELECT * FROM rating WHERE user_id = ? AND mode = ?`); const update_rating_stmt = db.prepare(` UPDATE rating SET s3_scores = ?, total_scores = ?, elo = ? WHERE user_id = ? AND mode = ?`, ); const insert_score_stmt = db.prepare(`INSERT INTO score ( game_id, user_id, mode, accuracy, score, max_combo, count_50, count_100, count_300, count_miss, count_geki, count_katu, perfect, enabled_mods, created_at, beatmap_id, placement, dodged, elo_diff ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ); const better_users_stmt = db.prepare(` SELECT COUNT(*) AS nb FROM rating r1, rating r2 WHERE r1.s3_scores >= ? AND r1.mode = ? AND r2.s3_scores >= ? AND r2.mode = ? AND r1.elo > r2.elo AND r2.user_id = ?`, ); const nb_active_users_stmt = db.prepare(`SELECT COUNT(*) AS nb FROM rating WHERE s3_scores >= ? AND mode = ?`); const nb_active_users = [ nb_active_users_stmt.get(Config.games_needed_for_rank, 0).nb, nb_active_users_stmt.get(Config.games_needed_for_rank, 1).nb, nb_active_users_stmt.get(Config.games_needed_for_rank, 2).nb, nb_active_users_stmt.get(Config.games_needed_for_rank, 3).nb, ]; const ratings_cache = [[], [], [], []]; function get_rating(user_id, ruleset) { let rating = ratings_cache[ruleset][user_id]; if (typeof rating === 'undefined') { rating = get_rating_stmt.get(user_id, ruleset); ratings_cache[ruleset][user_id] = rating; } return rating; } async function save_game_and_update_rating(lobby, game) { if (!game || !game.scores) return; if (game.scores.length < 2) return; const insert_game = db.prepare(`INSERT INTO game (game_id, match_id, start_time, end_time, beatmap_id, play_mode, scoring_type, team_type, mods) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ); try { insert_game.run( game.id, lobby.id, Date.parse(game.start_time).valueOf(), Date.parse(game.end_time).valueOf(), game.beatmap.id, game.mode_int, game.scoring_type, game.team_type, JSON.stringify(game.mods), ); } catch (err) { console.error(`Game #${game.id} already saved! Ignoring.`); return; } // Sort from highest to lowest score to get placements game.scores = game.scores.sort((a, b) => b.score - a.score); let placement = 1; let worst_score = game.scores[0]; for (const score of game.scores) { if (score.score < worst_score) placement++; score.placement = placement; worst_score = score.score; } // Get current user ratings for (const score of game.scores) { // Must be set BEFORE saving scores because we cache ratings score.tms = Date.parse(score.created_at).valueOf(); score.rating = get_rating(score.user_id, game.mode_int); score.expected = 0; score.actual = 0; score.new_elo = score.rating.elo; } const today = (new Date()).getDay(); const is_weekend = (today == 0) || (today == 6); // Compute elo changes for (const player of game.scores) { for (const opponent of game.scores) { if (player == opponent) continue; let perf = 0.5; if (player.score > opponent.score) perf = 1; if (player.score < opponent.score) perf = 0; if (opponent.dodged) perf = 1; if (player.dodged) perf = 0; player.actual += perf; player.expected += 1 / (1 + Math.pow(10, (opponent.rating.elo - player.rating.elo) / 480)); } let k_factor = 32; if (is_weekend || player.rating.s3_scores < Config.games_needed_for_rank) k_factor *= 1.25; if (player.rating.elo > 2100) k_factor *= 0.75; if (player.rating.elo > 2400) k_factor *= 0.75; player.new_elo = player.rating.elo + k_factor * (player.actual - player.expected); if (player.new_elo < 100) player.new_elo = 100; } // Save ratings and scores for (const score of game.scores) { insert_score_stmt.run( game.id, score.user_id, game.mode_int, score.accuracy, score.score, score.max_combo, score.statistics.count_50, score.statistics.count_100, score.statistics.count_300, score.statistics.count_miss, score.statistics.count_geki, score.statistics.count_katu, score.perfect ? 1 : 0, JSON.stringify(score.mods), Date.parse(score.created_at).valueOf(), game.beatmap.id, score.placement, score.dodged ? 1 : 0, score.new_elo - score.rating.elo, ); // score.rating is cached, so updating it here if (bancho.connected) score.rating.s3_scores++; score.rating.total_scores++; score.rating.elo = score.new_elo; // We also cache the total number of active users for each ruleset :) if (score.rating.s3_scores == 10) { nb_active_users[score.rating.mode]++; } update_rating_stmt.run( score.rating.s3_scores, score.rating.total_scores, score.rating.elo, score.rating.user_id, score.rating.mode, ); } // Usually, we're in a live lobby, but sometimes we're just recomputing // scores (like after updating the ranking algorithm), so we return early. if (!lobby || !bancho.connected) { return; } const RANK_DIVISIONS = [ 'Unranked', 'Cardboard', 'Wood', 'Wood+', 'Bronze', 'Bronze+', 'Silver', 'Silver+', 'Gold', 'Gold+', 'Platinum', 'Platinum+', 'Diamond', 'Diamond+', 'Legendary', 'The One', ]; const rank_changes = []; for (const score of game.scores) { const player = lobby.players.find((player) => player.user_id == score.user_id); if (player.ratings[game.mode_int].s3_scores < Config.games_needed_for_rank) continue; const old_rank_text = player.ratings[game.mode_int].division; const new_rank_text = get_division_from_elo(score.rating.elo, game.mode_int); if (old_rank_text != new_rank_text) { const user_page = gen_url(game.mode_int, `/u/${player.user_id}/`); const direction = (RANK_DIVISIONS.indexOf(new_rank_text) > RANK_DIVISIONS.indexOf(old_rank_text)) ? '▲' : '▼'; rank_changes.push(`${player.username} [${user_page} ${direction} ${new_rank_text} ]`); db.prepare(`UPDATE rating SET division = ? WHERE user_id = ? AND mode = ?`).run(new_rank_text, player.user_id, game.mode_int); player.ratings[game.mode_int].division = new_rank_text; } } // Update "The One" for (const score of game.scores) { try { await update_the_one(score.user_id, score.rating.elo, game.mode_int); } catch (err) { console.error('Error while updating the one', err); capture_sentry_exception(err); } } if (rank_changes.length > 0) { // Max 8 rank updates per message - or else it starts getting truncated const MAX_UPDATES_PER_MSG = 6; for (let i = 0, j = rank_changes.length; i < j; i += MAX_UPDATES_PER_MSG) { const updates = rank_changes.slice(i, i + MAX_UPDATES_PER_MSG); if (i == 0) { await lobby.send('Rank updates: ' + updates.join(' | ')); } else { await lobby.send(updates.join(' | ')); } } } } function get_active_users(ruleset) { return nb_active_users[ruleset]; } // TODO: optimize function get_user_rank(user_id, ruleset) { if (!user_id) return null; const rating = get_rating(user_id, ruleset); if (!rating) { // User not initialized yet; doesn't have a rank return null; } const better_users = better_users_stmt.get( Config.games_needed_for_rank, ruleset, Config.games_needed_for_rank, ruleset, user_id, ); const is_ranked = rating.s3_scores >= Config.games_needed_for_rank; const info = { elo: '???', fancy_elo: '???', rank_nb: '???', text: 'Unranked', s1_division: rating.s1_division, s2_division: rating.s2_division, s3_scores: rating.s3_scores, total_scores: rating.total_scores, is_ranked: is_ranked, }; if (is_ranked) { info.elo = rating.elo; info.fancy_elo = Math.round(rating.elo); info.rank_nb = (better_users.nb + 1); info.text = get_division_from_elo(rating.elo, ruleset); info.rankup = get_rankup_progress(rating.elo, ruleset); } return info; } export {save_game_and_update_rating, get_user_rank, get_active_users};