diff --git a/apps/web-chess/src/app/api/model/GameViewModel.ts b/apps/web-chess/src/app/api/model/GameViewModel.ts index 29e6053a..10846cbb 100644 --- a/apps/web-chess/src/app/api/model/GameViewModel.ts +++ b/apps/web-chess/src/app/api/model/GameViewModel.ts @@ -7,8 +7,18 @@ export type GameViewModel = { result?: GameResultV1; status: GameStatusTypeV1; players: { - white?: { username: string; avatar?: string }; - black?: { username: string; avatar?: string }; + white?: { + username: string; + avatar?: string; + rating?: string; + ratingDiff?: string; + }; + black?: { + username: string; + avatar?: string; + rating?: string; + ratingDiff?: string; + }; }; startedAt?: Date; clock?: CountdownClock; diff --git a/apps/web-chess/src/app/api/service/GameService.ts b/apps/web-chess/src/app/api/service/GameService.ts index 27ff82a5..8fa21a8a 100644 --- a/apps/web-chess/src/app/api/service/GameService.ts +++ b/apps/web-chess/src/app/api/service/GameService.ts @@ -46,6 +46,18 @@ export class GameService { } toGameViewModel(gameDetails: GameDetailsV1): GameViewModel { + const whiteRatingDiff = gameDetails.players.white?.ratingDiff; + const whiteRatingDiffText = whiteRatingDiff + ? whiteRatingDiff > 0 + ? `+ ${whiteRatingDiff}` + : `- ${Math.abs(whiteRatingDiff)}` + : undefined; + const blackRatingDiff = gameDetails.players.black?.ratingDiff; + const blackRatingDiffText = blackRatingDiff + ? blackRatingDiff > 0 + ? `+ ${blackRatingDiff}` + : `- ${Math.abs(blackRatingDiff)}` + : undefined; return { status: gameDetails.status, moves: gameDetails.moves.map((m) => Move.fromUci(m.uci)), @@ -62,12 +74,16 @@ export class GameService { ? { username: gameDetails.players.white.name, avatar: undefined, + rating: gameDetails.players.white.rating?.toString(), + ratingDiff: whiteRatingDiffText, } : undefined, black: gameDetails.players.black ? { username: gameDetails.players.black.name, avatar: undefined, + rating: gameDetails.players.black.rating?.toString(), + ratingDiff: blackRatingDiffText, } : undefined, }, diff --git a/apps/web-chess/src/app/features/game/RemoteGameContainer.tsx b/apps/web-chess/src/app/features/game/RemoteGameContainer.tsx index a3055bf9..d36a4f61 100644 --- a/apps/web-chess/src/app/features/game/RemoteGameContainer.tsx +++ b/apps/web-chess/src/app/features/game/RemoteGameContainer.tsx @@ -34,19 +34,10 @@ export const RemoteGameContainer = ({ isPeeking, } = usePeekBoardState(chessboard); const { auth } = useAuth(); - const { - players, - playerSide, - result, - isReadOnly, - actionOptions, - clock, - status, - } = gameState; + const { players, playerSide, result, isReadOnly, actionOptions, clock } = + gameState; orientation = playerSide !== 'spectator' ? playerSide : orientation; - const isInProgress = status === 'IN_PROGRESS'; - const blackPlayer = { ...players.black, color: Color.Black }; const whitePlayer = { ...players.white, color: Color.White }; const currentOrientation = orientation || Color.White; @@ -74,6 +65,8 @@ export const RemoteGameContainer = ({ username={topPlayer?.username} avatar={topPlayer?.avatar} color={topPlayer.color} + rating={topPlayer?.rating} + ratingDiff={topPlayer?.ratingDiff} isPlayerTurn={chessboard.position.turn === topPlayer.color} isLoading={isLoadingInitial} /> @@ -100,6 +93,8 @@ export const RemoteGameContainer = ({ username={bottomPlayer?.username} avatar={bottomPlayer?.avatar} color={bottomPlayer.color} + rating={bottomPlayer?.rating} + ratingDiff={bottomPlayer?.ratingDiff} isPlayerTurn={chessboard.position.turn === bottomPlayer.color} isLoading={isLoadingInitial} /> diff --git a/apps/web-chess/src/app/features/game/components/PlayerInfo.tsx b/apps/web-chess/src/app/features/game/components/PlayerInfo.tsx index c5c01177..f5ce3c80 100644 --- a/apps/web-chess/src/app/features/game/components/PlayerInfo.tsx +++ b/apps/web-chess/src/app/features/game/components/PlayerInfo.tsx @@ -1,5 +1,5 @@ import { Color } from '@michess/core-board'; -import { Avatar, Badge, Flex, Skeleton, Text } from '@radix-ui/themes'; +import { Avatar, Badge, Box, Flex, Skeleton, Text } from '@radix-ui/themes'; import React from 'react'; import { CountdownClock } from '../../../api/model/CountdownClock'; import { Clock } from './Clock'; @@ -13,6 +13,8 @@ type PlayerInfoProps = { isPlayerTurn?: boolean; isLoading?: boolean; clock?: CountdownClock; + rating?: string; + ratingDiff?: string; }; export const PlayerInfo: React.FC = ({ @@ -23,6 +25,8 @@ export const PlayerInfo: React.FC = ({ avatar, isPlayerTurn = false, clock, + rating, + ratingDiff, isLoading, }) => { const getInitials = (name: string): string => { @@ -52,16 +56,31 @@ export const PlayerInfo: React.FC = ({ - - {displayUsername} - + + + {displayUsername} + + + {rating !== undefined ? ( + {rating} + ) : undefined} + {ratingDiff !== undefined ? ( + + {ratingDiff} + + ) : undefined} + + diff --git a/apps/web-chess/src/app/features/lobby/GameLobby.tsx b/apps/web-chess/src/app/features/lobby/GameLobby.tsx index 2af091c8..bc8a1ab6 100644 --- a/apps/web-chess/src/app/features/lobby/GameLobby.tsx +++ b/apps/web-chess/src/app/features/lobby/GameLobby.tsx @@ -84,7 +84,8 @@ export const GameLobby: React.FC = ({ onCreateGame, onJoinGame }) => { - {game.opponent.name} + {game.opponent.name}{' '} + {game.opponent.rating ? `(${game.opponent.rating})` : ''} diff --git a/apps/web-chess/src/app/features/lobby/__tests__/GameLobby.spec.tsx b/apps/web-chess/src/app/features/lobby/__tests__/GameLobby.spec.tsx index c2a487df..2edc242d 100644 --- a/apps/web-chess/src/app/features/lobby/__tests__/GameLobby.spec.tsx +++ b/apps/web-chess/src/app/features/lobby/__tests__/GameLobby.spec.tsx @@ -4,6 +4,7 @@ import { http, HttpResponse, server } from '../../../../test/mocks/node-chess'; import { render, socketClient, + within, } from '../../../../test/utils/custom-testing-library'; import { GameLobby } from '../GameLobby'; @@ -15,7 +16,7 @@ describe('GameLobby', () => { expect(getByText('+ Create Game')).toBeTruthy(); }); - it('should call onCreateGame when create button is clicked', async () => { + it('should call onCreateGame when a game is created', async () => { const user = userEvent.setup(); const onCreateGame = vi.fn(); const gameDetailsMockV1: GameDetailsV1 = { @@ -43,6 +44,11 @@ describe('GameLobby', () => { await user.click(createButton); + const dialog = await findByRole('dialog'); + const createGameButton = within(dialog).getByRole('button', { + name: 'Create Game', + }); + await user.click(createGameButton); expect(onCreateGame).toHaveBeenCalledTimes(1); }); diff --git a/apps/web-chess/src/test/utils/setup-tests.ts b/apps/web-chess/src/test/utils/setup-tests.ts index 4a613b35..567d13d4 100644 --- a/apps/web-chess/src/test/utils/setup-tests.ts +++ b/apps/web-chess/src/test/utils/setup-tests.ts @@ -2,6 +2,15 @@ import '@testing-library/jest-dom/vitest'; import { fetch } from 'cross-fetch'; import { server } from '../mocks/node-chess'; global.fetch = fetch; +// Mock the ResizeObserver +const ResizeObserverMock = vi.fn(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), +})); + +// Stub the global ResizeObserver +vi.stubGlobal('ResizeObserver', ResizeObserverMock); vi.mock('socket.io-client', async () => { return { io: vi.fn(() => ({ diff --git a/libs/api-router/src/lib/socket/__tests__/SocketRouter.spec.ts b/libs/api-router/src/lib/socket/__tests__/SocketRouter.spec.ts index bebce027..fe185766 100644 --- a/libs/api-router/src/lib/socket/__tests__/SocketRouter.spec.ts +++ b/libs/api-router/src/lib/socket/__tests__/SocketRouter.spec.ts @@ -27,6 +27,7 @@ const apiMock: Api = { {} as never, {} as never, {} as never, + {} as never, ), auth: new AuthService({} as never, {} as never, {} as never, { google: { clientId: '', clientSecret: '' }, diff --git a/libs/api-schema/src/index.ts b/libs/api-schema/src/index.ts index 4f4d5c24..8ed63dff 100644 --- a/libs/api-schema/src/index.ts +++ b/libs/api-schema/src/index.ts @@ -36,4 +36,5 @@ export * from './lib/player/PlayerGameInfoPageResponseV1'; export * from './lib/player/PlayerGameInfoQueryV1'; export * from './lib/player/PlayerGameInfoQueryV1Schema'; export * from './lib/player/PlayerGameInfoV1'; +export * from './lib/player/PlayerInfoV1'; export * from './lib/ServerToClientEvents'; diff --git a/libs/api-schema/src/lib/player/PlayerInfoV1Schema.ts b/libs/api-schema/src/lib/player/PlayerInfoV1Schema.ts index efe00179..eeaff147 100644 --- a/libs/api-schema/src/lib/player/PlayerInfoV1Schema.ts +++ b/libs/api-schema/src/lib/player/PlayerInfoV1Schema.ts @@ -2,5 +2,7 @@ import { z } from 'zod'; export const PlayerInfoV1Schema = z.object({ id: z.string(), + rating: z.number().min(0).optional(), + ratingDiff: z.number().optional(), name: z.string().min(1).max(100), }); diff --git a/libs/api-service/src/lib/Api.ts b/libs/api-service/src/lib/Api.ts index 96e91fea..438e9b91 100644 --- a/libs/api-service/src/lib/Api.ts +++ b/libs/api-service/src/lib/Api.ts @@ -7,6 +7,7 @@ import { AuthService } from './auth/service/AuthService'; import { GamesService } from './games/service/GamesService'; import { LockService } from './lock/service/LockService'; import { UsageMetricsService } from './metrics/UsageMetricsService'; +import { RatingsService } from './user/service/RatingsService'; export type Api = { games: GamesService; @@ -22,11 +23,18 @@ const from = ( ): Api => { const processId = randomUUID(); const lockService = new LockService(repos.cache.client); + const ratingsService = new RatingsService( + repos.rating, + repos.game, + repos.cache, + lockService, + ); const gamesService = new GamesService( repos.game, repos.move, repos.action, repos.cache, + ratingsService, lockService, ); const authService = new AuthService( diff --git a/libs/api-service/src/lib/games/mapper/GameDetailsMapper.ts b/libs/api-service/src/lib/games/mapper/GameMapper.ts similarity index 61% rename from libs/api-service/src/lib/games/mapper/GameDetailsMapper.ts rename to libs/api-service/src/lib/games/mapper/GameMapper.ts index a1f34541..926bc8de 100644 --- a/libs/api-service/src/lib/games/mapper/GameDetailsMapper.ts +++ b/libs/api-service/src/lib/games/mapper/GameMapper.ts @@ -3,28 +3,28 @@ import { GameVariantV1, LobbyGameItemV1, PlayerGameInfoV1, + PlayerInfoV1, } from '@michess/api-schema'; import { isDefined, Maybe } from '@michess/common-utils'; -import { ChessPosition, Color, Move } from '@michess/core-board'; +import { Color, Move } from '@michess/core-board'; import { + ChessGame, ChessGameResult, ChessGameResultType, DrawReasonType, GameAction, - GameActionOption, - GameDetails, GameMeta, GamePlayers, GameStatusType, PlayerInfo, + TimeControlIn, } from '@michess/core-game'; import { InsertGame, SelectGame, SelectGameWithRelations, } from '@michess/infra-db'; -import { ClockInstant } from 'libs/core-game/src/lib/model/ClockInstant'; -import { TimeControl } from 'libs/core-game/src/lib/model/TimeControl'; +import z from 'zod'; const TO_RESULT_TYPE_MAPPING: Record< SelectGameWithRelations['result'], @@ -70,21 +70,34 @@ const toGameMeta = (game: SelectGameWithRelations | SelectGame): GameMeta => ({ isPrivate: game.isPrivate, createdAt: game.createdAt, startedAt: game.startedAt ?? undefined, - endedAt: game.endedAt ?? undefined, updatedAt: game.updatedAt, }); const toPlayerInfo = (player: { id: string; name: string | null; + rating: SelectGameWithRelations['whiteRating']; }): PlayerInfo => ({ id: player.id, name: player.name ?? 'Anonymous', + rating: player.rating + ? { + deviation: player.rating.deviation, + id: player.rating.id, + value: player.rating.rating, + timestamp: player.rating.timestamp, + volatility: player.rating.volatility, + } + : undefined, }); const toGamePlayers = (game: SelectGameWithRelations): GamePlayers => ({ - white: game.whitePlayer ? toPlayerInfo(game.whitePlayer) : undefined, - black: game.blackPlayer ? toPlayerInfo(game.blackPlayer) : undefined, + white: game.whitePlayer + ? toPlayerInfo({ ...game.whitePlayer, rating: game.whiteRating }) + : undefined, + black: game.blackPlayer + ? toPlayerInfo({ ...game.blackPlayer, rating: game.blackRating }) + : undefined, }); const toGameActions = (game: SelectGameWithRelations): GameAction[] => { @@ -111,106 +124,101 @@ const toGameActions = (game: SelectGameWithRelations): GameAction[] => { .filter(isDefined); }; -const toTimeControl = ({ - timeControl, - timeControlClassification, -}: SelectGame): TimeControl => { - switch (timeControlClassification) { - case 'bullet': - case 'blitz': - case 'rapid': - return { - classification: timeControlClassification, - incrementSec: - timeControl && 'increment' in timeControl ? timeControl.increment : 0, - initialSec: - timeControl && 'initial' in timeControl ? timeControl.initial : 0, - }; - case 'correspondence': - return { - classification: timeControlClassification, - daysPerMove: - timeControl && 'daysPerMove' in timeControl - ? timeControl.daysPerMove - : 0, - }; - case 'no_clock': - default: - return { - classification: 'no_clock', - }; - } +const toPlayerInfoV1 = (player: PlayerInfo): PlayerInfoV1 => { + return { + id: player.id, + name: player.name, + rating: player.rating?.value ? Math.round(player.rating.value) : undefined, + ratingDiff: player.ratingDiff, + }; }; const toChessGameResult = ({ result, + endedAt, }: SelectGame | SelectGameWithRelations): Maybe => { return result !== '0-0' ? { + timestamp: endedAt ? endedAt.getTime() : 0, type: TO_RESULT_TYPE_MAPPING[result], } : undefined; }; -export const GameDetailsMapper = { - fromSelectGame(game: SelectGame): GameDetails { - return { +const toTimeControlIn = (game: SelectGame): TimeControlIn => { + return { + classification: game.timeControlClassification, + daysPerMove: + z.object({ daysPerMove: z.number().min(0) }).safeParse(game.timeControl) + .data?.daysPerMove ?? 0, + incrementSec: + z.object({ increment: z.number().min(0) }).safeParse(game.timeControl) + .data?.increment ?? 0, + initialSec: + z.object({ initial: z.number().min(0) }).safeParse(game.timeControl).data + ?.initial ?? 0, + }; +}; + +export const GameMapper = { + fromSelectGame(game: SelectGame): ChessGame { + return ChessGame.from({ players: { white: undefined, black: undefined, }, - timeControl: toTimeControl(game), + timeControl: toTimeControlIn(game), actionRecord: [], result: toChessGameResult(game), status: FROM_STATUS_TYPE_MAPPING[game.status], - resultStr: game.result, - initialPosition: ChessPosition.standardInitial(), + initialPosition: undefined, movesRecord: [], ...toGameMeta(game), - }; + }); }, - fromSelectGameWithRelations(game: SelectGameWithRelations): GameDetails { + fromSelectGameWithRelations(game: SelectGameWithRelations): ChessGame { const players = toGamePlayers(game); - return { + return ChessGame.from({ ...toGameMeta(game), - timeControl: toTimeControl(game), + timeControl: toTimeControlIn(game), actionRecord: toGameActions(game), players, status: FROM_STATUS_TYPE_MAPPING[game.status], variant: game.variant ?? 'standard', isPrivate: game.isPrivate, - initialPosition: ChessPosition.standardInitial(), + initialPosition: undefined, result: toChessGameResult(game), - resultStr: game.result, movesRecord: game.moves.map((move) => ({ ...Move.fromUci(move.uci), timestamp: move.movedAt.getTime(), })), - }; + }); }, - toLobbyGameItemV1(game: GameDetails): LobbyGameItemV1 { + toLobbyGameItemV1(game: ChessGame): LobbyGameItemV1 { + const gameState = game.getState(); return { - id: game.id, - opponent: game.players.white - ? game.players.white - : game.players.black - ? game.players.black + id: gameState.id, + opponent: gameState.players.white + ? toPlayerInfoV1(gameState.players.white) + : gameState.players.black + ? toPlayerInfoV1(gameState.players.black) : { id: 'anon', name: 'Anonymous', }, - variant: game.variant as GameVariantV1, - createdAt: game.createdAt.toISOString(), - availableColor: !game.players.white + variant: gameState.variant as GameVariantV1, + createdAt: gameState.createdAt.toISOString(), + availableColor: !gameState.players.white ? 'white' - : !game.players.black + : !gameState.players.black ? 'black' : 'spectator', }; }, - toPlayerGameInfoV1(game: GameDetails, playerId: string): PlayerGameInfoV1 { + toPlayerGameInfoV1(chessGame: ChessGame, playerId: string): PlayerGameInfoV1 { + const game = chessGame.getState(); const ownSide = game.players.white?.id === playerId ? 'white' @@ -241,15 +249,13 @@ export const GameDetailsMapper = { : undefined, }; }, - toGameDetailsV1({ - game, - clock, - availableActions, - }: { - game: GameDetails; - clock: Maybe; - availableActions?: GameActionOption[]; - }): GameDetailsV1 { + toGameDetailsV1(chessGame: ChessGame, isSpectator?: boolean): GameDetailsV1 { + const game = chessGame.getState(); + const clock = chessGame.clock?.instant; + const availableActions = isSpectator + ? [] + : chessGame.getAdditionalActions(); + return { id: game.id, status: game.status, @@ -259,12 +265,20 @@ export const GameDetailsMapper = { white: game.players.white ? { id: game.players.white.id, + rating: game.players.white.rating?.value + ? Math.round(game.players.white.rating.value) + : undefined, + ratingDiff: game.players.white.ratingDiff, name: game.players.white.name, } : undefined, black: game.players.black ? { id: game.players.black.id, + rating: game.players.black.rating?.value + ? Math.round(game.players.black.rating.value) + : undefined, + ratingDiff: game.players.black.ratingDiff, name: game.players.black.name, } : undefined, @@ -285,14 +299,17 @@ export const GameDetailsMapper = { }; }, - toInsertGame(game: GameDetails): InsertGame { + toInsertGame(chessGame: ChessGame): InsertGame { + const game = chessGame.getState(); return { isPrivate: game.isPrivate, whitePlayerId: game.players.white ? game.players.white.id : null, blackPlayerId: game.players.black ? game.players.black.id : null, status: TO_STATUS_TYPE_MAPPING[game.status], startedAt: game.startedAt ?? null, - endedAt: game.endedAt ?? null, + endedAt: game.result?.timestamp ? new Date(game.result.timestamp) : null, + blackRatingId: game.players.black?.rating?.id ?? null, + whiteRatingId: game.players.white?.rating?.id ?? null, result: game.result ? FROM_RESULT_TYPE_MAPPING[game.result.type] : '0-0', }; }, diff --git a/libs/api-service/src/lib/games/service/GamesService.ts b/libs/api-service/src/lib/games/service/GamesService.ts index ac7b7f74..7878e3dd 100644 --- a/libs/api-service/src/lib/games/service/GamesService.ts +++ b/libs/api-service/src/lib/games/service/GamesService.ts @@ -31,7 +31,8 @@ import { Job, Queue, Worker } from 'bullmq'; import { Session } from '../../auth/model/Session'; import { LockService } from '../../lock/service/LockService'; import { PageResponseMapper } from '../../mapper/PageResponseMapper'; -import { GameDetailsMapper } from '../mapper/GameDetailsMapper'; +import { RatingsService } from '../../user/service/RatingsService'; +import { GameMapper } from '../mapper/GameMapper'; type TimeControlJobData = { gameId: string; @@ -50,6 +51,7 @@ export class GamesService { private moveRepository: MoveRepository, private actionRepository: ActionRepository, private cacheRepo: CacheRepository, + private ratingsService: RatingsService, private lockService: LockService, ) { const connectionOptions = { connection: this.cacheRepo.client }; @@ -88,17 +90,9 @@ export class GamesService { }> { const dbGame = await this.gameRepository.findGameWithRelationsById(gameId); assertDefined(dbGame, `Game '${gameId}' not found`); - const gameDetails = GameDetailsMapper.fromSelectGameWithRelations(dbGame); - const chessGame = ChessGame.fromGameState(gameDetails); - return { chessGame }; - } - private toGameDetailsV1(chessGame: ChessGame): GameDetailsV1 { - return GameDetailsMapper.toGameDetailsV1({ - game: chessGame.getState(), - clock: chessGame.clock?.instant, - availableActions: chessGame.getAdditionalActions(), - }); + const chessGame = GameMapper.fromSelectGameWithRelations(dbGame); + return { chessGame }; } private async updateFlagTimeoutJob(chessGame: ChessGame): Promise { @@ -158,6 +152,18 @@ export class GamesService { } } + private async handleGameEnd( + chessGame: ChessGame, + previousGame?: ChessGame, + ): Promise { + const state = chessGame.getState(); + // If previous game was not provided assume that it was IN_PROGRESS. + const previousStatus = previousGame?.getState().status ?? 'IN_PROGRESS'; + if (previousStatus !== 'ENDED' && state.status === 'ENDED') { + await this.ratingsService.queueRatingUpdateForGame(state); + } + } + async createGame(data: CreateGameV1): Promise { const { timeControlClassification, @@ -194,10 +200,9 @@ export class GamesService { timeControlClassification, timeControl, }); - const chessGame = ChessGame.fromGameState( - GameDetailsMapper.fromSelectGame(createdGame), - ); - return this.toGameDetailsV1(chessGame); + + const chessGame = GameMapper.fromSelectGame(createdGame); + return GameMapper.toGameDetailsV1(chessGame); } async queryLobby(query: PaginationQueryV1): Promise { @@ -210,14 +215,10 @@ export class GamesService { status: ['WAITING'], private: false, }); - const gameDetails = games.map( - GameDetailsMapper.fromSelectGameWithRelations, - ); + const chessGames = games.map(GameMapper.fromSelectGameWithRelations); return PageResponseMapper.toPageResponse({ - data: gameDetails.map((game) => - GameDetailsMapper.toLobbyGameItemV1(game), - ), + data: chessGames.map((game) => GameMapper.toLobbyGameItemV1(game)), limit, totalItems: totalCount, page, @@ -237,13 +238,11 @@ export class GamesService { playerId: userId, status: query.status ? [query.status] : ['ENDED', 'IN_PROGRESS'], }); - const gameDetails = games.map( - GameDetailsMapper.fromSelectGameWithRelations, - ); + const gameDetails = games.map(GameMapper.fromSelectGameWithRelations); return PageResponseMapper.toPageResponse({ data: gameDetails.map((game) => - GameDetailsMapper.toPlayerGameInfoV1(game, userId), + GameMapper.toPlayerGameInfoV1(game, userId), ), limit, totalItems: totalCount, @@ -268,22 +267,29 @@ export class GamesService { const { chessGame } = await this.loadChessGame(data.gameId); const gameState = chessGame.getState(); if (data.side === 'spectator') { - return GameDetailsMapper.toGameDetailsV1({ - game: gameState, - clock: chessGame.clock?.instant, - }); + return GameMapper.toGameDetailsV1(chessGame, true); } + const gameRating = await this.ratingsService.getRatingByPlayerId( + session.userId, + gameState.variant, + gameState.timeControl.classification, + ); + const updatedGame = chessGame.joinGame( - { id: session.userId, name: session.name ?? 'Anonymous' }, + { + id: session.userId, + name: session.name ?? 'Anonymous', + rating: gameRating, + }, data.side, ); - const updatedGameState = updatedGame.getState(); + await this.gameRepository.updateGame( - gameState.id, - GameDetailsMapper.toInsertGame(updatedGameState), + updatedGame.id, + GameMapper.toInsertGame(updatedGame), ); - return this.toGameDetailsV1(updatedGame); + return GameMapper.toGameDetailsV1(updatedGame); } async leaveGame( @@ -297,13 +303,12 @@ export class GamesService { } const updatedGame = chessGame.leaveGame(session.userId); - const updatedGameState = updatedGame.getState(); await this.gameRepository.updateGame( - chessGame.getState().id, - GameDetailsMapper.toInsertGame(updatedGameState), + updatedGame.id, + GameMapper.toInsertGame(updatedGame), ); - return this.toGameDetailsV1(updatedGame); + return GameMapper.toGameDetailsV1(updatedGame); } async makeMove( @@ -324,7 +329,7 @@ export class GamesService { assertDefined(newMove, 'No move found after playing move'); await this.moveRepository.createMove({ - gameId: updatedGameState.id, + gameId: updatedGame.id, uci: data.uci, movedAt: new Date(newMove.timestamp), }); @@ -334,8 +339,8 @@ export class GamesService { chessGame.hasNewActionOptions(updatedGame); if (gameStateUpdated) { await this.gameRepository.updateGame( - updatedGameState.id, - GameDetailsMapper.toInsertGame(updatedGameState), + updatedGame.id, + GameMapper.toInsertGame(updatedGame), ); } return { updatedGame, move: newMove, gameStateUpdated }; @@ -353,8 +358,9 @@ export class GamesService { }; if (gameStateUpdated) { + await this.handleGameEnd(updatedGame); return { - gameDetails: this.toGameDetailsV1(updatedGame), + gameDetails: GameMapper.toGameDetailsV1(updatedGame), move: moveMadeV1, }; } else { @@ -376,13 +382,16 @@ export class GamesService { const updatedGame = chessGame.makeAction(session.userId, gameActionIn); const updatedGameState = updatedGame.getState(); await this.gameRepository.updateGame( - updatedGameState.id, - GameDetailsMapper.toInsertGame(updatedGameState), + updatedGame.id, + GameMapper.toInsertGame(updatedGame), ); const action = updatedGameState.actionRecord.at(-1); action && (await this.actionRepository.createAction(updatedGameState.id, action)); - return this.toGameDetailsV1(updatedGame); + + await this.handleGameEnd(updatedGame, chessGame); + + return GameMapper.toGameDetailsV1(updatedGame); } async cleanupGames(): Promise { @@ -403,18 +412,11 @@ export class GamesService { const dbGame = await this.gameRepository.findGameWithRelationsById(gameId); assertDefined(dbGame, `Game '${gameId}' not found`); - const gameDetails = GameDetailsMapper.fromSelectGameWithRelations(dbGame); - const chessGame = ChessGame.fromGameState(gameDetails); + const chessGame = GameMapper.fromSelectGameWithRelations(dbGame); - const updatedGameState = chessGame.getState(); - const gameResultChanged = - updatedGameState.result?.type !== gameDetails.result?.type; - - if (gameResultChanged) { - await this.gameRepository.updateGame( - gameDetails.id, - GameDetailsMapper.toInsertGame(updatedGameState), - ); + const insertGame = GameMapper.toInsertGame(chessGame); + if (insertGame.result !== dbGame.result) { + await this.gameRepository.updateGame(chessGame.id, insertGame); return chessGame; } else { return undefined; @@ -423,7 +425,8 @@ export class GamesService { const chessGame = await handleFlagTimeoutWithLock(); if (chessGame) { - this.notifyObservers(this.toGameDetailsV1(chessGame)); + await this.handleGameEnd(chessGame); + this.notifyObservers(GameMapper.toGameDetailsV1(chessGame)); } } } diff --git a/libs/api-service/src/lib/lock/model/ResourceType.ts b/libs/api-service/src/lib/lock/model/ResourceType.ts index 606f77ee..da9f1d06 100644 --- a/libs/api-service/src/lib/lock/model/ResourceType.ts +++ b/libs/api-service/src/lib/lock/model/ResourceType.ts @@ -1 +1 @@ -export type ResourceType = 'game' | 'user'; +export type ResourceType = 'game' | 'user' | 'rating'; diff --git a/libs/api-service/src/lib/user/service/RatingsService.ts b/libs/api-service/src/lib/user/service/RatingsService.ts new file mode 100644 index 00000000..09f56bec --- /dev/null +++ b/libs/api-service/src/lib/user/service/RatingsService.ts @@ -0,0 +1,263 @@ +import { logger } from '@michess/be-utils'; +import { + GameState, + GameVariantType, + TimeControlClassification, +} from '@michess/core-game'; +import { Rating, RatingCalculator } from '@michess/core-rating'; +import { + CacheRepository, + GameRepository, + RatingRepository, +} from '@michess/infra-db'; +import { Job, Queue, Worker } from 'bullmq'; +import { GameMapper } from '../../games/mapper/GameMapper'; +import { LockService } from '../../lock/service/LockService'; + +type UpdateRatingJobData = { + playerId: string; + variant: GameVariantType; + timeControlClassification: TimeControlClassification; +}; + +const STALE_RATING_DAYS = 5; + +export class RatingsService { + private ratingDecayQueue: Queue; + private ratingDecayWorker: Worker; + private updateRatingQueue: Queue; + private updateRatingWorker: Worker; + + constructor( + private readonly ratingRepository: RatingRepository, + private readonly gameRepository: GameRepository, + private readonly cacheRepository: CacheRepository, + private readonly lockService: LockService, + ) { + const connectionOptions = { connection: this.cacheRepository.client }; + + this.ratingDecayQueue = new Queue('rating-decay', connectionOptions); + this.ratingDecayWorker = new Worker( + 'rating-decay', + this.processStaleRatings.bind(this), + connectionOptions, + ); + + this.updateRatingQueue = new Queue('update-rating', connectionOptions); + this.updateRatingWorker = new Worker( + 'update-rating', + this.updatePlayerRating.bind(this), + connectionOptions, + ); + } + + async initialize() { + // Schedule job to check for stale ratings every day at 2 AM + await this.ratingDecayQueue.upsertJobScheduler('check-stale-ratings', { + pattern: '0 2 * * *', + }); + } + + async close() { + logger.info('Closing ratings service'); + await this.ratingDecayWorker.close(); + await this.ratingDecayQueue.close(); + await this.updateRatingWorker.close(); + await this.updateRatingQueue.close(); + } + + async queueRatingUpdate( + playerId: string, + variant: GameVariantType, + timeControlClassification: TimeControlClassification, + ): Promise { + const deduplicationId = `${playerId}-${variant}-${timeControlClassification}`; + + await this.updateRatingQueue.add( + 'update-rating', + { + playerId, + variant, + timeControlClassification, + }, + { + deduplication: { + id: deduplicationId, + }, + }, + ); + } + + async queueRatingUpdateForGame(gameState: GameState): Promise { + const whitePlayerId = gameState.players.white?.id; + const blackPlayerId = gameState.players.black?.id; + + if (whitePlayerId) { + await this.queueRatingUpdate( + whitePlayerId, + gameState.variant, + gameState.timeControl.classification, + ); + } + + if (blackPlayerId) { + await this.queueRatingUpdate( + blackPlayerId, + gameState.variant, + gameState.timeControl.classification, + ); + } + } + + private async processStaleRatings(): Promise { + logger.info('Processing stale ratings...'); + const cutoffDate = new Date( + Date.now() - STALE_RATING_DAYS * 24 * 60 * 60 * 1000, + ); + + const staleRatings = + await this.ratingRepository.getStaleRatings(cutoffDate); + + logger.info( + { count: staleRatings.length, cutoffDate }, + 'Found stale ratings', + ); + + for (const staleRating of staleRatings) { + await this.queueRatingUpdate( + staleRating.playerId, + staleRating.variant, + staleRating.timeControlClassification, + ); + } + } + + private async updatePlayerRating( + job: Job, + ): Promise { + const { playerId, variant, timeControlClassification } = job.data; + + logger.info( + { + playerId, + variant, + timeControlClassification, + }, + 'Updating player rating', + ); + + // Get the current rating to determine when it was last updated + const currentRating = await this.ratingRepository.getRatingByPlayerId( + playerId, + variant, + timeControlClassification, + ); + + if (!currentRating) { + logger.warn( + { playerId, variant, timeControlClassification }, + 'No rating found for player, skipping update', + ); + return; + } + + // Remove deduplication key and acquire lock + // This allows new jobs to be queued while we process, but they'll wait for the lock + const deduplicationId = `${playerId}-${variant}-${timeControlClassification}`; + await using _ = await this.lockService.acquireLock( + 'rating', + deduplicationId, + ); + await this.updateRatingQueue.removeDeduplicationKey(deduplicationId); + + // Capture the "until" timestamp at the start to define our processing window + const processingEndTime = new Date(); + + // Query for games in the window [lastRatingTimestamp, processingEndTime] + const { games } = await this.gameRepository.queryGames( + { + playerId, + variant, + timeControlClassification, + completedAfter: currentRating.timestamp, + completedBefore: processingEndTime, + status: ['ENDED'], + }, + { sortBy: 'endedAt', sortOrder: 'asc' }, + ); + + logger.info( + { + playerId, + variant, + timeControlClassification, + gamesCount: games.length, + lastRatingTimestamp: currentRating.timestamp, + processingEndTime, + }, + 'Found games since last rating update', + ); + + let latestRating = currentRating; + for (const game of games) { + const chessGame = GameMapper.fromSelectGameWithRelations(game); + const gameResult = chessGame.getPlayerGameResult(playerId); + if (gameResult) { + const { newRating } = RatingCalculator.compute( + latestRating, + gameResult, + ); + const gameRating = await this.ratingRepository.createRating({ + playerId, + variant, + timeControlClassification, + rating: newRating.value, + deviation: newRating.deviation, + volatility: newRating.volatility, + timestamp: gameResult.timestamp, + }); + latestRating = gameRating; + } + } + + // Decay rating if the latest rating is older than 1 day. + if (latestRating.timestamp.getTime() + 1000 * 60 * 60 * 24 < Date.now()) { + const newRating = RatingCalculator.decay(latestRating, processingEndTime); + await this.ratingRepository.createRating({ + playerId, + variant, + timeControlClassification, + rating: newRating.value, + deviation: newRating.deviation, + volatility: newRating.volatility, + timestamp: processingEndTime, + }); + } + } + + async getRatingByPlayerId( + playerId: string, + variant: GameVariantType, + timeControl: TimeControlClassification, + ) { + const rating = await this.ratingRepository.getRatingByPlayerId( + playerId, + variant, + timeControl, + ); + if (rating) { + return rating; + } else { + const defaultRating = Rating.default(); + return this.ratingRepository.createRating({ + playerId, + variant, + timeControlClassification: timeControl, + rating: defaultRating.value, + deviation: defaultRating.deviation, + volatility: defaultRating.volatility, + timestamp: new Date(), + }); + } + } +} diff --git a/libs/core-game/src/index.ts b/libs/core-game/src/index.ts index 757c7f91..3d78924c 100644 --- a/libs/core-game/src/index.ts +++ b/libs/core-game/src/index.ts @@ -7,12 +7,18 @@ export * from './lib/Chessboard'; export * from './lib/ChessGame'; export * from './lib/model/ChessGameError'; export * from './lib/model/ChessGameErrorCode'; +export * from './lib/model/ChessGameIn'; export * from './lib/model/ChessGameResult'; export * from './lib/model/ChessGameResultType'; +export * from './lib/model/ClockInstant'; export * from './lib/model/GameDetails'; export * from './lib/model/GameMeta'; export * from './lib/model/GamePlayers'; export * from './lib/model/GameState'; export * from './lib/model/GameStatusType'; +export * from './lib/model/GameVariantType'; export * from './lib/model/PlayerInfo'; +export * from './lib/model/TimeControl'; export * from './lib/model/TimeControlClassification'; +export * from './lib/model/TimeControlIn'; +export * from './lib/rating/model/GameRating'; diff --git a/libs/core-game/src/lib/ChessClock.ts b/libs/core-game/src/lib/ChessClock.ts index d2e01a82..1092d06f 100644 --- a/libs/core-game/src/lib/ChessClock.ts +++ b/libs/core-game/src/lib/ChessClock.ts @@ -43,21 +43,25 @@ export class ChessClock = Maybe> { if (gameState.timeControl.classification !== 'no_clock') { const initialTurn = gameState.initialPosition.turn; const clockSettings = ClockSettings.fromGameState(gameState); - return gameState.movesRecord.reduce>( - (clock, moveRecord, index) => { - return clock.hit( - initialTurn === Color.White - ? index % 2 === 0 - ? Color.White - : Color.Black - : index % 2 === 0 - ? Color.Black - : Color.White, - moveRecord.timestamp, - ); - }, - ChessClock.from(clockSettings), - ); + const clockAfterMoves = gameState.movesRecord.reduce< + ChessClock + >((clock, moveRecord, index) => { + return clock.hit( + initialTurn === Color.White + ? index % 2 === 0 + ? Color.White + : Color.Black + : index % 2 === 0 + ? Color.Black + : Color.White, + moveRecord.timestamp, + ); + }, ChessClock.from(clockSettings)); + if (gameState.result?.timestamp) { + return clockAfterMoves.pause(gameState.result.timestamp); + } else { + return clockAfterMoves; + } } else { return undefined; } diff --git a/libs/core-game/src/lib/ChessGame.ts b/libs/core-game/src/lib/ChessGame.ts index 3b5a83e9..3df1ed60 100644 --- a/libs/core-game/src/lib/ChessGame.ts +++ b/libs/core-game/src/lib/ChessGame.ts @@ -1,12 +1,21 @@ -import { isDefined, Maybe } from '@michess/common-utils'; -import { ChessPosition, Color, Move } from '@michess/core-board'; +import { assertDefined, isDefined, Maybe } from '@michess/common-utils'; +import { + ChessPosition, + Color, + FenParser, + FenStr, + Move, +} from '@michess/core-board'; +import { GameResult } from '@michess/core-rating'; import { ChessGameActions } from './actions/ChessGameActions'; import { GameActionIn } from './actions/model/GameActionIn'; import { GameActionOption } from './actions/model/GameActionOption'; import { Chessboard } from './Chessboard'; import { ChessClock } from './ChessClock'; import { ChessGameError } from './model/ChessGameError'; +import { ChessGameIn } from './model/ChessGameIn'; import { ChessGameResult } from './model/ChessGameResult'; +import { ChessGameResultType } from './model/ChessGameResultType'; import { GameMeta } from './model/GameMeta'; import { GamePlayers } from './model/GamePlayers'; import { GameState } from './model/GameState'; @@ -37,23 +46,24 @@ export type ChessGame = { isPlayerInGame(playerId: string): boolean; hasNewStatus(oldChess: ChessGame): boolean; hasNewActionOptions(oldChess: ChessGame): boolean; + getPlayerGameResult(playerId: string): Maybe; clock: Maybe; + id: string; }; const endGame = ( gameState: GameStateInternal, result: ChessGameResult, ): GameStateInternal => { - const endedAt = new Date(); const resultToSet = gameState.result ?? result; - const pausedClock = gameState.clock?.pause(endedAt.getTime()); + const pausedClock = gameState.clock?.pause(resultToSet.timestamp); return { ...gameState, status: 'ENDED', + players: GamePlayers.from(gameState.players, resultToSet), meta: { ...gameState.meta, - endedAt, }, result: resultToSet, clock: pausedClock, @@ -175,7 +185,7 @@ const evalResult = ( board.position.turn === Color.White ? Color.Black : Color.White, ); } else if (board.isStalemate || board.isInsufficientMaterial) { - return { type: 'draw' }; + return ChessGameResult.toDraw(); } else if (flagResult) { return flagResult; } else { @@ -235,18 +245,14 @@ const fromGameStateInternal = ( const gameResult = evalResult(newBoard, newClock); const shouldStartGame = gameStateInternal.status === 'READY'; - const shouldEndGame = gameResult !== undefined; const newStatus = shouldStartGame ? 'IN_PROGRESS' - : shouldEndGame - ? 'ENDED' - : gameStateInternal.status; + : gameStateInternal.status; const startedAt = gameStateInternal.meta.startedAt ?? new Date(); - const endedAt = shouldEndGame ? new Date() : gameStateInternal.meta.endedAt; - return fromGameStateInternal({ + const updatedGameState: GameStateInternal = { ...gameStateInternal, board: newBoard, status: newStatus, @@ -254,13 +260,21 @@ const fromGameStateInternal = ( meta: { ...gameStateInternal.meta, startedAt, - endedAt, }, result: gameResult, additionalActions: additionalActions.updateBoard(newStatus, newBoard), - }); + }; + + if (gameResult) { + return fromGameStateInternal(endGame(updatedGameState, gameResult)); + } else { + return fromGameStateInternal(updatedGameState); + } }; return { + get id(): string { + return gameStateInternal.meta.id; + }, get clock(): Maybe { return gameStateInternal.clock; }, @@ -307,6 +321,33 @@ const fromGameStateInternal = ( isPlayerInGame: (playerId: string): boolean => { return getPlayerEntry(playerId) !== undefined; }, + getPlayerGameResult: (playerId: string): Maybe => { + const playerEntry = getPlayerEntry(playerId); + if (!playerEntry) { + throw new ChessGameError( + 'not_in_game', + 'Player is not part of the game', + ); + } + const [side] = playerEntry; + if (!gameStateInternal.result) { + throw new ChessGameError('game_not_over', 'Game has not ended yet'); + } + const opponentRating = + gameStateInternal.players[Color.opposite(side)]?.rating; + if (opponentRating) { + return { + opponent: opponentRating, + timestamp: new Date(gameStateInternal.result.timestamp), + value: ChessGameResultType.toScore( + gameStateInternal.result.type, + side, + ), + }; + } else { + return undefined; + } + }, hasNewActionOptions: (oldChess: ChessGame): boolean => { return !gameStateInternal.additionalActions.hasExactOptions( oldChess.getAdditionalActions(), @@ -375,7 +416,59 @@ const fromGameState = (gameState: GameState): ChessGame => { }); }; +const from = (chessGameIn: ChessGameIn): ChessGame => { + const timeControl: TimeControl = (() => { + switch (chessGameIn.timeControl.classification) { + case 'bullet': + case 'blitz': + case 'rapid': { + assertDefined(chessGameIn.timeControl.initialSec); + assertDefined(chessGameIn.timeControl.incrementSec); + return { + classification: chessGameIn.timeControl.classification, + initialSec: chessGameIn.timeControl.initialSec, + incrementSec: chessGameIn.timeControl.incrementSec, + }; + } + case 'correspondence': { + assertDefined(chessGameIn.timeControl.daysPerMove); + return { + classification: 'correspondence', + daysPerMove: chessGameIn.timeControl.daysPerMove, + }; + } + case 'no_clock': + default: + return { + classification: 'no_clock', + }; + } + })(); + return fromGameState({ + id: chessGameIn.id, + createdAt: chessGameIn.createdAt, + updatedAt: chessGameIn.updatedAt, + startedAt: chessGameIn.startedAt, + isPrivate: chessGameIn.isPrivate, + variant: chessGameIn.variant, + players: GamePlayers.from(chessGameIn.players, chessGameIn.result), + status: chessGameIn.status, + result: chessGameIn.result, + resultStr: ChessGameResult.toResultString(chessGameIn.result), + initialPosition: FenParser.toChessPosition( + chessGameIn.initialPosition ?? FenStr.standardInitial(), + ), + + actionRecord: chessGameIn.actionRecord, + movesRecord: chessGameIn.movesRecord, + timeControl, + }); +}; + export const ChessGame = { - fromChessPosition, + /** For testing */ fromGameState, + + fromChessPosition, + from, }; diff --git a/libs/core-game/src/lib/__tests__/ChessGame.spec.ts b/libs/core-game/src/lib/__tests__/ChessGame.spec.ts index 07888a6c..2e527254 100644 --- a/libs/core-game/src/lib/__tests__/ChessGame.spec.ts +++ b/libs/core-game/src/lib/__tests__/ChessGame.spec.ts @@ -141,6 +141,7 @@ describe('ChessGame', () => { const chessGame = ChessGame.fromChessPosition(position).setResult({ type: 'white_win', + timestamp: Date.now(), }); const actions = chessGame.getAdditionalActions(); diff --git a/libs/core-game/src/lib/model/ChessGameErrorCode.ts b/libs/core-game/src/lib/model/ChessGameErrorCode.ts index fe93df5a..14a82fa6 100644 --- a/libs/core-game/src/lib/model/ChessGameErrorCode.ts +++ b/libs/core-game/src/lib/model/ChessGameErrorCode.ts @@ -2,5 +2,6 @@ export type ChessGameErrorCode = | 'game_is_over' | 'not_your_turn' | 'player_flagged' + | 'game_not_over' | 'not_in_game' | 'action_not_available'; diff --git a/libs/core-game/src/lib/model/ChessGameIn.ts b/libs/core-game/src/lib/model/ChessGameIn.ts new file mode 100644 index 00000000..626ba7c8 --- /dev/null +++ b/libs/core-game/src/lib/model/ChessGameIn.ts @@ -0,0 +1,27 @@ +import { Maybe } from '@michess/common-utils'; +import { FenStr, MoveRecord } from '@michess/core-board'; +import { RatingSnapshot } from '@michess/core-rating'; +import { GameAction } from '../actions/model/GameAction'; +import { ChessGameResult } from './ChessGameResult'; +import { GameMeta } from './GameMeta'; +import { GameStatusType } from './GameStatusType'; +import { TimeControlIn } from './TimeControlIn'; + +type Player = { + id: string; + name: string; + rating?: RatingSnapshot; +}; + +export type ChessGameIn = GameMeta & { + players: { + white: Maybe; + black: Maybe; + }; + status: GameStatusType; + result: Maybe; + initialPosition: Maybe; + actionRecord: GameAction[]; + movesRecord: MoveRecord[]; + timeControl: TimeControlIn; +}; diff --git a/libs/core-game/src/lib/model/ChessGameResult.ts b/libs/core-game/src/lib/model/ChessGameResult.ts index 1900bc55..73ec7c44 100644 --- a/libs/core-game/src/lib/model/ChessGameResult.ts +++ b/libs/core-game/src/lib/model/ChessGameResult.ts @@ -5,6 +5,7 @@ import { ChessGameResultType } from './ChessGameResultType'; import { ClockInstant } from './ClockInstant'; export type ChessGameResult = { + timestamp: number; type: ChessGameResultType; // reason?: 'resignation' | 'stalemate' | 'threefold_repetition' | 'fifty_moves'; }; @@ -18,10 +19,12 @@ export const ChessGameResult = { case 'accept_draw': return { type: 'draw', + timestamp: Date.now(), }; case 'resign': return { type: turn === Color.White ? 'white_win' : 'black_win', + timestamp: Date.now(), }; default: return undefined; @@ -46,16 +49,25 @@ export const ChessGameResult = { toCheckmate: (winner: Color): ChessGameResult => { return { type: winner === Color.White ? 'white_win' : 'black_win', + timestamp: Date.now(), + }; + }, + toDraw: (): ChessGameResult => { + return { + type: 'draw', + timestamp: Date.now(), }; }, toFlag: (instant: ClockInstant): Maybe => { if (instant.blackMs === 0) { return { type: 'white_win', + timestamp: Date.now(), }; } else if (instant.whiteMs === 0) { return { type: 'black_win', + timestamp: Date.now(), }; } else { return undefined; diff --git a/libs/core-game/src/lib/model/ChessGameResultType.ts b/libs/core-game/src/lib/model/ChessGameResultType.ts index 4f0c9eed..1736fd49 100644 --- a/libs/core-game/src/lib/model/ChessGameResultType.ts +++ b/libs/core-game/src/lib/model/ChessGameResultType.ts @@ -1 +1,17 @@ +import { Color } from '@michess/core-board'; + export type ChessGameResultType = 'white_win' | 'black_win' | 'draw'; + +const toScore = (resultType: ChessGameResultType, color: Color): number => { + switch (resultType) { + case 'white_win': + return color === 'white' ? 1 : 0; + case 'black_win': + return color === 'black' ? 1 : 0; + case 'draw': + return 0.5; + } +}; +export const ChessGameResultType = { + toScore, +}; diff --git a/libs/core-game/src/lib/model/GameMeta.ts b/libs/core-game/src/lib/model/GameMeta.ts index 0c8ee85d..8e6a48de 100644 --- a/libs/core-game/src/lib/model/GameMeta.ts +++ b/libs/core-game/src/lib/model/GameMeta.ts @@ -1,11 +1,11 @@ import { Maybe } from '@michess/common-utils'; +import { GameVariantType } from './GameVariantType'; export type GameMeta = { id: string; - variant: string; + variant: GameVariantType; isPrivate: boolean; startedAt: Maybe; - endedAt: Maybe; createdAt: Date; updatedAt: Date; }; diff --git a/libs/core-game/src/lib/model/GamePlayers.ts b/libs/core-game/src/lib/model/GamePlayers.ts index 8aa99e67..f8252745 100644 --- a/libs/core-game/src/lib/model/GamePlayers.ts +++ b/libs/core-game/src/lib/model/GamePlayers.ts @@ -1,5 +1,11 @@ import { Maybe } from '@michess/common-utils'; import { Color } from '@michess/core-board'; +import { + GameResult, + RatingCalculator, + RatingSnapshot, +} from '@michess/core-rating'; +import { ChessGameResult } from './ChessGameResult'; import { PlayerInfo } from './PlayerInfo'; export type GamePlayers = { @@ -7,7 +13,118 @@ export type GamePlayers = { black: Maybe; }; +const getGameResult = ( + result: ChessGameResult, + color: Color, + byColor: { + white: RatingSnapshot; + black: RatingSnapshot; + }, +): GameResult => { + const opponent = byColor[Color.opposite(color)]; + const timestamp = new Date(result.timestamp); + + const score = (() => { + switch (result.type) { + case 'white_win': + return color === Color.White ? 1 : 0; + case 'black_win': + return color === Color.Black ? 1 : 0; + case 'draw': + return 0.5; + } + })(); + + return { opponent, value: score, timestamp }; +}; + +const fromResult = ( + byColor: { + white: { id: string; name: string; rating: RatingSnapshot }; + black: { id: string; name: string; rating: RatingSnapshot }; + }, + result: ChessGameResult, +): GamePlayers => { + const ratingsByColor = { + white: byColor.white.rating, + black: byColor.black.rating, + }; + + const whiteGameResult = getGameResult(result, Color.White, ratingsByColor); + const blackGameResult = getGameResult(result, Color.Black, ratingsByColor); + + return { + white: { + id: byColor.white.id, + name: byColor.white.name, + rating: byColor.white.rating, + ratingDiff: RatingCalculator.compute( + byColor.white.rating, + whiteGameResult, + ).diff, + }, + black: { + id: byColor.black.id, + name: byColor.black.name, + rating: byColor.black.rating, + ratingDiff: RatingCalculator.compute( + byColor.black.rating, + blackGameResult, + ).diff, + }, + }; +}; + +const from = ( + byColor: { + white?: { id: string; name: string; rating?: RatingSnapshot }; + black?: { id: string; name: string; rating?: RatingSnapshot }; + }, + result?: ChessGameResult, +): GamePlayers => { + const { white, black } = byColor; + + // If we have a game result and both players have ratings, calculate rating diffs + if (result && white?.rating && black?.rating) { + const whiteWithRating = { + id: white.id, + name: white.name, + rating: white.rating, + }; + const blackWithRating = { + id: black.id, + name: black.name, + rating: black.rating, + }; + + return fromResult( + { white: whiteWithRating, black: blackWithRating }, + result, + ); + } + + return { + white: white + ? { + id: white.id, + name: white.name, + rating: white.rating, + } + : undefined, + black: black + ? { + id: black.id, + name: black.name, + rating: black.rating, + } + : undefined, + }; +}; + export const GamePlayers = { + from, + fromResult, + getColor: (players: GamePlayers, playerId: string): Maybe => { if (players.white?.id === playerId) { return Color.White; diff --git a/libs/core-game/src/lib/model/GameState.ts b/libs/core-game/src/lib/model/GameState.ts index 55991772..e8736aa4 100644 --- a/libs/core-game/src/lib/model/GameState.ts +++ b/libs/core-game/src/lib/model/GameState.ts @@ -24,7 +24,6 @@ export const GameState = { variant: gameState.variant, isPrivate: gameState.isPrivate, startedAt: gameState.startedAt, - endedAt: gameState.endedAt, createdAt: gameState.createdAt, updatedAt: gameState.updatedAt, }), @@ -35,7 +34,6 @@ export const GameState = { timeControl: TimeControl.noClock(), isPrivate: false, startedAt: undefined, - endedAt: undefined, createdAt: new Date(), updatedAt: new Date(), players: { diff --git a/libs/core-game/src/lib/model/GameVariantType.ts b/libs/core-game/src/lib/model/GameVariantType.ts new file mode 100644 index 00000000..90af86b0 --- /dev/null +++ b/libs/core-game/src/lib/model/GameVariantType.ts @@ -0,0 +1 @@ +export type GameVariantType = 'standard'; diff --git a/libs/core-game/src/lib/model/PlayerInfo.ts b/libs/core-game/src/lib/model/PlayerInfo.ts index 359d2a9c..b9de7a59 100644 --- a/libs/core-game/src/lib/model/PlayerInfo.ts +++ b/libs/core-game/src/lib/model/PlayerInfo.ts @@ -1,4 +1,8 @@ +import { RatingSnapshot } from '@michess/core-rating'; + export type PlayerInfo = { id: string; + rating?: RatingSnapshot; + ratingDiff?: number; name: string; }; diff --git a/libs/core-game/src/lib/model/TimeControlIn.ts b/libs/core-game/src/lib/model/TimeControlIn.ts new file mode 100644 index 00000000..edea0e38 --- /dev/null +++ b/libs/core-game/src/lib/model/TimeControlIn.ts @@ -0,0 +1,6 @@ +export type TimeControlIn = { + classification: 'bullet' | 'blitz' | 'rapid' | 'correspondence' | 'no_clock'; + initialSec?: number; + incrementSec?: number; + daysPerMove?: number; +}; diff --git a/libs/core-game/src/lib/model/__mocks__/GameState.mock.ts b/libs/core-game/src/lib/model/__mocks__/GameState.mock.ts index ef64d444..d474f729 100644 --- a/libs/core-game/src/lib/model/__mocks__/GameState.mock.ts +++ b/libs/core-game/src/lib/model/__mocks__/GameState.mock.ts @@ -18,7 +18,6 @@ const gameStateMock: GameState = { createdAt: new Date(), updatedAt: new Date(), startedAt: undefined, - endedAt: undefined, variant: 'standard', status: 'IN_PROGRESS', actionRecord: [], diff --git a/libs/core-game/src/lib/rating/model/GameRating.ts b/libs/core-game/src/lib/rating/model/GameRating.ts new file mode 100644 index 00000000..b63c501b --- /dev/null +++ b/libs/core-game/src/lib/rating/model/GameRating.ts @@ -0,0 +1,11 @@ +import { Maybe } from '@michess/common-utils'; +import { RatingSnapshot } from '@michess/core-rating'; +import { GameVariantType } from '../../model/GameVariantType'; +import { TimeControlClassification } from '../../model/TimeControlClassification'; + +export type GameRating = RatingSnapshot & { + playerId: string; + gameId: Maybe; + timeControlClassification: TimeControlClassification; + variant: GameVariantType; +}; diff --git a/libs/core-rating/README.md b/libs/core-rating/README.md new file mode 100644 index 00000000..f21263b1 --- /dev/null +++ b/libs/core-rating/README.md @@ -0,0 +1,11 @@ +# core-rating + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build core-rating` to build the library. + +## Running unit tests + +Run `nx test core-rating` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/core-rating/eslint.config.cjs b/libs/core-rating/eslint.config.cjs new file mode 100644 index 00000000..1940c2b1 --- /dev/null +++ b/libs/core-rating/eslint.config.cjs @@ -0,0 +1,19 @@ +const baseConfig = require('../../.eslintrc.json'); + +module.exports = [ + ...baseConfig, + { + files: ['**/*.json'], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'], + }, + ], + }, + languageOptions: { + parser: require('jsonc-eslint-parser'), + }, + }, +]; diff --git a/libs/core-rating/jest.config.ts b/libs/core-rating/jest.config.ts new file mode 100644 index 00000000..fa812653 --- /dev/null +++ b/libs/core-rating/jest.config.ts @@ -0,0 +1,10 @@ +export default { + displayName: 'core-rating', + preset: '../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/libs/core-rating', +}; diff --git a/libs/core-rating/package.json b/libs/core-rating/package.json new file mode 100644 index 00000000..21cc0db0 --- /dev/null +++ b/libs/core-rating/package.json @@ -0,0 +1,11 @@ +{ + "name": "core-rating", + "version": "0.0.1", + "private": true, + "type": "commonjs", + "main": "./src/index.js", + "types": "./src/index.d.ts", + "dependencies": { + "tslib": "^2.3.0" + } +} diff --git a/libs/core-rating/project.json b/libs/core-rating/project.json new file mode 100644 index 00000000..b64c3610 --- /dev/null +++ b/libs/core-rating/project.json @@ -0,0 +1,19 @@ +{ + "name": "core-rating", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/core-rating/src", + "projectType": "library", + "tags": [], + "targets": { + "lint": { + "executor": "@nx/eslint:lint" + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/core-rating/jest.config.ts" + } + } + } +} diff --git a/libs/core-rating/src/index.ts b/libs/core-rating/src/index.ts new file mode 100644 index 00000000..84b8ea09 --- /dev/null +++ b/libs/core-rating/src/index.ts @@ -0,0 +1,4 @@ +export * from './lib/model/GameResult'; +export * from './lib/model/Rating'; +export * from './lib/model/RatingSnapshot'; +export * from './lib/RatingCalculator'; diff --git a/libs/core-rating/src/lib/GlickoTwo.ts b/libs/core-rating/src/lib/GlickoTwo.ts new file mode 100644 index 00000000..59539445 --- /dev/null +++ b/libs/core-rating/src/lib/GlickoTwo.ts @@ -0,0 +1,186 @@ +import { Maybe } from '@michess/common-utils'; +import { Rating } from './model/Rating'; +import { Score } from './model/Score'; + +const TAO = 0.5; +const GLICKO_SCALE_DENOMINATOR = 173.7178; + +const CONVERGENCE_EPSILON = 0.000001; + +const convertRatingToGlickoScale = (rating: number) => { + return (rating - 1500) / GLICKO_SCALE_DENOMINATOR; +}; +const convertDeviationToGlickoScale = (deviation: number) => { + return deviation / GLICKO_SCALE_DENOMINATOR; +}; +const convertToGlickoScale = (player: Rating): { phi: number; mu: number } => { + return { + mu: convertRatingToGlickoScale(player.value), + phi: convertDeviationToGlickoScale(player.deviation), + }; +}; + +const convertRatingFromGlickoScale = (glickoRating: number) => { + return glickoRating * GLICKO_SCALE_DENOMINATOR + 1500; +}; +const convertDeviationFromGlickoScale = (glickoDeviation: number) => { + return glickoDeviation * GLICKO_SCALE_DENOMINATOR; +}; +const convertFromGlickoScale = ( + glickoRating: number, + glickoDeviation: number, +): { rating: number; deviation: number } => { + return { + rating: convertRatingFromGlickoScale(glickoRating), + deviation: convertDeviationFromGlickoScale(glickoDeviation), + }; +}; + +const g = (glickoDeviation: number) => { + return ( + 1 / Math.sqrt(1 + (3 * Math.pow(glickoDeviation, 2)) / Math.pow(Math.PI, 2)) + ); +}; + +const E = ( + playerGlickoRating: number, + opponentGlickoRating: number, + opponentGlickoDeviation: number, +) => { + return ( + 1 / + (1 + + Math.exp( + -g(opponentGlickoDeviation) * + (playerGlickoRating - opponentGlickoRating), + )) + ); +}; + +const f = ( + delta: number, + phi: number, + upsilon: number, + sigma: number, +): ((x: number) => number) => { + const a = Math.log(Math.pow(sigma, 2)); + return (x: number) => { + const ex = Math.exp(x); + const numerator = + ex * (Math.pow(delta, 2) - Math.pow(phi, 2) - upsilon - ex); + const denominator = 2 * Math.pow(Math.pow(phi, 2) + upsilon + ex, 2); + return numerator / denominator - (x - a) / Math.pow(TAO, 2); + }; +}; + +const updateRatingDeviation = ( + phi: number, + sigma: number, + elapsedPeriodsSinceLastUpdate: number, +): number => { + return Math.sqrt( + Math.pow(phi, 2) + elapsedPeriodsSinceLastUpdate * Math.pow(sigma, 2), + ); +}; + +const algorithm = ( + rating: Maybe, + gameResults: Score[], + elapsedPeriodsSinceLastUpdate = 1, +): Rating => { + const actualRating = rating || Rating.default(); + // Step 2: Convert to Glicko-2 scale + const { phi, mu } = convertToGlickoScale(actualRating); + const sigma = actualRating.volatility; + + if (gameResults.length === 0) { + return { + value: actualRating.value, + deviation: convertDeviationFromGlickoScale( + updateRatingDeviation(phi, sigma, elapsedPeriodsSinceLastUpdate), + ), + volatility: actualRating.volatility, + }; + } + + // Step 3: Compute the estimated variance + const upsilon = + 1 / + gameResults.reduce((sum, gameResult) => { + const opponentPlayer = convertToGlickoScale(gameResult.opponent); + const EValue = E(mu, opponentPlayer.mu, opponentPlayer.phi); + return sum + Math.pow(g(opponentPlayer.phi), 2) * EValue * (1 - EValue); + }, 0); + + // Step 4: Compute the estimated rating improvement + const gameOutcomeRatingImprovementFactor = gameResults.reduce( + (sum, gameResult) => { + const opponentPlayer = convertToGlickoScale(gameResult.opponent); + const EValue = E(mu, opponentPlayer.mu, opponentPlayer.phi); + return sum + g(opponentPlayer.phi) * (gameResult.value - EValue); + }, + 0, + ); + const delta = upsilon * gameOutcomeRatingImprovementFactor; + + // Step 5: Determine new volatility + const fFunction = f(delta, phi, upsilon, sigma); + const deltaSquared = Math.pow(delta, 2); + const phiSquared = Math.pow(phi, 2); + let alpha = Math.log(Math.pow(sigma, 2)); + + const iterateKappa = (kappa: number): number => { + if (fFunction(alpha - kappa * TAO) < 0) { + return iterateKappa(kappa + 1); + } else { + return kappa; + } + }; + + let beta = + deltaSquared > phiSquared + upsilon + ? Math.log(deltaSquared - phiSquared - upsilon) + : alpha - iterateKappa(1) * TAO; + let fAlpha = fFunction(alpha); + let fBeta = fFunction(beta); + + while (Math.abs(beta - alpha) > CONVERGENCE_EPSILON) { + const c = alpha + ((alpha - beta) * fAlpha) / (fBeta - fAlpha); + const fC = fFunction(c); + if (fC * fBeta < 0) { + alpha = beta; + fAlpha = fBeta; + } else { + fAlpha = fAlpha / 2; + } + beta = c; + fBeta = fC; + } + const newSigma = Math.exp(alpha / 2); + + // Step 6: Update deviation to new pre-rating deviation + const phiStar = updateRatingDeviation( + phi, + newSigma, + elapsedPeriodsSinceLastUpdate, + ); + + // Step 7: Update rating and deviation + const newPhi = 1 / Math.sqrt(1 / Math.pow(phiStar, 2) + 1 / upsilon); + const newMu = mu + Math.pow(newPhi, 2) * gameOutcomeRatingImprovementFactor; + + const { rating: newRating, deviation: newDeviation } = convertFromGlickoScale( + newMu, + newPhi, + ); + + return { + value: newRating, + deviation: newDeviation, + volatility: newSigma, + }; +}; + +export const GlickoTwo = { + algorithm, +}; diff --git a/libs/core-rating/src/lib/RatingCalculator.ts b/libs/core-rating/src/lib/RatingCalculator.ts new file mode 100644 index 00000000..3ded8f05 --- /dev/null +++ b/libs/core-rating/src/lib/RatingCalculator.ts @@ -0,0 +1,61 @@ +import { GlickoTwo } from './GlickoTwo'; +import { GameResult } from './model/GameResult'; +import { RatingSnapshot } from './model/RatingSnapshot'; + +const compute = ( + ratingSnapshot: RatingSnapshot, + result: GameResult, +): { newRating: RatingSnapshot; diff: number } => { + const elapsedPeriodsSinceLastUpdate = + (Date.now() - ratingSnapshot.timestamp.getTime()) / + (1000 * 60 * 60 * 24 * 5); // 1 period = 5 days + const updatedRating = GlickoTwo.algorithm( + { + value: ratingSnapshot.value, + deviation: ratingSnapshot.deviation, + volatility: ratingSnapshot.volatility, + }, + [result], + elapsedPeriodsSinceLastUpdate, + ); + return { + newRating: { + id: 0, + timestamp: result.timestamp, + value: updatedRating.value, + deviation: updatedRating.deviation, + volatility: updatedRating.volatility, + }, + diff: Math.round(updatedRating.value - ratingSnapshot.value), + }; +}; + +const decay = ( + ratingSnapshot: RatingSnapshot, + currentDate: Date, +): RatingSnapshot => { + const elapsedPeriodsSinceLastUpdate = + (Date.now() - ratingSnapshot.timestamp.getTime()) / + (1000 * 60 * 60 * 24 * 5); // 1 period = 5 days + const updatedRating = GlickoTwo.algorithm( + { + value: ratingSnapshot.value, + deviation: ratingSnapshot.deviation, + volatility: ratingSnapshot.volatility, + }, + [], + elapsedPeriodsSinceLastUpdate, + ); + return { + id: ratingSnapshot.id, + timestamp: currentDate, + value: updatedRating.value, + deviation: updatedRating.deviation, + volatility: updatedRating.volatility, + }; +}; + +export const RatingCalculator = { + compute, + decay, +}; diff --git a/libs/core-rating/src/lib/__tests__/GlickoTwo.spec.ts b/libs/core-rating/src/lib/__tests__/GlickoTwo.spec.ts new file mode 100644 index 00000000..f668a22e --- /dev/null +++ b/libs/core-rating/src/lib/__tests__/GlickoTwo.spec.ts @@ -0,0 +1,43 @@ +import { GlickoTwo } from '../GlickoTwo'; +import { Rating } from '../model/Rating'; + +describe('GlickoTwo', () => { + it('should handle the example calculation from the report', () => { + const player: Rating = { + value: 1500, + deviation: 200, + volatility: 0.06, + }; + const result = GlickoTwo.algorithm(player, [ + { opponent: { value: 1400, deviation: 30, volatility: 0.06 }, value: 1 }, + { + opponent: { value: 1550, deviation: 100, volatility: 0.06 }, + value: 0, + }, + { + opponent: { value: 1700, deviation: 300, volatility: 0.06 }, + value: 0, + }, + ]); + + // Paper results are 1464.06, but it is not correct due to + // rounding in the intermediate steps: + // https://github.com/andriykuba/scala-glicko2?tab=readme-ov-file#precision + expect(result.value.toFixed(2)).toBe('1464.05'); + expect(result.deviation.toFixed(2)).toBe('151.52'); + expect(result.volatility).toBeCloseTo(0.05999, 4); + }); + + it('should handle a player with no games played', () => { + const player: Rating = { + value: 1500, + deviation: 60, + volatility: 0.06, + }; + const result = GlickoTwo.algorithm(player, [], 50); + + expect(result.value).toBe(1500); + expect(result.deviation).toBeCloseTo(95, 1); + expect(result.volatility).toBe(0.06); + }); +}); diff --git a/libs/core-rating/src/lib/model/GameResult.ts b/libs/core-rating/src/lib/model/GameResult.ts new file mode 100644 index 00000000..756f906e --- /dev/null +++ b/libs/core-rating/src/lib/model/GameResult.ts @@ -0,0 +1,5 @@ +import { Score } from './Score'; + +export type GameResult = Score & { + timestamp: Date; +}; diff --git a/libs/core-rating/src/lib/model/Rating.ts b/libs/core-rating/src/lib/model/Rating.ts new file mode 100644 index 00000000..b6b58cb5 --- /dev/null +++ b/libs/core-rating/src/lib/model/Rating.ts @@ -0,0 +1,14 @@ +export type Rating = { + value: number; + deviation: number; + volatility: number; +}; + +const DEFAULT_RATING: Rating = { + value: 1500, + deviation: 350, + volatility: 0.06, +}; +export const Rating = { + default: (): Rating => DEFAULT_RATING, +}; diff --git a/libs/core-rating/src/lib/model/RatingSnapshot.ts b/libs/core-rating/src/lib/model/RatingSnapshot.ts new file mode 100644 index 00000000..a6ffb775 --- /dev/null +++ b/libs/core-rating/src/lib/model/RatingSnapshot.ts @@ -0,0 +1,6 @@ +import { Rating } from './Rating'; + +export type RatingSnapshot = Rating & { + id: number; + timestamp: Date; +}; diff --git a/libs/core-rating/src/lib/model/Score.ts b/libs/core-rating/src/lib/model/Score.ts new file mode 100644 index 00000000..b5380876 --- /dev/null +++ b/libs/core-rating/src/lib/model/Score.ts @@ -0,0 +1,6 @@ +import { Rating } from './Rating'; + +export type Score = { + opponent: Rating; + value: number; // 1 = win, 0.5 = draw, 0 = loss +}; diff --git a/libs/core-rating/tsconfig.json b/libs/core-rating/tsconfig.json new file mode 100644 index 00000000..19b9eece --- /dev/null +++ b/libs/core-rating/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs" + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/core-rating/tsconfig.lib.json b/libs/core-rating/tsconfig.lib.json new file mode 100644 index 00000000..33eca2c2 --- /dev/null +++ b/libs/core-rating/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/core-rating/tsconfig.spec.json b/libs/core-rating/tsconfig.spec.json new file mode 100644 index 00000000..0d3c604e --- /dev/null +++ b/libs/core-rating/tsconfig.spec.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/infra-db/src/generated/migrations/0002_bored_gargoyle.sql b/libs/infra-db/src/generated/migrations/0002_bored_gargoyle.sql new file mode 100644 index 00000000..8b854b9e --- /dev/null +++ b/libs/infra-db/src/generated/migrations/0002_bored_gargoyle.sql @@ -0,0 +1,59 @@ +CREATE TABLE + "ratings" ( + "id" serial PRIMARY KEY NOT NULL, + "player_id" text NOT NULL, + "game_id" uuid, + "variant" "variant" NOT NULL, + "time_control_classification" time_control_classification NOT NULL, + "rating" real NOT NULL, + "deviation" real NOT NULL, + "volatility" real NOT NULL, + "timestamp" timestamp DEFAULT now () NOT NULL, + "created_at" timestamp DEFAULT now () NOT NULL + ); + +--> statement-breakpoint +ALTER TABLE "games" +ADD COLUMN "white_rating_id" integer; + +--> statement-breakpoint +ALTER TABLE "games" +ADD COLUMN "black_rating_id" integer; + +--> statement-breakpoint +ALTER TABLE "games" ADD CONSTRAINT "game_variant_tc_unique" UNIQUE ( + "game_id", + "variant", + "time_control_classification" +); + +--> statement-breakpoint +ALTER TABLE "ratings" ADD CONSTRAINT "ratings_player_id_users_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."users" ("id") ON DELETE cascade ON UPDATE no action; + +--> statement-breakpoint +ALTER TABLE "ratings" ADD CONSTRAINT "ratings_game_id_games_game_id_fk" FOREIGN KEY ("game_id") REFERENCES "public"."games" ("game_id") ON DELETE set null ON UPDATE no action; + +--> statement-breakpoint +ALTER TABLE "ratings" ADD CONSTRAINT "ratings_game_variant_tc_fk" FOREIGN KEY ( + "game_id", + "variant", + "time_control_classification" +) REFERENCES "public"."games" ( + "game_id", + "variant", + "time_control_classification" +) ON DELETE no action ON UPDATE no action; + +--> statement-breakpoint +CREATE INDEX "idx_ratings_player_variant_tc_created" ON "ratings" USING btree ( + "player_id", + "variant", + "time_control_classification", + "timestamp" DESC NULLS LAST +); + +--> statement-breakpoint +ALTER TABLE "games" ADD CONSTRAINT "games_white_rating_id_ratings_id_fk" FOREIGN KEY ("white_rating_id") REFERENCES "public"."ratings" ("id") ON DELETE set null ON UPDATE no action; + +--> statement-breakpoint +ALTER TABLE "games" ADD CONSTRAINT "games_black_rating_id_ratings_id_fk" FOREIGN KEY ("black_rating_id") REFERENCES "public"."ratings" ("id") ON DELETE set null ON UPDATE no action; \ No newline at end of file diff --git a/libs/infra-db/src/generated/migrations/meta/0002_snapshot.json b/libs/infra-db/src/generated/migrations/meta/0002_snapshot.json new file mode 100644 index 00000000..b780f1f6 --- /dev/null +++ b/libs/infra-db/src/generated/migrations/meta/0002_snapshot.json @@ -0,0 +1,819 @@ +{ + "id": "03b319a6-4160-4703-8068-36d0cf5eb0bf", + "prevId": "ad053b9e-f9bd-47f1-95d6-cbe91a4254c8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.actions": { + "name": "actions", + "schema": "", + "columns": { + "action_id": { + "name": "action_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "color", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "move_number": { + "name": "move_number", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "action_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "actions_game_id_games_game_id_fk": { + "name": "actions_game_id_games_game_id_fk", + "tableFrom": "actions", + "tableTo": "games", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "game_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.games": { + "name": "games", + "schema": "", + "columns": { + "game_id": { + "name": "game_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "variant": { + "name": "variant", + "type": "variant", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'standard'" + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "white_player_id": { + "name": "white_player_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "black_player_id": { + "name": "black_player_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "white_rating_id": { + "name": "white_rating_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "black_rating_id": { + "name": "black_rating_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "time_control_classification": { + "name": "time_control_classification", + "type": "time_control_classification", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'no_clock'" + }, + "time_control": { + "name": "time_control", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "game_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'empty'" + }, + "result": { + "name": "result", + "type": "result", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'0-0'" + }, + "result_reason": { + "name": "result_reason", + "type": "result_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "games_white_player_id_users_id_fk": { + "name": "games_white_player_id_users_id_fk", + "tableFrom": "games", + "tableTo": "users", + "columnsFrom": [ + "white_player_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "games_black_player_id_users_id_fk": { + "name": "games_black_player_id_users_id_fk", + "tableFrom": "games", + "tableTo": "users", + "columnsFrom": [ + "black_player_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "games_white_rating_id_ratings_id_fk": { + "name": "games_white_rating_id_ratings_id_fk", + "tableFrom": "games", + "tableTo": "ratings", + "columnsFrom": [ + "white_rating_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "games_black_rating_id_ratings_id_fk": { + "name": "games_black_rating_id_ratings_id_fk", + "tableFrom": "games", + "tableTo": "ratings", + "columnsFrom": [ + "black_rating_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "game_variant_tc_unique": { + "name": "game_variant_tc_unique", + "nullsNotDistinct": false, + "columns": [ + "game_id", + "variant", + "time_control_classification" + ] + } + }, + "policies": {}, + "checkConstraints": { + "time_control_standard_check": { + "name": "time_control_standard_check", + "value": "(\n \"games\".\"time_control_classification\" NOT IN ('bullet', 'blitz', 'rapid')\n OR (\n \"games\".\"time_control\" IS NOT NULL\n AND \"games\".\"time_control\"->>'initial' IS NOT NULL\n AND \"games\".\"time_control\"->>'increment' IS NOT NULL\n )\n )" + }, + "time_control_correspondence_check": { + "name": "time_control_correspondence_check", + "value": "(\n \"games\".\"time_control_classification\" != 'correspondence'\n OR (\n \"games\".\"time_control\" IS NOT NULL\n AND \"games\".\"time_control\"->>'daysPerMove' IS NOT NULL\n )\n )" + }, + "time_control_no_clock_check": { + "name": "time_control_no_clock_check", + "value": "(\n \"games\".\"time_control_classification\" != 'no_clock'\n OR \"games\".\"time_control\" IS NULL\n )" + } + }, + "isRLSEnabled": false + }, + "public.moves": { + "name": "moves", + "schema": "", + "columns": { + "move_id": { + "name": "move_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "uci": { + "name": "uci", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "moved_at": { + "name": "moved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "moves_game_id_games_game_id_fk": { + "name": "moves_game_id_games_game_id_fk", + "tableFrom": "moves", + "tableTo": "games", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "game_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ratings": { + "name": "ratings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "variant": { + "name": "variant", + "type": "variant", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "time_control_classification": { + "name": "time_control_classification", + "type": "time_control_classification", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "deviation": { + "name": "deviation", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "volatility": { + "name": "volatility", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_ratings_player_variant_tc_created": { + "name": "idx_ratings_player_variant_tc_created", + "columns": [ + { + "expression": "player_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "variant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "time_control_classification", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ratings_player_id_users_id_fk": { + "name": "ratings_player_id_users_id_fk", + "tableFrom": "ratings", + "tableTo": "users", + "columnsFrom": [ + "player_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ratings_game_id_games_game_id_fk": { + "name": "ratings_game_id_games_game_id_fk", + "tableFrom": "ratings", + "tableTo": "games", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "game_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ratings_game_variant_tc_fk": { + "name": "ratings_game_variant_tc_fk", + "tableFrom": "ratings", + "tableTo": "games", + "columnsFrom": [ + "game_id", + "variant", + "time_control_classification" + ], + "columnsTo": [ + "game_id", + "variant", + "time_control_classification" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.action_type": { + "name": "action_type", + "schema": "public", + "values": [ + "accept_draw", + "offer_draw", + "resign" + ] + }, + "public.color": { + "name": "color", + "schema": "public", + "values": [ + "white", + "black" + ] + }, + "public.game_status": { + "name": "game_status", + "schema": "public", + "values": [ + "empty", + "waiting", + "ready", + "in-progress", + "end" + ] + }, + "public.result": { + "name": "result", + "schema": "public", + "values": [ + "1-0", + "0-1", + "1/2-1/2", + "0-0" + ] + }, + "public.result_reason": { + "name": "result_reason", + "schema": "public", + "values": [ + "checkmate", + "stalemate", + "timeout", + "resignation", + "abandoned" + ] + }, + "public.time_control_classification": { + "name": "time_control_classification", + "schema": "public", + "values": [ + "correspondence", + "blitz", + "bullet", + "rapid", + "no_clock" + ] + }, + "public.variant": { + "name": "variant", + "schema": "public", + "values": [ + "standard" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/libs/infra-db/src/generated/migrations/meta/_journal.json b/libs/infra-db/src/generated/migrations/meta/_journal.json index f436280e..6524b060 100644 --- a/libs/infra-db/src/generated/migrations/meta/_journal.json +++ b/libs/infra-db/src/generated/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1761126887772, "tag": "0001_default_username_trigger", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1762431195086, + "tag": "0002_bored_gargoyle", + "breakpoints": true } ] } \ No newline at end of file diff --git a/libs/infra-db/src/index.ts b/libs/infra-db/src/index.ts index 81fd732d..d1a19796 100644 --- a/libs/infra-db/src/index.ts +++ b/libs/infra-db/src/index.ts @@ -9,5 +9,6 @@ export * from './lib/repository/ActionRepository'; export * from './lib/repository/CacheRepository'; export * from './lib/repository/GameRepository'; export * from './lib/repository/MoveRepository'; +export * from './lib/repository/RatingRepository'; export * from './lib/repository/UserRepository'; export * as schema from './lib/schema'; diff --git a/libs/infra-db/src/lib/Repositories.ts b/libs/infra-db/src/lib/Repositories.ts index 1605fb51..c21fc633 100644 --- a/libs/infra-db/src/lib/Repositories.ts +++ b/libs/infra-db/src/lib/Repositories.ts @@ -4,6 +4,7 @@ import { ActionRepository } from './repository/ActionRepository'; import { CacheRepository } from './repository/CacheRepository'; import { GameRepository } from './repository/GameRepository'; import { MoveRepository } from './repository/MoveRepository'; +import { RatingRepository } from './repository/RatingRepository'; import { UserRepository } from './repository/UserRepository'; export type Repositories = { @@ -12,6 +13,7 @@ export type Repositories = { move: MoveRepository; action: ActionRepository; cache: CacheRepository; + rating: RatingRepository; }; const from = (sql: Sql, redis: Redis): Repositories => { @@ -21,6 +23,7 @@ const from = (sql: Sql, redis: Redis): Repositories => { move: new MoveRepository(sql), action: new ActionRepository(sql), cache: new CacheRepository(redis), + rating: new RatingRepository(sql), }; }; diff --git a/libs/infra-db/src/lib/model/InsertRating.ts b/libs/infra-db/src/lib/model/InsertRating.ts new file mode 100644 index 00000000..1303f1a6 --- /dev/null +++ b/libs/infra-db/src/lib/model/InsertRating.ts @@ -0,0 +1,3 @@ +import { ratings } from '../schema/ratings'; + +export type InsertRating = typeof ratings.$inferInsert; diff --git a/libs/infra-db/src/lib/model/SelectGameWithRelations.ts b/libs/infra-db/src/lib/model/SelectGameWithRelations.ts index 2a28e00d..79d33a5d 100644 --- a/libs/infra-db/src/lib/model/SelectGameWithRelations.ts +++ b/libs/infra-db/src/lib/model/SelectGameWithRelations.ts @@ -2,5 +2,12 @@ import { InferResultType } from './InferResultType'; export type SelectGameWithRelations = InferResultType< 'games', - { moves: true; whitePlayer: true; blackPlayer: true; actions: true } + { + moves: true; + whitePlayer: true; + blackPlayer: true; + actions: true; + whiteRating: true; + blackRating: true; + } >; diff --git a/libs/infra-db/src/lib/model/SelectRating.ts b/libs/infra-db/src/lib/model/SelectRating.ts new file mode 100644 index 00000000..056bacfe --- /dev/null +++ b/libs/infra-db/src/lib/model/SelectRating.ts @@ -0,0 +1,3 @@ +import { ratings } from '../schema/ratings'; + +export type SelectRating = typeof ratings.$inferSelect; diff --git a/libs/infra-db/src/lib/repository/GameRepository.ts b/libs/infra-db/src/lib/repository/GameRepository.ts index d826313c..c8defddc 100644 --- a/libs/infra-db/src/lib/repository/GameRepository.ts +++ b/libs/infra-db/src/lib/repository/GameRepository.ts @@ -1,6 +1,10 @@ import { Maybe } from '@michess/common-utils'; -import { GameStatusType } from '@michess/core-game'; -import { and, count, eq, inArray, lt, sql } from 'drizzle-orm'; +import { + GameStatusType, + GameVariantType, + TimeControlClassification, +} from '@michess/core-game'; +import { and, asc, count, eq, gt, inArray, lt, or, sql } from 'drizzle-orm'; import { GameStatusEnum } from '../model/GameStatusEnum'; import { InsertGame } from '../model/InsertGame'; import { SelectGame } from '../model/SelectGame'; @@ -9,14 +13,22 @@ import { games } from '../schema'; import { BaseRepository } from './BaseRepository'; type QueryOptions = { - page: { + page?: { page: number; pageSize: number; }; status?: GameStatusType[]; playerId?: string; + variant?: GameVariantType; + timeControlClassification?: TimeControlClassification; + completedAfter?: Date; + completedBefore?: Date; private?: boolean; }; +type QuerySortOptions = { + sortBy: 'createdAt' | 'endedAt'; + sortOrder: 'asc' | 'desc'; +}; type DeleteGamesOptions = { olderThan: Date; @@ -33,8 +45,10 @@ export class GameRepository extends BaseRepository { where: eq(this.schema.games.gameId, id), }); } - - async queryGames(options: QueryOptions): Promise { + async queryGames( + options: QueryOptions, + sortOptions?: QuerySortOptions, + ): Promise { // Build the where condition for reuse const andConditions = []; if (options.status) { @@ -48,21 +62,51 @@ export class GameRepository extends BaseRepository { if (typeof options.private === 'boolean') { andConditions.push(eq(games.isPrivate, options.private)); } + if (options.playerId) { + andConditions.push( + or( + eq(games.whitePlayerId, options.playerId), + eq(games.blackPlayerId, options.playerId), + ), + ); + } + if (options.variant) { + andConditions.push(eq(games.variant, options.variant)); + } + if (options.timeControlClassification) { + andConditions.push( + eq(games.timeControlClassification, options.timeControlClassification), + ); + } + if (options.completedAfter) { + andConditions.push(gt(games.endedAt, options.completedAfter)); + } + if (options.completedBefore) { + andConditions.push(lt(games.endedAt, options.completedBefore)); + } const statusFilter = andConditions.length > 0 ? and(...andConditions) : undefined; const [gamesResult, countResult] = await Promise.all([ // Get paginated games with relations this.db.query.games.findMany({ - orderBy: (games, { desc }) => [desc(games.createdAt)], + orderBy: (games, { desc }) => [ + sortOptions?.sortOrder === 'asc' + ? asc(games[sortOptions?.sortBy || 'createdAt']) + : desc(games[sortOptions?.sortBy || 'createdAt']), + ], with: { whitePlayer: true, blackPlayer: true, + blackRating: true, + whiteRating: true, moves: true, actions: true, }, - offset: (options.page.page - 1) * options.page.pageSize, - limit: options.page.pageSize, + offset: options.page + ? (options.page.page - 1) * options.page.pageSize + : undefined, + limit: options.page?.pageSize, where: statusFilter, }), // Get total count with same filter @@ -85,6 +129,8 @@ export class GameRepository extends BaseRepository { whitePlayer: true, blackPlayer: true, actions: true, + whiteRating: true, + blackRating: true, }, }); } diff --git a/libs/infra-db/src/lib/repository/RatingRepository.ts b/libs/infra-db/src/lib/repository/RatingRepository.ts new file mode 100644 index 00000000..235a9099 --- /dev/null +++ b/libs/infra-db/src/lib/repository/RatingRepository.ts @@ -0,0 +1,89 @@ +import { Maybe } from '@michess/common-utils'; +import { + GameRating, + GameVariantType, + TimeControlClassification, +} from '@michess/core-game'; +import { and, eq, lt, sql } from 'drizzle-orm'; +import { InsertRating } from '../model/InsertRating'; +import { SelectRating } from '../model/SelectRating'; +import { ratings } from '../schema'; +import { BaseRepository } from './BaseRepository'; + +export type StaleRating = { + playerId: string; + variant: GameVariantType; + timeControlClassification: TimeControlClassification; + lastRatingTimestamp: Date; +}; + +export class RatingRepository extends BaseRepository { + toGameRating(rating: SelectRating): GameRating { + return { + id: rating.id, + playerId: rating.playerId, + gameId: rating.gameId ?? undefined, + value: rating.rating, + deviation: rating.deviation, + volatility: rating.volatility, + timestamp: rating.timestamp, + timeControlClassification: + rating.timeControlClassification as TimeControlClassification, + variant: rating.variant as GameVariantType, + }; + } + + async createRating(data: InsertRating): Promise { + const [rating] = await this.db.insert(ratings).values(data).returning(); + return this.toGameRating(rating); + } + + async getRatingByPlayerId( + playerId: string, + variant: GameVariantType, + timeControl: TimeControlClassification, + ): Promise> { + const rating = await this.db.query.ratings.findFirst({ + where: and( + eq(ratings.playerId, playerId), + eq(ratings.variant, variant), + eq(ratings.timeControlClassification, timeControl), + ), + }); + if (rating) { + return this.toGameRating(rating); + } else { + return undefined; + } + } + + async getStaleRatings(cutoffDate: Date): Promise { + // Get the most recent rating for each player/variant/timeControl combination + // where the timestamp is older than the cutoff date + const result = await this.db + .select({ + playerId: ratings.playerId, + variant: ratings.variant, + timeControlClassification: ratings.timeControlClassification, + lastRatingTimestamp: sql`MAX(${ratings.timestamp})`.as( + 'last_rating_timestamp', + ), + }) + .from(ratings) + .where(lt(ratings.timestamp, cutoffDate)) + .groupBy( + ratings.playerId, + ratings.variant, + ratings.timeControlClassification, + ) + .having(sql`MAX(${ratings.timestamp}) < ${cutoffDate}`); + + return result.map((row) => ({ + playerId: row.playerId, + variant: row.variant as GameVariantType, + timeControlClassification: + row.timeControlClassification as TimeControlClassification, + lastRatingTimestamp: row.lastRatingTimestamp, + })); + } +} diff --git a/libs/infra-db/src/lib/schema/games.ts b/libs/infra-db/src/lib/schema/games.ts index 93c2cfb9..e84aed2d 100644 --- a/libs/infra-db/src/lib/schema/games.ts +++ b/libs/infra-db/src/lib/schema/games.ts @@ -1,16 +1,20 @@ import { relations, sql } from 'drizzle-orm'; import { + AnyPgColumn, boolean, check, + integer, jsonb, pgTable, text, timestamp, + unique, uuid, } from 'drizzle-orm/pg-core'; import { TimeControlJsonB } from '../model/TimeControlJsonB'; import { actions } from './actions'; import { moves } from './moves'; +import { ratings } from './ratings'; import { gameStatusEnum } from './shared/gameStatusEnum'; import { resultEnum } from './shared/resultEnum'; import { resultReasonEnum } from './shared/resultReasonEnum'; @@ -32,6 +36,18 @@ export const games = pgTable( blackPlayerId: text('black_player_id').references(() => users.id, { onDelete: 'set null', }), + whiteRatingId: integer('white_rating_id').references( + (): AnyPgColumn => ratings.id, + { + onDelete: 'set null', + }, + ), + blackRatingId: integer('black_rating_id').references( + (): AnyPgColumn => ratings.id, + { + onDelete: 'set null', + }, + ), timeControlClassification: timeControlClassificationEnum( 'time_control_classification', ) @@ -47,8 +63,8 @@ export const games = pgTable( startedAt: timestamp('started_at'), endedAt: timestamp('ended_at'), }, - (table) => ({ - timeControlStandardCheck: check( + (table) => [ + check( 'time_control_standard_check', sql`( ${table.timeControlClassification} NOT IN ('bullet', 'blitz', 'rapid') @@ -59,7 +75,7 @@ export const games = pgTable( ) )`, ), - timeControlCorrespondenceCheck: check( + check( 'time_control_correspondence_check', sql`( ${table.timeControlClassification} != 'correspondence' @@ -69,14 +85,20 @@ export const games = pgTable( ) )`, ), - timeControlNoClockCheck: check( + check( 'time_control_no_clock_check', sql`( ${table.timeControlClassification} != 'no_clock' OR ${table.timeControl} IS NULL )`, ), - }), + // Composite unique constraint for foreign key reference + unique('game_variant_tc_unique').on( + table.gameId, + table.variant, + table.timeControlClassification, + ), + ], ); export const gamesRelations = relations(games, ({ many, one }) => ({ @@ -90,4 +112,14 @@ export const gamesRelations = relations(games, ({ many, one }) => ({ fields: [games.blackPlayerId], references: [users.id], }), + whiteRating: one(ratings, { + fields: [games.whiteRatingId], + references: [ratings.id], + relationName: 'whiteRating', + }), + blackRating: one(ratings, { + fields: [games.blackRatingId], + references: [ratings.id], + relationName: 'blackRating', + }), })); diff --git a/libs/infra-db/src/lib/schema/index.ts b/libs/infra-db/src/lib/schema/index.ts index 0e8beae2..4f2e522d 100644 --- a/libs/infra-db/src/lib/schema/index.ts +++ b/libs/infra-db/src/lib/schema/index.ts @@ -2,6 +2,7 @@ export * from './accounts'; export * from './actions'; export * from './games'; export * from './moves'; +export * from './ratings'; export * from './shared/colorEnum'; export * from './shared/gameStatusEnum'; export * from './shared/resultEnum'; diff --git a/libs/infra-db/src/lib/schema/ratings.ts b/libs/infra-db/src/lib/schema/ratings.ts new file mode 100644 index 00000000..f12b9921 --- /dev/null +++ b/libs/infra-db/src/lib/schema/ratings.ts @@ -0,0 +1,72 @@ +import { relations } from 'drizzle-orm'; +import { + foreignKey, + index, + pgTable, + real, + serial, + text, + timestamp, + uuid, +} from 'drizzle-orm/pg-core'; +import { games } from './games'; +import { timeControlClassificationEnum } from './shared/timeControlClassificationEnum'; +import { variantEnum } from './shared/variantEnum'; +import { users } from './users'; + +export const ratings = pgTable( + 'ratings', + { + id: serial('id').primaryKey().notNull(), + playerId: text('player_id') + .notNull() + .references(() => users.id, { + onDelete: 'cascade', + }), + gameId: uuid('game_id').references(() => games.gameId, { + onDelete: 'set null', + }), + variant: variantEnum().notNull(), + timeControlClassification: timeControlClassificationEnum( + 'time_control_classification', + ).notNull(), + rating: real('rating').notNull(), + deviation: real('deviation').notNull(), + volatility: real('volatility').notNull(), + timestamp: timestamp('timestamp').defaultNow().notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (table) => [ + index('idx_ratings_player_variant_tc_created').on( + table.playerId, + table.variant, + table.timeControlClassification, + table.timestamp.desc(), + ), + // Composite foreign key constraint + foreignKey({ + columns: [table.gameId, table.variant, table.timeControlClassification], + foreignColumns: [ + games.gameId, + games.variant, + games.timeControlClassification, + ], + name: 'ratings_game_variant_tc_fk', + }), + ], +); + +export const ratingsRelations = relations(ratings, ({ one }) => ({ + player: one(users, { + fields: [ratings.playerId], + references: [users.id], + }), + game: one(games, { + fields: [ + ratings.gameId, + ratings.variant, + ratings.timeControlClassification, + ], + references: [games.gameId, games.variant, games.timeControlClassification], + }), +})); diff --git a/package.json b/package.json index 2cdd89f0..778e44b7 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,9 @@ "@nx/vite": "21.6.4", "@nx/web": "21.6.4", "@nx/workspace": "21.6.4", + "@swc-node/register": "~1.9.1", + "@swc/core": "~1.5.7", + "@swc/helpers": "~0.5.11", "@tanstack/react-query-devtools": "^5.87.4", "@tanstack/react-router-devtools": "^1.131.42", "@testing-library/dom": "^10.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1efb5c7a..f0f84b03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,6 +147,15 @@ importers: '@nx/workspace': specifier: 21.6.4 version: 21.6.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17)) + '@swc-node/register': + specifier: ~1.9.1 + version: 1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.9.3) + '@swc/core': + specifier: ~1.5.7 + version: 1.5.29(@swc/helpers@0.5.17) + '@swc/helpers': + specifier: ~0.5.11 + version: 0.5.17 '@tanstack/react-query-devtools': specifier: ^5.87.4 version: 5.87.4(@tanstack/react-query@5.90.2(react@19.1.0))(react@19.1.0) @@ -14659,7 +14668,6 @@ snapshots: dependencies: '@swc/core': 1.5.29(@swc/helpers@0.5.17) '@swc/types': 0.1.24 - optional: true '@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.9.3)': dependencies: @@ -14674,13 +14682,11 @@ snapshots: transitivePeerDependencies: - '@swc/types' - supports-color - optional: true '@swc-node/sourcemap-support@0.5.1': dependencies: source-map-support: 0.5.21 tslib: 2.8.1 - optional: true '@swc/core-darwin-arm64@1.5.29': optional: true @@ -14728,10 +14734,8 @@ snapshots: '@swc/core-win32-ia32-msvc': 1.5.29 '@swc/core-win32-x64-msvc': 1.5.29 '@swc/helpers': 0.5.17 - optional: true - '@swc/counter@0.1.3': - optional: true + '@swc/counter@0.1.3': {} '@swc/helpers@0.5.17': dependencies: @@ -14740,7 +14744,6 @@ snapshots: '@swc/types@0.1.24': dependencies: '@swc/counter': 0.1.3 - optional: true '@tanstack/history@1.132.31': {} diff --git a/tsconfig.base.json b/tsconfig.base.json index 32bf8ea1..c6d19fc5 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -28,7 +28,8 @@ "@michess/infra-email": ["libs/infra-email/src/index.ts"], "@michess/react-chessboard": ["libs/react-chessboard/src/index.ts"], "@michess/react-dnd": ["libs/react-dnd/src/index.ts"], - "@michess/react-emails": ["libs/react-emails/src/index.ts"] + "@michess/react-emails": ["libs/react-emails/src/index.ts"], + "@michess/core-rating": ["libs/core-rating/src/index.ts"] } }, "exclude": ["node_modules", "tmp"]