feat: 1vs BOT
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { DestroyRef, Injectable, OnDestroy, inject } from '@angular/core';
|
import { DestroyRef, Injectable, OnDestroy, inject } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
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 { getPieceAtSquare, isPieceColor } from '../../core/chess/fen.utils';
|
||||||
import { getErrorMessage } from '../../core/http/error-message.util';
|
import { getErrorMessage } from '../../core/http/error-message.util';
|
||||||
import { GameFull, GameState, GameStreamEvent, LegalMove } from '../../models/game.models';
|
import { GameFull, GameState, GameStreamEvent, LegalMove } from '../../models/game.models';
|
||||||
@@ -25,16 +25,37 @@ export class GameFacade implements OnDestroy {
|
|||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private streamSubscription: Subscription | null = null;
|
private streamSubscription: Subscription | null = null;
|
||||||
private pollSubscription: Subscription | null = null;
|
private pollSubscription: Subscription | null = null;
|
||||||
|
private botMoveSubscription: Subscription | null = null;
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.streamSubscription?.unsubscribe();
|
this.streamSubscription?.unsubscribe();
|
||||||
this.pollSubscription?.unsubscribe();
|
this.pollSubscription?.unsubscribe();
|
||||||
|
this.botMoveSubscription?.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
get state(): GameState | null {
|
get state(): GameState | null {
|
||||||
return this.game?.state ?? 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 {
|
setGameId(gameId: string): void {
|
||||||
this.gameId = gameId;
|
this.gameId = gameId;
|
||||||
this.loadGame();
|
this.loadGame();
|
||||||
@@ -94,6 +115,7 @@ export class GameFacade implements OnDestroy {
|
|||||||
}
|
}
|
||||||
this.moveInput = '';
|
this.moveInput = '';
|
||||||
this.clearSelection();
|
this.clearSelection();
|
||||||
|
this.tryMakeBotMove();
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.errorMessage = getErrorMessage(error, 'Move rejected.');
|
this.errorMessage = getErrorMessage(error, 'Move rejected.');
|
||||||
@@ -153,6 +175,7 @@ export class GameFacade implements OnDestroy {
|
|||||||
this.clearSelection();
|
this.clearSelection();
|
||||||
this.streamSubscription?.unsubscribe();
|
this.streamSubscription?.unsubscribe();
|
||||||
this.pollSubscription?.unsubscribe();
|
this.pollSubscription?.unsubscribe();
|
||||||
|
this.botMoveSubscription?.unsubscribe();
|
||||||
this.pollSubscription = null;
|
this.pollSubscription = null;
|
||||||
|
|
||||||
this.gameApi
|
this.gameApi
|
||||||
@@ -163,6 +186,7 @@ export class GameFacade implements OnDestroy {
|
|||||||
this.game = game;
|
this.game = game;
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
this.startStream();
|
this.startStream();
|
||||||
|
this.tryMakeBotMove();
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.errorMessage = getErrorMessage(error, `Could not load game ${this.gameId}.`);
|
this.errorMessage = getErrorMessage(error, `Could not load game ${this.gameId}.`);
|
||||||
@@ -205,6 +229,7 @@ export class GameFacade implements OnDestroy {
|
|||||||
this.game = game;
|
this.game = game;
|
||||||
if (previousMoves !== game.state.moves.join(',')) {
|
if (previousMoves !== game.state.moves.join(',')) {
|
||||||
this.clearSelection();
|
this.clearSelection();
|
||||||
|
this.tryMakeBotMove();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -214,6 +239,7 @@ export class GameFacade implements OnDestroy {
|
|||||||
if (event.type === 'gameFull') {
|
if (event.type === 'gameFull') {
|
||||||
this.game = event.game;
|
this.game = event.game;
|
||||||
this.clearSelection();
|
this.clearSelection();
|
||||||
|
this.tryMakeBotMove();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +248,7 @@ export class GameFacade implements OnDestroy {
|
|||||||
this.game = { ...this.game, state: event.state };
|
this.game = { ...this.game, state: event.state };
|
||||||
if (event.state.moves.length !== moveCountBefore) {
|
if (event.state.moves.length !== moveCountBefore) {
|
||||||
this.clearSelection();
|
this.clearSelection();
|
||||||
|
this.tryMakeBotMove();
|
||||||
}
|
}
|
||||||
return;
|
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 {
|
private clearSelection(): void {
|
||||||
this.selectedSquare = null;
|
this.selectedSquare = null;
|
||||||
this.selectedSquareMoves = [];
|
this.selectedSquareMoves = [];
|
||||||
|
|||||||
@@ -142,3 +142,69 @@ p {
|
|||||||
font-size: 0.95rem;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,11 +4,28 @@
|
|||||||
<p>Pick a mode to begin.</p>
|
<p>Pick a mode to begin.</p>
|
||||||
|
|
||||||
<div class="mode-grid">
|
<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>
|
<span>Bot</span>
|
||||||
<small>Coming soon</small>
|
<small>{{ creating ? 'Creating game...' : 'Choose difficulty' }}</small>
|
||||||
</button>
|
</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">
|
<button type="button" class="mode mode-active" (click)="startOneVsOne()" [disabled]="creating">
|
||||||
<span>1 vs 1</span>
|
<span>1 vs 1</span>
|
||||||
<small>{{ creating ? 'Creating game...' : 'Start now' }}</small>
|
<small>{{ creating ? 'Creating game...' : 'Start now' }}</small>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { GameApiService } from '../../services/game-api.service';
|
|||||||
export class WelcomeComponent {
|
export class WelcomeComponent {
|
||||||
creating = false;
|
creating = false;
|
||||||
errorMessage = '';
|
errorMessage = '';
|
||||||
|
showDifficultySelector = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly router: Router,
|
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 = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
GameFull,
|
GameFull,
|
||||||
GameState,
|
GameState,
|
||||||
GameStreamEvent,
|
GameStreamEvent,
|
||||||
LegalMovesResponse
|
LegalMovesResponse,
|
||||||
|
PlayerInfo
|
||||||
} from '../models/game.models';
|
} from '../models/game.models';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -21,6 +22,25 @@ export class GameApiService {
|
|||||||
return this.http.post<GameFull>(`${this.apiBase}/api/board/game`, {});
|
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> {
|
getGame(gameId: string): Observable<GameFull> {
|
||||||
return this.http.get<GameFull>(`${this.apiBase}/api/board/game/${gameId}`);
|
return this.http.get<GameFull>(`${this.apiBase}/api/board/game/${gameId}`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user