Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00b51b57b4 | |||
| 1dabd88c62 | |||
| ab2c641130 | |||
| 412591dfe0 | |||
| e374d5e791 | |||
| 5b5fd6f027 | |||
| ea8048e064 | |||
| ce1fb0d60b |
@@ -76,3 +76,23 @@
|
|||||||
### 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))
|
||||||
|
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.5.0...0.0.0) (2026-06-23)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* NCWF-10 streaming endpoint ([#14](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/14)) ([1dabd88](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/1dabd88c6286a7b01d6fe8527aec864b24e21cca))
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ProfileComponent } from './pages/profile/profile.component';
|
|||||||
import { ChallengesComponent } from './pages/challenges/challenges.component';
|
import { ChallengesComponent } from './pages/challenges/challenges.component';
|
||||||
import { GamesComponent } from './pages/games/games.component';
|
import { GamesComponent } from './pages/games/games.component';
|
||||||
import { TournamentsComponent } from './pages/tournaments/tournaments.component';
|
import { TournamentsComponent } from './pages/tournaments/tournaments.component';
|
||||||
|
import { TournamentWatchComponent } from './pages/tournament-watch/tournament-watch.component';
|
||||||
import { BotsComponent } from './pages/bots/bots.component';
|
import { BotsComponent } from './pages/bots/bots.component';
|
||||||
import { AnalysisComponent } from './pages/analysis/analysis.component';
|
import { AnalysisComponent } from './pages/analysis/analysis.component';
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ export const routes: Routes = [
|
|||||||
{ path: 'games', component: GamesComponent },
|
{ path: 'games', component: GamesComponent },
|
||||||
{ path: 'challenges', component: ChallengesComponent },
|
{ path: 'challenges', component: ChallengesComponent },
|
||||||
{ path: 'tournaments', component: TournamentsComponent },
|
{ path: 'tournaments', component: TournamentsComponent },
|
||||||
|
{ path: 'tournament/:id/game/:gameId', component: TournamentWatchComponent },
|
||||||
{ 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 },
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,3 +64,40 @@ export interface RoundPairings {
|
|||||||
round: number;
|
round: number;
|
||||||
pairings: TournamentPairing[];
|
pairings: TournamentPairing[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TournamentStreamEvent =
|
||||||
|
| { type: 'tournamentStarted' }
|
||||||
|
| { type: 'roundStarted'; round: number }
|
||||||
|
| { type: 'gameStart'; round: number; gameId: string; color: 'white' | 'black' }
|
||||||
|
| { type: 'roundFinished'; round: number }
|
||||||
|
| { type: 'tournamentFinished'; winner: TournamentBotRef | null }
|
||||||
|
| { type: 'heartbeat' };
|
||||||
|
|
||||||
|
export interface GameClock {
|
||||||
|
whiteTime: number;
|
||||||
|
blackTime: number;
|
||||||
|
increment: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GameStatus = 'pending' | 'ongoing' | 'checkmate' | 'stalemate' | 'draw' | 'resigned' | 'timeout';
|
||||||
|
|
||||||
|
export interface GameStateSnapshot {
|
||||||
|
id?: string;
|
||||||
|
tournamentId?: string;
|
||||||
|
round?: number;
|
||||||
|
white?: TournamentBotRef;
|
||||||
|
black?: TournamentBotRef;
|
||||||
|
moves: string;
|
||||||
|
fen: string;
|
||||||
|
status: GameStatus;
|
||||||
|
turn: 'white' | 'black';
|
||||||
|
winner: 'white' | 'black' | null;
|
||||||
|
clock?: GameClock;
|
||||||
|
startPosition?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GameStreamEvent =
|
||||||
|
| ({ type: 'gameState' } & GameStateSnapshot)
|
||||||
|
| { type: 'move'; uci: string; fen: string; turn: 'white' | 'black'; clock?: GameClock }
|
||||||
|
| { type: 'gameEnd'; winner: 'white' | 'black' | null; status: GameStatus }
|
||||||
|
| { type: 'heartbeat' };
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
.watch-page {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
color: var(--text-primary, #e6e6e6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
color: var(--text-secondary, #9aa3b2);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.back-link:hover { color: var(--accent, #d4b572); }
|
||||||
|
|
||||||
|
.watch-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.vs { color: var(--text-secondary, #9aa3b2); font-weight: 400; }
|
||||||
|
.round-tag {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(212, 181, 114, 0.15);
|
||||||
|
color: var(--accent, #d4b572);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-error {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(220, 70, 70, 0.12);
|
||||||
|
border: 1px solid rgba(220, 70, 70, 0.3);
|
||||||
|
color: #ff8a8a;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.watch-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-secondary, #9aa3b2);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.pulse {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%;
|
||||||
|
background: var(--accent, #d4b572);
|
||||||
|
animation: pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 0.3; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 320px;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.watch-layout { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-wrap { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
|
||||||
|
.clock {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface, #1b1f27);
|
||||||
|
border: 1px solid var(--border, #2a2f3a);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.clock.active {
|
||||||
|
border-color: var(--accent, #d4b572);
|
||||||
|
box-shadow: 0 0 0 1px rgba(212, 181, 114, 0.3);
|
||||||
|
}
|
||||||
|
.clock-label { font-weight: 500; }
|
||||||
|
.clock-time { font-size: 20px; font-weight: 600; }
|
||||||
|
|
||||||
|
.watch-side { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
.status-card {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface, #1b1f27);
|
||||||
|
border: 1px solid var(--border, #2a2f3a);
|
||||||
|
}
|
||||||
|
.status-line { font-weight: 600; }
|
||||||
|
.status-sub { font-size: 12px; color: var(--text-secondary, #9aa3b2); margin-top: 4px; }
|
||||||
|
|
||||||
|
.moves-card {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface, #1b1f27);
|
||||||
|
border: 1px solid var(--border, #2a2f3a);
|
||||||
|
max-height: 480px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.moves-heading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-secondary, #9aa3b2);
|
||||||
|
}
|
||||||
|
.ply-count {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent, #d4b572);
|
||||||
|
}
|
||||||
|
.moves-empty { color: var(--text-secondary, #9aa3b2); font-size: 13px; }
|
||||||
|
.moves-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.moves-list li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 32px 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.moves-list li:nth-child(odd) { background: rgba(255, 255, 255, 0.02); }
|
||||||
|
.move-no { color: var(--text-secondary, #9aa3b2); }
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<div class="watch-page">
|
||||||
|
<header class="watch-head">
|
||||||
|
<a class="back-link" routerLink="/tournaments">← Tournaments</a>
|
||||||
|
<div class="watch-title">
|
||||||
|
@if (snapshot?.white && snapshot?.black) {
|
||||||
|
<span class="player">{{ snapshot!.white!.name }}</span>
|
||||||
|
<span class="vs">vs</span>
|
||||||
|
<span class="player">{{ snapshot!.black!.name }}</span>
|
||||||
|
@if (snapshot?.round) {
|
||||||
|
<span class="round-tag">Round {{ snapshot!.round }}</span>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<span class="player">Game {{ gameId }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (error) {
|
||||||
|
<div class="watch-error">{{ error }}</div>
|
||||||
|
} @else if (connecting) {
|
||||||
|
<div class="watch-loading"><span class="pulse"></span>Connecting to stream…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="watch-layout">
|
||||||
|
<div class="board-wrap">
|
||||||
|
<div class="clock clock-top" [class.active]="status === 'ongoing' && turn === 'black'">
|
||||||
|
<span class="clock-label">{{ snapshot?.black?.name ?? 'Black' }}</span>
|
||||||
|
<span class="clock-time">{{ formatTime(clock?.blackTime) }}</span>
|
||||||
|
</div>
|
||||||
|
<app-chess-board [fen]="fen"></app-chess-board>
|
||||||
|
<div class="clock clock-bot" [class.active]="status === 'ongoing' && turn === 'white'">
|
||||||
|
<span class="clock-label">{{ snapshot?.white?.name ?? 'White' }}</span>
|
||||||
|
<span class="clock-time">{{ formatTime(clock?.whiteTime) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="watch-side">
|
||||||
|
<div class="status-card" [attr.data-status]="status">
|
||||||
|
<div class="status-line">{{ statusLabel() }}</div>
|
||||||
|
@if (clock?.increment) {
|
||||||
|
<div class="status-sub">+{{ clock!.increment }}s increment</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="moves-card">
|
||||||
|
<h3 class="moves-heading">Moves <span class="ply-count">{{ moves.length }}</span></h3>
|
||||||
|
@if (moves.length === 0) {
|
||||||
|
<p class="moves-empty">No moves yet.</p>
|
||||||
|
} @else {
|
||||||
|
<ol class="moves-list">
|
||||||
|
@for (p of movePairs(); track p.n) {
|
||||||
|
<li>
|
||||||
|
<span class="move-no">{{ p.n }}.</span>
|
||||||
|
<span class="move-uci">{{ p.white }}</span>
|
||||||
|
<span class="move-uci">{{ p.black ?? '' }}</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { Component, DestroyRef, OnInit, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
import { ChessBoardComponent } from '../../components/chess-board/chess-board.component';
|
||||||
|
import { TournamentStreamService } from '../../services/tournament-stream.service';
|
||||||
|
import { GameClock, GameStateSnapshot, GameStreamEvent, GameStatus } from '../../models/tournament.models';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
|
const INITIAL_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-tournament-watch',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterLink, ChessBoardComponent],
|
||||||
|
templateUrl: './tournament-watch.component.html',
|
||||||
|
styleUrl: './tournament-watch.component.css'
|
||||||
|
})
|
||||||
|
export class TournamentWatchComponent implements OnInit {
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly stream = inject(TournamentStreamService);
|
||||||
|
|
||||||
|
tournamentId = '';
|
||||||
|
gameId = '';
|
||||||
|
serverUrl = '';
|
||||||
|
|
||||||
|
fen = INITIAL_FEN;
|
||||||
|
turn: 'white' | 'black' = 'white';
|
||||||
|
status: GameStatus = 'pending';
|
||||||
|
winner: 'white' | 'black' | null = null;
|
||||||
|
clock: GameClock | null = null;
|
||||||
|
moves: string[] = [];
|
||||||
|
snapshot: GameStateSnapshot | null = null;
|
||||||
|
|
||||||
|
connecting = true;
|
||||||
|
error: string | null = null;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.tournamentId = this.route.snapshot.paramMap.get('id') ?? '';
|
||||||
|
this.gameId = this.route.snapshot.paramMap.get('gameId') ?? '';
|
||||||
|
this.serverUrl = this.route.snapshot.queryParamMap.get('server') ?? environment.tournamentServerUrl ?? '';
|
||||||
|
if (!this.tournamentId || !this.gameId) {
|
||||||
|
this.error = 'Missing tournament or game id.';
|
||||||
|
this.connecting = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.serverUrl) {
|
||||||
|
this.error = 'Missing tournament server URL.';
|
||||||
|
this.connecting = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stream.streamGame(this.serverUrl, this.tournamentId, this.gameId)
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe({
|
||||||
|
next: ev => this.apply(ev),
|
||||||
|
error: err => {
|
||||||
|
this.connecting = false;
|
||||||
|
this.error = (err as Error).message ?? 'Stream failed.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
movePairs(): { n: number; white: string; black: string | null }[] {
|
||||||
|
const pairs: { n: number; white: string; black: string | null }[] = [];
|
||||||
|
for (let i = 0; i < this.moves.length; i += 2) {
|
||||||
|
pairs.push({ n: i / 2 + 1, white: this.moves[i], black: this.moves[i + 1] ?? null });
|
||||||
|
}
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatTime(seconds: number | undefined): string {
|
||||||
|
if (seconds === undefined || seconds === null) return '—';
|
||||||
|
const s = Math.max(0, Math.floor(seconds));
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
const r = s % 60;
|
||||||
|
return `${m}:${r.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusLabel(): string {
|
||||||
|
switch (this.status) {
|
||||||
|
case 'pending': return 'Waiting to start';
|
||||||
|
case 'ongoing': return `${this.turn === 'white' ? 'White' : 'Black'} to move`;
|
||||||
|
case 'checkmate': return `Checkmate — ${this.winner ?? '—'} wins`;
|
||||||
|
case 'stalemate': return 'Stalemate';
|
||||||
|
case 'draw': return 'Draw';
|
||||||
|
case 'resigned': return `Resigned — ${this.winner ?? '—'} wins`;
|
||||||
|
case 'timeout': return `Timeout — ${this.winner ?? '—'} wins`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private apply(ev: GameStreamEvent): void {
|
||||||
|
this.connecting = false;
|
||||||
|
switch (ev.type) {
|
||||||
|
case 'gameState':
|
||||||
|
this.snapshot = ev;
|
||||||
|
this.fen = ev.fen;
|
||||||
|
this.turn = ev.turn;
|
||||||
|
this.status = ev.status;
|
||||||
|
this.winner = ev.winner;
|
||||||
|
this.clock = ev.clock ?? null;
|
||||||
|
this.moves = ev.moves ? ev.moves.split(/\s+/).filter(Boolean) : [];
|
||||||
|
return;
|
||||||
|
case 'move':
|
||||||
|
this.fen = ev.fen;
|
||||||
|
this.turn = ev.turn;
|
||||||
|
if (ev.clock) this.clock = ev.clock;
|
||||||
|
this.moves = [...this.moves, ev.uci];
|
||||||
|
return;
|
||||||
|
case 'gameEnd':
|
||||||
|
this.status = ev.status;
|
||||||
|
this.winner = ev.winner;
|
||||||
|
return;
|
||||||
|
case 'heartbeat':
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -11,6 +12,7 @@ import { TournamentServerService, ExternalTournamentServer } from '../../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';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
type StatusTab = 'started' | 'created' | 'finished';
|
type StatusTab = 'started' | 'created' | 'finished';
|
||||||
|
|
||||||
@@ -58,6 +60,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,17 +72,15 @@ 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$
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
.subscribe(u => { this.currentUser = u; });
|
.subscribe(u => { this.currentUser = u; });
|
||||||
this.loadTournaments();
|
this.loadTournaments();
|
||||||
|
this.tournamentServerService.list()
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe({ next: res => { this.servers = res.servers; }, error: () => {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
openCreateDialog(): void {
|
openCreateDialog(): void {
|
||||||
@@ -167,7 +168,16 @@ export class TournamentsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watchGame(gameId: string): void {
|
watchGame(gameId: string): void {
|
||||||
void this.router.navigate(['/game', gameId]);
|
const tid = this.selectedTournament?.id;
|
||||||
|
if (!tid) return;
|
||||||
|
const server = this.servers[0]?.url || environment.tournamentServerUrl;
|
||||||
|
if (!server) {
|
||||||
|
this.joinError = 'No tournament server configured. Cannot open stream.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void this.router.navigate(['/tournament', tid, 'game', gameId], {
|
||||||
|
queryParams: { server }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
openJoinDialog(event: MouseEvent, tournamentId: string): void {
|
openJoinDialog(event: MouseEvent, tournamentId: string): void {
|
||||||
@@ -175,16 +185,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 +256,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 +270,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))
|
||||||
|
|||||||
@@ -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}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { GameStreamEvent, TournamentStreamEvent } from '../models/tournament.models';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class TournamentStreamService {
|
||||||
|
streamTournament(serverUrl: string, tournamentId: string): Observable<TournamentStreamEvent> {
|
||||||
|
return this.ndjson<TournamentStreamEvent>(
|
||||||
|
this.url(serverUrl, `/api/tournament/${tournamentId}/stream`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
streamGame(serverUrl: string, tournamentId: string, gameId: string): Observable<GameStreamEvent> {
|
||||||
|
return this.ndjson<GameStreamEvent>(
|
||||||
|
this.url(serverUrl, `/api/tournament/${tournamentId}/game/${gameId}/stream`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private url(base: string, path: string): string {
|
||||||
|
if (!base) return path;
|
||||||
|
return `${base.replace(/\/+$/, '')}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ndjson<T>(url: string): Observable<T> {
|
||||||
|
return new Observable<T>(subscriber => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const headers: Record<string, string> = { Accept: 'application/x-ndjson' };
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { headers, signal: controller.signal });
|
||||||
|
if (!res.ok || !res.body) {
|
||||||
|
subscriber.error(new Error(`Stream failed: ${res.status} ${res.statusText}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
let nl: number;
|
||||||
|
while ((nl = buffer.indexOf('\n')) >= 0) {
|
||||||
|
const line = buffer.slice(0, nl).trim();
|
||||||
|
buffer = buffer.slice(nl + 1);
|
||||||
|
if (!line) continue;
|
||||||
|
try {
|
||||||
|
subscriber.next(JSON.parse(line) as T);
|
||||||
|
} catch {
|
||||||
|
// Drop malformed lines silently — server may emit partial keep-alives.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
subscriber.complete();
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name !== 'AbortError') subscriber.error(err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,5 +4,6 @@ export const environment = {
|
|||||||
accountServiceUrl: '',
|
accountServiceUrl: '',
|
||||||
wsBaseUrl: 'ws://localhost:8084',
|
wsBaseUrl: 'ws://localhost:8084',
|
||||||
userWsBaseUrl: 'ws://localhost:8084',
|
userWsBaseUrl: 'ws://localhost:8084',
|
||||||
apiPath: '/api/board/game'
|
apiPath: '/api/board/game',
|
||||||
|
tournamentServerUrl: 'http://141.37.123.132:8086'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ export const environment = {
|
|||||||
accountServiceUrl: runtimeConfig.apiUrl || 'https://st.nowchess.janis-eccarius.de',
|
accountServiceUrl: runtimeConfig.apiUrl || 'https://st.nowchess.janis-eccarius.de',
|
||||||
wsBaseUrl: runtimeConfig.wsUrl || 'wss://st.nowchess.janis-eccarius.de',
|
wsBaseUrl: runtimeConfig.wsUrl || 'wss://st.nowchess.janis-eccarius.de',
|
||||||
userWsBaseUrl: runtimeConfig.wsUrl || 'wss://st.nowchess.janis-eccarius.de',
|
userWsBaseUrl: runtimeConfig.wsUrl || 'wss://st.nowchess.janis-eccarius.de',
|
||||||
apiPath: '/api/board/game'
|
apiPath: '/api/board/game',
|
||||||
|
tournamentServerUrl: 'http://141.37.123.132:8086'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ export const environment = {
|
|||||||
accountServiceUrl: runtimeConfig.apiUrl || '',
|
accountServiceUrl: runtimeConfig.apiUrl || '',
|
||||||
wsBaseUrl: runtimeConfig.wsUrl,
|
wsBaseUrl: runtimeConfig.wsUrl,
|
||||||
userWsBaseUrl: runtimeConfig.wsUrl,
|
userWsBaseUrl: runtimeConfig.wsUrl,
|
||||||
apiPath: '/api/board/game'
|
apiPath: '/api/board/game',
|
||||||
|
tournamentServerUrl: 'http://141.37.123.132:8086'
|
||||||
};
|
};
|
||||||
|
|||||||
Submodule
+1
Submodule tournament-server added at ffe36da943
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
MAJOR=0
|
MAJOR=0
|
||||||
MINOR=4
|
MINOR=6
|
||||||
PATCH=2
|
PATCH=0
|
||||||
|
|||||||
Reference in New Issue
Block a user