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"
},
"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"
@@ -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
# -------------------------------------------------------------------------
+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);
}
.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));
+8
View File
@@ -9,6 +9,14 @@
@if (facade.loading) {
<p>Loading game state...</p>
} @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="row g-3">
<!-- 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 { 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({
+48
View File
@@ -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();
+26 -10
View File
@@ -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<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> {
@@ -38,15 +39,15 @@ export class GameApiService {
? { white: playerInfo, black: botInfo }
: { 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> {
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> {
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> {
@@ -54,21 +55,30 @@ export class GameApiService {
if (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> {
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> {
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> {
return new Observable<GameStreamEvent>((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();
+3 -2
View File
@@ -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'
};
+3 -2
View File
@@ -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'
};