Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
f52fd1c
Add the ratings lib
micnil Oct 30, 2025
22d7134
Add GlickoTwo algorithm
micnil Nov 1, 2025
4d7a45e
Add test
micnil Nov 2, 2025
59ce873
Add support for fractional rating periods
micnil Nov 3, 2025
5565e24
Add test for when no games has been played
micnil Nov 3, 2025
2498b4b
Rename Player to RatingProfile
micnil Nov 6, 2025
e9b08e3
Add ratings table and migration files
micnil Nov 6, 2025
e0a41b9
Fix tests
micnil Nov 6, 2025
ed44580
Add rating snapshots to games table
micnil Nov 6, 2025
a2bf504
Add rating reference to game when joining
micnil Nov 6, 2025
dff7978
Add indicative player rating diff on game state
micnil Nov 8, 2025
d2b92f0
Fix test warnings and migration order
micnil Nov 8, 2025
b361996
Fix broke test
micnil Nov 8, 2025
dc517db
Add rating decay job
micnil Nov 9, 2025
44aa8f7
Handle race condition for rating updates
micnil Nov 9, 2025
8076da8
Stop using GameDetailsMapper
micnil Nov 9, 2025
7e52596
Remove GameDetailsMapper
micnil Nov 9, 2025
2a12c9e
Queue rating updates when a game is ended
micnil Nov 10, 2025
d9f234b
Fix so that clock is stopped when game ends.
micnil Nov 10, 2025
f23e32b
Display rating
micnil Nov 10, 2025
66aa493
Show rating of player in lobby
micnil Nov 10, 2025
b463ae0
Remove commented code
micnil Nov 10, 2025
ede5324
Merge pull request #16 from micnil/feature/rating
micnil Nov 10, 2025
eaefa8b
Add ChessGame Unit tests
micnil Nov 10, 2025
17ae5c8
Refactor game cleanup logic to its own service
micnil Nov 10, 2025
3624575
Break out gameplayservice from gamesservice
micnil Nov 10, 2025
b84196c
Add icon for raiting loss/gain
micnil Nov 10, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions apps/web-chess/src/app/api/model/GameViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
16 changes: 16 additions & 0 deletions apps/web-chess/src/app/api/service/GameService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand All @@ -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,
},
Expand Down
17 changes: 6 additions & 11 deletions apps/web-chess/src/app/features/game/RemoteGameContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}
/>
Expand All @@ -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}
/>
Expand Down
47 changes: 37 additions & 10 deletions apps/web-chess/src/app/features/game/components/PlayerInfo.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Color } from '@michess/core-board';
import { Avatar, Badge, Flex, Skeleton, Text } from '@radix-ui/themes';
import { ArrowDownRight, ArrowUpRight } from 'lucide-react';
import React from 'react';
import { CountdownClock } from '../../../api/model/CountdownClock';
import { Clock } from './Clock';
Expand All @@ -13,6 +14,8 @@ type PlayerInfoProps = {
isPlayerTurn?: boolean;
isLoading?: boolean;
clock?: CountdownClock;
rating?: string;
ratingDiff?: string;
};

export const PlayerInfo: React.FC<PlayerInfoProps> = ({
Expand All @@ -23,6 +26,8 @@ export const PlayerInfo: React.FC<PlayerInfoProps> = ({
avatar,
isPlayerTurn = false,
clock,
rating,
ratingDiff,
isLoading,
}) => {
const getInitials = (name: string): string => {
Expand Down Expand Up @@ -52,16 +57,38 @@ export const PlayerInfo: React.FC<PlayerInfoProps> = ({
</Skeleton>
<Flex direction="column" gap="2">
<Skeleton loading={isLoading}>
<Text
size="3"
weight="bold"
color="gray"
highContrast
trim="both"
title={displayUsername}
>
{displayUsername}
</Text>
<Flex align={'center'} gap={'2'}>
<Text
size="3"
weight="bold"
color="gray"
highContrast
trim="both"
title={displayUsername}
>
{displayUsername}
</Text>
<Flex align="center">
{rating !== undefined ? (
<Text color={'gray'}>{rating}</Text>
) : undefined}
{ratingDiff !== undefined ? (
<>
<Text
ml="1"
color={ratingDiff.startsWith('+') ? 'green' : 'red'}
>
{ratingDiff}
</Text>
{ratingDiff.startsWith('+') ? (
<ArrowUpRight color={'green'} />
) : (
<ArrowDownRight color={'tomato'} />
)}
</>
) : undefined}
</Flex>
</Flex>
</Skeleton>

<Flex align="center" gap="1">
Expand Down
3 changes: 2 additions & 1 deletion apps/web-chess/src/app/features/lobby/GameLobby.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ export const GameLobby: React.FC<Props> = ({ onCreateGame, onJoinGame }) => {
<Flex align="center" gap="4">
<Box style={{ minWidth: '120px' }}>
<Text weight="medium" size="3">
{game.opponent.name}
{game.opponent.name}{' '}
{game.opponent.rating ? `(${game.opponent.rating})` : ''}
</Text>
</Box>
<Flex align="center" gap="2" style={{ minWidth: '100px' }}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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 = {
Expand Down Expand Up @@ -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);
});

Expand Down
9 changes: 9 additions & 0 deletions apps/web-chess/src/test/utils/setup-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => ({
Expand Down
5 changes: 3 additions & 2 deletions libs/api-router/src/lib/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,13 @@ const from = (
restRouter,
socketRouter,
init: async () => {
await api.games.initialize();
await api.gameJobScheduler.initialize();
await api.usageMetrics.initialize();
},
close: async () => {
socketRouter.close();
await api.games.close();
await api.gameplay.close();
await api.gameJobScheduler.close();
await api.usageMetrics.close();
},
};
Expand Down
10 changes: 5 additions & 5 deletions libs/api-router/src/lib/socket/SocketRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const leaveGame = async (
api: Api,
leaveGamePayloadV1: LeaveGamePayloadV1,
) => {
const gameState = await api.games.leaveGame(
const gameState = await api.gameplay.leaveGame(
socket.data.session,
leaveGamePayloadV1,
);
Expand Down Expand Up @@ -106,7 +106,7 @@ const from = (api: Api, redis: Redis, config: RouterConfig) => {
},
`User joining game`,
);
const gameState = await api.games.joinGame(
const gameState = await api.gameplay.joinGame(
socket.data.session,
joinGamePayloadV1,
);
Expand Down Expand Up @@ -147,7 +147,7 @@ const from = (api: Api, redis: Redis, config: RouterConfig) => {
{ ...makeMovePayloadV1, rooms: Array.from(socket.rooms) },
'Received make-move event',
);
const { gameDetails, move } = await api.games.makeMove(
const { gameDetails, move } = await api.gameplay.makeMove(
socket.data.session,
makeMovePayloadV1,
);
Expand All @@ -174,7 +174,7 @@ const from = (api: Api, redis: Redis, config: RouterConfig) => {
},
'User made action',
);
const gameDetails = await api.games.makeAction(
const gameDetails = await api.gameplay.makeAction(
socket.data.session,
makeActionPayload,
);
Expand Down Expand Up @@ -222,7 +222,7 @@ const from = (api: Api, redis: Redis, config: RouterConfig) => {
}
});

api.games.subscribe((gameDetails) => {
api.gameplay.subscribe((gameDetails) => {
io.to(gameDetails.id).emit('game-updated', gameDetails);
});

Expand Down
14 changes: 9 additions & 5 deletions libs/api-router/src/lib/socket/__tests__/SocketRouter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import {
Api,
AuthService,
GameplayService,
GamesService,
Session,
UsageMetricsService,
Expand All @@ -21,7 +22,9 @@ import { SocketRouter } from '../SocketRouter';
jest.mock('@michess/api-service');

const apiMock: Api = {
games: new GamesService(
games: new GamesService({} as never),
gameplay: new GameplayService(
{} as never,
{} as never,
{} as never,
{} as never,
Expand All @@ -32,6 +35,7 @@ const apiMock: Api = {
google: { clientId: '', clientSecret: '' },
}),
usageMetrics: new UsageMetricsService({} as never, {} as never, {} as never),
gameJobScheduler: {} as never,
};

const waitFor = <T>(
Expand Down Expand Up @@ -148,7 +152,7 @@ describe('SocketRouter', () => {
};
serverSocket2.join(joinGamePayload.gameId);

apiMock.games.joinGame = jest.fn().mockResolvedValue(mockGameState);
apiMock.gameplay.joinGame = jest.fn().mockResolvedValue(mockGameState);

const gameUpdatedPromise = waitFor(clientSocket2, 'game-updated');

Expand All @@ -162,7 +166,7 @@ describe('SocketRouter', () => {

expect(response.status).toEqual('ok');
expect(response.status === 'ok' && response.data).toEqual(mockGameState);
expect(apiMock.games.joinGame).toHaveBeenCalled();
expect(apiMock.gameplay.joinGame).toHaveBeenCalled();
});
});

Expand All @@ -181,7 +185,7 @@ describe('SocketRouter', () => {
gameId: makeMovePayload.gameId,
clock: { whiteMs: 300000, blackMs: 300000 },
};
apiMock.games.makeMove = jest.fn().mockResolvedValue({ move: moveV1 });
apiMock.gameplay.makeMove = jest.fn().mockResolvedValue({ move: moveV1 });

const moveMadePromise = waitFor<MoveMadeV1>(clientSocket2, 'move-made');

Expand All @@ -194,7 +198,7 @@ describe('SocketRouter', () => {
expect(data).toEqual(moveV1);
expect(response.status).toEqual('ok');
expect(response.status === 'ok' && response.data).toEqual(moveV1);
expect(apiMock.games.makeMove).toHaveBeenCalled();
expect(apiMock.gameplay.makeMove).toHaveBeenCalled();
});
});
});
1 change: 1 addition & 0 deletions libs/api-schema/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
2 changes: 2 additions & 0 deletions libs/api-schema/src/lib/player/PlayerInfoV1Schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
2 changes: 2 additions & 0 deletions libs/api-service/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export * from './lib/Api';
export * from './lib/auth/model/Session';
export * from './lib/auth/service/AuthService';
export * from './lib/games/service/GameJobSchedulerService';
export * from './lib/games/service/GameplayService';
export * from './lib/games/service/GamesService';
export * from './lib/metrics/UsageMetricsService';
Loading