feat: new spec
This commit is contained in:
@@ -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
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:8080",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"ws": true
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user