Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions apps/node-chess/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ EMAIL_PASS=testpass
GOOGLE_OAUTH_CLIENT_SECRET=foo
GOOGLE_OAUTH_CLIENT_ID=bar

# LLM Configuration
GEMINI_API_KEY=your_gemini_api_key_here

WEB_APP_URL=http://localhost:4200

APP_PORT=5000
Expand Down
2 changes: 1 addition & 1 deletion apps/node-chess/src/Server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const start = (app: App, appConfig: AppConfig): ServerType => {
});

app.init().catch((err) => {
logger.error('Failed to initialize app:', err);
logger.error(err, 'Failed to initialize app');
process.exit(1);
});

Expand Down
2 changes: 2 additions & 0 deletions apps/node-chess/src/config/model/AppConfig.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { LlmConfig } from '@michess/api-service';
import { EmailConfig } from '@michess/infra-email';

export type AppConfig = {
Expand All @@ -22,4 +23,5 @@ export type AppConfig = {
clientSecret: string;
};
};
llm: LlmConfig;
};
3 changes: 3 additions & 0 deletions apps/node-chess/src/config/service/AppConfigService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ const getConfig = (): AppConfig => {
clientSecret: readEnvStrict('GOOGLE_OAUTH_CLIENT_SECRET'),
},
},
llm: {
geminiApiKey: readEnvStrict('GEMINI_API_KEY'),
},
};
};

Expand Down
8 changes: 7 additions & 1 deletion apps/node-chess/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,13 @@ const main = async () => {
);

const repos = Repositories.from(pgClient, redis);
const api = Api.from(repos, pgClient, emailClient, appConfig.auth);
const api = Api.from(
repos,
pgClient,
emailClient,
appConfig.auth,
appConfig.llm,
);
const app = App.from(api, redis, { cors: appConfig.cors });

Server.start(app, appConfig);
Expand Down
4 changes: 4 additions & 0 deletions apps/web-chess/src/app/api/Api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { AuthClient } from './infra/AuthClient';
import { RestClient } from './infra/RestClient';
import { SocketClient } from './infra/SocketClient';
import { AuthService } from './service/AuthService';
import { BotService } from './service/BotService';
import { GameService } from './service/GameService';
import { MetricsService } from './service/MetricsService';

export type Api = {
games: GameService;
auth: AuthService;
metrics: MetricsService;
bots: BotService;
};

export const Api = {
Expand All @@ -20,10 +22,12 @@ export const Api = {
const auth = new AuthService(authClient, socketClient);
const games = new GameService(restClient, socketClient, auth);
const metrics = new MetricsService(restClient, socketClient);
const bots = new BotService(restClient);
return {
games,
auth,
metrics,
bots,
};
},
};
10 changes: 10 additions & 0 deletions apps/web-chess/src/app/api/service/BotService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { BotInfoV1 } from '@michess/api-schema';
import { RestClient } from '../infra/RestClient';

export class BotService {
constructor(private restClient: RestClient) {}

async listBots(): Promise<BotInfoV1[]> {
return this.restClient.get('bots').json();
}
}
20 changes: 20 additions & 0 deletions apps/web-chess/src/app/api/service/GameService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,26 @@ export class GameService {
return response;
}

async challengeBot(params: {
botId: string;
timeControl: { initialSec: number; incrementSec: number };
}): Promise<GameDetailsV1> {
const response = await this.restClient
.post<GameDetailsV1>('games/challenge', {
json: {
opponentId: params.botId,
variant: 'standard',
timeControl: {
type: 'realtime',
initialSec: params.timeControl.initialSec,
incrementSec: params.timeControl.incrementSec,
},
},
})
.json();
return response;
}

async getLobbyGames(page: number) {
const queryParams = new URLSearchParams({
page: page.toString(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ describe('GameLobby', () => {
items: [
{
id: 'game-1',
opponent: { name: 'Alice', id: 'alice-id' },
opponent: { name: 'Alice', id: 'alice-id', isBot: false },
availableColor: 'white',
variant: 'standard',
createdAt: new Date().toISOString(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Button, Flex, SegmentedControl, Switch, Text } from '@radix-ui/themes';
import { FC, useState } from 'react';
import { CreateGameInput } from '../../../api/model/CreateGameInput';
import { Alert } from '../../../components/Alert';
import { TimeControlRadioCards } from './TimeControlRadioGroup';
import { TimeControlRadioCards } from '../../play/components/TimeControlRadioCards';

type Props = {
onSubmit: (formInput: CreateGameInput) => void;
Expand Down
65 changes: 65 additions & 0 deletions apps/web-chess/src/app/features/play/PlayCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Maybe } from '@michess/common-utils';
import { Button, Card, Flex, Heading } from '@radix-ui/themes';
import { useState } from 'react';
import { Alert } from '../../components/Alert';
import { BotSelector } from './components/BotSelector';
import { OpponentTypeSelector } from './components/OpponentTypeSelector';
import { TimeControlSelector } from './components/TimeControlSelector';

type TimeControlStr = `${number}|${number}`;
type OpponentType = 'random' | 'bot';

type Props = {
onPlay?: (params: {
timeControl: TimeControlStr;
opponentType: OpponentType;
botId?: string;
}) => void;
error?: Maybe<string>;
loading?: boolean;
};

export const PlayCard = ({ onPlay, error, loading }: Props) => {
const [timeControl, setTimeControl] = useState<TimeControlStr>('3|2');
const [opponentType, setOpponentType] = useState<OpponentType>('random');
const [botId, setBotId] = useState<string | undefined>(undefined);

const handlePlay = () => {
onPlay?.({
timeControl,
opponentType,
botId: opponentType === 'bot' ? botId : undefined,
});
};

return (
<Card size="3" style={{ padding: '24px' }}>
<Flex direction="column" gap="4">
<Heading size="4" weight="medium">
Play
</Heading>

<Alert text={error} />

<Flex gap="3" align="center">
<TimeControlSelector value={timeControl} onChange={setTimeControl} />
</Flex>

<OpponentTypeSelector value={opponentType} onChange={setOpponentType} />

{opponentType === 'bot' && (
<BotSelector value={botId} onChange={setBotId} />
)}

<Button
size="3"
onClick={handlePlay}
disabled={opponentType === 'random' || loading}
loading={loading}
>
Play
</Button>
</Flex>
</Card>
);
};
55 changes: 55 additions & 0 deletions apps/web-chess/src/app/features/play/components/BotSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Flex, Select, Skeleton, Text } from '@radix-ui/themes';
import { use, useEffect } from 'react';
import { ApiContext } from '../../../api/context/ApiContext';
import { Alert } from '../../../components/Alert';
import { useQuery } from '../../../util/useQuery';

type BotId = string;

type Props = {
value?: BotId;
onChange?: (value: BotId) => void;
};

export const BotSelector = ({ value, onChange }: Props) => {
const api = use(ApiContext);

const {
data: bots,
isPending,
error,
} = useQuery({
queryKey: ['bots'],
queryFn: () => api.bots.listBots(),
});

// Set the first bot as default when bots load and no value is set
useEffect(() => {
if (bots && bots.length > 0 && !value && onChange) {
onChange(bots[0].id);
}
}, [bots, value, onChange]);

const selectedValue = value ?? bots?.[0]?.id;

return (
<Flex direction="column" gap="2">
<Text size="2" weight="medium" color="gray">
Select Bot
</Text>
{error && <Alert text={`Error loading bots: ${error.message}`} />}
<Skeleton loading={isPending}>
<Select.Root value={selectedValue} onValueChange={onChange}>
<Select.Trigger style={{ width: '100%' }} />
<Select.Content>
{bots?.map((bot) => (
<Select.Item key={bot.id} value={bot.id}>
{bot.name}
</Select.Item>
))}
</Select.Content>
</Select.Root>
</Skeleton>
</Flex>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Flex, RadioCards, Text } from '@radix-ui/themes';
import { Bot, UserRoundSearch } from 'lucide-react';

type OpponentType = 'random' | 'bot';

type Props = {
value?: OpponentType;
onChange?: (value: OpponentType) => void;
};

export const OpponentTypeSelector = ({ value = 'random', onChange }: Props) => {
return (
<Flex direction="column" gap="2">
<Text size="2" weight="medium" color="gray">
Opponent
</Text>
<RadioCards.Root
value={value}
onValueChange={(val) => onChange?.(val as OpponentType)}
columns="2"
gap="2"
>
<RadioCards.Item value="random">
<Flex direction="column" gap="1">
<Flex align="center" gap="2">
<UserRoundSearch size={16} />
<Text size="2" weight="bold">
Random
</Text>
</Flex>
<Text size="1" color="gray">
Play vs human
</Text>
</Flex>
</RadioCards.Item>
<RadioCards.Item value="bot">
<Flex direction="column" gap="1">
<Flex align="center" gap="2">
<Bot size={16} />
<Text size="2" weight="bold">
Bot
</Text>
</Flex>
<Text size="1" color="gray">
Play vs AI
</Text>
</Flex>
</RadioCards.Item>
</RadioCards.Root>
</Flex>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Button, Flex, Popover, Text } from '@radix-ui/themes';
import { useState } from 'react';
import { TimeControlRadioCards } from './TimeControlRadioCards';

type TimeControlStr = `${number}|${number}`;

type Props = {
value?: TimeControlStr;
onChange?: (value: TimeControlStr) => void;
};

export const TimeControlSelector = ({ value = '3|2', onChange }: Props) => {
const [open, setOpen] = useState(false);

const handleTimeControlChange = (newValue: TimeControlStr) => {
onChange?.(newValue);
setOpen(false);
};

const formatTimeControl = (tc: TimeControlStr) => {
const [initial, increment] = tc.split('|');
return `${initial}+${increment}`;
};

return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger>
<Button variant="soft" size="2" style={{ minWidth: '120px' }}>
<Flex
gap="2"
align="center"
justify="between"
style={{ width: '100%' }}
>
<Text size="2" color="gray">
Time:
</Text>
<Text size="2" weight="bold">
{formatTimeControl(value)}
</Text>
</Flex>
</Button>
</Popover.Trigger>
<Popover.Content style={{ width: '380px' }}>
<TimeControlRadioCards
name="play-time-control"
onValueChange={handleTimeControlChange}
/>
</Popover.Content>
</Popover.Root>
);
};
Loading