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 @@
+
+
+
+ @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