Compare commits
30 Commits
b9998b6710
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 131bbb3c06 | |||
| c0a98a90dd | |||
| efb129c323 | |||
| 828c2a03c1 | |||
| 8603222ab4 | |||
| 76e0e3db78 | |||
| 0ac61032bd | |||
| 890c3fcecc | |||
| a98b5534b7 | |||
| 147d7f0d2c | |||
| 1f1339809f | |||
| 0621968c3c | |||
| bd6d023513 | |||
| 2229cfd00a | |||
| 00b51b57b4 | |||
| 1dabd88c62 | |||
| ab2c641130 | |||
| 412591dfe0 | |||
| e374d5e791 | |||
| 5b5fd6f027 | |||
| ea8048e064 | |||
| ce1fb0d60b | |||
| 74f82bc0ba | |||
| 1d2c217da8 | |||
| 3d1b330396 | |||
| a54957aa74 | |||
| c9bf6d21fc | |||
| f9420e5848 | |||
| a3e51bade5 | |||
| 48959daae3 |
@@ -19,7 +19,6 @@ If `$ARGUMENTS` already answers some of these, skip those questions.
|
|||||||
## Step 2 — Research (if needed)
|
## Step 2 — Research (if needed)
|
||||||
|
|
||||||
If the bug involves component logic, a service, or routing:
|
If the bug involves component logic, a service, or routing:
|
||||||
|
|
||||||
- Search repo for relevant code (`Grep`/`Bash`) under `src/app/{components,services,models,core,pages}`.
|
- Search repo for relevant code (`Grep`/`Bash`) under `src/app/{components,services,models,core,pages}`.
|
||||||
- Check `.spec.ts` files for existing coverage of the broken area.
|
- Check `.spec.ts` files for existing coverage of the broken area.
|
||||||
- Do NOT guess at root cause. Surface findings before drafting.
|
- Do NOT guess at root cause. Surface findings before drafting.
|
||||||
@@ -57,7 +56,6 @@ Environment / Notes
|
|||||||
```
|
```
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
- Steps must be minimal and reproducible.
|
- Steps must be minimal and reproducible.
|
||||||
- Expected vs actual: concrete and unambiguous.
|
- Expected vs actual: concrete and unambiguous.
|
||||||
- Omit "Environment / Notes" section if not relevant.
|
- Omit "Environment / Notes" section if not relevant.
|
||||||
@@ -66,7 +64,6 @@ Rules:
|
|||||||
|
|
||||||
Show the draft to the user.
|
Show the draft to the user.
|
||||||
**Use `AskUserQuestion` tool to ask:**
|
**Use `AskUserQuestion` tool to ask:**
|
||||||
|
|
||||||
- Are steps to reproduce complete and accurate?
|
- Are steps to reproduce complete and accurate?
|
||||||
- Severity: Blocker / Critical / Major / Minor / Trivial?
|
- Severity: Blocker / Critical / Major / Minor / Trivial?
|
||||||
- Any related tickets or recent changes to link?
|
- Any related tickets or recent changes to link?
|
||||||
@@ -76,7 +73,6 @@ Incorporate feedback. Repeat until user approves.
|
|||||||
## Step 5 — Determine Project
|
## Step 5 — Determine Project
|
||||||
|
|
||||||
> **Project routing rules (always apply these):**
|
> **Project routing rules (always apply these):**
|
||||||
>
|
|
||||||
> - Frontend code (UI, UX, web app, components, services) → `NCWF`
|
> - Frontend code (UI, UX, web app, components, services) → `NCWF`
|
||||||
> - Backend code (game engine, bots, API, services, coordinator) → `NCS`
|
> - Backend code (game engine, bots, API, services, coordinator) → `NCS`
|
||||||
> - Infrastructure (Kubernetes, pipelines, CI/CD, DB setup, cloud infra) → `NCI`
|
> - Infrastructure (Kubernetes, pipelines, CI/CD, DB setup, cloud infra) → `NCI`
|
||||||
@@ -90,7 +86,6 @@ If ambiguous, ask the user.
|
|||||||
## Step 6 — Create Issue
|
## Step 6 — Create Issue
|
||||||
|
|
||||||
Call `mcp__youtrack__create_issue` with:
|
Call `mcp__youtrack__create_issue` with:
|
||||||
|
|
||||||
- `project`: determined in Step 5
|
- `project`: determined in Step 5
|
||||||
- `summary`: concise title describing what is broken (≤72 chars, sentence case)
|
- `summary`: concise title describing what is broken (≤72 chars, sentence case)
|
||||||
- `description`: full formatted defect report from Step 3 (Markdown)
|
- `description`: full formatted defect report from Step 3 (Markdown)
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ If `$ARGUMENTS` already answers some of these, skip those questions.
|
|||||||
## Step 2 — Research (if needed)
|
## Step 2 — Research (if needed)
|
||||||
|
|
||||||
If the topic involves unfamiliar UI flows, component structure, or technical constraints:
|
If the topic involves unfamiliar UI flows, component structure, or technical constraints:
|
||||||
|
|
||||||
- Search the repo for relevant code under `src/app/` (use `Grep`/`Bash`).
|
- Search the repo for relevant code under `src/app/` (use `Grep`/`Bash`).
|
||||||
- Use `WebSearch` if the topic involves Angular APIs, external standards or protocols.
|
- Use `WebSearch` if the topic involves Angular APIs, external standards or protocols.
|
||||||
- Do NOT guess. Surface findings before drafting.
|
- Do NOT guess. Surface findings before drafting.
|
||||||
@@ -54,7 +53,6 @@ Implementation Notes
|
|||||||
```
|
```
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
- User story line: plain English, present tense, from user's perspective.
|
- User story line: plain English, present tense, from user's perspective.
|
||||||
- Acceptance criteria: testable, unambiguous, one condition each.
|
- Acceptance criteria: testable, unambiguous, one condition each.
|
||||||
- Implementation notes: optional — only include if there are known constraints, related tickets, or design refs.
|
- Implementation notes: optional — only include if there are known constraints, related tickets, or design refs.
|
||||||
@@ -63,7 +61,6 @@ Rules:
|
|||||||
|
|
||||||
Show the draft to the user.
|
Show the draft to the user.
|
||||||
**Use `AskUserQuestion` tool to ask:**
|
**Use `AskUserQuestion` tool to ask:**
|
||||||
|
|
||||||
- Are the acceptance criteria complete and correct?
|
- Are the acceptance criteria complete and correct?
|
||||||
- Any implementation constraints to add?
|
- Any implementation constraints to add?
|
||||||
- Priority (if known)?
|
- Priority (if known)?
|
||||||
@@ -73,7 +70,6 @@ Incorporate feedback. Repeat until user approves.
|
|||||||
## Step 5 — Determine Project
|
## Step 5 — Determine Project
|
||||||
|
|
||||||
> **Project routing rules (always apply these):**
|
> **Project routing rules (always apply these):**
|
||||||
>
|
|
||||||
> - Frontend code (UI, UX, web app, components, services) → `NCWF`
|
> - Frontend code (UI, UX, web app, components, services) → `NCWF`
|
||||||
> - Backend code (game engine, bots, API, services, coordinator) → `NCS`
|
> - Backend code (game engine, bots, API, services, coordinator) → `NCS`
|
||||||
> - Infrastructure (Kubernetes, pipelines, CI/CD, DB setup, cloud infra) → `NCI`
|
> - Infrastructure (Kubernetes, pipelines, CI/CD, DB setup, cloud infra) → `NCI`
|
||||||
@@ -87,7 +83,6 @@ If still ambiguous, ask the user.
|
|||||||
## Step 6 — Create Issue
|
## Step 6 — Create Issue
|
||||||
|
|
||||||
Call `mcp__youtrack__create_issue` with:
|
Call `mcp__youtrack__create_issue` with:
|
||||||
|
|
||||||
- `project`: determined in Step 5
|
- `project`: determined in Step 5
|
||||||
- `summary`: concise title derived from the "I want to" clause (≤72 chars, sentence case)
|
- `summary`: concise title derived from the "I want to" clause (≤72 chars, sentence case)
|
||||||
- `description`: full formatted story from Step 3 (Markdown)
|
- `description`: full formatted story from Step 3 (Markdown)
|
||||||
@@ -102,7 +97,7 @@ After creation, ask the user (use `AskUserQuestion` if interactive, otherwise in
|
|||||||
Collect any issue IDs the user mentions. For each, determine the correct relation and call `mcp__youtrack__link_issues`:
|
Collect any issue IDs the user mentions. For each, determine the correct relation and call `mcp__youtrack__link_issues`:
|
||||||
|
|
||||||
| Situation | Relation to use |
|
| Situation | Relation to use |
|
||||||
| --------------------------------------- | --------------- |
|
|-----------|----------------|
|
||||||
| This story must be done before another | `blocks` |
|
| This story must be done before another | `blocks` |
|
||||||
| Another story must be done before this | `is blocked by` |
|
| Another story must be done before this | `is blocked by` |
|
||||||
| Stories share domain or are related | `relates to` |
|
| Stories share domain or are related | `relates to` |
|
||||||
|
|||||||
@@ -7,12 +7,10 @@ This is the **NowChess-Frontend** repo. Sprint mode defaults to project `NCWF`.
|
|||||||
## Step 1 — Determine Scope
|
## Step 1 — Determine Scope
|
||||||
|
|
||||||
**Single-issue mode** (`$ARGUMENTS` set):
|
**Single-issue mode** (`$ARGUMENTS` set):
|
||||||
|
|
||||||
- Call `mcp__youtrack__get_issue` on `$ARGUMENTS`.
|
- Call `mcp__youtrack__get_issue` on `$ARGUMENTS`.
|
||||||
- Proceed with that issue only.
|
- Proceed with that issue only.
|
||||||
|
|
||||||
**Sprint mode** (`$ARGUMENTS` empty):
|
**Sprint mode** (`$ARGUMENTS` empty):
|
||||||
|
|
||||||
- Call `mcp__youtrack__search_issues` with query `project: NCWF Sprints: {current sprint} #Unresolved`.
|
- Call `mcp__youtrack__search_issues` with query `project: NCWF Sprints: {current sprint} #Unresolved`.
|
||||||
- If query returns 0 results, use `AskUserQuestion` to ask for the sprint name, then retry with `project: NCWF Sprints: {name}`.
|
- If query returns 0 results, use `AskUserQuestion` to ask for the sprint name, then retry with `project: NCWF Sprints: {name}`.
|
||||||
- Collect all returned issues.
|
- Collect all returned issues.
|
||||||
@@ -20,7 +18,6 @@ This is the **NowChess-Frontend** repo. Sprint mode defaults to project `NCWF`.
|
|||||||
## Step 2 — Build Issue Tree
|
## Step 2 — Build Issue Tree
|
||||||
|
|
||||||
For each top-level issue from Step 1:
|
For each top-level issue from Step 1:
|
||||||
|
|
||||||
1. Fetch full details via `mcp__youtrack__get_issue`: summary, description, acceptance criteria, Type, existing `Zeitschätzung`, linked issues.
|
1. Fetch full details via `mcp__youtrack__get_issue`: summary, description, acceptance criteria, Type, existing `Zeitschätzung`, linked issues.
|
||||||
2. Identify subtasks from links with relation `subtask of` (i.e. issues where the fetched issue is the parent).
|
2. Identify subtasks from links with relation `subtask of` (i.e. issues where the fetched issue is the parent).
|
||||||
3. Recursively fetch subtasks until all leaves are known.
|
3. Recursively fetch subtasks until all leaves are known.
|
||||||
@@ -32,13 +29,12 @@ For each top-level issue from Step 1:
|
|||||||
## Step 3 — Estimate Leaf Nodes
|
## Step 3 — Estimate Leaf Nodes
|
||||||
|
|
||||||
For each leaf node:
|
For each leaf node:
|
||||||
|
|
||||||
1. Read: summary, description, acceptance criteria, implementation notes.
|
1. Read: summary, description, acceptance criteria, implementation notes.
|
||||||
2. If scope is unclear, search codebase (`Grep`/`Bash`) under `src/app/` for related files to gauge complexity.
|
2. If scope is unclear, search codebase (`Grep`/`Bash`) under `src/app/` for related files to gauge complexity.
|
||||||
3. Assign estimate using this scale:
|
3. Assign estimate using this scale:
|
||||||
|
|
||||||
| Size | Criteria | Estimate |
|
| Size | Criteria | Estimate |
|
||||||
| ------- | ------------------------------------------------------ | -------- |
|
|------|----------|----------|
|
||||||
| Trivial | Style tweak, copy change, 1-file tweak | 30m |
|
| Trivial | Style tweak, copy change, 1-file tweak | 30m |
|
||||||
| Small | 1–3 files, single component/service, no unknowns | 1h–2h |
|
| Small | 1–3 files, single component/service, no unknowns | 1h–2h |
|
||||||
| Medium | 3–6 files, new component + service wiring, some design | 3h–5h |
|
| Medium | 3–6 files, new component + service wiring, some design | 3h–5h |
|
||||||
@@ -53,7 +49,6 @@ For each leaf node:
|
|||||||
YouTrack auto-sums `Zeitschätzung` from subtasks up to parents — **do not write estimates to parent nodes**.
|
YouTrack auto-sums `Zeitschätzung` from subtasks up to parents — **do not write estimates to parent nodes**.
|
||||||
|
|
||||||
Compute display-only rolled-up totals:
|
Compute display-only rolled-up totals:
|
||||||
|
|
||||||
- Parent total = sum of all descendant leaf estimates (including pre-estimated ones).
|
- Parent total = sum of all descendant leaf estimates (including pre-estimated ones).
|
||||||
- Flag any branch where some leaves are missing estimates (partial roll-up).
|
- Flag any branch where some leaves are missing estimates (partial roll-up).
|
||||||
|
|
||||||
@@ -73,13 +68,11 @@ Epic NCWF-10: Board UI overhaul [4h 30m] ← rolled up
|
|||||||
Legend: `[X]` = display-only roll-up (not written). Plain = will be written to YouTrack.
|
Legend: `[X]` = display-only roll-up (not written). Plain = will be written to YouTrack.
|
||||||
|
|
||||||
If sprint mode, show grand total at bottom:
|
If sprint mode, show grand total at bottom:
|
||||||
|
|
||||||
```
|
```
|
||||||
Sprint total: Xd Yh Zm (N issues, M leaves to update)
|
Sprint total: Xd Yh Zm (N issues, M leaves to update)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Use `AskUserQuestion` tool:**
|
**Use `AskUserQuestion` tool:**
|
||||||
|
|
||||||
- Does the breakdown look right?
|
- Does the breakdown look right?
|
||||||
- Any estimates to adjust before writing to YouTrack?
|
- Any estimates to adjust before writing to YouTrack?
|
||||||
|
|
||||||
@@ -88,7 +81,6 @@ Incorporate all feedback before proceeding.
|
|||||||
## Step 6 — Write Estimates
|
## Step 6 — Write Estimates
|
||||||
|
|
||||||
On user approval, write estimates **only to leaf nodes** (bottom-up order):
|
On user approval, write estimates **only to leaf nodes** (bottom-up order):
|
||||||
|
|
||||||
- For each leaf with a new estimate, call `mcp__youtrack__update_issue` with field `Zeitschätzung` = approved estimate.
|
- For each leaf with a new estimate, call `mcp__youtrack__update_issue` with field `Zeitschätzung` = approved estimate.
|
||||||
- YouTrack period format: `"30m"`, `"1h 30m"`, `"1d"`, `"2d 4h"`.
|
- YouTrack period format: `"30m"`, `"1h 30m"`, `"1d"`, `"2d 4h"`.
|
||||||
- Skip leaves already pre-estimated.
|
- Skip leaves already pre-estimated.
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
Automated defect-fix workflow. Ticket ID: `$ARGUMENTS`
|
Automated defect-fix workflow. Ticket ID: `$ARGUMENTS`
|
||||||
|
|
||||||
This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). Gates:
|
This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). Gates:
|
||||||
|
|
||||||
- Build: `npm run build`
|
- Build: `npm run build`
|
||||||
- Test: `npm test -- --watch=false --browsers=ChromeHeadless`
|
- Test: `npm test -- --watch=false --browsers=ChromeHeadless`
|
||||||
- Format: `npx prettier --write .` (check with `npx prettier --check .`)
|
- Format: `npx prettier --write .` (check with `npx prettier --check .`)
|
||||||
@@ -16,7 +15,6 @@ Extract and display: summary, description, steps to reproduce, Priority, Subsyst
|
|||||||
## Step 2 — Create Worktree
|
## Step 2 — Create Worktree
|
||||||
|
|
||||||
Derive branch name from ticket:
|
Derive branch name from ticket:
|
||||||
|
|
||||||
- `type` from YouTrack issue type: `bug` → `fix`, `feature`/`task` → `feat`, `refactor` → `refactor`, else `chore`
|
- `type` from YouTrack issue type: `bug` → `fix`, `feature`/`task` → `feat`, `refactor` → `refactor`, else `chore`
|
||||||
- `scope` from affected area (kebab-case, omit if unclear)
|
- `scope` from affected area (kebab-case, omit if unclear)
|
||||||
- `description` from ticket summary: lowercase, kebab-case, max 40 chars, drop articles
|
- `description` from ticket summary: lowercase, kebab-case, max 40 chars, drop articles
|
||||||
@@ -44,7 +42,6 @@ After root cause confirmed, assess scope:
|
|||||||
**Complex** (3+ files, multiple concerns, or estimated > 1 hour): create subtasks before coding.
|
**Complex** (3+ files, multiple concerns, or estimated > 1 hour): create subtasks before coding.
|
||||||
|
|
||||||
To create subtasks:
|
To create subtasks:
|
||||||
|
|
||||||
1. Break fix into discrete, independently-completable tasks (e.g. "Fix selection state in BoardComponent", "Add spec for flip behaviour", "Update GameService move stream").
|
1. Break fix into discrete, independently-completable tasks (e.g. "Fix selection state in BoardComponent", "Add spec for flip behaviour", "Update GameService move stream").
|
||||||
2. For each subtask call `mcp__youtrack__create_issue` with:
|
2. For each subtask call `mcp__youtrack__create_issue` with:
|
||||||
- `project`: based on subtask content — do **not** inherit from parent. Frontend/UI → `NCWF`; backend code → `NCS`; Kubernetes/pipelines/CI-CD/infrastructure → `NCI`. If ambiguous, ask user.
|
- `project`: based on subtask content — do **not** inherit from parent. Frontend/UI → `NCWF`; backend code → `NCS`; Kubernetes/pipelines/CI-CD/infrastructure → `NCI`. If ambiguous, ask user.
|
||||||
@@ -69,7 +66,7 @@ Then proceed to Step 4, implementing subtasks in order.
|
|||||||
5. Run `npx prettier --check .` — **blocking, foreground only** (never `run_in_background`). Wait for exit code 0. Must be green.
|
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.
|
- 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.**
|
- **Do NOT proceed to Step 5 until the build, tests and format check all pass.**
|
||||||
If any step fails, iterate until all pass.
|
If any step fails, iterate until all pass.
|
||||||
|
|
||||||
## Step 5 — Review
|
## Step 5 — Review
|
||||||
|
|
||||||
@@ -79,7 +76,6 @@ Display findings grouped by severity.
|
|||||||
## Step 5b — Apply Review Findings
|
## Step 5b — Apply Review Findings
|
||||||
|
|
||||||
If the review produced any findings (any severity):
|
If the review produced any findings (any severity):
|
||||||
|
|
||||||
1. Implement all agreed fixes.
|
1. Implement all agreed fixes.
|
||||||
2. Run `npm run build` — must be green.
|
2. Run `npm run build` — must be green.
|
||||||
3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green.
|
3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green.
|
||||||
@@ -130,7 +126,7 @@ Files changed:
|
|||||||
After commenting, ask the user if `$ARGUMENTS` should be linked to any other issues not already linked:
|
After commenting, ask the user if `$ARGUMENTS` should be linked to any other issues not already linked:
|
||||||
|
|
||||||
| Situation | Relation |
|
| Situation | Relation |
|
||||||
| ----------------------------------- | --------------- |
|
|-----------|---------|
|
||||||
| This fix blocks another open ticket | `blocks` |
|
| This fix blocks another open ticket | `blocks` |
|
||||||
| Another ticket must ship first | `is blocked by` |
|
| Another ticket must ship first | `is blocked by` |
|
||||||
| Related defect or story | `relates to` |
|
| Related defect or story | `relates to` |
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ Automated feature-implementation workflow. Ticket ID: `$ARGUMENTS`
|
|||||||
|
|
||||||
This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). In-project =
|
This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). In-project =
|
||||||
`NCWF`. Gates:
|
`NCWF`. Gates:
|
||||||
|
|
||||||
- Build: `npm run build`
|
- Build: `npm run build`
|
||||||
- Test: `npm test -- --watch=false --browsers=ChromeHeadless`
|
- Test: `npm test -- --watch=false --browsers=ChromeHeadless`
|
||||||
- Format: `npx prettier --write .` (check with `npx prettier --check .`)
|
- Format: `npx prettier --write .` (check with `npx prettier --check .`)
|
||||||
@@ -41,7 +40,6 @@ are collected and reported at the end with a ready-to-run prompt.
|
|||||||
## Step 3 — Create Worktree
|
## Step 3 — Create Worktree
|
||||||
|
|
||||||
Derive branch name from the root ticket `$ARGUMENTS`:
|
Derive branch name from the root ticket `$ARGUMENTS`:
|
||||||
|
|
||||||
- `type` from YouTrack issue type: `feature`/`task` → `feat`, `refactor` → `refactor`, `bug` → `fix`, else `chore`
|
- `type` from YouTrack issue type: `feature`/`task` → `feat`, `refactor` → `refactor`, `bug` → `fix`, else `chore`
|
||||||
- `scope` from affected area (kebab-case, omit if unclear)
|
- `scope` from affected area (kebab-case, omit if unclear)
|
||||||
- `description` from ticket summary: lowercase, kebab-case, max 40 chars, drop articles
|
- `description` from ticket summary: lowercase, kebab-case, max 40 chars, drop articles
|
||||||
@@ -85,7 +83,6 @@ Display findings grouped by severity.
|
|||||||
## Step 6b — Apply Review Findings
|
## Step 6b — Apply Review Findings
|
||||||
|
|
||||||
If the review produced any findings (any severity):
|
If the review produced any findings (any severity):
|
||||||
|
|
||||||
1. Implement all agreed fixes.
|
1. Implement all agreed fixes.
|
||||||
2. Run `npm run build` — must be green.
|
2. Run `npm run build` — must be green.
|
||||||
3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green.
|
3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green.
|
||||||
@@ -142,12 +139,10 @@ Call `ExitWorktree` with `discard_changes: true` to delete the worktree.
|
|||||||
Final report to the user, in two sections:
|
Final report to the user, in two sections:
|
||||||
|
|
||||||
### Blocked in-project tasks
|
### Blocked in-project tasks
|
||||||
|
|
||||||
List any `NCWF` tasks that could **not** be implemented, with the blocker(s)
|
List any `NCWF` tasks that could **not** be implemented, with the blocker(s)
|
||||||
that stopped them. (These can be re-run with this command once blockers clear.)
|
that stopped them. (These can be re-run with this command once blockers clear.)
|
||||||
|
|
||||||
### Cross-project tasks (NCS / NCI / other)
|
### Cross-project tasks (NCS / NCI / other)
|
||||||
|
|
||||||
For every out-of-project task discovered in the tree (whether it was a subtask
|
For every out-of-project task discovered in the tree (whether it was a subtask
|
||||||
or a blocker), output one entry:
|
or a blocker), output one entry:
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ Extract and display: summary, description, acceptance criteria, implementation n
|
|||||||
## Step 2 — Research (if needed)
|
## Step 2 — Research (if needed)
|
||||||
|
|
||||||
If the story involves unfamiliar UI flows or technical constraints:
|
If the story involves unfamiliar UI flows or technical constraints:
|
||||||
|
|
||||||
- Search repo for relevant code under `src/app/` (`Grep`/`Bash`).
|
- Search repo for relevant code under `src/app/` (`Grep`/`Bash`).
|
||||||
- Use `WebSearch` for Angular APIs, external standards or protocols.
|
- Use `WebSearch` for Angular APIs, external standards or protocols.
|
||||||
- Do NOT guess. Surface findings before proposing splits.
|
- Do NOT guess. Surface findings before proposing splits.
|
||||||
@@ -20,7 +19,6 @@ If the story involves unfamiliar UI flows or technical constraints:
|
|||||||
## Step 3 — Propose Split
|
## Step 3 — Propose Split
|
||||||
|
|
||||||
Analyse the story and propose a set of subtasks. Rules:
|
Analyse the story and propose a set of subtasks. Rules:
|
||||||
|
|
||||||
- Each subtask = one unit of work, completable independently or in sequence.
|
- Each subtask = one unit of work, completable independently or in sequence.
|
||||||
- No subtask should exceed ~2 days of work.
|
- No subtask should exceed ~2 days of work.
|
||||||
- Name subtasks in imperative mood (e.g. "Add board theme service", "Render theme picker component").
|
- Name subtasks in imperative mood (e.g. "Add board theme service", "Render theme picker component").
|
||||||
@@ -28,7 +26,6 @@ Analyse the story and propose a set of subtasks. Rules:
|
|||||||
|
|
||||||
Show proposed subtask list to user (titles only) and ask:
|
Show proposed subtask list to user (titles only) and ask:
|
||||||
**Use `AskUserQuestion` tool:**
|
**Use `AskUserQuestion` tool:**
|
||||||
|
|
||||||
- Does the split look right?
|
- Does the split look right?
|
||||||
- Any subtasks to add, remove, or merge?
|
- Any subtasks to add, remove, or merge?
|
||||||
- Should any subtask be assigned to a specific person?
|
- Should any subtask be assigned to a specific person?
|
||||||
@@ -60,7 +57,6 @@ What must be true for this subtask to be considered complete:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
- Steps/Tasks: concrete, ordered where order matters.
|
- Steps/Tasks: concrete, ordered where order matters.
|
||||||
- Definition of Done: adjust per subtask — not all subtasks need the same criteria (e.g. a research spike has different DoD than an implementation task).
|
- Definition of Done: adjust per subtask — not all subtasks need the same criteria (e.g. a research spike has different DoD than an implementation task).
|
||||||
- Keep description short — one paragraph max.
|
- Keep description short — one paragraph max.
|
||||||
@@ -78,7 +74,6 @@ If a subtask's project is ambiguous, ask the user before creating it.
|
|||||||
## Step 6 — Create Subtasks
|
## Step 6 — Create Subtasks
|
||||||
|
|
||||||
For each subtask call `mcp__youtrack__create_issue` with:
|
For each subtask call `mcp__youtrack__create_issue` with:
|
||||||
|
|
||||||
- `project`: from Step 5
|
- `project`: from Step 5
|
||||||
- `summary`: subtask title (≤72 chars, sentence case)
|
- `summary`: subtask title (≤72 chars, sentence case)
|
||||||
- `description`: full formatted description from Step 4 (Markdown)
|
- `description`: full formatted description from Step 4 (Markdown)
|
||||||
@@ -89,7 +84,6 @@ Then call `mcp__youtrack__link_issues` to link each created subtask to `$ARGUMEN
|
|||||||
## Step 6b — Inter-Subtask Links
|
## Step 6b — Inter-Subtask Links
|
||||||
|
|
||||||
If subtasks must be done in sequence (one depends on output of another), add ordering links:
|
If subtasks must be done in sequence (one depends on output of another), add ordering links:
|
||||||
|
|
||||||
- For each dependency pair call `mcp__youtrack__link_issues` with relation `is blocked by` (subtask B is blocked by subtask A).
|
- For each dependency pair call `mcp__youtrack__link_issues` with relation `is blocked by` (subtask B is blocked by subtask A).
|
||||||
|
|
||||||
Ask the user to confirm sequencing before adding these links:
|
Ask the user to confirm sequencing before adding these links:
|
||||||
@@ -101,7 +95,7 @@ Ask the user to confirm sequencing before adding these links:
|
|||||||
Scan `$ARGUMENTS` description and implementation notes for any referenced issue IDs not already linked. For each:
|
Scan `$ARGUMENTS` description and implementation notes for any referenced issue IDs not already linked. For each:
|
||||||
|
|
||||||
| Situation | Relation |
|
| Situation | Relation |
|
||||||
| ---------------------------------------- | --------------- |
|
|-----------|---------|
|
||||||
| Parent story blocks another epic/story | `blocks` |
|
| Parent story blocks another epic/story | `blocks` |
|
||||||
| Story depends on another epic completing | `is blocked by` |
|
| Story depends on another epic completing | `is blocked by` |
|
||||||
| Related story in same domain | `relates to` |
|
| Related story in same domain | `relates to` |
|
||||||
|
|||||||
@@ -41,3 +41,6 @@ Thumbs.db
|
|||||||
# Claude Code
|
# Claude Code
|
||||||
/.claude/settings.local.json
|
/.claude/settings.local.json
|
||||||
/.claude/worktrees/
|
/.claude/worktrees/
|
||||||
|
|
||||||
|
# Local clone of the tournament server repo (not tracked)
|
||||||
|
/tournament-server/
|
||||||
|
|||||||
+93
-40
@@ -1,77 +1,130 @@
|
|||||||
## [0.3.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.8...0.3.0) (2026-06-10)
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- NCWF-5 Scaffold post-game analysis page at /analysis with board viewer and route
|
|
||||||
- NCWF-6 FEN / PGN / Game-ID input form for custom analysis
|
|
||||||
- NCWF-7 Integrate backend analysis API (POST /api/analysis/position) via GameApiService
|
|
||||||
- NCWF-8 Per-move evaluation timeline SVG chart
|
|
||||||
- NCWF-9 Annotated move list with quality labels (!! ! ?! ? ??)
|
|
||||||
|
|
||||||
## 0.0.0 (2026-05-12)
|
## 0.0.0 (2026-05-12)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- added bot, light and dark mode ([2de003e](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/2de003e497baee72f998d0d805ca1e58aababe48))
|
* added bot, light and dark mode ([2de003e](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/2de003e497baee72f998d0d805ca1e58aababe48))
|
||||||
- added web view 1v1 ([1828fa3](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/1828fa3275ddb8ce6bb90a9f6a970ec428ebce3a))
|
* added web view 1v1 ([1828fa3](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/1828fa3275ddb8ce6bb90a9f6a970ec428ebce3a))
|
||||||
- NCS-63 User account implementation ([#2](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/2)) ([ff75c8c](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/ff75c8ce2fad54137f04a14c15bc1d4a38fa9bb8))
|
* NCS-63 User account implementation ([#2](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/2)) ([ff75c8c](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/ff75c8ce2fad54137f04a14c15bc1d4a38fa9bb8))
|
||||||
- NCS-75 Frontend Deployment Dockerfile ([#4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/4)) ([bd7ec58](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/bd7ec581e38b5d8e775741bf16e19b4dc67b979e))
|
* NCS-75 Frontend Deployment Dockerfile ([#4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/4)) ([bd7ec58](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/bd7ec581e38b5d8e775741bf16e19b4dc67b979e))
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
- build issues ([36d72fd](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/36d72fd6cda41be51d28f8ac307dcdbcd31afa91))
|
* build issues ([36d72fd](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/36d72fd6cda41be51d28f8ac307dcdbcd31afa91))
|
||||||
- cleaner components seperation ([8b090e4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/8b090e4d96c64c0adb253e3aefad7930108ccfb9))
|
* cleaner components seperation ([8b090e4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/8b090e4d96c64c0adb253e3aefad7930108ccfb9))
|
||||||
- gitignore ([4da882f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/4da882f481ba7a008aac868fb37de7cb2bafea5d))
|
* gitignore ([4da882f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/4da882f481ba7a008aac868fb37de7cb2bafea5d))
|
||||||
- GITIGNORE ([8df2d05](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/8df2d0550ab17c9afb2d19c414eac700a75add02))
|
* GITIGNORE ([8df2d05](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/8df2d0550ab17c9afb2d19c414eac700a75add02))
|
||||||
- npm ([c11c1d4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c11c1d4dce9de4bd5b463e891eebf961b37feb04))
|
* npm ([c11c1d4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c11c1d4dce9de4bd5b463e891eebf961b37feb04))
|
||||||
- removed .vs ([2833ead](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/2833ead7be3b47ee5c188d2d21b7326cb3cb3f26))
|
* removed .vs ([2833ead](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/2833ead7be3b47ee5c188d2d21b7326cb3cb3f26))
|
||||||
- removed cache files ([7ee59c4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/7ee59c434bf137a08fd560bbc02ceefbcfd90f04))
|
* removed cache files ([7ee59c4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/7ee59c434bf137a08fd560bbc02ceefbcfd90f04))
|
||||||
- size of pieces and removed text view of the game state ([c60d00f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c60d00f9d25247504845654615065fbccd7fe448))
|
* size of pieces and removed text view of the game state ([c60d00f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c60d00f9d25247504845654615065fbccd7fe448))
|
||||||
- structure ([3e8c7c4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/3e8c7c4057e55aeec7cee8c24f6751ff24912c93))
|
* structure ([3e8c7c4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/3e8c7c4057e55aeec7cee8c24f6751ff24912c93))
|
||||||
|
|
||||||
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.1.0...0.0.0) (2026-05-12)
|
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.1.0...0.0.0) (2026-05-12)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- NCS-69 Challenge request ([#3](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/3)) ([bad7366](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/bad7366bdbb048c20218257b30ac22efc9ecb6db))
|
* NCS-69 Challenge request ([#3](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/3)) ([bad7366](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/bad7366bdbb048c20218257b30ac22efc9ecb6db))
|
||||||
|
|
||||||
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.0...0.0.0) (2026-05-12)
|
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.0...0.0.0) (2026-05-12)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
- NCWF-1 401 ([#5](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/5)) ([f8f93ef](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/f8f93efff48f1d7198023fed45b675c2e225df36))
|
* NCWF-1 401 ([#5](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/5)) ([f8f93ef](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/f8f93efff48f1d7198023fed45b675c2e225df36))
|
||||||
|
|
||||||
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.1...0.0.0) (2026-05-12)
|
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.1...0.0.0) (2026-05-12)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
- NCWF-1 401 ([#6](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/6)) ([6d1e06d](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/6d1e06dfd606b93d029e9c9b84eea6f8b3b6294e))
|
* NCWF-1 401 ([#6](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/6)) ([6d1e06d](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/6d1e06dfd606b93d029e9c9b84eea6f8b3b6294e))
|
||||||
|
|
||||||
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.2...0.0.0) (2026-05-14)
|
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.2...0.0.0) (2026-05-14)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
- added missing challenge routes ([61000f8](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/61000f8a22aff8b524664a756cc933834365f923))
|
* added missing challenge routes ([61000f8](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/61000f8a22aff8b524664a756cc933834365f923))
|
||||||
|
|
||||||
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.3...0.0.0) (2026-05-15)
|
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.3...0.0.0) (2026-05-15)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
- build error ([51a363a](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/51a363a2432be111b804082df362975047dc8080))
|
* build error ([51a363a](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/51a363a2432be111b804082df362975047dc8080))
|
||||||
- NCWF-2 bugs and desing fixes ([#7](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/7)) ([c02414e](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c02414ea40177b05a5e62dcf68dcb44efa6d3740))
|
* NCWF-2 bugs and desing fixes ([#7](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/7)) ([c02414e](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c02414ea40177b05a5e62dcf68dcb44efa6d3740))
|
||||||
|
|
||||||
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.4...0.0.0) (2026-06-01)
|
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.4...0.0.0) (2026-06-01)
|
||||||
|
|
||||||
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.5...0.0.0) (2026-06-02)
|
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.5...0.0.0) (2026-06-02)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
- NCWF-4 Token Issues ([#8](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/8)) ([95eff42](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/95eff42dfe6d9c23ede08c7297614369a1b00d9f))
|
* NCWF-4 Token Issues ([#8](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/8)) ([95eff42](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/95eff42dfe6d9c23ede08c7297614369a1b00d9f))
|
||||||
|
|
||||||
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.6...0.0.0) (2026-06-06)
|
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.6...0.0.0) (2026-06-06)
|
||||||
|
|
||||||
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.7...0.0.0) (2026-06-10)
|
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.7...0.0.0) (2026-06-10)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
- route play-vs-bot to /vs-bot endpoint ([a620735](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/a62073511f2ac912ceb0f6b4730bef37545dd8ea))
|
* route play-vs-bot to /vs-bot endpoint ([a620735](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/a62073511f2ac912ceb0f6b4730bef37545dd8ea))
|
||||||
|
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.8...0.0.0) (2026-06-10)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* bots ([#9](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/9)) ([48959da](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/48959daae36e709ea7782ca04fdde699854f8e66))
|
||||||
|
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.3.0...0.0.0) (2026-06-17)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* NCWF-5/6/7/8/9 chess analysis page and engine integration ([#11](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/11)) ([f9420e5](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/f9420e5848d8724bcb0e9cf08f08b871c91cf4ba))
|
||||||
|
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.4.0...0.0.0) (2026-06-17)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **auth:** attach Bearer token to /api/bots requests ([#12](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/12)) ([a54957a](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/a54957aa74ef15bf2dd439d386e221ac134c5c5c))
|
||||||
|
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.4.1...0.0.0) (2026-06-17)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* NCS-122 send WS token via first-message auth instead of query param ([#13](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/13)) ([1d2c217](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/1d2c217da8982d361e2eb7de26f6447171a1dd43))
|
||||||
|
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.4.2...0.0.0) (2026-06-21)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **analysis:** fix API field mismatch and enable full game analysis ([ce1fb0d](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/ce1fb0d60b695093495ee0ad824c511dd2db7fbb))
|
||||||
|
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.4.3...0.0.0) (2026-06-21)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **tournaments:** load both user bots and official bots in join dialog ([5b5fd6f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/5b5fd6f027b4aedb951a802725fcd929d514c359))
|
||||||
|
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.4.4...0.0.0) (2026-06-21)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **tournaments:** remove external server add/remove UI ([412591d](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/412591dfe0119dbec84c3783cd94590810884580))
|
||||||
|
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.5.0...0.0.0) (2026-06-23)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* NCWF-10 streaming endpoint ([#14](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/14)) ([1dabd88](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/1dabd88c6286a7b01d6fe8527aec864b24e21cca))
|
||||||
|
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.6.0...0.0.0) (2026-06-23)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* api streaming issues ([0621968](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/0621968c3ceddebe01e9c363bda345b5dcccfbbf))
|
||||||
|
* api url ([2229cfd](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/2229cfd00a7d16daa6a9544c8940e792c4362dfb))
|
||||||
|
* streaming issues ([bd6d023](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/bd6d02351336ed6adf66244979c6d959f47e318b))
|
||||||
|
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.6.1...0.0.0) (2026-06-23)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* jwt token issue ([147d7f0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/147d7f0d2ca7a77bb80eb4b73b8d60b00ad2f708))
|
||||||
|
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.6.2...0.0.0) (2026-06-23)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* route tournament calls through backend gateway ([890c3fc](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/890c3fcecccec89e643180725e2a601f84fa5d99))
|
||||||
|
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.6.3...0.0.0) (2026-06-28)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* show actual bot names on watch page instead of Black/White fallbacks ([#15](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/15)) ([76e0e3d](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/76e0e3db7869609e400593ca8f08ea8f72e72f68))
|
||||||
|
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.6.4...0.0.0) (2026-06-29)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* show finished games on watch page instead of hanging spinner ([#16](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/16)) ([828c2a0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/828c2a03c18c74d191d7181cfa70c824c2beca67))
|
||||||
|
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.6.5...0.0.0) (2026-06-30)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **tournaments:** make finished games in previous rounds clickable ([#17](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/17)) ([c0a98a9](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c0a98a90dd53e675b5f6655d71bab681a1dcf361))
|
||||||
|
|||||||
+13
-4
@@ -18,7 +18,9 @@
|
|||||||
"builder": "@angular/build:application",
|
"builder": "@angular/build:application",
|
||||||
"options": {
|
"options": {
|
||||||
"browser": "src/main.ts",
|
"browser": "src/main.ts",
|
||||||
"polyfills": ["zone.js"],
|
"polyfills": [
|
||||||
|
"zone.js"
|
||||||
|
],
|
||||||
"tsConfig": "tsconfig.app.json",
|
"tsConfig": "tsconfig.app.json",
|
||||||
"assets": [
|
"assets": [
|
||||||
{
|
{
|
||||||
@@ -36,7 +38,9 @@
|
|||||||
"output": "/assets/ChessAssets"
|
"output": "/assets/ChessAssets"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": ["src/styles.css"]
|
"styles": [
|
||||||
|
"src/styles.css"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
@@ -102,7 +106,10 @@
|
|||||||
"test": {
|
"test": {
|
||||||
"builder": "@angular/build:karma",
|
"builder": "@angular/build:karma",
|
||||||
"options": {
|
"options": {
|
||||||
"polyfills": ["zone.js", "zone.js/testing"],
|
"polyfills": [
|
||||||
|
"zone.js",
|
||||||
|
"zone.js/testing"
|
||||||
|
],
|
||||||
"tsConfig": "tsconfig.spec.json",
|
"tsConfig": "tsconfig.spec.json",
|
||||||
"assets": [
|
"assets": [
|
||||||
{
|
{
|
||||||
@@ -120,7 +127,9 @@
|
|||||||
"output": "/assets/ChessAssets"
|
"output": "/assets/ChessAssets"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": ["src/styles.css"]
|
"styles": [
|
||||||
|
"src/styles.css"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ tags:
|
|||||||
description: Export a game as FEN or PGN
|
description: Export a game as FEN or PGN
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Game lifecycle
|
# Game lifecycle
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -466,6 +467,7 @@ paths:
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
components:
|
components:
|
||||||
|
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
bearerAuth:
|
bearerAuth:
|
||||||
type: http
|
type: http
|
||||||
@@ -515,6 +517,7 @@ components:
|
|||||||
$ref: '#/components/schemas/ApiError'
|
$ref: '#/components/schemas/ApiError'
|
||||||
|
|
||||||
schemas:
|
schemas:
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Requests
|
# Requests
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -548,7 +551,7 @@ components:
|
|||||||
pgn:
|
pgn:
|
||||||
type: string
|
type: string
|
||||||
description: PGN text (headers and move list)
|
description: PGN text (headers and move list)
|
||||||
example: '1. e4 e5 2. Nf3 Nc6 *'
|
example: "1. e4 e5 2. Nf3 Nc6 *"
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Game state
|
# Game state
|
||||||
@@ -584,7 +587,7 @@ components:
|
|||||||
pgn:
|
pgn:
|
||||||
type: string
|
type: string
|
||||||
description: PGN move text for the full game so far
|
description: PGN move text for the full game so far
|
||||||
example: '1. e4'
|
example: "1. e4"
|
||||||
turn:
|
turn:
|
||||||
type: string
|
type: string
|
||||||
enum: [white, black]
|
enum: [white, black]
|
||||||
|
|||||||
Generated
+24
-3
@@ -458,6 +458,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.19.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.19.tgz",
|
||||||
"integrity": "sha512-hcB1eUEN8LGcKGc4DlRJ+abS6AYfbEHDZKg8LnXNugkbwI6Ebyh2AUYTDhzZL2S4aH+C8biHKgSYHFCqieCRhA==",
|
"integrity": "sha512-hcB1eUEN8LGcKGc4DlRJ+abS6AYfbEHDZKg8LnXNugkbwI6Ebyh2AUYTDhzZL2S4aH+C8biHKgSYHFCqieCRhA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
@@ -474,6 +475,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.19.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.19.tgz",
|
||||||
"integrity": "sha512-ETkgDKm0l2PuaBubgPJe0ccy8kE75DFu6/zKcz7TUuk3KrKF2OZAopbbjftsUSZGeCNvCdqHzjmcL6hQ6oAOwA==",
|
"integrity": "sha512-ETkgDKm0l2PuaBubgPJe0ccy8kE75DFu6/zKcz7TUuk3KrKF2OZAopbbjftsUSZGeCNvCdqHzjmcL6hQ6oAOwA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
@@ -487,6 +489,7 @@
|
|||||||
"integrity": "sha512-ET/JjO8s62kAHfgIsGXlvW5VUwLqHm03q1y/2yD7aQW/WdDvssMsvZv7Knl440989vdOFemIGTMwVPakmWqRmA==",
|
"integrity": "sha512-ET/JjO8s62kAHfgIsGXlvW5VUwLqHm03q1y/2yD7aQW/WdDvssMsvZv7Knl440989vdOFemIGTMwVPakmWqRmA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/core": "7.28.3",
|
"@babel/core": "7.28.3",
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.14",
|
"@jridgewell/sourcemap-codec": "^1.4.14",
|
||||||
@@ -519,6 +522,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.19.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.19.tgz",
|
||||||
"integrity": "sha512-SYnwW+q51bQoPtGFoGovm1P5GK9fMEXsG0lGaEAUapjskblAYyX7hLlM/jgueSojv2SjhqNF8aXR+gjHLhZVNA==",
|
"integrity": "sha512-SYnwW+q51bQoPtGFoGovm1P5GK9fMEXsG0lGaEAUapjskblAYyX7hLlM/jgueSojv2SjhqNF8aXR+gjHLhZVNA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
@@ -562,6 +566,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.19.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.19.tgz",
|
||||||
"integrity": "sha512-TRZfatH1B/kreDwFRwtpLEurJQ6044qh6DWpvxzTbugaG5otLQJKTk+1z81/KsJwQqc1+24v+yuywc1LM7aq7w==",
|
"integrity": "sha512-TRZfatH1B/kreDwFRwtpLEurJQ6044qh6DWpvxzTbugaG5otLQJKTk+1z81/KsJwQqc1+24v+yuywc1LM7aq7w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
@@ -628,6 +633,7 @@
|
|||||||
"integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
|
"integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ampproject/remapping": "^2.2.0",
|
"@ampproject/remapping": "^2.2.0",
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
@@ -1601,6 +1607,7 @@
|
|||||||
"integrity": "sha512-nqhDw2ZcAUrKNPwhjinJny903bRhI0rQhiDz1LksjeRxqa36i3l75+4iXbOy0rlDpLJGxqtgoPavQjmmyS5UJw==",
|
"integrity": "sha512-nqhDw2ZcAUrKNPwhjinJny903bRhI0rQhiDz1LksjeRxqa36i3l75+4iXbOy0rlDpLJGxqtgoPavQjmmyS5UJw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@inquirer/checkbox": "^4.2.1",
|
"@inquirer/checkbox": "^4.2.1",
|
||||||
"@inquirer/confirm": "^5.1.14",
|
"@inquirer/confirm": "^5.1.14",
|
||||||
@@ -3531,6 +3538,7 @@
|
|||||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.19.0"
|
"undici-types": "~7.19.0"
|
||||||
}
|
}
|
||||||
@@ -3887,6 +3895,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.12",
|
"baseline-browser-mapping": "^2.10.12",
|
||||||
"caniuse-lite": "^1.0.30001782",
|
"caniuse-lite": "^1.0.30001782",
|
||||||
@@ -4902,6 +4911,7 @@
|
|||||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "^2.0.0",
|
"accepts": "^2.0.0",
|
||||||
"body-parser": "^2.2.1",
|
"body-parser": "^2.2.1",
|
||||||
@@ -5343,6 +5353,7 @@
|
|||||||
"integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
|
"integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
}
|
}
|
||||||
@@ -5839,7 +5850,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.9.0.tgz",
|
||||||
"integrity": "sha512-OMUvF1iI6+gSRYOhMrH4QYothVLN9C3EJ6wm4g7zLJlnaTl8zbaPOr0bTw70l7QxkoM7sVFOWo83u9B2Fe2Zng==",
|
"integrity": "sha512-OMUvF1iI6+gSRYOhMrH4QYothVLN9C3EJ6wm4g7zLJlnaTl8zbaPOr0bTw70l7QxkoM7sVFOWo83u9B2Fe2Zng==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/jose": {
|
"node_modules/jose": {
|
||||||
"version": "6.2.2",
|
"version": "6.2.2",
|
||||||
@@ -5941,6 +5953,7 @@
|
|||||||
"integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==",
|
"integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@colors/colors": "1.5.0",
|
"@colors/colors": "1.5.0",
|
||||||
"body-parser": "^1.19.0",
|
"body-parser": "^1.19.0",
|
||||||
@@ -6408,6 +6421,7 @@
|
|||||||
"integrity": "sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g==",
|
"integrity": "sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cli-truncate": "^4.0.0",
|
"cli-truncate": "^4.0.0",
|
||||||
"colorette": "^2.0.20",
|
"colorette": "^2.0.20",
|
||||||
@@ -7920,6 +7934,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
}
|
}
|
||||||
@@ -7955,6 +7970,7 @@
|
|||||||
"integrity": "sha512-9GUyuksjw70uNpb1MTYWsH9MQHOHY6kwfnkafC24+7aOMZn9+rVMBxRbLvw756mrBFbIsFg6Xw9IkR2Fnn3k+Q==",
|
"integrity": "sha512-9GUyuksjw70uNpb1MTYWsH9MQHOHY6kwfnkafC24+7aOMZn9+rVMBxRbLvw756mrBFbIsFg6Xw9IkR2Fnn3k+Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^4.0.0",
|
"chokidar": "^4.0.0",
|
||||||
"immutable": "^5.0.2",
|
"immutable": "^5.0.2",
|
||||||
@@ -8574,7 +8590,8 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"license": "0BSD",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/tuf-js": {
|
"node_modules/tuf-js": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
@@ -8612,6 +8629,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -8741,6 +8759,7 @@
|
|||||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -9538,6 +9557,7 @@
|
|||||||
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
|
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
@@ -9556,7 +9576,8 @@
|
|||||||
"version": "0.15.1",
|
"version": "0.15.1",
|
||||||
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz",
|
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz",
|
||||||
"integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==",
|
"integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
window.__RUNTIME_CONFIG__ = {
|
window.__RUNTIME_CONFIG__ = {
|
||||||
API_URL: '${API_URL}',
|
API_URL: "${API_URL}",
|
||||||
WEBSOCKET_URL: '${WEBSOCKET_URL}',
|
WEBSOCKET_URL: "${WEBSOCKET_URL}"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ProfileComponent } from './pages/profile/profile.component';
|
|||||||
import { ChallengesComponent } from './pages/challenges/challenges.component';
|
import { ChallengesComponent } from './pages/challenges/challenges.component';
|
||||||
import { GamesComponent } from './pages/games/games.component';
|
import { GamesComponent } from './pages/games/games.component';
|
||||||
import { TournamentsComponent } from './pages/tournaments/tournaments.component';
|
import { TournamentsComponent } from './pages/tournaments/tournaments.component';
|
||||||
|
import { TournamentWatchComponent } from './pages/tournament-watch/tournament-watch.component';
|
||||||
import { BotsComponent } from './pages/bots/bots.component';
|
import { BotsComponent } from './pages/bots/bots.component';
|
||||||
import { AnalysisComponent } from './pages/analysis/analysis.component';
|
import { AnalysisComponent } from './pages/analysis/analysis.component';
|
||||||
|
|
||||||
@@ -14,8 +15,9 @@ export const routes: Routes = [
|
|||||||
{ path: 'games', component: GamesComponent },
|
{ path: 'games', component: GamesComponent },
|
||||||
{ path: 'challenges', component: ChallengesComponent },
|
{ path: 'challenges', component: ChallengesComponent },
|
||||||
{ path: 'tournaments', component: TournamentsComponent },
|
{ path: 'tournaments', component: TournamentsComponent },
|
||||||
|
{ path: 'tournament/:id/game/:gameId', component: TournamentWatchComponent },
|
||||||
{ path: 'bots', component: BotsComponent },
|
{ path: 'bots', component: BotsComponent },
|
||||||
{ path: 'analysis', component: AnalysisComponent },
|
{ path: 'analysis', component: AnalysisComponent },
|
||||||
{ path: 'game/:gameId', component: GameComponent },
|
{ path: 'game/:gameId', component: GameComponent },
|
||||||
{ path: '**', redirectTo: '' },
|
{ path: '**', redirectTo: '' }
|
||||||
];
|
];
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ describe('App', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [App],
|
imports: [App],
|
||||||
providers: [provideRouter([]), provideHttpClient()],
|
providers: [provideRouter([]), provideHttpClient()]
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<nav class="nc-nav">
|
<nav class="nc-nav">
|
||||||
|
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="nc-logo" (click)="goToHome()" role="button" tabindex="0">
|
<div class="nc-logo" (click)="goToHome()" role="button" tabindex="0">
|
||||||
<div class="nc-logo-mark">♞</div>
|
<div class="nc-logo-mark">♞</div>
|
||||||
@@ -8,17 +9,9 @@
|
|||||||
<!-- Center links — only when logged in -->
|
<!-- Center links — only when logged in -->
|
||||||
@if (currentUser) {
|
@if (currentUser) {
|
||||||
<div class="nc-links">
|
<div class="nc-links">
|
||||||
<button type="button" class="nc-link">
|
<button type="button" class="nc-link" (click)="goToTournaments()">
|
||||||
<svg
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
||||||
width="14"
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.7"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||||
<circle cx="12" cy="12" r="3" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -32,26 +25,15 @@
|
|||||||
|
|
||||||
<!-- Right cluster -->
|
<!-- Right cluster -->
|
||||||
<div class="nc-right">
|
<div class="nc-right">
|
||||||
|
|
||||||
@if (currentUser; as user) {
|
@if (currentUser; as user) {
|
||||||
|
|
||||||
<!-- Notifications bell -->
|
<!-- Notifications bell -->
|
||||||
<div class="nc-dropdown-wrap" data-dropdown="notif">
|
<div class="nc-dropdown-wrap" data-dropdown="notif">
|
||||||
<button
|
<button type="button" class="nc-bell" [class.is-open]="notifOpen" (click)="toggleNotif($event)"
|
||||||
type="button"
|
aria-label="Notifications">
|
||||||
class="nc-bell"
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"
|
||||||
[class.is-open]="notifOpen"
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
(click)="toggleNotif($event)"
|
|
||||||
aria-label="Notifications"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.6"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
||||||
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -73,21 +55,11 @@
|
|||||||
@for (challenge of incomingChallenges; track challenge.id) {
|
@for (challenge of incomingChallenges; track challenge.id) {
|
||||||
<div class="nc-notif-row is-unread">
|
<div class="nc-notif-row is-unread">
|
||||||
<div class="nc-notif-icon">
|
<div class="nc-notif-icon">
|
||||||
<svg
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"
|
||||||
width="14"
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.6"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<line x1="14.5" y1="17.5" x2="3" y2="6" />
|
<line x1="14.5" y1="17.5" x2="3" y2="6" />
|
||||||
<path d="M13 19l6 -6" />
|
<path d="M13 19l6 -6" /><path d="M16 16l4 4" />
|
||||||
<path d="M16 16l4 4" />
|
<path d="M19 21l2 -2" /><path d="M15 5l4 4" />
|
||||||
<path d="M19 21l2 -2" />
|
|
||||||
<path d="M15 5l4 4" />
|
|
||||||
<path d="M21 3l-3 1l-4 4" />
|
<path d="M21 3l-3 1l-4 4" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,49 +69,24 @@
|
|||||||
{{ getTimeControlDisplay(challenge) }} game.
|
{{ getTimeControlDisplay(challenge) }} game.
|
||||||
</div>
|
</div>
|
||||||
<div class="nc-notif-meta">
|
<div class="nc-notif-meta">
|
||||||
{{ challenge.challenger.rating }} ·
|
{{ challenge.challenger.rating }} · {{ challenge.timeControl.type ?? 'Custom' }} · {{ getExpirationInfo(challenge) }}
|
||||||
{{ challenge.timeControl.type ?? 'Custom' }} ·
|
|
||||||
{{ getExpirationInfo(challenge) }}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="nc-notif-actions">
|
<div class="nc-notif-actions">
|
||||||
<button
|
<button type="button" class="nc-btn-accept"
|
||||||
type="button"
|
|
||||||
class="nc-btn-accept"
|
|
||||||
[disabled]="acceptingId === challenge.id || !!decliningId"
|
[disabled]="acceptingId === challenge.id || !!decliningId"
|
||||||
(click)="acceptChallenge($event, challenge)"
|
(click)="acceptChallenge($event, challenge)">
|
||||||
>
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"
|
||||||
<svg
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
width="11"
|
|
||||||
height="11"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2.4"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<polyline points="20 6 9 17 4 12" />
|
<polyline points="20 6 9 17 4 12" />
|
||||||
</svg>
|
</svg>
|
||||||
{{ acceptingId === challenge.id ? '...' : 'Accept' }}
|
{{ acceptingId === challenge.id ? '...' : 'Accept' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" class="nc-btn-decline"
|
||||||
type="button"
|
|
||||||
class="nc-btn-decline"
|
|
||||||
[disabled]="!!acceptingId || decliningId === challenge.id"
|
[disabled]="!!acceptingId || decliningId === challenge.id"
|
||||||
(click)="declineChallenge($event, challenge)"
|
(click)="declineChallenge($event, challenge)">
|
||||||
>
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"
|
||||||
<svg
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
width="10"
|
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||||
height="10"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2.4"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
|
||||||
</svg>
|
</svg>
|
||||||
{{ decliningId === challenge.id ? '...' : 'Decline' }}
|
{{ decliningId === challenge.id ? '...' : 'Decline' }}
|
||||||
</button>
|
</button>
|
||||||
@@ -150,9 +97,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nc-notif-footer">
|
<div class="nc-notif-footer">
|
||||||
<button type="button" class="nc-view-all" (click)="goToChallenges()">
|
<button type="button" class="nc-view-all" (click)="goToChallenges()">View all challenges</button>
|
||||||
View all challenges
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -160,47 +105,23 @@
|
|||||||
|
|
||||||
<!-- Games quick-access -->
|
<!-- Games quick-access -->
|
||||||
<button type="button" class="nc-games-btn" (click)="goToGames()">
|
<button type="button" class="nc-games-btn" (click)="goToGames()">
|
||||||
<svg
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
||||||
width="13"
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
height="13"
|
<path d="M14.5 17.5L3 6"/>
|
||||||
viewBox="0 0 24 24"
|
<path d="M13 19l6-6"/><path d="M16 16l4 4"/>
|
||||||
fill="none"
|
<path d="M19 21l2-2"/><path d="M15 5l4 4"/>
|
||||||
stroke="currentColor"
|
<path d="M21 3l-3 1-4 4"/>
|
||||||
stroke-width="1.7"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M14.5 17.5L3 6" />
|
|
||||||
<path d="M13 19l6-6" />
|
|
||||||
<path d="M16 16l4 4" />
|
|
||||||
<path d="M19 21l2-2" />
|
|
||||||
<path d="M15 5l4 4" />
|
|
||||||
<path d="M21 3l-3 1-4 4" />
|
|
||||||
</svg>
|
</svg>
|
||||||
Games
|
Games
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Profile -->
|
<!-- Profile -->
|
||||||
<div class="nc-dropdown-wrap" data-dropdown="profile">
|
<div class="nc-dropdown-wrap" data-dropdown="profile">
|
||||||
<button
|
<button type="button" class="nc-profile" [class.is-open]="profileOpen" (click)="toggleProfile($event)">
|
||||||
type="button"
|
|
||||||
class="nc-profile"
|
|
||||||
[class.is-open]="profileOpen"
|
|
||||||
(click)="toggleProfile($event)"
|
|
||||||
>
|
|
||||||
<div class="nc-avatar nc-avatar-sm">{{ getInitial() }}</div>
|
<div class="nc-avatar nc-avatar-sm">{{ getInitial() }}</div>
|
||||||
<span class="nc-profile-name">{{ user.username }}</span>
|
<span class="nc-profile-name">{{ user.username }}</span>
|
||||||
<svg
|
<svg class="nc-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
class="nc-chevron"
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
width="12"
|
|
||||||
height="12"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<polyline points="6 9 12 15 18 9" />
|
<polyline points="6 9 12 15 18 9" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -218,16 +139,8 @@
|
|||||||
<div class="nc-menu-group">
|
<div class="nc-menu-group">
|
||||||
<button type="button" class="nc-menu-item" (click)="goToProfile()">
|
<button type="button" class="nc-menu-item" (click)="goToProfile()">
|
||||||
<span class="nc-menu-icon">
|
<span class="nc-menu-icon">
|
||||||
<svg
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
|
||||||
width="14"
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.8"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||||
<circle cx="12" cy="7" r="4" />
|
<circle cx="12" cy="7" r="4" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -237,21 +150,11 @@
|
|||||||
|
|
||||||
<button type="button" class="nc-menu-item" (click)="goToChallenges()">
|
<button type="button" class="nc-menu-item" (click)="goToChallenges()">
|
||||||
<span class="nc-menu-icon">
|
<span class="nc-menu-icon">
|
||||||
<svg
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"
|
||||||
width="14"
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.6"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<line x1="14.5" y1="17.5" x2="3" y2="6" />
|
<line x1="14.5" y1="17.5" x2="3" y2="6" />
|
||||||
<path d="M13 19l6 -6" />
|
<path d="M13 19l6 -6" /><path d="M16 16l4 4" />
|
||||||
<path d="M16 16l4 4" />
|
<path d="M19 21l2 -2" /><path d="M15 5l4 4" />
|
||||||
<path d="M19 21l2 -2" />
|
|
||||||
<path d="M15 5l4 4" />
|
|
||||||
<path d="M21 3l-3 1l-4 4" />
|
<path d="M21 3l-3 1l-4 4" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
@@ -260,16 +163,8 @@
|
|||||||
|
|
||||||
<button type="button" class="nc-menu-item" (click)="toggleTheme($event)">
|
<button type="button" class="nc-menu-item" (click)="toggleTheme($event)">
|
||||||
<span class="nc-menu-icon">
|
<span class="nc-menu-icon">
|
||||||
<svg
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
||||||
width="14"
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.7"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
|
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
@@ -281,16 +176,8 @@
|
|||||||
<div class="nc-menu-group">
|
<div class="nc-menu-group">
|
||||||
<button type="button" class="nc-menu-item danger" (click)="logout()">
|
<button type="button" class="nc-menu-item danger" (click)="logout()">
|
||||||
<span class="nc-menu-icon">
|
<span class="nc-menu-icon">
|
||||||
<svg
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
||||||
width="14"
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.7"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||||
<polyline points="16 17 21 12 16 7" />
|
<polyline points="16 17 21 12 16 7" />
|
||||||
<line x1="21" y1="12" x2="9" y2="12" />
|
<line x1="21" y1="12" x2="9" y2="12" />
|
||||||
@@ -302,20 +189,21 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
} @else {
|
} @else {
|
||||||
|
|
||||||
<!-- Logged-out auth buttons -->
|
<!-- Logged-out auth buttons -->
|
||||||
<button type="button" class="nc-auth-btn" (click)="openLoginDialog()">Login</button>
|
<button type="button" class="nc-auth-btn" (click)="openLoginDialog()">Login</button>
|
||||||
<button type="button" class="nc-auth-btn nc-auth-btn--primary" (click)="openRegisterDialog()">
|
<button type="button" class="nc-auth-btn nc-auth-btn--primary" (click)="openRegisterDialog()">Register</button>
|
||||||
Register
|
|
||||||
</button>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@if (showLoginDialog) {
|
@if (showLoginDialog) {
|
||||||
<app-login-dialog (onClose)="closeLoginDialog()" (onSuccess)="onLoginSuccess()" />
|
<app-login-dialog (onClose)="closeLoginDialog()" (onSuccess)="onLoginSuccess()" />
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (showRegisterDialog) {
|
@if (showRegisterDialog) {
|
||||||
<app-register-dialog (onClose)="closeRegisterDialog()" (onSuccess)="onRegisterSuccess()" />
|
<app-register-dialog (onClose)="closeRegisterDialog()" (onSuccess)="onRegisterSuccess()" />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { Challenge } from '../../models/challenge.models';
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, LoginDialogComponent, RegisterDialogComponent],
|
imports: [CommonModule, LoginDialogComponent, RegisterDialogComponent],
|
||||||
templateUrl: './toolbar.component.html',
|
templateUrl: './toolbar.component.html',
|
||||||
styleUrl: './toolbar.component.css',
|
styleUrl: './toolbar.component.css'
|
||||||
})
|
})
|
||||||
export class ToolbarComponent implements OnInit {
|
export class ToolbarComponent implements OnInit {
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
@@ -46,7 +46,9 @@ export class ToolbarComponent implements OnInit {
|
|||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.destroyRef.onDestroy(() => this.stopPolling());
|
this.destroyRef.onDestroy(() => this.stopPolling());
|
||||||
|
|
||||||
this.authService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user) => {
|
this.authService.currentUser$
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe(user => {
|
||||||
this.currentUser = user;
|
this.currentUser = user;
|
||||||
if (user) {
|
if (user) {
|
||||||
this.challengeWs.connect();
|
this.challengeWs.connect();
|
||||||
@@ -61,21 +63,18 @@ export class ToolbarComponent implements OnInit {
|
|||||||
|
|
||||||
this.authDialogService.dialogState$
|
this.authDialogService.dialogState$
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
.subscribe((state) => {
|
.subscribe(state => {
|
||||||
this.showLoginDialog = state === 'login';
|
this.showLoginDialog = state === 'login';
|
||||||
this.showRegisterDialog = state === 'register';
|
this.showRegisterDialog = state === 'register';
|
||||||
});
|
});
|
||||||
|
|
||||||
this.themeService.darkMode$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((isDark) => {
|
this.themeService.darkMode$
|
||||||
this.isDarkMode = isDark;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.challengeEventService
|
|
||||||
.getIncomingChallenges$()
|
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
.subscribe((challenges) => {
|
.subscribe(isDark => { this.isDarkMode = isDark; });
|
||||||
this.incomingChallenges = challenges;
|
|
||||||
});
|
this.challengeEventService.getIncomingChallenges$()
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe(challenges => { this.incomingChallenges = challenges; });
|
||||||
}
|
}
|
||||||
|
|
||||||
private startPolling(): void {
|
private startPolling(): void {
|
||||||
@@ -92,7 +91,7 @@ export class ToolbarComponent implements OnInit {
|
|||||||
|
|
||||||
private fetchChallenges(): void {
|
private fetchChallenges(): void {
|
||||||
this.challengeService.listChallenges().subscribe({
|
this.challengeService.listChallenges().subscribe({
|
||||||
next: (response) => {
|
next: response => {
|
||||||
const incoming = response.in ?? response.incoming ?? [];
|
const incoming = response.in ?? response.incoming ?? [];
|
||||||
this.challengeEventService.setIncomingChallenges(incoming);
|
this.challengeEventService.setIncomingChallenges(incoming);
|
||||||
|
|
||||||
@@ -105,7 +104,7 @@ export class ToolbarComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,14 +233,12 @@ export class ToolbarComponent implements OnInit {
|
|||||||
if (this.acceptingId || this.decliningId) return;
|
if (this.acceptingId || this.decliningId) return;
|
||||||
this.acceptingId = challenge.id;
|
this.acceptingId = challenge.id;
|
||||||
this.challengeService.acceptChallenge(challenge.id).subscribe({
|
this.challengeService.acceptChallenge(challenge.id).subscribe({
|
||||||
next: (accepted) => {
|
next: accepted => {
|
||||||
this.acceptingId = null;
|
this.acceptingId = null;
|
||||||
this.challengeEventService.onChallengeAccepted(accepted);
|
this.challengeEventService.onChallengeAccepted(accepted);
|
||||||
if (accepted.gameId) void this.router.navigate(['/game', accepted.gameId]);
|
if (accepted.gameId) void this.router.navigate(['/game', accepted.gameId]);
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => { this.acceptingId = null; }
|
||||||
this.acceptingId = null;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,9 +251,7 @@ export class ToolbarComponent implements OnInit {
|
|||||||
this.decliningId = null;
|
this.decliningId = null;
|
||||||
this.challengeEventService.removeChallenge(challenge.id);
|
this.challengeEventService.removeChallenge(challenge.id);
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => { this.decliningId = null; }
|
||||||
this.decliningId = null;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,21 @@ export interface AnalysisRequest {
|
|||||||
depth: number;
|
depth: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RawAnalysisResponse {
|
||||||
|
fen: string;
|
||||||
|
evaluation: number;
|
||||||
|
depth: number;
|
||||||
|
bestMove: string;
|
||||||
|
mate: number | null;
|
||||||
|
continuationMoves: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface AnalysisResponse {
|
export interface AnalysisResponse {
|
||||||
eval: number;
|
eval: number;
|
||||||
winChance: number;
|
winChance: number;
|
||||||
depth: number;
|
depth: number;
|
||||||
bestMove: string;
|
bestMove: string;
|
||||||
|
mate: number | null;
|
||||||
continuations: string[];
|
continuations: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,16 +51,60 @@ export interface TournamentList {
|
|||||||
finished: Tournament[];
|
finished: Tournament[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TournamentMatch {
|
||||||
|
gameId: string;
|
||||||
|
whiteId: string;
|
||||||
|
winner?: 'white' | 'black' | 'draw' | null;
|
||||||
|
status?: GameStatus;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TournamentPairing {
|
export interface TournamentPairing {
|
||||||
id: string;
|
id?: string;
|
||||||
round: number;
|
round?: number;
|
||||||
white: TournamentBotRef | null;
|
white: TournamentBotRef | null;
|
||||||
black: TournamentBotRef;
|
black: TournamentBotRef;
|
||||||
gameId: string | null;
|
matches: TournamentMatch[];
|
||||||
winner: 'white' | 'black' | 'draw' | null;
|
winner?: 'white' | 'black' | 'draw' | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoundPairings {
|
export interface RoundPairings {
|
||||||
round: number;
|
round: number;
|
||||||
pairings: TournamentPairing[];
|
pairings: TournamentPairing[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TournamentStreamEvent =
|
||||||
|
| { type: 'tournamentStarted' }
|
||||||
|
| { type: 'roundStarted'; round: number }
|
||||||
|
| { type: 'gameStart'; round: number; gameId: string; color: 'white' | 'black' }
|
||||||
|
| { type: 'roundFinished'; round: number }
|
||||||
|
| { type: 'tournamentFinished'; winner: TournamentBotRef | null }
|
||||||
|
| { type: 'heartbeat' };
|
||||||
|
|
||||||
|
export interface GameClock {
|
||||||
|
whiteTime: number;
|
||||||
|
blackTime: number;
|
||||||
|
increment: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GameStatus = 'pending' | 'ongoing' | 'checkmate' | 'stalemate' | 'draw' | 'resigned' | 'timeout';
|
||||||
|
|
||||||
|
export interface GameStateSnapshot {
|
||||||
|
id?: string;
|
||||||
|
tournamentId?: string;
|
||||||
|
round?: number;
|
||||||
|
white?: TournamentBotRef;
|
||||||
|
black?: TournamentBotRef;
|
||||||
|
moves: string;
|
||||||
|
fen: string;
|
||||||
|
status: GameStatus;
|
||||||
|
turn: 'white' | 'black';
|
||||||
|
winner: 'white' | 'black' | null;
|
||||||
|
clock?: GameClock;
|
||||||
|
startPosition?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GameStreamEvent =
|
||||||
|
| ({ type: 'gameState' } & GameStateSnapshot)
|
||||||
|
| { type: 'move'; uci: string; fen: string; turn: 'white' | 'black'; clock?: GameClock }
|
||||||
|
| { type: 'gameEnd'; winner: 'white' | 'black' | null; status: GameStatus }
|
||||||
|
| { type: 'heartbeat' };
|
||||||
|
|||||||
@@ -246,7 +246,7 @@
|
|||||||
<!-- Side column -->
|
<!-- Side column -->
|
||||||
<aside class="side">
|
<aside class="side">
|
||||||
<!-- Single position analysis result -->
|
<!-- Single position analysis result -->
|
||||||
@if (positionAnalysis && !hasAnnotations) {
|
@if (positionAnalysis) {
|
||||||
<details class="side-card" open>
|
<details class="side-card" open>
|
||||||
<summary class="side-card-summary">
|
<summary class="side-card-summary">
|
||||||
<span class="side-card-title">Position Analysis</span>
|
<span class="side-card-title">Position Analysis</span>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Component, DestroyRef, inject, OnInit } from '@angular/core';
|
|||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { switchMap, of } from 'rxjs';
|
import { switchMap, of, forkJoin } from 'rxjs';
|
||||||
|
|
||||||
import { ChessBoardComponent } from '../../components/chess-board/chess-board.component';
|
import { ChessBoardComponent } from '../../components/chess-board/chess-board.component';
|
||||||
import { EvalTimelineComponent } from '../../components/eval-timeline/eval-timeline.component';
|
import { EvalTimelineComponent } from '../../components/eval-timeline/eval-timeline.component';
|
||||||
@@ -146,6 +146,7 @@ export class AnalysisComponent implements OnInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.analysing = true;
|
this.analysing = true;
|
||||||
|
this.positionAnalysis = null;
|
||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
const sans =
|
const sans =
|
||||||
this.annotatedMoves.length > 0
|
this.annotatedMoves.length > 0
|
||||||
@@ -207,12 +208,7 @@ export class AnalysisComponent implements OnInit {
|
|||||||
private applyGame(game: GameFull): void {
|
private applyGame(game: GameFull): void {
|
||||||
this.game = game;
|
this.game = game;
|
||||||
this.currentFen = game.state.fen;
|
this.currentFen = game.state.fen;
|
||||||
// Build a flat FEN history from scratch using moves array
|
|
||||||
// The server gives us the final FEN. We reconstruct history by
|
|
||||||
// storing the final FEN; full per-ply history requires per-move API calls
|
|
||||||
// which is out of scope here — we store what we have and allow analysis to proceed.
|
|
||||||
this.fenHistory = [game.state.fen];
|
this.fenHistory = [game.state.fen];
|
||||||
// Seed annotated moves with san strings, no quality yet
|
|
||||||
this.annotatedMoves = game.state.moves.map((san) => ({
|
this.annotatedMoves = game.state.moves.map((san) => ({
|
||||||
san,
|
san,
|
||||||
fen: game.state.fen,
|
fen: game.state.fen,
|
||||||
@@ -223,6 +219,16 @@ export class AnalysisComponent implements OnInit {
|
|||||||
winChanceBefore: null,
|
winChanceBefore: null,
|
||||||
winChanceAfter: null,
|
winChanceAfter: null,
|
||||||
}));
|
}));
|
||||||
|
this.gameApi
|
||||||
|
.getFenHistory(game.gameId)
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe({
|
||||||
|
next: (fens) => {
|
||||||
|
if (fens.length >= 2) {
|
||||||
|
this.fenHistory = fens;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private analyseSinglePosition(fen: string): void {
|
private analyseSinglePosition(fen: string): void {
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
.watch-page {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
color: var(--text-primary, #e6e6e6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
color: var(--text-secondary, #9aa3b2);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.back-link:hover { color: var(--accent, #d4b572); }
|
||||||
|
|
||||||
|
.watch-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.vs { color: var(--text-secondary, #9aa3b2); font-weight: 400; }
|
||||||
|
.round-tag {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(212, 181, 114, 0.15);
|
||||||
|
color: var(--accent, #d4b572);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-error {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(220, 70, 70, 0.12);
|
||||||
|
border: 1px solid rgba(220, 70, 70, 0.3);
|
||||||
|
color: #ff8a8a;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.watch-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-secondary, #9aa3b2);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.pulse {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%;
|
||||||
|
background: var(--accent, #d4b572);
|
||||||
|
animation: pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 0.3; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 320px;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.watch-layout { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-wrap { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
|
||||||
|
.clock {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface, #1b1f27);
|
||||||
|
border: 1px solid var(--border, #2a2f3a);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.clock.active {
|
||||||
|
border-color: var(--accent, #d4b572);
|
||||||
|
box-shadow: 0 0 0 1px rgba(212, 181, 114, 0.3);
|
||||||
|
}
|
||||||
|
.clock-label { font-weight: 500; }
|
||||||
|
.clock-time { font-size: 20px; font-weight: 600; }
|
||||||
|
|
||||||
|
.watch-side { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
.status-card {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface, #1b1f27);
|
||||||
|
border: 1px solid var(--border, #2a2f3a);
|
||||||
|
}
|
||||||
|
.status-line { font-weight: 600; }
|
||||||
|
.status-sub { font-size: 12px; color: var(--text-secondary, #9aa3b2); margin-top: 4px; }
|
||||||
|
|
||||||
|
.moves-card {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface, #1b1f27);
|
||||||
|
border: 1px solid var(--border, #2a2f3a);
|
||||||
|
max-height: 480px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.moves-heading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-secondary, #9aa3b2);
|
||||||
|
}
|
||||||
|
.ply-count {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent, #d4b572);
|
||||||
|
}
|
||||||
|
.moves-empty { color: var(--text-secondary, #9aa3b2); font-size: 13px; }
|
||||||
|
.moves-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.moves-list li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 32px 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.moves-list li:nth-child(odd) { background: rgba(255, 255, 255, 0.02); }
|
||||||
|
.move-no { color: var(--text-secondary, #9aa3b2); }
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<div class="watch-page">
|
||||||
|
<header class="watch-head">
|
||||||
|
<a class="back-link" routerLink="/tournaments">← Tournaments</a>
|
||||||
|
<div class="watch-title">
|
||||||
|
@if (whiteName && blackName) {
|
||||||
|
<span class="player">{{ whiteName }}</span>
|
||||||
|
<span class="vs">vs</span>
|
||||||
|
<span class="player">{{ blackName }}</span>
|
||||||
|
@if (snapshot?.round) {
|
||||||
|
<span class="round-tag">Round {{ snapshot!.round }}</span>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<span class="player">Game {{ gameId }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (error) {
|
||||||
|
<div class="watch-error">{{ error }}</div>
|
||||||
|
} @else if (connecting) {
|
||||||
|
<div class="watch-loading"><span class="pulse"></span>Connecting to stream…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="watch-layout">
|
||||||
|
<div class="board-wrap">
|
||||||
|
<div class="clock clock-top" [class.active]="status === 'ongoing' && turn === 'black'">
|
||||||
|
<span class="clock-label">{{ blackName ?? 'Black' }}</span>
|
||||||
|
<span class="clock-time">{{ formatTime(clock?.blackTime) }}</span>
|
||||||
|
</div>
|
||||||
|
<app-chess-board [fen]="fen"></app-chess-board>
|
||||||
|
<div class="clock clock-bot" [class.active]="status === 'ongoing' && turn === 'white'">
|
||||||
|
<span class="clock-label">{{ whiteName ?? 'White' }}</span>
|
||||||
|
<span class="clock-time">{{ formatTime(clock?.whiteTime) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="watch-side">
|
||||||
|
<div class="status-card" [attr.data-status]="status">
|
||||||
|
<div class="status-line">{{ statusLabel() }}</div>
|
||||||
|
@if (clock?.increment) {
|
||||||
|
<div class="status-sub">+{{ clock!.increment }}s increment</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="moves-card">
|
||||||
|
<h3 class="moves-heading">Moves <span class="ply-count">{{ moves.length }}</span></h3>
|
||||||
|
@if (moves.length === 0) {
|
||||||
|
<p class="moves-empty">No moves yet.</p>
|
||||||
|
} @else {
|
||||||
|
<ol class="moves-list">
|
||||||
|
@for (p of movePairs(); track p.n) {
|
||||||
|
<li>
|
||||||
|
<span class="move-no">{{ p.n }}.</span>
|
||||||
|
<span class="move-uci">{{ p.white }}</span>
|
||||||
|
<span class="move-uci">{{ p.black ?? '' }}</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { Component, DestroyRef, OnInit, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
|
import { from } from 'rxjs';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
import { ChessBoardComponent } from '../../components/chess-board/chess-board.component';
|
||||||
|
import { TournamentStreamService } from '../../services/tournament-stream.service';
|
||||||
|
import { GameClock, GameStateSnapshot, GameStreamEvent, GameStatus } from '../../models/tournament.models';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
|
const INITIAL_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-tournament-watch',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterLink, ChessBoardComponent],
|
||||||
|
templateUrl: './tournament-watch.component.html',
|
||||||
|
styleUrl: './tournament-watch.component.css'
|
||||||
|
})
|
||||||
|
export class TournamentWatchComponent implements OnInit {
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly stream = inject(TournamentStreamService);
|
||||||
|
|
||||||
|
tournamentId = '';
|
||||||
|
gameId = '';
|
||||||
|
serverUrl = '';
|
||||||
|
|
||||||
|
whiteName: string | null = null;
|
||||||
|
blackName: string | null = null;
|
||||||
|
|
||||||
|
fen = INITIAL_FEN;
|
||||||
|
turn: 'white' | 'black' = 'white';
|
||||||
|
status: GameStatus = 'pending';
|
||||||
|
winner: 'white' | 'black' | null = null;
|
||||||
|
clock: GameClock | null = null;
|
||||||
|
moves: string[] = [];
|
||||||
|
snapshot: GameStateSnapshot | null = null;
|
||||||
|
|
||||||
|
connecting = true;
|
||||||
|
error: string | null = null;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.tournamentId = this.route.snapshot.paramMap.get('id') ?? '';
|
||||||
|
this.gameId = this.route.snapshot.paramMap.get('gameId') ?? '';
|
||||||
|
this.serverUrl = this.route.snapshot.queryParamMap.get('server') ?? environment.tournamentServerUrl ?? '';
|
||||||
|
if (!this.tournamentId || !this.gameId) {
|
||||||
|
this.error = 'Missing tournament or game id.';
|
||||||
|
this.connecting = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.serverUrl) {
|
||||||
|
this.error = 'Missing tournament server URL.';
|
||||||
|
this.connecting = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
from(this.stream.fetchGame(this.serverUrl, this.tournamentId, this.gameId))
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe({
|
||||||
|
next: game => {
|
||||||
|
this.applySnapshot(game);
|
||||||
|
this.connecting = false;
|
||||||
|
if (!this.isFinished(game.status)) {
|
||||||
|
this.subscribeToStream();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: err => {
|
||||||
|
this.connecting = false;
|
||||||
|
this.error = (err as Error).message ?? 'Failed to load game.';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private applySnapshot(game: GameStateSnapshot): void {
|
||||||
|
this.whiteName = game.white?.name ?? null;
|
||||||
|
this.blackName = game.black?.name ?? null;
|
||||||
|
this.snapshot = game;
|
||||||
|
this.fen = game.fen;
|
||||||
|
this.turn = game.turn;
|
||||||
|
this.status = game.status;
|
||||||
|
this.winner = game.winner;
|
||||||
|
this.clock = game.clock ?? null;
|
||||||
|
this.moves = game.moves ? game.moves.split(/\s+/).filter(Boolean) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private isFinished(status: GameStatus): boolean {
|
||||||
|
return status !== 'pending' && status !== 'ongoing';
|
||||||
|
}
|
||||||
|
|
||||||
|
private subscribeToStream(): void {
|
||||||
|
this.stream.streamGame(this.serverUrl, this.tournamentId, this.gameId)
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe({
|
||||||
|
next: ev => this.apply(ev),
|
||||||
|
error: err => {
|
||||||
|
this.error = (err as Error).message ?? 'Stream failed.';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
movePairs(): { n: number; white: string; black: string | null }[] {
|
||||||
|
const pairs: { n: number; white: string; black: string | null }[] = [];
|
||||||
|
for (let i = 0; i < this.moves.length; i += 2) {
|
||||||
|
pairs.push({ n: i / 2 + 1, white: this.moves[i], black: this.moves[i + 1] ?? null });
|
||||||
|
}
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatTime(seconds: number | undefined): string {
|
||||||
|
if (seconds === undefined || seconds === null) return '—';
|
||||||
|
const s = Math.max(0, Math.floor(seconds));
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
const r = s % 60;
|
||||||
|
return `${m}:${r.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusLabel(): string {
|
||||||
|
switch (this.status) {
|
||||||
|
case 'pending': return 'Waiting to start';
|
||||||
|
case 'ongoing': return `${this.turn === 'white' ? 'White' : 'Black'} to move`;
|
||||||
|
case 'checkmate': return `Checkmate — ${this.winner ?? '—'} wins`;
|
||||||
|
case 'stalemate': return 'Stalemate';
|
||||||
|
case 'draw': return 'Draw';
|
||||||
|
case 'resigned': return `Resigned — ${this.winner ?? '—'} wins`;
|
||||||
|
case 'timeout': return `Timeout — ${this.winner ?? '—'} wins`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private apply(ev: GameStreamEvent): void {
|
||||||
|
this.connecting = false;
|
||||||
|
switch (ev.type) {
|
||||||
|
case 'gameState':
|
||||||
|
this.snapshot = ev;
|
||||||
|
this.fen = ev.fen;
|
||||||
|
this.turn = ev.turn;
|
||||||
|
this.status = ev.status;
|
||||||
|
this.winner = ev.winner;
|
||||||
|
this.clock = ev.clock ?? null;
|
||||||
|
this.moves = ev.moves ? ev.moves.split(/\s+/).filter(Boolean) : [];
|
||||||
|
return;
|
||||||
|
case 'move':
|
||||||
|
this.fen = ev.fen;
|
||||||
|
this.turn = ev.turn;
|
||||||
|
if (ev.clock) this.clock = ev.clock;
|
||||||
|
this.moves = [...this.moves, ev.uci];
|
||||||
|
return;
|
||||||
|
case 'gameEnd':
|
||||||
|
this.status = ev.status;
|
||||||
|
this.winner = ev.winner;
|
||||||
|
return;
|
||||||
|
case 'heartbeat':
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,18 @@
|
|||||||
}
|
}
|
||||||
.page-title { font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
|
.page-title { font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
|
||||||
|
|
||||||
|
.page-actions { display: flex; align-items: center; gap: 8px; }
|
||||||
|
|
||||||
|
.btn-servers {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 7px 14px; border-radius: 8px;
|
||||||
|
border: 1px solid var(--nc-border-strong);
|
||||||
|
background: transparent; color: var(--nc-text-muted);
|
||||||
|
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.btn-servers:hover { color: var(--nc-text); border-color: var(--nc-text-muted); }
|
||||||
|
|
||||||
.btn-new {
|
.btn-new {
|
||||||
display: inline-flex; align-items: center; gap: 6px;
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
padding: 7px 14px; border-radius: 8px; border: none;
|
padding: 7px 14px; border-radius: 8px; border: none;
|
||||||
@@ -327,3 +339,81 @@
|
|||||||
color: var(--nc-success); letter-spacing: 0.06em; text-transform: uppercase;
|
color: var(--nc-success); letter-spacing: 0.06em; text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.pairing-ongoing svg { animation: pulse 1.4s ease-in-out infinite; }
|
.pairing-ongoing svg { animation: pulse 1.4s ease-in-out infinite; }
|
||||||
|
|
||||||
|
.pairing-review {
|
||||||
|
font-size: 10px; font-weight: 700; letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase; color: var(--nc-neon);
|
||||||
|
opacity: 0; transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.pairing-row.is-watchable:hover .pairing-review { opacity: 1; }
|
||||||
|
|
||||||
|
/* Official bot join section */
|
||||||
|
.join-divider {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
margin: 20px 0 14px;
|
||||||
|
}
|
||||||
|
.join-divider::before, .join-divider::after {
|
||||||
|
content: ''; flex: 1; height: 1px; background: var(--nc-border);
|
||||||
|
}
|
||||||
|
.join-divider-label {
|
||||||
|
font-size: 10px; font-weight: 600; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em; color: var(--nc-text-dim); white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.official-bot-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.official-bot-btn {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||||
|
padding: 9px 4px; border-radius: 8px; border: 1px solid var(--nc-border);
|
||||||
|
background: var(--nc-surface); font-size: 12px; font-weight: 700;
|
||||||
|
cursor: pointer; transition: border-color 0.15s, background 0.15s, color 0.15s;
|
||||||
|
color: var(--nc-text-muted); text-transform: capitalize;
|
||||||
|
}
|
||||||
|
.official-bot-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
.official-btn-easy:hover:not(:disabled) { border-color: var(--nc-success); color: var(--nc-success); background: rgba(94,229,161,0.07); }
|
||||||
|
.official-btn-medium:hover:not(:disabled) { border-color: var(--nc-warn); color: var(--nc-warn); background: rgba(255,209,102,0.07); }
|
||||||
|
.official-btn-hard:hover:not(:disabled) { border-color: var(--nc-neon); color: var(--nc-neon); background: rgba(255,69,200,0.07); }
|
||||||
|
.official-btn-expert:hover:not(:disabled) { border-color: var(--nc-danger); color: var(--nc-danger); background: rgba(255,122,122,0.07); }
|
||||||
|
|
||||||
|
/* Servers dialog */
|
||||||
|
.servers-dialog { max-width: 480px; }
|
||||||
|
|
||||||
|
.servers-list {
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.server-row {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 10px 12px; border-radius: 8px;
|
||||||
|
border: 1px solid var(--nc-border); background: var(--nc-surface);
|
||||||
|
}
|
||||||
|
.server-info { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||||
|
.server-label { font-size: 13px; font-weight: 600; color: var(--nc-text); }
|
||||||
|
.server-url {
|
||||||
|
font-size: 11px; color: var(--nc-text-dim);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.server-remove-btn {
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
color: var(--nc-text-dim); padding: 4px; border-radius: 6px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
flex-shrink: 0; transition: color 0.15s, background 0.15s;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.server-remove-btn:hover:not(:disabled) { color: var(--nc-danger); background: rgba(255,122,122,0.1); }
|
||||||
|
.server-remove-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.server-add-form {
|
||||||
|
border-top: 1px solid var(--nc-border);
|
||||||
|
padding-top: 16px;
|
||||||
|
display: flex; flex-direction: column; gap: 0;
|
||||||
|
}
|
||||||
|
.server-add-heading {
|
||||||
|
font-size: 11px; font-weight: 700; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em; color: var(--nc-text-muted);
|
||||||
|
margin: 0 0 14px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,16 @@
|
|||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<div class="page-title-row">
|
<div class="page-title-row">
|
||||||
<h1 class="page-title">Tournaments</h1>
|
<h1 class="page-title">Tournaments</h1>
|
||||||
|
<div class="page-actions">
|
||||||
@if (currentUser) {
|
@if (currentUser) {
|
||||||
|
<button type="button" class="btn-servers" (click)="openServersDialog()">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/>
|
||||||
|
<line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
Servers
|
||||||
|
</button>
|
||||||
<button type="button" class="btn-new" (click)="openCreateDialog()">
|
<button type="button" class="btn-new" (click)="openCreateDialog()">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
|
stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
|
||||||
@@ -26,6 +35,7 @@
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="tabs" role="tablist">
|
<div class="tabs" role="tablist">
|
||||||
<button type="button" class="tab-btn" [class.active]="tab === 'started'" (click)="setTab('started')">
|
<button type="button" class="tab-btn" [class.active]="tab === 'started'" (click)="setTab('started')">
|
||||||
Live
|
Live
|
||||||
@@ -148,9 +158,10 @@
|
|||||||
<div class="state-msg small"><span class="pulse"></span>Loading…</div>
|
<div class="state-msg small"><span class="pulse"></span>Loading…</div>
|
||||||
} @else if (pairings && pairings.pairings.length > 0) {
|
} @else if (pairings && pairings.pairings.length > 0) {
|
||||||
<div class="pairings-list">
|
<div class="pairings-list">
|
||||||
@for (p of pairings.pairings; track p.id) {
|
@for (p of pairings.pairings; track pairingKey(p)) {
|
||||||
<div class="pairing-row" [class.is-watchable]="!!p.gameId"
|
@let gid = firstGameId(p);
|
||||||
(click)="p.gameId && watchGame(p.gameId)">
|
<div class="pairing-row" [class.is-watchable]="!!gid"
|
||||||
|
(click)="gid && watchGame(gid)">
|
||||||
<span class="pairing-white">{{ p.white?.name ?? 'Bye' }}</span>
|
<span class="pairing-white">{{ p.white?.name ?? 'Bye' }}</span>
|
||||||
<span class="pairing-vs">vs</span>
|
<span class="pairing-vs">vs</span>
|
||||||
<span class="pairing-black">{{ p.black.name }}</span>
|
<span class="pairing-black">{{ p.black.name }}</span>
|
||||||
@@ -158,7 +169,7 @@
|
|||||||
<span class="pairing-result" [class]="'result-' + p.winner">
|
<span class="pairing-result" [class]="'result-' + p.winner">
|
||||||
{{ p.winner === 'draw' ? '½–½' : p.winner === 'white' ? '1–0' : '0–1' }}
|
{{ p.winner === 'draw' ? '½–½' : p.winner === 'white' ? '1–0' : '0–1' }}
|
||||||
</span>
|
</span>
|
||||||
} @else if (p.gameId) {
|
} @else if (gid) {
|
||||||
<span class="pairing-ongoing">
|
<span class="pairing-ongoing">
|
||||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<circle cx="12" cy="12" r="10"/>
|
<circle cx="12" cy="12" r="10"/>
|
||||||
@@ -175,6 +186,35 @@
|
|||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!-- Previous rounds -->
|
||||||
|
@if (previousRoundsLoading) {
|
||||||
|
<div class="state-msg small"><span class="pulse"></span>Loading previous rounds…</div>
|
||||||
|
}
|
||||||
|
@for (rp of previousRoundPairings; track rp.round) {
|
||||||
|
<section class="detail-section">
|
||||||
|
<h3 class="detail-heading">Round {{ rp.round }} pairings</h3>
|
||||||
|
<div class="pairings-list">
|
||||||
|
@for (p of rp.pairings; track pairingKey(p)) {
|
||||||
|
@let gid = firstGameId(p);
|
||||||
|
<div class="pairing-row" [class.is-watchable]="!!gid"
|
||||||
|
(click)="gid && watchGame(gid)">
|
||||||
|
<span class="pairing-white">{{ p.white?.name ?? 'Bye' }}</span>
|
||||||
|
<span class="pairing-vs">vs</span>
|
||||||
|
<span class="pairing-black">{{ p.black.name }}</span>
|
||||||
|
@if (p.winner) {
|
||||||
|
<span class="pairing-result" [class]="'result-' + p.winner">
|
||||||
|
{{ p.winner === 'draw' ? '½–½' : p.winner === 'white' ? '1–0' : '0–1' }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
@if (gid) {
|
||||||
|
<span class="pairing-review">Review</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -192,17 +232,16 @@
|
|||||||
<span class="dialog-brand">Join with a bot</span>
|
<span class="dialog-brand">Join with a bot</span>
|
||||||
<button type="button" class="dialog-close" (click)="closeJoinDialog()">×</button>
|
<button type="button" class="dialog-close" (click)="closeJoinDialog()">×</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="join-hint">Select one of your bots to enter this tournament. Its token will be rotated to authenticate the join.</p>
|
|
||||||
|
|
||||||
@if (botsLoading) {
|
@if (botsLoading) {
|
||||||
<div class="dialog-loading"><span class="pulse"></span>Loading bots…</div>
|
<div class="dialog-loading"><span class="pulse"></span>Loading bots…</div>
|
||||||
} @else if (userBots.length === 0) {
|
} @else {
|
||||||
<p class="join-empty">You have no bots yet. Go to <strong>Bots</strong> in the nav to create one first.</p>
|
@if (userBots.length === 0) {
|
||||||
|
<p class="join-empty">You have no bots. <a routerLink="/bots">Create one</a> to join with your own bot.</p>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="bot-pick-list">
|
<div class="bot-pick-list">
|
||||||
@for (bot of userBots; track bot.id) {
|
@for (bot of userBots; track bot.id) {
|
||||||
<button type="button" class="bot-pick-row"
|
<button type="button" class="bot-pick-row"
|
||||||
[disabled]="!!joiningBotId"
|
[disabled]="!!joiningBotId || !!joiningOfficialDifficulty"
|
||||||
(click)="joinWithBot(bot)">
|
(click)="joinWithBot(bot)">
|
||||||
<span class="bot-pick-avatar">{{ bot.name.charAt(0).toUpperCase() }}</span>
|
<span class="bot-pick-avatar">{{ bot.name.charAt(0).toUpperCase() }}</span>
|
||||||
<span class="bot-pick-name">{{ bot.name }}</span>
|
<span class="bot-pick-name">{{ bot.name }}</span>
|
||||||
@@ -218,6 +257,67 @@
|
|||||||
@if (joinError) {
|
@if (joinError) {
|
||||||
<div class="dialog-error">{{ joinError }}</div>
|
<div class="dialog-error">{{ joinError }}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<div class="join-divider">
|
||||||
|
<span class="join-divider-label">or join with an official bot</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (officialBots.length === 0) {
|
||||||
|
<p class="join-empty">No official bots are available. The official-bots engine service must be running to register them.</p>
|
||||||
|
} @else {
|
||||||
|
<div class="official-bot-grid">
|
||||||
|
@for (d of officialDifficulties; track d) {
|
||||||
|
<button type="button" class="official-bot-btn"
|
||||||
|
[class]="'official-btn-' + d"
|
||||||
|
[disabled]="!!joiningOfficialDifficulty || !!joiningBotId"
|
||||||
|
(click)="joinWithOfficialBot(d)">
|
||||||
|
@if (joiningOfficialDifficulty === d) {
|
||||||
|
<span class="pulse"></span>
|
||||||
|
}
|
||||||
|
{{ d | titlecase }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (officialJoinError) {
|
||||||
|
<div class="dialog-error">{{ officialJoinError }}</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showServersDialog) {
|
||||||
|
<div class="dialog-overlay" (click)="closeServersDialog()">
|
||||||
|
<div class="dialog-card servers-dialog" (click)="$event.stopPropagation()">
|
||||||
|
<div class="dialog-head">
|
||||||
|
<span class="dialog-brand">Tournament servers</span>
|
||||||
|
<button type="button" class="dialog-close" (click)="closeServersDialog()">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="join-hint">External tournament servers aggregated into this view. Tournaments from all servers appear in the list.</p>
|
||||||
|
|
||||||
|
@if (serversLoading) {
|
||||||
|
<div class="dialog-loading"><span class="pulse"></span>Loading servers…</div>
|
||||||
|
} @else if (servers.length === 0) {
|
||||||
|
<p class="join-empty">No external servers registered.</p>
|
||||||
|
} @else {
|
||||||
|
<div class="servers-list">
|
||||||
|
@for (s of servers; track s.id) {
|
||||||
|
<div class="server-row">
|
||||||
|
<div class="server-info">
|
||||||
|
<span class="server-label">{{ s.label }}</span>
|
||||||
|
<span class="server-url">{{ s.url }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button type="button" class="btn-ghost" (click)="closeServersDialog()">Close</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
import { Component, DestroyRef, OnInit, inject } from '@angular/core';
|
import { Component, DestroyRef, OnInit, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule, TitleCasePipe } from '@angular/common';
|
||||||
import { Router, RouterLink } from '@angular/router';
|
import { Router, RouterLink } from '@angular/router';
|
||||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
import { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
import { forkJoin } from 'rxjs';
|
||||||
import { TournamentService } from '../../services/tournament.service';
|
import { TournamentService } from '../../services/tournament.service';
|
||||||
import { AuthService } from '../../services/auth.service';
|
import { AuthService } from '../../services/auth.service';
|
||||||
import { BotService } from '../../services/bot.service';
|
import { BotService } from '../../services/bot.service';
|
||||||
|
import { OfficialBotService } from '../../services/official-bot.service';
|
||||||
|
import { TournamentServerService, ExternalTournamentServer } from '../../services/tournament-server.service';
|
||||||
import { Bot } from '../../models/bot.models';
|
import { Bot } from '../../models/bot.models';
|
||||||
import { Tournament, TournamentResult, RoundPairings } from '../../models/tournament.models';
|
import { Tournament, TournamentResult, RoundPairings, TournamentPairing } from '../../models/tournament.models';
|
||||||
import { CurrentUser } from '../../models/auth.models';
|
import { CurrentUser } from '../../models/auth.models';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
type StatusTab = 'started' | 'created' | 'finished';
|
type StatusTab = 'started' | 'created' | 'finished';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-tournaments',
|
selector: 'app-tournaments',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterLink, ReactiveFormsModule],
|
imports: [CommonModule, RouterLink, FormsModule, ReactiveFormsModule, TitleCasePipe],
|
||||||
templateUrl: './tournaments.component.html',
|
templateUrl: './tournaments.component.html',
|
||||||
styleUrl: './tournaments.component.css'
|
styleUrl: './tournaments.component.css'
|
||||||
})
|
})
|
||||||
@@ -25,6 +29,8 @@ export class TournamentsComponent implements OnInit {
|
|||||||
private readonly authService = inject(AuthService);
|
private readonly authService = inject(AuthService);
|
||||||
private readonly fb = inject(FormBuilder);
|
private readonly fb = inject(FormBuilder);
|
||||||
private readonly botService = inject(BotService);
|
private readonly botService = inject(BotService);
|
||||||
|
private readonly officialBotService = inject(OfficialBotService);
|
||||||
|
private readonly tournamentServerService = inject(TournamentServerService);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
loading = true;
|
loading = true;
|
||||||
@@ -38,6 +44,8 @@ export class TournamentsComponent implements OnInit {
|
|||||||
selectedTournament: Tournament | null = null;
|
selectedTournament: Tournament | null = null;
|
||||||
pairings: RoundPairings | null = null;
|
pairings: RoundPairings | null = null;
|
||||||
pairingsLoading = false;
|
pairingsLoading = false;
|
||||||
|
previousRoundPairings: RoundPairings[] = [];
|
||||||
|
previousRoundsLoading = false;
|
||||||
|
|
||||||
showCreateDialog = false;
|
showCreateDialog = false;
|
||||||
createForm: FormGroup = this.fb.group({
|
createForm: FormGroup = this.fb.group({
|
||||||
@@ -54,15 +62,27 @@ export class TournamentsComponent implements OnInit {
|
|||||||
|
|
||||||
joinDialogTournamentId: string | null = null;
|
joinDialogTournamentId: string | null = null;
|
||||||
userBots: Bot[] = [];
|
userBots: Bot[] = [];
|
||||||
|
officialBots: Bot[] = [];
|
||||||
botsLoading = false;
|
botsLoading = false;
|
||||||
joiningBotId: string | null = null;
|
joiningBotId: string | null = null;
|
||||||
joinError: string | null = null;
|
joinError: string | null = null;
|
||||||
|
|
||||||
|
readonly officialDifficulties = ['easy', 'medium', 'hard', 'expert'] as const;
|
||||||
|
joiningOfficialDifficulty: string | null = null;
|
||||||
|
officialJoinError: string | null = null;
|
||||||
|
|
||||||
|
showServersDialog = false;
|
||||||
|
servers: ExternalTournamentServer[] = [];
|
||||||
|
serversLoading = false;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.authService.currentUser$
|
this.authService.currentUser$
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
.subscribe(u => { this.currentUser = u; });
|
.subscribe(u => { this.currentUser = u; });
|
||||||
this.loadTournaments();
|
this.loadTournaments();
|
||||||
|
this.tournamentServerService.list()
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe({ next: res => { this.servers = res.servers; }, error: () => {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
openCreateDialog(): void {
|
openCreateDialog(): void {
|
||||||
@@ -98,19 +118,35 @@ export class TournamentsComponent implements OnInit {
|
|||||||
this.tab = tab;
|
this.tab = tab;
|
||||||
this.selectedTournament = null;
|
this.selectedTournament = null;
|
||||||
this.pairings = null;
|
this.pairings = null;
|
||||||
|
this.previousRoundPairings = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
selectTournament(t: Tournament): void {
|
selectTournament(t: Tournament): void {
|
||||||
if (this.selectedTournament?.id === t.id) {
|
if (this.selectedTournament?.id === t.id) {
|
||||||
this.selectedTournament = null;
|
this.selectedTournament = null;
|
||||||
this.pairings = null;
|
this.pairings = null;
|
||||||
|
this.previousRoundPairings = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.selectedTournament = t;
|
this.selectedTournament = t;
|
||||||
this.pairings = null;
|
this.pairings = null;
|
||||||
|
this.previousRoundPairings = [];
|
||||||
if (t.round > 0) {
|
if (t.round > 0) {
|
||||||
this.loadPairings(t.id, t.round);
|
this.loadPairings(t.id, t.round);
|
||||||
}
|
}
|
||||||
|
if (t.round > 1) {
|
||||||
|
this.previousRoundsLoading = true;
|
||||||
|
const rounds = Array.from({ length: t.round - 1 }, (_, i) => i + 1);
|
||||||
|
forkJoin(rounds.map(r => this.tournamentService.roundPairings(t.id, r)))
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe({
|
||||||
|
next: results => {
|
||||||
|
this.previousRoundPairings = [...results].reverse();
|
||||||
|
this.previousRoundsLoading = false;
|
||||||
|
},
|
||||||
|
error: () => { this.previousRoundsLoading = false; }
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get activeList(): Tournament[] {
|
get activeList(): Tournament[] {
|
||||||
@@ -149,8 +185,27 @@ export class TournamentsComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
firstGameId(p: TournamentPairing): string | null {
|
||||||
|
return p.matches?.[0]?.gameId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
pairingKey(p: TournamentPairing): string {
|
||||||
|
const w = p.white?.id ?? 'bye';
|
||||||
|
const b = p.black?.id ?? 'bye';
|
||||||
|
return `${w}-${b}-${this.firstGameId(p) ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
watchGame(gameId: string): void {
|
watchGame(gameId: string): void {
|
||||||
void this.router.navigate(['/game', gameId]);
|
const tid = this.selectedTournament?.id;
|
||||||
|
if (!tid) return;
|
||||||
|
const server = this.servers[0]?.url || environment.tournamentServerUrl;
|
||||||
|
if (!server) {
|
||||||
|
this.joinError = 'No tournament server configured. Cannot open stream.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void this.router.navigate(['/tournament', tid, 'game', gameId], {
|
||||||
|
queryParams: { server }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
openJoinDialog(event: MouseEvent, tournamentId: string): void {
|
openJoinDialog(event: MouseEvent, tournamentId: string): void {
|
||||||
@@ -158,27 +213,57 @@ export class TournamentsComponent implements OnInit {
|
|||||||
this.joinDialogTournamentId = tournamentId;
|
this.joinDialogTournamentId = tournamentId;
|
||||||
this.joinError = null;
|
this.joinError = null;
|
||||||
this.botsLoading = true;
|
this.botsLoading = true;
|
||||||
this.botService.list()
|
forkJoin({ user: this.botService.list(), official: this.botService.listOfficial() })
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: bots => { this.userBots = bots; this.botsLoading = false; },
|
next: ({ user, official }) => {
|
||||||
|
this.userBots = user;
|
||||||
|
this.officialBots = official;
|
||||||
|
this.botsLoading = false;
|
||||||
|
},
|
||||||
error: () => { this.botsLoading = false; }
|
error: () => { this.botsLoading = false; }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
closeJoinDialog(): void {
|
closeJoinDialog(): void {
|
||||||
this.joinDialogTournamentId = null;
|
this.joinDialogTournamentId = null;
|
||||||
|
this.userBots = [];
|
||||||
|
this.officialBots = [];
|
||||||
this.joiningBotId = null;
|
this.joiningBotId = null;
|
||||||
this.joinError = null;
|
this.joinError = null;
|
||||||
|
this.joiningOfficialDifficulty = null;
|
||||||
|
this.officialJoinError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
joinWithOfficialBot(difficulty: string): void {
|
||||||
|
if (!this.joinDialogTournamentId || this.joiningOfficialDifficulty || this.joiningBotId) return;
|
||||||
|
this.joiningOfficialDifficulty = difficulty;
|
||||||
|
this.officialJoinError = null;
|
||||||
|
const tid = this.joinDialogTournamentId;
|
||||||
|
this.officialBotService.joinTournament(tid, difficulty).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.joiningOfficialDifficulty = null;
|
||||||
|
this.closeJoinDialog();
|
||||||
|
this.tournamentService.get(tid)
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe(updated => {
|
||||||
|
this.created = this.created.map(x => x.id === tid ? updated : x);
|
||||||
|
this.started = this.started.map(x => x.id === tid ? updated : x);
|
||||||
|
if (this.selectedTournament?.id === tid) this.selectedTournament = updated;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error: err => {
|
||||||
|
this.joiningOfficialDifficulty = null;
|
||||||
|
this.officialJoinError = err.error?.error ?? 'Failed to join with official bot.';
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
joinWithBot(bot: Bot): void {
|
joinWithBot(bot: Bot): void {
|
||||||
if (!this.joinDialogTournamentId || this.joiningBotId) return;
|
if (!this.joinDialogTournamentId || this.joiningBotId) return;
|
||||||
this.joiningBotId = bot.id;
|
this.joiningBotId = bot.id;
|
||||||
this.joinError = null;
|
this.joinError = null;
|
||||||
this.botService.rotateToken(bot.id).subscribe({
|
this.tournamentService.join(this.joinDialogTournamentId, bot.id, bot.name).subscribe({
|
||||||
next: token => {
|
|
||||||
this.tournamentService.joinWithBotToken(this.joinDialogTournamentId!, token).subscribe({
|
|
||||||
next: () => {
|
next: () => {
|
||||||
this.joiningBotId = null;
|
this.joiningBotId = null;
|
||||||
const tid = this.joinDialogTournamentId!;
|
const tid = this.joinDialogTournamentId!;
|
||||||
@@ -196,14 +281,23 @@ export class TournamentsComponent implements OnInit {
|
|||||||
this.joinError = err.error?.message ?? err.error?.error ?? 'Failed to join tournament.';
|
this.joinError = err.error?.message ?? err.error?.error ?? 'Failed to join tournament.';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
this.joiningBotId = null;
|
|
||||||
this.joinError = 'Failed to get bot token.';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openServersDialog(): void {
|
||||||
|
this.showServersDialog = true;
|
||||||
|
this.serversLoading = true;
|
||||||
|
this.tournamentServerService.list()
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe({
|
||||||
|
next: res => { this.servers = res.servers; this.serversLoading = false; },
|
||||||
|
error: () => { this.serversLoading = false; }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
closeServersDialog(): void {
|
||||||
|
this.showServersDialog = false;
|
||||||
|
}
|
||||||
|
|
||||||
private loadTournaments(): void {
|
private loadTournaments(): void {
|
||||||
this.tournamentService.list()
|
this.tournamentService.list()
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
|||||||
req.url.includes('/api/account/official-bots') ||
|
req.url.includes('/api/account/official-bots') ||
|
||||||
req.url.includes('/api/board/game') ||
|
req.url.includes('/api/board/game') ||
|
||||||
req.url.includes('/api/challenge') ||
|
req.url.includes('/api/challenge') ||
|
||||||
req.url.includes('/api/tournament');
|
req.url.includes('/api/tournament') ||
|
||||||
|
req.url.includes('/api/bots');
|
||||||
|
|
||||||
if (token && isProtectedEndpoint && !req.headers.has('Authorization')) {
|
if (token && isProtectedEndpoint && !req.headers.has('Authorization')) {
|
||||||
req = req.clone({
|
req = req.clone({
|
||||||
|
|||||||
@@ -7,11 +7,16 @@ import { Bot, BotWithToken } from '../models/bot.models';
|
|||||||
export class BotService {
|
export class BotService {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
private readonly base = '/api/account/bots';
|
private readonly base = '/api/account/bots';
|
||||||
|
private readonly officialBase = '/api/account/official-bots';
|
||||||
|
|
||||||
list(): Observable<Bot[]> {
|
list(): Observable<Bot[]> {
|
||||||
return this.http.get<Bot[]>(this.base);
|
return this.http.get<Bot[]>(this.base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listOfficial(): Observable<Bot[]> {
|
||||||
|
return this.http.get<Bot[]>(this.officialBase);
|
||||||
|
}
|
||||||
|
|
||||||
create(name: string): Observable<BotWithToken> {
|
create(name: string): Observable<BotWithToken> {
|
||||||
return this.http.post<BotWithToken>(this.base, { name });
|
return this.http.post<BotWithToken>(this.base, { name });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export class ChallengeWebSocketService {
|
|||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
const url = `${environment.userWsBaseUrl}/api/user/ws?token=${encodeURIComponent(token)}`;
|
const url = `${environment.userWsBaseUrl}/api/user/ws`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.intentionalClose = false;
|
this.intentionalClose = false;
|
||||||
@@ -30,6 +30,7 @@ export class ChallengeWebSocketService {
|
|||||||
|
|
||||||
this.ws.onopen = () => {
|
this.ws.onopen = () => {
|
||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
|
this.ws?.send(JSON.stringify({ type: 'auth', token }));
|
||||||
};
|
};
|
||||||
|
|
||||||
this.ws.onmessage = (event) => {
|
this.ws.onmessage = (event) => {
|
||||||
@@ -76,8 +77,10 @@ export class ChallengeWebSocketService {
|
|||||||
const challengeId = message['challengeId'] as string | undefined;
|
const challengeId = message['challengeId'] as string | undefined;
|
||||||
if (challengeId) {
|
if (challengeId) {
|
||||||
this.challengeService.getChallenge(challengeId).subscribe({
|
this.challengeService.getChallenge(challengeId).subscribe({
|
||||||
next: challenge => this.challengeEventService.onChallengeReceived(challenge),
|
next: (challenge) => this.challengeEventService.onChallengeReceived(challenge),
|
||||||
error: () => { /* challenge may have already expired */ }
|
error: () => {
|
||||||
|
/* challenge may have already expired */
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -110,6 +113,8 @@ export class ChallengeWebSocketService {
|
|||||||
private attemptReconnect(): void {
|
private attemptReconnect(): void {
|
||||||
if (this.intentionalClose || this.reconnectAttempts >= this.maxReconnectAttempts) return;
|
if (this.intentionalClose || this.reconnectAttempts >= this.maxReconnectAttempts) return;
|
||||||
this.reconnectAttempts++;
|
this.reconnectAttempts++;
|
||||||
setTimeout(() => { this.connect(); }, this.reconnectDelay);
|
setTimeout(() => {
|
||||||
|
this.connect();
|
||||||
|
}, this.reconnectDelay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
import {
|
import {
|
||||||
GameFull,
|
GameFull,
|
||||||
@@ -9,7 +10,7 @@ import {
|
|||||||
LegalMovesResponse,
|
LegalMovesResponse,
|
||||||
PlayerInfo,
|
PlayerInfo,
|
||||||
} from '../models/game.models';
|
} from '../models/game.models';
|
||||||
import { AnalysisRequest, AnalysisResponse } from '../models/analysis.models';
|
import { AnalysisRequest, AnalysisResponse, RawAnalysisResponse } from '../models/analysis.models';
|
||||||
import { StreamHandlerService } from './stream-handler.service';
|
import { StreamHandlerService } from './stream-handler.service';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -78,8 +79,28 @@ export class GameApiService {
|
|||||||
return this.http.post<void>(`${this.apiBase}${this.apiPath}/${gameId}/draw/offer`, {});
|
return this.http.post<void>(`${this.apiBase}${this.apiPath}/${gameId}/draw/offer`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getFenHistory(gameId: string): Observable<string[]> {
|
||||||
|
return this.http
|
||||||
|
.get<{ fens: string[] }>(`${this.apiBase}${this.apiPath}/${gameId}/fen-history`)
|
||||||
|
.pipe(map((r) => r.fens));
|
||||||
|
}
|
||||||
|
|
||||||
analyzePosition(request: AnalysisRequest): Observable<AnalysisResponse> {
|
analyzePosition(request: AnalysisRequest): Observable<AnalysisResponse> {
|
||||||
return this.http.post<AnalysisResponse>(`${this.apiBase}/api/analysis/position`, request);
|
return this.http
|
||||||
|
.post<RawAnalysisResponse>(`${this.apiBase}/api/analysis/position`, request)
|
||||||
|
.pipe(map((raw) => this.mapAnalysisResponse(raw)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapAnalysisResponse(raw: RawAnalysisResponse): AnalysisResponse {
|
||||||
|
const evalPawns = raw.evaluation / 100;
|
||||||
|
return {
|
||||||
|
eval: evalPawns,
|
||||||
|
winChance: 1 / (1 + Math.exp(-0.374 * evalPawns)),
|
||||||
|
depth: raw.depth,
|
||||||
|
bestMove: raw.bestMove,
|
||||||
|
mate: raw.mate,
|
||||||
|
continuations: raw.continuationMoves ?? [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveWsBase(): string {
|
private resolveWsBase(): string {
|
||||||
@@ -92,11 +113,8 @@ export class GameApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
streamGame(gameId: string): Observable<GameStreamEvent> {
|
streamGame(gameId: string): Observable<GameStreamEvent> {
|
||||||
const token = localStorage.getItem('token');
|
const wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/ws`;
|
||||||
let wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/ws`;
|
const token = localStorage.getItem('token') ?? '';
|
||||||
if (token) {
|
return this.streamHandler.createGameStream(wsUrl, gameId, token);
|
||||||
wsUrl += `?token=${encodeURIComponent(token)}`;
|
|
||||||
}
|
|
||||||
return this.streamHandler.createGameStream(wsUrl, gameId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ const WS_CONNECT_TIMEOUT_MS = 3000;
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class StreamHandlerService {
|
export class StreamHandlerService {
|
||||||
createGameStream(wsUrl: string, gameId: string): Observable<GameStreamEvent> {
|
createGameStream(wsUrl: string, gameId: string, token: string): Observable<GameStreamEvent> {
|
||||||
return new Observable<GameStreamEvent>((observer) => {
|
return new Observable<GameStreamEvent>((observer) => {
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
let connected = false;
|
let connected = false;
|
||||||
@@ -14,7 +14,7 @@ export class StreamHandlerService {
|
|||||||
const emitErrorEvent = (message: string): void => {
|
const emitErrorEvent = (message: string): void => {
|
||||||
const errorEvent: ErrorEvent = {
|
const errorEvent: ErrorEvent = {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
error: { code: 'STREAM_ERROR', message }
|
error: { code: 'STREAM_ERROR', message },
|
||||||
};
|
};
|
||||||
observer.next(errorEvent);
|
observer.next(errorEvent);
|
||||||
};
|
};
|
||||||
@@ -36,6 +36,7 @@ export class StreamHandlerService {
|
|||||||
connected = true;
|
connected = true;
|
||||||
clearTimeout(connectionTimeoutId);
|
clearTimeout(connectionTimeoutId);
|
||||||
console.log(`[StreamHandler] WebSocket connected for ${gameId}`);
|
console.log(`[StreamHandler] WebSocket connected for ${gameId}`);
|
||||||
|
ws.send(JSON.stringify({ type: 'auth', token }));
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (message) => {
|
ws.onmessage = (message) => {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
interface RegisterResponse {
|
||||||
|
id: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class TournamentAuthService {
|
||||||
|
private readonly inflight = new Map<string, Promise<string>>();
|
||||||
|
|
||||||
|
async getToken(serverUrl: string): Promise<string> {
|
||||||
|
const key = this.cacheKey(serverUrl);
|
||||||
|
const cached = localStorage.getItem(key);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const existing = this.inflight.get(key);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const promise = this.register(serverUrl)
|
||||||
|
.then(token => {
|
||||||
|
localStorage.setItem(key, token);
|
||||||
|
this.inflight.delete(key);
|
||||||
|
return token;
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
this.inflight.delete(key);
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.inflight.set(key, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearToken(serverUrl: string): void {
|
||||||
|
localStorage.removeItem(this.cacheKey(serverUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async register(serverUrl: string): Promise<string> {
|
||||||
|
const base = serverUrl.replace(/\/+$/, '');
|
||||||
|
const localName = localStorage.getItem('username') ?? 'viewer';
|
||||||
|
const res = await fetch(`${base}/api/auth/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: `${localName}-watch`, isBot: false })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`tournament-server register failed: ${res.status}`);
|
||||||
|
const body = (await res.json()) as RegisterResponse;
|
||||||
|
return body.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cacheKey(serverUrl: string): string {
|
||||||
|
return `tournament-token:${serverUrl.replace(/\/+$/, '')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
export interface ExternalTournamentServer {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalTournamentServerList {
|
||||||
|
servers: ExternalTournamentServer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class TournamentServerService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly base = '/api/tournament/servers';
|
||||||
|
|
||||||
|
list(): Observable<ExternalTournamentServerList> {
|
||||||
|
return this.http.get<ExternalTournamentServerList>(this.base);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { GameStateSnapshot, GameStreamEvent, TournamentStreamEvent } from '../models/tournament.models';
|
||||||
|
import { TournamentAuthService } from './tournament-auth.service';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class TournamentStreamService {
|
||||||
|
private readonly auth = inject(TournamentAuthService);
|
||||||
|
|
||||||
|
streamTournament(serverUrl: string, tournamentId: string): Observable<TournamentStreamEvent> {
|
||||||
|
return this.ndjson<TournamentStreamEvent>(
|
||||||
|
serverUrl,
|
||||||
|
`/api/tournament/${tournamentId}/stream`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
streamGame(serverUrl: string, tournamentId: string, gameId: string): Observable<GameStreamEvent> {
|
||||||
|
return this.ndjson<GameStreamEvent>(
|
||||||
|
serverUrl,
|
||||||
|
`/api/tournament/${tournamentId}/game/${gameId}/stream`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchGame(serverUrl: string, tournamentId: string, gameId: string): Promise<GameStateSnapshot> {
|
||||||
|
const base = serverUrl.replace(/\/+$/, '');
|
||||||
|
const token = await this.auth.getToken(serverUrl);
|
||||||
|
const res = await fetch(`${base}/api/tournament/${tournamentId}/game/${gameId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Game fetch failed: ${res.status}`);
|
||||||
|
return res.json() as Promise<GameStateSnapshot>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private fullUrl(base: string, path: string): string {
|
||||||
|
if (!base) return path;
|
||||||
|
return `${base.replace(/\/+$/, '')}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ndjson<T>(serverUrl: string, path: string): Observable<T> {
|
||||||
|
return new Observable<T>(subscriber => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await this.openWithRetry(serverUrl, path, controller.signal);
|
||||||
|
if (!res.ok || !res.body) {
|
||||||
|
subscriber.error(new Error(`Stream failed: ${res.status} ${res.statusText}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
let nl: number;
|
||||||
|
while ((nl = buffer.indexOf('\n')) >= 0) {
|
||||||
|
const line = buffer.slice(0, nl).trim();
|
||||||
|
buffer = buffer.slice(nl + 1);
|
||||||
|
if (!line) continue;
|
||||||
|
try {
|
||||||
|
subscriber.next(JSON.parse(line) as T);
|
||||||
|
} catch {
|
||||||
|
// Drop malformed lines silently — server may emit partial keep-alives.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
subscriber.complete();
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name !== 'AbortError') subscriber.error(err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async openWithRetry(serverUrl: string, path: string, signal: AbortSignal): Promise<Response> {
|
||||||
|
const url = this.fullUrl(serverUrl, path);
|
||||||
|
const token = await this.auth.getToken(serverUrl);
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { Accept: 'application/x-ndjson', Authorization: `Bearer ${token}` },
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (res.status !== 401) return res;
|
||||||
|
this.auth.clearToken(serverUrl);
|
||||||
|
const fresh = await this.auth.getToken(serverUrl);
|
||||||
|
return fetch(url, {
|
||||||
|
headers: { Accept: 'application/x-ndjson', Authorization: `Bearer ${fresh}` },
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,10 +40,8 @@ export class TournamentService {
|
|||||||
return this.http.post<Tournament>(`${this.base}/${id}/start`, null);
|
return this.http.post<Tournament>(`${this.base}/${id}/start`, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
joinWithBotToken(id: string, botToken: string): Observable<void> {
|
join(id: string, botId: string, botName: string): Observable<void> {
|
||||||
return this.http.post<void>(`${this.base}/${id}/join`, null, {
|
return this.http.post<void>(`${this.base}/${id}/join`, { botId, botName });
|
||||||
headers: new HttpHeaders({ Authorization: `Bearer ${botToken}` })
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
roundPairings(id: string, round: number): Observable<RoundPairings> {
|
roundPairings(id: string, round: number): Observable<RoundPairings> {
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ export const environment = {
|
|||||||
accountServiceUrl: '',
|
accountServiceUrl: '',
|
||||||
wsBaseUrl: 'ws://localhost:8084',
|
wsBaseUrl: 'ws://localhost:8084',
|
||||||
userWsBaseUrl: 'ws://localhost:8084',
|
userWsBaseUrl: 'ws://localhost:8084',
|
||||||
apiPath: '/api/board/game'
|
apiPath: '/api/board/game',
|
||||||
|
tournamentServerUrl: 'http://141.37.123.132:8086'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ export const environment = {
|
|||||||
accountServiceUrl: runtimeConfig.apiUrl || 'https://st.nowchess.janis-eccarius.de',
|
accountServiceUrl: runtimeConfig.apiUrl || 'https://st.nowchess.janis-eccarius.de',
|
||||||
wsBaseUrl: runtimeConfig.wsUrl || 'wss://st.nowchess.janis-eccarius.de',
|
wsBaseUrl: runtimeConfig.wsUrl || 'wss://st.nowchess.janis-eccarius.de',
|
||||||
userWsBaseUrl: runtimeConfig.wsUrl || 'wss://st.nowchess.janis-eccarius.de',
|
userWsBaseUrl: runtimeConfig.wsUrl || 'wss://st.nowchess.janis-eccarius.de',
|
||||||
apiPath: '/api/board/game'
|
apiPath: '/api/board/game',
|
||||||
|
tournamentServerUrl: 'http://141.37.123.132:8086'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ export const environment = {
|
|||||||
accountServiceUrl: runtimeConfig.apiUrl || '',
|
accountServiceUrl: runtimeConfig.apiUrl || '',
|
||||||
wsBaseUrl: runtimeConfig.wsUrl,
|
wsBaseUrl: runtimeConfig.wsUrl,
|
||||||
userWsBaseUrl: runtimeConfig.wsUrl,
|
userWsBaseUrl: runtimeConfig.wsUrl,
|
||||||
apiPath: '/api/board/game'
|
apiPath: '/api/board/game',
|
||||||
|
tournamentServerUrl: 'http://141.37.123.132:8086'
|
||||||
};
|
};
|
||||||
|
|||||||
+6
-2
@@ -6,6 +6,10 @@
|
|||||||
"outDir": "./out-tsc/app",
|
"outDir": "./out-tsc/app",
|
||||||
"types": []
|
"types": []
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": [
|
||||||
"exclude": ["src/**/*.spec.ts"]
|
"src/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"src/**/*.spec.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-2
@@ -4,7 +4,12 @@
|
|||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./out-tsc/spec",
|
"outDir": "./out-tsc/spec",
|
||||||
"types": ["jasmine"]
|
"types": [
|
||||||
|
"jasmine"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.d.ts", "src/**/*.spec.ts"]
|
"include": [
|
||||||
|
"src/**/*.d.ts",
|
||||||
|
"src/**/*.spec.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
MAJOR=0
|
MAJOR=0
|
||||||
MINOR=3
|
MINOR=7
|
||||||
PATCH=0
|
PATCH=0
|
||||||
|
|||||||
Reference in New Issue
Block a user