Compare commits
1 Commits
0.6.0
..
9b72ac9b63
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b72ac9b63 |
@@ -56,43 +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))
|
|
||||||
## [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,7 +5,6 @@ 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';
|
||||||
|
|
||||||
@@ -15,7 +14,6 @@ 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,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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,40 +64,3 @@ 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) {
|
@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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
.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); }
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,25 +16,15 @@
|
|||||||
<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-new" (click)="openCreateDialog()">
|
||||||
<button type="button" class="btn-servers" (click)="openServersDialog()">
|
<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.2" stroke-linecap="round" stroke-linejoin="round">
|
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/>
|
</svg>
|
||||||
<line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>
|
New tournament
|
||||||
</svg>
|
</button>
|
||||||
Servers
|
}
|
||||||
</button>
|
|
||||||
<button type="button" class="btn-new" (click)="openCreateDialog()">
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
||||||
stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
|
||||||
</svg>
|
|
||||||
New tournament
|
|
||||||
</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')">
|
||||||
@@ -202,92 +192,32 @@
|
|||||||
<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 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>
|
||||||
} @else {
|
} @else {
|
||||||
@if (userBots.length === 0) {
|
<div class="bot-pick-list">
|
||||||
<p class="join-empty">You have no bots. <a routerLink="/bots">Create one</a> to join with your own bot.</p>
|
@for (bot of userBots; track bot.id) {
|
||||||
} @else {
|
<button type="button" class="bot-pick-row"
|
||||||
<div class="bot-pick-list">
|
[disabled]="!!joiningBotId"
|
||||||
@for (bot of userBots; track bot.id) {
|
(click)="joinWithBot(bot)">
|
||||||
<button type="button" class="bot-pick-row"
|
<span class="bot-pick-avatar">{{ bot.name.charAt(0).toUpperCase() }}</span>
|
||||||
[disabled]="!!joiningBotId || !!joiningOfficialDifficulty"
|
<span class="bot-pick-name">{{ bot.name }}</span>
|
||||||
(click)="joinWithBot(bot)">
|
<span class="bot-pick-rating">{{ bot.rating }}</span>
|
||||||
<span class="bot-pick-avatar">{{ bot.name.charAt(0).toUpperCase() }}</span>
|
@if (joiningBotId === bot.id) {
|
||||||
<span class="bot-pick-name">{{ bot.name }}</span>
|
<span class="bot-pick-spinner">…</span>
|
||||||
<span class="bot-pick-rating">{{ bot.rating }}</span>
|
}
|
||||||
@if (joiningBotId === bot.id) {
|
</button>
|
||||||
<span class="bot-pick-spinner">…</span>
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (joinError) {
|
|
||||||
<div class="dialog-error">{{ joinError }}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="join-divider">
|
|
||||||
<span class="join-divider-label">or join with an official bot</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@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.</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>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="dialog-actions">
|
@if (joinError) {
|
||||||
<button type="button" class="btn-ghost" (click)="closeServersDialog()">Close</button>
|
<div class="dialog-error">{{ joinError }}</div>
|
||||||
</div>
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,21 @@
|
|||||||
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';
|
||||||
import { environment } from '../../../environments/environment';
|
|
||||||
|
|
||||||
type StatusTab = 'started' | 'created' | 'finished';
|
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'
|
||||||
})
|
})
|
||||||
@@ -29,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;
|
||||||
@@ -60,27 +54,15 @@ 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;
|
|
||||||
|
|
||||||
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 {
|
||||||
@@ -168,16 +150,7 @@ export class TournamentsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watchGame(gameId: string): void {
|
watchGame(gameId: string): void {
|
||||||
const tid = this.selectedTournament?.id;
|
void this.router.navigate(['/game', gameId]);
|
||||||
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 {
|
||||||
@@ -185,91 +158,52 @@ 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: () => {
|
next: token => {
|
||||||
this.joiningBotId = null;
|
this.tournamentService.joinWithBotToken(this.joinDialogTournamentId!, token).subscribe({
|
||||||
const tid = this.joinDialogTournamentId!;
|
next: () => {
|
||||||
this.closeJoinDialog();
|
this.joiningBotId = null;
|
||||||
this.tournamentService.get(tid)
|
const tid = this.joinDialogTournamentId!;
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
this.closeJoinDialog();
|
||||||
.subscribe(updated => {
|
this.tournamentService.get(tid)
|
||||||
this.created = this.created.map(x => x.id === tid ? updated : x);
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
this.started = this.started.map(x => x.id === tid ? updated : x);
|
.subscribe(updated => {
|
||||||
if (this.selectedTournament?.id === tid) this.selectedTournament = 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.joiningBotId = null;
|
||||||
|
this.joinError = err.error?.message ?? err.error?.error ?? 'Failed to join tournament.';
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
error: err => {
|
error: () => {
|
||||||
this.joiningBotId = null;
|
this.joiningBotId = null;
|
||||||
this.joinError = err.error?.message ?? err.error?.error ?? 'Failed to join tournament.';
|
this.joinError = 'Failed to get bot token.';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
openServersDialog(): void {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadTournaments(): void {
|
private loadTournaments(): void {
|
||||||
this.tournamentService.list()
|
this.tournamentService.list()
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,115 +6,110 @@ 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`;
|
const url = `${environment.userWsBaseUrl}/api/user/ws?token=${encodeURIComponent(token)}`;
|
||||||
|
|
||||||
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;
|
||||||
this.ws?.send(JSON.stringify({ type: 'auth', token }));
|
if (this.ws) {
|
||||||
};
|
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (message['type']) {
|
private handleMessage(data: string): void {
|
||||||
case 'CONNECTED':
|
let message: Record<string, unknown>;
|
||||||
break;
|
try {
|
||||||
|
message = JSON.parse(data) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
case 'challengeCreated': {
|
switch (message['type']) {
|
||||||
const challengeId = message['challengeId'] as string | undefined;
|
case 'CONNECTED':
|
||||||
if (challengeId) {
|
break;
|
||||||
this.challengeService.getChallenge(challengeId).subscribe({
|
|
||||||
next: (challenge) => this.challengeEventService.onChallengeReceived(challenge),
|
|
||||||
error: () => {
|
|
||||||
/* challenge may have already expired */
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'challengeAccepted': {
|
case 'challengeCreated': {
|
||||||
const challengeId = message['challengeId'] as string | undefined;
|
const challengeId = message['challengeId'] as string | undefined;
|
||||||
const gameId = message['gameId'] as string | undefined;
|
if (challengeId) {
|
||||||
if (challengeId) {
|
this.challengeService.getChallenge(challengeId).subscribe({
|
||||||
this.challengeEventService.removeChallenge(challengeId);
|
next: challenge => this.challengeEventService.onChallengeReceived(challenge),
|
||||||
}
|
error: () => { /* challenge may have already expired */ }
|
||||||
if (gameId) {
|
});
|
||||||
void this.router.navigate(['/game', gameId]);
|
}
|
||||||
}
|
break;
|
||||||
break;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
case 'challengeDeclined':
|
case 'challengeAccepted': {
|
||||||
case 'challengeExpired':
|
const challengeId = message['challengeId'] as string | undefined;
|
||||||
case 'challengeCancelled': {
|
const gameId = message['gameId'] as string | undefined;
|
||||||
const challengeId = message['challengeId'] as string | undefined;
|
if (challengeId) {
|
||||||
if (challengeId) {
|
this.challengeEventService.removeChallenge(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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
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, 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' })
|
||||||
@@ -30,11 +29,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 =
|
||||||
@@ -58,9 +57,7 @@ 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`, {
|
return this.http.get<LegalMovesResponse>(`${this.apiBase}${this.apiPath}/${gameId}/moves`, { params });
|
||||||
params,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
importFen(fen: string): Observable<GameFull> {
|
importFen(fen: string): Observable<GameFull> {
|
||||||
@@ -79,28 +76,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 +90,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,23 +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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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> {
|
||||||
|
|||||||
@@ -4,6 +4,5 @@ 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,6 +8,5 @@ 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,6 +8,5 @@ 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 tournament-server deleted from ffe36da943
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
MAJOR=0
|
MAJOR=0
|
||||||
MINOR=6
|
MINOR=2
|
||||||
PATCH=0
|
PATCH=8
|
||||||
|
|||||||
Reference in New Issue
Block a user