Compare commits

..

22 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
TeamCity ea8048e064 ci: bump version to v0.4.3 2026-06-21 09:06:47 +00:00
Janis Eccarius ce1fb0d60b fix(analysis): fix API field mismatch and enable full game analysis
Map raw backend response (evaluation/continuationMoves) to frontend
model (eval/winChance/continuations). Add getFenHistory() call after
loading a game or PGN so runAnalysis() gets per-ply FEN history and
triggers analyzeGame() instead of falling back to single-position
analysis. Remove !hasAnnotations guard so positionAnalysis card shows
even when a game is loaded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 11:00:29 +02:00
TeamCity 74f82bc0ba ci: bump version to v0.4.2 2026-06-17 09:19:46 +00:00
lq64 1d2c217da8 fix: NCS-122 send WS token via first-message auth instead of query param (#13)
Remove token from WebSocket URL query parameters in ChallengeWebSocketService
and GameApiService. Instead, send {"type":"auth","token":"..."} as the first
text frame after the connection opens, matching the new backend auth protocol.

---------

Co-authored-by: LQ63 <lkhermann@web.de>
Reviewed-on: #13
2026-06-17 10:50:16 +02:00
TeamCity 3d1b330396 ci: bump version to v0.4.1 2026-06-17 07:20:31 +00:00
Janis a54957aa74 fix(auth): attach Bearer token to /api/bots requests (#12)
OfficialBotService posts to /api/bots/official/join-tournament, but
the auth interceptor's protected-endpoint whitelist omitted /api/bots,
so no Authorization header was sent and the request hit the official-
bots service anonymously -> 401.

Add /api/bots to the whitelist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Reviewed-on: #12
2026-06-17 09:08:55 +02:00
TeamCity c9bf6d21fc ci: bump version to v0.4.0 2026-06-17 06:22:59 +00:00
Janis f9420e5848 feat: NCWF-5/6/7/8/9 chess analysis page and engine integration (#11)
Co-authored-by: Janis Eccarius <eccariusjanis@gmail.com>
Reviewed-on: #11
2026-06-17 08:17:55 +02:00
TeamCity a3e51bade5 ci: bump version to v0.3.0 2026-06-10 20:41:14 +00:00
lq64 48959daae3 feat: bots (#9)
Co-authored-by: Lala, Shahd <Shahd.Lala@sybit.de>
Reviewed-on: #9
Co-authored-by: Leon Hermann <lq@blackhole.local>
Co-committed-by: Leon Hermann <lq@blackhole.local>
2026-06-10 22:38:06 +02:00
30 changed files with 1282 additions and 507 deletions
+3
View File
@@ -41,3 +41,6 @@ Thumbs.db
# Claude Code # Claude Code
/.claude/settings.local.json /.claude/settings.local.json
/.claude/worktrees/ /.claude/worktrees/
# Local clone of the tournament server repo (not tracked)
/tournament-server/
+52
View File
@@ -56,3 +56,55 @@
### 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))
## [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))
+3 -1
View File
@@ -5,6 +5,7 @@ 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';
@@ -14,8 +15,9 @@ 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 },
{ path: '**', redirectTo: '' }, { path: '**', redirectTo: '' }
]; ];
+1 -1
View File
@@ -7,7 +7,7 @@ describe('App', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [App], imports: [App],
providers: [provideRouter([]), provideHttpClient()], providers: [provideRouter([]), provideHttpClient()]
}).compileComponents(); }).compileComponents();
}); });
+176 -288
View File
@@ -1,4 +1,5 @@
<nav class="nc-nav"> <nav class="nc-nav">
<!-- Logo --> <!-- Logo -->
<div class="nc-logo" (click)="goToHome()" role="button" tabindex="0"> <div class="nc-logo" (click)="goToHome()" role="button" tabindex="0">
<div class="nc-logo-mark"></div> <div class="nc-logo-mark"></div>
@@ -7,315 +8,202 @@
<!-- Center links — only when logged in --> <!-- Center links — only when logged in -->
@if (currentUser) { @if (currentUser) {
<div class="nc-links"> <div class="nc-links">
<button type="button" class="nc-link"> <button type="button" class="nc-link" (click)="goToTournaments()">
<svg <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
width="14" stroke-linecap="round" stroke-linejoin="round">
height="14" <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
viewBox="0 0 24 24" <circle cx="12" cy="12" r="3" />
fill="none" </svg>
stroke="currentColor" Watch
stroke-width="1.7" </button>
stroke-linecap="round" <button type="button" class="nc-link" (click)="goToTournaments()">Tournaments</button>
stroke-linejoin="round" <button type="button" class="nc-link" (click)="goToBots()">Bots</button>
> <button type="button" class="nc-link" (click)="goToAnalysis()">Analysis</button>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /> </div>
<circle cx="12" cy="12" r="3" />
</svg>
Watch
</button>
<button type="button" class="nc-link" (click)="goToTournaments()">Tournaments</button>
<button type="button" class="nc-link" (click)="goToBots()">Bots</button>
<button type="button" class="nc-link" (click)="goToAnalysis()">Analysis</button>
</div>
} }
<!-- Right cluster --> <!-- Right cluster -->
<div class="nc-right"> <div class="nc-right">
@if (currentUser; as user) { @if (currentUser; as user) {
<!-- Notifications bell -->
<div class="nc-dropdown-wrap" data-dropdown="notif">
<button
type="button"
class="nc-bell"
[class.is-open]="notifOpen"
(click)="toggleNotif($event)"
aria-label="Notifications"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</svg>
@if (incomingChallenges.length > 0) {
<span class="nc-badge">{{ incomingChallenges.length }}</span>
}
</button>
@if (notifOpen) { <!-- Notifications bell -->
<div class="nc-popover nc-notif" (click)="$event.stopPropagation()"> <div class="nc-dropdown-wrap" data-dropdown="notif">
<div class="nc-notif-header"> <button type="button" class="nc-bell" [class.is-open]="notifOpen" (click)="toggleNotif($event)"
<span class="nc-notif-header-title">Challenges</span> aria-label="Notifications">
</div> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round">
<div class="nc-notif-list"> <path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
@if (incomingChallenges.length === 0) { <path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
<div class="nc-notif-empty">No pending challenges</div>
}
@for (challenge of incomingChallenges; track challenge.id) {
<div class="nc-notif-row is-unread">
<div class="nc-notif-icon">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="14.5" y1="17.5" x2="3" y2="6" />
<path d="M13 19l6 -6" />
<path d="M16 16l4 4" />
<path d="M19 21l2 -2" />
<path d="M15 5l4 4" />
<path d="M21 3l-3 1l-4 4" />
</svg>
</div>
<div class="nc-notif-body">
<div class="nc-notif-text">
<b>{{ challenge.challenger.name }}</b> challenged you to a
{{ getTimeControlDisplay(challenge) }} game.
</div>
<div class="nc-notif-meta">
{{ challenge.challenger.rating }} ·
{{ challenge.timeControl.type ?? 'Custom' }} ·
{{ getExpirationInfo(challenge) }}
</div>
<div class="nc-notif-actions">
<button
type="button"
class="nc-btn-accept"
[disabled]="acceptingId === challenge.id || !!decliningId"
(click)="acceptChallenge($event, challenge)"
>
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
{{ acceptingId === challenge.id ? '...' : 'Accept' }}
</button>
<button
type="button"
class="nc-btn-decline"
[disabled]="!!acceptingId || decliningId === challenge.id"
(click)="declineChallenge($event, challenge)"
>
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
{{ decliningId === challenge.id ? '...' : 'Decline' }}
</button>
</div>
</div>
</div>
}
</div>
<div class="nc-notif-footer">
<button type="button" class="nc-view-all" (click)="goToChallenges()">
View all challenges
</button>
</div>
</div>
}
</div>
<!-- Games quick-access -->
<button type="button" class="nc-games-btn" (click)="goToGames()">
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14.5 17.5L3 6" />
<path d="M13 19l6-6" />
<path d="M16 16l4 4" />
<path d="M19 21l2-2" />
<path d="M15 5l4 4" />
<path d="M21 3l-3 1-4 4" />
</svg> </svg>
Games @if (incomingChallenges.length > 0) {
<span class="nc-badge">{{ incomingChallenges.length }}</span>
}
</button> </button>
<!-- Profile --> @if (notifOpen) {
<div class="nc-dropdown-wrap" data-dropdown="profile"> <div class="nc-popover nc-notif" (click)="$event.stopPropagation()">
<button <div class="nc-notif-header">
type="button" <span class="nc-notif-header-title">Challenges</span>
class="nc-profile" </div>
[class.is-open]="profileOpen"
(click)="toggleProfile($event)"
>
<div class="nc-avatar nc-avatar-sm">{{ getInitial() }}</div>
<span class="nc-profile-name">{{ user.username }}</span>
<svg
class="nc-chevron"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
@if (profileOpen) { <div class="nc-notif-list">
<div class="nc-popover nc-menu" (click)="$event.stopPropagation()"> @if (incomingChallenges.length === 0) {
<div class="nc-menu-header"> <div class="nc-notif-empty">No pending challenges</div>
<div class="nc-avatar nc-avatar-md">{{ getInitial() }}</div> }
<div> @for (challenge of incomingChallenges; track challenge.id) {
<div class="nc-menu-user-name">{{ user.username }}</div> <div class="nc-notif-row is-unread">
<div class="nc-menu-user-sub">{{ user.rating }} · &#64;{{ user.username }}</div> <div class="nc-notif-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round">
<line x1="14.5" y1="17.5" x2="3" y2="6" />
<path d="M13 19l6 -6" /><path d="M16 16l4 4" />
<path d="M19 21l2 -2" /><path d="M15 5l4 4" />
<path d="M21 3l-3 1l-4 4" />
</svg>
</div>
<div class="nc-notif-body">
<div class="nc-notif-text">
<b>{{ challenge.challenger.name }}</b> challenged you to a
{{ getTimeControlDisplay(challenge) }} game.
</div>
<div class="nc-notif-meta">
{{ challenge.challenger.rating }} · {{ challenge.timeControl.type ?? 'Custom' }} · {{ getExpirationInfo(challenge) }}
</div>
<div class="nc-notif-actions">
<button type="button" class="nc-btn-accept"
[disabled]="acceptingId === challenge.id || !!decliningId"
(click)="acceptChallenge($event, challenge)">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
{{ acceptingId === challenge.id ? '...' : 'Accept' }}
</button>
<button type="button" class="nc-btn-decline"
[disabled]="!!acceptingId || decliningId === challenge.id"
(click)="declineChallenge($event, challenge)">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"
stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
{{ decliningId === challenge.id ? '...' : 'Decline' }}
</button>
</div> </div>
</div> </div>
<div class="nc-menu-group">
<button type="button" class="nc-menu-item" (click)="goToProfile()">
<span class="nc-menu-icon">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</span>
<span class="nc-menu-label">My profile</span>
</button>
<button type="button" class="nc-menu-item" (click)="goToChallenges()">
<span class="nc-menu-icon">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="14.5" y1="17.5" x2="3" y2="6" />
<path d="M13 19l6 -6" />
<path d="M16 16l4 4" />
<path d="M19 21l2 -2" />
<path d="M15 5l4 4" />
<path d="M21 3l-3 1l-4 4" />
</svg>
</span>
<span class="nc-menu-label">Challenges</span>
</button>
<button type="button" class="nc-menu-item" (click)="toggleTheme($event)">
<span class="nc-menu-icon">
<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="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
</svg>
</span>
<span class="nc-menu-label">{{ isDarkMode ? 'Light mode' : 'Dark mode' }}</span>
<span class="nc-toggle" [class.is-on]="isDarkMode"></span>
</button>
</div>
<div class="nc-menu-group">
<button type="button" class="nc-menu-item danger" (click)="logout()">
<span class="nc-menu-icon">
<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="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</span>
<span class="nc-menu-label">Log out</span>
</button>
</div>
</div> </div>
} }
</div>
<div class="nc-notif-footer">
<button type="button" class="nc-view-all" (click)="goToChallenges()">View all challenges</button>
</div>
</div> </div>
} @else { }
<!-- Logged-out auth buttons --> </div>
<button type="button" class="nc-auth-btn" (click)="openLoginDialog()">Login</button>
<button type="button" class="nc-auth-btn nc-auth-btn--primary" (click)="openRegisterDialog()"> <!-- Games quick-access -->
Register <button type="button" class="nc-games-btn" (click)="goToGames()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round">
<path d="M14.5 17.5L3 6"/>
<path d="M13 19l6-6"/><path d="M16 16l4 4"/>
<path d="M19 21l2-2"/><path d="M15 5l4 4"/>
<path d="M21 3l-3 1-4 4"/>
</svg>
Games
</button>
<!-- Profile -->
<div class="nc-dropdown-wrap" data-dropdown="profile">
<button type="button" class="nc-profile" [class.is-open]="profileOpen" (click)="toggleProfile($event)">
<div class="nc-avatar nc-avatar-sm">{{ getInitial() }}</div>
<span class="nc-profile-name">{{ user.username }}</span>
<svg class="nc-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</button> </button>
@if (profileOpen) {
<div class="nc-popover nc-menu" (click)="$event.stopPropagation()">
<div class="nc-menu-header">
<div class="nc-avatar nc-avatar-md">{{ getInitial() }}</div>
<div>
<div class="nc-menu-user-name">{{ user.username }}</div>
<div class="nc-menu-user-sub">{{ user.rating }} · &#64;{{ user.username }}</div>
</div>
</div>
<div class="nc-menu-group">
<button type="button" class="nc-menu-item" (click)="goToProfile()">
<span class="nc-menu-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</span>
<span class="nc-menu-label">My profile</span>
</button>
<button type="button" class="nc-menu-item" (click)="goToChallenges()">
<span class="nc-menu-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round">
<line x1="14.5" y1="17.5" x2="3" y2="6" />
<path d="M13 19l6 -6" /><path d="M16 16l4 4" />
<path d="M19 21l2 -2" /><path d="M15 5l4 4" />
<path d="M21 3l-3 1l-4 4" />
</svg>
</span>
<span class="nc-menu-label">Challenges</span>
</button>
<button type="button" class="nc-menu-item" (click)="toggleTheme($event)">
<span class="nc-menu-icon">
<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="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
</svg>
</span>
<span class="nc-menu-label">{{ isDarkMode ? 'Light mode' : 'Dark mode' }}</span>
<span class="nc-toggle" [class.is-on]="isDarkMode"></span>
</button>
</div>
<div class="nc-menu-group">
<button type="button" class="nc-menu-item danger" (click)="logout()">
<span class="nc-menu-icon">
<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="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</span>
<span class="nc-menu-label">Log out</span>
</button>
</div>
</div>
}
</div>
} @else {
<!-- Logged-out auth buttons -->
<button type="button" class="nc-auth-btn" (click)="openLoginDialog()">Login</button>
<button type="button" class="nc-auth-btn nc-auth-btn--primary" (click)="openRegisterDialog()">Register</button>
} }
</div> </div>
</nav> </nav>
@if (showLoginDialog) { @if (showLoginDialog) {
<app-login-dialog (onClose)="closeLoginDialog()" (onSuccess)="onLoginSuccess()" /> <app-login-dialog (onClose)="closeLoginDialog()" (onSuccess)="onLoginSuccess()" />
} }
@if (showRegisterDialog) { @if (showRegisterDialog) {
<app-register-dialog (onClose)="closeRegisterDialog()" (onSuccess)="onRegisterSuccess()" /> <app-register-dialog (onClose)="closeRegisterDialog()" (onSuccess)="onRegisterSuccess()" />
} }
+27 -32
View File
@@ -18,7 +18,7 @@ import { Challenge } from '../../models/challenge.models';
standalone: true, standalone: true,
imports: [CommonModule, LoginDialogComponent, RegisterDialogComponent], imports: [CommonModule, LoginDialogComponent, RegisterDialogComponent],
templateUrl: './toolbar.component.html', templateUrl: './toolbar.component.html',
styleUrl: './toolbar.component.css', styleUrl: './toolbar.component.css'
}) })
export class ToolbarComponent implements OnInit { export class ToolbarComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
@@ -46,36 +46,35 @@ export class ToolbarComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.destroyRef.onDestroy(() => this.stopPolling()); this.destroyRef.onDestroy(() => this.stopPolling());
this.authService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user) => { this.authService.currentUser$
this.currentUser = user; .pipe(takeUntilDestroyed(this.destroyRef))
if (user) { .subscribe(user => {
this.challengeWs.connect(); this.currentUser = user;
this.startPolling(); if (user) {
} else { this.challengeWs.connect();
this.challengeWs.disconnect(); this.startPolling();
this.stopPolling(); } else {
this.navigatedChallengeIds.clear(); this.challengeWs.disconnect();
this.challengeEventService.clear(); this.stopPolling();
} this.navigatedChallengeIds.clear();
}); this.challengeEventService.clear();
}
});
this.authDialogService.dialogState$ this.authDialogService.dialogState$
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe(state => {
this.showLoginDialog = state === 'login'; this.showLoginDialog = state === 'login';
this.showRegisterDialog = state === 'register'; this.showRegisterDialog = state === 'register';
}); });
this.themeService.darkMode$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((isDark) => { this.themeService.darkMode$
this.isDarkMode = isDark;
});
this.challengeEventService
.getIncomingChallenges$()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((challenges) => { .subscribe(isDark => { this.isDarkMode = isDark; });
this.incomingChallenges = challenges;
}); this.challengeEventService.getIncomingChallenges$()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(challenges => { this.incomingChallenges = challenges; });
} }
private startPolling(): void { private startPolling(): void {
@@ -92,7 +91,7 @@ export class ToolbarComponent implements OnInit {
private fetchChallenges(): void { private fetchChallenges(): void {
this.challengeService.listChallenges().subscribe({ this.challengeService.listChallenges().subscribe({
next: (response) => { next: response => {
const incoming = response.in ?? response.incoming ?? []; const incoming = response.in ?? response.incoming ?? [];
this.challengeEventService.setIncomingChallenges(incoming); this.challengeEventService.setIncomingChallenges(incoming);
@@ -105,7 +104,7 @@ export class ToolbarComponent implements OnInit {
} }
} }
} }
}, }
}); });
} }
@@ -234,14 +233,12 @@ export class ToolbarComponent implements OnInit {
if (this.acceptingId || this.decliningId) return; if (this.acceptingId || this.decliningId) return;
this.acceptingId = challenge.id; this.acceptingId = challenge.id;
this.challengeService.acceptChallenge(challenge.id).subscribe({ this.challengeService.acceptChallenge(challenge.id).subscribe({
next: (accepted) => { next: accepted => {
this.acceptingId = null; this.acceptingId = null;
this.challengeEventService.onChallengeAccepted(accepted); this.challengeEventService.onChallengeAccepted(accepted);
if (accepted.gameId) void this.router.navigate(['/game', accepted.gameId]); if (accepted.gameId) void this.router.navigate(['/game', accepted.gameId]);
}, },
error: () => { error: () => { this.acceptingId = null; }
this.acceptingId = null;
},
}); });
} }
@@ -254,9 +251,7 @@ export class ToolbarComponent implements OnInit {
this.decliningId = null; this.decliningId = null;
this.challengeEventService.removeChallenge(challenge.id); this.challengeEventService.removeChallenge(challenge.id);
}, },
error: () => { error: () => { this.decliningId = null; }
this.decliningId = null;
},
}); });
} }
} }
+10
View File
@@ -3,11 +3,21 @@ 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[];
} }
+48 -4
View File
@@ -51,16 +51,60 @@ export interface TournamentList {
finished: Tournament[]; finished: Tournament[];
} }
export interface TournamentMatch {
gameId: string;
whiteId: string;
winner?: 'white' | 'black' | 'draw' | null;
status?: GameStatus;
}
export interface TournamentPairing { export interface TournamentPairing {
id: string; id?: string;
round: number; round?: number;
white: TournamentBotRef | null; white: TournamentBotRef | null;
black: TournamentBotRef; black: TournamentBotRef;
gameId: string | null; matches: TournamentMatch[];
winner: 'white' | 'black' | 'draw' | null; winner?: 'white' | 'black' | 'draw' | null;
} }
export interface RoundPairings { 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 && !hasAnnotations) { @if (positionAnalysis) {
<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>
+12 -6
View File
@@ -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 } from 'rxjs'; import { switchMap, of, forkJoin } 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,6 +146,7 @@ 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
@@ -207,12 +208,7 @@ 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,
@@ -223,6 +219,16 @@ 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 {
@@ -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;
}
}
}
@@ -64,6 +64,18 @@
} }
.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;
@@ -327,3 +339,74 @@
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,15 +16,25 @@
<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>
@if (currentUser) { <div class="page-actions">
<button type="button" class="btn-new" (click)="openCreateDialog()"> @if (currentUser) {
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" <button type="button" class="btn-servers" (click)="openServersDialog()">
stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/> stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
</svg> <rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/>
New tournament <line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>
</button> </svg>
} 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')">
@@ -148,9 +158,10 @@
<div class="state-msg small"><span class="pulse"></span>Loading…</div> <div class="state-msg small"><span class="pulse"></span>Loading…</div>
} @else if (pairings && pairings.pairings.length > 0) { } @else if (pairings && pairings.pairings.length > 0) {
<div class="pairings-list"> <div class="pairings-list">
@for (p of pairings.pairings; track p.id) { @for (p of pairings.pairings; track pairingKey(p)) {
<div class="pairing-row" [class.is-watchable]="!!p.gameId" @let gid = firstGameId(p);
(click)="p.gameId && watchGame(p.gameId)"> <div class="pairing-row" [class.is-watchable]="!!gid"
(click)="gid && watchGame(gid)">
<span class="pairing-white">{{ p.white?.name ?? 'Bye' }}</span> <span class="pairing-white">{{ p.white?.name ?? 'Bye' }}</span>
<span class="pairing-vs">vs</span> <span class="pairing-vs">vs</span>
<span class="pairing-black">{{ p.black.name }}</span> <span class="pairing-black">{{ p.black.name }}</span>
@@ -158,7 +169,7 @@
<span class="pairing-result" [class]="'result-' + p.winner"> <span class="pairing-result" [class]="'result-' + p.winner">
{{ p.winner === 'draw' ? '½–½' : p.winner === 'white' ? '10' : '01' }} {{ p.winner === 'draw' ? '½–½' : p.winner === 'white' ? '10' : '01' }}
</span> </span>
} @else if (p.gameId) { } @else if (gid) {
<span class="pairing-ongoing"> <span class="pairing-ongoing">
<svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor"> <svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="10"/> <circle cx="12" cy="12" r="10"/>
@@ -192,32 +203,92 @@
<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 {
<div class="bot-pick-list"> @if (userBots.length === 0) {
@for (bot of userBots; track bot.id) { <p class="join-empty">You have no bots. <a routerLink="/bots">Create one</a> to join with your own bot.</p>
<button type="button" class="bot-pick-row" } @else {
[disabled]="!!joiningBotId" <div class="bot-pick-list">
(click)="joinWithBot(bot)"> @for (bot of userBots; track bot.id) {
<span class="bot-pick-avatar">{{ bot.name.charAt(0).toUpperCase() }}</span> <button type="button" class="bot-pick-row"
<span class="bot-pick-name">{{ bot.name }}</span> [disabled]="!!joiningBotId || !!joiningOfficialDifficulty"
<span class="bot-pick-rating">{{ bot.rating }}</span> (click)="joinWithBot(bot)">
@if (joiningBotId === bot.id) { <span class="bot-pick-avatar">{{ bot.name.charAt(0).toUpperCase() }}</span>
<span class="bot-pick-spinner"></span> <span class="bot-pick-name">{{ bot.name }}</span>
} <span class="bot-pick-rating">{{ bot.rating }}</span>
</button> @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>
@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>
} }
@if (joinError) { <div class="dialog-actions">
<div class="dialog-error">{{ joinError }}</div> <button type="button" class="btn-ghost" (click)="closeServersDialog()">Close</button>
} </div>
</div> </div>
</div> </div>
} }
@@ -1,21 +1,25 @@
import { Component, DestroyRef, OnInit, inject } from '@angular/core'; import { Component, DestroyRef, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule, TitleCasePipe } from '@angular/common';
import { Router, RouterLink } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormBuilder, FormGroup, FormsModule, 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, TournamentPairing } 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, ReactiveFormsModule], imports: [CommonModule, RouterLink, FormsModule, ReactiveFormsModule, TitleCasePipe],
templateUrl: './tournaments.component.html', templateUrl: './tournaments.component.html',
styleUrl: './tournaments.component.css' styleUrl: './tournaments.component.css'
}) })
@@ -25,6 +29,8 @@ 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;
@@ -54,15 +60,27 @@ 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 {
@@ -149,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 { 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 { openJoinDialog(event: MouseEvent, tournamentId: string): void {
@@ -158,52 +195,91 @@ export class TournamentsComponent implements OnInit {
this.joinDialogTournamentId = tournamentId; this.joinDialogTournamentId = tournamentId;
this.joinError = null; this.joinError = null;
this.botsLoading = true; this.botsLoading = true;
this.botService.list() forkJoin({ user: this.botService.list(), official: this.botService.listOfficial() })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ .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; } 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.botService.rotateToken(bot.id).subscribe({ this.tournamentService.join(this.joinDialogTournamentId, bot.id, bot.name).subscribe({
next: token => { next: () => {
this.tournamentService.joinWithBotToken(this.joinDialogTournamentId!, token).subscribe({
next: () => {
this.joiningBotId = null;
const tid = this.joinDialogTournamentId!;
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.joiningBotId = null;
this.joinError = err.error?.message ?? err.error?.error ?? 'Failed to join tournament.';
}
});
},
error: () => {
this.joiningBotId = null; this.joiningBotId = null;
this.joinError = 'Failed to get bot token.'; const tid = this.joinDialogTournamentId!;
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.joiningBotId = null;
this.joinError = err.error?.message ?? err.error?.error ?? 'Failed to join tournament.';
} }
}); });
} }
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))
+2 -1
View File
@@ -9,7 +9,8 @@ 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({
+5
View File
@@ -7,11 +7,16 @@ 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 });
} }
+96 -91
View File
@@ -6,110 +6,115 @@ 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?token=${encodeURIComponent(token)}`; const url = `${environment.userWsBaseUrl}/api/user/ws`;
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;
if (this.ws) { this.ws?.send(JSON.stringify({ type: 'auth', token }));
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;
} }
private handleMessage(data: string): void { switch (message['type']) {
let message: Record<string, unknown>; case 'CONNECTED':
try { break;
message = JSON.parse(data) as Record<string, unknown>;
} catch { case 'challengeCreated': {
return; const challengeId = message['challengeId'] as string | undefined;
if (challengeId) {
this.challengeService.getChallenge(challengeId).subscribe({
next: (challenge) => this.challengeEventService.onChallengeReceived(challenge),
error: () => {
/* challenge may have already expired */
},
});
} }
break;
}
switch (message['type']) { case 'challengeAccepted': {
case 'CONNECTED': const challengeId = message['challengeId'] as string | undefined;
break; const gameId = message['gameId'] as string | undefined;
if (challengeId) {
case 'challengeCreated': { this.challengeEventService.removeChallenge(challengeId);
const challengeId = message['challengeId'] as string | undefined;
if (challengeId) {
this.challengeService.getChallenge(challengeId).subscribe({
next: challenge => this.challengeEventService.onChallengeReceived(challenge),
error: () => { /* challenge may have already expired */ }
});
}
break;
}
case 'challengeAccepted': {
const challengeId = message['challengeId'] as string | undefined;
const gameId = message['gameId'] as string | undefined;
if (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;
}
} }
} if (gameId) {
void this.router.navigate(['/game', gameId]);
}
break;
}
private attemptReconnect(): void { case 'challengeDeclined':
if (this.intentionalClose || this.reconnectAttempts >= this.maxReconnectAttempts) return; case 'challengeExpired':
this.reconnectAttempts++; case 'challengeCancelled': {
setTimeout(() => { this.connect(); }, this.reconnectDelay); const challengeId = message['challengeId'] as string | undefined;
if (challengeId) {
this.challengeEventService.removeChallenge(challengeId);
}
break;
}
} }
}
private attemptReconnect(): void {
if (this.intentionalClose || this.reconnectAttempts >= this.maxReconnectAttempts) return;
this.reconnectAttempts++;
setTimeout(() => {
this.connect();
}, this.reconnectDelay);
}
} }
+26 -8
View File
@@ -1,6 +1,7 @@
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,
@@ -9,7 +10,7 @@ import {
LegalMovesResponse, LegalMovesResponse,
PlayerInfo, PlayerInfo,
} from '../models/game.models'; } from '../models/game.models';
import { AnalysisRequest, AnalysisResponse } from '../models/analysis.models'; import { AnalysisRequest, AnalysisResponse, RawAnalysisResponse } from '../models/analysis.models';
import { StreamHandlerService } from './stream-handler.service'; import { StreamHandlerService } from './stream-handler.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -78,8 +79,28 @@ 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.post<AnalysisResponse>(`${this.apiBase}/api/analysis/position`, request); return this.http
.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 {
@@ -92,11 +113,8 @@ export class GameApiService {
} }
streamGame(gameId: string): Observable<GameStreamEvent> { streamGame(gameId: string): Observable<GameStreamEvent> {
const token = localStorage.getItem('token'); const wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/ws`;
let wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/ws`; const token = localStorage.getItem('token') ?? '';
if (token) { return this.streamHandler.createGameStream(wsUrl, gameId, token);
wsUrl += `?token=${encodeURIComponent(token)}`;
}
return this.streamHandler.createGameStream(wsUrl, gameId);
} }
} }
+22
View File
@@ -0,0 +1,22 @@
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,
});
}
}
+3 -2
View File
@@ -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): Observable<GameStreamEvent> { createGameStream(wsUrl: string, gameId: string, token: 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,6 +36,7 @@ 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) => {
@@ -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(/\/+$/, '')}`;
}
}
@@ -0,0 +1,23 @@
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);
}
}
@@ -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,
});
}
}
+4 -5
View File
@@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http'; import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { Tournament, TournamentList, RoundPairings } from '../models/tournament.models'; import { Tournament, TournamentList, RoundPairings } from '../models/tournament.models';
import { environment } from '../../environments/environment';
export interface CreateTournamentForm { export interface CreateTournamentForm {
name: string; name: string;
@@ -14,7 +15,7 @@ export interface CreateTournamentForm {
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class TournamentService { export class TournamentService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly base = '/api/tournament'; private readonly base = `${(environment.tournamentServerUrl ?? '').replace(/\/+$/, '')}/api/tournament`;
list(): Observable<TournamentList> { list(): Observable<TournamentList> {
return this.http.get<TournamentList>(this.base); return this.http.get<TournamentList>(this.base);
@@ -40,10 +41,8 @@ export class TournamentService {
return this.http.post<Tournament>(`${this.base}/${id}/start`, null); return this.http.post<Tournament>(`${this.base}/${id}/start`, null);
} }
joinWithBotToken(id: string, botToken: string): Observable<void> { join(id: string, botId: string, botName: string): Observable<void> {
return this.http.post<void>(`${this.base}/${id}/join`, null, { return this.http.post<void>(`${this.base}/${id}/join`, { botId, botName });
headers: new HttpHeaders({ Authorization: `Bearer ${botToken}` })
});
} }
roundPairings(id: string, round: number): Observable<RoundPairings> { roundPairings(id: string, round: number): Observable<RoundPairings> {
+2 -1
View File
@@ -4,5 +4,6 @@ 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'
}; };
+2 -1
View File
@@ -8,5 +8,6 @@ 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'
}; };
+2 -1
View File
@@ -8,5 +8,6 @@ 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'
}; };
+2 -2
View File
@@ -1,3 +1,3 @@
MAJOR=0 MAJOR=0
MINOR=2 MINOR=6
PATCH=8 PATCH=2