16 Commits

Author SHA1 Message Date
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
TeamCity ae952d70b0 ci: bump version to v0.2.8 2026-06-10 09:18:51 +00:00
LQ63 a62073511f fix: route play-vs-bot to /vs-bot endpoint
POST /api/board/game is @InternalOnly and returns 401 for browser clients.
Switch to /api/board/game/vs-bot which is @PermitAll and notifies the
official-bots service to play the bot side automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 11:13:37 +02:00
TeamCity 16079dd63a ci: bump version to v0.2.7 2026-06-06 12:36:12 +00:00
Janis Eccarius a65d748f99 chore(claude): add YouTrack command suite
Port create-defect, create-story, estimate-issues, fix-defect,
implement-feature and split-story commands, adapted for the Angular
frontend (npm build/test, prettier gates, NCWF routing).
implement-feature now walks subtasks, respects blocked-by, and reports
cross-project (NCS/NCI) tasks. Ignore .claude/worktrees.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 14:33:07 +02:00
36 changed files with 3012 additions and 168 deletions
+97
View File
@@ -0,0 +1,97 @@
# Create Defect in YouTrack
Automated defect creation workflow. Topic/hint: `$ARGUMENTS`
This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). Defects raised
here usually belong to project `NCWF`, but routing in Step 5 still applies.
## Step 1 — Gather Context
Use `AskUserQuestion` tool to ask the user (max 4 questions at once):
1. **Component** — Where does the bug occur? (e.g. a component, service, route/page, model, styling, API integration)
2. **What breaks** — What is the actual (broken) behavior?
3. **Expected** — What should happen instead?
4. **Reproducibility** — Is it always reproducible? Any known trigger conditions? (route, viewport, browser)
If `$ARGUMENTS` already answers some of these, skip those questions.
## Step 2 — Research (if needed)
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}`.
- Check `.spec.ts` files for existing coverage of the broken area.
- Do NOT guess at root cause. Surface findings before drafting.
## Step 3 — Draft Defect
Compose the full defect report using this template:
```
Summary
[One-sentence description of what is broken.]
Steps to Reproduce
1. Step one
2. Step two
3. Step three
Expected Behavior
[What should happen.]
Actual Behavior
[What actually happens.]
Environment / Notes
[Any relevant context: route/URL, viewport, browser, console errors, API payloads — only if applicable.]
```
Rules:
- Steps must be minimal and reproducible.
- Expected vs actual: concrete and unambiguous.
- Omit "Environment / Notes" section if not relevant.
## Step 4 — Clarify
Show the draft to the user.
**Use `AskUserQuestion` tool to ask:**
- Are steps to reproduce complete and accurate?
- Severity: Blocker / Critical / Major / Minor / Trivial?
- Any related tickets or recent changes to link?
Incorporate feedback. Repeat until user approves.
## Step 5 — Determine Project
> **Project routing rules (always apply these):**
> - Frontend code (UI, UX, web app, components, services) → `NCWF`
> - Backend code (game engine, bots, API, services, coordinator) → `NCS`
> - Infrastructure (Kubernetes, pipelines, CI/CD, DB setup, cloud infra) → `NCI`
- Frontend / UI / UX → project: `NCWF` (default for this repo)
- Backend / coordinator / systems / bot / engine → project: `NCS`
- Kubernetes, pipelines, CI/CD, infrastructure → project: `NCI`
If ambiguous, ask the user.
## Step 6 — Create Issue
Call `mcp__youtrack__create_issue` with:
- `project`: determined in Step 5
- `summary`: concise title describing what is broken (≤72 chars, sentence case)
- `description`: full formatted defect report from Step 3 (Markdown)
- `type`: `Bug`
## Step 7 — Report
Display the created issue ID and URL.
Ask if a linked investigation or fix task is needed.
+114
View File
@@ -0,0 +1,114 @@
# Create User Story in YouTrack
Automated user-story creation workflow. Topic/hint: `$ARGUMENTS`
This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). Stories raised
here usually belong to project `NCWF`, but routing in Step 5 still applies.
## Step 1 — Gather Context
Use `AskUserQuestion` tool to ask the user (max 4 questions at once):
1. **Domain** — Is this frontend (UI/UX) work, or does it touch backend/infrastructure?
2. **User type** — Who is the actor? (e.g. player, spectator, admin, anonymous visitor)
3. **Action** — What should the user be able to do?
4. **Goal/value** — Why? What outcome does it enable?
If `$ARGUMENTS` already answers some of these, skip those questions.
## Step 2 — Research (if needed)
If the topic involves unfamiliar UI flows, component structure, or technical constraints:
- Search the repo for relevant code under `src/app/` (use `Grep`/`Bash`).
- Use `WebSearch` if the topic involves Angular APIs, external standards or protocols.
- Do NOT guess. Surface findings before drafting.
## Step 3 — Draft Story
Compose the full story using this template:
```
As a [type of user]
I want to [perform an action]
So that [achieve a goal or value]
Description
[Additional context or business logic for this story.]
Acceptance Criteria
[List the specific, measurable criteria that define when this story is done:]
- Criterion 1
- Criterion 2
- Criterion 3
Implementation Notes
[Technical notes, component/service references, design refs, or constraints.]
```
Rules:
- User story line: plain English, present tense, from user's perspective.
- Acceptance criteria: testable, unambiguous, one condition each.
- Implementation notes: optional — only include if there are known constraints, related tickets, or design refs.
## Step 4 — Clarify Acceptance Criteria
Show the draft to the user.
**Use `AskUserQuestion` tool to ask:**
- Are the acceptance criteria complete and correct?
- Any implementation constraints to add?
- Priority (if known)?
Incorporate feedback. Repeat until user approves.
## Step 5 — Determine Project
> **Project routing rules (always apply these):**
> - Frontend code (UI, UX, web app, components, services) → `NCWF`
> - Backend code (game engine, bots, API, services, coordinator) → `NCS`
> - Infrastructure (Kubernetes, pipelines, CI/CD, DB setup, cloud infra) → `NCI`
- Frontend / UI / UX → project: `NCWF` (default for this repo)
- Backend / coordinator / systems / bot / engine → project: `NCS`
- Kubernetes, pipelines, CI/CD, infrastructure → project: `NCI`
If still ambiguous, ask the user.
## Step 6 — Create Issue
Call `mcp__youtrack__create_issue` with:
- `project`: determined in Step 5
- `summary`: concise title derived from the "I want to" clause (≤72 chars, sentence case)
- `description`: full formatted story from Step 3 (Markdown)
- `type`: `Feature` (or `Task` if purely technical with no user-facing value)
## Step 7 — Link Issues
After creation, ask the user (use `AskUserQuestion` if interactive, otherwise infer from context):
> Are there related issues to link? (skip if none)
Collect any issue IDs the user mentions. For each, determine the correct relation and call `mcp__youtrack__link_issues`:
| Situation | Relation to use |
|-----------|----------------|
| This story must be done before another | `blocks` |
| Another story must be done before this | `is blocked by` |
| Stories share domain or are related | `relates to` |
| This is a child of an epic/story | `subtask of` |
| This is a parent grouping subtasks | `parent for` |
| This depends on another ticket's output | `depends on` |
If the user mentions an issue in the story description or implementation notes (e.g. "see NCS-42", "after NCWF-12 is done"), auto-detect and suggest linking it — confirm before creating the link.
## Step 8 — Report
Display the created issue ID and URL.
List any links created (relation type + linked issue ID).
Ask if a linked sub-task or implementation ticket is needed.
+92
View File
@@ -0,0 +1,92 @@
# Estimate Issue Time in YouTrack
Sprint planning time estimator. Issue ID or empty for full current sprint: `$ARGUMENTS`
This is the **NowChess-Frontend** repo. Sprint mode defaults to project `NCWF`.
## Step 1 — Determine Scope
**Single-issue mode** (`$ARGUMENTS` set):
- Call `mcp__youtrack__get_issue` on `$ARGUMENTS`.
- Proceed with that issue only.
**Sprint mode** (`$ARGUMENTS` empty):
- 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}`.
- Collect all returned issues.
## Step 2 — Build Issue Tree
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.
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.
4. Group into tree: Epic → Story → Task/Subtask.
**Leaf node** = issue with no subtask children.
**Parent node** = issue that has at least one subtask child.
## Step 3 — Estimate Leaf Nodes
For each leaf node:
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.
3. Assign estimate using this scale:
| Size | Criteria | Estimate |
|------|----------|----------|
| Trivial | Style tweak, copy change, 1-file tweak | 30m |
| Small | 13 files, single component/service, no unknowns | 1h2h |
| Medium | 36 files, new component + service wiring, some design | 3h5h |
| Large | 6+ files, cross-feature, non-trivial state/routing | 1d2d |
| XL | New feature area, major refactor, research spike | 3d5d |
4. Record: estimate + one-line reasoning.
5. Skip leaf if it already has `Zeitschätzung` set — note it as pre-estimated.
## Step 4 — Roll Up for Display
YouTrack auto-sums `Zeitschätzung` from subtasks up to parents — **do not write estimates to parent nodes**.
Compute display-only rolled-up totals:
- Parent total = sum of all descendant leaf estimates (including pre-estimated ones).
- Flag any branch where some leaves are missing estimates (partial roll-up).
## Step 5 — Show Summary + Confirm
Display full tree with estimates. Format:
```
Epic NCWF-10: Board UI overhaul [4h 30m] ← rolled up
Story NCWF-11: Drag-and-drop pieces [2h 30m] ← rolled up
Task NCWF-12: Add drag directive 1h 30m ← leaf (new)
Task NCWF-13: Add component specs 1h ← leaf (new)
Story NCWF-14: Move-list panel [2h] ← rolled up
Task NCWF-15: Render SAN move list 2h ← leaf (pre-set, skipped)
```
Legend: `[X]` = display-only roll-up (not written). Plain = will be written to YouTrack.
If sprint mode, show grand total at bottom:
```
Sprint total: Xd Yh Zm (N issues, M leaves to update)
```
**Use `AskUserQuestion` tool:**
- Does the breakdown look right?
- Any estimates to adjust before writing to YouTrack?
Incorporate all feedback before proceeding.
## Step 6 — Write Estimates
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.
- YouTrack period format: `"30m"`, `"1h 30m"`, `"1d"`, `"2d 4h"`.
- Skip leaves already pre-estimated.
## Step 7 — Report
List all updated issues with set estimates.
Show final rolled-up totals per Epic/Story (read back from YouTrack via `mcp__youtrack__get_issue` if needed).
In sprint mode, show total sprint estimate.
+143
View File
@@ -0,0 +1,143 @@
# Fix Defect from YouTrack
Automated defect-fix workflow. Ticket ID: `$ARGUMENTS`
This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). Gates:
- Build: `npm run build`
- Test: `npm test -- --watch=false --browsers=ChromeHeadless`
- Format: `npx prettier --write .` (check with `npx prettier --check .`)
## Step 1 — Fetch Ticket
Call `mcp__youtrack__get_issue` with ID `$ARGUMENTS`.
Extract and display: summary, description, steps to reproduce, Priority, Subsystem.
## Step 2 — Create Worktree
Derive branch name from ticket:
- `type` from YouTrack issue type: `bug``fix`, `feature`/`task``feat`, `refactor``refactor`, else `chore`
- `scope` from affected area (kebab-case, omit if unclear)
- `description` from ticket summary: lowercase, kebab-case, max 40 chars, drop articles
Branch format: `<type>/<ticket-id>-<description>`
Example: `fix/NCWF-123-board-flip-resets-selection`
Call `EnterWorktree` with that branch name.
All subsequent file work happens inside this worktree.
## Step 3 — Identify Root Cause (read-only)
1. Run `npm run build` — capture all errors and warnings.
2. Run `npm test -- --watch=false --browsers=ChromeHeadless` — capture all failures.
3. Spawn `cavecrew-investigator` with: ticket description + build/test output → locate root cause (files, line numbers, what's wrong).
4. **If anything is ambiguous (reproduction unclear, scope uncertain, conflicting signals), use `AskUserQuestion` tool to ask — max 4 questions at once.**
5. **Report findings to user. No file writes yet. Wait for acknowledgement before continuing.**
## Step 3b — Complexity Assessment + Subtasks
After root cause confirmed, assess scope:
**Simple** (12 files, single concern, < 1 hour estimated): proceed directly to Step 4.
**Complex** (3+ files, multiple concerns, or estimated > 1 hour): create subtasks before coding.
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").
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.
- `summary`: concise action-oriented title
- `type`: `Task`
- `description`: what to do and why
3. Call `mcp__youtrack__link_issues` to link each subtask to `$ARGUMENTS` with relation `subtask of`.
4. Check if the ticket description or comments mention other issue IDs. For each mentioned ID, suggest a link and confirm with user:
- Fix depends on another fix finishing first → `is blocked by`
- This fix blocks another ticket → `blocks`
- Logically related but independent → `relates to`
5. List created subtask IDs and any additional links to user.
Then proceed to Step 4, implementing subtasks in order.
## Step 4 — Fix
1. Implement fix (use the `general-purpose` agent for non-trivial multi-file changes; inline edits for small ones).
2. Run `npm run build` — must be green.
3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green (add new specs for new behaviour; do not modify existing specs unless requirements changed).
4. Run `npx prettier --write .`**blocking, foreground only**. Wait for completion before continuing.
5. Run `npx prettier --check .`**blocking, foreground only** (never `run_in_background`). Wait for exit code 0. Must be green.
- If it fails, fix all issues and re-run until exit code 0.
- **Do NOT proceed to Step 5 until the build, tests and format check all pass.**
If any step fails, iterate until all pass.
## Step 5 — Review
Spawn `cavecrew-reviewer` on the full diff.
Display findings grouped by severity.
## Step 5b — Apply Review Findings
If the review produced any findings (any severity):
1. Implement all agreed fixes.
2. Run `npm run build` — must be green.
3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green.
4. Run `npx prettier --write .` then `npx prettier --check .`**blocking, foreground only**. Wait for exit code 0.
5. Re-spawn `cavecrew-reviewer` on the updated diff to confirm all findings are resolved.
Repeat until review is clean or user explicitly accepts remaining findings.
## Step 6 — Confirm + Push
Show summary: ticket, branch, files changed, review findings.
**Use `AskUserQuestion` tool to ask for explicit approval before pushing.** Include any open questions about commit message scope or body if unclear.
On approval, commit following Conventional Commits:
```
<type>(<scope>): <short description, imperative, ≤50 chars>
<optional body: what changed and why, wrap at 72 chars>
Closes $ARGUMENTS
https://knockoutwhist.youtrack.cloud/issue/$ARGUMENTS
```
- `type`: same as branch type (`fix`, `feat`, `refactor`, `chore`, etc.)
- `scope`: affected area (`ui`, `components`, `services`, `models`, `core`, `routing`, `pages`, `styles`)
- Subject: imperative mood, no period, lowercase
- Footer `Closes $ARGUMENTS` and ticket URL always present
Push branch to remote.
## Step 7 — Comment on Ticket
After successful push, call `mcp__youtrack__add_issue_comment` on `$ARGUMENTS` with:
```
Branch `<branch-name>` pushed.
<one-sentence summary of what was changed and why>
Files changed:
- <file1>
- <file2>
```
## Step 7b — Additional Links
After commenting, ask the user if `$ARGUMENTS` should be linked to any other issues not already linked:
| Situation | Relation |
|-----------|---------|
| This fix blocks another open ticket | `blocks` |
| Another ticket must ship first | `is blocked by` |
| Related defect or story | `relates to` |
| Duplicate of another defect | `duplicates` |
Scan the ticket description and comments for any issue IDs that were mentioned but not yet linked. Suggest those automatically.
Call `mcp__youtrack__link_issues` for each confirmed link.
## Step 8 — Cleanup
Call `ExitWorktree` with `discard_changes: true` to delete the worktree.
(Branch was pushed in step 6 — commits are safe on remote; `discard_changes: true` bypasses the local-ahead guard.)
Report: branch pushed, ticket commented, links created, worktree deleted, done.
+159
View File
@@ -0,0 +1,159 @@
# Implement Feature from YouTrack
Automated feature-implementation workflow. Ticket ID: `$ARGUMENTS`
This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). In-project =
`NCWF`. Gates:
- Build: `npm run build`
- Test: `npm test -- --watch=false --browsers=ChromeHeadless`
- Format: `npx prettier --write .` (check with `npx prettier --check .`)
This workflow implements the given ticket **and all of its subtasks**, while
respecting `blocked by` dependencies. Tasks that live in other projects
(`NCS`, `NCI`, or any project other than `NCWF`) are never edited here — they
are collected and reported at the end with a ready-to-run prompt.
## Step 1 — Fetch Ticket + Build Task Tree
1. Call `mcp__youtrack__get_issue` with ID `$ARGUMENTS`.
2. Extract and display: summary, description, acceptance criteria, Priority, Subsystem, and the **issue links**.
3. From the links, build the task tree:
- **Subtasks** = issues linked as `subtask of` / `parent for` (children of `$ARGUMENTS`). Recurse: fetch each subtask with `get_issue` and collect its subtasks too.
- **Blocked-by** = for every task in the tree, record issues linked as `is blocked by`.
4. Classify each task by project (the prefix before the dash in the issue ID):
- **In-project** = `NCWF`.
- **Out-of-project** = `NCS`, `NCI`, or any other prefix. These are **never implemented here**.
5. Display the full tree: root, subtasks (nested), and for each its blockers + project tag.
## Step 2 — Resolve Implementation Order
1. Filter to **in-project (`NCWF`), not-yet-resolved** tasks only (root + subtasks). Out-of-project tasks are excluded from implementation.
2. Topologically sort by `blocked by`: a task is only implementable once all its in-project blockers are resolved.
3. A task is **blocked** (cannot start) if any blocker is:
- an in-project task that is not yet resolved in this run, **or**
- an out-of-project task (`NCS`/`NCI`/etc.) — these can't be resolved here.
4. Produce two lists:
- **Implementable order** — `NCWF` tasks, dependency-sorted.
- **Blocked tasks** — with the blocker(s) that stop them.
5. **Report both lists to the user.** Wait for acknowledgement before continuing.
## Step 3 — Create Worktree
Derive branch name from the root ticket `$ARGUMENTS`:
- `type` from YouTrack issue type: `feature`/`task``feat`, `refactor``refactor`, `bug``fix`, else `chore`
- `scope` from affected area (kebab-case, omit if unclear)
- `description` from ticket summary: lowercase, kebab-case, max 40 chars, drop articles
Branch format: `<type>/<ticket-id>-<description>`
Example: `feat/NCWF-456-add-board-theme-selector`
Call `EnterWorktree` with that branch name.
All subsequent file work happens inside this worktree. All implementable
tasks (root + subtasks) are implemented on this one branch.
## Step 4 — Understand Requirements (read-only)
1. Run `npm run build` — confirm baseline is green.
2. Run `npm test -- --watch=false --browsers=ChromeHeadless` — confirm baseline is green.
3. For the root + each implementable subtask, spawn `cavecrew-investigator` with: that task's description + acceptance criteria → locate affected files under `src/app/`, relevant components/services/models, routes, integration touch-points.
4. **If anything is ambiguous (scope unclear, acceptance criteria missing, design decisions needed), use `AskUserQuestion` tool to ask — max 4 questions at once.**
5. **Report plan to user: per task — what will be added/changed, which files, which areas. No file writes yet. Wait for acknowledgement before continuing.**
## Step 5 — Implement (per task, in dependency order)
For each task in the implementable order from Step 2, do the following before moving to the next:
1. Implement task (use the `general-purpose` agent for non-trivial multi-file changes; inline edits for small ones).
2. Run `npm run build` — must be green.
3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green (add new specs for new behaviour; do not modify existing specs unless requirements changed).
4. Run `npx prettier --write .`**blocking, foreground only**. Wait for completion before continuing.
5. Run `npx prettier --check .`**blocking, foreground only** (never `run_in_background`). Wait for exit code 0. Must be green.
- If it fails, fix all issues and re-run until exit code 0.
- **Do NOT proceed to the next task until build, tests and format check all pass.**
If any step fails, iterate until all pass. Once a task is fully green it counts
as **resolved** for the purpose of unblocking later tasks — re-check Step 2's
blocked list: any task whose blockers are now all resolved becomes implementable.
## Step 6 — Review
Spawn `cavecrew-reviewer` on the full diff (covering all implemented tasks).
Display findings grouped by severity.
## Step 6b — Apply Review Findings
If the review produced any findings (any severity):
1. Implement all agreed fixes.
2. Run `npm run build` — must be green.
3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green.
4. Run `npx prettier --write .` then `npx prettier --check .`**blocking, foreground only**. Wait for exit code 0.
5. Re-spawn `cavecrew-reviewer` on the updated diff to confirm all findings are resolved.
Repeat until review is clean or user explicitly accepts remaining findings.
## Step 7 — Confirm + Push
Show summary: root ticket, implemented subtasks, branch, files changed, review findings.
**Use `AskUserQuestion` tool to ask for explicit approval before pushing.** Include any open questions about commit message scope or body if unclear.
On approval, commit following Conventional Commits:
```
<type>(<scope>): <short description, imperative, ≤50 chars>
<optional body: what changed and why, wrap at 72 chars>
Closes $ARGUMENTS
<also list Closes <ID> for each implemented subtask>
https://knockoutwhist.youtrack.cloud/issue/$ARGUMENTS
```
- `type`: same as branch type (`feat`, `refactor`, `chore`, etc.)
- `scope`: affected area (`ui`, `components`, `services`, `models`, `core`, `routing`, `pages`, `styles`)
- Subject: imperative mood, no period, lowercase
- Footer `Closes <ID>` for the root and every resolved subtask, plus the root ticket URL.
Push branch to remote.
## Step 8 — Comment on Tickets
After successful push, call `mcp__youtrack__add_issue_comment` on `$ARGUMENTS` **and on each implemented subtask** with:
```
Branch `<branch-name>` pushed.
<one-sentence summary of what was added and why>
Files changed:
- <file1>
- <file2>
```
## Step 9 — Cleanup
Call `ExitWorktree` with `discard_changes: true` to delete the worktree.
(Branch was pushed in step 7 — commits are safe on remote; `discard_changes: true` bypasses the local-ahead guard.)
## Step 10 — Report Blocked + Cross-Project Tasks
Final report to the user, in two sections:
### Blocked in-project tasks
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.)
### Cross-project tasks (NCS / NCI / other)
For every out-of-project task discovered in the tree (whether it was a subtask
or a blocker), output one entry:
```
- <ID> [<PROJECT>]: <summary>
Prompt: /implement-feature <ID>
```
Where `Prompt` is a short, copy-pasteable instruction to implement that task in
its own project — e.g. the ticket ID plus a one-line description of what the
other project needs to do so this project's blocked tasks can proceed.
End with: branch pushed, tickets commented, worktree deleted, plus the counts of
implemented / blocked / cross-project tasks.
+111
View File
@@ -0,0 +1,111 @@
# Split Story into Subtasks in YouTrack
Split a user story into smaller, implementable subtasks. Story ID: `$ARGUMENTS`
This is the **NowChess-Frontend** repo. Frontend subtasks route to `NCWF`.
## Step 1 — Fetch Story
Call `mcp__youtrack__get_issue` with ID `$ARGUMENTS`.
Extract and display: summary, description, acceptance criteria, implementation notes.
## Step 2 — Research (if needed)
If the story involves unfamiliar UI flows or technical constraints:
- Search repo for relevant code under `src/app/` (`Grep`/`Bash`).
- Use `WebSearch` for Angular APIs, external standards or protocols.
- Do NOT guess. Surface findings before proposing splits.
## Step 3 — Propose Split
Analyse the story and propose a set of subtasks. Rules:
- Each subtask = one unit of work, completable independently or in sequence.
- No subtask should exceed ~2 days of work.
- Name subtasks in imperative mood (e.g. "Add board theme service", "Render theme picker component").
- Cover the full scope of the parent story — no gaps.
Show proposed subtask list to user (titles only) and ask:
**Use `AskUserQuestion` tool:**
- Does the split look right?
- Any subtasks to add, remove, or merge?
- Should any subtask be assigned to a specific person?
Incorporate feedback. Repeat until user approves the list.
## Step 4 — Draft Each Subtask
For each approved subtask, compose description using this template:
```
[Brief description of what needs to be done for this subtask.]
Steps / Tasks
- Task 1
- Task 2
- Task 3
Definition of Done
What must be true for this subtask to be considered complete:
- Code implemented
- Specs passing
- Reviewed and merged
```
Rules:
- 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).
- Keep description short — one paragraph max.
## Step 5 — Determine Project per Subtask
Assign each subtask's project based on its content — do **not** inherit blindly from parent:
- Frontend code (UI, UX, web app, components, services) → `NCWF`
- Backend code (game engine, bots, API, services, coordinator) → `NCS`
- Kubernetes, pipelines, CI/CD, DB setup, infrastructure → `NCI`
If a subtask's project is ambiguous, ask the user before creating it.
## Step 6 — Create Subtasks
For each subtask call `mcp__youtrack__create_issue` with:
- `project`: from Step 5
- `summary`: subtask title (≤72 chars, sentence case)
- `description`: full formatted description from Step 4 (Markdown)
- `type`: `Task`
Then call `mcp__youtrack__link_issues` to link each created subtask to `$ARGUMENTS` with relation `subtask of`.
## Step 6b — Inter-Subtask 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).
Ask the user to confirm sequencing before adding these links:
> Do any subtasks have ordering dependencies? (e.g. "Add theme service must come before Render theme picker")
## Step 6c — External Links
Scan `$ARGUMENTS` description and implementation notes for any referenced issue IDs not already linked. For each:
| Situation | Relation |
|-----------|---------|
| Parent story blocks another epic/story | `blocks` |
| Story depends on another epic completing | `is blocked by` |
| Related story in same domain | `relates to` |
| This story duplicates or supersedes | `duplicates` |
Suggest links to the user and call `mcp__youtrack__link_issues` on confirmation.
## Step 7 — Report
List all created subtask IDs and summaries.
List all links created (subtask-of, blocking chains, external).
Display parent story link.
Ask if any subtask needs further splitting.
+4
View File
@@ -37,3 +37,7 @@ __screenshots__/
# System files
.DS_Store
Thumbs.db
# Claude Code
/.claude/settings.local.json
/.claude/worktrees/
+36
View File
@@ -50,3 +50,39 @@
### 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))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.6...0.0.0) (2026-06-06)
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.7...0.0.0) (2026-06-10)
### Bug Fixes
* route play-vs-bot to /vs-bot endpoint ([a620735](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/a62073511f2ac912ceb0f6b4730bef37545dd8ea))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.8...0.0.0) (2026-06-10)
### Features
* bots ([#9](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/9)) ([48959da](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/48959daae36e709ea7782ca04fdde699854f8e66))
## [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))
+5
View File
@@ -1,4 +1,9 @@
{
"/api/analysis": {
"target": "http://localhost:8087",
"secure": false,
"changeOrigin": true
},
"/api/tournament": {
"target": "http://localhost:8089",
"secure": false,
+2
View File
@@ -6,6 +6,7 @@ import { ChallengesComponent } from './pages/challenges/challenges.component';
import { GamesComponent } from './pages/games/games.component';
import { TournamentsComponent } from './pages/tournaments/tournaments.component';
import { BotsComponent } from './pages/bots/bots.component';
import { AnalysisComponent } from './pages/analysis/analysis.component';
export const routes: Routes = [
{ path: '', component: WelcomeComponent },
@@ -14,6 +15,7 @@ export const routes: Routes = [
{ path: 'challenges', component: ChallengesComponent },
{ path: 'tournaments', component: TournamentsComponent },
{ path: 'bots', component: BotsComponent },
{ path: 'analysis', component: AnalysisComponent },
{ path: 'game/:gameId', component: GameComponent },
{ path: '**', redirectTo: '' }
];
+2 -1
View File
@@ -1,12 +1,13 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
providers: [provideRouter([])]
providers: [provideRouter([]), provideHttpClient()]
}).compileComponents();
});
@@ -0,0 +1,108 @@
:host {
display: block;
}
.empty {
font-size: 11px;
color: rgba(255, 255, 255, 0.4);
padding: 12px 16px;
font-family: var(--nc-mono, monospace);
letter-spacing: 0.06em;
}
.move-grid {
display: grid;
grid-template-columns: 28px 1fr 1fr;
gap: 2px 4px;
padding: 8px 12px;
font-family: var(--nc-mono, monospace);
font-size: 12px;
}
.mv-num {
color: rgba(255, 255, 255, 0.3);
font-size: 10px;
display: flex;
align-items: center;
letter-spacing: 0.04em;
}
.mv {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
cursor: pointer;
border-radius: 2px;
color: rgba(255, 255, 255, 0.8);
transition:
background 0.12s,
color 0.12s;
}
.mv:hover {
background: rgba(255, 255, 255, 0.06);
color: #fff;
}
.mv.active {
background: rgba(255, 69, 200, 0.18);
color: #fff;
}
.mv-empty {
cursor: default;
color: rgba(255, 255, 255, 0.25);
}
.mv-empty:hover {
background: transparent;
color: rgba(255, 255, 255, 0.25);
}
.mv-san {
flex: 1;
}
.mv-placeholder {
opacity: 0.4;
}
/* Quality badges */
.mv-badge {
font-size: 10px;
font-weight: 700;
padding: 1px 4px;
border-radius: 2px;
flex-shrink: 0;
}
.q-brilliant .mv-badge,
.mv-badge.q-brilliant {
color: #5ee5a1;
background: rgba(94, 229, 161, 0.15);
}
.q-best .mv-badge,
.mv-badge.q-best {
color: #5ee5a1;
background: rgba(94, 229, 161, 0.1);
}
.q-inaccuracy .mv-badge,
.mv-badge.q-inaccuracy {
color: #ffb13a;
background: rgba(255, 177, 58, 0.15);
}
.q-mistake .mv-badge,
.mv-badge.q-mistake {
color: #ff7a7a;
background: rgba(255, 122, 122, 0.15);
}
.q-blunder .mv-badge,
.mv-badge.q-blunder {
color: #ff4444;
background: rgba(255, 68, 68, 0.18);
}
@@ -0,0 +1,48 @@
@if (moves.length === 0) {
<div class="empty">No annotated moves yet.</div>
} @else {
<div class="move-grid" role="list">
@for (pair of pairs; track $index) {
<div class="mv-num" role="presentation">{{ $index + 1 }}</div>
<div
class="mv"
[class.active]="isWhiteActive($index)"
[class]="qualityClass(pair.white)"
role="listitem"
tabindex="0"
(click)="selectWhite($index)"
(keydown.enter)="selectWhite($index)"
>
<span class="mv-san">{{ pair.white?.san ?? '' }}</span>
@if (qualityLabel(pair.white)) {
<span class="mv-badge" [class]="qualityClass(pair.white)">{{
qualityLabel(pair.white)
}}</span>
}
</div>
<div
class="mv"
[class.active]="isBlackActive($index)"
[class.mv-empty]="!pair.black"
[class]="qualityClass(pair.black)"
role="listitem"
[attr.tabindex]="pair.black ? 0 : null"
(click)="selectBlack($index, pair.black)"
(keydown.enter)="selectBlack($index, pair.black)"
>
@if (pair.black) {
<span class="mv-san">{{ pair.black.san }}</span>
@if (qualityLabel(pair.black)) {
<span class="mv-badge" [class]="qualityClass(pair.black)">{{
qualityLabel(pair.black)
}}</span>
}
} @else {
<span class="mv-san mv-placeholder"></span>
}
</div>
}
</div>
}
@@ -0,0 +1,83 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { AnnotatedMove, MoveQuality } from '../../models/analysis.models';
interface AnnotatedPair {
white: AnnotatedMove | null;
black: AnnotatedMove | null;
}
const QUALITY_LABELS: Record<MoveQuality, string> = {
brilliant: '!!',
best: '!',
good: '',
inaccuracy: '?!',
mistake: '?',
blunder: '??',
};
const QUALITY_CLASSES: Record<MoveQuality, string> = {
brilliant: 'q-brilliant',
best: 'q-best',
good: 'q-good',
inaccuracy: 'q-inaccuracy',
mistake: 'q-mistake',
blunder: 'q-blunder',
};
@Component({
selector: 'app-annotated-move-list',
standalone: true,
imports: [],
templateUrl: './annotated-move-list.component.html',
styleUrl: './annotated-move-list.component.css',
})
export class AnnotatedMoveListComponent {
@Input({ required: true }) moves: AnnotatedMove[] = [];
@Input() activePly: number | null = null;
@Output() plySelected = new EventEmitter<number>();
get pairs(): AnnotatedPair[] {
const result: AnnotatedPair[] = [];
for (let i = 0; i < this.moves.length; i += 2) {
result.push({
white: this.moves[i] ?? null,
black: this.moves[i + 1] ?? null,
});
}
return result;
}
qualityLabel(move: AnnotatedMove | null): string {
if (!move?.quality) return '';
return QUALITY_LABELS[move.quality] ?? '';
}
qualityClass(move: AnnotatedMove | null): string {
if (!move?.quality) return '';
return QUALITY_CLASSES[move.quality] ?? '';
}
isWhiteActive(pairIndex: number): boolean {
return this.activePly === pairIndex * 2;
}
isBlackActive(pairIndex: number): boolean {
return this.activePly === pairIndex * 2 + 1;
}
selectWhite(pairIndex: number): void {
this.plySelected.emit(pairIndex * 2);
}
selectBlack(pairIndex: number, black: AnnotatedMove | null): void {
if (!black) return;
this.plySelected.emit(pairIndex * 2 + 1);
}
formatEval(move: AnnotatedMove | null): string {
if (!move || move.evalAfter === null) return '';
const v = move.evalAfter;
const sign = v > 0 ? '+' : '';
return `${sign}${v.toFixed(2)}`;
}
}
@@ -0,0 +1,53 @@
:host {
display: block;
width: 100%;
}
.timeline-wrap {
width: 100%;
overflow: hidden;
}
.timeline-svg {
display: block;
width: 100%;
height: 80px;
}
.midline {
stroke: rgba(255, 255, 255, 0.12);
stroke-width: 1;
stroke-dasharray: 4 4;
}
.area-white {
fill: rgba(255, 255, 255, 0.18);
}
.area-black {
fill: rgba(20, 20, 30, 0.55);
}
.eval-line {
fill: none;
stroke: var(--nc-neon, #ff45c8);
stroke-width: 1.5;
stroke-linejoin: round;
stroke-linecap: round;
}
.active-marker {
stroke: var(--nc-warning, #ffb13a);
stroke-width: 1.5;
stroke-dasharray: 3 3;
opacity: 0.8;
}
.empty {
font-size: 11px;
color: rgba(255, 255, 255, 0.4);
padding: 12px 0;
font-family: var(--nc-mono, monospace);
letter-spacing: 0.06em;
text-align: center;
}
@@ -0,0 +1,53 @@
@if (points.length === 0) {
<div class="empty">No evaluation data yet.</div>
} @else {
<div class="timeline-wrap" role="img" aria-label="Evaluation timeline">
<svg
class="timeline-svg"
[attr.viewBox]="'0 0 ' + svgWidth + ' ' + svgHeight"
preserveAspectRatio="none"
>
<!-- Midline (balanced) -->
<line
x1="0"
[attr.y1]="svgHeight / 2"
[attr.x2]="svgWidth"
[attr.y2]="svgHeight / 2"
class="midline"
/>
<!-- White-advantage fill (clip above midline) -->
<defs>
<clipPath id="clip-white">
<rect x="0" y="0" [attr.width]="svgWidth" [attr.height]="svgHeight / 2" />
</clipPath>
<clipPath id="clip-black">
<rect
x="0"
[attr.y]="svgHeight / 2"
[attr.width]="svgWidth"
[attr.height]="svgHeight / 2"
/>
</clipPath>
</defs>
<polygon [attr.points]="polylineWhite" class="area-white" clip-path="url(#clip-white)" />
<polygon [attr.points]="polylineBlack" class="area-black" clip-path="url(#clip-black)" />
<!-- Eval line -->
<polyline [attr.points]="evalPolyline" class="eval-line" />
<!-- Active ply marker -->
@if (activeX() !== null) {
<line
[attr.x1]="activeX()"
y1="0"
[attr.x2]="activeX()"
[attr.y2]="svgHeight"
class="active-marker"
/>
}
</svg>
</div>
}
@@ -0,0 +1,73 @@
import { Component, Input, OnChanges } from '@angular/core';
import { AnnotatedMove } from '../../models/analysis.models';
interface TimelinePoint {
x: number;
y: number;
eval: number;
san: string;
plyIndex: number;
}
const CLAMP = 5; // clamp eval to ±5 pawns for display
const HEIGHT = 80;
const WIDTH = 600;
@Component({
selector: 'app-eval-timeline',
standalone: true,
imports: [],
templateUrl: './eval-timeline.component.html',
styleUrl: './eval-timeline.component.css',
})
export class EvalTimelineComponent implements OnChanges {
@Input({ required: true }) moves: AnnotatedMove[] = [];
@Input() activePly: number | null = null;
points: TimelinePoint[] = [];
evalPolyline = '';
polylineWhite = '';
polylineBlack = '';
svgWidth = WIDTH;
svgHeight = HEIGHT;
ngOnChanges(): void {
this.buildChart();
}
activeX(): number | null {
if (this.activePly === null) return null;
const pt = this.points[this.activePly];
return pt ? pt.x : null;
}
private buildChart(): void {
if (this.moves.length === 0) {
this.points = [];
this.evalPolyline = '';
this.polylineWhite = '';
this.polylineBlack = '';
return;
}
const total = this.moves.length;
this.points = this.moves.map((m, i) => {
const evalValue = m.evalAfter ?? 0;
const clamped = Math.max(-CLAMP, Math.min(CLAMP, evalValue));
const x = (i / Math.max(total - 1, 1)) * WIDTH;
// y=0 => white winning (top), y=HEIGHT => black winning (bottom)
const y = ((CLAMP - clamped) / (CLAMP * 2)) * HEIGHT;
return { x, y, eval: evalValue, san: m.san, plyIndex: i };
});
const coordStr = this.points.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
this.evalPolyline = coordStr;
const mid = HEIGHT / 2;
const first = this.points[0];
const last = this.points[this.points.length - 1];
this.polylineWhite = `${first.x.toFixed(1)},${mid} ${coordStr} ${last.x.toFixed(1)},${mid}`;
this.polylineBlack = this.polylineWhite;
}
}
@@ -19,6 +19,7 @@
</button>
<button type="button" class="nc-link" (click)="goToTournaments()">Tournaments</button>
<button type="button" class="nc-link" (click)="goToBots()">Bots</button>
<button type="button" class="nc-link" (click)="goToAnalysis()">Analysis</button>
</div>
}
@@ -197,6 +197,12 @@ export class ToolbarComponent implements OnInit {
void this.router.navigate(['/bots']);
}
goToAnalysis(): void {
this.profileOpen = false;
this.notifOpen = false;
void this.router.navigate(['/analysis']);
}
onLoginSuccess(): void {
this.closeLoginDialog();
}
+35
View File
@@ -0,0 +1,35 @@
export interface AnalysisRequest {
fen: string;
depth: number;
}
export interface RawAnalysisResponse {
fen: string;
evaluation: number;
depth: number;
bestMove: string;
mate: number | null;
continuationMoves: string[];
}
export interface AnalysisResponse {
eval: number;
winChance: number;
depth: number;
bestMove: string;
mate: number | null;
continuations: string[];
}
export type MoveQuality = 'brilliant' | 'best' | 'good' | 'inaccuracy' | 'mistake' | 'blunder';
export interface AnnotatedMove {
san: string;
fen: string;
evalBefore: number | null;
evalAfter: number | null;
quality: MoveQuality | null;
bestMove: string | null;
winChanceBefore: number | null;
winChanceAfter: number | null;
}
@@ -0,0 +1,542 @@
/* ============================================================
DESIGN TOKENS — dark mode (default)
============================================================ */
:host {
--nc-neon: #ff45c8;
--nc-neon-soft: rgba(255, 69, 200, 0.55);
--nc-bg: #06060d;
--nc-surface: rgba(20, 17, 42, 0.6);
--nc-surface-solid: rgba(10, 8, 22, 0.95);
--nc-text: #fff;
--nc-text-muted: rgba(255, 255, 255, 0.65);
--nc-text-dim: rgba(255, 255, 255, 0.45);
--nc-border: rgba(255, 255, 255, 0.08);
--nc-border-strong: rgba(255, 255, 255, 0.15);
--nc-warning: #ffb13a;
--nc-warning-soft: rgba(255, 177, 58, 0.4);
--nc-danger: #ff7a7a;
--nc-danger-bg: rgba(255, 122, 122, 0.08);
--nc-danger-soft: rgba(255, 122, 122, 0.3);
--nc-success: #5ee5a1;
--nc-clock-bg: rgba(0, 0, 0, 0.4);
--nc-btn-bg: rgba(255, 255, 255, 0.03);
--nc-btn-hover-bg: rgba(255, 255, 255, 0.07);
--nc-sans: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--nc-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace;
}
:host-context(html:not([data-theme='dark'])) {
--nc-neon: #ff3dbb;
--nc-neon-soft: rgba(255, 61, 187, 0.55);
--nc-bg: transparent;
--nc-surface: rgba(26, 24, 56, 0.72);
--nc-surface-solid: rgba(26, 24, 56, 0.97);
--nc-text: #fff;
--nc-text-muted: rgba(255, 255, 255, 0.72);
--nc-text-dim: rgba(255, 255, 255, 0.45);
--nc-border: rgba(255, 255, 255, 0.1);
--nc-border-strong: rgba(255, 255, 255, 0.18);
--nc-btn-bg: rgba(255, 255, 255, 0.05);
--nc-btn-hover-bg: rgba(255, 255, 255, 0.1);
}
/* ============================================================
SHELL
============================================================ */
.analysis-shell {
min-height: 100dvh;
background: var(--nc-bg);
font-family: var(--nc-sans);
color: var(--nc-text);
position: relative;
}
.analysis-shell::before {
content: '';
position: fixed;
inset: 0;
background:
radial-gradient(ellipse 80% 50% at 20% 100%, rgba(74, 41, 98, 0.12), transparent 60%),
radial-gradient(ellipse 60% 40% at 90% 0%, rgba(41, 74, 98, 0.18), transparent 60%);
pointer-events: none;
z-index: 0;
}
/* ============================================================
PAGE CONTAINER
============================================================ */
.page {
position: relative;
z-index: 1;
max-width: 1320px;
margin: 0 auto;
padding: 28px 32px 60px;
}
/* ============================================================
BREADCRUMB
============================================================ */
.crumb {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 18px;
font-family: var(--nc-mono);
font-size: 11px;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.crumb-link {
color: var(--nc-text-dim);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 6px;
transition: color 0.15s;
}
.crumb-link:hover {
color: var(--nc-neon);
}
.crumb-sep {
color: var(--nc-text-dim);
opacity: 0.5;
}
.crumb-current {
color: var(--nc-text-muted);
}
/* ============================================================
PAGE HEADER
============================================================ */
.page-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
margin-bottom: 28px;
padding-bottom: 20px;
border-bottom: 1px solid var(--nc-border);
}
.page-title h1 {
margin: 0 0 4px;
font-size: 26px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--nc-text);
}
.page-subtitle {
margin: 0;
font-family: var(--nc-mono);
font-size: 11px;
color: var(--nc-text-dim);
letter-spacing: 0.06em;
}
/* ============================================================
ERROR
============================================================ */
.state-error {
padding: 14px 16px;
margin-bottom: 20px;
color: var(--nc-danger);
background: var(--nc-danger-bg);
border: 1px solid var(--nc-danger-soft);
font-size: 13px;
}
/* ============================================================
INPUT SECTION
============================================================ */
.input-section {
background: var(--nc-surface);
border: 1px solid var(--nc-border);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
padding: 20px;
margin-bottom: 28px;
display: flex;
flex-direction: column;
gap: 14px;
}
.mode-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--nc-border);
padding-bottom: 12px;
}
.mode-tab {
background: transparent;
border: 1px solid transparent;
color: var(--nc-text-muted);
font-family: var(--nc-mono);
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
padding: 6px 14px;
cursor: pointer;
transition:
color 0.15s,
border-color 0.15s;
}
.mode-tab:hover {
color: var(--nc-text);
}
.mode-tab.active {
color: var(--nc-neon);
border-color: var(--nc-neon-soft);
}
.input-row {
display: flex;
gap: 8px;
}
.text-input {
flex: 1;
background: var(--nc-clock-bg);
border: 1px solid var(--nc-border);
color: var(--nc-text);
font-family: var(--nc-mono);
font-size: 12px;
padding: 9px 12px;
letter-spacing: 0.04em;
outline: none;
transition: border-color 0.15s;
}
.text-input:focus {
border-color: var(--nc-neon-soft);
}
.text-input::placeholder {
color: var(--nc-text-dim);
}
.pgn-col {
display: flex;
flex-direction: column;
gap: 8px;
}
.text-area {
background: var(--nc-clock-bg);
border: 1px solid var(--nc-border);
color: var(--nc-text);
font-family: var(--nc-mono);
font-size: 12px;
padding: 10px 12px;
letter-spacing: 0.04em;
resize: vertical;
outline: none;
transition: border-color 0.15s;
line-height: 1.5;
}
.text-area:focus {
border-color: var(--nc-neon-soft);
}
.text-area::placeholder {
color: var(--nc-text-dim);
}
.depth-row {
display: flex;
align-items: center;
gap: 10px;
padding-top: 6px;
border-top: 1px solid var(--nc-border);
}
.depth-label {
font-family: var(--nc-mono);
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--nc-text-dim);
}
.depth-input {
width: 56px;
background: var(--nc-clock-bg);
border: 1px solid var(--nc-border);
color: var(--nc-text);
font-family: var(--nc-mono);
font-size: 13px;
padding: 7px 10px;
outline: none;
transition: border-color 0.15s;
text-align: center;
}
.depth-input:focus {
border-color: var(--nc-neon-soft);
}
/* ============================================================
BUTTONS
============================================================ */
.btn {
font-family: var(--nc-sans);
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
font-weight: 600;
padding: 9px 14px;
cursor: pointer;
border: 1px solid var(--nc-border-strong);
background: var(--nc-btn-bg);
color: var(--nc-text);
display: inline-flex;
align-items: center;
gap: 6px;
transition:
background 0.15s,
border-color 0.15s;
flex-shrink: 0;
}
.btn:hover:not([disabled]) {
background: var(--nc-btn-hover-bg);
border-color: var(--nc-text-muted);
}
.btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--nc-neon) !important;
color: #fff !important;
border-color: var(--nc-neon) !important;
box-shadow: 0 0 14px rgba(255, 69, 200, 0.3);
font-weight: 700;
}
.btn-primary:hover:not([disabled]) {
box-shadow: 0 0 20px rgba(255, 69, 200, 0.5);
}
.btn-analyse {
margin-left: auto;
background: rgba(255, 69, 200, 0.12);
color: var(--nc-neon);
border-color: var(--nc-neon-soft);
}
.btn-analyse:hover:not([disabled]) {
background: rgba(255, 69, 200, 0.2);
}
/* ============================================================
MAIN GRID
============================================================ */
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
gap: 28px;
align-items: start;
}
/* ============================================================
BOARD COLUMN
============================================================ */
.board-col {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 520px;
width: 100%;
margin: 0 auto;
}
.board-wrap {
container-type: size;
aspect-ratio: 1 / 1;
padding: 10px;
background: var(--nc-surface);
border: 1px solid var(--nc-border);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
box-shadow:
0 8px 40px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 69, 200, 0.06);
}
.nav-bar {
display: flex;
gap: 4px;
justify-content: center;
padding: 6px 0;
}
.icon-btn {
background: var(--nc-btn-bg);
border: 1px solid var(--nc-border);
color: var(--nc-text-muted);
padding: 8px 10px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition:
color 0.15s,
background 0.15s;
}
.icon-btn:hover {
color: var(--nc-neon);
background: var(--nc-btn-hover-bg);
}
/* ============================================================
SIDE COLUMN
============================================================ */
.side {
display: flex;
flex-direction: column;
gap: 12px;
}
.side-card {
background: var(--nc-surface);
border: 1px solid var(--nc-border);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.side-card-summary {
list-style: none;
cursor: pointer;
padding: 13px 16px;
display: flex;
align-items: center;
gap: 8px;
user-select: none;
}
.side-card-summary::-webkit-details-marker {
display: none;
}
.side-card-title {
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--nc-text-muted);
font-weight: 600;
flex: 1;
}
.side-card-meta {
font-family: var(--nc-mono);
font-size: 10px;
color: var(--nc-text-dim);
letter-spacing: 0.08em;
}
.chev {
color: var(--nc-text-dim);
flex-shrink: 0;
transition: transform 0.2s;
}
.side-card[open] .chev {
transform: rotate(180deg);
}
.side-card[open] .side-card-summary {
border-bottom: 1px solid var(--nc-border);
}
.side-card-body {
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.timeline-body {
padding: 10px 16px;
}
/* ============================================================
EVAL GRID
============================================================ */
.eval-grid {
gap: 8px;
}
.eval-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.eval-row--col {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.eval-label {
font-family: var(--nc-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--nc-text-dim);
flex-shrink: 0;
}
.eval-value {
font-size: 14px;
font-weight: 600;
color: var(--nc-text);
}
.eval-value.mono {
font-family: var(--nc-mono);
font-size: 12px;
}
.eval-value.positive {
color: var(--nc-success);
}
.eval-value.negative {
color: var(--nc-danger);
}
.continuation {
font-size: 11px;
color: var(--nc-text-muted);
line-height: 1.6;
word-break: break-all;
}
/* ============================================================
RESPONSIVE
============================================================ */
@media (max-width: 1100px) {
.layout {
grid-template-columns: 1fr;
}
.board-col {
max-width: 560px;
margin: 0 auto;
}
}
@media (max-width: 640px) {
.page {
padding: 16px 16px 48px;
}
.page-title h1 {
font-size: 20px;
}
}
@@ -0,0 +1,355 @@
<div class="analysis-shell">
<div class="page">
<!-- Breadcrumb -->
<nav class="crumb" aria-label="Breadcrumb">
<a routerLink="/" class="crumb-link">
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
Back to lobby
</a>
<span class="crumb-sep">/</span>
<span class="crumb-current">Analysis</span>
</nav>
<!-- Page header -->
<header class="page-header">
<div class="page-title">
<h1>Chess Analysis</h1>
<p class="page-subtitle">Analyse positions, games or custom PGN with the engine</p>
</div>
</header>
<!-- Error -->
@if (errorMessage) {
<div class="state-error">{{ errorMessage }}</div>
}
<!-- Input section -->
<section class="input-section">
<!-- Mode tabs -->
<div class="mode-tabs" role="tablist" aria-label="Analysis input mode">
<button
class="mode-tab"
[class.active]="inputMode === 'fen'"
role="tab"
(click)="setInputMode('fen')"
>
FEN
</button>
<button
class="mode-tab"
[class.active]="inputMode === 'pgn'"
role="tab"
(click)="setInputMode('pgn')"
>
PGN
</button>
<button
class="mode-tab"
[class.active]="inputMode === 'game'"
role="tab"
(click)="setInputMode('game')"
>
Game ID
</button>
</div>
<!-- FEN input -->
@if (inputMode === 'fen') {
<div class="input-row">
<input
id="fen-input"
type="text"
class="text-input"
placeholder="Paste FEN string here…"
autocomplete="off"
[(ngModel)]="fenInput"
(keydown.enter)="loadFen()"
/>
<button
class="btn btn-primary"
type="button"
(click)="loadFen()"
[disabled]="!fenInput.trim()"
>
Load
</button>
</div>
}
<!-- PGN input -->
@if (inputMode === 'pgn') {
<div class="pgn-col">
<textarea
id="pgn-input"
class="text-area"
rows="5"
placeholder="Paste PGN here…"
[(ngModel)]="pgnInput"
></textarea>
<button
class="btn btn-primary"
type="button"
(click)="loadPgn()"
[disabled]="!pgnInput.trim() || loading"
>
@if (loading) {
Loading…
} @else {
Import PGN
}
</button>
</div>
}
<!-- Game ID input -->
@if (inputMode === 'game') {
<div class="input-row">
<input
id="gameid-input"
type="text"
class="text-input"
placeholder="Game ID"
autocomplete="off"
[(ngModel)]="fenInput"
(keydown.enter)="loadGame(fenInput)"
/>
<button
class="btn btn-primary"
type="button"
(click)="loadGame(fenInput)"
[disabled]="!fenInput.trim() || loading"
>
@if (loading) {
Loading…
} @else {
Load game
}
</button>
</div>
}
<!-- Depth + analyse -->
<div class="depth-row">
<label class="depth-label" for="depth-input">Depth</label>
<input
id="depth-input"
type="number"
class="depth-input"
min="1"
max="18"
[(ngModel)]="depth"
/>
<button
class="btn btn-analyse"
type="button"
(click)="runAnalysis()"
[disabled]="analysing"
>
@if (analysing) {
Analysing…
} @else {
Analyse
}
</button>
</div>
</section>
<!-- Main layout: board + sidebar -->
<div class="layout">
<!-- Board column -->
<div class="board-col">
<div class="board-wrap">
<app-chess-board
[fen]="displayFen"
[selectedSquare]="null"
[highlightedSquares]="[]"
boardTheme="arabian"
/>
</div>
<!-- Navigation bar -->
@if (fenHistory.length > 1) {
<div class="nav-bar">
<button class="icon-btn" title="First position" (click)="navigateHistory('first')">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="11 17 6 12 11 7" />
<polyline points="18 17 13 12 18 7" />
</svg>
</button>
<button class="icon-btn" title="Previous" (click)="navigateHistory('prev')">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<button class="icon-btn" title="Next" (click)="navigateHistory('next')">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
<button class="icon-btn" title="Last position" (click)="navigateHistory('last')">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="13 17 18 12 13 7" />
<polyline points="6 17 11 12 6 7" />
</svg>
</button>
</div>
}
</div>
<!-- Side column -->
<aside class="side">
<!-- Single position analysis result -->
@if (positionAnalysis) {
<details class="side-card" open>
<summary class="side-card-summary">
<span class="side-card-title">Position Analysis</span>
<svg
class="chev"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</summary>
<div class="side-card-body eval-grid">
<div class="eval-row">
<span class="eval-label">Evaluation</span>
<span
class="eval-value"
[class.positive]="positionAnalysis.eval > 0"
[class.negative]="positionAnalysis.eval < 0"
>
{{ positionAnalysis.eval > 0 ? '+' : '' }}{{ positionAnalysis.eval.toFixed(2) }}
</span>
</div>
<div class="eval-row">
<span class="eval-label">Win chance</span>
<span class="eval-value">{{ (positionAnalysis.winChance * 100).toFixed(1) }}%</span>
</div>
<div class="eval-row">
<span class="eval-label">Best move</span>
<span class="eval-value mono">{{ positionAnalysis.bestMove }}</span>
</div>
<div class="eval-row">
<span class="eval-label">Depth</span>
<span class="eval-value mono">{{ positionAnalysis.depth }}</span>
</div>
@if (positionAnalysis.continuations.length > 0) {
<div class="eval-row eval-row--col">
<span class="eval-label">Continuation</span>
<span class="eval-value mono continuation">{{
positionAnalysis.continuations.join(' ')
}}</span>
</div>
}
</div>
</details>
}
<!-- Evaluation timeline -->
@if (hasAnnotations) {
<details class="side-card" open>
<summary class="side-card-summary">
<span class="side-card-title">Evaluation</span>
<svg
class="chev"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</summary>
<div class="side-card-body timeline-body">
<app-eval-timeline [moves]="annotatedMoves" [activePly]="activePly" />
</div>
</details>
<!-- Annotated moves -->
<details class="side-card" open>
<summary class="side-card-summary">
<span class="side-card-title">Moves</span>
<span class="side-card-meta">{{ annotatedMoves.length }} plies</span>
<svg
class="chev"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</summary>
<app-annotated-move-list
[moves]="annotatedMoves"
[activePly]="activePly"
(plySelected)="navigateToPly($event)"
/>
</details>
}
</aside>
</div>
</div>
</div>
@@ -0,0 +1,250 @@
import { Component, DestroyRef, inject, OnInit } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { switchMap, of, forkJoin } from 'rxjs';
import { ChessBoardComponent } from '../../components/chess-board/chess-board.component';
import { EvalTimelineComponent } from '../../components/eval-timeline/eval-timeline.component';
import { AnnotatedMoveListComponent } from '../../components/annotated-move-list/annotated-move-list.component';
import { GameApiService } from '../../services/game-api.service';
import { AnalysisService } from '../../services/analysis.service';
import { AnnotatedMove, AnalysisResponse } from '../../models/analysis.models';
import { GameFull } from '../../models/game.models';
import { getErrorMessage } from '../../core/http/error-message.util';
const ANALYSIS_DEPTH_DEFAULT = 14;
const STARTING_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
type InputMode = 'fen' | 'pgn' | 'game';
@Component({
selector: 'app-analysis',
standalone: true,
imports: [
RouterLink,
FormsModule,
ChessBoardComponent,
EvalTimelineComponent,
AnnotatedMoveListComponent,
],
templateUrl: './analysis.component.html',
styleUrl: './analysis.component.css',
})
export class AnalysisComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly gameApi = inject(GameApiService);
private readonly analysisService = inject(AnalysisService);
private readonly destroyRef = inject(DestroyRef);
// ── State ─────────────────────────────────────────────────
inputMode: InputMode = 'fen';
fenInput = '';
pgnInput = '';
depth = ANALYSIS_DEPTH_DEFAULT;
loading = false;
analysing = false;
errorMessage = '';
currentFen = STARTING_FEN;
game: GameFull | null = null;
/** FEN for each ply (index 0 = position before move 0 was played) */
fenHistory: string[] = [STARTING_FEN];
annotatedMoves: AnnotatedMove[] = [];
activePly: number | null = null;
/** Single-position analysis result (for custom FEN/PGN input) */
positionAnalysis: AnalysisResponse | null = null;
get displayFen(): string {
if (this.activePly !== null && this.fenHistory[this.activePly + 1]) {
return this.fenHistory[this.activePly + 1];
}
return this.currentFen;
}
get hasAnnotations(): boolean {
return this.annotatedMoves.length > 0;
}
// ── Lifecycle ─────────────────────────────────────────────
ngOnInit(): void {
// Support /analysis?gameId=xxx deep-link
this.route.queryParamMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
const gameId = params.get('gameId');
if (gameId) {
this.inputMode = 'game';
this.loadGame(gameId);
}
});
}
// ── Input mode ─────────────────────────────────────────────
setInputMode(mode: InputMode): void {
this.inputMode = mode;
this.errorMessage = '';
}
// ── Load FEN ──────────────────────────────────────────────
loadFen(): void {
const fen = this.fenInput.trim();
if (!fen) return;
this.reset();
this.currentFen = fen;
this.fenHistory = [fen];
this.analyseSinglePosition(fen);
}
// ── Load PGN ──────────────────────────────────────────────
loadPgn(): void {
const pgn = this.pgnInput.trim();
if (!pgn) return;
this.reset();
this.loading = true;
this.gameApi
.importPgn(pgn)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (game) => {
this.loading = false;
this.applyGame(game);
},
error: (err) => {
this.loading = false;
this.errorMessage = getErrorMessage(err, 'Could not import PGN.');
},
});
}
// ── Load game by ID ───────────────────────────────────────
loadGame(gameId: string): void {
this.reset();
this.loading = true;
this.gameApi
.getGame(gameId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (game) => {
this.loading = false;
this.applyGame(game);
},
error: (err) => {
this.loading = false;
this.errorMessage = getErrorMessage(err, 'Could not load game.');
},
});
}
// ── Run full analysis ─────────────────────────────────────
runAnalysis(): void {
if (this.fenHistory.length < 2) {
this.analyseSinglePosition(this.currentFen);
return;
}
this.analysing = true;
this.positionAnalysis = null;
this.errorMessage = '';
const sans =
this.annotatedMoves.length > 0
? this.annotatedMoves.map((m) => m.san)
: (this.game?.state.moves ?? []);
this.analysisService
.analyzeGame(sans, this.fenHistory, this.depth)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (annotated) => {
this.annotatedMoves = annotated;
this.analysing = false;
},
error: (err) => {
this.errorMessage = getErrorMessage(err, 'Analysis failed.');
this.analysing = false;
},
});
}
// ── Board navigation ──────────────────────────────────────
navigateToPly(ply: number): void {
this.activePly = ply;
}
navigateHistory(direction: 'first' | 'prev' | 'next' | 'last'): void {
const total = this.fenHistory.length - 1;
const current = this.activePly ?? total;
let next: number;
switch (direction) {
case 'first':
next = 0;
break;
case 'prev':
next = Math.max(0, current - 1);
break;
case 'next':
next = Math.min(total, current + 1);
break;
default:
next = total;
break;
}
this.activePly = next >= total ? null : next;
}
// ── Private helpers ───────────────────────────────────────
private reset(): void {
this.errorMessage = '';
this.annotatedMoves = [];
this.positionAnalysis = null;
this.activePly = null;
this.game = null;
this.fenHistory = [STARTING_FEN];
this.currentFen = STARTING_FEN;
}
private applyGame(game: GameFull): void {
this.game = game;
this.currentFen = game.state.fen;
this.fenHistory = [game.state.fen];
this.annotatedMoves = game.state.moves.map((san) => ({
san,
fen: game.state.fen,
evalBefore: null,
evalAfter: null,
quality: null,
bestMove: null,
winChanceBefore: 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 {
this.analysing = true;
this.analysisService
.analyzePosition(fen, this.depth)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (result) => {
this.positionAnalysis = result;
this.analysing = false;
},
error: (err) => {
this.errorMessage = getErrorMessage(err, 'Analysis failed.');
this.analysing = false;
},
});
}
}
@@ -64,6 +64,18 @@
}
.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 {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 8px; border: none;
@@ -327,3 +339,74 @@
color: var(--nc-success); letter-spacing: 0.06em; text-transform: uppercase;
}
.pairing-ongoing svg { animation: pulse 1.4s ease-in-out infinite; }
/* Official bot join section */
.join-divider {
display: flex; align-items: center; gap: 10px;
margin: 20px 0 14px;
}
.join-divider::before, .join-divider::after {
content: ''; flex: 1; height: 1px; background: var(--nc-border);
}
.join-divider-label {
font-size: 10px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.08em; color: var(--nc-text-dim); white-space: nowrap;
}
.official-bot-grid {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;
margin-bottom: 4px;
}
.official-bot-btn {
display: flex; align-items: center; justify-content: center; gap: 6px;
padding: 9px 4px; border-radius: 8px; border: 1px solid var(--nc-border);
background: var(--nc-surface); font-size: 12px; font-weight: 700;
cursor: pointer; transition: border-color 0.15s, background 0.15s, color 0.15s;
color: var(--nc-text-muted); text-transform: capitalize;
}
.official-bot-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.official-btn-easy:hover:not(:disabled) { border-color: var(--nc-success); color: var(--nc-success); background: rgba(94,229,161,0.07); }
.official-btn-medium:hover:not(:disabled) { border-color: var(--nc-warn); color: var(--nc-warn); background: rgba(255,209,102,0.07); }
.official-btn-hard:hover:not(:disabled) { border-color: var(--nc-neon); color: var(--nc-neon); background: rgba(255,69,200,0.07); }
.official-btn-expert:hover:not(:disabled) { border-color: var(--nc-danger); color: var(--nc-danger); background: rgba(255,122,122,0.07); }
/* Servers dialog */
.servers-dialog { max-width: 480px; }
.servers-list {
display: flex; flex-direction: column; gap: 6px;
margin-bottom: 20px;
}
.server-row {
display: flex; align-items: center; gap: 10px;
padding: 10px 12px; border-radius: 8px;
border: 1px solid var(--nc-border); background: var(--nc-surface);
}
.server-info { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.server-label { font-size: 13px; font-weight: 600; color: var(--nc-text); }
.server-url {
font-size: 11px; color: var(--nc-text-dim);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
font-family: monospace;
}
.server-remove-btn {
background: none; border: none; cursor: pointer;
color: var(--nc-text-dim); padding: 4px; border-radius: 6px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; transition: color 0.15s, background 0.15s;
font-size: 13px;
}
.server-remove-btn:hover:not(:disabled) { color: var(--nc-danger); background: rgba(255,122,122,0.1); }
.server-remove-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.server-add-form {
border-top: 1px solid var(--nc-border);
padding-top: 16px;
display: flex; flex-direction: column; gap: 0;
}
.server-add-heading {
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.08em; color: var(--nc-text-muted);
margin: 0 0 14px;
}
@@ -16,15 +16,25 @@
<header class="page-header">
<div class="page-title-row">
<h1 class="page-title">Tournaments</h1>
@if (currentUser) {
<button type="button" class="btn-new" (click)="openCreateDialog()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
New tournament
</button>
}
<div class="page-actions">
@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()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
New tournament
</button>
}
</div>
</div>
<div class="tabs" role="tablist">
<button type="button" class="tab-btn" [class.active]="tab === 'started'" (click)="setTab('started')">
@@ -192,32 +202,127 @@
<span class="dialog-brand">Join with a bot</span>
<button type="button" class="dialog-close" (click)="closeJoinDialog()">×</button>
</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) {
<div class="dialog-loading"><span class="pulse"></span>Loading bots…</div>
} @else if (userBots.length === 0) {
<p class="join-empty">You have no bots yet. Go to <strong>Bots</strong> in the nav to create one first.</p>
} @else {
<div class="bot-pick-list">
@for (bot of userBots; track bot.id) {
<button type="button" class="bot-pick-row"
[disabled]="!!joiningBotId"
(click)="joinWithBot(bot)">
<span class="bot-pick-avatar">{{ bot.name.charAt(0).toUpperCase() }}</span>
<span class="bot-pick-name">{{ bot.name }}</span>
<span class="bot-pick-rating">{{ bot.rating }}</span>
@if (joiningBotId === bot.id) {
<span class="bot-pick-spinner"></span>
}
</button>
@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 {
<div class="bot-pick-list">
@for (bot of userBots; track bot.id) {
<button type="button" class="bot-pick-row"
[disabled]="!!joiningBotId || !!joiningOfficialDifficulty"
(click)="joinWithBot(bot)">
<span class="bot-pick-avatar">{{ bot.name.charAt(0).toUpperCase() }}</span>
<span class="bot-pick-name">{{ bot.name }}</span>
<span class="bot-pick-rating">{{ bot.rating }}</span>
@if (joiningBotId === bot.id) {
<span class="bot-pick-spinner"></span>
}
</button>
}
</div>
}
@if (joinError) {
<div class="dialog-error">{{ joinError }}</div>
}
<div class="join-divider">
<span class="join-divider-label">or join with an official bot</span>
</div>
@if (officialBots.length === 0) {
<p class="join-empty">No official bots are available. The official-bots engine service must be running to register them.</p>
} @else {
<div class="official-bot-grid">
@for (d of officialDifficulties; track d) {
<button type="button" class="official-bot-btn"
[class]="'official-btn-' + d"
[disabled]="!!joiningOfficialDifficulty || !!joiningBotId"
(click)="joinWithOfficialBot(d)">
@if (joiningOfficialDifficulty === d) {
<span class="pulse"></span>
}
{{ d | titlecase }}
</button>
}
</div>
}
@if (officialJoinError) {
<div class="dialog-error">{{ officialJoinError }}</div>
}
}
</div>
</div>
}
@if (showServersDialog) {
<div class="dialog-overlay" (click)="closeServersDialog()">
<div class="dialog-card servers-dialog" (click)="$event.stopPropagation()">
<div class="dialog-head">
<span class="dialog-brand">Tournament servers</span>
<button type="button" class="dialog-close" (click)="closeServersDialog()">×</button>
</div>
<p class="join-hint">External tournament servers aggregated into this view. Tournaments from all servers appear in the list.</p>
@if (serversLoading) {
<div class="dialog-loading"><span class="pulse"></span>Loading servers…</div>
} @else if (servers.length === 0) {
<p class="join-empty">No external servers registered yet.</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>
<button type="button" class="server-remove-btn"
[disabled]="removingServerId === s.id"
(click)="removeServer(s.id)"
title="Remove server">
@if (removingServerId === s.id) { … } @else {
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6"/><path d="M14 11v6"/>
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
}
</button>
</div>
}
</div>
}
@if (joinError) {
<div class="dialog-error">{{ joinError }}</div>
}
<div class="server-add-form">
<h4 class="server-add-heading">Add server</h4>
<div class="dialog-field">
<label class="dialog-label">Label</label>
<input type="text" class="dialog-input" [(ngModel)]="newServerLabel"
placeholder="e.g. Local Dev Server" />
</div>
<div class="dialog-field">
<label class="dialog-label">URL</label>
<input type="url" class="dialog-input" [(ngModel)]="newServerUrl"
placeholder="http://host:8089" />
</div>
@if (addServerError) {
<div class="dialog-error">{{ addServerError }}</div>
}
<div class="dialog-actions">
<button type="button" class="btn-ghost" (click)="closeServersDialog()">Close</button>
<button type="button" class="btn-primary"
[disabled]="addingServer || !newServerLabel.trim() || !newServerUrl.trim()"
(click)="addServer()">
{{ addingServer ? 'Adding…' : 'Add' }}
</button>
</div>
</div>
</div>
</div>
}
@@ -1,11 +1,14 @@
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 { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { forkJoin } from 'rxjs';
import { TournamentService } from '../../services/tournament.service';
import { AuthService } from '../../services/auth.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 { Tournament, TournamentResult, RoundPairings } from '../../models/tournament.models';
import { CurrentUser } from '../../models/auth.models';
@@ -15,7 +18,7 @@ type StatusTab = 'started' | 'created' | 'finished';
@Component({
selector: 'app-tournaments',
standalone: true,
imports: [CommonModule, RouterLink, ReactiveFormsModule],
imports: [CommonModule, RouterLink, FormsModule, ReactiveFormsModule, TitleCasePipe],
templateUrl: './tournaments.component.html',
styleUrl: './tournaments.component.css'
})
@@ -25,6 +28,8 @@ export class TournamentsComponent implements OnInit {
private readonly authService = inject(AuthService);
private readonly fb = inject(FormBuilder);
private readonly botService = inject(BotService);
private readonly officialBotService = inject(OfficialBotService);
private readonly tournamentServerService = inject(TournamentServerService);
private readonly router = inject(Router);
loading = true;
@@ -54,10 +59,24 @@ export class TournamentsComponent implements OnInit {
joinDialogTournamentId: string | null = null;
userBots: Bot[] = [];
officialBots: Bot[] = [];
botsLoading = false;
joiningBotId: 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;
newServerLabel = '';
newServerUrl = '';
addingServer = false;
addServerError: string | null = null;
removingServerId: string | null = null;
ngOnInit(): void {
this.authService.currentUser$
.pipe(takeUntilDestroyed(this.destroyRef))
@@ -158,52 +177,128 @@ export class TournamentsComponent implements OnInit {
this.joinDialogTournamentId = tournamentId;
this.joinError = null;
this.botsLoading = true;
this.botService.list()
forkJoin({ user: this.botService.list(), official: this.botService.listOfficial() })
.pipe(takeUntilDestroyed(this.destroyRef))
.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; }
});
}
closeJoinDialog(): void {
this.joinDialogTournamentId = null;
this.userBots = [];
this.officialBots = [];
this.joiningBotId = 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 {
if (!this.joinDialogTournamentId || this.joiningBotId) return;
this.joiningBotId = bot.id;
this.joinError = null;
this.botService.rotateToken(bot.id).subscribe({
next: token => {
this.tournamentService.joinWithBotToken(this.joinDialogTournamentId!, token).subscribe({
next: () => {
this.joiningBotId = null;
const tid = this.joinDialogTournamentId!;
this.closeJoinDialog();
this.tournamentService.get(tid)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(updated => {
this.created = this.created.map(x => x.id === tid ? updated : x);
this.started = this.started.map(x => x.id === tid ? updated : x);
if (this.selectedTournament?.id === tid) this.selectedTournament = updated;
});
},
error: err => {
this.joiningBotId = null;
this.joinError = err.error?.message ?? err.error?.error ?? 'Failed to join tournament.';
}
});
},
error: () => {
this.tournamentService.join(this.joinDialogTournamentId, bot.id, bot.name).subscribe({
next: () => {
this.joiningBotId = null;
this.joinError = 'Failed to get bot token.';
const tid = this.joinDialogTournamentId!;
this.closeJoinDialog();
this.tournamentService.get(tid)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(updated => {
this.created = this.created.map(x => x.id === tid ? updated : x);
this.started = this.started.map(x => x.id === tid ? updated : x);
if (this.selectedTournament?.id === tid) this.selectedTournament = updated;
});
},
error: err => {
this.joiningBotId = null;
this.joinError = err.error?.message ?? err.error?.error ?? 'Failed to join tournament.';
}
});
}
openServersDialog(): void {
this.newServerLabel = '';
this.newServerUrl = '';
this.addServerError = null;
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;
}
addServer(): void {
const label = this.newServerLabel.trim();
const url = this.newServerUrl.trim();
if (!label || !url || this.addingServer) return;
this.addingServer = true;
this.addServerError = null;
this.tournamentServerService.register(label, url).subscribe({
next: server => {
this.addingServer = false;
this.servers = [...this.servers, server];
this.newServerLabel = '';
this.newServerUrl = '';
this.loadTournaments();
},
error: err => {
this.addingServer = false;
this.addServerError = err.error?.error ?? 'Failed to add server.';
}
});
}
removeServer(id: string): void {
if (this.removingServerId) return;
this.removingServerId = id;
this.tournamentServerService.remove(id).subscribe({
next: () => {
this.removingServerId = null;
this.servers = this.servers.filter(s => s.id !== id);
this.loadTournaments();
},
error: () => { this.removingServerId = null; }
});
}
private loadTournaments(): void {
this.tournamentService.list()
.pipe(takeUntilDestroyed(this.destroyRef))
+102
View File
@@ -0,0 +1,102 @@
import { Injectable, inject } from '@angular/core';
import { Observable, forkJoin, of } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { GameApiService } from './game-api.service';
import { AnnotatedMove, AnalysisResponse, MoveQuality } from '../models/analysis.models';
const DEFAULT_DEPTH = 14;
@Injectable({ providedIn: 'root' })
export class AnalysisService {
private readonly gameApi = inject(GameApiService);
analyzePosition(fen: string, depth = DEFAULT_DEPTH): Observable<AnalysisResponse> {
return this.gameApi.analyzePosition({ fen, depth });
}
/**
* Analyse a sequence of FEN positions (one per ply) and return annotated moves.
* fenHistory[0] is the starting position; fenHistory[n] is reached after move san[n-1].
*/
analyzeGame(
sans: string[],
fenHistory: string[],
depth = DEFAULT_DEPTH,
): Observable<AnnotatedMove[]> {
if (sans.length === 0 || fenHistory.length < 2) {
return of([]);
}
const requests = fenHistory.map((fen) => this.gameApi.analyzePosition({ fen, depth }));
return forkJoin(requests).pipe(
map((results) => this.buildAnnotations(sans, fenHistory, results)),
);
}
private buildAnnotations(
sans: string[],
fenHistory: string[],
results: AnalysisResponse[],
): AnnotatedMove[] {
return sans.map((san, i) => {
const evalBefore = results[i]?.eval ?? null;
const winChanceBefore = results[i]?.winChance ?? null;
const evalAfter = results[i + 1]?.eval ?? null;
const winChanceAfter = results[i + 1]?.winChance ?? null;
const bestMove = results[i]?.bestMove ?? null;
const quality = this.classifyQuality(
evalBefore,
evalAfter,
winChanceBefore,
winChanceAfter,
san,
bestMove,
);
return {
san,
fen: fenHistory[i + 1] ?? fenHistory[i],
evalBefore,
evalAfter,
quality,
bestMove,
winChanceBefore,
winChanceAfter,
};
});
}
/**
* Classify move quality based on win-chance delta.
* Win-chance is from the engine's perspective (side to move before the move).
* After the move the side has flipped, so we invert the after value.
*/
private classifyQuality(
_evalBefore: number | null,
_evalAfter: number | null,
winChanceBefore: number | null,
winChanceAfter: number | null,
san: string,
bestMove: string | null,
): MoveQuality | null {
if (winChanceBefore === null || winChanceAfter === null) return null;
// The engine expresses win chance for the mover. After our move the mover
// has switched, so the opponent's win chance = winChanceAfter. Our remaining
// winning chance from our own perspective is 1 - winChanceAfter.
const wcAfterOurPerspective = 1 - winChanceAfter;
const delta = wcAfterOurPerspective - winChanceBefore; // negative = we lost win chance
if (bestMove && san === bestMove) {
if (delta >= -0.01) return 'brilliant';
return 'best';
}
if (delta >= -0.02) return 'good';
if (delta >= -0.05) return 'inaccuracy';
if (delta >= -0.1) return 'mistake';
return 'blunder';
}
}
+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/board/game') ||
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')) {
req = req.clone({
+5
View File
@@ -7,11 +7,16 @@ import { Bot, BotWithToken } from '../models/bot.models';
export class BotService {
private readonly http = inject(HttpClient);
private readonly base = '/api/account/bots';
private readonly officialBase = '/api/account/official-bots';
list(): Observable<Bot[]> {
return this.http.get<Bot[]>(this.base);
}
listOfficial(): Observable<Bot[]> {
return this.http.get<Bot[]>(this.officialBase);
}
create(name: string): Observable<BotWithToken> {
return this.http.post<BotWithToken>(this.base, { name });
}
+96 -91
View File
@@ -6,110 +6,115 @@ import { ChallengeService } from './challenge.service';
@Injectable({ providedIn: 'root' })
export class ChallengeWebSocketService {
private readonly challengeEventService = inject(ChallengeEventService);
private readonly challengeService = inject(ChallengeService);
private readonly router = inject(Router);
private readonly challengeEventService = inject(ChallengeEventService);
private readonly challengeService = inject(ChallengeService);
private readonly router = inject(Router);
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private readonly maxReconnectAttempts = 5;
private readonly reconnectDelay = 3000;
private intentionalClose = false;
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private readonly maxReconnectAttempts = 5;
private readonly reconnectDelay = 3000;
private intentionalClose = false;
connect(): void {
if (this.ws) return;
connect(): void {
if (this.ws) return;
const token = localStorage.getItem('token');
if (!token) return;
const token = localStorage.getItem('token');
if (!token) return;
const url = `${environment.userWsBaseUrl}/api/user/ws?token=${encodeURIComponent(token)}`;
const url = `${environment.userWsBaseUrl}/api/user/ws`;
try {
this.intentionalClose = false;
this.ws = new WebSocket(url);
try {
this.intentionalClose = false;
this.ws = new WebSocket(url);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
};
this.ws.onmessage = (event) => {
this.handleMessage(event.data as string);
};
this.ws.onerror = () => {
// onclose fires right after, handles reconnect
};
this.ws.onclose = () => {
this.ws = null;
if (!this.intentionalClose) {
this.attemptReconnect();
}
};
} catch {
this.attemptReconnect();
}
}
disconnect(): void {
this.intentionalClose = true;
this.ws.onopen = () => {
this.reconnectAttempts = 0;
if (this.ws) {
this.ws.close();
this.ws = null;
this.ws?.send(JSON.stringify({ type: 'auth', token }));
};
this.ws.onmessage = (event) => {
this.handleMessage(event.data as string);
};
this.ws.onerror = () => {
// onclose fires right after, handles reconnect
};
this.ws.onclose = () => {
this.ws = null;
if (!this.intentionalClose) {
this.attemptReconnect();
}
};
} catch {
this.attemptReconnect();
}
}
disconnect(): void {
this.intentionalClose = true;
this.reconnectAttempts = 0;
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
private handleMessage(data: string): void {
let message: Record<string, unknown>;
try {
message = JSON.parse(data) as Record<string, unknown>;
} catch {
return;
}
private handleMessage(data: string): void {
let message: Record<string, unknown>;
try {
message = JSON.parse(data) as Record<string, unknown>;
} catch {
return;
switch (message['type']) {
case 'CONNECTED':
break;
case 'challengeCreated': {
const challengeId = message['challengeId'] as string | undefined;
if (challengeId) {
this.challengeService.getChallenge(challengeId).subscribe({
next: (challenge) => this.challengeEventService.onChallengeReceived(challenge),
error: () => {
/* challenge may have already expired */
},
});
}
break;
}
switch (message['type']) {
case 'CONNECTED':
break;
case 'challengeCreated': {
const challengeId = message['challengeId'] as string | undefined;
if (challengeId) {
this.challengeService.getChallenge(challengeId).subscribe({
next: challenge => this.challengeEventService.onChallengeReceived(challenge),
error: () => { /* challenge may have already expired */ }
});
}
break;
}
case 'challengeAccepted': {
const challengeId = message['challengeId'] as string | undefined;
const gameId = message['gameId'] as string | undefined;
if (challengeId) {
this.challengeEventService.removeChallenge(challengeId);
}
if (gameId) {
void this.router.navigate(['/game', gameId]);
}
break;
}
case 'challengeDeclined':
case 'challengeExpired':
case 'challengeCancelled': {
const challengeId = message['challengeId'] as string | undefined;
if (challengeId) {
this.challengeEventService.removeChallenge(challengeId);
}
break;
}
case 'challengeAccepted': {
const challengeId = message['challengeId'] as string | undefined;
const gameId = message['gameId'] as string | undefined;
if (challengeId) {
this.challengeEventService.removeChallenge(challengeId);
}
}
if (gameId) {
void this.router.navigate(['/game', gameId]);
}
break;
}
private attemptReconnect(): void {
if (this.intentionalClose || this.reconnectAttempts >= this.maxReconnectAttempts) return;
this.reconnectAttempts++;
setTimeout(() => { this.connect(); }, this.reconnectDelay);
case 'challengeDeclined':
case 'challengeExpired':
case 'challengeCancelled': {
const challengeId = message['challengeId'] as string | undefined;
if (challengeId) {
this.challengeEventService.removeChallenge(challengeId);
}
break;
}
}
}
private attemptReconnect(): void {
if (this.intentionalClose || this.reconnectAttempts >= this.maxReconnectAttempts) return;
this.reconnectAttempts++;
setTimeout(() => {
this.connect();
}, this.reconnectDelay);
}
}
+36 -11
View File
@@ -1,14 +1,16 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { environment } from '../../environments/environment';
import {
GameFull,
GameState,
GameStreamEvent,
LegalMovesResponse,
PlayerInfo
PlayerInfo,
} from '../models/game.models';
import { AnalysisRequest, AnalysisResponse, RawAnalysisResponse } from '../models/analysis.models';
import { StreamHandlerService } from './stream-handler.service';
@Injectable({ providedIn: 'root' })
@@ -28,11 +30,11 @@ export class GameApiService {
const playerColor = Math.random() > 0.5 ? 'white' : 'black';
const playerInfo: PlayerInfo = {
id: `player-${Date.now()}`,
displayName: 'You'
displayName: 'You',
};
const botInfo: PlayerInfo = {
id: `bot-${difficulty}`,
displayName: `Bot (${difficulty})`
displayName: `Bot (${difficulty})`,
};
const payload =
@@ -40,7 +42,7 @@ export class GameApiService {
? { white: playerInfo, black: botInfo }
: { white: botInfo, black: playerInfo };
return this.http.post<GameFull>(`${this.apiBase}${this.apiPath}`, payload);
return this.http.post<GameFull>(`${this.apiBase}${this.apiPath}/vs-bot`, payload);
}
getGame(gameId: string): Observable<GameFull> {
@@ -56,7 +58,9 @@ export class GameApiService {
if (square) {
params = params.set('square', square);
}
return this.http.get<LegalMovesResponse>(`${this.apiBase}${this.apiPath}/${gameId}/moves`, { params });
return this.http.get<LegalMovesResponse>(`${this.apiBase}${this.apiPath}/${gameId}/moves`, {
params,
});
}
importFen(fen: string): Observable<GameFull> {
@@ -75,6 +79,30 @@ export class GameApiService {
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> {
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 {
if (this.wsBase) {
return this.wsBase;
@@ -85,11 +113,8 @@ export class GameApiService {
}
streamGame(gameId: string): Observable<GameStreamEvent> {
const token = localStorage.getItem('token');
let wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/ws`;
if (token) {
wsUrl += `?token=${encodeURIComponent(token)}`;
}
return this.streamHandler.createGameStream(wsUrl, gameId);
const wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/ws`;
const token = localStorage.getItem('token') ?? '';
return this.streamHandler.createGameStream(wsUrl, gameId, token);
}
}
+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' })
export class StreamHandlerService {
createGameStream(wsUrl: string, gameId: string): Observable<GameStreamEvent> {
createGameStream(wsUrl: string, gameId: string, token: string): Observable<GameStreamEvent> {
return new Observable<GameStreamEvent>((observer) => {
const ws = new WebSocket(wsUrl);
let connected = false;
@@ -14,7 +14,7 @@ export class StreamHandlerService {
const emitErrorEvent = (message: string): void => {
const errorEvent: ErrorEvent = {
type: 'error',
error: { code: 'STREAM_ERROR', message }
error: { code: 'STREAM_ERROR', message },
};
observer.next(errorEvent);
};
@@ -36,6 +36,7 @@ export class StreamHandlerService {
connected = true;
clearTimeout(connectionTimeoutId);
console.log(`[StreamHandler] WebSocket connected for ${gameId}`);
ws.send(JSON.stringify({ type: 'auth', token }));
};
ws.onmessage = (message) => {
@@ -0,0 +1,31 @@
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);
}
register(label: string, url: string): Observable<ExternalTournamentServer> {
return this.http.post<ExternalTournamentServer>(this.base, { label, url });
}
remove(id: string): Observable<void> {
return this.http.delete<void>(`${this.base}/${id}`);
}
}
+2 -4
View File
@@ -40,10 +40,8 @@ export class TournamentService {
return this.http.post<Tournament>(`${this.base}/${id}/start`, null);
}
joinWithBotToken(id: string, botToken: string): Observable<void> {
return this.http.post<void>(`${this.base}/${id}/join`, null, {
headers: new HttpHeaders({ Authorization: `Bearer ${botToken}` })
});
join(id: string, botId: string, botName: string): Observable<void> {
return this.http.post<void>(`${this.base}/${id}/join`, { botId, botName });
}
roundPairings(id: string, round: number): Observable<RoundPairings> {
+2 -2
View File
@@ -1,3 +1,3 @@
MAJOR=0
MINOR=2
PATCH=6
MINOR=4
PATCH=4