6 Commits

Author SHA1 Message Date
TeamCity ab2c641130 ci: bump version to v0.5.0 2026-06-21 20:08:19 +00:00
Janis Eccarius 412591dfe0 feat(tournaments): remove external server add/remove UI
Servers are now env-var configured; the Servers dialog, add form,
remove buttons, and TournamentServerService are all deleted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 21:40:48 +02:00
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
9 changed files with 121 additions and 142 deletions
+15
View File
@@ -76,3 +76,18 @@
### Bug Fixes ### 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)) * 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))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.4.4...0.0.0) (2026-06-21)
### Features
* **tournaments:** remove external server add/remove UI ([412591d](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/412591dfe0119dbec84c3783cd94590810884580))
+10
View File
@@ -3,11 +3,21 @@ export interface AnalysisRequest {
depth: number; depth: number;
} }
export interface RawAnalysisResponse {
fen: string;
evaluation: number;
depth: number;
bestMove: string;
mate: number | null;
continuationMoves: string[];
}
export interface AnalysisResponse { export interface AnalysisResponse {
eval: number; eval: number;
winChance: number; winChance: number;
depth: number; depth: number;
bestMove: string; bestMove: string;
mate: number | null;
continuations: string[]; continuations: string[];
} }
@@ -246,7 +246,7 @@
<!-- Side column --> <!-- Side column -->
<aside class="side"> <aside class="side">
<!-- Single position analysis result --> <!-- Single position analysis result -->
@if (positionAnalysis && !hasAnnotations) { @if (positionAnalysis) {
<details class="side-card" open> <details class="side-card" open>
<summary class="side-card-summary"> <summary class="side-card-summary">
<span class="side-card-title">Position Analysis</span> <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 { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, RouterLink } from '@angular/router'; import { ActivatedRoute, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms'; 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 { ChessBoardComponent } from '../../components/chess-board/chess-board.component';
import { EvalTimelineComponent } from '../../components/eval-timeline/eval-timeline.component'; import { EvalTimelineComponent } from '../../components/eval-timeline/eval-timeline.component';
@@ -146,6 +146,7 @@ export class AnalysisComponent implements OnInit {
return; return;
} }
this.analysing = true; this.analysing = true;
this.positionAnalysis = null;
this.errorMessage = ''; this.errorMessage = '';
const sans = const sans =
this.annotatedMoves.length > 0 this.annotatedMoves.length > 0
@@ -207,12 +208,7 @@ export class AnalysisComponent implements OnInit {
private applyGame(game: GameFull): void { private applyGame(game: GameFull): void {
this.game = game; this.game = game;
this.currentFen = game.state.fen; 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]; this.fenHistory = [game.state.fen];
// Seed annotated moves with san strings, no quality yet
this.annotatedMoves = game.state.moves.map((san) => ({ this.annotatedMoves = game.state.moves.map((san) => ({
san, san,
fen: game.state.fen, fen: game.state.fen,
@@ -223,6 +219,16 @@ export class AnalysisComponent implements OnInit {
winChanceBefore: null, winChanceBefore: null,
winChanceAfter: 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 { private analyseSinglePosition(fen: string): void {
@@ -202,17 +202,16 @@
<span class="dialog-brand">Join with a bot</span> <span class="dialog-brand">Join with a bot</span>
<button type="button" class="dialog-close" (click)="closeJoinDialog()">×</button> <button type="button" class="dialog-close" (click)="closeJoinDialog()">×</button>
</div> </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) { @if (botsLoading) {
<div class="dialog-loading"><span class="pulse"></span>Loading bots…</div> <div class="dialog-loading"><span class="pulse"></span>Loading bots…</div>
} @else if (userBots.length === 0) { } @else {
<p class="join-empty">No official bots are available. The official-bots engine service must be running to register them.</p> @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 { } @else {
<div class="bot-pick-list"> <div class="bot-pick-list">
@for (bot of userBots; track bot.id) { @for (bot of userBots; track bot.id) {
<button type="button" class="bot-pick-row" <button type="button" class="bot-pick-row"
[disabled]="!!joiningBotId" [disabled]="!!joiningBotId || !!joiningOfficialDifficulty"
(click)="joinWithBot(bot)"> (click)="joinWithBot(bot)">
<span class="bot-pick-avatar">{{ bot.name.charAt(0).toUpperCase() }}</span> <span class="bot-pick-avatar">{{ bot.name.charAt(0).toUpperCase() }}</span>
<span class="bot-pick-name">{{ bot.name }}</span> <span class="bot-pick-name">{{ bot.name }}</span>
@@ -233,6 +232,9 @@
<span class="join-divider-label">or join with an official bot</span> <span class="join-divider-label">or join with an official bot</span>
</div> </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"> <div class="official-bot-grid">
@for (d of officialDifficulties; track d) { @for (d of officialDifficulties; track d) {
<button type="button" class="official-bot-btn" <button type="button" class="official-bot-btn"
@@ -246,10 +248,12 @@
</button> </button>
} }
</div> </div>
}
@if (officialJoinError) { @if (officialJoinError) {
<div class="dialog-error">{{ officialJoinError }}</div> <div class="dialog-error">{{ officialJoinError }}</div>
} }
}
</div> </div>
</div> </div>
} }
@@ -267,7 +271,7 @@
@if (serversLoading) { @if (serversLoading) {
<div class="dialog-loading"><span class="pulse"></span>Loading servers…</div> <div class="dialog-loading"><span class="pulse"></span>Loading servers…</div>
} @else if (servers.length === 0) { } @else if (servers.length === 0) {
<p class="join-empty">No external servers registered yet.</p> <p class="join-empty">No external servers registered.</p>
} @else { } @else {
<div class="servers-list"> <div class="servers-list">
@for (s of servers; track s.id) { @for (s of servers; track s.id) {
@@ -276,50 +280,15 @@
<span class="server-label">{{ s.label }}</span> <span class="server-label">{{ s.label }}</span>
<span class="server-url">{{ s.url }}</span> <span class="server-url">{{ s.url }}</span>
</div> </div>
<button type="button" class="server-remove-btn"
[disabled]="removingServerId === s.id"
(click)="removeServer(s.id)"
title="Remove server">
@if (removingServerId === s.id) { … } @else {
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6"/><path d="M14 11v6"/>
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
}
</button>
</div> </div>
} }
</div> </div>
} }
<div class="server-add-form">
<h4 class="server-add-heading">Add server</h4>
<div class="dialog-field">
<label class="dialog-label">Label</label>
<input type="text" class="dialog-input" [(ngModel)]="newServerLabel"
placeholder="e.g. Local Dev Server" />
</div>
<div class="dialog-field">
<label class="dialog-label">URL</label>
<input type="url" class="dialog-input" [(ngModel)]="newServerUrl"
placeholder="http://host:8089" />
</div>
@if (addServerError) {
<div class="dialog-error">{{ addServerError }}</div>
}
<div class="dialog-actions"> <div class="dialog-actions">
<button type="button" class="btn-ghost" (click)="closeServersDialog()">Close</button> <button type="button" class="btn-ghost" (click)="closeServersDialog()">Close</button>
<button type="button" class="btn-primary"
[disabled]="addingServer || !newServerLabel.trim() || !newServerUrl.trim()"
(click)="addServer()">
{{ addingServer ? 'Adding…' : 'Add' }}
</button>
</div> </div>
</div> </div>
</div>
</div> </div>
} }
@@ -3,6 +3,7 @@ import { CommonModule, TitleCasePipe } from '@angular/common';
import { Router, RouterLink } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
import { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { forkJoin } from 'rxjs';
import { TournamentService } from '../../services/tournament.service'; import { TournamentService } from '../../services/tournament.service';
import { AuthService } from '../../services/auth.service'; import { AuthService } from '../../services/auth.service';
import { BotService } from '../../services/bot.service'; import { BotService } from '../../services/bot.service';
@@ -58,6 +59,7 @@ export class TournamentsComponent implements OnInit {
joinDialogTournamentId: string | null = null; joinDialogTournamentId: string | null = null;
userBots: Bot[] = []; userBots: Bot[] = [];
officialBots: Bot[] = [];
botsLoading = false; botsLoading = false;
joiningBotId: string | null = null; joiningBotId: string | null = null;
joinError: string | null = null; joinError: string | null = null;
@@ -69,11 +71,6 @@ export class TournamentsComponent implements OnInit {
showServersDialog = false; showServersDialog = false;
servers: ExternalTournamentServer[] = []; servers: ExternalTournamentServer[] = [];
serversLoading = false; serversLoading = false;
newServerLabel = '';
newServerUrl = '';
addingServer = false;
addServerError: string | null = null;
removingServerId: string | null = null;
ngOnInit(): void { ngOnInit(): void {
this.authService.currentUser$ this.authService.currentUser$
@@ -175,16 +172,22 @@ export class TournamentsComponent implements OnInit {
this.joinDialogTournamentId = tournamentId; this.joinDialogTournamentId = tournamentId;
this.joinError = null; this.joinError = null;
this.botsLoading = true; this.botsLoading = true;
this.botService.listOfficial() forkJoin({ user: this.botService.list(), official: this.botService.listOfficial() })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ .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; } error: () => { this.botsLoading = false; }
}); });
} }
closeJoinDialog(): void { closeJoinDialog(): void {
this.joinDialogTournamentId = null; this.joinDialogTournamentId = null;
this.userBots = [];
this.officialBots = [];
this.joiningBotId = null; this.joiningBotId = null;
this.joinError = null; this.joinError = null;
this.joiningOfficialDifficulty = null; this.joiningOfficialDifficulty = null;
@@ -240,9 +243,6 @@ export class TournamentsComponent implements OnInit {
} }
openServersDialog(): void { openServersDialog(): void {
this.newServerLabel = '';
this.newServerUrl = '';
this.addServerError = null;
this.showServersDialog = true; this.showServersDialog = true;
this.serversLoading = true; this.serversLoading = true;
this.tournamentServerService.list() this.tournamentServerService.list()
@@ -257,40 +257,6 @@ export class TournamentsComponent implements OnInit {
this.showServersDialog = false; this.showServersDialog = false;
} }
addServer(): void {
const label = this.newServerLabel.trim();
const url = this.newServerUrl.trim();
if (!label || !url || this.addingServer) return;
this.addingServer = true;
this.addServerError = null;
this.tournamentServerService.register(label, url).subscribe({
next: server => {
this.addingServer = false;
this.servers = [...this.servers, server];
this.newServerLabel = '';
this.newServerUrl = '';
this.loadTournaments();
},
error: err => {
this.addingServer = false;
this.addServerError = err.error?.error ?? 'Failed to add server.';
}
});
}
removeServer(id: string): void {
if (this.removingServerId) return;
this.removingServerId = id;
this.tournamentServerService.remove(id).subscribe({
next: () => {
this.removingServerId = null;
this.servers = this.servers.filter(s => s.id !== id);
this.loadTournaments();
},
error: () => { this.removingServerId = null; }
});
}
private loadTournaments(): void { private loadTournaments(): void {
this.tournamentService.list() this.tournamentService.list()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
+23 -2
View File
@@ -1,6 +1,7 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
import { import {
GameFull, GameFull,
@@ -9,7 +10,7 @@ import {
LegalMovesResponse, LegalMovesResponse,
PlayerInfo, PlayerInfo,
} from '../models/game.models'; } 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'; import { StreamHandlerService } from './stream-handler.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -78,8 +79,28 @@ export class GameApiService {
return this.http.post<void>(`${this.apiBase}${this.apiPath}/${gameId}/draw/offer`, {}); 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> { 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 { private resolveWsBase(): string {
@@ -20,12 +20,4 @@ export class TournamentServerService {
list(): Observable<ExternalTournamentServerList> { list(): Observable<ExternalTournamentServerList> {
return this.http.get<ExternalTournamentServerList>(this.base); return this.http.get<ExternalTournamentServerList>(this.base);
} }
register(label: string, url: string): Observable<ExternalTournamentServer> {
return this.http.post<ExternalTournamentServer>(this.base, { label, url });
}
remove(id: string): Observable<void> {
return this.http.delete<void>(`${this.base}/${id}`);
}
} }
+2 -2
View File
@@ -1,3 +1,3 @@
MAJOR=0 MAJOR=0
MINOR=4 MINOR=5
PATCH=2 PATCH=0