Skip to content

Commit

Permalink
Merge pull request #332 from govariantsteam/rating-system
Browse files Browse the repository at this point in the history
Rating system
  • Loading branch information
SameerDalal authored Nov 27, 2024
2 parents c3bfc4d + c13ab6d commit dcfe2d5
Show file tree
Hide file tree
Showing 7 changed files with 284 additions and 9 deletions.
1 change: 1 addition & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions packages/server/src/games.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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;
}

Expand Down
126 changes: 126 additions & 0 deletions packages/server/src/rating/__tests__/rating.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
119 changes: 119 additions & 0 deletions packages/server/src/rating/rating.ts
Original file line number Diff line number Diff line change
@@ -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<object, object>,
) {
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);
}
16 changes: 9 additions & 7 deletions packages/server/src/users.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<void> {
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");
Expand All @@ -49,6 +49,7 @@ export async function getUserByName(username: string): Promise<PersistentUser> {
username: db_user.username,
password_hash: db_user.password_hash,
login_type: db_user.login_type,
ranking: db_user.ranking,
};
}

Expand Down Expand Up @@ -134,6 +135,7 @@ export async function createUserWithUsernameAndPassword(
username,
password_hash,
login_type: "persistent",
ranking: {},
};

const result = await usersCollection().insertOne(user);
Expand Down Expand Up @@ -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 || {},
};
}

Expand Down
14 changes: 12 additions & 2 deletions packages/shared/src/api_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,7 +32,7 @@ export interface UserResponse {
id?: string;
login_type: "guest" | "persistent";
username?: string;
rating?: number;
ranking?: UserRankings;
}

export type GamesFilter = {
Expand Down
8 changes: 8 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down

0 comments on commit dcfe2d5

Please sign in to comment.