feat: 1vs BOT

This commit is contained in:
shahdlala66
2026-04-19 01:06:13 +02:00
parent 5497997455
commit bc644c16e3
5 changed files with 201 additions and 4 deletions
+67 -1
View File
@@ -1,7 +1,7 @@
import { DestroyRef, Injectable, OnDestroy, inject } from '@angular/core';
import { Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval, startWith, Subscription, switchMap } from 'rxjs';
import { interval, startWith, Subscription, switchMap, delay } from 'rxjs';
import { getPieceAtSquare, isPieceColor } from '../../core/chess/fen.utils';
import { getErrorMessage } from '../../core/http/error-message.util';
import { GameFull, GameState, GameStreamEvent, LegalMove } from '../../models/game.models';
@@ -25,16 +25,37 @@ export class GameFacade implements OnDestroy {
private readonly destroyRef = inject(DestroyRef);
private streamSubscription: Subscription | null = null;
private pollSubscription: Subscription | null = null;
private botMoveSubscription: Subscription | null = null;
ngOnDestroy(): void {
this.streamSubscription?.unsubscribe();
this.pollSubscription?.unsubscribe();
this.botMoveSubscription?.unsubscribe();
}
get state(): GameState | null {
return this.game?.state ?? null;
}
private isBotPlayer(playerId: string): boolean {
return playerId.startsWith('bot-');
}
private isPlayingAgainstBot(): boolean {
if (!this.game) {
return false;
}
return this.isBotPlayer(this.game.white.id) || this.isBotPlayer(this.game.black.id);
}
private isCurrentPlayerBot(): boolean {
if (!this.game || !this.state) {
return false;
}
const currentPlayer = this.state.turn === 'white' ? this.game.white : this.game.black;
return this.isBotPlayer(currentPlayer.id);
}
setGameId(gameId: string): void {
this.gameId = gameId;
this.loadGame();
@@ -94,6 +115,7 @@ export class GameFacade implements OnDestroy {
}
this.moveInput = '';
this.clearSelection();
this.tryMakeBotMove();
},
error: (error) => {
this.errorMessage = getErrorMessage(error, 'Move rejected.');
@@ -153,6 +175,7 @@ export class GameFacade implements OnDestroy {
this.clearSelection();
this.streamSubscription?.unsubscribe();
this.pollSubscription?.unsubscribe();
this.botMoveSubscription?.unsubscribe();
this.pollSubscription = null;
this.gameApi
@@ -163,6 +186,7 @@ export class GameFacade implements OnDestroy {
this.game = game;
this.loading = false;
this.startStream();
this.tryMakeBotMove();
},
error: (error) => {
this.errorMessage = getErrorMessage(error, `Could not load game ${this.gameId}.`);
@@ -205,6 +229,7 @@ export class GameFacade implements OnDestroy {
this.game = game;
if (previousMoves !== game.state.moves.join(',')) {
this.clearSelection();
this.tryMakeBotMove();
}
}
});
@@ -214,6 +239,7 @@ export class GameFacade implements OnDestroy {
if (event.type === 'gameFull') {
this.game = event.game;
this.clearSelection();
this.tryMakeBotMove();
return;
}
@@ -222,6 +248,7 @@ export class GameFacade implements OnDestroy {
this.game = { ...this.game, state: event.state };
if (event.state.moves.length !== moveCountBefore) {
this.clearSelection();
this.tryMakeBotMove();
}
return;
}
@@ -231,6 +258,45 @@ export class GameFacade implements OnDestroy {
}
}
private tryMakeBotMove(): void {
if (!this.isPlayingAgainstBot() || !this.isCurrentPlayerBot() || !this.state) {
return;
}
this.botMoveSubscription?.unsubscribe();
this.botMoveSubscription = this.gameApi
.getLegalMoves(this.gameId)
.pipe(
delay(1000),
takeUntilDestroyed(this.destroyRef)
)
.subscribe({
next: (response) => {
if (response.moves.length === 0) {
return;
}
const botMove = response.moves[Math.floor(Math.random() * response.moves.length)];
this.gameApi
.makeMove(this.gameId, botMove.uci)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (state) => {
if (this.game) {
this.game = { ...this.game, state };
}
this.clearSelection();
},
error: (error) => {
this.errorMessage = getErrorMessage(error, 'Bot move failed.');
}
});
},
error: () => {
this.errorMessage = 'Could not get legal moves for bot move.';
}
});
}
private clearSelection(): void {
this.selectedSquare = null;
this.selectedSquareMoves = [];
@@ -142,3 +142,69 @@ p {
font-size: 0.95rem;
}
}
.difficulty-selector {
grid-column: 1 / -1;
background: var(--color-bg-card);
border: var(--border-width) solid var(--color-border);
border-radius: var(--border-radius-md);
padding: var(--size-lg-padding);
margin: var(--size-md) 0;
}
.difficulty-selector p {
margin: 0 0 var(--size-md);
font-weight: 600;
}
.difficulty-buttons {
display: flex;
gap: var(--size-md-gap);
flex-wrap: wrap;
}
.difficulty-btn {
flex: 1;
min-width: 80px;
padding: var(--size-md-padding);
border: var(--border-width) solid var(--color-border);
border-radius: var(--border-radius-md);
cursor: pointer;
font-weight: 600;
transition: all 0.3s ease;
}
.difficulty-btn.easy {
background: var(--color-success-light, #d4edda);
color: var(--color-text-primary);
}
.difficulty-btn.easy:hover:enabled {
background: var(--color-success, #28a745);
color: white;
}
.difficulty-btn.medium {
background: var(--color-warning-light, #fff3cd);
color: var(--color-text-primary);
}
.difficulty-btn.medium:hover:enabled {
background: var(--color-warning, #ffc107);
color: var(--color-text-primary);
}
.difficulty-btn.hard {
background: var(--color-danger-light, #f8d7da);
color: var(--color-text-primary);
}
.difficulty-btn.hard:hover:enabled {
background: var(--color-danger, #dc3545);
color: white;
}
.difficulty-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
+19 -2
View File
@@ -4,11 +4,28 @@
<p>Pick a mode to begin.</p>
<div class="mode-grid">
<button type="button" class="mode mode-disabled" disabled>
<button type="button" class="mode mode-active" (click)="toggleDifficultySelector()" [disabled]="creating">
<span>Bot</span>
<small>Coming soon</small>
<small>{{ creating ? 'Creating game...' : 'Choose difficulty' }}</small>
</button>
@if (showDifficultySelector) {
<div class="difficulty-selector">
<p>Select difficulty:</p>
<div class="difficulty-buttons">
<button type="button" class="difficulty-btn easy" (click)="startVsBot('easy')" [disabled]="creating">
Easy
</button>
<button type="button" class="difficulty-btn medium" (click)="startVsBot('medium')" [disabled]="creating">
Medium
</button>
<button type="button" class="difficulty-btn hard" (click)="startVsBot('hard')" [disabled]="creating">
Hard
</button>
</div>
</div>
}
<button type="button" class="mode mode-active" (click)="startOneVsOne()" [disabled]="creating">
<span>1 vs 1</span>
<small>{{ creating ? 'Creating game...' : 'Start now' }}</small>
@@ -15,6 +15,7 @@ import { GameApiService } from '../../services/game-api.service';
export class WelcomeComponent {
creating = false;
errorMessage = '';
showDifficultySelector = false;
constructor(
private readonly router: Router,
@@ -41,4 +42,31 @@ export class WelcomeComponent {
}
});
}
startVsBot(difficulty: 'easy' | 'medium' | 'hard'): void {
if (this.creating) {
return;
}
this.errorMessage = '';
this.creating = true;
this.showDifficultySelector = false;
this.gameApi
.createGameVsBot(difficulty)
.pipe(finalize(() => (this.creating = false)))
.subscribe({
next: (game) => {
void this.router.navigate(['/game', game.gameId]);
},
error: (error) => {
this.errorMessage = getErrorMessage(error, 'Unable to create a game against bot.');
}
});
}
toggleDifficultySelector(): void {
this.showDifficultySelector = !this.showDifficultySelector;
this.errorMessage = '';
}
}
+21 -1
View File
@@ -7,7 +7,8 @@ import {
GameFull,
GameState,
GameStreamEvent,
LegalMovesResponse
LegalMovesResponse,
PlayerInfo
} from '../models/game.models';
@Injectable({ providedIn: 'root' })
@@ -21,6 +22,25 @@ export class GameApiService {
return this.http.post<GameFull>(`${this.apiBase}/api/board/game`, {});
}
createGameVsBot(difficulty: 'easy' | 'medium' | 'hard' = 'medium'): Observable<GameFull> {
const playerColor = Math.random() > 0.5 ? 'white' : 'black';
const playerInfo: PlayerInfo = {
id: `player-${Date.now()}`,
displayName: 'You'
};
const botInfo: PlayerInfo = {
id: `bot-${difficulty}`,
displayName: `Bot (${difficulty})`
};
const payload =
playerColor === 'white'
? { white: playerInfo, black: botInfo }
: { white: botInfo, black: playerInfo };
return this.http.post<GameFull>(`${this.apiBase}/api/board/game`, payload);
}
getGame(gameId: string): Observable<GameFull> {
return this.http.get<GameFull>(`${this.apiBase}/api/board/game/${gameId}`);
}