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
41 changed files with 1363 additions and 619 deletions
-5
View File
@@ -19,7 +19,6 @@ If `$ARGUMENTS` already answers some of these, skip those questions.
## Step 2 — Research (if needed) ## Step 2 — Research (if needed)
If the bug involves component logic, a service, or routing: If the bug involves component logic, a service, or routing:
- Search repo for relevant code (`Grep`/`Bash`) under `src/app/{components,services,models,core,pages}`. - Search repo for relevant code (`Grep`/`Bash`) under `src/app/{components,services,models,core,pages}`.
- Check `.spec.ts` files for existing coverage of the broken area. - Check `.spec.ts` files for existing coverage of the broken area.
- Do NOT guess at root cause. Surface findings before drafting. - Do NOT guess at root cause. Surface findings before drafting.
@@ -57,7 +56,6 @@ Environment / Notes
``` ```
Rules: Rules:
- Steps must be minimal and reproducible. - Steps must be minimal and reproducible.
- Expected vs actual: concrete and unambiguous. - Expected vs actual: concrete and unambiguous.
- Omit "Environment / Notes" section if not relevant. - Omit "Environment / Notes" section if not relevant.
@@ -66,7 +64,6 @@ Rules:
Show the draft to the user. Show the draft to the user.
**Use `AskUserQuestion` tool to ask:** **Use `AskUserQuestion` tool to ask:**
- Are steps to reproduce complete and accurate? - Are steps to reproduce complete and accurate?
- Severity: Blocker / Critical / Major / Minor / Trivial? - Severity: Blocker / Critical / Major / Minor / Trivial?
- Any related tickets or recent changes to link? - Any related tickets or recent changes to link?
@@ -76,7 +73,6 @@ Incorporate feedback. Repeat until user approves.
## Step 5 — Determine Project ## Step 5 — Determine Project
> **Project routing rules (always apply these):** > **Project routing rules (always apply these):**
>
> - Frontend code (UI, UX, web app, components, services) → `NCWF` > - Frontend code (UI, UX, web app, components, services) → `NCWF`
> - Backend code (game engine, bots, API, services, coordinator) → `NCS` > - Backend code (game engine, bots, API, services, coordinator) → `NCS`
> - Infrastructure (Kubernetes, pipelines, CI/CD, DB setup, cloud infra) → `NCI` > - Infrastructure (Kubernetes, pipelines, CI/CD, DB setup, cloud infra) → `NCI`
@@ -90,7 +86,6 @@ If ambiguous, ask the user.
## Step 6 — Create Issue ## Step 6 — Create Issue
Call `mcp__youtrack__create_issue` with: Call `mcp__youtrack__create_issue` with:
- `project`: determined in Step 5 - `project`: determined in Step 5
- `summary`: concise title describing what is broken (≤72 chars, sentence case) - `summary`: concise title describing what is broken (≤72 chars, sentence case)
- `description`: full formatted defect report from Step 3 (Markdown) - `description`: full formatted defect report from Step 3 (Markdown)
+1 -6
View File
@@ -19,7 +19,6 @@ If `$ARGUMENTS` already answers some of these, skip those questions.
## Step 2 — Research (if needed) ## Step 2 — Research (if needed)
If the topic involves unfamiliar UI flows, component structure, or technical constraints: If the topic involves unfamiliar UI flows, component structure, or technical constraints:
- Search the repo for relevant code under `src/app/` (use `Grep`/`Bash`). - Search the repo for relevant code under `src/app/` (use `Grep`/`Bash`).
- Use `WebSearch` if the topic involves Angular APIs, external standards or protocols. - Use `WebSearch` if the topic involves Angular APIs, external standards or protocols.
- Do NOT guess. Surface findings before drafting. - Do NOT guess. Surface findings before drafting.
@@ -54,7 +53,6 @@ Implementation Notes
``` ```
Rules: Rules:
- User story line: plain English, present tense, from user's perspective. - User story line: plain English, present tense, from user's perspective.
- Acceptance criteria: testable, unambiguous, one condition each. - Acceptance criteria: testable, unambiguous, one condition each.
- Implementation notes: optional — only include if there are known constraints, related tickets, or design refs. - Implementation notes: optional — only include if there are known constraints, related tickets, or design refs.
@@ -63,7 +61,6 @@ Rules:
Show the draft to the user. Show the draft to the user.
**Use `AskUserQuestion` tool to ask:** **Use `AskUserQuestion` tool to ask:**
- Are the acceptance criteria complete and correct? - Are the acceptance criteria complete and correct?
- Any implementation constraints to add? - Any implementation constraints to add?
- Priority (if known)? - Priority (if known)?
@@ -73,7 +70,6 @@ Incorporate feedback. Repeat until user approves.
## Step 5 — Determine Project ## Step 5 — Determine Project
> **Project routing rules (always apply these):** > **Project routing rules (always apply these):**
>
> - Frontend code (UI, UX, web app, components, services) → `NCWF` > - Frontend code (UI, UX, web app, components, services) → `NCWF`
> - Backend code (game engine, bots, API, services, coordinator) → `NCS` > - Backend code (game engine, bots, API, services, coordinator) → `NCS`
> - Infrastructure (Kubernetes, pipelines, CI/CD, DB setup, cloud infra) → `NCI` > - Infrastructure (Kubernetes, pipelines, CI/CD, DB setup, cloud infra) → `NCI`
@@ -87,7 +83,6 @@ If still ambiguous, ask the user.
## Step 6 — Create Issue ## Step 6 — Create Issue
Call `mcp__youtrack__create_issue` with: Call `mcp__youtrack__create_issue` with:
- `project`: determined in Step 5 - `project`: determined in Step 5
- `summary`: concise title derived from the "I want to" clause (≤72 chars, sentence case) - `summary`: concise title derived from the "I want to" clause (≤72 chars, sentence case)
- `description`: full formatted story from Step 3 (Markdown) - `description`: full formatted story from Step 3 (Markdown)
@@ -102,7 +97,7 @@ After creation, ask the user (use `AskUserQuestion` if interactive, otherwise in
Collect any issue IDs the user mentions. For each, determine the correct relation and call `mcp__youtrack__link_issues`: Collect any issue IDs the user mentions. For each, determine the correct relation and call `mcp__youtrack__link_issues`:
| Situation | Relation to use | | Situation | Relation to use |
| --------------------------------------- | --------------- | |-----------|----------------|
| This story must be done before another | `blocks` | | This story must be done before another | `blocks` |
| Another story must be done before this | `is blocked by` | | Another story must be done before this | `is blocked by` |
| Stories share domain or are related | `relates to` | | Stories share domain or are related | `relates to` |
+1 -9
View File
@@ -7,12 +7,10 @@ This is the **NowChess-Frontend** repo. Sprint mode defaults to project `NCWF`.
## Step 1 — Determine Scope ## Step 1 — Determine Scope
**Single-issue mode** (`$ARGUMENTS` set): **Single-issue mode** (`$ARGUMENTS` set):
- Call `mcp__youtrack__get_issue` on `$ARGUMENTS`. - Call `mcp__youtrack__get_issue` on `$ARGUMENTS`.
- Proceed with that issue only. - Proceed with that issue only.
**Sprint mode** (`$ARGUMENTS` empty): **Sprint mode** (`$ARGUMENTS` empty):
- Call `mcp__youtrack__search_issues` with query `project: NCWF Sprints: {current sprint} #Unresolved`. - Call `mcp__youtrack__search_issues` with query `project: NCWF Sprints: {current sprint} #Unresolved`.
- If query returns 0 results, use `AskUserQuestion` to ask for the sprint name, then retry with `project: NCWF Sprints: {name}`. - If query returns 0 results, use `AskUserQuestion` to ask for the sprint name, then retry with `project: NCWF Sprints: {name}`.
- Collect all returned issues. - Collect all returned issues.
@@ -20,7 +18,6 @@ This is the **NowChess-Frontend** repo. Sprint mode defaults to project `NCWF`.
## Step 2 — Build Issue Tree ## Step 2 — Build Issue Tree
For each top-level issue from Step 1: For each top-level issue from Step 1:
1. Fetch full details via `mcp__youtrack__get_issue`: summary, description, acceptance criteria, Type, existing `Zeitschätzung`, linked issues. 1. Fetch full details via `mcp__youtrack__get_issue`: summary, description, acceptance criteria, Type, existing `Zeitschätzung`, linked issues.
2. Identify subtasks from links with relation `subtask of` (i.e. issues where the fetched issue is the parent). 2. Identify subtasks from links with relation `subtask of` (i.e. issues where the fetched issue is the parent).
3. Recursively fetch subtasks until all leaves are known. 3. Recursively fetch subtasks until all leaves are known.
@@ -32,13 +29,12 @@ For each top-level issue from Step 1:
## Step 3 — Estimate Leaf Nodes ## Step 3 — Estimate Leaf Nodes
For each leaf node: For each leaf node:
1. Read: summary, description, acceptance criteria, implementation notes. 1. Read: summary, description, acceptance criteria, implementation notes.
2. If scope is unclear, search codebase (`Grep`/`Bash`) under `src/app/` for related files to gauge complexity. 2. If scope is unclear, search codebase (`Grep`/`Bash`) under `src/app/` for related files to gauge complexity.
3. Assign estimate using this scale: 3. Assign estimate using this scale:
| Size | Criteria | Estimate | | Size | Criteria | Estimate |
| ------- | ------------------------------------------------------ | -------- | |------|----------|----------|
| Trivial | Style tweak, copy change, 1-file tweak | 30m | | Trivial | Style tweak, copy change, 1-file tweak | 30m |
| Small | 13 files, single component/service, no unknowns | 1h2h | | Small | 13 files, single component/service, no unknowns | 1h2h |
| Medium | 36 files, new component + service wiring, some design | 3h5h | | Medium | 36 files, new component + service wiring, some design | 3h5h |
@@ -53,7 +49,6 @@ For each leaf node:
YouTrack auto-sums `Zeitschätzung` from subtasks up to parents — **do not write estimates to parent nodes**. YouTrack auto-sums `Zeitschätzung` from subtasks up to parents — **do not write estimates to parent nodes**.
Compute display-only rolled-up totals: Compute display-only rolled-up totals:
- Parent total = sum of all descendant leaf estimates (including pre-estimated ones). - Parent total = sum of all descendant leaf estimates (including pre-estimated ones).
- Flag any branch where some leaves are missing estimates (partial roll-up). - Flag any branch where some leaves are missing estimates (partial roll-up).
@@ -73,13 +68,11 @@ Epic NCWF-10: Board UI overhaul [4h 30m] ← rolled up
Legend: `[X]` = display-only roll-up (not written). Plain = will be written to YouTrack. Legend: `[X]` = display-only roll-up (not written). Plain = will be written to YouTrack.
If sprint mode, show grand total at bottom: If sprint mode, show grand total at bottom:
``` ```
Sprint total: Xd Yh Zm (N issues, M leaves to update) Sprint total: Xd Yh Zm (N issues, M leaves to update)
``` ```
**Use `AskUserQuestion` tool:** **Use `AskUserQuestion` tool:**
- Does the breakdown look right? - Does the breakdown look right?
- Any estimates to adjust before writing to YouTrack? - Any estimates to adjust before writing to YouTrack?
@@ -88,7 +81,6 @@ Incorporate all feedback before proceeding.
## Step 6 — Write Estimates ## Step 6 — Write Estimates
On user approval, write estimates **only to leaf nodes** (bottom-up order): On user approval, write estimates **only to leaf nodes** (bottom-up order):
- For each leaf with a new estimate, call `mcp__youtrack__update_issue` with field `Zeitschätzung` = approved estimate. - For each leaf with a new estimate, call `mcp__youtrack__update_issue` with field `Zeitschätzung` = approved estimate.
- YouTrack period format: `"30m"`, `"1h 30m"`, `"1d"`, `"2d 4h"`. - YouTrack period format: `"30m"`, `"1h 30m"`, `"1d"`, `"2d 4h"`.
- Skip leaves already pre-estimated. - Skip leaves already pre-estimated.
+1 -5
View File
@@ -3,7 +3,6 @@
Automated defect-fix workflow. Ticket ID: `$ARGUMENTS` Automated defect-fix workflow. Ticket ID: `$ARGUMENTS`
This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). Gates: This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). Gates:
- Build: `npm run build` - Build: `npm run build`
- Test: `npm test -- --watch=false --browsers=ChromeHeadless` - Test: `npm test -- --watch=false --browsers=ChromeHeadless`
- Format: `npx prettier --write .` (check with `npx prettier --check .`) - Format: `npx prettier --write .` (check with `npx prettier --check .`)
@@ -16,7 +15,6 @@ Extract and display: summary, description, steps to reproduce, Priority, Subsyst
## Step 2 — Create Worktree ## Step 2 — Create Worktree
Derive branch name from ticket: Derive branch name from ticket:
- `type` from YouTrack issue type: `bug``fix`, `feature`/`task``feat`, `refactor``refactor`, else `chore` - `type` from YouTrack issue type: `bug``fix`, `feature`/`task``feat`, `refactor``refactor`, else `chore`
- `scope` from affected area (kebab-case, omit if unclear) - `scope` from affected area (kebab-case, omit if unclear)
- `description` from ticket summary: lowercase, kebab-case, max 40 chars, drop articles - `description` from ticket summary: lowercase, kebab-case, max 40 chars, drop articles
@@ -44,7 +42,6 @@ After root cause confirmed, assess scope:
**Complex** (3+ files, multiple concerns, or estimated > 1 hour): create subtasks before coding. **Complex** (3+ files, multiple concerns, or estimated > 1 hour): create subtasks before coding.
To create subtasks: To create subtasks:
1. Break fix into discrete, independently-completable tasks (e.g. "Fix selection state in BoardComponent", "Add spec for flip behaviour", "Update GameService move stream"). 1. Break fix into discrete, independently-completable tasks (e.g. "Fix selection state in BoardComponent", "Add spec for flip behaviour", "Update GameService move stream").
2. For each subtask call `mcp__youtrack__create_issue` with: 2. For each subtask call `mcp__youtrack__create_issue` with:
- `project`: based on subtask content — do **not** inherit from parent. Frontend/UI → `NCWF`; backend code → `NCS`; Kubernetes/pipelines/CI-CD/infrastructure → `NCI`. If ambiguous, ask user. - `project`: based on subtask content — do **not** inherit from parent. Frontend/UI → `NCWF`; backend code → `NCS`; Kubernetes/pipelines/CI-CD/infrastructure → `NCI`. If ambiguous, ask user.
@@ -79,7 +76,6 @@ Display findings grouped by severity.
## Step 5b — Apply Review Findings ## Step 5b — Apply Review Findings
If the review produced any findings (any severity): If the review produced any findings (any severity):
1. Implement all agreed fixes. 1. Implement all agreed fixes.
2. Run `npm run build` — must be green. 2. Run `npm run build` — must be green.
3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green. 3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green.
@@ -130,7 +126,7 @@ Files changed:
After commenting, ask the user if `$ARGUMENTS` should be linked to any other issues not already linked: After commenting, ask the user if `$ARGUMENTS` should be linked to any other issues not already linked:
| Situation | Relation | | Situation | Relation |
| ----------------------------------- | --------------- | |-----------|---------|
| This fix blocks another open ticket | `blocks` | | This fix blocks another open ticket | `blocks` |
| Another ticket must ship first | `is blocked by` | | Another ticket must ship first | `is blocked by` |
| Related defect or story | `relates to` | | Related defect or story | `relates to` |
-5
View File
@@ -4,7 +4,6 @@ Automated feature-implementation workflow. Ticket ID: `$ARGUMENTS`
This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). In-project = This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). In-project =
`NCWF`. Gates: `NCWF`. Gates:
- Build: `npm run build` - Build: `npm run build`
- Test: `npm test -- --watch=false --browsers=ChromeHeadless` - Test: `npm test -- --watch=false --browsers=ChromeHeadless`
- Format: `npx prettier --write .` (check with `npx prettier --check .`) - Format: `npx prettier --write .` (check with `npx prettier --check .`)
@@ -41,7 +40,6 @@ are collected and reported at the end with a ready-to-run prompt.
## Step 3 — Create Worktree ## Step 3 — Create Worktree
Derive branch name from the root ticket `$ARGUMENTS`: Derive branch name from the root ticket `$ARGUMENTS`:
- `type` from YouTrack issue type: `feature`/`task``feat`, `refactor``refactor`, `bug``fix`, else `chore` - `type` from YouTrack issue type: `feature`/`task``feat`, `refactor``refactor`, `bug``fix`, else `chore`
- `scope` from affected area (kebab-case, omit if unclear) - `scope` from affected area (kebab-case, omit if unclear)
- `description` from ticket summary: lowercase, kebab-case, max 40 chars, drop articles - `description` from ticket summary: lowercase, kebab-case, max 40 chars, drop articles
@@ -85,7 +83,6 @@ Display findings grouped by severity.
## Step 6b — Apply Review Findings ## Step 6b — Apply Review Findings
If the review produced any findings (any severity): If the review produced any findings (any severity):
1. Implement all agreed fixes. 1. Implement all agreed fixes.
2. Run `npm run build` — must be green. 2. Run `npm run build` — must be green.
3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green. 3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green.
@@ -142,12 +139,10 @@ Call `ExitWorktree` with `discard_changes: true` to delete the worktree.
Final report to the user, in two sections: Final report to the user, in two sections:
### Blocked in-project tasks ### Blocked in-project tasks
List any `NCWF` tasks that could **not** be implemented, with the blocker(s) List any `NCWF` tasks that could **not** be implemented, with the blocker(s)
that stopped them. (These can be re-run with this command once blockers clear.) that stopped them. (These can be re-run with this command once blockers clear.)
### Cross-project tasks (NCS / NCI / other) ### Cross-project tasks (NCS / NCI / other)
For every out-of-project task discovered in the tree (whether it was a subtask For every out-of-project task discovered in the tree (whether it was a subtask
or a blocker), output one entry: or a blocker), output one entry:
+1 -7
View File
@@ -12,7 +12,6 @@ Extract and display: summary, description, acceptance criteria, implementation n
## Step 2 — Research (if needed) ## Step 2 — Research (if needed)
If the story involves unfamiliar UI flows or technical constraints: If the story involves unfamiliar UI flows or technical constraints:
- Search repo for relevant code under `src/app/` (`Grep`/`Bash`). - Search repo for relevant code under `src/app/` (`Grep`/`Bash`).
- Use `WebSearch` for Angular APIs, external standards or protocols. - Use `WebSearch` for Angular APIs, external standards or protocols.
- Do NOT guess. Surface findings before proposing splits. - Do NOT guess. Surface findings before proposing splits.
@@ -20,7 +19,6 @@ If the story involves unfamiliar UI flows or technical constraints:
## Step 3 — Propose Split ## Step 3 — Propose Split
Analyse the story and propose a set of subtasks. Rules: Analyse the story and propose a set of subtasks. Rules:
- Each subtask = one unit of work, completable independently or in sequence. - Each subtask = one unit of work, completable independently or in sequence.
- No subtask should exceed ~2 days of work. - No subtask should exceed ~2 days of work.
- Name subtasks in imperative mood (e.g. "Add board theme service", "Render theme picker component"). - Name subtasks in imperative mood (e.g. "Add board theme service", "Render theme picker component").
@@ -28,7 +26,6 @@ Analyse the story and propose a set of subtasks. Rules:
Show proposed subtask list to user (titles only) and ask: Show proposed subtask list to user (titles only) and ask:
**Use `AskUserQuestion` tool:** **Use `AskUserQuestion` tool:**
- Does the split look right? - Does the split look right?
- Any subtasks to add, remove, or merge? - Any subtasks to add, remove, or merge?
- Should any subtask be assigned to a specific person? - Should any subtask be assigned to a specific person?
@@ -60,7 +57,6 @@ What must be true for this subtask to be considered complete:
``` ```
Rules: Rules:
- Steps/Tasks: concrete, ordered where order matters. - Steps/Tasks: concrete, ordered where order matters.
- Definition of Done: adjust per subtask — not all subtasks need the same criteria (e.g. a research spike has different DoD than an implementation task). - Definition of Done: adjust per subtask — not all subtasks need the same criteria (e.g. a research spike has different DoD than an implementation task).
- Keep description short — one paragraph max. - Keep description short — one paragraph max.
@@ -78,7 +74,6 @@ If a subtask's project is ambiguous, ask the user before creating it.
## Step 6 — Create Subtasks ## Step 6 — Create Subtasks
For each subtask call `mcp__youtrack__create_issue` with: For each subtask call `mcp__youtrack__create_issue` with:
- `project`: from Step 5 - `project`: from Step 5
- `summary`: subtask title (≤72 chars, sentence case) - `summary`: subtask title (≤72 chars, sentence case)
- `description`: full formatted description from Step 4 (Markdown) - `description`: full formatted description from Step 4 (Markdown)
@@ -89,7 +84,6 @@ Then call `mcp__youtrack__link_issues` to link each created subtask to `$ARGUMEN
## Step 6b — Inter-Subtask Links ## Step 6b — Inter-Subtask Links
If subtasks must be done in sequence (one depends on output of another), add ordering links: If subtasks must be done in sequence (one depends on output of another), add ordering links:
- For each dependency pair call `mcp__youtrack__link_issues` with relation `is blocked by` (subtask B is blocked by subtask A). - For each dependency pair call `mcp__youtrack__link_issues` with relation `is blocked by` (subtask B is blocked by subtask A).
Ask the user to confirm sequencing before adding these links: Ask the user to confirm sequencing before adding these links:
@@ -101,7 +95,7 @@ Ask the user to confirm sequencing before adding these links:
Scan `$ARGUMENTS` description and implementation notes for any referenced issue IDs not already linked. For each: Scan `$ARGUMENTS` description and implementation notes for any referenced issue IDs not already linked. For each:
| Situation | Relation | | Situation | Relation |
| ---------------------------------------- | --------------- | |-----------|---------|
| Parent story blocks another epic/story | `blocks` | | Parent story blocks another epic/story | `blocks` |
| Story depends on another epic completing | `is blocked by` | | Story depends on another epic completing | `is blocked by` |
| Related story in same domain | `relates to` | | Related story in same domain | `relates to` |
+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/
+73 -40
View File
@@ -1,77 +1,110 @@
## [0.3.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.8...0.3.0) (2026-06-10)
### Features
- NCWF-5 Scaffold post-game analysis page at /analysis with board viewer and route
- NCWF-6 FEN / PGN / Game-ID input form for custom analysis
- NCWF-7 Integrate backend analysis API (POST /api/analysis/position) via GameApiService
- NCWF-8 Per-move evaluation timeline SVG chart
- NCWF-9 Annotated move list with quality labels (!! ! ?! ? ??)
## 0.0.0 (2026-05-12) ## 0.0.0 (2026-05-12)
### Features ### Features
- added bot, light and dark mode ([2de003e](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/2de003e497baee72f998d0d805ca1e58aababe48)) * added bot, light and dark mode ([2de003e](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/2de003e497baee72f998d0d805ca1e58aababe48))
- added web view 1v1 ([1828fa3](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/1828fa3275ddb8ce6bb90a9f6a970ec428ebce3a)) * added web view 1v1 ([1828fa3](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/1828fa3275ddb8ce6bb90a9f6a970ec428ebce3a))
- NCS-63 User account implementation ([#2](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/2)) ([ff75c8c](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/ff75c8ce2fad54137f04a14c15bc1d4a38fa9bb8)) * NCS-63 User account implementation ([#2](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/2)) ([ff75c8c](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/ff75c8ce2fad54137f04a14c15bc1d4a38fa9bb8))
- NCS-75 Frontend Deployment Dockerfile ([#4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/4)) ([bd7ec58](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/bd7ec581e38b5d8e775741bf16e19b4dc67b979e)) * NCS-75 Frontend Deployment Dockerfile ([#4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/4)) ([bd7ec58](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/bd7ec581e38b5d8e775741bf16e19b4dc67b979e))
### Bug Fixes ### Bug Fixes
- build issues ([36d72fd](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/36d72fd6cda41be51d28f8ac307dcdbcd31afa91)) * build issues ([36d72fd](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/36d72fd6cda41be51d28f8ac307dcdbcd31afa91))
- cleaner components seperation ([8b090e4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/8b090e4d96c64c0adb253e3aefad7930108ccfb9)) * cleaner components seperation ([8b090e4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/8b090e4d96c64c0adb253e3aefad7930108ccfb9))
- gitignore ([4da882f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/4da882f481ba7a008aac868fb37de7cb2bafea5d)) * gitignore ([4da882f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/4da882f481ba7a008aac868fb37de7cb2bafea5d))
- GITIGNORE ([8df2d05](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/8df2d0550ab17c9afb2d19c414eac700a75add02)) * GITIGNORE ([8df2d05](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/8df2d0550ab17c9afb2d19c414eac700a75add02))
- npm ([c11c1d4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c11c1d4dce9de4bd5b463e891eebf961b37feb04)) * npm ([c11c1d4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c11c1d4dce9de4bd5b463e891eebf961b37feb04))
- removed .vs ([2833ead](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/2833ead7be3b47ee5c188d2d21b7326cb3cb3f26)) * removed .vs ([2833ead](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/2833ead7be3b47ee5c188d2d21b7326cb3cb3f26))
- removed cache files ([7ee59c4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/7ee59c434bf137a08fd560bbc02ceefbcfd90f04)) * removed cache files ([7ee59c4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/7ee59c434bf137a08fd560bbc02ceefbcfd90f04))
- size of pieces and removed text view of the game state ([c60d00f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c60d00f9d25247504845654615065fbccd7fe448)) * size of pieces and removed text view of the game state ([c60d00f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c60d00f9d25247504845654615065fbccd7fe448))
- structure ([3e8c7c4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/3e8c7c4057e55aeec7cee8c24f6751ff24912c93)) * structure ([3e8c7c4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/3e8c7c4057e55aeec7cee8c24f6751ff24912c93))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.1.0...0.0.0) (2026-05-12) ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.1.0...0.0.0) (2026-05-12)
### Features ### Features
- NCS-69 Challenge request ([#3](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/3)) ([bad7366](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/bad7366bdbb048c20218257b30ac22efc9ecb6db)) * NCS-69 Challenge request ([#3](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/3)) ([bad7366](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/bad7366bdbb048c20218257b30ac22efc9ecb6db))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.0...0.0.0) (2026-05-12) ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.0...0.0.0) (2026-05-12)
### Bug Fixes ### Bug Fixes
- NCWF-1 401 ([#5](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/5)) ([f8f93ef](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/f8f93efff48f1d7198023fed45b675c2e225df36)) * NCWF-1 401 ([#5](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/5)) ([f8f93ef](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/f8f93efff48f1d7198023fed45b675c2e225df36))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.1...0.0.0) (2026-05-12) ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.1...0.0.0) (2026-05-12)
### Bug Fixes ### Bug Fixes
- NCWF-1 401 ([#6](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/6)) ([6d1e06d](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/6d1e06dfd606b93d029e9c9b84eea6f8b3b6294e)) * NCWF-1 401 ([#6](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/6)) ([6d1e06d](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/6d1e06dfd606b93d029e9c9b84eea6f8b3b6294e))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.2...0.0.0) (2026-05-14) ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.2...0.0.0) (2026-05-14)
### Bug Fixes ### Bug Fixes
- added missing challenge routes ([61000f8](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/61000f8a22aff8b524664a756cc933834365f923)) * added missing challenge routes ([61000f8](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/61000f8a22aff8b524664a756cc933834365f923))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.3...0.0.0) (2026-05-15) ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.3...0.0.0) (2026-05-15)
### Bug Fixes ### Bug Fixes
- build error ([51a363a](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/51a363a2432be111b804082df362975047dc8080)) * build error ([51a363a](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/51a363a2432be111b804082df362975047dc8080))
- NCWF-2 bugs and desing fixes ([#7](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/7)) ([c02414e](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c02414ea40177b05a5e62dcf68dcb44efa6d3740)) * NCWF-2 bugs and desing fixes ([#7](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/7)) ([c02414e](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c02414ea40177b05a5e62dcf68dcb44efa6d3740))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.4...0.0.0) (2026-06-01) ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.4...0.0.0) (2026-06-01)
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.5...0.0.0) (2026-06-02) ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.5...0.0.0) (2026-06-02)
### Bug Fixes ### Bug Fixes
- 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) ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.7...0.0.0) (2026-06-10)
### 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))
+13 -4
View File
@@ -18,7 +18,9 @@
"builder": "@angular/build:application", "builder": "@angular/build:application",
"options": { "options": {
"browser": "src/main.ts", "browser": "src/main.ts",
"polyfills": ["zone.js"], "polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json", "tsConfig": "tsconfig.app.json",
"assets": [ "assets": [
{ {
@@ -36,7 +38,9 @@
"output": "/assets/ChessAssets" "output": "/assets/ChessAssets"
} }
], ],
"styles": ["src/styles.css"] "styles": [
"src/styles.css"
]
}, },
"configurations": { "configurations": {
"production": { "production": {
@@ -102,7 +106,10 @@
"test": { "test": {
"builder": "@angular/build:karma", "builder": "@angular/build:karma",
"options": { "options": {
"polyfills": ["zone.js", "zone.js/testing"], "polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json", "tsConfig": "tsconfig.spec.json",
"assets": [ "assets": [
{ {
@@ -120,7 +127,9 @@
"output": "/assets/ChessAssets" "output": "/assets/ChessAssets"
} }
], ],
"styles": ["src/styles.css"] "styles": [
"src/styles.css"
]
} }
} }
} }
+5 -2
View File
@@ -55,6 +55,7 @@ tags:
description: Export a game as FEN or PGN description: Export a game as FEN or PGN
paths: paths:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Game lifecycle # Game lifecycle
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -466,6 +467,7 @@ paths:
# ============================================================================= # =============================================================================
components: components:
securitySchemes: securitySchemes:
bearerAuth: bearerAuth:
type: http type: http
@@ -515,6 +517,7 @@ components:
$ref: '#/components/schemas/ApiError' $ref: '#/components/schemas/ApiError'
schemas: schemas:
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Requests # Requests
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -548,7 +551,7 @@ components:
pgn: pgn:
type: string type: string
description: PGN text (headers and move list) description: PGN text (headers and move list)
example: '1. e4 e5 2. Nf3 Nc6 *' example: "1. e4 e5 2. Nf3 Nc6 *"
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Game state # Game state
@@ -584,7 +587,7 @@ components:
pgn: pgn:
type: string type: string
description: PGN move text for the full game so far description: PGN move text for the full game so far
example: '1. e4' example: "1. e4"
turn: turn:
type: string type: string
enum: [white, black] enum: [white, black]
+2 -2
View File
@@ -1,4 +1,4 @@
window.__RUNTIME_CONFIG__ = { window.__RUNTIME_CONFIG__ = {
API_URL: '${API_URL}', API_URL: "${API_URL}",
WEBSOCKET_URL: '${WEBSOCKET_URL}', WEBSOCKET_URL: "${WEBSOCKET_URL}"
}; };
+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();
}); });
+46 -158
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>
@@ -8,17 +9,9 @@
<!-- 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"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /> <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" /> <circle cx="12" cy="12" r="3" />
</svg> </svg>
@@ -32,26 +25,15 @@
<!-- Right cluster --> <!-- Right cluster -->
<div class="nc-right"> <div class="nc-right">
@if (currentUser; as user) { @if (currentUser; as user) {
<!-- Notifications bell --> <!-- Notifications bell -->
<div class="nc-dropdown-wrap" data-dropdown="notif"> <div class="nc-dropdown-wrap" data-dropdown="notif">
<button <button type="button" class="nc-bell" [class.is-open]="notifOpen" (click)="toggleNotif($event)"
type="button" aria-label="Notifications">
class="nc-bell" <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"
[class.is-open]="notifOpen" stroke-linecap="round" stroke-linejoin="round">
(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="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" /> <path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</svg> </svg>
@@ -73,21 +55,11 @@
@for (challenge of incomingChallenges; track challenge.id) { @for (challenge of incomingChallenges; track challenge.id) {
<div class="nc-notif-row is-unread"> <div class="nc-notif-row is-unread">
<div class="nc-notif-icon"> <div class="nc-notif-icon">
<svg <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"
width="14" stroke-linecap="round" stroke-linejoin="round">
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" /> <line x1="14.5" y1="17.5" x2="3" y2="6" />
<path d="M13 19l6 -6" /> <path d="M13 19l6 -6" /><path d="M16 16l4 4" />
<path d="M16 16l4 4" /> <path d="M19 21l2 -2" /><path d="M15 5l4 4" />
<path d="M19 21l2 -2" />
<path d="M15 5l4 4" />
<path d="M21 3l-3 1l-4 4" /> <path d="M21 3l-3 1l-4 4" />
</svg> </svg>
</div> </div>
@@ -97,49 +69,24 @@
{{ getTimeControlDisplay(challenge) }} game. {{ getTimeControlDisplay(challenge) }} game.
</div> </div>
<div class="nc-notif-meta"> <div class="nc-notif-meta">
{{ challenge.challenger.rating }} · {{ challenge.challenger.rating }} · {{ challenge.timeControl.type ?? 'Custom' }} · {{ getExpirationInfo(challenge) }}
{{ challenge.timeControl.type ?? 'Custom' }} ·
{{ getExpirationInfo(challenge) }}
</div> </div>
<div class="nc-notif-actions"> <div class="nc-notif-actions">
<button <button type="button" class="nc-btn-accept"
type="button"
class="nc-btn-accept"
[disabled]="acceptingId === challenge.id || !!decliningId" [disabled]="acceptingId === challenge.id || !!decliningId"
(click)="acceptChallenge($event, challenge)" (click)="acceptChallenge($event, challenge)">
> <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"
<svg stroke-linecap="round" stroke-linejoin="round">
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" /> <polyline points="20 6 9 17 4 12" />
</svg> </svg>
{{ acceptingId === challenge.id ? '...' : 'Accept' }} {{ acceptingId === challenge.id ? '...' : 'Accept' }}
</button> </button>
<button <button type="button" class="nc-btn-decline"
type="button"
class="nc-btn-decline"
[disabled]="!!acceptingId || decliningId === challenge.id" [disabled]="!!acceptingId || decliningId === challenge.id"
(click)="declineChallenge($event, challenge)" (click)="declineChallenge($event, challenge)">
> <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"
<svg stroke-linecap="round" stroke-linejoin="round">
width="10" <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
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> </svg>
{{ decliningId === challenge.id ? '...' : 'Decline' }} {{ decliningId === challenge.id ? '...' : 'Decline' }}
</button> </button>
@@ -150,9 +97,7 @@
</div> </div>
<div class="nc-notif-footer"> <div class="nc-notif-footer">
<button type="button" class="nc-view-all" (click)="goToChallenges()"> <button type="button" class="nc-view-all" (click)="goToChallenges()">View all challenges</button>
View all challenges
</button>
</div> </div>
</div> </div>
} }
@@ -160,21 +105,11 @@
<!-- Games quick-access --> <!-- Games quick-access -->
<button type="button" class="nc-games-btn" (click)="goToGames()"> <button type="button" class="nc-games-btn" (click)="goToGames()">
<svg <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
width="13" stroke-linecap="round" stroke-linejoin="round">
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="M14.5 17.5L3 6"/>
<path d="M13 19l6-6" /> <path d="M13 19l6-6"/><path d="M16 16l4 4"/>
<path d="M16 16l4 4" /> <path d="M19 21l2-2"/><path d="M15 5l4 4"/>
<path d="M19 21l2-2" />
<path d="M15 5l4 4" />
<path d="M21 3l-3 1-4 4"/> <path d="M21 3l-3 1-4 4"/>
</svg> </svg>
Games Games
@@ -182,25 +117,11 @@
<!-- Profile --> <!-- Profile -->
<div class="nc-dropdown-wrap" data-dropdown="profile"> <div class="nc-dropdown-wrap" data-dropdown="profile">
<button <button type="button" class="nc-profile" [class.is-open]="profileOpen" (click)="toggleProfile($event)">
type="button"
class="nc-profile"
[class.is-open]="profileOpen"
(click)="toggleProfile($event)"
>
<div class="nc-avatar nc-avatar-sm">{{ getInitial() }}</div> <div class="nc-avatar nc-avatar-sm">{{ getInitial() }}</div>
<span class="nc-profile-name">{{ user.username }}</span> <span class="nc-profile-name">{{ user.username }}</span>
<svg <svg class="nc-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
class="nc-chevron" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
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" /> <polyline points="6 9 12 15 18 9" />
</svg> </svg>
</button> </button>
@@ -218,16 +139,8 @@
<div class="nc-menu-group"> <div class="nc-menu-group">
<button type="button" class="nc-menu-item" (click)="goToProfile()"> <button type="button" class="nc-menu-item" (click)="goToProfile()">
<span class="nc-menu-icon"> <span class="nc-menu-icon">
<svg <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
width="14" stroke-linecap="round" stroke-linejoin="round">
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" /> <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" /> <circle cx="12" cy="7" r="4" />
</svg> </svg>
@@ -237,21 +150,11 @@
<button type="button" class="nc-menu-item" (click)="goToChallenges()"> <button type="button" class="nc-menu-item" (click)="goToChallenges()">
<span class="nc-menu-icon"> <span class="nc-menu-icon">
<svg <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"
width="14" stroke-linecap="round" stroke-linejoin="round">
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" /> <line x1="14.5" y1="17.5" x2="3" y2="6" />
<path d="M13 19l6 -6" /> <path d="M13 19l6 -6" /><path d="M16 16l4 4" />
<path d="M16 16l4 4" /> <path d="M19 21l2 -2" /><path d="M15 5l4 4" />
<path d="M19 21l2 -2" />
<path d="M15 5l4 4" />
<path d="M21 3l-3 1l-4 4" /> <path d="M21 3l-3 1l-4 4" />
</svg> </svg>
</span> </span>
@@ -260,16 +163,8 @@
<button type="button" class="nc-menu-item" (click)="toggleTheme($event)"> <button type="button" class="nc-menu-item" (click)="toggleTheme($event)">
<span class="nc-menu-icon"> <span class="nc-menu-icon">
<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"
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" /> <path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
</svg> </svg>
</span> </span>
@@ -281,16 +176,8 @@
<div class="nc-menu-group"> <div class="nc-menu-group">
<button type="button" class="nc-menu-item danger" (click)="logout()"> <button type="button" class="nc-menu-item danger" (click)="logout()">
<span class="nc-menu-icon"> <span class="nc-menu-icon">
<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"
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" /> <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" /> <polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" /> <line x1="21" y1="12" x2="9" y2="12" />
@@ -302,12 +189,13 @@
</div> </div>
} }
</div> </div>
} @else { } @else {
<!-- Logged-out auth buttons --> <!-- Logged-out auth buttons -->
<button type="button" class="nc-auth-btn" (click)="openLoginDialog()">Login</button> <button type="button" class="nc-auth-btn" (click)="openLoginDialog()">Login</button>
<button type="button" class="nc-auth-btn nc-auth-btn--primary" (click)="openRegisterDialog()"> <button type="button" class="nc-auth-btn nc-auth-btn--primary" (click)="openRegisterDialog()">Register</button>
Register
</button>
} }
</div> </div>
</nav> </nav>
+16 -21
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,7 +46,9 @@ 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$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(user => {
this.currentUser = user; this.currentUser = user;
if (user) { if (user) {
this.challengeWs.connect(); this.challengeWs.connect();
@@ -61,21 +63,18 @@ export class ToolbarComponent implements OnInit {
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,7 +16,16 @@
<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>
<div class="page-actions">
@if (currentUser) { @if (currentUser) {
<button type="button" class="btn-servers" (click)="openServersDialog()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/>
<line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
Servers
</button>
<button type="button" class="btn-new" (click)="openCreateDialog()"> <button type="button" class="btn-new" (click)="openCreateDialog()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"> stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
@@ -26,6 +35,7 @@
</button> </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')">
Live Live
@@ -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,17 +203,16 @@
<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) { } @else {
<p class="join-empty">You have no bots yet. Go to <strong>Bots</strong> in the nav to create one first.</p> @if (userBots.length === 0) {
<p class="join-empty">You have no bots. <a routerLink="/bots">Create one</a> to join with your own bot.</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) {
<button type="button" class="bot-pick-row" <button type="button" class="bot-pick-row"
[disabled]="!!joiningBotId" [disabled]="!!joiningBotId || !!joiningOfficialDifficulty"
(click)="joinWithBot(bot)"> (click)="joinWithBot(bot)">
<span class="bot-pick-avatar">{{ bot.name.charAt(0).toUpperCase() }}</span> <span class="bot-pick-avatar">{{ bot.name.charAt(0).toUpperCase() }}</span>
<span class="bot-pick-name">{{ bot.name }}</span> <span class="bot-pick-name">{{ bot.name }}</span>
@@ -218,6 +228,67 @@
@if (joinError) { @if (joinError) {
<div class="dialog-error">{{ joinError }}</div> <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 class="dialog-actions">
<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,27 +195,57 @@ 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 => {
this.tournamentService.joinWithBotToken(this.joinDialogTournamentId!, token).subscribe({
next: () => { next: () => {
this.joiningBotId = null; this.joiningBotId = null;
const tid = this.joinDialogTournamentId!; const tid = this.joinDialogTournamentId!;
@@ -196,14 +263,23 @@ export class TournamentsComponent implements OnInit {
this.joinError = err.error?.message ?? err.error?.error ?? 'Failed to join tournament.'; this.joinError = err.error?.message ?? err.error?.error ?? 'Failed to join tournament.';
} }
}); });
},
error: () => {
this.joiningBotId = null;
this.joinError = 'Failed to get bot token.';
} }
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 });
} }
@@ -22,7 +22,7 @@ export class ChallengeWebSocketService {
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;
@@ -30,6 +30,7 @@ export class ChallengeWebSocketService {
this.ws.onopen = () => { this.ws.onopen = () => {
this.reconnectAttempts = 0; this.reconnectAttempts = 0;
this.ws?.send(JSON.stringify({ type: 'auth', token }));
}; };
this.ws.onmessage = (event) => { this.ws.onmessage = (event) => {
@@ -76,8 +77,10 @@ export class ChallengeWebSocketService {
const challengeId = message['challengeId'] as string | undefined; const challengeId = message['challengeId'] as string | undefined;
if (challengeId) { if (challengeId) {
this.challengeService.getChallenge(challengeId).subscribe({ this.challengeService.getChallenge(challengeId).subscribe({
next: challenge => this.challengeEventService.onChallengeReceived(challenge), next: (challenge) => this.challengeEventService.onChallengeReceived(challenge),
error: () => { /* challenge may have already expired */ } error: () => {
/* challenge may have already expired */
},
}); });
} }
break; break;
@@ -110,6 +113,8 @@ export class ChallengeWebSocketService {
private attemptReconnect(): void { private attemptReconnect(): void {
if (this.intentionalClose || this.reconnectAttempts >= this.maxReconnectAttempts) return; if (this.intentionalClose || this.reconnectAttempts >= this.maxReconnectAttempts) return;
this.reconnectAttempts++; this.reconnectAttempts++;
setTimeout(() => { this.connect(); }, this.reconnectDelay); 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'
}; };
+6 -2
View File
@@ -6,6 +6,10 @@
"outDir": "./out-tsc/app", "outDir": "./out-tsc/app",
"types": [] "types": []
}, },
"include": ["src/**/*.ts"], "include": [
"exclude": ["src/**/*.spec.ts"] "src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
} }
+7 -2
View File
@@ -4,7 +4,12 @@
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./out-tsc/spec", "outDir": "./out-tsc/spec",
"types": ["jasmine"] "types": [
"jasmine"
]
}, },
"include": ["src/**/*.d.ts", "src/**/*.spec.ts"] "include": [
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
} }
+2 -2
View File
@@ -1,3 +1,3 @@
MAJOR=0 MAJOR=0
MINOR=3 MINOR=6
PATCH=0 PATCH=2