diff --git a/packages/server/package.json b/packages/server/package.json index 506ee4c4..f91eb62a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -22,6 +22,7 @@ "connect-mongo": "^5.0.0", "express": "^4.18.2", "express-session": "^1.17.3", + "glicko2": "^1.2.1", "mongodb": "^5.6.0", "nodemon": "^3.1.0", "passport": "^0.6.0", diff --git a/packages/server/src/games.ts b/packages/server/src/games.ts index 104ca405..2918d493 100644 --- a/packages/server/src/games.ts +++ b/packages/server/src/games.ts @@ -21,6 +21,7 @@ import { } from "./time-control/time-control"; import { timeControlHandlerMap } from "./time-control/time-handler-map"; import { Clock } from "./time-control/clock"; +import { updateRatings, supportsRatings } from "./rating/rating"; export function gamesCollection() { return getDb().db().collection("games"); @@ -199,6 +200,14 @@ export async function handleMoveAndTime( emitGame(game.id, game.players?.length ?? 0, game_obj, timeControl); + if ( + game_obj.phase == "gameover" && + game.players.length == 2 && + supportsRatings(game.variant) + ) { + await updateRatings(game, game_obj); + } + return game; } diff --git a/packages/server/src/rating/__tests__/rating.test.ts b/packages/server/src/rating/__tests__/rating.test.ts new file mode 100644 index 00000000..2006a156 --- /dev/null +++ b/packages/server/src/rating/__tests__/rating.test.ts @@ -0,0 +1,126 @@ +import { + getGlickoPlayer, + applyPlayerRankingToUserResponse, + supportsRatings, + getGlickoResult, +} from "../rating"; +import { Glicko2 } from "glicko2"; +import { + UserRanking, + UserRankings, + UserResponse, +} from "@ogfcommunity/variants-shared"; + +test("supportsRatings", () => { + const supportsRating = supportsRatings("chess"); + expect(supportsRating).toEqual(false); + + const new_supportsRating = supportsRatings("quantum"); + expect(new_supportsRating).toEqual(true); +}); + +test("getGlickoPlayer - with typical player value", () => { + const db_player_ranking_quantum = { + rating: 1337, + rd: 290, + vol: 0.0599, + }; + + const ranking = new Glicko2({ + tau: 0.5, + rating: 1500, + rd: 350, + vol: 0.06, + }); + + const player = getGlickoPlayer(db_player_ranking_quantum, ranking); + + expect(player.getRating()).toBe(1337); + expect(player.getRd()).toBe(290); + expect(player.getVol()).toBe(0.0599); +}); + +test("getGlickoPlayer - when player does not have any rating", () => { + const db_player_ranking_quantum: undefined = undefined; + + const ranking = new Glicko2({ + tau: 0.5, + rating: 1500, + rd: 350, + vol: 0.06, + }); + + const player = getGlickoPlayer(db_player_ranking_quantum, ranking); + + expect(player.getRating()).toBe(1500); + expect(player.getRd()).toBe(350); + expect(player.getVol()).toBe(0.06); +}); + +test("getGlickoResult", () => { + expect(getGlickoResult("B")).toBe(1); + expect(getGlickoResult("W")).toBe(0); + expect(getGlickoResult("T")).toBe(0.5); +}); + +test("applyPlayerRankingToUserResponse", () => { + const variant = "quantum"; + const glicko_ranking = new Glicko2({ + tau: 0.5, + rating: 1500, + rd: 350, + vol: 0.06, + }); + const db_player_initial: UserResponse = { + id: "672844d2254d76r4387b8488", + login_type: "persistent", + username: "testUser", + ranking: { + quantum: { + rating: 1337, + rd: 290, + vol: 0.0599, + }, + baduk: { + rating: 1623, + rd: 290, + vol: 0.0599, + }, + phantom: { + rating: 1762, + rd: 290, + vol: 0.0599, + }, + }, + }; + + const new_quantum_ranking: UserRanking = { + rating: 1667, + rd: 295, + vol: 0.0699, + }; + + const glicko_player = getGlickoPlayer(new_quantum_ranking, glicko_ranking); + + const expected_player_rankings: UserRankings = { + quantum: { + rating: 1667, + rd: 295, + vol: 0.0699, + }, + baduk: { + rating: 1623, + rd: 290, + vol: 0.0599, + }, + phantom: { + rating: 1762, + rd: 290, + vol: 0.0599, + }, + }; + + expect( + applyPlayerRankingToUserResponse(db_player_initial, glicko_player, variant), + ).toEqual(expected_player_rankings); +}); diff --git a/packages/server/src/rating/rating.ts b/packages/server/src/rating/rating.ts new file mode 100644 index 00000000..38ed73c9 --- /dev/null +++ b/packages/server/src/rating/rating.ts @@ -0,0 +1,119 @@ +import { + GameResponse, + AbstractGame, + UserRanking, + UserRankings, + UserResponse, +} from "@ogfcommunity/variants-shared"; +import { Glicko2, Player } from "glicko2"; +import { updateUserRanking, getUser } from "../users"; + +export async function updateRatings( + game: GameResponse, + game_obj: AbstractGame, +) { + const ranking = new Glicko2({ + tau: 0.5, + rating: 1500, + rd: 350, + vol: 0.06, + }); + + const variant = game.variant; + + const glicko_outcome = getGlickoResult(game_obj.result[0]); + + const player_black_id = game.players[0].id; + const player_white_id = game.players[1].id; + + const db_player_black = await getUser(player_black_id); + const db_player_white = await getUser(player_white_id); + + const glicko_player_black = getGlickoPlayer( + db_player_black.ranking[variant], + ranking, + ); + const glicko_player_white = getGlickoPlayer( + db_player_white.ranking[variant], + ranking, + ); + + ranking.addResult(glicko_player_black, glicko_player_white, glicko_outcome); + + ranking.calculatePlayersRatings(); + + const player_black_new_ranking = applyPlayerRankingToUserResponse( + db_player_black, + glicko_player_black, + variant, + ); + const player_white_new_ranking = applyPlayerRankingToUserResponse( + db_player_white, + glicko_player_white, + variant, + ); + + await Promise.all([ + updateUserRanking(player_black_id, player_black_new_ranking), + updateUserRanking(player_white_id, player_white_new_ranking), + ]); +} + +export function getGlickoResult(game_result: string): number { + if (game_result === "B") { + return 1; + } + if (game_result === "W") { + return 0; + } + return 0.5; +} + +export function getGlickoPlayer( + db_player_ranking: UserRanking, + ranking: Glicko2, +): Player { + if (db_player_ranking == undefined) { + return ranking.makePlayer(1500, 350, 0.06); + } + return ranking.makePlayer( + db_player_ranking.rating, + db_player_ranking.rd, + db_player_ranking.vol, + ); +} + +// this function updates player's UserRanking with new ratings +export function applyPlayerRankingToUserResponse( + player: UserResponse, + glicko_player: Player, + variant: string, +): UserRankings { + const ranking: UserRanking = { + rating: glicko_player.getRating(), + rd: glicko_player.getRd(), + vol: glicko_player.getVol(), + }; + + const player_ranking = player.ranking; + player_ranking[variant] = ranking; + return player_ranking; +} + +export function supportsRatings(variant: string) { + return [ + "baduk", + "phantom", + "capture", + "tetris", + "pyramid", + "thue-morse", + "freeze", + "fractional", + "keima", + "one color", + "drift", + "quantum", + "sfractional", + ].includes(variant); +} diff --git a/packages/server/src/users.ts b/packages/server/src/users.ts index af855380..d251ccfd 100644 --- a/packages/server/src/users.ts +++ b/packages/server/src/users.ts @@ -1,12 +1,12 @@ import { getDb } from "./db"; -import { UserResponse } from "@ogfcommunity/variants-shared"; +import { UserResponse, UserRankings } from "@ogfcommunity/variants-shared"; import { Collection, WithId, ObjectId } from "mongodb"; import { randomBytes, scrypt } from "node:crypto"; export interface GuestUser extends UserResponse { token: string; login_type: "guest"; - rating?: number; + ranking?: UserRankings; } // Not currently used, but the plan is to use LocalStrategy from Password.js @@ -15,16 +15,16 @@ export interface PersistentUser extends UserResponse { username: string; password_hash: string; login_type: "persistent"; - rating?: number; + ranking?: UserRankings; } -export async function updateUserRating( +export async function updateUserRanking( user_id: string, - new_rating: number, + new_ranking: UserRankings, ): Promise { const update_result = await usersCollection().updateOne( { _id: new ObjectId(user_id) }, - { $set: { rating: new_rating } }, + { $set: { ranking: new_ranking } }, ); if (update_result.matchedCount == 0) { throw new Error("User not found"); @@ -49,6 +49,7 @@ export async function getUserByName(username: string): Promise { username: db_user.username, password_hash: db_user.password_hash, login_type: db_user.login_type, + ranking: db_user.ranking, }; } @@ -134,6 +135,7 @@ export async function createUserWithUsernameAndPassword( username, password_hash, login_type: "persistent", + ranking: {}, }; const result = await usersCollection().insertOne(user); @@ -188,7 +190,7 @@ function outwardFacingUser( id: db_user._id.toString(), login_type: db_user.login_type, ...(db_user.login_type === "persistent" && { username: db_user.username }), - rating: db_user.rating, + ranking: db_user.ranking || {}, }; } diff --git a/packages/shared/src/api_types.ts b/packages/shared/src/api_types.ts index ea3ff21e..cf586ac2 100644 --- a/packages/shared/src/api_types.ts +++ b/packages/shared/src/api_types.ts @@ -4,10 +4,20 @@ import { ITimeControlConfig, } from "./time_control/time_control.types"; +export interface UserRankings { + [variant: string]: UserRanking; +} + +export interface UserRanking { + rating: number; + rd: number; + vol: number; +} + export interface User { username?: string; id: string; - rating?: number; + ranking?: UserRankings; } export interface GameResponse { id: string; @@ -22,7 +32,7 @@ export interface UserResponse { id?: string; login_type: "guest" | "persistent"; username?: string; - rating?: number; + ranking?: UserRankings; } export type GamesFilter = { diff --git a/yarn.lock b/yarn.lock index 9eab34f6..496dd792 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1116,6 +1116,7 @@ __metadata: eslint: ^8.44.0 express: ^4.18.2 express-session: ^1.17.3 + glicko2: ^1.2.1 jest: ^29.7.0 mongodb: ^5.6.0 nodemon: ^3.1.0 @@ -4248,6 +4249,13 @@ __metadata: languageName: node linkType: hard +"glicko2@npm:^1.2.1": + version: 1.2.1 + resolution: "glicko2@npm:1.2.1" + checksum: f0c5c15aec1d43daca49cb7b75d49346d36e2f2f239738cdf7d77e1e4459ed98b7638c8f0eba8b112def8e56937eea3eda17fbd71e42996b35e2adddc4efd08b + languageName: node + linkType: hard + "glob-parent@npm:^5.1.2, glob-parent@npm:~5.1.2": version: 5.1.2 resolution: "glob-parent@npm:5.1.2"