feat: new spec

This commit is contained in:
shahdlala66
2026-04-21 13:40:48 +02:00
parent fdc0f1d73b
commit 97365371c8
10 changed files with 157 additions and 23 deletions
+9
View File
@@ -53,6 +53,12 @@
"outputHashing": "all" "outputHashing": "all"
}, },
"development": { "development": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts"
}
],
"optimization": false, "optimization": false,
"extractLicenses": false, "extractLicenses": false,
"sourceMap": true "sourceMap": true
@@ -62,6 +68,9 @@
}, },
"serve": { "serve": {
"builder": "@angular/build:dev-server", "builder": "@angular/build:dev-server",
"options": {
"proxyConfig": "proxy.conf.json"
},
"configurations": { "configurations": {
"production": { "production": {
"buildTarget": "nowchess-frontend:build:production" "buildTarget": "nowchess-frontend:build:production"
@@ -1,6 +1,6 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: NowChess API title: NowChess Board API
description: | description: |
REST API for the NowChess application. Designed to feel familiar to users REST API for the NowChess application. Designed to feel familiar to users
of the [lichess API](https://lichess.org/api). of the [lichess API](https://lichess.org/api).
@@ -186,11 +186,8 @@ paths:
currently to move. currently to move.
For promotion moves include the target piece as the fifth character: For promotion moves include the target piece as the fifth character:
`e7e8q`, `a2a1r`, etc. `e7e8q`, `a2a1r`, etc. Promotion moves without the fifth character
are rejected with `400 INVALID_MOVE`.
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.
security: security:
- bearerAuth: [] - bearerAuth: []
parameters: parameters:
@@ -630,7 +627,6 @@ components:
| `draw` | Draw agreed or claimed — game over | | `draw` | Draw agreed or claimed — game over |
| `drawOffered` | Waiting for the opponent to accept or decline a draw offer | | `drawOffered` | Waiting for the opponent to accept or decline a draw offer |
| `fiftyMoveAvailable` | Fifty-move rule threshold reached; active player may claim draw | | `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) | | `insufficientMaterial` | Neither side has enough pieces to deliver checkmate — game over (draw) |
enum: enum:
- started - started
@@ -641,7 +637,6 @@ components:
- draw - draw
- drawOffered - drawOffered
- fiftyMoveAvailable - fiftyMoveAvailable
- promotionPending
- insufficientMaterial - insufficientMaterial
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
+8
View File
@@ -0,0 +1,8 @@
{
"/api": {
"target": "http://localhost:8080",
"secure": false,
"changeOrigin": true,
"ws": true
}
}
+48
View File
@@ -75,6 +75,54 @@ h2 {
border: var(--border-width) solid var(--color-border); 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) { @media (max-width: 991px) {
.game-card { .game-card {
padding: clamp(var(--size-md), 1.5vw, var(--size-lg)); padding: clamp(var(--size-md), 1.5vw, var(--size-lg));
+8
View File
@@ -9,6 +9,14 @@
@if (facade.loading) { @if (facade.loading) {
<p>Loading game state...</p> <p>Loading game state...</p>
} @else if (facade.state) { } @else if (facade.state) {
@if (facade.isGameFinished && facade.gameCompletionMessage) {
<div class="game-completion-alert alert alert-success mb-3">
<h2 class="completion-title">{{ facade.gameCompletionMessage }}</h2>
<p class="completion-subtitle mb-0">
<a routerLink="/" class="completion-link">Start a new game</a>
</p>
</div>
}
<div class="container-fluid"> <div class="container-fluid">
<div class="row g-3"> <div class="row g-3">
<!-- Left Sidebar - FEN Import --> <!-- Left Sidebar - FEN Import -->
+1 -1
View File
@@ -4,7 +4,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router'; import { ActivatedRoute, RouterLink } from '@angular/router';
import { ChessBoardComponent } from '../../components/chess-board/chess-board.component'; 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'; import { GameFacade } from './game.facade';
@Component({ @Component({
+48
View File
@@ -18,6 +18,8 @@ export class GameFacade implements OnDestroy {
loading = true; loading = true;
selectedSquare: string | null = null; selectedSquare: string | null = null;
highlightedSquares: string[] = []; highlightedSquares: string[] = [];
gameCompletionMessage = '';
isGameFinished = false;
private selectedSquareMoves: LegalMove[] = []; private selectedSquareMoves: LegalMove[] = [];
private readonly router = inject(Router); private readonly router = inject(Router);
@@ -27,6 +29,48 @@ export class GameFacade implements OnDestroy {
private pollSubscription: Subscription | null = null; private pollSubscription: Subscription | null = null;
private botMoveSubscription: 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 { ngOnDestroy(): void {
this.streamSubscription?.unsubscribe(); this.streamSubscription?.unsubscribe();
this.pollSubscription?.unsubscribe(); this.pollSubscription?.unsubscribe();
@@ -185,6 +229,7 @@ export class GameFacade implements OnDestroy {
next: (game) => { next: (game) => {
this.game = game; this.game = game;
this.loading = false; this.loading = false;
this.getGameCompletionMessage();
this.startStream(); this.startStream();
this.tryMakeBotMove(); this.tryMakeBotMove();
}, },
@@ -227,6 +272,7 @@ export class GameFacade implements OnDestroy {
next: (game) => { next: (game) => {
const previousMoves = this.game?.state.moves.join(',') ?? ''; const previousMoves = this.game?.state.moves.join(',') ?? '';
this.game = game; this.game = game;
this.getGameCompletionMessage();
if (previousMoves !== game.state.moves.join(',')) { if (previousMoves !== game.state.moves.join(',')) {
this.clearSelection(); this.clearSelection();
this.tryMakeBotMove(); this.tryMakeBotMove();
@@ -239,6 +285,7 @@ export class GameFacade implements OnDestroy {
if (event.type === 'gameFull') { if (event.type === 'gameFull') {
this.game = event.game; this.game = event.game;
this.clearSelection(); this.clearSelection();
this.getGameCompletionMessage();
this.tryMakeBotMove(); this.tryMakeBotMove();
return; return;
} }
@@ -246,6 +293,7 @@ export class GameFacade implements OnDestroy {
if (event.type === 'gameState' && this.game) { if (event.type === 'gameState' && this.game) {
const moveCountBefore = this.game.state.moves.length; const moveCountBefore = this.game.state.moves.length;
this.game = { ...this.game, state: event.state }; this.game = { ...this.game, state: event.state };
this.getGameCompletionMessage();
if (event.state.moves.length !== moveCountBefore) { if (event.state.moves.length !== moveCountBefore) {
this.clearSelection(); this.clearSelection();
this.tryMakeBotMove(); this.tryMakeBotMove();
+26 -10
View File
@@ -15,11 +15,12 @@ import {
export class GameApiService { export class GameApiService {
private readonly apiBase = environment.apiBaseUrl; private readonly apiBase = environment.apiBaseUrl;
private readonly wsBase = environment.wsBaseUrl; private readonly wsBase = environment.wsBaseUrl;
private readonly apiPath = environment.apiPath;
constructor(private readonly http: HttpClient) {} constructor(private readonly http: HttpClient) {}
createGame(): Observable<GameFull> { createGame(): Observable<GameFull> {
return this.http.post<GameFull>(`${this.apiBase}/api/board/game`, {}); return this.http.post<GameFull>(`${this.apiBase}${this.apiPath}`, {});
} }
createGameVsBot(difficulty: 'easy' | 'medium' | 'hard' = 'medium'): Observable<GameFull> { createGameVsBot(difficulty: 'easy' | 'medium' | 'hard' = 'medium'): Observable<GameFull> {
@@ -38,15 +39,15 @@ export class GameApiService {
? { white: playerInfo, black: botInfo } ? { white: playerInfo, black: botInfo }
: { white: botInfo, black: playerInfo }; : { white: botInfo, black: playerInfo };
return this.http.post<GameFull>(`${this.apiBase}/api/board/game`, payload); return this.http.post<GameFull>(`${this.apiBase}${this.apiPath}`, payload);
} }
getGame(gameId: string): Observable<GameFull> { getGame(gameId: string): Observable<GameFull> {
return this.http.get<GameFull>(`${this.apiBase}/api/board/game/${gameId}`); return this.http.get<GameFull>(`${this.apiBase}${this.apiPath}/${gameId}`);
} }
makeMove(gameId: string, uci: string): Observable<GameState> { makeMove(gameId: string, uci: string): Observable<GameState> {
return this.http.post<GameState>(`${this.apiBase}/api/board/game/${gameId}/move/${uci}`, {}); return this.http.post<GameState>(`${this.apiBase}${this.apiPath}/${gameId}/move/${uci}`, {});
} }
getLegalMoves(gameId: string, square?: string): Observable<LegalMovesResponse> { getLegalMoves(gameId: string, square?: string): Observable<LegalMovesResponse> {
@@ -54,21 +55,30 @@ export class GameApiService {
if (square) { if (square) {
params = params.set('square', square); params = params.set('square', square);
} }
return this.http.get<LegalMovesResponse>(`${this.apiBase}/api/board/game/${gameId}/moves`, { params }); return this.http.get<LegalMovesResponse>(`${this.apiBase}${this.apiPath}/${gameId}/moves`, { params });
} }
importFen(fen: string): Observable<GameFull> { importFen(fen: string): Observable<GameFull> {
return this.http.post<GameFull>(`${this.apiBase}/api/board/game/import/fen`, { fen }); return this.http.post<GameFull>(`${this.apiBase}${this.apiPath}/import/fen`, { fen });
} }
importPgn(pgn: string): Observable<GameFull> { importPgn(pgn: string): Observable<GameFull> {
return this.http.post<GameFull>(`${this.apiBase}/api/board/game/import/pgn`, { pgn }); return this.http.post<GameFull>(`${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<GameStreamEvent> { streamGame(gameId: string): Observable<GameStreamEvent> {
return new Observable<GameStreamEvent>((observer) => { return new Observable<GameStreamEvent>((observer) => {
const wsUrl = `${this.wsBase}/api/board/game/${gameId}/stream`; const wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/stream`;
const streamUrl = `${this.apiBase}/api/board/game/${gameId}/stream`; const streamUrl = `${this.apiBase}${this.apiPath}/${gameId}/stream`;
const ws = new WebSocket(wsUrl); const ws = new WebSocket(wsUrl);
const abortController = new AbortController(); const abortController = new AbortController();
let connected = false; let connected = false;
@@ -101,6 +111,7 @@ export class GameApiService {
} }
fallbackActive = true; fallbackActive = true;
console.log(`[GameApiService] NDJSON fallback started for ${gameId}, URL:`, streamUrl);
try { try {
const response = await fetch(streamUrl, { const response = await fetch(streamUrl, {
@@ -109,11 +120,13 @@ export class GameApiService {
}); });
if (!response.ok || !response.body) { if (!response.ok || !response.body) {
console.error(`[GameApiService] NDJSON fetch failed: HTTP ${response.status}`);
emitErrorEvent(`Unable to open stream: HTTP ${response.status}`); emitErrorEvent(`Unable to open stream: HTTP ${response.status}`);
observer.complete(); observer.complete();
return; return;
} }
console.log(`[GameApiService] NDJSON stream connected for ${gameId}`);
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ''; 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) { if (!connected) {
void startNdjsonFallback(); void startNdjsonFallback();
} }
}; };
ws.onclose = () => { ws.onclose = () => {
console.warn(`[GameApiService] WebSocket closed for ${gameId}, connected=${connected}`);
if (!connected) { if (!connected) {
console.log(`[GameApiService] Starting NDJSON fallback for ${gameId}`);
void startNdjsonFallback(); void startNdjsonFallback();
} else { } else {
observer.complete(); observer.complete();
+3 -2
View File
@@ -1,5 +1,6 @@
export const environment = { export const environment = {
production: false, production: false,
apiBaseUrl: 'http://localhost:8080', apiBaseUrl: '',
wsBaseUrl: 'ws://localhost:8080' wsBaseUrl: 'ws://localhost:8080',
apiPath: '/api/board/game'
}; };
+3 -2
View File
@@ -1,5 +1,6 @@
export const environment = { export const environment = {
production: true, production: true,
apiBaseUrl: 'http://localhost:8080', apiBaseUrl: '',
wsBaseUrl: 'ws://localhost:8080' wsBaseUrl: 'ws://localhost:8080',
apiPath: '/api/board/game'
}; };