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
+117 -223
View File
@@ -1,11 +1,13 @@
import { DestroyRef, Injectable, OnDestroy, inject } from '@angular/core'; import { DestroyRef, Injectable, OnDestroy, inject } from '@angular/core';
import { Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
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 { getErrorMessage } from '../../core/http/error-message.util';
import { GameFull, GameState, GameStreamEvent, LegalMove } from '../../models/game.models'; import { GameFull, GameState, GameStreamEvent } from '../../models/game.models';
import { GameApiService } from '../../services/game-api.service'; import { GameApiService } from '../../services/game-api.service';
import { BotMoveService } from '../../services/bot-move.service';
import { GameCompletionService } from '../../services/game-completion.service';
import { GameImportService } from '../../services/game-import.service';
import { BoardSelectionService, BoardSelection } from '../../services/board-selection.service';
import { GameStreamService } from '../../services/game-stream.service';
@Injectable() @Injectable()
export class GameFacade implements OnDestroy { export class GameFacade implements OnDestroy {
@@ -16,88 +18,38 @@ export class GameFacade implements OnDestroy {
fenInput = ''; fenInput = '';
pgnInput = ''; pgnInput = '';
loading = true; loading = true;
selectedSquare: string | null = null;
highlightedSquares: string[] = [];
gameCompletionMessage = ''; gameCompletionMessage = '';
isGameFinished = false; isGameFinished = false;
private selectedSquareMoves: LegalMove[] = []; private boardSelection: BoardSelection = {
private readonly router = inject(Router); selectedSquare: null,
highlightedSquares: [],
selectedSquareMoves: []
};
private readonly gameApi = inject(GameApiService); private readonly gameApi = inject(GameApiService);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private streamSubscription: Subscription | null = null; private readonly botMoveService = inject(BotMoveService);
private pollSubscription: Subscription | null = null; private readonly completionService = inject(GameCompletionService);
private botMoveSubscription: Subscription | null = null; private readonly importService = inject(GameImportService);
private readonly boardSelectionService = inject(BoardSelectionService);
private getGameCompletionMessage(): void { private readonly streamService = inject(GameStreamService);
if (!this.game || !this.state) {
this.gameCompletionMessage = '';
this.isGameFinished = false;
return;
}
const status = this.state.status;
const gameEndingStatuses = ['checkmate', 'stalemate', 'resign', 'draw', 'insufficientMaterial'];
if (!gameEndingStatuses.includes(status)) {
this.gameCompletionMessage = '';
this.isGameFinished = false;
return;
}
this.isGameFinished = true;
switch (status) {
case 'checkmate':
const winner = this.state.winner === 'white' ? this.game.white.displayName : this.game.black.displayName;
this.gameCompletionMessage = `Checkmate! ${winner} wins!`;
break;
case 'stalemate':
this.gameCompletionMessage = 'Stalemate! The game is a draw.';
break;
case 'resign':
const resignedPlayer = this.state.winner === 'white' ? this.game.black.displayName : this.game.white.displayName;
const resignedWinner = this.state.winner === 'white' ? this.game.white.displayName : this.game.black.displayName;
this.gameCompletionMessage = `${resignedPlayer} resigned. ${resignedWinner} wins!`;
break;
case 'draw':
this.gameCompletionMessage = 'Draw! The game ended in a draw.';
break;
case 'insufficientMaterial':
this.gameCompletionMessage = 'Insufficient material! The game is a draw.';
break;
default:
this.gameCompletionMessage = 'Game ended!';
}
}
ngOnDestroy(): void {
this.streamSubscription?.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 { get selectedSquare(): string | null {
return playerId.startsWith('bot-'); return this.boardSelection.selectedSquare;
} }
private isPlayingAgainstBot(): boolean { get highlightedSquares(): string[] {
if (!this.game) { return this.boardSelection.highlightedSquares;
return false;
}
return this.isBotPlayer(this.game.white.id) || this.isBotPlayer(this.game.black.id);
} }
private isCurrentPlayerBot(): boolean { ngOnDestroy(): void {
if (!this.game || !this.state) { this.streamService.cleanup();
return false; this.botMoveService.cleanup();
}
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 {
@@ -110,8 +62,9 @@ export class GameFacade implements OnDestroy {
return; return;
} }
if (this.selectedSquare && this.highlightedSquares.includes(square)) { // Handle move selection
const selectedMove = this.selectedSquareMoves.find((move) => move.to === square); if (this.boardSelection.selectedSquare && this.boardSelection.highlightedSquares.includes(square)) {
const selectedMove = this.boardSelection.selectedSquareMoves.find((move) => move.to === square);
if (selectedMove) { if (selectedMove) {
this.moveInput = selectedMove.uci; this.moveInput = selectedMove.uci;
this.submitMove(); this.submitMove();
@@ -119,27 +72,26 @@ export class GameFacade implements OnDestroy {
return; return;
} }
const piece = getPieceAtSquare(this.state.fen, square); // Load moves for selected square
if (!piece || !isPieceColor(piece, this.state.turn)) {
this.clearSelection();
return;
}
this.errorMessage = ''; this.errorMessage = '';
this.gameApi const newSelection = this.boardSelectionService.handleSquareSelection(
.getLegalMoves(this.gameId, square) square,
.pipe(takeUntilDestroyed(this.destroyRef)) this.gameId,
.subscribe({ this.state,
next: (response) => { this.boardSelection,
this.selectedSquare = square; (moves) => {
this.selectedSquareMoves = response.moves; this.boardSelection = {
this.highlightedSquares = response.moves.map((move) => move.to); selectedSquare: square,
}, highlightedSquares: moves.map((move) => move.to),
error: () => { selectedSquareMoves: moves
this.clearSelection(); };
this.errorMessage = 'Could not load legal moves for selected square.'; },
} (error) => {
}); this.errorMessage = error;
this.boardSelection = this.boardSelectionService.clearSelection();
}
);
this.boardSelection = newSelection;
} }
submitMove(): void { submitMove(): void {
@@ -156,10 +108,25 @@ export class GameFacade implements OnDestroy {
next: (state) => { next: (state) => {
if (this.game) { if (this.game) {
this.game = { ...this.game, state }; this.game = { ...this.game, state };
this.updateGameCompletion();
} }
this.moveInput = ''; this.moveInput = '';
this.clearSelection(); this.boardSelection = this.boardSelectionService.clearSelection();
this.tryMakeBotMove(); this.botMoveService.tryMakeBotMove(
this.gameId,
this.game,
this.state,
(updatedState) => {
if (this.game) {
this.game = { ...this.game, state: updatedState };
this.updateGameCompletion();
}
this.boardSelection = this.boardSelectionService.clearSelection();
},
(error) => {
this.errorMessage = error;
}
);
}, },
error: (error) => { error: (error) => {
this.errorMessage = getErrorMessage(error, 'Move rejected.'); this.errorMessage = getErrorMessage(error, 'Move rejected.');
@@ -168,59 +135,38 @@ export class GameFacade implements OnDestroy {
} }
importFen(): void { importFen(): void {
const fen = this.fenInput.trim();
if (!fen) {
this.errorMessage = 'Please provide a FEN string.';
return;
}
this.errorMessage = ''; this.errorMessage = '';
this.gameApi this.importService.importFen(
.importFen(fen) this.fenInput,
.pipe(takeUntilDestroyed(this.destroyRef)) () => {
.subscribe({ this.fenInput = '';
next: (game) => { this.pgnInput = '';
this.fenInput = ''; },
this.pgnInput = ''; (error) => {
void this.router.navigate(['/game', game.gameId]); this.errorMessage = error;
}, }
error: (error) => { );
this.errorMessage = getErrorMessage(error, 'FEN import failed.');
}
});
} }
importPgn(): void { importPgn(): void {
const pgn = this.pgnInput.trim();
if (!pgn) {
this.errorMessage = 'Please provide a PGN string.';
return;
}
this.errorMessage = ''; this.errorMessage = '';
this.gameApi this.importService.importPgn(
.importPgn(pgn) this.pgnInput,
.pipe(takeUntilDestroyed(this.destroyRef)) () => {
.subscribe({ this.pgnInput = '';
next: (game) => { this.fenInput = '';
this.pgnInput = ''; },
this.fenInput = ''; (error) => {
void this.router.navigate(['/game', game.gameId]); this.errorMessage = error;
}, }
error: (error) => { );
this.errorMessage = getErrorMessage(error, 'PGN import failed.');
}
});
} }
private loadGame(): void { private loadGame(): void {
this.loading = true; this.loading = true;
this.errorMessage = ''; this.errorMessage = '';
this.clearSelection(); this.boardSelection = this.boardSelectionService.clearSelection();
this.streamSubscription?.unsubscribe(); this.streamService.cleanup();
this.pollSubscription?.unsubscribe();
this.botMoveSubscription?.unsubscribe();
this.pollSubscription = null;
this.gameApi this.gameApi
.getGame(this.gameId) .getGame(this.gameId)
@@ -229,8 +175,8 @@ export class GameFacade implements OnDestroy {
next: (game) => { next: (game) => {
this.game = game; this.game = game;
this.loading = false; this.loading = false;
this.getGameCompletionMessage(); this.updateGameCompletion();
this.startStream(); this.startStreaming();
this.tryMakeBotMove(); this.tryMakeBotMove();
}, },
error: (error) => { error: (error) => {
@@ -240,52 +186,21 @@ export class GameFacade implements OnDestroy {
}); });
} }
private startStream(): void { private startStreaming(): void {
this.streamSubscription = this.gameApi this.streamService.startStreaming(
.streamGame(this.gameId) this.gameId,
.pipe(takeUntilDestroyed(this.destroyRef)) (event) => this.applyStreamEvent(event),
.subscribe({ () => {
next: (event) => this.applyStreamEvent(event), this.errorMessage = 'Live stream disconnected. Falling back to polling.';
error: () => { }
this.errorMessage = 'Live stream disconnected. Falling back to polling.'; );
this.startPolling();
},
complete: () => {
this.errorMessage = 'Live stream ended. Falling back to polling.';
this.startPolling();
}
});
}
private startPolling(): void {
if (this.pollSubscription) {
return;
}
this.pollSubscription = interval(1500)
.pipe(
startWith(0),
switchMap(() => this.gameApi.getGame(this.gameId)),
takeUntilDestroyed(this.destroyRef)
)
.subscribe({
next: (game) => {
const previousMoves = this.game?.state.moves.join(',') ?? '';
this.game = game;
this.getGameCompletionMessage();
if (previousMoves !== game.state.moves.join(',')) {
this.clearSelection();
this.tryMakeBotMove();
}
}
});
} }
private applyStreamEvent(event: GameStreamEvent): void { private applyStreamEvent(event: GameStreamEvent): void {
if (event.type === 'gameFull') { if (event.type === 'gameFull') {
this.game = event.game; this.game = event.game;
this.clearSelection(); this.boardSelection = this.boardSelectionService.clearSelection();
this.getGameCompletionMessage(); this.updateGameCompletion();
this.tryMakeBotMove(); this.tryMakeBotMove();
return; return;
} }
@@ -293,9 +208,9 @@ export class GameFacade implements OnDestroy {
if (event.type === 'gameState' && this.game) { if (event.type === 'gameState' && this.game) {
const moveCountBefore = this.game.state.moves.length; const moveCountBefore = this.game.state.moves.length;
this.game = { ...this.game, state: event.state }; this.game = { ...this.game, state: event.state };
this.getGameCompletionMessage(); this.updateGameCompletion();
if (event.state.moves.length !== moveCountBefore) { if (event.state.moves.length !== moveCountBefore) {
this.clearSelection(); this.boardSelection = this.boardSelectionService.clearSelection();
this.tryMakeBotMove(); this.tryMakeBotMove();
} }
return; return;
@@ -307,47 +222,26 @@ export class GameFacade implements OnDestroy {
} }
private tryMakeBotMove(): void { private tryMakeBotMove(): void {
if (!this.isPlayingAgainstBot() || !this.isCurrentPlayerBot() || !this.state) { this.botMoveService.tryMakeBotMove(
return; this.gameId,
} this.game,
this.state,
this.botMoveSubscription?.unsubscribe(); (updatedState) => {
this.botMoveSubscription = this.gameApi if (this.game) {
.getLegalMoves(this.gameId) this.game = { ...this.game, state: updatedState };
.pipe( this.updateGameCompletion();
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.';
} }
}); this.boardSelection = this.boardSelectionService.clearSelection();
},
(error) => {
this.errorMessage = error;
}
);
} }
private clearSelection(): void { private updateGameCompletion(): void {
this.selectedSquare = null; const completion = this.completionService.getGameCompletion(this.game, this.state);
this.selectedSquareMoves = []; this.gameCompletionMessage = completion.message;
this.highlightedSquares = []; this.isGameFinished = completion.isFinished;
} }
} }
@@ -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 { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
import { import {
ErrorEvent,
GameFull, GameFull,
GameState, GameState,
GameStreamEvent, GameStreamEvent,
LegalMovesResponse, LegalMovesResponse,
PlayerInfo PlayerInfo
} from '../models/game.models'; } from '../models/game.models';
import { StreamHandlerService } from './stream-handler.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class GameApiService { export class GameApiService {
private readonly apiBase = environment.apiBaseUrl; private readonly apiBase = environment.apiBaseUrl;
private readonly wsBase = environment.wsBaseUrl; private readonly wsBase = environment.wsBaseUrl;
private readonly apiPath = environment.apiPath; private readonly apiPath = environment.apiPath;
private readonly streamHandler = inject(StreamHandlerService);
constructor(private readonly http: HttpClient) {} constructor(private readonly http: HttpClient) {}
@@ -76,121 +77,8 @@ export class GameApiService {
} }
streamGame(gameId: string): Observable<GameStreamEvent> { streamGame(gameId: string): Observable<GameStreamEvent> {
return new Observable<GameStreamEvent>((observer) => { const wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/stream`;
const wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/stream`; const fallbackUrl = `${this.apiBase}${this.apiPath}/${gameId}/stream`;
const streamUrl = `${this.apiBase}${this.apiPath}/${gameId}/stream`; return this.streamHandler.createGameStream(wsUrl, fallbackUrl, gameId);
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();
};
});
} }
} }
@@ -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();
};
});
}
}