Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab2c641130 | |||
| 412591dfe0 | |||
| e374d5e791 | |||
| 5b5fd6f027 | |||
| ea8048e064 | |||
| ce1fb0d60b | |||
| 74f82bc0ba | |||
| 1d2c217da8 |
@@ -71,3 +71,23 @@
|
|||||||
### Bug Fixes
|
### 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))
|
* **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))
|
||||||
|
## [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))
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,53 +202,57 @@
|
|||||||
<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) {
|
|
||||||
<p class="join-empty">No official bots are available. The official-bots engine service must be running to register them.</p>
|
|
||||||
} @else {
|
} @else {
|
||||||
<div class="bot-pick-list">
|
@if (userBots.length === 0) {
|
||||||
@for (bot of userBots; track bot.id) {
|
<p class="join-empty">You have no bots. <a routerLink="/bots">Create one</a> to join with your own bot.</p>
|
||||||
<button type="button" class="bot-pick-row"
|
} @else {
|
||||||
[disabled]="!!joiningBotId"
|
<div class="bot-pick-list">
|
||||||
(click)="joinWithBot(bot)">
|
@for (bot of userBots; track bot.id) {
|
||||||
<span class="bot-pick-avatar">{{ bot.name.charAt(0).toUpperCase() }}</span>
|
<button type="button" class="bot-pick-row"
|
||||||
<span class="bot-pick-name">{{ bot.name }}</span>
|
[disabled]="!!joiningBotId || !!joiningOfficialDifficulty"
|
||||||
<span class="bot-pick-rating">{{ bot.rating }}</span>
|
(click)="joinWithBot(bot)">
|
||||||
@if (joiningBotId === bot.id) {
|
<span class="bot-pick-avatar">{{ bot.name.charAt(0).toUpperCase() }}</span>
|
||||||
<span class="bot-pick-spinner">…</span>
|
<span class="bot-pick-name">{{ bot.name }}</span>
|
||||||
}
|
<span class="bot-pick-rating">{{ bot.rating }}</span>
|
||||||
</button>
|
@if (joiningBotId === bot.id) {
|
||||||
}
|
<span class="bot-pick-spinner">…</span>
|
||||||
</div>
|
}
|
||||||
}
|
</button>
|
||||||
|
|
||||||
@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>
|
|
||||||
}
|
}
|
||||||
{{ d | titlecase }}
|
</div>
|
||||||
</button>
|
|
||||||
}
|
}
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (officialJoinError) {
|
@if (joinError) {
|
||||||
<div class="dialog-error">{{ officialJoinError }}</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>
|
||||||
</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,49 +280,14 @@
|
|||||||
<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">
|
<div class="dialog-actions">
|
||||||
<h4 class="server-add-heading">Add server</h4>
|
<button type="button" class="btn-ghost" (click)="closeServersDialog()">Close</button>
|
||||||
<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>
|
</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))
|
||||||
|
|||||||
@@ -6,110 +6,115 @@ import { ChallengeService } from './challenge.service';
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ChallengeWebSocketService {
|
export class ChallengeWebSocketService {
|
||||||
private readonly challengeEventService = inject(ChallengeEventService);
|
private readonly challengeEventService = inject(ChallengeEventService);
|
||||||
private readonly challengeService = inject(ChallengeService);
|
private readonly challengeService = inject(ChallengeService);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
private reconnectAttempts = 0;
|
private reconnectAttempts = 0;
|
||||||
private readonly maxReconnectAttempts = 5;
|
private readonly maxReconnectAttempts = 5;
|
||||||
private readonly reconnectDelay = 3000;
|
private readonly reconnectDelay = 3000;
|
||||||
private intentionalClose = false;
|
private intentionalClose = false;
|
||||||
|
|
||||||
connect(): void {
|
connect(): void {
|
||||||
if (this.ws) return;
|
if (this.ws) return;
|
||||||
|
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
const url = `${environment.userWsBaseUrl}/api/user/ws?token=${encodeURIComponent(token)}`;
|
const url = `${environment.userWsBaseUrl}/api/user/ws`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.intentionalClose = false;
|
this.intentionalClose = false;
|
||||||
this.ws = new WebSocket(url);
|
this.ws = new WebSocket(url);
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
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.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
if (this.ws) {
|
this.ws?.send(JSON.stringify({ type: 'auth', token }));
|
||||||
this.ws.close();
|
};
|
||||||
this.ws = null;
|
|
||||||
|
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 {
|
switch (message['type']) {
|
||||||
let message: Record<string, unknown>;
|
case 'CONNECTED':
|
||||||
try {
|
break;
|
||||||
message = JSON.parse(data) as Record<string, unknown>;
|
|
||||||
} catch {
|
case 'challengeCreated': {
|
||||||
return;
|
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 'challengeAccepted': {
|
||||||
case 'CONNECTED':
|
const challengeId = message['challengeId'] as string | undefined;
|
||||||
break;
|
const gameId = message['gameId'] as string | undefined;
|
||||||
|
if (challengeId) {
|
||||||
case 'challengeCreated': {
|
this.challengeEventService.removeChallenge(challengeId);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
if (gameId) {
|
||||||
|
void this.router.navigate(['/game', gameId]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
private attemptReconnect(): void {
|
case 'challengeDeclined':
|
||||||
if (this.intentionalClose || this.reconnectAttempts >= this.maxReconnectAttempts) return;
|
case 'challengeExpired':
|
||||||
this.reconnectAttempts++;
|
case 'challengeCancelled': {
|
||||||
setTimeout(() => { this.connect(); }, this.reconnectDelay);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
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,
|
||||||
GameState,
|
GameState,
|
||||||
GameStreamEvent,
|
GameStreamEvent,
|
||||||
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' })
|
||||||
@@ -29,11 +30,11 @@ export class GameApiService {
|
|||||||
const playerColor = Math.random() > 0.5 ? 'white' : 'black';
|
const playerColor = Math.random() > 0.5 ? 'white' : 'black';
|
||||||
const playerInfo: PlayerInfo = {
|
const playerInfo: PlayerInfo = {
|
||||||
id: `player-${Date.now()}`,
|
id: `player-${Date.now()}`,
|
||||||
displayName: 'You'
|
displayName: 'You',
|
||||||
};
|
};
|
||||||
const botInfo: PlayerInfo = {
|
const botInfo: PlayerInfo = {
|
||||||
id: `bot-${difficulty}`,
|
id: `bot-${difficulty}`,
|
||||||
displayName: `Bot (${difficulty})`
|
displayName: `Bot (${difficulty})`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const payload =
|
const payload =
|
||||||
@@ -57,7 +58,9 @@ export class GameApiService {
|
|||||||
if (square) {
|
if (square) {
|
||||||
params = params.set('square', 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> {
|
importFen(fen: string): Observable<GameFull> {
|
||||||
@@ -76,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 {
|
||||||
@@ -90,11 +113,8 @@ export class GameApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
streamGame(gameId: string): Observable<GameStreamEvent> {
|
streamGame(gameId: string): Observable<GameStreamEvent> {
|
||||||
const token = localStorage.getItem('token');
|
const wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/ws`;
|
||||||
let wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/ws`;
|
const token = localStorage.getItem('token') ?? '';
|
||||||
if (token) {
|
return this.streamHandler.createGameStream(wsUrl, gameId, token);
|
||||||
wsUrl += `?token=${encodeURIComponent(token)}`;
|
|
||||||
}
|
|
||||||
return this.streamHandler.createGameStream(wsUrl, gameId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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): Observable<GameStreamEvent> {
|
createGameStream(wsUrl: string, gameId: string, token: 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,6 +36,7 @@ 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) => {
|
||||||
|
|||||||
@@ -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
@@ -1,3 +1,3 @@
|
|||||||
MAJOR=0
|
MAJOR=0
|
||||||
MINOR=4
|
MINOR=5
|
||||||
PATCH=1
|
PATCH=0
|
||||||
|
|||||||
Reference in New Issue
Block a user