From 97365371c8409570f4fa8fb86f0b7282e0e35b81 Mon Sep 17 00:00:00 2001 From: shahdlala66 Date: Tue, 21 Apr 2026 13:40:48 +0200 Subject: [PATCH] feat: new spec --- angular.json | 9 ++++ docs/{api-spec.yaml => board-api-spec.yaml} | 11 ++--- proxy.conf.json | 8 ++++ src/app/pages/game/game.component.css | 48 +++++++++++++++++++++ src/app/pages/game/game.component.html | 8 ++++ src/app/pages/game/game.component.ts | 2 +- src/app/pages/game/game.facade.ts | 48 +++++++++++++++++++++ src/app/services/game-api.service.ts | 36 +++++++++++----- src/environments/environment.development.ts | 5 ++- src/environments/environment.ts | 5 ++- 10 files changed, 157 insertions(+), 23 deletions(-) rename docs/{api-spec.yaml => board-api-spec.yaml} (98%) create mode 100644 proxy.conf.json diff --git a/angular.json b/angular.json index 72e5f83..fd1f73a 100644 --- a/angular.json +++ b/angular.json @@ -53,6 +53,12 @@ "outputHashing": "all" }, "development": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.development.ts" + } + ], "optimization": false, "extractLicenses": false, "sourceMap": true @@ -62,6 +68,9 @@ }, "serve": { "builder": "@angular/build:dev-server", + "options": { + "proxyConfig": "proxy.conf.json" + }, "configurations": { "production": { "buildTarget": "nowchess-frontend:build:production" diff --git a/docs/api-spec.yaml b/docs/board-api-spec.yaml similarity index 98% rename from docs/api-spec.yaml rename to docs/board-api-spec.yaml index 8b20333..61bf241 100644 --- a/docs/api-spec.yaml +++ b/docs/board-api-spec.yaml @@ -1,6 +1,6 @@ openapi: 3.0.3 info: - title: NowChess API + title: NowChess Board API description: | REST API for the NowChess application. Designed to feel familiar to users of the [lichess API](https://lichess.org/api). @@ -186,11 +186,8 @@ paths: currently to move. For promotion moves include the target piece as the fifth character: - `e7e8q`, `a2a1r`, etc. - - If the move results in a pawn reaching the back rank and no promotion - character is supplied, the game enters `promotionPending` status and - the move is not yet applied — resubmit with the promotion character. + `e7e8q`, `a2a1r`, etc. Promotion moves without the fifth character + are rejected with `400 INVALID_MOVE`. security: - bearerAuth: [] parameters: @@ -630,7 +627,6 @@ components: | `draw` | Draw agreed or claimed — game over | | `drawOffered` | Waiting for the opponent to accept or decline a draw offer | | `fiftyMoveAvailable` | Fifty-move rule threshold reached; active player may claim draw | - | `promotionPending` | A pawn reached the back rank; awaiting promotion piece selection | | `insufficientMaterial` | Neither side has enough pieces to deliver checkmate — game over (draw) | enum: - started @@ -641,7 +637,6 @@ components: - draw - drawOffered - fiftyMoveAvailable - - promotionPending - insufficientMaterial # ------------------------------------------------------------------------- diff --git a/proxy.conf.json b/proxy.conf.json new file mode 100644 index 0000000..b1b7ec5 --- /dev/null +++ b/proxy.conf.json @@ -0,0 +1,8 @@ +{ + "/api": { + "target": "http://localhost:8080", + "secure": false, + "changeOrigin": true, + "ws": true + } +} diff --git a/src/app/pages/game/game.component.css b/src/app/pages/game/game.component.css index 2f5903c..042bcef 100644 --- a/src/app/pages/game/game.component.css +++ b/src/app/pages/game/game.component.css @@ -75,6 +75,54 @@ h2 { border: var(--border-width) solid var(--color-border); } +.game-completion-alert { + background: linear-gradient(135deg, var(--color-secondary-mint, #B9DAD1) 0%, var(--color-secondary-blue, #B9C2DA) 100%); + border: 2px solid var(--color-secondary-mint, #B9DAD1) !important; + border-radius: var(--border-radius-lg) !important; + padding: var(--size-xl-padding) !important; + box-shadow: 0 8px 16px rgba(185, 218, 209, 0.3); + animation: slideIn 0.4s ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.completion-title { + color: var(--color-text-primary); + font-size: 1.75rem; + margin: 0 0 var(--size-md) 0; + font-weight: 700; + text-align: center; +} + +.completion-subtitle { + text-align: center; + color: var(--color-text-primary); + font-size: 1rem; +} + +.completion-link { + color: var(--color-text-primary); + text-decoration: none; + font-weight: 600; + border-bottom: 2px solid var(--color-text-primary); + transition: all 0.3s ease; + padding-bottom: 2px; +} + +.completion-link:hover { + color: var(--color-secondary-blue); + border-bottom-color: var(--color-secondary-blue); +} + @media (max-width: 991px) { .game-card { padding: clamp(var(--size-md), 1.5vw, var(--size-lg)); diff --git a/src/app/pages/game/game.component.html b/src/app/pages/game/game.component.html index 02677d9..8f2b10a 100644 --- a/src/app/pages/game/game.component.html +++ b/src/app/pages/game/game.component.html @@ -9,6 +9,14 @@ @if (facade.loading) {

Loading game state...

} @else if (facade.state) { + @if (facade.isGameFinished && facade.gameCompletionMessage) { +
+

{{ facade.gameCompletionMessage }}

+

+ Start a new game +

+
+ }
diff --git a/src/app/pages/game/game.component.ts b/src/app/pages/game/game.component.ts index 0a1fb94..f7e4271 100644 --- a/src/app/pages/game/game.component.ts +++ b/src/app/pages/game/game.component.ts @@ -4,7 +4,7 @@ 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 { GameFacade } from './game.facade'; @Component({ diff --git a/src/app/pages/game/game.facade.ts b/src/app/pages/game/game.facade.ts index d545b33..9da4f1c 100644 --- a/src/app/pages/game/game.facade.ts +++ b/src/app/pages/game/game.facade.ts @@ -18,6 +18,8 @@ export class GameFacade implements OnDestroy { loading = true; selectedSquare: string | null = null; highlightedSquares: string[] = []; + gameCompletionMessage = ''; + isGameFinished = false; private selectedSquareMoves: LegalMove[] = []; private readonly router = inject(Router); @@ -27,6 +29,48 @@ export class GameFacade implements OnDestroy { 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(); @@ -185,6 +229,7 @@ export class GameFacade implements OnDestroy { next: (game) => { this.game = game; this.loading = false; + this.getGameCompletionMessage(); this.startStream(); this.tryMakeBotMove(); }, @@ -227,6 +272,7 @@ export class GameFacade implements OnDestroy { next: (game) => { const previousMoves = this.game?.state.moves.join(',') ?? ''; this.game = game; + this.getGameCompletionMessage(); if (previousMoves !== game.state.moves.join(',')) { this.clearSelection(); this.tryMakeBotMove(); @@ -239,6 +285,7 @@ export class GameFacade implements OnDestroy { if (event.type === 'gameFull') { this.game = event.game; this.clearSelection(); + this.getGameCompletionMessage(); this.tryMakeBotMove(); return; } @@ -246,6 +293,7 @@ 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(); if (event.state.moves.length !== moveCountBefore) { this.clearSelection(); this.tryMakeBotMove(); diff --git a/src/app/services/game-api.service.ts b/src/app/services/game-api.service.ts index c2953a3..a1657c3 100644 --- a/src/app/services/game-api.service.ts +++ b/src/app/services/game-api.service.ts @@ -15,11 +15,12 @@ import { export class GameApiService { private readonly apiBase = environment.apiBaseUrl; private readonly wsBase = environment.wsBaseUrl; + private readonly apiPath = environment.apiPath; constructor(private readonly http: HttpClient) {} createGame(): Observable { - return this.http.post(`${this.apiBase}/api/board/game`, {}); + return this.http.post(`${this.apiBase}${this.apiPath}`, {}); } createGameVsBot(difficulty: 'easy' | 'medium' | 'hard' = 'medium'): Observable { @@ -38,15 +39,15 @@ export class GameApiService { ? { white: playerInfo, black: botInfo } : { white: botInfo, black: playerInfo }; - return this.http.post(`${this.apiBase}/api/board/game`, payload); + return this.http.post(`${this.apiBase}${this.apiPath}`, payload); } getGame(gameId: string): Observable { - return this.http.get(`${this.apiBase}/api/board/game/${gameId}`); + return this.http.get(`${this.apiBase}${this.apiPath}/${gameId}`); } makeMove(gameId: string, uci: string): Observable { - return this.http.post(`${this.apiBase}/api/board/game/${gameId}/move/${uci}`, {}); + return this.http.post(`${this.apiBase}${this.apiPath}/${gameId}/move/${uci}`, {}); } getLegalMoves(gameId: string, square?: string): Observable { @@ -54,21 +55,30 @@ export class GameApiService { if (square) { params = params.set('square', square); } - return this.http.get(`${this.apiBase}/api/board/game/${gameId}/moves`, { params }); + return this.http.get(`${this.apiBase}${this.apiPath}/${gameId}/moves`, { params }); } importFen(fen: string): Observable { - return this.http.post(`${this.apiBase}/api/board/game/import/fen`, { fen }); + return this.http.post(`${this.apiBase}${this.apiPath}/import/fen`, { fen }); } importPgn(pgn: string): Observable { - return this.http.post(`${this.apiBase}/api/board/game/import/pgn`, { pgn }); + return this.http.post(`${this.apiBase}${this.apiPath}/import/pgn`, { pgn }); + } + + private resolveWsBase(): string { + if (this.wsBase) { + return this.wsBase; + } + + const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; + return `${wsProtocol}://${window.location.host}`; } streamGame(gameId: string): Observable { return new Observable((observer) => { - const wsUrl = `${this.wsBase}/api/board/game/${gameId}/stream`; - const streamUrl = `${this.apiBase}/api/board/game/${gameId}/stream`; + 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; @@ -101,6 +111,7 @@ export class GameApiService { } fallbackActive = true; + console.log(`[GameApiService] NDJSON fallback started for ${gameId}, URL:`, streamUrl); try { const response = await fetch(streamUrl, { @@ -109,11 +120,13 @@ export class GameApiService { }); 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 = ''; @@ -157,14 +170,17 @@ export class GameApiService { } }; - ws.onerror = () => { + 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(); diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts index 5ad9ba5..e55757c 100644 --- a/src/environments/environment.development.ts +++ b/src/environments/environment.development.ts @@ -1,5 +1,6 @@ export const environment = { production: false, - apiBaseUrl: 'http://localhost:8080', - wsBaseUrl: 'ws://localhost:8080' + apiBaseUrl: '', + wsBaseUrl: 'ws://localhost:8080', + apiPath: '/api/board/game' }; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index ac31fb7..42a2794 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -1,5 +1,6 @@ export const environment = { production: true, - apiBaseUrl: 'http://localhost:8080', - wsBaseUrl: 'ws://localhost:8080' + apiBaseUrl: '', + wsBaseUrl: 'ws://localhost:8080', + apiPath: '/api/board/game' };