elo.js 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. import bancho from './bancho.js';
  2. import db from './database.js';
  3. import Config from './util/config.js';
  4. import {capture_sentry_exception, gen_url} from './util/helpers.js';
  5. import {get_division_from_elo, get_rankup_progress, update_the_one} from './elo_cache.js';
  6. const get_rating_stmt = db.prepare(`SELECT * FROM rating WHERE user_id = ? AND mode = ?`);
  7. const update_rating_stmt = db.prepare(`
  8. UPDATE rating SET
  9. s3_scores = ?,
  10. total_scores = ?,
  11. elo = ?
  12. WHERE user_id = ? AND mode = ?`,
  13. );
  14. const insert_score_stmt = db.prepare(`INSERT INTO score (
  15. game_id, user_id, mode, accuracy, score, max_combo,
  16. count_50, count_100, count_300, count_miss, count_geki, count_katu,
  17. perfect, enabled_mods, created_at, beatmap_id, placement, dodged, elo_diff
  18. ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
  19. );
  20. const better_users_stmt = db.prepare(`
  21. SELECT COUNT(*) AS nb FROM rating r1, rating r2
  22. WHERE r1.s3_scores >= ? AND r1.mode = ?
  23. AND r2.s3_scores >= ? AND r2.mode = ?
  24. AND r1.elo > r2.elo AND r2.user_id = ?`,
  25. );
  26. const nb_active_users_stmt = db.prepare(`SELECT COUNT(*) AS nb FROM rating WHERE s3_scores >= ? AND mode = ?`);
  27. const nb_active_users = [
  28. nb_active_users_stmt.get(Config.games_needed_for_rank, 0).nb,
  29. nb_active_users_stmt.get(Config.games_needed_for_rank, 1).nb,
  30. nb_active_users_stmt.get(Config.games_needed_for_rank, 2).nb,
  31. nb_active_users_stmt.get(Config.games_needed_for_rank, 3).nb,
  32. ];
  33. const ratings_cache = [[], [], [], []];
  34. function get_rating(user_id, ruleset) {
  35. let rating = ratings_cache[ruleset][user_id];
  36. if (typeof rating === 'undefined') {
  37. rating = get_rating_stmt.get(user_id, ruleset);
  38. ratings_cache[ruleset][user_id] = rating;
  39. }
  40. return rating;
  41. }
  42. async function save_game_and_update_rating(lobby, game) {
  43. if (!game || !game.scores) return;
  44. if (game.scores.length < 2) return;
  45. const insert_game = db.prepare(`INSERT INTO
  46. game (game_id, match_id, start_time, end_time, beatmap_id, play_mode, scoring_type, team_type, mods)
  47. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
  48. );
  49. try {
  50. insert_game.run(
  51. game.id, lobby.id, Date.parse(game.start_time).valueOf(), Date.parse(game.end_time).valueOf(),
  52. game.beatmap.id, game.mode_int, game.scoring_type, game.team_type, JSON.stringify(game.mods),
  53. );
  54. } catch (err) {
  55. console.error(`Game #${game.id} already saved! Ignoring.`);
  56. return;
  57. }
  58. // Sort from highest to lowest score to get placements
  59. game.scores = game.scores.sort((a, b) => b.score - a.score);
  60. let placement = 1;
  61. let worst_score = game.scores[0];
  62. for (const score of game.scores) {
  63. if (score.score < worst_score) placement++;
  64. score.placement = placement;
  65. worst_score = score.score;
  66. }
  67. // Get current user ratings
  68. for (const score of game.scores) {
  69. // Must be set BEFORE saving scores because we cache ratings
  70. score.tms = Date.parse(score.created_at).valueOf();
  71. score.rating = get_rating(score.user_id, game.mode_int);
  72. score.expected = 0;
  73. score.actual = 0;
  74. score.new_elo = score.rating.elo;
  75. }
  76. const today = (new Date()).getDay();
  77. const is_weekend = (today == 0) || (today == 6);
  78. // Compute elo changes
  79. for (const player of game.scores) {
  80. for (const opponent of game.scores) {
  81. if (player == opponent) continue;
  82. let perf = 0.5;
  83. if (player.score > opponent.score) perf = 1;
  84. if (player.score < opponent.score) perf = 0;
  85. if (opponent.dodged) perf = 1;
  86. if (player.dodged) perf = 0;
  87. player.actual += perf;
  88. player.expected += 1 / (1 + Math.pow(10, (opponent.rating.elo - player.rating.elo) / 480));
  89. }
  90. let k_factor = 32;
  91. if (is_weekend || player.rating.s3_scores < Config.games_needed_for_rank) k_factor *= 1.25;
  92. if (player.rating.elo > 2100) k_factor *= 0.75;
  93. if (player.rating.elo > 2400) k_factor *= 0.75;
  94. player.new_elo = player.rating.elo + k_factor * (player.actual - player.expected);
  95. if (player.new_elo < 100) player.new_elo = 100;
  96. }
  97. // Save ratings and scores
  98. for (const score of game.scores) {
  99. insert_score_stmt.run(
  100. game.id, score.user_id, game.mode_int, score.accuracy, score.score, score.max_combo,
  101. score.statistics.count_50, score.statistics.count_100, score.statistics.count_300,
  102. score.statistics.count_miss, score.statistics.count_geki, score.statistics.count_katu,
  103. score.perfect ? 1 : 0, JSON.stringify(score.mods),
  104. Date.parse(score.created_at).valueOf(), game.beatmap.id, score.placement,
  105. score.dodged ? 1 : 0, score.new_elo - score.rating.elo,
  106. );
  107. // score.rating is cached, so updating it here
  108. if (bancho.connected) score.rating.s3_scores++;
  109. score.rating.total_scores++;
  110. score.rating.elo = score.new_elo;
  111. // We also cache the total number of active users for each ruleset :)
  112. if (score.rating.s3_scores == 10) {
  113. nb_active_users[score.rating.mode]++;
  114. }
  115. update_rating_stmt.run(
  116. score.rating.s3_scores,
  117. score.rating.total_scores,
  118. score.rating.elo,
  119. score.rating.user_id,
  120. score.rating.mode,
  121. );
  122. }
  123. // Usually, we're in a live lobby, but sometimes we're just recomputing
  124. // scores (like after updating the ranking algorithm), so we return early.
  125. if (!lobby || !bancho.connected) {
  126. return;
  127. }
  128. const RANK_DIVISIONS = [
  129. 'Unranked',
  130. 'Cardboard',
  131. 'Wood',
  132. 'Wood+',
  133. 'Bronze',
  134. 'Bronze+',
  135. 'Silver',
  136. 'Silver+',
  137. 'Gold',
  138. 'Gold+',
  139. 'Platinum',
  140. 'Platinum+',
  141. 'Diamond',
  142. 'Diamond+',
  143. 'Legendary',
  144. 'The One',
  145. ];
  146. const rank_changes = [];
  147. for (const score of game.scores) {
  148. const player = lobby.players.find((player) => player.user_id == score.user_id);
  149. if (player.ratings[game.mode_int].s3_scores < Config.games_needed_for_rank) continue;
  150. const old_rank_text = player.ratings[game.mode_int].division;
  151. const new_rank_text = get_division_from_elo(score.rating.elo, game.mode_int);
  152. if (old_rank_text != new_rank_text) {
  153. const user_page = gen_url(game.mode_int, `/u/${player.user_id}/`);
  154. const direction = (RANK_DIVISIONS.indexOf(new_rank_text) > RANK_DIVISIONS.indexOf(old_rank_text)) ? '▲' : '▼';
  155. rank_changes.push(`${player.username} [${user_page} ${direction} ${new_rank_text} ]`);
  156. db.prepare(`UPDATE rating SET division = ? WHERE user_id = ? AND mode = ?`).run(new_rank_text, player.user_id, game.mode_int);
  157. player.ratings[game.mode_int].division = new_rank_text;
  158. }
  159. }
  160. // Update "The One"
  161. for (const score of game.scores) {
  162. try {
  163. await update_the_one(score.user_id, score.rating.elo, game.mode_int);
  164. } catch (err) {
  165. console.error('Error while updating the one', err);
  166. capture_sentry_exception(err);
  167. }
  168. }
  169. if (rank_changes.length > 0) {
  170. // Max 8 rank updates per message - or else it starts getting truncated
  171. const MAX_UPDATES_PER_MSG = 6;
  172. for (let i = 0, j = rank_changes.length; i < j; i += MAX_UPDATES_PER_MSG) {
  173. const updates = rank_changes.slice(i, i + MAX_UPDATES_PER_MSG);
  174. if (i == 0) {
  175. await lobby.send('Rank updates: ' + updates.join(' | '));
  176. } else {
  177. await lobby.send(updates.join(' | '));
  178. }
  179. }
  180. }
  181. }
  182. function get_active_users(ruleset) {
  183. return nb_active_users[ruleset];
  184. }
  185. // TODO: optimize
  186. function get_user_rank(user_id, ruleset) {
  187. if (!user_id) return null;
  188. const rating = get_rating(user_id, ruleset);
  189. if (!rating) {
  190. // User not initialized yet; doesn't have a rank
  191. return null;
  192. }
  193. const better_users = better_users_stmt.get(
  194. Config.games_needed_for_rank, ruleset,
  195. Config.games_needed_for_rank, ruleset,
  196. user_id,
  197. );
  198. const is_ranked = rating.s3_scores >= Config.games_needed_for_rank;
  199. const info = {
  200. elo: '???',
  201. fancy_elo: '???',
  202. rank_nb: '???',
  203. text: 'Unranked',
  204. s1_division: rating.s1_division,
  205. s2_division: rating.s2_division,
  206. s3_scores: rating.s3_scores,
  207. total_scores: rating.total_scores,
  208. is_ranked: is_ranked,
  209. };
  210. if (is_ranked) {
  211. info.elo = rating.elo;
  212. info.fancy_elo = Math.round(rating.elo);
  213. info.rank_nb = (better_users.nb + 1);
  214. info.text = get_division_from_elo(rating.elo, ruleset);
  215. info.rankup = get_rankup_progress(rating.elo, ruleset);
  216. }
  217. return info;
  218. }
  219. export {save_game_and_update_rating, get_user_rank, get_active_users};