feat: clean ups and shorter files

This commit is contained in:
shahdlala66
2026-04-22 08:28:16 +02:00
parent c18026bce6
commit 25b69fd7b6
8 changed files with 558 additions and 341 deletions
@@ -0,0 +1,64 @@
import { Injectable, DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { GameApiService } from './game-api.service';
import { getPieceAtSquare, isPieceColor } from '../core/chess/fen.utils';
import { GameState, LegalMove } from '../models/game.models';
export interface BoardSelection {
selectedSquare: string | null;
highlightedSquares: string[];
selectedSquareMoves: LegalMove[];
}
@Injectable({ providedIn: 'root' })
export class BoardSelectionService {
private readonly gameApi = inject(GameApiService);
private readonly destroyRef = inject(DestroyRef);
handleSquareSelection(
square: string,
gameId: string,
state: GameState | null,
currentSelection: BoardSelection,
onMovesLoaded: (moves: LegalMove[]) => void,
onError: (error: string) => void
): BoardSelection {
if (!state) {
return currentSelection;
}
// If clicking on a highlighted square, it's a move
if (currentSelection.selectedSquare && currentSelection.highlightedSquares.includes(square)) {
const selectedMove = currentSelection.selectedSquareMoves.find((move) => move.to === square);
if (selectedMove) {
return { selectedSquare: null, highlightedSquares: [], selectedSquareMoves: [] };
}
return currentSelection;
}
// Check if square has a piece of the correct color
const piece = getPieceAtSquare(state.fen, square);
if (!piece || !isPieceColor(piece, state.turn)) {
return { selectedSquare: null, highlightedSquares: [], selectedSquareMoves: [] };
}
// Load legal moves for this square
this.gameApi
.getLegalMoves(gameId, square)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (response) => {
onMovesLoaded(response.moves);
},
error: () => {
onError('Could not load legal moves for selected square.');
}
});
return { selectedSquare: square, highlightedSquares: [], selectedSquareMoves: [] };
}
clearSelection(): BoardSelection {
return { selectedSquare: null, highlightedSquares: [], selectedSquareMoves: [] };
}
}
+78
View File
@@ -0,0 +1,78 @@
import { Injectable, DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { delay, Subscription } from 'rxjs';
import { GameApiService } from './game-api.service';
import { getErrorMessage } from '../core/http/error-message.util';
import { GameFull, GameState } from '../models/game.models';
@Injectable({ providedIn: 'root' })
export class BotMoveService {
private readonly gameApi = inject(GameApiService);
private readonly destroyRef = inject(DestroyRef);
private botMoveSubscription: Subscription | null = null;
private isBotPlayer(playerId: string): boolean {
return playerId.startsWith('bot-');
}
isPlayingAgainstBot(game: GameFull | null): boolean {
if (!game) {
return false;
}
return this.isBotPlayer(game.white.id) || this.isBotPlayer(game.black.id);
}
isCurrentPlayerBot(game: GameFull | null, state: GameState | null): boolean {
if (!game || !state) {
return false;
}
const currentPlayer = state.turn === 'white' ? game.white : game.black;
return this.isBotPlayer(currentPlayer.id);
}
tryMakeBotMove(
gameId: string,
game: GameFull | null,
state: GameState | null,
onSuccess: (updatedState: GameState) => void,
onError: (error: string) => void
): void {
if (!this.isPlayingAgainstBot(game) || !this.isCurrentPlayerBot(game, state) || !state) {
return;
}
this.botMoveSubscription?.unsubscribe();
this.botMoveSubscription = this.gameApi
.getLegalMoves(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(gameId, botMove.uci)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (updatedState) => {
onSuccess(updatedState);
},
error: (error) => {
onError(getErrorMessage(error, 'Bot move failed.'));
}
});
},
error: () => {
onError('Could not get legal moves for bot move.');
}
});
}
cleanup(): void {
this.botMoveSubscription?.unsubscribe();
}
}
+6 -118
View File
@@ -1,21 +1,22 @@
import { Injectable } from '@angular/core';
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
import {
ErrorEvent,
GameFull,
GameState,
GameStreamEvent,
LegalMovesResponse,
PlayerInfo
} from '../models/game.models';
import { StreamHandlerService } from './stream-handler.service';
@Injectable({ providedIn: 'root' })
export class GameApiService {
private readonly apiBase = environment.apiBaseUrl;
private readonly wsBase = environment.wsBaseUrl;
private readonly apiPath = environment.apiPath;
private readonly streamHandler = inject(StreamHandlerService);
constructor(private readonly http: HttpClient) {}
@@ -76,121 +77,8 @@ export class GameApiService {
}
streamGame(gameId: string): Observable<GameStreamEvent> {
return new Observable<GameStreamEvent>((observer) => {
const wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/stream`;
const streamUrl = `${this.apiBase}${this.apiPath}/${gameId}/stream`;
const ws = new WebSocket(wsUrl);
const abortController = new AbortController();
let connected = false;
let fallbackActive = false;
const parseEvent = (raw: string): GameStreamEvent | null => {
if (!raw.trim()) {
return null;
}
try {
const parsed = JSON.parse(raw) as GameStreamEvent;
return parsed;
} catch {
return null;
}
};
const emitErrorEvent = (message: string): void => {
const errorEvent: ErrorEvent = {
type: 'error',
error: { code: 'STREAM_ERROR', message }
};
observer.next(errorEvent);
};
const startNdjsonFallback = async (): Promise<void> => {
if (fallbackActive) {
return;
}
fallbackActive = true;
console.log(`[GameApiService] NDJSON fallback started for ${gameId}, URL:`, streamUrl);
try {
const response = await fetch(streamUrl, {
headers: { Accept: 'application/x-ndjson' },
signal: abortController.signal
});
if (!response.ok || !response.body) {
console.error(`[GameApiService] NDJSON fetch failed: HTTP ${response.status}`);
emitErrorEvent(`Unable to open stream: HTTP ${response.status}`);
observer.complete();
return;
}
console.log(`[GameApiService] NDJSON stream connected for ${gameId}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const event = parseEvent(line);
if (event) {
observer.next(event);
}
}
}
observer.complete();
} catch (error) {
if ((error as Error).name !== 'AbortError') {
emitErrorEvent((error as Error).message);
observer.error(error);
}
}
};
ws.onopen = () => {
connected = true;
};
ws.onmessage = (message) => {
const payload = typeof message.data === 'string' ? message.data : '';
const event = parseEvent(payload);
if (event) {
observer.next(event);
}
};
ws.onerror = (error) => {
console.warn(`[GameApiService] WebSocket error for ${gameId}, attempting NDJSON fallback:`, error);
if (!connected) {
void startNdjsonFallback();
}
};
ws.onclose = () => {
console.warn(`[GameApiService] WebSocket closed for ${gameId}, connected=${connected}`);
if (!connected) {
console.log(`[GameApiService] Starting NDJSON fallback for ${gameId}`);
void startNdjsonFallback();
} else {
observer.complete();
}
};
return () => {
abortController.abort();
ws.close();
};
});
const wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/stream`;
const fallbackUrl = `${this.apiBase}${this.apiPath}/${gameId}/stream`;
return this.streamHandler.createGameStream(wsUrl, fallbackUrl, gameId);
}
}
@@ -0,0 +1,46 @@
import { Injectable } from '@angular/core';
import { GameFull, GameState, GameStatus } from '../models/game.models';
export interface GameCompletion {
isFinished: boolean;
message: string;
}
@Injectable({ providedIn: 'root' })
export class GameCompletionService {
getGameCompletion(game: GameFull | null, state: GameState | null): GameCompletion {
if (!game || !state) {
return { isFinished: false, message: '' };
}
const status = state.status;
const gameEndingStatuses: GameStatus[] = ['checkmate', 'stalemate', 'resign', 'draw', 'insufficientMaterial'];
if (!gameEndingStatuses.includes(status)) {
return { isFinished: false, message: '' };
}
const message = this.buildCompletionMessage(status, state, game);
return { isFinished: true, message };
}
private buildCompletionMessage(status: GameStatus, state: GameState, game: GameFull): string {
switch (status) {
case 'checkmate':
const winner = state.winner === 'white' ? game.white.displayName : game.black.displayName;
return `Checkmate! ${winner} wins!`;
case 'stalemate':
return 'Stalemate! The game is a draw.';
case 'resign':
const resignedPlayer = state.winner === 'white' ? game.black.displayName : game.white.displayName;
const resignedWinner = state.winner === 'white' ? game.white.displayName : game.black.displayName;
return `${resignedPlayer} resigned. ${resignedWinner} wins!`;
case 'draw':
return 'Draw! The game ended in a draw.';
case 'insufficientMaterial':
return 'Insufficient material! The game is a draw.';
default:
return 'Game ended!';
}
}
}
+62
View File
@@ -0,0 +1,62 @@
import { Injectable, DestroyRef, inject } from '@angular/core';
import { Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { GameApiService } from './game-api.service';
import { getErrorMessage } from '../core/http/error-message.util';
@Injectable({ providedIn: 'root' })
export class GameImportService {
private readonly gameApi = inject(GameApiService);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
importFen(
fen: string,
onSuccess: () => void,
onError: (error: string) => void
): void {
const trimmedFen = fen.trim();
if (!trimmedFen) {
onError('Please provide a FEN string.');
return;
}
this.gameApi
.importFen(trimmedFen)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (game) => {
onSuccess();
void this.router.navigate(['/game', game.gameId]);
},
error: (error) => {
onError(getErrorMessage(error, 'FEN import failed.'));
}
});
}
importPgn(
pgn: string,
onSuccess: () => void,
onError: (error: string) => void
): void {
const trimmedPgn = pgn.trim();
if (!trimmedPgn) {
onError('Please provide a PGN string.');
return;
}
this.gameApi
.importPgn(trimmedPgn)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (game) => {
onSuccess();
void this.router.navigate(['/game', game.gameId]);
},
error: (error) => {
onError(getErrorMessage(error, 'PGN import failed.'));
}
});
}
}
+63
View File
@@ -0,0 +1,63 @@
import { Injectable, DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval, startWith, Subscription, switchMap } from 'rxjs';
import { GameApiService } from './game-api.service';
import { GameStreamEvent } from '../models/game.models';
@Injectable({ providedIn: 'root' })
export class GameStreamService {
private readonly gameApi = inject(GameApiService);
private readonly destroyRef = inject(DestroyRef);
private streamSubscription: Subscription | null = null;
private pollSubscription: Subscription | null = null;
startStreaming(
gameId: string,
onEvent: (event: GameStreamEvent) => void,
onStreamError: () => void
): void {
this.streamSubscription = this.gameApi
.streamGame(gameId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (event) => onEvent(event),
error: () => {
onStreamError();
this.startPolling(gameId, onEvent);
},
complete: () => {
onStreamError();
this.startPolling(gameId, onEvent);
}
});
}
startPolling(gameId: string, onEvent: (event: GameStreamEvent) => void): void {
if (this.pollSubscription) {
return;
}
this.pollSubscription = interval(1500)
.pipe(
startWith(0),
switchMap(() => this.gameApi.getGame(gameId)),
takeUntilDestroyed(this.destroyRef)
)
.subscribe({
next: (game) => {
const event: GameStreamEvent = {
type: 'gameFull',
game
};
onEvent(event);
}
});
}
cleanup(): void {
this.streamSubscription?.unsubscribe();
this.pollSubscription?.unsubscribe();
this.streamSubscription = null;
this.pollSubscription = null;
}
}
+122
View File
@@ -0,0 +1,122 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { GameStreamEvent, ErrorEvent } from '../models/game.models';
@Injectable({ providedIn: 'root' })
export class StreamHandlerService {
createGameStream(wsUrl: string, fallbackUrl: string, gameId: string): Observable<GameStreamEvent> {
return new Observable<GameStreamEvent>((observer) => {
const ws = new WebSocket(wsUrl);
const abortController = new AbortController();
let connected = false;
let fallbackActive = false;
const parseEvent = (raw: string): GameStreamEvent | null => {
if (!raw.trim()) {
return null;
}
try {
return JSON.parse(raw) as GameStreamEvent;
} catch {
return null;
}
};
const emitErrorEvent = (message: string): void => {
const errorEvent: ErrorEvent = {
type: 'error',
error: { code: 'STREAM_ERROR', message }
};
observer.next(errorEvent);
};
const startNdjsonFallback = async (): Promise<void> => {
if (fallbackActive) {
return;
}
fallbackActive = true;
console.log(`[StreamHandler] NDJSON fallback started for ${gameId}, URL:`, fallbackUrl);
try {
const response = await fetch(fallbackUrl, {
headers: { Accept: 'application/x-ndjson' },
signal: abortController.signal
});
if (!response.ok || !response.body) {
console.error(`[StreamHandler] NDJSON fetch failed: HTTP ${response.status}`);
emitErrorEvent(`Unable to open stream: HTTP ${response.status}`);
observer.complete();
return;
}
console.log(`[StreamHandler] NDJSON stream connected for ${gameId}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const event = parseEvent(line);
if (event) {
observer.next(event);
}
}
}
observer.complete();
} catch (error) {
if ((error as Error).name !== 'AbortError') {
emitErrorEvent((error as Error).message);
observer.error(error);
}
}
};
ws.onopen = () => {
connected = true;
};
ws.onmessage = (message) => {
const payload = typeof message.data === 'string' ? message.data : '';
const event = parseEvent(payload);
if (event) {
observer.next(event);
}
};
ws.onerror = (error) => {
console.warn(`[StreamHandler] WebSocket error for ${gameId}, attempting NDJSON fallback:`, error);
if (!connected) {
void startNdjsonFallback();
}
};
ws.onclose = () => {
console.warn(`[StreamHandler] WebSocket closed for ${gameId}, connected=${connected}`);
if (!connected) {
console.log(`[StreamHandler] Starting NDJSON fallback for ${gameId}`);
void startNdjsonFallback();
} else {
observer.complete();
}
};
return () => {
abortController.abort();
ws.close();
};
});
}
}