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) {
+
+ }
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'
};