diff --git a/lib/utils.ts b/lib/utils.ts index b121d6738c511..88e62b25ac0a6 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -36,6 +36,12 @@ export function getString(str: any): string { return (typeof str === 'string' || typeof str === 'number') ? '' + str : ''; } +export function getNumber(num: any): number { + if (typeof num === 'number') return num; + if (typeof num === 'string') return Number(num); + return NaN; +} + export function escapeRegex(str: string) { return str.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); } @@ -407,6 +413,11 @@ export function parseExactInt(str: string): number { return parseInt(str); } +export function ensureValidNumber(num: number, defaultValue: number): number { + if (Number.isNaN(num)) return defaultValue; + return num; +} + /** formats an array into a series of question marks and adds the elements to an arguments array */ export function formatSQLArray(arr: unknown[], args?: unknown[]) { args?.push(...arr); @@ -431,10 +442,12 @@ export class Multiset extends Map { // backwards compatibility export const Utils = { - parseExactInt, waitUntil, html, escapeHTML, + parseExactInt, ensureValidNumber, + waitUntil, html, escapeHTML, compare, sortBy, levenshtein, shuffle, deepClone, clearRequireCache, randomElement, forceWrap, splitFirst, - stripHTML, visualize, getString, + stripHTML, visualize, + getString, getNumber, escapeRegex, formatSQLArray, Multiset, }; diff --git a/server/chat-commands/core.ts b/server/chat-commands/core.ts index 9334f78074c6b..541cf06de47d5 100644 --- a/server/chat-commands/core.ts +++ b/server/chat-commands/core.ts @@ -1459,7 +1459,7 @@ export const commands: Chat.ChatCommands = { return false; } const ladder = Ladders(target); - if (!user.registered && Config.forceregisterelo && await ladder.getRating(user.id) >= Config.forceregisterelo) { + if (!user.registered && Config.forceregisterelo && await ladder.getElo(user.id) >= Config.forceregisterelo) { user.send( Utils.html`|popup||html|${this.tr`Since you have reached ${Config.forceregisterelo} ELO in ${target}, you must register your account to continue playing that format on ladder.`}

` ); diff --git a/server/ladders-challenges.ts b/server/ladders-challenges.ts index b075a76524a20..e45c356a3234e 100644 --- a/server/ladders-challenges.ts +++ b/server/ladders-challenges.ts @@ -1,4 +1,5 @@ import type {ChallengeType} from './room-battle'; +import type {CachedMMR} from './users'; /** * A bundle of: @@ -13,14 +14,14 @@ export class BattleReady { readonly userid: ID; readonly formatid: string; readonly settings: User['battleSettings']; - readonly rating: number; + readonly rating: CachedMMR; readonly challengeType: ChallengeType; readonly time: number; constructor( userid: ID, formatid: string, settings: User['battleSettings'], - rating = 0, + rating: CachedMMR = {elo: 0}, challengeType: ChallengeType = 'challenge' ) { this.userid = userid; diff --git a/server/ladders-local.ts b/server/ladders-local.ts index 6f96c5a55a29f..06d422f29f493 100644 --- a/server/ladders-local.ts +++ b/server/ladders-local.ts @@ -145,7 +145,7 @@ export class LadderStore { } /** - * Returns a Promise for the Elo rating of a user + * Returns a Promise for the ratings of a user */ async getRating(userid: string) { const formatid = this.formatid; @@ -155,14 +155,22 @@ export class LadderStore { } const ladder = await this.getLadder(); const index = this.indexOfUser(userid); - let rating = 1000; + const ratings = {elo: 1000}; if (index >= 0) { - rating = ladder[index][1]; + ratings.elo = ladder[index][1]; } if (user && user.id === userid) { - user.mmrCache[formatid] = rating; + user.mmrCache[formatid] = ratings; } - return rating; + return ratings; + } + + /** + * Returns a Promise for the Elo of a user + */ + async getElo(userid: string) { + const ratings = await this.getRating(userid); + return ratings?.elo ?? 1000; } /** @@ -249,9 +257,10 @@ export class LadderStore { } const p1 = Users.getExact(p1name); - if (p1) p1.mmrCache[formatid] = +p1newElo; + if (p1) p1.updateEloCache(formatid, +p1newElo); const p2 = Users.getExact(p2name); - if (p2) p2.mmrCache[formatid] = +p2newElo; + if (p2) p2.updateEloCache(formatid, +p2newElo); + void this.save(); if (!room.battle) { @@ -279,7 +288,7 @@ export class LadderStore { room.update(); } - return [p1score, p1newElo, p2newElo]; + return [p1score, {elo: p1newElo}, {elo: p2newElo}]; } /** diff --git a/server/ladders-remote.ts b/server/ladders-remote.ts index 8b618ab592adc..75906f65cf17a 100644 --- a/server/ladders-remote.ts +++ b/server/ladders-remote.ts @@ -33,7 +33,7 @@ export class LadderStore { } /** - * Returns a Promise for the Elo rating of a user + * Returns a Promise for the ratings of a user */ async getRating(userid: string) { const formatid = this.formatid; @@ -41,20 +41,31 @@ export class LadderStore { if (user?.mmrCache[formatid]) { return user.mmrCache[formatid]; } - const [data] = await LoginServer.request('mmr', { + const [data] = await LoginServer.request('rating', { format: formatid, user: userid, }); - let mmr = NaN; - if (data && !data.errorip) { - mmr = Number(data); + if (!data || data.errorip) { + return null; } - if (isNaN(mmr)) return 1000; + const ratings = { + elo: Utils.ensureValidNumber(Utils.getNumber(data.elo), 1000), + glickoScore: Utils.ensureValidNumber(Utils.getNumber(data.rpr), 1500), + glickoDeviation: Utils.ensureValidNumber(Utils.getNumber(data.rprd), 130), + }; if (user && user.id === userid) { - user.mmrCache[formatid] = mmr; + user.mmrCache[formatid] = ratings; } - return mmr; + return ratings; + } + + /** + * Returns a Promise for the Elo of a user + */ + async getElo(userid: string) { + const ratings = await this.getRating(userid); + return ratings?.elo ?? 1000; } /** @@ -83,7 +94,7 @@ export class LadderStore { }); // calculate new Elo scores and display to room while loginserver updates the ladder - const [p1OldElo, p2OldElo] = (await Promise.all([this.getRating(p1id), this.getRating(p2id)])).map(Math.round); + const [p1OldElo, p2OldElo] = (await Promise.all([this.getElo(p1id), this.getElo(p2id)])).map(Math.round); const p1NewElo = Math.round(this.calculateElo(p1OldElo, p1score, p2OldElo)); const p2NewElo = Math.round(this.calculateElo(p2OldElo, 1 - p1score, p1OldElo)); @@ -99,8 +110,8 @@ export class LadderStore { room.rated = Math.min(p1NewElo, p2NewElo); - if (p1) p1.mmrCache[formatid] = +p1NewElo; - if (p2) p2.mmrCache[formatid] = +p2NewElo; + if (p1) p1.updateEloCache(formatid, +p1NewElo); + if (p2) p2.updateEloCache(formatid, +p2NewElo); room.update(); diff --git a/server/ladders.ts b/server/ladders.ts index 2c7cb33207d5f..5f2e56866ca1b 100644 --- a/server/ladders.ts +++ b/server/ladders.ts @@ -15,7 +15,8 @@ const LadderStore: typeof import('./ladders-remote').LadderStore = ( const SECONDS = 1000; const PERIODIC_MATCH_INTERVAL = 60 * SECONDS; -import type {ChallengeType} from './room-battle'; +import type {ChallengeType, RoomBattlePlayerOptions} from './room-battle'; +import type {CachedMMR} from './users'; import {BattleReady, BattleChallenge, GameChallenge, BattleInvite, challenges} from './ladders-challenges'; /** @@ -70,7 +71,7 @@ class Ladder extends LadderStore { return null; } - let rating = 0; + let rating = {elo: 0} as CachedMMR; let valResult; let removeNicknames = !!(user.locked || user.namelocked); @@ -111,7 +112,8 @@ class Ladder extends LadderStore { if (isRated && !Ladders.disabled) { const uid = user.id; - [valResult, rating] = await Promise.all([ + let ratingResult; + [valResult, ratingResult] = await Promise.all([ TeamValidatorAsync.get(this.formatid).validateTeam(team, {removeNicknames, user: uid}), this.getRating(uid), ]); @@ -119,11 +121,13 @@ class Ladder extends LadderStore { // User feedback for renames handled elsewhere. return null; } - if (!rating) rating = 1; + // Elo has a hard-floor of 1000. + // (Ab)-use this fact to use 1 as an unknown Elo sentinel value. + rating = ratingResult ?? {elo: 1}; } else { if (Ladders.disabled) { connection.popup(`The ladder is temporarily disabled due to technical difficulties - you will not receive ladder rating for this game.`); - rating = 1; + rating = {elo: 1}; } const validator = TeamValidatorAsync.get(this.formatid); valResult = await validator.validateTeam(team, {removeNicknames, user: user.id}); @@ -368,7 +372,7 @@ class Ladder extends LadderStore { searchRange += elapsed / 300; // +1 every .3 seconds if (searchRange > 300) searchRange = 300 + (searchRange - 300) / 10; // +1 every 3 sec after 300 if (searchRange > 600) searchRange = 600; - const ratings = matches.map(([search]) => search.rating); + const ratings = matches.map(([search]) => search.rating.elo); if (Math.max(...ratings) - Math.min(...ratings) > searchRange) return false; matches[0][1].lastMatch = matches[1][1].id; @@ -450,7 +454,7 @@ class Ladder extends LadderStore { static match(readies: BattleReady[]) { const formatid = readies[0].formatid; if (readies.some(ready => ready.formatid !== formatid)) throw new Error(`Format IDs don't match`); - const players = []; + const players = [] as RoomBattlePlayerOptions[]; let missingUser = null; let minRating = Infinity; for (const ready of readies) { @@ -459,14 +463,15 @@ class Ladder extends LadderStore { missingUser = ready.userid; break; } - players.push({ + const playerOpts = { user, team: ready.settings.team, - rating: ready.rating, + rating: ready.rating.elo ?? 1000, hidden: ready.settings.hidden, inviteOnly: ready.settings.inviteOnly, - }); - if (ready.rating < minRating) minRating = ready.rating; + }; + players.push(playerOpts); + if (playerOpts.rating < minRating) minRating = playerOpts.rating; } if (missingUser) { for (const ready of readies) { diff --git a/server/room-battle.ts b/server/room-battle.ts index 8cefa75275c7f..67ce2aae1a0ea 100644 --- a/server/room-battle.ts +++ b/server/room-battle.ts @@ -504,7 +504,7 @@ export class RoomBattle extends RoomGame { readonly gameType: string | undefined; readonly challengeType: ChallengeType; /** - * The lower player's rating, for searching purposes. + * The lower player's rating, for Elo searching purposes. * 0 for unrated battles. 1 for unknown ratings. */ readonly rated: number; @@ -872,6 +872,12 @@ export class RoomBattle extends RoomGame { this.p1.name, this.p2.name, p1score, this.room ); void this.logBattle(score, p1rating, p2rating); + if (Config.remoteladder) { + const p1 = Users.getExact(this.p1.name); + const p2 = Users.getExact(this.p2.name); + if (p1) p1.updateRatingCache(this.format, p1rating); + if (p2) p2.updateRatingCache(this.format, p2rating); + } Chat.runHandlers('onBattleRanked', this, winnerid, [p1rating, p2rating], [this.p1.id, this.p2.id]); } async logBattle( diff --git a/server/users.ts b/server/users.ts index 091b0cd38dad0..defe0b3f3cd90 100644 --- a/server/users.ts +++ b/server/users.ts @@ -335,6 +335,12 @@ export interface UserSettings { hideLogins: boolean; } +export interface CachedMMR { + elo: number; + glickoScore?: number; + glickoDeviation?: number; +} + // User export class User extends Chat.MessageContext { /** In addition to needing it to implement MessageContext, this is also nice for compatibility with Connection. */ @@ -351,7 +357,7 @@ export class User extends Chat.MessageContext { * `)` */ readonly games: Set; - mmrCache: {[format: string]: number}; + mmrCache: {[format: string]: CachedMMR}; guestNum: number; name: string; named: boolean; @@ -910,7 +916,7 @@ export class User extends Chat.MessageContext { } // MMR is different for each userid - this.mmrCache = {}; + this.mmrCache = Object.create(null); this.updateGroup(registered); } else if (registered) { @@ -1407,6 +1413,27 @@ export class User extends Chat.MessageContext { updateSearch(connection: Connection | null = null) { Ladders.updateSearch(this, connection); } + updateRatingCache(formatid: string, data: any) { + if (!data || !('elo' in data)) return; + if (!('rpr' in data)) { + this.mmrCache[formatid] = {elo: Utils.ensureValidNumber(Utils.getNumber(data.elo), 1000)}; + } else { + const ratings = { + elo: Utils.ensureValidNumber(Utils.getNumber(data.elo), 1000), + glickoScore: Utils.ensureValidNumber(Utils.getNumber(data.rpr), 1500), + glickoDeviation: Utils.ensureValidNumber(Utils.getNumber(data.rprd), 130), + }; + this.mmrCache[formatid] = ratings; + } + } + updateEloCache(formatid: string, elo: number) { + if (!this.mmrCache[formatid]) { + this.mmrCache[formatid] = {elo} as CachedMMR; + return; + } else { + this.mmrCache[formatid].elo = elo; + } + } /** * Moves the user's connections in a given room to another room. * This function's main use case is for when a room is renamed. diff --git a/test/server/ladders.js b/test/server/ladders.js index 659ec930e4ff3..afa1e163c3b6f 100644 --- a/test/server/ladders.js +++ b/test/server/ladders.js @@ -8,7 +8,7 @@ const {makeUser} = require('../users-utils'); describe('Matchmaker', function () { const FORMATID = 'gen7ou'; const addSearch = (player, rating = 1000, formatid = FORMATID) => { - const search = new Ladders.BattleReady(player.id, formatid, player.battleSettings, rating); + const search = new Ladders.BattleReady(player.id, formatid, player.battleSettings, {elo: rating}); Ladders(formatid).addSearch(search, player); return search; }; @@ -48,7 +48,7 @@ describe('Matchmaker', function () { assert.equal(formatSearches.size, 1); assert.equal(s1.userid, this.p1.id); assert.equal(s1.settings.team, this.p1.battleSettings.team); - assert.equal(s1.rating, 1000); + assert.equal(s1.rating.elo, 1000); }); it('should matchmake users when appropriate', function () { @@ -78,7 +78,7 @@ describe('Matchmaker', function () { const s2 = addSearch(this.p2, 2000); assert.equal(Ladders.searches.get(FORMATID).searches.size, 2); - s2.rating = 1000; + s2.rating.elo = 1000; Ladders.Ladder.periodicMatch(); assert.equal(Ladders.searches.get(FORMATID).searches.size, 0);