Compare commits

..

1 Commits

Author SHA1 Message Date
Janis Eccarius 7d1be8d175 feat: NCWF-5/6/7/8/9 chess analysis page and engine integration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 22:36:09 +02:00
20 changed files with 491 additions and 784 deletions
-30
View File
@@ -56,33 +56,3 @@
### Bug Fixes ### Bug Fixes
* route play-vs-bot to /vs-bot endpoint ([a620735](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/a62073511f2ac912ceb0f6b4730bef37545dd8ea)) * route play-vs-bot to /vs-bot endpoint ([a620735](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/a62073511f2ac912ceb0f6b4730bef37545dd8ea))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.8...0.0.0) (2026-06-10)
### Features
* bots ([#9](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/9)) ([48959da](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/48959daae36e709ea7782ca04fdde699854f8e66))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.3.0...0.0.0) (2026-06-17)
### 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))
+1 -1
View File
@@ -17,5 +17,5 @@ export const routes: Routes = [
{ path: 'bots', component: BotsComponent }, { path: 'bots', component: BotsComponent },
{ path: 'analysis', component: AnalysisComponent }, { path: 'analysis', component: AnalysisComponent },
{ path: 'game/:gameId', component: GameComponent }, { path: 'game/:gameId', component: GameComponent },
{ path: '**', redirectTo: '' } { path: '**', redirectTo: '' },
]; ];
+1 -1
View File
@@ -7,7 +7,7 @@ describe('App', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [App], imports: [App],
providers: [provideRouter([]), provideHttpClient()] providers: [provideRouter([]), provideHttpClient()],
}).compileComponents(); }).compileComponents();
}); });
+161 -49
View File
@@ -1,5 +1,4 @@
<nav class="nc-nav"> <nav class="nc-nav">
<!-- Logo --> <!-- Logo -->
<div class="nc-logo" (click)="goToHome()" role="button" tabindex="0"> <div class="nc-logo" (click)="goToHome()" role="button" tabindex="0">
<div class="nc-logo-mark"></div> <div class="nc-logo-mark"></div>
@@ -10,8 +9,16 @@
@if (currentUser) { @if (currentUser) {
<div class="nc-links"> <div class="nc-links">
<button type="button" class="nc-link"> <button type="button" class="nc-link">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" <svg
stroke-linecap="round" stroke-linejoin="round"> width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /> <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" /> <circle cx="12" cy="12" r="3" />
</svg> </svg>
@@ -25,15 +32,26 @@
<!-- Right cluster --> <!-- Right cluster -->
<div class="nc-right"> <div class="nc-right">
@if (currentUser; as user) { @if (currentUser; as user) {
<!-- Notifications bell --> <!-- Notifications bell -->
<div class="nc-dropdown-wrap" data-dropdown="notif"> <div class="nc-dropdown-wrap" data-dropdown="notif">
<button type="button" class="nc-bell" [class.is-open]="notifOpen" (click)="toggleNotif($event)" <button
aria-label="Notifications"> type="button"
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" class="nc-bell"
stroke-linecap="round" stroke-linejoin="round"> [class.is-open]="notifOpen"
(click)="toggleNotif($event)"
aria-label="Notifications"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" /> <path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" /> <path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</svg> </svg>
@@ -55,11 +73,21 @@
@for (challenge of incomingChallenges; track challenge.id) { @for (challenge of incomingChallenges; track challenge.id) {
<div class="nc-notif-row is-unread"> <div class="nc-notif-row is-unread">
<div class="nc-notif-icon"> <div class="nc-notif-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" <svg
stroke-linecap="round" stroke-linejoin="round"> width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="14.5" y1="17.5" x2="3" y2="6" /> <line x1="14.5" y1="17.5" x2="3" y2="6" />
<path d="M13 19l6 -6" /><path d="M16 16l4 4" /> <path d="M13 19l6 -6" />
<path d="M19 21l2 -2" /><path d="M15 5l4 4" /> <path d="M16 16l4 4" />
<path d="M19 21l2 -2" />
<path d="M15 5l4 4" />
<path d="M21 3l-3 1l-4 4" /> <path d="M21 3l-3 1l-4 4" />
</svg> </svg>
</div> </div>
@@ -69,24 +97,49 @@
{{ getTimeControlDisplay(challenge) }} game. {{ getTimeControlDisplay(challenge) }} game.
</div> </div>
<div class="nc-notif-meta"> <div class="nc-notif-meta">
{{ challenge.challenger.rating }} · {{ challenge.timeControl.type ?? 'Custom' }} · {{ getExpirationInfo(challenge) }} {{ challenge.challenger.rating }} ·
{{ challenge.timeControl.type ?? 'Custom' }} ·
{{ getExpirationInfo(challenge) }}
</div> </div>
<div class="nc-notif-actions"> <div class="nc-notif-actions">
<button type="button" class="nc-btn-accept" <button
type="button"
class="nc-btn-accept"
[disabled]="acceptingId === challenge.id || !!decliningId" [disabled]="acceptingId === challenge.id || !!decliningId"
(click)="acceptChallenge($event, challenge)"> (click)="acceptChallenge($event, challenge)"
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" >
stroke-linecap="round" stroke-linejoin="round"> <svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="20 6 9 17 4 12" /> <polyline points="20 6 9 17 4 12" />
</svg> </svg>
{{ acceptingId === challenge.id ? '...' : 'Accept' }} {{ acceptingId === challenge.id ? '...' : 'Accept' }}
</button> </button>
<button type="button" class="nc-btn-decline" <button
type="button"
class="nc-btn-decline"
[disabled]="!!acceptingId || decliningId === challenge.id" [disabled]="!!acceptingId || decliningId === challenge.id"
(click)="declineChallenge($event, challenge)"> (click)="declineChallenge($event, challenge)"
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" >
stroke-linecap="round" stroke-linejoin="round"> <svg
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /> width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg> </svg>
{{ decliningId === challenge.id ? '...' : 'Decline' }} {{ decliningId === challenge.id ? '...' : 'Decline' }}
</button> </button>
@@ -97,7 +150,9 @@
</div> </div>
<div class="nc-notif-footer"> <div class="nc-notif-footer">
<button type="button" class="nc-view-all" (click)="goToChallenges()">View all challenges</button> <button type="button" class="nc-view-all" (click)="goToChallenges()">
View all challenges
</button>
</div> </div>
</div> </div>
} }
@@ -105,23 +160,47 @@
<!-- Games quick-access --> <!-- Games quick-access -->
<button type="button" class="nc-games-btn" (click)="goToGames()"> <button type="button" class="nc-games-btn" (click)="goToGames()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" <svg
stroke-linecap="round" stroke-linejoin="round"> width="13"
<path d="M14.5 17.5L3 6"/> height="13"
<path d="M13 19l6-6"/><path d="M16 16l4 4"/> viewBox="0 0 24 24"
<path d="M19 21l2-2"/><path d="M15 5l4 4"/> fill="none"
<path d="M21 3l-3 1-4 4"/> stroke="currentColor"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14.5 17.5L3 6" />
<path d="M13 19l6-6" />
<path d="M16 16l4 4" />
<path d="M19 21l2-2" />
<path d="M15 5l4 4" />
<path d="M21 3l-3 1-4 4" />
</svg> </svg>
Games Games
</button> </button>
<!-- Profile --> <!-- Profile -->
<div class="nc-dropdown-wrap" data-dropdown="profile"> <div class="nc-dropdown-wrap" data-dropdown="profile">
<button type="button" class="nc-profile" [class.is-open]="profileOpen" (click)="toggleProfile($event)"> <button
type="button"
class="nc-profile"
[class.is-open]="profileOpen"
(click)="toggleProfile($event)"
>
<div class="nc-avatar nc-avatar-sm">{{ getInitial() }}</div> <div class="nc-avatar nc-avatar-sm">{{ getInitial() }}</div>
<span class="nc-profile-name">{{ user.username }}</span> <span class="nc-profile-name">{{ user.username }}</span>
<svg class="nc-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" <svg
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> class="nc-chevron"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" /> <polyline points="6 9 12 15 18 9" />
</svg> </svg>
</button> </button>
@@ -139,8 +218,16 @@
<div class="nc-menu-group"> <div class="nc-menu-group">
<button type="button" class="nc-menu-item" (click)="goToProfile()"> <button type="button" class="nc-menu-item" (click)="goToProfile()">
<span class="nc-menu-icon"> <span class="nc-menu-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" <svg
stroke-linecap="round" stroke-linejoin="round"> width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /> <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" /> <circle cx="12" cy="7" r="4" />
</svg> </svg>
@@ -150,11 +237,21 @@
<button type="button" class="nc-menu-item" (click)="goToChallenges()"> <button type="button" class="nc-menu-item" (click)="goToChallenges()">
<span class="nc-menu-icon"> <span class="nc-menu-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" <svg
stroke-linecap="round" stroke-linejoin="round"> width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="14.5" y1="17.5" x2="3" y2="6" /> <line x1="14.5" y1="17.5" x2="3" y2="6" />
<path d="M13 19l6 -6" /><path d="M16 16l4 4" /> <path d="M13 19l6 -6" />
<path d="M19 21l2 -2" /><path d="M15 5l4 4" /> <path d="M16 16l4 4" />
<path d="M19 21l2 -2" />
<path d="M15 5l4 4" />
<path d="M21 3l-3 1l-4 4" /> <path d="M21 3l-3 1l-4 4" />
</svg> </svg>
</span> </span>
@@ -163,8 +260,16 @@
<button type="button" class="nc-menu-item" (click)="toggleTheme($event)"> <button type="button" class="nc-menu-item" (click)="toggleTheme($event)">
<span class="nc-menu-icon"> <span class="nc-menu-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" <svg
stroke-linecap="round" stroke-linejoin="round"> width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" /> <path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
</svg> </svg>
</span> </span>
@@ -176,8 +281,16 @@
<div class="nc-menu-group"> <div class="nc-menu-group">
<button type="button" class="nc-menu-item danger" (click)="logout()"> <button type="button" class="nc-menu-item danger" (click)="logout()">
<span class="nc-menu-icon"> <span class="nc-menu-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" <svg
stroke-linecap="round" stroke-linejoin="round"> width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /> <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" /> <polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" /> <line x1="21" y1="12" x2="9" y2="12" />
@@ -189,21 +302,20 @@
</div> </div>
} }
</div> </div>
} @else { } @else {
<!-- Logged-out auth buttons --> <!-- Logged-out auth buttons -->
<button type="button" class="nc-auth-btn" (click)="openLoginDialog()">Login</button> <button type="button" class="nc-auth-btn" (click)="openLoginDialog()">Login</button>
<button type="button" class="nc-auth-btn nc-auth-btn--primary" (click)="openRegisterDialog()">Register</button> <button type="button" class="nc-auth-btn nc-auth-btn--primary" (click)="openRegisterDialog()">
Register
</button>
} }
</div> </div>
</nav> </nav>
@if (showLoginDialog) { @if (showLoginDialog) {
<app-login-dialog (onClose)="closeLoginDialog()" (onSuccess)="onLoginSuccess()" /> <app-login-dialog (onClose)="closeLoginDialog()" (onSuccess)="onLoginSuccess()" />
} }
@if (showRegisterDialog) { @if (showRegisterDialog) {
<app-register-dialog (onClose)="closeRegisterDialog()" (onSuccess)="onRegisterSuccess()" /> <app-register-dialog (onClose)="closeRegisterDialog()" (onSuccess)="onRegisterSuccess()" />
} }
+20 -15
View File
@@ -18,7 +18,7 @@ import { Challenge } from '../../models/challenge.models';
standalone: true, standalone: true,
imports: [CommonModule, LoginDialogComponent, RegisterDialogComponent], imports: [CommonModule, LoginDialogComponent, RegisterDialogComponent],
templateUrl: './toolbar.component.html', templateUrl: './toolbar.component.html',
styleUrl: './toolbar.component.css' styleUrl: './toolbar.component.css',
}) })
export class ToolbarComponent implements OnInit { export class ToolbarComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
@@ -46,9 +46,7 @@ export class ToolbarComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.destroyRef.onDestroy(() => this.stopPolling()); this.destroyRef.onDestroy(() => this.stopPolling());
this.authService.currentUser$ this.authService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user) => {
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(user => {
this.currentUser = user; this.currentUser = user;
if (user) { if (user) {
this.challengeWs.connect(); this.challengeWs.connect();
@@ -63,18 +61,21 @@ export class ToolbarComponent implements OnInit {
this.authDialogService.dialogState$ this.authDialogService.dialogState$
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(state => { .subscribe((state) => {
this.showLoginDialog = state === 'login'; this.showLoginDialog = state === 'login';
this.showRegisterDialog = state === 'register'; this.showRegisterDialog = state === 'register';
}); });
this.themeService.darkMode$ this.themeService.darkMode$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((isDark) => {
.pipe(takeUntilDestroyed(this.destroyRef)) this.isDarkMode = isDark;
.subscribe(isDark => { this.isDarkMode = isDark; }); });
this.challengeEventService.getIncomingChallenges$() this.challengeEventService
.getIncomingChallenges$()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(challenges => { this.incomingChallenges = challenges; }); .subscribe((challenges) => {
this.incomingChallenges = challenges;
});
} }
private startPolling(): void { private startPolling(): void {
@@ -91,7 +92,7 @@ export class ToolbarComponent implements OnInit {
private fetchChallenges(): void { private fetchChallenges(): void {
this.challengeService.listChallenges().subscribe({ this.challengeService.listChallenges().subscribe({
next: response => { next: (response) => {
const incoming = response.in ?? response.incoming ?? []; const incoming = response.in ?? response.incoming ?? [];
this.challengeEventService.setIncomingChallenges(incoming); this.challengeEventService.setIncomingChallenges(incoming);
@@ -104,7 +105,7 @@ export class ToolbarComponent implements OnInit {
} }
} }
} }
} },
}); });
} }
@@ -233,12 +234,14 @@ export class ToolbarComponent implements OnInit {
if (this.acceptingId || this.decliningId) return; if (this.acceptingId || this.decliningId) return;
this.acceptingId = challenge.id; this.acceptingId = challenge.id;
this.challengeService.acceptChallenge(challenge.id).subscribe({ this.challengeService.acceptChallenge(challenge.id).subscribe({
next: accepted => { next: (accepted) => {
this.acceptingId = null; this.acceptingId = null;
this.challengeEventService.onChallengeAccepted(accepted); this.challengeEventService.onChallengeAccepted(accepted);
if (accepted.gameId) void this.router.navigate(['/game', accepted.gameId]); if (accepted.gameId) void this.router.navigate(['/game', accepted.gameId]);
}, },
error: () => { this.acceptingId = null; } error: () => {
this.acceptingId = null;
},
}); });
} }
@@ -251,7 +254,9 @@ export class ToolbarComponent implements OnInit {
this.decliningId = null; this.decliningId = null;
this.challengeEventService.removeChallenge(challenge.id); this.challengeEventService.removeChallenge(challenge.id);
}, },
error: () => { this.decliningId = null; } error: () => {
this.decliningId = null;
},
}); });
} }
} }
-10
View File
@@ -3,21 +3,11 @@ 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) { @if (positionAnalysis && !hasAnnotations) {
<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>
+6 -12
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, forkJoin } from 'rxjs'; import { switchMap, of } 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,7 +146,6 @@ 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
@@ -208,7 +207,12 @@ 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,
@@ -219,16 +223,6 @@ 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 {
@@ -64,18 +64,6 @@
} }
.page-title { font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em; } .page-title { font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
.page-actions { display: flex; align-items: center; gap: 8px; }
.btn-servers {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 8px;
border: 1px solid var(--nc-border-strong);
background: transparent; color: var(--nc-text-muted);
font-size: 13px; font-weight: 600; cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.btn-servers:hover { color: var(--nc-text); border-color: var(--nc-text-muted); }
.btn-new { .btn-new {
display: inline-flex; align-items: center; gap: 6px; display: inline-flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 8px; border: none; padding: 7px 14px; border-radius: 8px; border: none;
@@ -339,74 +327,3 @@
color: var(--nc-success); letter-spacing: 0.06em; text-transform: uppercase; color: var(--nc-success); letter-spacing: 0.06em; text-transform: uppercase;
} }
.pairing-ongoing svg { animation: pulse 1.4s ease-in-out infinite; } .pairing-ongoing svg { animation: pulse 1.4s ease-in-out infinite; }
/* Official bot join section */
.join-divider {
display: flex; align-items: center; gap: 10px;
margin: 20px 0 14px;
}
.join-divider::before, .join-divider::after {
content: ''; flex: 1; height: 1px; background: var(--nc-border);
}
.join-divider-label {
font-size: 10px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.08em; color: var(--nc-text-dim); white-space: nowrap;
}
.official-bot-grid {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;
margin-bottom: 4px;
}
.official-bot-btn {
display: flex; align-items: center; justify-content: center; gap: 6px;
padding: 9px 4px; border-radius: 8px; border: 1px solid var(--nc-border);
background: var(--nc-surface); font-size: 12px; font-weight: 700;
cursor: pointer; transition: border-color 0.15s, background 0.15s, color 0.15s;
color: var(--nc-text-muted); text-transform: capitalize;
}
.official-bot-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.official-btn-easy:hover:not(:disabled) { border-color: var(--nc-success); color: var(--nc-success); background: rgba(94,229,161,0.07); }
.official-btn-medium:hover:not(:disabled) { border-color: var(--nc-warn); color: var(--nc-warn); background: rgba(255,209,102,0.07); }
.official-btn-hard:hover:not(:disabled) { border-color: var(--nc-neon); color: var(--nc-neon); background: rgba(255,69,200,0.07); }
.official-btn-expert:hover:not(:disabled) { border-color: var(--nc-danger); color: var(--nc-danger); background: rgba(255,122,122,0.07); }
/* Servers dialog */
.servers-dialog { max-width: 480px; }
.servers-list {
display: flex; flex-direction: column; gap: 6px;
margin-bottom: 20px;
}
.server-row {
display: flex; align-items: center; gap: 10px;
padding: 10px 12px; border-radius: 8px;
border: 1px solid var(--nc-border); background: var(--nc-surface);
}
.server-info { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.server-label { font-size: 13px; font-weight: 600; color: var(--nc-text); }
.server-url {
font-size: 11px; color: var(--nc-text-dim);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
font-family: monospace;
}
.server-remove-btn {
background: none; border: none; cursor: pointer;
color: var(--nc-text-dim); padding: 4px; border-radius: 6px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; transition: color 0.15s, background 0.15s;
font-size: 13px;
}
.server-remove-btn:hover:not(:disabled) { color: var(--nc-danger); background: rgba(255,122,122,0.1); }
.server-remove-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.server-add-form {
border-top: 1px solid var(--nc-border);
padding-top: 16px;
display: flex; flex-direction: column; gap: 0;
}
.server-add-heading {
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.08em; color: var(--nc-text-muted);
margin: 0 0 14px;
}
@@ -16,16 +16,7 @@
<header class="page-header"> <header class="page-header">
<div class="page-title-row"> <div class="page-title-row">
<h1 class="page-title">Tournaments</h1> <h1 class="page-title">Tournaments</h1>
<div class="page-actions">
@if (currentUser) { @if (currentUser) {
<button type="button" class="btn-servers" (click)="openServersDialog()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/>
<line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
Servers
</button>
<button type="button" class="btn-new" (click)="openCreateDialog()"> <button type="button" class="btn-new" (click)="openCreateDialog()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"> stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
@@ -35,7 +26,6 @@
</button> </button>
} }
</div> </div>
</div>
<div class="tabs" role="tablist"> <div class="tabs" role="tablist">
<button type="button" class="tab-btn" [class.active]="tab === 'started'" (click)="setTab('started')"> <button type="button" class="tab-btn" [class.active]="tab === 'started'" (click)="setTab('started')">
Live Live
@@ -202,16 +192,17 @@
<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 one of your bots to enter this tournament. Its token will be rotated to authenticate the join.</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 { } @else if (userBots.length === 0) {
@if (userBots.length === 0) { <p class="join-empty">You have no bots yet. Go to <strong>Bots</strong> in the nav to create one first.</p>
<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 || !!joiningOfficialDifficulty" [disabled]="!!joiningBotId"
(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>
@@ -227,102 +218,6 @@
@if (joinError) { @if (joinError) {
<div class="dialog-error">{{ joinError }}</div> <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>
}
@if (showServersDialog) {
<div class="dialog-overlay" (click)="closeServersDialog()">
<div class="dialog-card servers-dialog" (click)="$event.stopPropagation()">
<div class="dialog-head">
<span class="dialog-brand">Tournament servers</span>
<button type="button" class="dialog-close" (click)="closeServersDialog()">×</button>
</div>
<p class="join-hint">External tournament servers aggregated into this view. Tournaments from all servers appear in the list.</p>
@if (serversLoading) {
<div class="dialog-loading"><span class="pulse"></span>Loading servers…</div>
} @else if (servers.length === 0) {
<p class="join-empty">No external servers registered yet.</p>
} @else {
<div class="servers-list">
@for (s of servers; track s.id) {
<div class="server-row">
<div class="server-info">
<span class="server-label">{{ s.label }}</span>
<span class="server-url">{{ s.url }}</span>
</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 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">
<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>
} }
@@ -1,14 +1,11 @@
import { Component, DestroyRef, OnInit, inject } from '@angular/core'; import { Component, DestroyRef, OnInit, inject } from '@angular/core';
import { CommonModule, TitleCasePipe } from '@angular/common'; import { CommonModule } 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, 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';
import { OfficialBotService } from '../../services/official-bot.service';
import { TournamentServerService, ExternalTournamentServer } from '../../services/tournament-server.service';
import { Bot } from '../../models/bot.models'; import { Bot } from '../../models/bot.models';
import { Tournament, TournamentResult, RoundPairings } from '../../models/tournament.models'; import { Tournament, TournamentResult, RoundPairings } from '../../models/tournament.models';
import { CurrentUser } from '../../models/auth.models'; import { CurrentUser } from '../../models/auth.models';
@@ -18,7 +15,7 @@ type StatusTab = 'started' | 'created' | 'finished';
@Component({ @Component({
selector: 'app-tournaments', selector: 'app-tournaments',
standalone: true, standalone: true,
imports: [CommonModule, RouterLink, FormsModule, ReactiveFormsModule, TitleCasePipe], imports: [CommonModule, RouterLink, ReactiveFormsModule],
templateUrl: './tournaments.component.html', templateUrl: './tournaments.component.html',
styleUrl: './tournaments.component.css' styleUrl: './tournaments.component.css'
}) })
@@ -28,8 +25,6 @@ export class TournamentsComponent implements OnInit {
private readonly authService = inject(AuthService); private readonly authService = inject(AuthService);
private readonly fb = inject(FormBuilder); private readonly fb = inject(FormBuilder);
private readonly botService = inject(BotService); private readonly botService = inject(BotService);
private readonly officialBotService = inject(OfficialBotService);
private readonly tournamentServerService = inject(TournamentServerService);
private readonly router = inject(Router); private readonly router = inject(Router);
loading = true; loading = true;
@@ -59,24 +54,10 @@ 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;
readonly officialDifficulties = ['easy', 'medium', 'hard', 'expert'] as const;
joiningOfficialDifficulty: string | null = null;
officialJoinError: string | null = null;
showServersDialog = false;
servers: ExternalTournamentServer[] = [];
serversLoading = false;
newServerLabel = '';
newServerUrl = '';
addingServer = false;
addServerError: string | null = null;
removingServerId: string | null = null;
ngOnInit(): void { ngOnInit(): void {
this.authService.currentUser$ this.authService.currentUser$
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@@ -177,57 +158,27 @@ export class TournamentsComponent implements OnInit {
this.joinDialogTournamentId = tournamentId; this.joinDialogTournamentId = tournamentId;
this.joinError = null; this.joinError = null;
this.botsLoading = true; this.botsLoading = true;
forkJoin({ user: this.botService.list(), official: this.botService.listOfficial() }) this.botService.list()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ .subscribe({
next: ({ user, official }) => { next: bots => { this.userBots = bots; this.botsLoading = false; },
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.officialJoinError = null;
}
joinWithOfficialBot(difficulty: string): void {
if (!this.joinDialogTournamentId || this.joiningOfficialDifficulty || this.joiningBotId) return;
this.joiningOfficialDifficulty = difficulty;
this.officialJoinError = null;
const tid = this.joinDialogTournamentId;
this.officialBotService.joinTournament(tid, difficulty).subscribe({
next: () => {
this.joiningOfficialDifficulty = null;
this.closeJoinDialog();
this.tournamentService.get(tid)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(updated => {
this.created = this.created.map(x => x.id === tid ? updated : x);
this.started = this.started.map(x => x.id === tid ? updated : x);
if (this.selectedTournament?.id === tid) this.selectedTournament = updated;
});
},
error: err => {
this.joiningOfficialDifficulty = null;
this.officialJoinError = err.error?.error ?? 'Failed to join with official bot.';
}
});
} }
joinWithBot(bot: Bot): void { joinWithBot(bot: Bot): void {
if (!this.joinDialogTournamentId || this.joiningBotId) return; if (!this.joinDialogTournamentId || this.joiningBotId) return;
this.joiningBotId = bot.id; this.joiningBotId = bot.id;
this.joinError = null; this.joinError = null;
this.tournamentService.join(this.joinDialogTournamentId, bot.id, bot.name).subscribe({ this.botService.rotateToken(bot.id).subscribe({
next: token => {
this.tournamentService.joinWithBotToken(this.joinDialogTournamentId!, token).subscribe({
next: () => { next: () => {
this.joiningBotId = null; this.joiningBotId = null;
const tid = this.joinDialogTournamentId!; const tid = this.joinDialogTournamentId!;
@@ -245,60 +196,14 @@ export class TournamentsComponent implements OnInit {
this.joinError = err.error?.message ?? err.error?.error ?? 'Failed to join tournament.'; this.joinError = err.error?.message ?? err.error?.error ?? 'Failed to join tournament.';
} }
}); });
}
openServersDialog(): void {
this.newServerLabel = '';
this.newServerUrl = '';
this.addServerError = null;
this.showServersDialog = true;
this.serversLoading = true;
this.tournamentServerService.list()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: res => { this.servers = res.servers; this.serversLoading = false; },
error: () => { this.serversLoading = false; }
});
}
closeServersDialog(): void {
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 => { error: () => {
this.addingServer = false; this.joiningBotId = null;
this.addServerError = err.error?.error ?? 'Failed to add server.'; this.joinError = 'Failed to get bot token.';
} }
}); });
} }
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))
+1 -2
View File
@@ -9,8 +9,7 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
req.url.includes('/api/account/official-bots') || req.url.includes('/api/account/official-bots') ||
req.url.includes('/api/board/game') || req.url.includes('/api/board/game') ||
req.url.includes('/api/challenge') || 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')) { if (token && isProtectedEndpoint && !req.headers.has('Authorization')) {
req = req.clone({ req = req.clone({
-5
View File
@@ -7,16 +7,11 @@ import { Bot, BotWithToken } from '../models/bot.models';
export class BotService { export class BotService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly base = '/api/account/bots'; private readonly base = '/api/account/bots';
private readonly officialBase = '/api/account/official-bots';
list(): Observable<Bot[]> { list(): Observable<Bot[]> {
return this.http.get<Bot[]>(this.base); return this.http.get<Bot[]>(this.base);
} }
listOfficial(): Observable<Bot[]> {
return this.http.get<Bot[]>(this.officialBase);
}
create(name: string): Observable<BotWithToken> { create(name: string): Observable<BotWithToken> {
return this.http.post<BotWithToken>(this.base, { name }); return this.http.post<BotWithToken>(this.base, { name });
} }
@@ -22,7 +22,7 @@ export class ChallengeWebSocketService {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (!token) return; if (!token) return;
const url = `${environment.userWsBaseUrl}/api/user/ws`; const url = `${environment.userWsBaseUrl}/api/user/ws?token=${encodeURIComponent(token)}`;
try { try {
this.intentionalClose = false; this.intentionalClose = false;
@@ -30,7 +30,6 @@ export class ChallengeWebSocketService {
this.ws.onopen = () => { this.ws.onopen = () => {
this.reconnectAttempts = 0; this.reconnectAttempts = 0;
this.ws?.send(JSON.stringify({ type: 'auth', token }));
}; };
this.ws.onmessage = (event) => { this.ws.onmessage = (event) => {
@@ -77,10 +76,8 @@ export class ChallengeWebSocketService {
const challengeId = message['challengeId'] as string | undefined; const challengeId = message['challengeId'] as string | undefined;
if (challengeId) { if (challengeId) {
this.challengeService.getChallenge(challengeId).subscribe({ this.challengeService.getChallenge(challengeId).subscribe({
next: (challenge) => this.challengeEventService.onChallengeReceived(challenge), next: challenge => this.challengeEventService.onChallengeReceived(challenge),
error: () => { error: () => { /* challenge may have already expired */ }
/* challenge may have already expired */
},
}); });
} }
break; break;
@@ -113,8 +110,6 @@ export class ChallengeWebSocketService {
private attemptReconnect(): void { private attemptReconnect(): void {
if (this.intentionalClose || this.reconnectAttempts >= this.maxReconnectAttempts) return; if (this.intentionalClose || this.reconnectAttempts >= this.maxReconnectAttempts) return;
this.reconnectAttempts++; this.reconnectAttempts++;
setTimeout(() => { setTimeout(() => { this.connect(); }, this.reconnectDelay);
this.connect();
}, this.reconnectDelay);
} }
} }
+8 -26
View File
@@ -1,7 +1,6 @@
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,
@@ -10,7 +9,7 @@ import {
LegalMovesResponse, LegalMovesResponse,
PlayerInfo, PlayerInfo,
} from '../models/game.models'; } from '../models/game.models';
import { AnalysisRequest, AnalysisResponse, RawAnalysisResponse } from '../models/analysis.models'; import { AnalysisRequest, AnalysisResponse } from '../models/analysis.models';
import { StreamHandlerService } from './stream-handler.service'; import { StreamHandlerService } from './stream-handler.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -79,28 +78,8 @@ 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 return this.http.post<AnalysisResponse>(`${this.apiBase}/api/analysis/position`, request);
.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 {
@@ -113,8 +92,11 @@ export class GameApiService {
} }
streamGame(gameId: string): Observable<GameStreamEvent> { streamGame(gameId: string): Observable<GameStreamEvent> {
const wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/ws`; const token = localStorage.getItem('token');
const token = localStorage.getItem('token') ?? ''; let wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/ws`;
return this.streamHandler.createGameStream(wsUrl, gameId, token); if (token) {
wsUrl += `?token=${encodeURIComponent(token)}`;
}
return this.streamHandler.createGameStream(wsUrl, gameId);
} }
} }
-22
View File
@@ -1,22 +0,0 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface JoinTournamentResponse {
botId: string;
difficulty: string;
status: string;
}
@Injectable({ providedIn: 'root' })
export class OfficialBotService {
private readonly http = inject(HttpClient);
private readonly base = '/api/bots/official';
joinTournament(tournamentId: string, difficulty: string): Observable<JoinTournamentResponse> {
return this.http.post<JoinTournamentResponse>(`${this.base}/join-tournament`, {
tournamentId,
difficulty,
});
}
}
+2 -3
View File
@@ -6,7 +6,7 @@ const WS_CONNECT_TIMEOUT_MS = 3000;
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class StreamHandlerService { export class StreamHandlerService {
createGameStream(wsUrl: string, gameId: string, token: string): Observable<GameStreamEvent> { createGameStream(wsUrl: string, gameId: string): Observable<GameStreamEvent> {
return new Observable<GameStreamEvent>((observer) => { return new Observable<GameStreamEvent>((observer) => {
const ws = new WebSocket(wsUrl); const ws = new WebSocket(wsUrl);
let connected = false; let connected = false;
@@ -14,7 +14,7 @@ export class StreamHandlerService {
const emitErrorEvent = (message: string): void => { const emitErrorEvent = (message: string): void => {
const errorEvent: ErrorEvent = { const errorEvent: ErrorEvent = {
type: 'error', type: 'error',
error: { code: 'STREAM_ERROR', message }, error: { code: 'STREAM_ERROR', message }
}; };
observer.next(errorEvent); observer.next(errorEvent);
}; };
@@ -36,7 +36,6 @@ export class StreamHandlerService {
connected = true; connected = true;
clearTimeout(connectionTimeoutId); clearTimeout(connectionTimeoutId);
console.log(`[StreamHandler] WebSocket connected for ${gameId}`); console.log(`[StreamHandler] WebSocket connected for ${gameId}`);
ws.send(JSON.stringify({ type: 'auth', token }));
}; };
ws.onmessage = (message) => { ws.onmessage = (message) => {
@@ -1,31 +0,0 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface ExternalTournamentServer {
id: string;
label: string;
url: string;
}
export interface ExternalTournamentServerList {
servers: ExternalTournamentServer[];
}
@Injectable({ providedIn: 'root' })
export class TournamentServerService {
private readonly http = inject(HttpClient);
private readonly base = '/api/tournament/servers';
list(): Observable<ExternalTournamentServerList> {
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}`);
}
}
+4 -2
View File
@@ -40,8 +40,10 @@ export class TournamentService {
return this.http.post<Tournament>(`${this.base}/${id}/start`, null); return this.http.post<Tournament>(`${this.base}/${id}/start`, null);
} }
join(id: string, botId: string, botName: string): Observable<void> { joinWithBotToken(id: string, botToken: string): Observable<void> {
return this.http.post<void>(`${this.base}/${id}/join`, { botId, botName }); return this.http.post<void>(`${this.base}/${id}/join`, null, {
headers: new HttpHeaders({ Authorization: `Bearer ${botToken}` })
});
} }
roundPairings(id: string, round: number): Observable<RoundPairings> { roundPairings(id: string, round: number): Observable<RoundPairings> {
+2 -2
View File
@@ -1,3 +1,3 @@
MAJOR=0 MAJOR=0
MINOR=4 MINOR=2
PATCH=4 PATCH=8