Compare commits

...

12 Commits

Author SHA1 Message Date
TeamCity a98b5534b7 ci: bump version to v0.6.2 2026-06-23 12:20:31 +00:00
shahdlala66 147d7f0d2c fix: jwt token issue 2026-06-23 14:17:22 +02:00
TeamCity 1f1339809f ci: bump version to v0.6.1 2026-06-23 08:48:26 +00:00
shahdlala66 0621968c3c fix: api streaming issues 2026-06-23 10:33:31 +02:00
shahdlala66 bd6d023513 fix: streaming issues 2026-06-23 10:33:04 +02:00
shahdlala66 2229cfd00a fix: api url 2026-06-23 10:19:35 +02:00
TeamCity 00b51b57b4 ci: bump version to v0.6.0 2026-06-23 07:41:38 +00:00
shosho996 1dabd88c62 feat: NCWF-10 streaming endpoint (#14)
Co-authored-by: shahdlala66 <shahd.lala66@gmail.com>
Reviewed-on: #14
2026-06-23 09:38:08 +02:00
TeamCity ab2c641130 ci: bump version to v0.5.0 2026-06-21 20:08:19 +00:00
Janis Eccarius 412591dfe0 feat(tournaments): remove external server add/remove UI
Servers are now env-var configured; the Servers dialog, add form,
remove buttons, and TournamentServerService are all deleted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 21:40:48 +02:00
TeamCity e374d5e791 ci: bump version to v0.4.4 2026-06-21 14:06:48 +00:00
Janis Eccarius 5b5fd6f027 fix(tournaments): load both user bots and official bots in join dialog
openJoinDialog now fetches user bots and official bots in parallel via
forkJoin. Each section shows its own empty state independently.

Official bot difficulty buttons are hidden when no official bots are
registered. User bots empty state links to /bots to create one.

Disables all join buttons while any join is in progress.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 15:40:24 +02:00
18 changed files with 647 additions and 148 deletions
+3
View File
@@ -41,3 +41,6 @@ Thumbs.db
# Claude Code
/.claude/settings.local.json
/.claude/worktrees/
# Local clone of the tournament server repo (not tracked)
/tournament-server/
+27
View File
@@ -81,3 +81,30 @@
### 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))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.6.0...0.0.0) (2026-06-23)
### Bug Fixes
* api streaming issues ([0621968](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/0621968c3ceddebe01e9c363bda345b5dcccfbbf))
* api url ([2229cfd](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/2229cfd00a7d16daa6a9544c8940e792c4362dfb))
* streaming issues ([bd6d023](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/bd6d02351336ed6adf66244979c6d959f47e318b))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.6.1...0.0.0) (2026-06-23)
### Bug Fixes
* jwt token issue ([147d7f0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/147d7f0d2ca7a77bb80eb4b73b8d60b00ad2f708))
+2
View File
@@ -5,6 +5,7 @@ import { ProfileComponent } from './pages/profile/profile.component';
import { ChallengesComponent } from './pages/challenges/challenges.component';
import { GamesComponent } from './pages/games/games.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 { AnalysisComponent } from './pages/analysis/analysis.component';
@@ -14,6 +15,7 @@ export const routes: Routes = [
{ path: 'games', component: GamesComponent },
{ path: 'challenges', component: ChallengesComponent },
{ path: 'tournaments', component: TournamentsComponent },
{ path: 'tournament/:id/game/:gameId', component: TournamentWatchComponent },
{ path: 'bots', component: BotsComponent },
{ path: 'analysis', component: AnalysisComponent },
{ path: 'game/:gameId', component: GameComponent },
@@ -9,7 +9,7 @@
<!-- Center links — only when logged in -->
@if (currentUser) {
<div class="nc-links">
<button type="button" class="nc-link">
<button type="button" class="nc-link" (click)="goToTournaments()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
+48 -4
View File
@@ -51,16 +51,60 @@ export interface TournamentList {
finished: Tournament[];
}
export interface TournamentMatch {
gameId: string;
whiteId: string;
winner?: 'white' | 'black' | 'draw' | null;
status?: GameStatus;
}
export interface TournamentPairing {
id: string;
round: number;
id?: string;
round?: number;
white: TournamentBotRef | null;
black: TournamentBotRef;
gameId: string | null;
winner: 'white' | 'black' | 'draw' | null;
matches: TournamentMatch[];
winner?: 'white' | 'black' | 'draw' | null;
}
export interface RoundPairings {
round: number;
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' };
@@ -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;
}
}
}
@@ -158,9 +158,10 @@
<div class="state-msg small"><span class="pulse"></span>Loading…</div>
} @else if (pairings && pairings.pairings.length > 0) {
<div class="pairings-list">
@for (p of pairings.pairings; track p.id) {
<div class="pairing-row" [class.is-watchable]="!!p.gameId"
(click)="p.gameId && watchGame(p.gameId)">
@for (p of pairings.pairings; track pairingKey(p)) {
@let gid = firstGameId(p);
<div class="pairing-row" [class.is-watchable]="!!gid"
(click)="gid && watchGame(gid)">
<span class="pairing-white">{{ p.white?.name ?? 'Bye' }}</span>
<span class="pairing-vs">vs</span>
<span class="pairing-black">{{ p.black.name }}</span>
@@ -168,7 +169,7 @@
<span class="pairing-result" [class]="'result-' + p.winner">
{{ p.winner === 'draw' ? '½–½' : p.winner === 'white' ? '10' : '01' }}
</span>
} @else if (p.gameId) {
} @else if (gid) {
<span class="pairing-ongoing">
<svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="10"/>
@@ -202,53 +203,57 @@
<span class="dialog-brand">Join with a bot</span>
<button type="button" class="dialog-close" (click)="closeJoinDialog()">×</button>
</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) {
<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 {
<div class="bot-pick-list">
@for (bot of userBots; track bot.id) {
<button type="button" class="bot-pick-row"
[disabled]="!!joiningBotId"
(click)="joinWithBot(bot)">
<span class="bot-pick-avatar">{{ bot.name.charAt(0).toUpperCase() }}</span>
<span class="bot-pick-name">{{ bot.name }}</span>
<span class="bot-pick-rating">{{ bot.rating }}</span>
@if (joiningBotId === bot.id) {
<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>
<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>
@if (userBots.length === 0) {
<p class="join-empty">You have no bots. <a routerLink="/bots">Create one</a> to join with your own bot.</p>
} @else {
<div class="bot-pick-list">
@for (bot of userBots; track bot.id) {
<button type="button" class="bot-pick-row"
[disabled]="!!joiningBotId || !!joiningOfficialDifficulty"
(click)="joinWithBot(bot)">
<span class="bot-pick-avatar">{{ bot.name.charAt(0).toUpperCase() }}</span>
<span class="bot-pick-name">{{ bot.name }}</span>
<span class="bot-pick-rating">{{ bot.rating }}</span>
@if (joiningBotId === bot.id) {
<span class="bot-pick-spinner"></span>
}
</button>
}
{{ d | titlecase }}
</button>
</div>
}
</div>
@if (officialJoinError) {
<div class="dialog-error">{{ officialJoinError }}</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>
@@ -267,7 +272,7 @@
@if (serversLoading) {
<div class="dialog-loading"><span class="pulse"></span>Loading servers…</div>
} @else if (servers.length === 0) {
<p class="join-empty">No external servers registered yet.</p>
<p class="join-empty">No external servers registered.</p>
} @else {
<div class="servers-list">
@for (s of servers; track s.id) {
@@ -276,49 +281,14 @@
<span class="server-label">{{ s.label }}</span>
<span class="server-url">{{ s.url }}</span>
</div>
<button type="button" class="server-remove-btn"
[disabled]="removingServerId === s.id"
(click)="removeServer(s.id)"
title="Remove server">
@if (removingServerId === s.id) { … } @else {
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6"/><path d="M14 11v6"/>
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
}
</button>
</div>
}
</div>
}
<div class="server-add-form">
<h4 class="server-add-heading">Add server</h4>
<div class="dialog-field">
<label class="dialog-label">Label</label>
<input type="text" class="dialog-input" [(ngModel)]="newServerLabel"
placeholder="e.g. Local Dev Server" />
</div>
<div class="dialog-field">
<label class="dialog-label">URL</label>
<input type="url" class="dialog-input" [(ngModel)]="newServerUrl"
placeholder="http://host:8089" />
</div>
@if (addServerError) {
<div class="dialog-error">{{ addServerError }}</div>
}
<div class="dialog-actions">
<button type="button" class="btn-ghost" (click)="closeServersDialog()">Close</button>
<button type="button" class="btn-primary"
[disabled]="addingServer || !newServerLabel.trim() || !newServerUrl.trim()"
(click)="addServer()">
{{ addingServer ? 'Adding…' : 'Add' }}
</button>
</div>
<div class="dialog-actions">
<button type="button" class="btn-ghost" (click)="closeServersDialog()">Close</button>
</div>
</div>
</div>
}
@@ -3,14 +3,16 @@ import { CommonModule, TitleCasePipe } from '@angular/common';
import { Router, RouterLink } from '@angular/router';
import { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { forkJoin } from 'rxjs';
import { TournamentService } from '../../services/tournament.service';
import { AuthService } from '../../services/auth.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 { Tournament, TournamentResult, RoundPairings } from '../../models/tournament.models';
import { Tournament, TournamentResult, RoundPairings, TournamentPairing } from '../../models/tournament.models';
import { CurrentUser } from '../../models/auth.models';
import { environment } from '../../../environments/environment';
type StatusTab = 'started' | 'created' | 'finished';
@@ -58,6 +60,7 @@ export class TournamentsComponent implements OnInit {
joinDialogTournamentId: string | null = null;
userBots: Bot[] = [];
officialBots: Bot[] = [];
botsLoading = false;
joiningBotId: string | null = null;
joinError: string | null = null;
@@ -69,17 +72,15 @@ export class TournamentsComponent implements OnInit {
showServersDialog = false;
servers: ExternalTournamentServer[] = [];
serversLoading = false;
newServerLabel = '';
newServerUrl = '';
addingServer = false;
addServerError: string | null = null;
removingServerId: string | null = null;
ngOnInit(): void {
this.authService.currentUser$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(u => { this.currentUser = u; });
this.loadTournaments();
this.tournamentServerService.list()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ next: res => { this.servers = res.servers; }, error: () => {} });
}
openCreateDialog(): void {
@@ -166,8 +167,27 @@ export class TournamentsComponent implements OnInit {
});
}
firstGameId(p: TournamentPairing): string | null {
return p.matches?.[0]?.gameId ?? null;
}
pairingKey(p: TournamentPairing): string {
const w = p.white?.id ?? 'bye';
const b = p.black?.id ?? 'bye';
return `${w}-${b}-${this.firstGameId(p) ?? ''}`;
}
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 {
@@ -175,16 +195,22 @@ export class TournamentsComponent implements OnInit {
this.joinDialogTournamentId = tournamentId;
this.joinError = null;
this.botsLoading = true;
this.botService.listOfficial()
forkJoin({ user: this.botService.list(), official: this.botService.listOfficial() })
.pipe(takeUntilDestroyed(this.destroyRef))
.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; }
});
}
closeJoinDialog(): void {
this.joinDialogTournamentId = null;
this.userBots = [];
this.officialBots = [];
this.joiningBotId = null;
this.joinError = null;
this.joiningOfficialDifficulty = null;
@@ -240,9 +266,6 @@ export class TournamentsComponent implements OnInit {
}
openServersDialog(): void {
this.newServerLabel = '';
this.newServerUrl = '';
this.addServerError = null;
this.showServersDialog = true;
this.serversLoading = true;
this.tournamentServerService.list()
@@ -257,40 +280,6 @@ export class TournamentsComponent implements OnInit {
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 {
this.tournamentService.list()
.pipe(takeUntilDestroyed(this.destroyRef))
@@ -0,0 +1,55 @@
import { Injectable } from '@angular/core';
interface RegisterResponse {
id: string;
token: string;
}
@Injectable({ providedIn: 'root' })
export class TournamentAuthService {
private readonly inflight = new Map<string, Promise<string>>();
async getToken(serverUrl: string): Promise<string> {
const key = this.cacheKey(serverUrl);
const cached = localStorage.getItem(key);
if (cached) return cached;
const existing = this.inflight.get(key);
if (existing) return existing;
const promise = this.register(serverUrl)
.then(token => {
localStorage.setItem(key, token);
this.inflight.delete(key);
return token;
})
.catch(err => {
this.inflight.delete(key);
throw err;
});
this.inflight.set(key, promise);
return promise;
}
clearToken(serverUrl: string): void {
localStorage.removeItem(this.cacheKey(serverUrl));
}
private async register(serverUrl: string): Promise<string> {
const base = serverUrl.replace(/\/+$/, '');
const localName = localStorage.getItem('username') ?? 'viewer';
const res = await fetch(`${base}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: `${localName}-watch`, isBot: false })
});
if (!res.ok) throw new Error(`tournament-server register failed: ${res.status}`);
const body = (await res.json()) as RegisterResponse;
return body.token;
}
private cacheKey(serverUrl: string): string {
return `tournament-token:${serverUrl.replace(/\/+$/, '')}`;
}
}
@@ -20,12 +20,4 @@ export class TournamentServerService {
list(): Observable<ExternalTournamentServerList> {
return this.http.get<ExternalTournamentServerList>(this.base);
}
register(label: string, url: string): Observable<ExternalTournamentServer> {
return this.http.post<ExternalTournamentServer>(this.base, { label, url });
}
remove(id: string): Observable<void> {
return this.http.delete<void>(`${this.base}/${id}`);
}
}
@@ -0,0 +1,84 @@
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { GameStreamEvent, TournamentStreamEvent } from '../models/tournament.models';
import { TournamentAuthService } from './tournament-auth.service';
@Injectable({ providedIn: 'root' })
export class TournamentStreamService {
private readonly auth = inject(TournamentAuthService);
streamTournament(serverUrl: string, tournamentId: string): Observable<TournamentStreamEvent> {
return this.ndjson<TournamentStreamEvent>(
serverUrl,
`/api/tournament/${tournamentId}/stream`
);
}
streamGame(serverUrl: string, tournamentId: string, gameId: string): Observable<GameStreamEvent> {
return this.ndjson<GameStreamEvent>(
serverUrl,
`/api/tournament/${tournamentId}/game/${gameId}/stream`
);
}
private fullUrl(base: string, path: string): string {
if (!base) return path;
return `${base.replace(/\/+$/, '')}${path}`;
}
private ndjson<T>(serverUrl: string, path: string): Observable<T> {
return new Observable<T>(subscriber => {
const controller = new AbortController();
(async () => {
try {
const res = await this.openWithRetry(serverUrl, path, 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();
});
}
private async openWithRetry(serverUrl: string, path: string, signal: AbortSignal): Promise<Response> {
const url = this.fullUrl(serverUrl, path);
const token = await this.auth.getToken(serverUrl);
const res = await fetch(url, {
headers: { Accept: 'application/x-ndjson', Authorization: `Bearer ${token}` },
signal,
});
if (res.status !== 401) return res;
this.auth.clearToken(serverUrl);
const fresh = await this.auth.getToken(serverUrl);
return fetch(url, {
headers: { Accept: 'application/x-ndjson', Authorization: `Bearer ${fresh}` },
signal,
});
}
}
+2 -1
View File
@@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Tournament, TournamentList, RoundPairings } from '../models/tournament.models';
import { environment } from '../../environments/environment';
export interface CreateTournamentForm {
name: string;
@@ -14,7 +15,7 @@ export interface CreateTournamentForm {
@Injectable({ providedIn: 'root' })
export class TournamentService {
private readonly http = inject(HttpClient);
private readonly base = '/api/tournament';
private readonly base = `${(environment.tournamentServerUrl ?? '').replace(/\/+$/, '')}/api/tournament`;
list(): Observable<TournamentList> {
return this.http.get<TournamentList>(this.base);
+2 -1
View File
@@ -4,5 +4,6 @@ export const environment = {
accountServiceUrl: '',
wsBaseUrl: 'ws://localhost:8084',
userWsBaseUrl: 'ws://localhost:8084',
apiPath: '/api/board/game'
apiPath: '/api/board/game',
tournamentServerUrl: 'http://141.37.123.132:8086'
};
+2 -1
View File
@@ -8,5 +8,6 @@ export const environment = {
accountServiceUrl: runtimeConfig.apiUrl || 'https://st.nowchess.janis-eccarius.de',
wsBaseUrl: 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'
};
+2 -1
View File
@@ -8,5 +8,6 @@ export const environment = {
accountServiceUrl: runtimeConfig.apiUrl || '',
wsBaseUrl: runtimeConfig.wsUrl,
userWsBaseUrl: runtimeConfig.wsUrl,
apiPath: '/api/board/game'
apiPath: '/api/board/game',
tournamentServerUrl: 'http://141.37.123.132:8086'
};
+2 -2
View File
@@ -1,3 +1,3 @@
MAJOR=0
MINOR=4
PATCH=3
MINOR=6
PATCH=2