2 Commits

Author SHA1 Message Date
shahdlala66 4f76bcc7c6 feat: piece pormotion 2026-04-22 08:35:58 +02:00
shahdlala66 25b69fd7b6 feat: clean ups and shorter files 2026-04-22 08:28:16 +02:00
13 changed files with 793 additions and 340 deletions
@@ -0,0 +1,131 @@
.promotion-dialog-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
z-index: 1000;
&.open {
opacity: 1;
visibility: visible;
}
}
.promotion-dialog {
background: white;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
max-width: 400px;
width: 90%;
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.promotion-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #e0e0e0;
h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #333;
}
.close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #999;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
&:hover {
color: #333;
}
}
}
.promotion-body {
padding: 20px;
}
.promotion-prompt {
margin: 0 0 20px 0;
text-align: center;
color: #666;
font-size: 14px;
}
.promotion-options {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.promotion-button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 16px;
background: #f5f5f5;
border: 2px solid #ddd;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
&:hover {
background: #e8e8e8;
border-color: #999;
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
&:active {
transform: translateY(0);
box-shadow: none;
}
.piece-symbol {
font-size: 32px;
line-height: 1;
}
.piece-label {
font-size: 12px;
font-weight: 500;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
@@ -0,0 +1,26 @@
<div class="promotion-dialog-overlay" [class.open]="isOpen">
<div class="promotion-dialog">
<div class="promotion-header">
<h3>Pawn Promotion</h3>
<button class="close-btn" (click)="close()" aria-label="Close">&times;</button>
</div>
<div class="promotion-body">
<p class="promotion-prompt">Choose a piece to promote your pawn to:</p>
<div class="promotion-options">
@for (piece of promotionPieces; track piece.type) {
<button
class="promotion-button"
[attr.data-piece]="piece.type"
(click)="selectPromotion(piece.type)"
[title]="piece.label"
>
<span class="piece-symbol">{{ piece.symbol }}</span>
<span class="piece-label">{{ piece.label }}</span>
</button>
}
</div>
</div>
</div>
</div>
@@ -0,0 +1,39 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
type PromotionPieceType = 'queen' | 'rook' | 'bishop' | 'knight';
interface PromotionPieceOption {
type: PromotionPieceType;
label: string;
symbol: string;
}
@Component({
selector: 'app-promotion-dialog',
standalone: true,
imports: [CommonModule],
templateUrl: './promotion-dialog.component.html',
styleUrl: './promotion-dialog.component.css'
})
export class PromotionDialogComponent {
@Input() isOpen = false;
@Output() promotionSelected = new EventEmitter<PromotionPieceType>();
@Output() closed = new EventEmitter<void>();
promotionPieces: PromotionPieceOption[] = [
{ type: 'queen', label: 'Queen', symbol: '♕' },
{ type: 'rook', label: 'Rook', symbol: '♖' },
{ type: 'bishop', label: 'Bishop', symbol: '♗' },
{ type: 'knight', label: 'Knight', symbol: '♘' }
];
selectPromotion(type: PromotionPieceType): void {
this.promotionSelected.emit(type);
this.isOpen = false;
}
close(): void {
this.closed.emit();
this.isOpen = false;
}
}
+6
View File
@@ -1,4 +1,10 @@
<main class="game-shell">
<app-promotion-dialog
[isOpen]="facade.isPromotionDialogOpen"
(promotionSelected)="facade.onPromotionSelected($event)"
(closed)="facade.onPromotionClosed()"
/>
<section class="game-card">
<header class="mb-3">
<a routerLink="/" class="back-link">Back</a>
+3 -2
View File
@@ -4,13 +4,14 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ChessBoardComponent } from '../../components/chess-board/chess-board.component';
import { InputCardComponent } from '../../components/input-card/input-card.component';
import { InputCardComponent } from '../../components/input-card/input-card.component';
import { PromotionDialogComponent } from '../../components/promotion-dialog/promotion-dialog.component';
import { GameFacade } from './game.facade';
@Component({
selector: 'app-game',
standalone: true,
imports: [CommonModule, FormsModule, RouterLink, ChessBoardComponent, InputCardComponent],
imports: [CommonModule, FormsModule, RouterLink, ChessBoardComponent, InputCardComponent, PromotionDialogComponent],
providers: [GameFacade],
templateUrl: './game.component.html',
styleUrl: './game.component.css'
+147 -220
View File
@@ -1,11 +1,13 @@
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, 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';
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()
export class GameFacade implements OnDestroy {
@@ -16,88 +18,40 @@ export class GameFacade implements OnDestroy {
fenInput = '';
pgnInput = '';
loading = true;
selectedSquare: string | null = null;
highlightedSquares: string[] = [];
gameCompletionMessage = '';
isGameFinished = false;
isPromotionDialogOpen = false;
private boardSelection: BoardSelection = {
selectedSquare: null,
highlightedSquares: [],
selectedSquareMoves: []
};
private pendingPromotionMoves: LegalMove[] = [];
private selectedSquareMoves: LegalMove[] = [];
private readonly router = inject(Router);
private readonly gameApi = inject(GameApiService);
private readonly destroyRef = inject(DestroyRef);
private streamSubscription: Subscription | null = null;
private pollSubscription: Subscription | null = null;
private botMoveSubscription: Subscription | null = null;
private getGameCompletionMessage(): void {
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();
}
private readonly botMoveService = inject(BotMoveService);
private readonly completionService = inject(GameCompletionService);
private readonly importService = inject(GameImportService);
private readonly boardSelectionService = inject(BoardSelectionService);
private readonly streamService = inject(GameStreamService);
get state(): GameState | null {
return this.game?.state ?? null;
}
private isBotPlayer(playerId: string): boolean {
return playerId.startsWith('bot-');
get selectedSquare(): string | null {
return this.boardSelection.selectedSquare;
}
private isPlayingAgainstBot(): boolean {
if (!this.game) {
return false;
}
return this.isBotPlayer(this.game.white.id) || this.isBotPlayer(this.game.black.id);
get highlightedSquares(): string[] {
return this.boardSelection.highlightedSquares;
}
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);
ngOnDestroy(): void {
this.streamService.cleanup();
this.botMoveService.cleanup();
}
setGameId(gameId: string): void {
@@ -110,36 +64,45 @@ export class GameFacade implements OnDestroy {
return;
}
if (this.selectedSquare && this.highlightedSquares.includes(square)) {
const selectedMove = this.selectedSquareMoves.find((move) => move.to === square);
// Handle move selection
if (this.boardSelection.selectedSquare && this.boardSelection.highlightedSquares.includes(square)) {
const selectedMove = this.boardSelection.selectedSquareMoves.find((move) => move.to === square);
if (selectedMove) {
// If multiple promotion outcomes exist for the target, ask player to choose one.
const promotionMoves = this.boardSelection.selectedSquareMoves.filter(
(move) => move.to === square && !!move.promotion
);
if (promotionMoves.length > 0) {
this.pendingPromotionMoves = promotionMoves;
this.isPromotionDialogOpen = true;
return;
}
this.moveInput = selectedMove.uci;
this.submitMove();
}
return;
}
const piece = getPieceAtSquare(this.state.fen, square);
if (!piece || !isPieceColor(piece, this.state.turn)) {
this.clearSelection();
return;
}
// Load moves for selected square
this.errorMessage = '';
this.gameApi
.getLegalMoves(this.gameId, square)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (response) => {
this.selectedSquare = square;
this.selectedSquareMoves = response.moves;
this.highlightedSquares = response.moves.map((move) => move.to);
},
error: () => {
this.clearSelection();
this.errorMessage = 'Could not load legal moves for selected square.';
}
});
const newSelection = this.boardSelectionService.handleSquareSelection(
square,
this.gameId,
this.state,
this.boardSelection,
(moves) => {
this.boardSelection = {
selectedSquare: square,
highlightedSquares: moves.map((move) => move.to),
selectedSquareMoves: moves
};
},
(error) => {
this.errorMessage = error;
this.boardSelection = this.boardSelectionService.clearSelection();
}
);
this.boardSelection = newSelection;
}
submitMove(): void {
@@ -156,10 +119,25 @@ export class GameFacade implements OnDestroy {
next: (state) => {
if (this.game) {
this.game = { ...this.game, state };
this.updateGameCompletion();
}
this.moveInput = '';
this.clearSelection();
this.tryMakeBotMove();
this.boardSelection = this.boardSelectionService.clearSelection();
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) => {
this.errorMessage = getErrorMessage(error, 'Move rejected.');
@@ -167,60 +145,61 @@ export class GameFacade implements OnDestroy {
});
}
importFen(): void {
const fen = this.fenInput.trim();
if (!fen) {
this.errorMessage = 'Please provide a FEN string.';
onPromotionSelected(promotionPiece: 'queen' | 'rook' | 'bishop' | 'knight'): void {
const selectedPromotionMove = this.pendingPromotionMoves.find((move) => move.promotion === promotionPiece);
if (!selectedPromotionMove) {
this.errorMessage = 'Selected promotion move is unavailable.';
this.isPromotionDialogOpen = false;
this.pendingPromotionMoves = [];
return;
}
this.moveInput = selectedPromotionMove.uci;
this.isPromotionDialogOpen = false;
this.boardSelection = this.boardSelectionService.clearSelection();
this.pendingPromotionMoves = [];
this.submitMove();
}
onPromotionClosed(): void {
this.isPromotionDialogOpen = false;
this.boardSelection = this.boardSelectionService.clearSelection();
this.pendingPromotionMoves = [];
}
importFen(): void {
this.errorMessage = '';
this.gameApi
.importFen(fen)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (game) => {
this.fenInput = '';
this.pgnInput = '';
void this.router.navigate(['/game', game.gameId]);
},
error: (error) => {
this.errorMessage = getErrorMessage(error, 'FEN import failed.');
}
});
this.importService.importFen(
this.fenInput,
() => {
this.fenInput = '';
this.pgnInput = '';
},
(error) => {
this.errorMessage = error;
}
);
}
importPgn(): void {
const pgn = this.pgnInput.trim();
if (!pgn) {
this.errorMessage = 'Please provide a PGN string.';
return;
}
this.errorMessage = '';
this.gameApi
.importPgn(pgn)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (game) => {
this.pgnInput = '';
this.fenInput = '';
void this.router.navigate(['/game', game.gameId]);
},
error: (error) => {
this.errorMessage = getErrorMessage(error, 'PGN import failed.');
}
});
this.importService.importPgn(
this.pgnInput,
() => {
this.pgnInput = '';
this.fenInput = '';
},
(error) => {
this.errorMessage = error;
}
);
}
private loadGame(): void {
this.loading = true;
this.errorMessage = '';
this.clearSelection();
this.streamSubscription?.unsubscribe();
this.pollSubscription?.unsubscribe();
this.botMoveSubscription?.unsubscribe();
this.pollSubscription = null;
this.boardSelection = this.boardSelectionService.clearSelection();
this.streamService.cleanup();
this.gameApi
.getGame(this.gameId)
@@ -229,8 +208,8 @@ export class GameFacade implements OnDestroy {
next: (game) => {
this.game = game;
this.loading = false;
this.getGameCompletionMessage();
this.startStream();
this.updateGameCompletion();
this.startStreaming();
this.tryMakeBotMove();
},
error: (error) => {
@@ -240,52 +219,21 @@ export class GameFacade implements OnDestroy {
});
}
private startStream(): void {
this.streamSubscription = this.gameApi
.streamGame(this.gameId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (event) => this.applyStreamEvent(event),
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 startStreaming(): void {
this.streamService.startStreaming(
this.gameId,
(event) => this.applyStreamEvent(event),
() => {
this.errorMessage = 'Live stream disconnected. Falling back to polling.';
}
);
}
private applyStreamEvent(event: GameStreamEvent): void {
if (event.type === 'gameFull') {
this.game = event.game;
this.clearSelection();
this.getGameCompletionMessage();
this.boardSelection = this.boardSelectionService.clearSelection();
this.updateGameCompletion();
this.tryMakeBotMove();
return;
}
@@ -293,9 +241,9 @@ export class GameFacade implements OnDestroy {
if (event.type === 'gameState' && this.game) {
const moveCountBefore = this.game.state.moves.length;
this.game = { ...this.game, state: event.state };
this.getGameCompletionMessage();
this.updateGameCompletion();
if (event.state.moves.length !== moveCountBefore) {
this.clearSelection();
this.boardSelection = this.boardSelectionService.clearSelection();
this.tryMakeBotMove();
}
return;
@@ -307,47 +255,26 @@ 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.';
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;
}
);
}
private clearSelection(): void {
this.selectedSquare = null;
this.selectedSquareMoves = [];
this.highlightedSquares = [];
private updateGameCompletion(): void {
const completion = this.completionService.getGameCompletion(this.game, this.state);
this.gameCompletionMessage = completion.message;
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 { 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();
};
});
}
}