8 Commits

Author SHA1 Message Date
TeamCity e374d5e791 ci: bump version to v0.4.4 2026-06-21 14:06:48 +00:00
Janis Eccarius 5b5fd6f027 fix(tournaments): load both user bots and official bots in join dialog
openJoinDialog now fetches user bots and official bots in parallel via
forkJoin. Each section shows its own empty state independently.

Official bot difficulty buttons are hidden when no official bots are
registered. User bots empty state links to /bots to create one.

Disables all join buttons while any join is in progress.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 15:40:24 +02:00
TeamCity ea8048e064 ci: bump version to v0.4.3 2026-06-21 09:06:47 +00:00
Janis Eccarius ce1fb0d60b fix(analysis): fix API field mismatch and enable full game analysis
Map raw backend response (evaluation/continuationMoves) to frontend
model (eval/winChance/continuations). Add getFenHistory() call after
loading a game or PGN so runAnalysis() gets per-ply FEN history and
triggers analyzeGame() instead of falling back to single-position
analysis. Remove !hasAnnotations guard so positionAnalysis card shows
even when a game is loaded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 11:00:29 +02:00
TeamCity 74f82bc0ba ci: bump version to v0.4.2 2026-06-17 09:19:46 +00:00
lq64 1d2c217da8 fix: NCS-122 send WS token via first-message auth instead of query param (#13)
Remove token from WebSocket URL query parameters in ChallengeWebSocketService
and GameApiService. Instead, send {"type":"auth","token":"..."} as the first
text frame after the connection opens, matching the new backend auth protocol.

---------

Co-authored-by: LQ63 <lkhermann@web.de>
Reviewed-on: #13
2026-06-17 10:50:16 +02:00
TeamCity 3d1b330396 ci: bump version to v0.4.1 2026-06-17 07:20:31 +00:00
Janis a54957aa74 fix(auth): attach Bearer token to /api/bots requests (#12)
OfficialBotService posts to /api/bots/official/join-tournament, but
the auth interceptor's protected-endpoint whitelist omitted /api/bots,
so no Authorization header was sent and the request hit the official-
bots service anonymously -> 401.

Add /api/bots to the whitelist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Reviewed-on: #12
2026-06-17 09:08:55 +02:00
11 changed files with 232 additions and 157 deletions
+20
View File
@@ -66,3 +66,23 @@
### Features
* NCWF-5/6/7/8/9 chess analysis page and engine integration ([#11](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/11)) ([f9420e5](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/f9420e5848d8724bcb0e9cf08f08b871c91cf4ba))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.4.0...0.0.0) (2026-06-17)
### Bug Fixes
* **auth:** attach Bearer token to /api/bots requests ([#12](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/12)) ([a54957a](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/a54957aa74ef15bf2dd439d386e221ac134c5c5c))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.4.1...0.0.0) (2026-06-17)
### Bug Fixes
* NCS-122 send WS token via first-message auth instead of query param ([#13](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/13)) ([1d2c217](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/1d2c217da8982d361e2eb7de26f6447171a1dd43))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.4.2...0.0.0) (2026-06-21)
### Bug Fixes
* **analysis:** fix API field mismatch and enable full game analysis ([ce1fb0d](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/ce1fb0d60b695093495ee0ad824c511dd2db7fbb))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.4.3...0.0.0) (2026-06-21)
### Bug Fixes
* **tournaments:** load both user bots and official bots in join dialog ([5b5fd6f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/5b5fd6f027b4aedb951a802725fcd929d514c359))
+10
View File
@@ -3,11 +3,21 @@ export interface AnalysisRequest {
depth: number;
}
export interface RawAnalysisResponse {
fen: string;
evaluation: number;
depth: number;
bestMove: string;
mate: number | null;
continuationMoves: string[];
}
export interface AnalysisResponse {
eval: number;
winChance: number;
depth: number;
bestMove: string;
mate: number | null;
continuations: string[];
}
@@ -246,7 +246,7 @@
<!-- Side column -->
<aside class="side">
<!-- Single position analysis result -->
@if (positionAnalysis && !hasAnnotations) {
@if (positionAnalysis) {
<details class="side-card" open>
<summary class="side-card-summary">
<span class="side-card-title">Position Analysis</span>
+12 -6
View File
@@ -2,7 +2,7 @@ import { Component, DestroyRef, inject, OnInit } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { switchMap, of } from 'rxjs';
import { switchMap, of, forkJoin } from 'rxjs';
import { ChessBoardComponent } from '../../components/chess-board/chess-board.component';
import { EvalTimelineComponent } from '../../components/eval-timeline/eval-timeline.component';
@@ -146,6 +146,7 @@ export class AnalysisComponent implements OnInit {
return;
}
this.analysing = true;
this.positionAnalysis = null;
this.errorMessage = '';
const sans =
this.annotatedMoves.length > 0
@@ -207,12 +208,7 @@ export class AnalysisComponent implements OnInit {
private applyGame(game: GameFull): void {
this.game = game;
this.currentFen = game.state.fen;
// Build a flat FEN history from scratch using moves array
// The server gives us the final FEN. We reconstruct history by
// storing the final FEN; full per-ply history requires per-move API calls
// which is out of scope here — we store what we have and allow analysis to proceed.
this.fenHistory = [game.state.fen];
// Seed annotated moves with san strings, no quality yet
this.annotatedMoves = game.state.moves.map((san) => ({
san,
fen: game.state.fen,
@@ -223,6 +219,16 @@ export class AnalysisComponent implements OnInit {
winChanceBefore: null,
winChanceAfter: null,
}));
this.gameApi
.getFenHistory(game.gameId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (fens) => {
if (fens.length >= 2) {
this.fenHistory = fens;
}
},
});
}
private analyseSinglePosition(fen: string): void {
@@ -202,53 +202,57 @@
<span class="dialog-brand">Join with a bot</span>
<button type="button" class="dialog-close" (click)="closeJoinDialog()">×</button>
</div>
<p class="join-hint">Select an official (engine-backed) bot to enter this tournament. These are the bots that actually play their moves.</p>
@if (botsLoading) {
<div class="dialog-loading"><span class="pulse"></span>Loading bots…</div>
} @else if (userBots.length === 0) {
<p class="join-empty">No official bots are available. The official-bots engine service must be running to register them.</p>
} @else {
<div class="bot-pick-list">
@for (bot of userBots; track bot.id) {
<button type="button" class="bot-pick-row"
[disabled]="!!joiningBotId"
(click)="joinWithBot(bot)">
<span class="bot-pick-avatar">{{ bot.name.charAt(0).toUpperCase() }}</span>
<span class="bot-pick-name">{{ bot.name }}</span>
<span class="bot-pick-rating">{{ bot.rating }}</span>
@if (joiningBotId === bot.id) {
<span class="bot-pick-spinner"></span>
}
</button>
}
</div>
}
@if (joinError) {
<div class="dialog-error">{{ joinError }}</div>
}
<div class="join-divider">
<span class="join-divider-label">or join with an official bot</span>
</div>
<div class="official-bot-grid">
@for (d of officialDifficulties; track d) {
<button type="button" class="official-bot-btn"
[class]="'official-btn-' + d"
[disabled]="!!joiningOfficialDifficulty || !!joiningBotId"
(click)="joinWithOfficialBot(d)">
@if (joiningOfficialDifficulty === d) {
<span class="pulse"></span>
@if (userBots.length === 0) {
<p class="join-empty">You have no bots. <a routerLink="/bots">Create one</a> to join with your own bot.</p>
} @else {
<div class="bot-pick-list">
@for (bot of userBots; track bot.id) {
<button type="button" class="bot-pick-row"
[disabled]="!!joiningBotId || !!joiningOfficialDifficulty"
(click)="joinWithBot(bot)">
<span class="bot-pick-avatar">{{ bot.name.charAt(0).toUpperCase() }}</span>
<span class="bot-pick-name">{{ bot.name }}</span>
<span class="bot-pick-rating">{{ bot.rating }}</span>
@if (joiningBotId === bot.id) {
<span class="bot-pick-spinner"></span>
}
</button>
}
{{ d | titlecase }}
</button>
</div>
}
</div>
@if (officialJoinError) {
<div class="dialog-error">{{ officialJoinError }}</div>
@if (joinError) {
<div class="dialog-error">{{ joinError }}</div>
}
<div class="join-divider">
<span class="join-divider-label">or join with an official bot</span>
</div>
@if (officialBots.length === 0) {
<p class="join-empty">No official bots are available. The official-bots engine service must be running to register them.</p>
} @else {
<div class="official-bot-grid">
@for (d of officialDifficulties; track d) {
<button type="button" class="official-bot-btn"
[class]="'official-btn-' + d"
[disabled]="!!joiningOfficialDifficulty || !!joiningBotId"
(click)="joinWithOfficialBot(d)">
@if (joiningOfficialDifficulty === d) {
<span class="pulse"></span>
}
{{ d | titlecase }}
</button>
}
</div>
}
@if (officialJoinError) {
<div class="dialog-error">{{ officialJoinError }}</div>
}
}
</div>
</div>
@@ -3,6 +3,7 @@ import { CommonModule, TitleCasePipe } from '@angular/common';
import { Router, RouterLink } from '@angular/router';
import { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { forkJoin } from 'rxjs';
import { TournamentService } from '../../services/tournament.service';
import { AuthService } from '../../services/auth.service';
import { BotService } from '../../services/bot.service';
@@ -58,6 +59,7 @@ export class TournamentsComponent implements OnInit {
joinDialogTournamentId: string | null = null;
userBots: Bot[] = [];
officialBots: Bot[] = [];
botsLoading = false;
joiningBotId: string | null = null;
joinError: string | null = null;
@@ -175,16 +177,22 @@ export class TournamentsComponent implements OnInit {
this.joinDialogTournamentId = tournamentId;
this.joinError = null;
this.botsLoading = true;
this.botService.listOfficial()
forkJoin({ user: this.botService.list(), official: this.botService.listOfficial() })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: bots => { this.userBots = bots; this.botsLoading = false; },
next: ({ user, official }) => {
this.userBots = user;
this.officialBots = official;
this.botsLoading = false;
},
error: () => { this.botsLoading = false; }
});
}
closeJoinDialog(): void {
this.joinDialogTournamentId = null;
this.userBots = [];
this.officialBots = [];
this.joiningBotId = null;
this.joinError = null;
this.joiningOfficialDifficulty = null;
+2 -1
View File
@@ -9,7 +9,8 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
req.url.includes('/api/account/official-bots') ||
req.url.includes('/api/board/game') ||
req.url.includes('/api/challenge') ||
req.url.includes('/api/tournament');
req.url.includes('/api/tournament') ||
req.url.includes('/api/bots');
if (token && isProtectedEndpoint && !req.headers.has('Authorization')) {
req = req.clone({
+96 -91
View File
@@ -6,110 +6,115 @@ import { ChallengeService } from './challenge.service';
@Injectable({ providedIn: 'root' })
export class ChallengeWebSocketService {
private readonly challengeEventService = inject(ChallengeEventService);
private readonly challengeService = inject(ChallengeService);
private readonly router = inject(Router);
private readonly challengeEventService = inject(ChallengeEventService);
private readonly challengeService = inject(ChallengeService);
private readonly router = inject(Router);
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private readonly maxReconnectAttempts = 5;
private readonly reconnectDelay = 3000;
private intentionalClose = false;
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private readonly maxReconnectAttempts = 5;
private readonly reconnectDelay = 3000;
private intentionalClose = false;
connect(): void {
if (this.ws) return;
connect(): void {
if (this.ws) return;
const token = localStorage.getItem('token');
if (!token) return;
const token = localStorage.getItem('token');
if (!token) return;
const url = `${environment.userWsBaseUrl}/api/user/ws?token=${encodeURIComponent(token)}`;
const url = `${environment.userWsBaseUrl}/api/user/ws`;
try {
this.intentionalClose = false;
this.ws = new WebSocket(url);
try {
this.intentionalClose = false;
this.ws = new WebSocket(url);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
};
this.ws.onmessage = (event) => {
this.handleMessage(event.data as string);
};
this.ws.onerror = () => {
// onclose fires right after, handles reconnect
};
this.ws.onclose = () => {
this.ws = null;
if (!this.intentionalClose) {
this.attemptReconnect();
}
};
} catch {
this.attemptReconnect();
}
}
disconnect(): void {
this.intentionalClose = true;
this.ws.onopen = () => {
this.reconnectAttempts = 0;
if (this.ws) {
this.ws.close();
this.ws = null;
this.ws?.send(JSON.stringify({ type: 'auth', token }));
};
this.ws.onmessage = (event) => {
this.handleMessage(event.data as string);
};
this.ws.onerror = () => {
// onclose fires right after, handles reconnect
};
this.ws.onclose = () => {
this.ws = null;
if (!this.intentionalClose) {
this.attemptReconnect();
}
};
} catch {
this.attemptReconnect();
}
}
disconnect(): void {
this.intentionalClose = true;
this.reconnectAttempts = 0;
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
private handleMessage(data: string): void {
let message: Record<string, unknown>;
try {
message = JSON.parse(data) as Record<string, unknown>;
} catch {
return;
}
private handleMessage(data: string): void {
let message: Record<string, unknown>;
try {
message = JSON.parse(data) as Record<string, unknown>;
} catch {
return;
switch (message['type']) {
case 'CONNECTED':
break;
case 'challengeCreated': {
const challengeId = message['challengeId'] as string | undefined;
if (challengeId) {
this.challengeService.getChallenge(challengeId).subscribe({
next: (challenge) => this.challengeEventService.onChallengeReceived(challenge),
error: () => {
/* challenge may have already expired */
},
});
}
break;
}
switch (message['type']) {
case 'CONNECTED':
break;
case 'challengeCreated': {
const challengeId = message['challengeId'] as string | undefined;
if (challengeId) {
this.challengeService.getChallenge(challengeId).subscribe({
next: challenge => this.challengeEventService.onChallengeReceived(challenge),
error: () => { /* challenge may have already expired */ }
});
}
break;
}
case 'challengeAccepted': {
const challengeId = message['challengeId'] as string | undefined;
const gameId = message['gameId'] as string | undefined;
if (challengeId) {
this.challengeEventService.removeChallenge(challengeId);
}
if (gameId) {
void this.router.navigate(['/game', gameId]);
}
break;
}
case 'challengeDeclined':
case 'challengeExpired':
case 'challengeCancelled': {
const challengeId = message['challengeId'] as string | undefined;
if (challengeId) {
this.challengeEventService.removeChallenge(challengeId);
}
break;
}
case 'challengeAccepted': {
const challengeId = message['challengeId'] as string | undefined;
const gameId = message['gameId'] as string | undefined;
if (challengeId) {
this.challengeEventService.removeChallenge(challengeId);
}
}
if (gameId) {
void this.router.navigate(['/game', gameId]);
}
break;
}
private attemptReconnect(): void {
if (this.intentionalClose || this.reconnectAttempts >= this.maxReconnectAttempts) return;
this.reconnectAttempts++;
setTimeout(() => { this.connect(); }, this.reconnectDelay);
case 'challengeDeclined':
case 'challengeExpired':
case 'challengeCancelled': {
const challengeId = message['challengeId'] as string | undefined;
if (challengeId) {
this.challengeEventService.removeChallenge(challengeId);
}
break;
}
}
}
private attemptReconnect(): void {
if (this.intentionalClose || this.reconnectAttempts >= this.maxReconnectAttempts) return;
this.reconnectAttempts++;
setTimeout(() => {
this.connect();
}, this.reconnectDelay);
}
}
+32 -12
View File
@@ -1,15 +1,16 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { environment } from '../../environments/environment';
import {
GameFull,
GameState,
GameStreamEvent,
LegalMovesResponse,
PlayerInfo
PlayerInfo,
} from '../models/game.models';
import { AnalysisRequest, AnalysisResponse } from '../models/analysis.models';
import { AnalysisRequest, AnalysisResponse, RawAnalysisResponse } from '../models/analysis.models';
import { StreamHandlerService } from './stream-handler.service';
@Injectable({ providedIn: 'root' })
@@ -29,11 +30,11 @@ export class GameApiService {
const playerColor = Math.random() > 0.5 ? 'white' : 'black';
const playerInfo: PlayerInfo = {
id: `player-${Date.now()}`,
displayName: 'You'
displayName: 'You',
};
const botInfo: PlayerInfo = {
id: `bot-${difficulty}`,
displayName: `Bot (${difficulty})`
displayName: `Bot (${difficulty})`,
};
const payload =
@@ -57,7 +58,9 @@ export class GameApiService {
if (square) {
params = params.set('square', square);
}
return this.http.get<LegalMovesResponse>(`${this.apiBase}${this.apiPath}/${gameId}/moves`, { params });
return this.http.get<LegalMovesResponse>(`${this.apiBase}${this.apiPath}/${gameId}/moves`, {
params,
});
}
importFen(fen: string): Observable<GameFull> {
@@ -76,8 +79,28 @@ export class GameApiService {
return this.http.post<void>(`${this.apiBase}${this.apiPath}/${gameId}/draw/offer`, {});
}
getFenHistory(gameId: string): Observable<string[]> {
return this.http
.get<{ fens: string[] }>(`${this.apiBase}${this.apiPath}/${gameId}/fen-history`)
.pipe(map((r) => r.fens));
}
analyzePosition(request: AnalysisRequest): Observable<AnalysisResponse> {
return this.http.post<AnalysisResponse>(`${this.apiBase}/api/analysis/position`, request);
return this.http
.post<RawAnalysisResponse>(`${this.apiBase}/api/analysis/position`, request)
.pipe(map((raw) => this.mapAnalysisResponse(raw)));
}
private mapAnalysisResponse(raw: RawAnalysisResponse): AnalysisResponse {
const evalPawns = raw.evaluation / 100;
return {
eval: evalPawns,
winChance: 1 / (1 + Math.exp(-0.374 * evalPawns)),
depth: raw.depth,
bestMove: raw.bestMove,
mate: raw.mate,
continuations: raw.continuationMoves ?? [],
};
}
private resolveWsBase(): string {
@@ -90,11 +113,8 @@ export class GameApiService {
}
streamGame(gameId: string): Observable<GameStreamEvent> {
const token = localStorage.getItem('token');
let wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/ws`;
if (token) {
wsUrl += `?token=${encodeURIComponent(token)}`;
}
return this.streamHandler.createGameStream(wsUrl, gameId);
const wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/ws`;
const token = localStorage.getItem('token') ?? '';
return this.streamHandler.createGameStream(wsUrl, gameId, token);
}
}
+3 -2
View File
@@ -6,7 +6,7 @@ const WS_CONNECT_TIMEOUT_MS = 3000;
@Injectable({ providedIn: 'root' })
export class StreamHandlerService {
createGameStream(wsUrl: string, gameId: string): Observable<GameStreamEvent> {
createGameStream(wsUrl: string, gameId: string, token: string): Observable<GameStreamEvent> {
return new Observable<GameStreamEvent>((observer) => {
const ws = new WebSocket(wsUrl);
let connected = false;
@@ -14,7 +14,7 @@ export class StreamHandlerService {
const emitErrorEvent = (message: string): void => {
const errorEvent: ErrorEvent = {
type: 'error',
error: { code: 'STREAM_ERROR', message }
error: { code: 'STREAM_ERROR', message },
};
observer.next(errorEvent);
};
@@ -36,6 +36,7 @@ export class StreamHandlerService {
connected = true;
clearTimeout(connectionTimeoutId);
console.log(`[StreamHandler] WebSocket connected for ${gameId}`);
ws.send(JSON.stringify({ type: 'auth', token }));
};
ws.onmessage = (message) => {
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0
MINOR=4
PATCH=0
PATCH=4