Compare commits
5 Commits
0.2.7
..
9dc0b4a7df
| Author | SHA1 | Date | |
|---|---|---|---|
| 9dc0b4a7df | |||
| a3e51bade5 | |||
| 48959daae3 | |||
| ae952d70b0 | |||
| a62073511f |
@@ -51,3 +51,13 @@
|
|||||||
|
|
||||||
* NCWF-4 Token Issues ([#8](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/8)) ([95eff42](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/95eff42dfe6d9c23ede08c7297614369a1b00d9f))
|
* NCWF-4 Token Issues ([#8](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/8)) ([95eff42](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/95eff42dfe6d9c23ede08c7297614369a1b00d9f))
|
||||||
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.6...0.0.0) (2026-06-06)
|
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.6...0.0.0) (2026-06-06)
|
||||||
|
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.7...0.0.0) (2026-06-10)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* 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))
|
||||||
|
|||||||
@@ -192,12 +192,12 @@
|
|||||||
<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>
|
<p class="join-hint">Select an official (engine-backed) bot to enter this tournament. These are the bots that actually play their moves.</p>
|
||||||
|
|
||||||
@if (botsLoading) {
|
@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) {
|
} @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>
|
<p class="join-empty">No official bots are available. The official-bots engine service must be running to register them.</p>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="bot-pick-list">
|
<div class="bot-pick-list">
|
||||||
@for (bot of userBots; track bot.id) {
|
@for (bot of userBots; track bot.id) {
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ 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()
|
this.botService.listOfficial()
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: bots => { this.userBots = bots; this.botsLoading = false; },
|
next: bots => { this.userBots = bots; this.botsLoading = false; },
|
||||||
@@ -176,30 +176,22 @@ export class TournamentsComponent implements OnInit {
|
|||||||
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.';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
GameState,
|
GameState,
|
||||||
GameStreamEvent,
|
GameStreamEvent,
|
||||||
LegalMovesResponse,
|
LegalMovesResponse,
|
||||||
PlayerInfo
|
PlayerInfo,
|
||||||
} from '../models/game.models';
|
} from '../models/game.models';
|
||||||
import { StreamHandlerService } from './stream-handler.service';
|
import { StreamHandlerService } from './stream-handler.service';
|
||||||
|
|
||||||
@@ -28,11 +28,11 @@ export class GameApiService {
|
|||||||
const playerColor = Math.random() > 0.5 ? 'white' : 'black';
|
const playerColor = Math.random() > 0.5 ? 'white' : 'black';
|
||||||
const playerInfo: PlayerInfo = {
|
const playerInfo: PlayerInfo = {
|
||||||
id: `player-${Date.now()}`,
|
id: `player-${Date.now()}`,
|
||||||
displayName: 'You'
|
displayName: 'You',
|
||||||
};
|
};
|
||||||
const botInfo: PlayerInfo = {
|
const botInfo: PlayerInfo = {
|
||||||
id: `bot-${difficulty}`,
|
id: `bot-${difficulty}`,
|
||||||
displayName: `Bot (${difficulty})`
|
displayName: `Bot (${difficulty})`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const payload =
|
const payload =
|
||||||
@@ -40,7 +40,7 @@ export class GameApiService {
|
|||||||
? { white: playerInfo, black: botInfo }
|
? { white: playerInfo, black: botInfo }
|
||||||
: { white: botInfo, black: playerInfo };
|
: { white: botInfo, black: playerInfo };
|
||||||
|
|
||||||
return this.http.post<GameFull>(`${this.apiBase}${this.apiPath}`, payload);
|
return this.http.post<GameFull>(`${this.apiBase}${this.apiPath}/vs-bot`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
getGame(gameId: string): Observable<GameFull> {
|
getGame(gameId: string): Observable<GameFull> {
|
||||||
@@ -56,7 +56,9 @@ export class GameApiService {
|
|||||||
if (square) {
|
if (square) {
|
||||||
params = params.set('square', square);
|
params = params.set('square', square);
|
||||||
}
|
}
|
||||||
return this.http.get<LegalMovesResponse>(`${this.apiBase}${this.apiPath}/${gameId}/moves`, { params });
|
return this.http.get<LegalMovesResponse>(`${this.apiBase}${this.apiPath}/${gameId}/moves`, {
|
||||||
|
params,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
importFen(fen: string): Observable<GameFull> {
|
importFen(fen: string): Observable<GameFull> {
|
||||||
@@ -85,11 +87,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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -40,10 +40,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
-2
@@ -1,3 +1,3 @@
|
|||||||
MAJOR=0
|
MAJOR=0
|
||||||
MINOR=2
|
MINOR=3
|
||||||
PATCH=7
|
PATCH=0
|
||||||
|
|||||||
Reference in New Issue
Block a user