feat: new spec
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -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);
|
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));
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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'
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user