From 1dabd88c6286a7b01d6fe8527aec864b24e21cca Mon Sep 17 00:00:00 2001 From: Shahd Lala Date: Tue, 23 Jun 2026 09:38:08 +0200 Subject: [PATCH] feat: NCWF-10 streaming endpoint (#14) Co-authored-by: shahdlala66 Reviewed-on: https://git.janis-eccarius.de/NowChess/NowChess-Frontend/pulls/14 --- src/app/app.routes.ts | 2 + src/app/models/tournament.models.ts | 37 +++++ .../tournament-watch.component.css | 147 ++++++++++++++++++ .../tournament-watch.component.html | 63 ++++++++ .../tournament-watch.component.ts | 119 ++++++++++++++ .../tournaments/tournaments.component.ts | 15 +- src/app/services/tournament-stream.service.ts | 66 ++++++++ src/environments/environment.development.ts | 3 +- src/environments/environment.staging.ts | 3 +- src/environments/environment.ts | 3 +- tournament-server | 1 + 11 files changed, 455 insertions(+), 4 deletions(-) create mode 100644 src/app/pages/tournament-watch/tournament-watch.component.css create mode 100644 src/app/pages/tournament-watch/tournament-watch.component.html create mode 100644 src/app/pages/tournament-watch/tournament-watch.component.ts create mode 100644 src/app/services/tournament-stream.service.ts create mode 160000 tournament-server diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 30729b1..c1149dc 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -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 }, diff --git a/src/app/models/tournament.models.ts b/src/app/models/tournament.models.ts index 7dc854c..2951a72 100644 --- a/src/app/models/tournament.models.ts +++ b/src/app/models/tournament.models.ts @@ -64,3 +64,40 @@ 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' }; diff --git a/src/app/pages/tournament-watch/tournament-watch.component.css b/src/app/pages/tournament-watch/tournament-watch.component.css new file mode 100644 index 0000000..97be500 --- /dev/null +++ b/src/app/pages/tournament-watch/tournament-watch.component.css @@ -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); } diff --git a/src/app/pages/tournament-watch/tournament-watch.component.html b/src/app/pages/tournament-watch/tournament-watch.component.html new file mode 100644 index 0000000..ec1568f --- /dev/null +++ b/src/app/pages/tournament-watch/tournament-watch.component.html @@ -0,0 +1,63 @@ +
+
+ ← Tournaments +
+ @if (snapshot?.white && snapshot?.black) { + {{ snapshot!.white!.name }} + vs + {{ snapshot!.black!.name }} + @if (snapshot?.round) { + Round {{ snapshot!.round }} + } + } @else { + Game {{ gameId }} + } +
+
+ + @if (error) { +
{{ error }}
+ } @else if (connecting) { +
Connecting to stream…
+ } + +
+
+
+ {{ snapshot?.black?.name ?? 'Black' }} + {{ formatTime(clock?.blackTime) }} +
+ +
+ {{ snapshot?.white?.name ?? 'White' }} + {{ formatTime(clock?.whiteTime) }} +
+
+ + +
+
diff --git a/src/app/pages/tournament-watch/tournament-watch.component.ts b/src/app/pages/tournament-watch/tournament-watch.component.ts new file mode 100644 index 0000000..502cf50 --- /dev/null +++ b/src/app/pages/tournament-watch/tournament-watch.component.ts @@ -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; + } + } +} diff --git a/src/app/pages/tournaments/tournaments.component.ts b/src/app/pages/tournaments/tournaments.component.ts index bead045..bd62f99 100644 --- a/src/app/pages/tournaments/tournaments.component.ts +++ b/src/app/pages/tournaments/tournaments.component.ts @@ -12,6 +12,7 @@ import { TournamentServerService, ExternalTournamentServer } from '../../service import { Bot } from '../../models/bot.models'; import { Tournament, TournamentResult, RoundPairings } from '../../models/tournament.models'; import { CurrentUser } from '../../models/auth.models'; +import { environment } from '../../../environments/environment'; type StatusTab = 'started' | 'created' | 'finished'; @@ -77,6 +78,9 @@ export class TournamentsComponent implements OnInit { .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 { @@ -164,7 +168,16 @@ export class TournamentsComponent implements OnInit { } 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 { diff --git a/src/app/services/tournament-stream.service.ts b/src/app/services/tournament-stream.service.ts new file mode 100644 index 0000000..d6458bd --- /dev/null +++ b/src/app/services/tournament-stream.service.ts @@ -0,0 +1,66 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { GameStreamEvent, TournamentStreamEvent } from '../models/tournament.models'; + +@Injectable({ providedIn: 'root' }) +export class TournamentStreamService { + streamTournament(serverUrl: string, tournamentId: string): Observable { + return this.ndjson( + this.url(serverUrl, `/api/tournament/${tournamentId}/stream`) + ); + } + + streamGame(serverUrl: string, tournamentId: string, gameId: string): Observable { + return this.ndjson( + 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(url: string): Observable { + return new Observable(subscriber => { + const controller = new AbortController(); + const token = localStorage.getItem('token'); + const headers: Record = { 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(); + }); + } +} diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts index 0bb31eb..f80e457 100644 --- a/src/environments/environment.development.ts +++ b/src/environments/environment.development.ts @@ -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' }; diff --git a/src/environments/environment.staging.ts b/src/environments/environment.staging.ts index 208ecbe..b6eb936 100644 --- a/src/environments/environment.staging.ts +++ b/src/environments/environment.staging.ts @@ -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' }; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 58d8445..f31511f 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -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' }; diff --git a/tournament-server b/tournament-server new file mode 160000 index 0000000..ffe36da --- /dev/null +++ b/tournament-server @@ -0,0 +1 @@ +Subproject commit ffe36da94314f3a3487ca4528a4c8cbb15f5ef99