Compare commits

..

4 Commits

Author SHA1 Message Date
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
15 changed files with 470 additions and 94 deletions
+10
View File
@@ -86,3 +86,13 @@
### 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))
+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 },
+37
View File
@@ -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' };
@@ -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;
}
}
}
@@ -271,7 +271,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) {
@@ -280,49 +280,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>
}
@@ -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';
@@ -71,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 {
@@ -169,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 {
@@ -248,9 +256,6 @@ export class TournamentsComponent implements OnInit {
}
openServersDialog(): void {
this.newServerLabel = '';
this.newServerUrl = '';
this.addServerError = null;
this.showServersDialog = true;
this.serversLoading = true;
this.tournamentServerService.list()
@@ -265,40 +270,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))
@@ -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,66 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { GameStreamEvent, TournamentStreamEvent } from '../models/tournament.models';
@Injectable({ providedIn: 'root' })
export class TournamentStreamService {
streamTournament(serverUrl: string, tournamentId: string): Observable<TournamentStreamEvent> {
return this.ndjson<TournamentStreamEvent>(
this.url(serverUrl, `/api/tournament/${tournamentId}/stream`)
);
}
streamGame(serverUrl: string, tournamentId: string, gameId: string): Observable<GameStreamEvent> {
return this.ndjson<GameStreamEvent>(
this.url(serverUrl, `/api/tournament/${tournamentId}/game/${gameId}/stream`)
);
}
private url(base: string, path: string): string {
if (!base) return path;
return `${base.replace(/\/+$/, '')}${path}`;
}
private ndjson<T>(url: string): Observable<T> {
return new Observable<T>(subscriber => {
const controller = new AbortController();
const token = localStorage.getItem('token');
const headers: Record<string, string> = { Accept: 'application/x-ndjson' };
if (token) headers['Authorization'] = `Bearer ${token}`;
(async () => {
try {
const res = await fetch(url, { headers, signal: controller.signal });
if (!res.ok || !res.body) {
subscriber.error(new Error(`Stream failed: ${res.status} ${res.statusText}`));
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let nl: number;
while ((nl = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, nl).trim();
buffer = buffer.slice(nl + 1);
if (!line) continue;
try {
subscriber.next(JSON.parse(line) as T);
} catch {
// Drop malformed lines silently — server may emit partial keep-alives.
}
}
}
subscriber.complete();
} catch (err) {
if ((err as Error).name !== 'AbortError') subscriber.error(err);
}
})();
return () => controller.abort();
});
}
}
+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'
};
+1
Submodule tournament-server added at ffe36da943
+2 -2
View File
@@ -1,3 +1,3 @@
MAJOR=0
MINOR=4
PATCH=4
MINOR=6
PATCH=0