diff --git a/src/app/core/chess/fen.utils.ts b/src/app/core/chess/fen.utils.ts new file mode 100644 index 0000000..ff67133 --- /dev/null +++ b/src/app/core/chess/fen.utils.ts @@ -0,0 +1,38 @@ +import { GameTurn } from '../../models/game.models'; + +export function getPieceAtSquare(fen: string, targetSquare: string): string | null { + const placement = fen.split(' ')[0] ?? ''; + const rows = placement.split('/'); + if (rows.length !== 8 || targetSquare.length !== 2) { + return null; + } + + const file = targetSquare.charCodeAt(0) - 97; + const rank = Number(targetSquare[1]); + const rowIndex = 8 - rank; + + if (Number.isNaN(rank) || file < 0 || file > 7 || rowIndex < 0 || rowIndex > 7) { + return null; + } + + let column = 0; + for (const symbol of rows[rowIndex]) { + if (symbol >= '1' && symbol <= '8') { + column += Number(symbol); + continue; + } + + if (column === file) { + return symbol; + } + + column += 1; + } + + return null; +} + +export function isPieceColor(pieceCode: string, turn: GameTurn): boolean { + const isWhitePiece = pieceCode === pieceCode.toUpperCase(); + return (turn === 'white' && isWhitePiece) || (turn === 'black' && !isWhitePiece); +} diff --git a/src/app/core/http/error-message.util.ts b/src/app/core/http/error-message.util.ts new file mode 100644 index 0000000..86e9423 --- /dev/null +++ b/src/app/core/http/error-message.util.ts @@ -0,0 +1,8 @@ +export function getErrorMessage(error: unknown, fallback: string): string { + if (!error || typeof error !== 'object') { + return fallback; + } + + const httpError = error as { error?: { message?: unknown } }; + return typeof httpError.error?.message === 'string' ? httpError.error.message : fallback; +} diff --git a/src/app/pages/game/game.component.html b/src/app/pages/game/game.component.html index 05397c4..64a6f81 100644 --- a/src/app/pages/game/game.component.html +++ b/src/app/pages/game/game.component.html @@ -3,30 +3,30 @@
Back

1 vs 1 Game

-

Game ID: {{ gameId }}

+

Game ID: {{ facade.gameId }}

- @if (loading) { + @if (facade.loading) {

Loading game state...

- } @else if (state) { + } @else if (facade.state) {
-
+ - +

Click your piece to highlight legal targets.

@@ -34,10 +34,10 @@
@@ -47,17 +47,17 @@ - +
} - @if (errorMessage) { -

{{ errorMessage }}

+ @if (facade.errorMessage) { +

{{ facade.errorMessage }}

}
diff --git a/src/app/pages/game/game.component.ts b/src/app/pages/game/game.component.ts index 28f130d..d269243 100644 --- a/src/app/pages/game/game.component.ts +++ b/src/app/pages/game/game.component.ts @@ -1,272 +1,34 @@ import { CommonModule } from '@angular/common'; -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Component, DestroyRef, OnInit, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; -import { ActivatedRoute, Router, RouterLink } from '@angular/router'; -import { interval, startWith, Subscription, switchMap } from 'rxjs'; +import { ActivatedRoute, RouterLink } from '@angular/router'; import { ChessBoardComponent } from '../../components/chess-board/chess-board.component'; -import { GameFull, GameState, GameStreamEvent, LegalMove } from '../../models/game.models'; -import { GameApiService } from '../../services/game-api.service'; +import { GameFacade } from './game.facade'; @Component({ selector: 'app-game', standalone: true, imports: [CommonModule, FormsModule, RouterLink, ChessBoardComponent], + providers: [GameFacade], templateUrl: './game.component.html', styleUrl: './game.component.css' }) -export class GameComponent implements OnInit, OnDestroy { - gameId = ''; - game: GameFull | null = null; - errorMessage = ''; - moveInput = ''; - fenInput = ''; - pgnInput = ''; - loading = true; - selectedSquare: string | null = null; - highlightedSquares: string[] = []; - - private selectedSquareMoves: LegalMove[] = []; - private streamSubscription: Subscription | null = null; - private pollSubscription: Subscription | null = null; - private routeSubscription: Subscription | null = null; - - constructor( - private readonly route: ActivatedRoute, - private readonly router: Router, - private readonly gameApi: GameApiService - ) {} - - get state(): GameState | null { - return this.game?.state ?? null; - } +export class GameComponent implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly destroyRef = inject(DestroyRef); + readonly facade = inject(GameFacade); ngOnInit(): void { - this.routeSubscription = this.route.paramMap.subscribe((paramMap) => { + this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((paramMap) => { const id = paramMap.get('gameId'); if (!id) { - this.errorMessage = 'Missing gameId in route.'; - this.loading = false; + this.facade.errorMessage = 'Missing gameId in route.'; + this.facade.loading = false; return; } - this.gameId = id; - this.loadGame(); + this.facade.setGameId(id); }); } - - ngOnDestroy(): void { - this.routeSubscription?.unsubscribe(); - this.streamSubscription?.unsubscribe(); - this.pollSubscription?.unsubscribe(); - } - - onBoardSquareSelected(square: string): void { - if (!this.state) { - return; - } - - if (this.selectedSquare && this.highlightedSquares.includes(square)) { - const selectedMove = this.selectedSquareMoves.find((move) => move.to === square); - if (selectedMove) { - this.moveInput = selectedMove.uci; - this.submitMove(); - } - return; - } - - const piece = this.getPieceAtSquare(this.state.fen, square); - if (!piece || !this.isCurrentTurnPiece(piece)) { - this.clearSelection(); - return; - } - - this.errorMessage = ''; - this.gameApi.getLegalMoves(this.gameId, square).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.'; - } - }); - } - - submitMove(): void { - const uci = this.moveInput.trim(); - if (!uci) { - return; - } - - this.errorMessage = ''; - this.gameApi.makeMove(this.gameId, uci).subscribe({ - next: (state) => { - if (this.game) { - this.game = { ...this.game, state }; - } - this.moveInput = ''; - this.clearSelection(); - }, - error: (error: { error?: { message?: string } }) => { - this.errorMessage = error.error?.message ?? 'Move rejected.'; - } - }); - } - - importFen(): void { - const fen = this.fenInput.trim(); - if (!fen) { - this.errorMessage = 'Please provide a FEN string.'; - return; - } - - this.errorMessage = ''; - this.gameApi.importFen(fen).subscribe({ - next: (game) => { - this.fenInput = ''; - this.pgnInput = ''; - void this.router.navigate(['/game', game.gameId]); - }, - error: (error: { error?: { message?: string } }) => { - this.errorMessage = error.error?.message ?? 'FEN import failed.'; - } - }); - } - - importPgn(): void { - const pgn = this.pgnInput.trim(); - if (!pgn) { - this.errorMessage = 'Please provide a PGN string.'; - return; - } - - this.errorMessage = ''; - this.gameApi.importPgn(pgn).subscribe({ - next: (game) => { - this.pgnInput = ''; - this.fenInput = ''; - void this.router.navigate(['/game', game.gameId]); - }, - error: (error: { error?: { message?: string } }) => { - this.errorMessage = error.error?.message ?? 'PGN import failed.'; - } - }); - } - - private loadGame(): void { - this.loading = true; - this.errorMessage = ''; - this.clearSelection(); - this.streamSubscription?.unsubscribe(); - this.pollSubscription?.unsubscribe(); - - this.gameApi.getGame(this.gameId).subscribe({ - next: (game) => { - this.game = game; - this.loading = false; - this.startStream(); - this.startPolling(); - }, - error: (error: { error?: { message?: string } }) => { - this.errorMessage = error.error?.message ?? `Could not load game ${this.gameId}.`; - this.loading = false; - } - }); - } - - private startStream(): void { - this.streamSubscription = this.gameApi.streamGame(this.gameId).subscribe({ - next: (event) => this.applyStreamEvent(event), - error: () => { - this.errorMessage = 'Live stream disconnected.'; - } - }); - } - - private startPolling(): void { - this.pollSubscription = interval(1500) - .pipe( - startWith(0), - switchMap(() => this.gameApi.getGame(this.gameId)) - ) - .subscribe({ - next: (game) => { - const previousMoves = this.game?.state.moves.join(',') ?? ''; - this.game = game; - if (previousMoves !== game.state.moves.join(',')) { - this.clearSelection(); - } - } - }); - } - - private applyStreamEvent(event: GameStreamEvent): void { - if (event.type === 'gameFull') { - this.game = event.game; - this.clearSelection(); - return; - } - - if (event.type === 'gameState' && this.game) { - const moveCountBefore = this.game.state.moves.length; - this.game = { ...this.game, state: event.state }; - if (event.state.moves.length !== moveCountBefore) { - this.clearSelection(); - } - return; - } - - if (event.type === 'error') { - this.errorMessage = event.error.message; - } - } - - private clearSelection(): void { - this.selectedSquare = null; - this.selectedSquareMoves = []; - this.highlightedSquares = []; - } - - private isCurrentTurnPiece(pieceCode: string): boolean { - if (!this.state) { - return false; - } - - const isWhitePiece = pieceCode === pieceCode.toUpperCase(); - return (this.state.turn === 'white' && isWhitePiece) || (this.state.turn === 'black' && !isWhitePiece); - } - - private getPieceAtSquare(fen: string, targetSquare: string): string | null { - const placement = fen.split(' ')[0] ?? ''; - const rows = placement.split('/'); - if (rows.length !== 8 || targetSquare.length !== 2) { - return null; - } - - const file = targetSquare.charCodeAt(0) - 97; - const rank = Number(targetSquare[1]); - const rowIndex = 8 - rank; - - if (Number.isNaN(rank) || file < 0 || file > 7 || rowIndex < 0 || rowIndex > 7) { - return null; - } - - let column = 0; - for (const symbol of rows[rowIndex]) { - if (symbol >= '1' && symbol <= '8') { - column += Number(symbol); - continue; - } - - if (column === file) { - return symbol; - } - - column += 1; - } - - return null; - } } diff --git a/src/app/pages/game/game.facade.ts b/src/app/pages/game/game.facade.ts new file mode 100644 index 0000000..03f9619 --- /dev/null +++ b/src/app/pages/game/game.facade.ts @@ -0,0 +1,239 @@ +import { DestroyRef, Injectable, OnDestroy, inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { interval, startWith, Subscription, switchMap } from 'rxjs'; +import { 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'; + +@Injectable() +export class GameFacade implements OnDestroy { + gameId = ''; + game: GameFull | null = null; + errorMessage = ''; + moveInput = ''; + fenInput = ''; + pgnInput = ''; + loading = true; + selectedSquare: string | null = null; + highlightedSquares: string[] = []; + + 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; + + ngOnDestroy(): void { + this.streamSubscription?.unsubscribe(); + this.pollSubscription?.unsubscribe(); + } + + get state(): GameState | null { + return this.game?.state ?? null; + } + + setGameId(gameId: string): void { + this.gameId = gameId; + this.loadGame(); + } + + onBoardSquareSelected(square: string): void { + if (!this.state) { + return; + } + + if (this.selectedSquare && this.highlightedSquares.includes(square)) { + const selectedMove = this.selectedSquareMoves.find((move) => move.to === square); + if (selectedMove) { + this.moveInput = selectedMove.uci; + this.submitMove(); + } + return; + } + + const piece = getPieceAtSquare(this.state.fen, square); + if (!piece || !isPieceColor(piece, this.state.turn)) { + this.clearSelection(); + return; + } + + 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.'; + } + }); + } + + submitMove(): void { + const uci = this.moveInput.trim(); + if (!uci) { + return; + } + + this.errorMessage = ''; + this.gameApi + .makeMove(this.gameId, uci) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (state) => { + if (this.game) { + this.game = { ...this.game, state }; + } + this.moveInput = ''; + this.clearSelection(); + }, + error: (error) => { + this.errorMessage = getErrorMessage(error, 'Move rejected.'); + } + }); + } + + importFen(): void { + const fen = this.fenInput.trim(); + if (!fen) { + this.errorMessage = 'Please provide a FEN string.'; + return; + } + + 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.'); + } + }); + } + + 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.'); + } + }); + } + + private loadGame(): void { + this.loading = true; + this.errorMessage = ''; + this.clearSelection(); + this.streamSubscription?.unsubscribe(); + this.pollSubscription?.unsubscribe(); + this.pollSubscription = null; + + this.gameApi + .getGame(this.gameId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (game) => { + this.game = game; + this.loading = false; + this.startStream(); + }, + error: (error) => { + this.errorMessage = getErrorMessage(error, `Could not load game ${this.gameId}.`); + this.loading = false; + } + }); + } + + 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; + if (previousMoves !== game.state.moves.join(',')) { + this.clearSelection(); + } + } + }); + } + + private applyStreamEvent(event: GameStreamEvent): void { + if (event.type === 'gameFull') { + this.game = event.game; + this.clearSelection(); + return; + } + + if (event.type === 'gameState' && this.game) { + const moveCountBefore = this.game.state.moves.length; + this.game = { ...this.game, state: event.state }; + if (event.state.moves.length !== moveCountBefore) { + this.clearSelection(); + } + return; + } + + if (event.type === 'error') { + this.errorMessage = event.error.message; + } + } + + private clearSelection(): void { + this.selectedSquare = null; + this.selectedSquareMoves = []; + this.highlightedSquares = []; + } +} diff --git a/src/app/pages/welcome/welcome.component.ts b/src/app/pages/welcome/welcome.component.ts index d41fe84..0b64679 100644 --- a/src/app/pages/welcome/welcome.component.ts +++ b/src/app/pages/welcome/welcome.component.ts @@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common'; import { Component } from '@angular/core'; import { Router } from '@angular/router'; import { finalize } from 'rxjs'; +import { getErrorMessage } from '../../core/http/error-message.util'; import { GameApiService } from '../../services/game-api.service'; @Component({ @@ -35,8 +36,8 @@ export class WelcomeComponent { next: (game) => { void this.router.navigate(['/game', game.gameId]); }, - error: (error: { error?: { message?: string } }) => { - this.errorMessage = error.error?.message ?? 'Unable to create a game.'; + error: (error) => { + this.errorMessage = getErrorMessage(error, 'Unable to create a game.'); } }); }