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 @@
- @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.');
}
});
}