Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60f4e87579 | |||
| 145f467648 | |||
| db9d153391 | |||
| 55f102cbaa | |||
| d66b6fa471 | |||
| 676e4110c0 | |||
| 0ad2e10999 | |||
| 225c2285b7 | |||
| c5661de4a0 | |||
| 3b6c5297f6 | |||
| a24924c230 | |||
| 11826356c1 | |||
| e18b1df744 | |||
| 595c172900 | |||
| cf225826c0 | |||
| 25c05bc2d8 | |||
| 4ec6295d1d | |||
| 2d18478110 | |||
| 8b95f10c65 | |||
| f3d96fd700 |
@@ -0,0 +1,88 @@
|
|||||||
|
# Create Defect in YouTrack
|
||||||
|
|
||||||
|
Automated defect creation workflow. Topic/hint: `$ARGUMENTS`
|
||||||
|
|
||||||
|
## Step 1 — Gather Context
|
||||||
|
|
||||||
|
Use `AskUserQuestion` tool to ask the user (max 4 questions at once):
|
||||||
|
|
||||||
|
1. **Component** — Where does the bug occur? (e.g. move generation, FEN parsing, castling, UI, API)
|
||||||
|
2. **What breaks** — What is the actual (broken) behavior?
|
||||||
|
3. **Expected** — What should happen instead?
|
||||||
|
4. **Reproducibility** — Is it always reproducible? Any known trigger conditions?
|
||||||
|
|
||||||
|
If `$ARGUMENTS` already answers some of these, skip those questions.
|
||||||
|
|
||||||
|
## Step 2 — Research (if needed)
|
||||||
|
|
||||||
|
If the bug involves domain logic or rules:
|
||||||
|
- Search repo for relevant code (`Grep`/`Bash`).
|
||||||
|
- Check test files for existing coverage of the broken area.
|
||||||
|
- Do NOT guess at root cause. Surface findings before drafting.
|
||||||
|
|
||||||
|
## Step 3 — Draft Defect
|
||||||
|
|
||||||
|
Compose the full defect report using this template:
|
||||||
|
|
||||||
|
```
|
||||||
|
Summary
|
||||||
|
|
||||||
|
[One-sentence description of what is broken.]
|
||||||
|
|
||||||
|
|
||||||
|
Steps to Reproduce
|
||||||
|
|
||||||
|
1. Step one
|
||||||
|
2. Step two
|
||||||
|
3. Step three
|
||||||
|
|
||||||
|
|
||||||
|
Expected Behavior
|
||||||
|
|
||||||
|
[What should happen.]
|
||||||
|
|
||||||
|
|
||||||
|
Actual Behavior
|
||||||
|
|
||||||
|
[What actually happens.]
|
||||||
|
|
||||||
|
|
||||||
|
Environment / Notes
|
||||||
|
|
||||||
|
[Any relevant context: FEN positions, game conditions, config, browser, OS — only if applicable.]
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Steps must be minimal and reproducible.
|
||||||
|
- Expected vs actual: concrete and unambiguous.
|
||||||
|
- Omit "Environment / Notes" section if not relevant.
|
||||||
|
|
||||||
|
## Step 4 — Clarify
|
||||||
|
|
||||||
|
Show the draft to the user.
|
||||||
|
**Use `AskUserQuestion` tool to ask:**
|
||||||
|
- Are steps to reproduce complete and accurate?
|
||||||
|
- Severity: Blocker / Critical / Major / Minor / Trivial?
|
||||||
|
- Any related tickets or recent changes to link?
|
||||||
|
|
||||||
|
Incorporate feedback. Repeat until user approves.
|
||||||
|
|
||||||
|
## Step 5 — Determine Project
|
||||||
|
|
||||||
|
- Frontend / UI / UX → project: `NCWF`
|
||||||
|
- Backend / coordinator / systems / bot / engine → project: `NCS`
|
||||||
|
|
||||||
|
If ambiguous, ask the user.
|
||||||
|
|
||||||
|
## Step 6 — Create Issue
|
||||||
|
|
||||||
|
Call `mcp__youtrack__create_issue` with:
|
||||||
|
- `project`: determined in Step 5
|
||||||
|
- `summary`: concise title describing what is broken (≤72 chars, sentence case)
|
||||||
|
- `description`: full formatted defect report from Step 3 (Markdown)
|
||||||
|
- `type`: `Bug`
|
||||||
|
|
||||||
|
## Step 7 — Report
|
||||||
|
|
||||||
|
Display the created issue ID and URL.
|
||||||
|
Ask if a linked investigation or fix task is needed.
|
||||||
@@ -66,10 +66,17 @@ Incorporate feedback. Repeat until user approves.
|
|||||||
|
|
||||||
## Step 5 — Determine Project
|
## Step 5 — Determine Project
|
||||||
|
|
||||||
|
> **Project routing rules (always apply these):**
|
||||||
|
> - Backend code (game engine, bots, API, services, coordinator) → `NCS`
|
||||||
|
> - Frontend code (UI, UX, web app) → `NCWF`
|
||||||
|
> - Infrastructure (Kubernetes, pipelines, CI/CD, DB setup, cloud infra) → `NCI`
|
||||||
|
> - If ambiguous, ask the user.
|
||||||
|
|
||||||
- Frontend / UI / UX → project: `NCWF`
|
- Frontend / UI / UX → project: `NCWF`
|
||||||
- Backend / coordinator / systems / bot / engine → project: `NCS`
|
- Backend / coordinator / systems / bot / engine → project: `NCS`
|
||||||
|
- Kubernetes, pipelines, CI/CD, DB setup, infrastructure → project: `NCI`
|
||||||
|
|
||||||
If ambiguous, ask the user.
|
If still ambiguous, ask the user.
|
||||||
|
|
||||||
## Step 6 — Create Issue
|
## Step 6 — Create Issue
|
||||||
|
|
||||||
@@ -79,7 +86,27 @@ Call `mcp__youtrack__create_issue` with:
|
|||||||
- `description`: full formatted story from Step 3 (Markdown)
|
- `description`: full formatted story from Step 3 (Markdown)
|
||||||
- `type`: `Feature` (or `Task` if purely technical with no user-facing value)
|
- `type`: `Feature` (or `Task` if purely technical with no user-facing value)
|
||||||
|
|
||||||
## Step 7 — Report
|
## Step 7 — Link Issues
|
||||||
|
|
||||||
|
After creation, **automatically** ask the user (use `AskUserQuestion` if interactive, otherwise infer from context):
|
||||||
|
|
||||||
|
> Are there related issues to link? (skip if none)
|
||||||
|
|
||||||
|
Collect any issue IDs the user mentions. For each, determine the correct relation and call `mcp__youtrack__link_issues`:
|
||||||
|
|
||||||
|
| Situation | Relation to use |
|
||||||
|
|-----------|----------------|
|
||||||
|
| This story must be done before another | `blocks` |
|
||||||
|
| Another story must be done before this | `is blocked by` |
|
||||||
|
| Stories share domain or are related | `relates to` |
|
||||||
|
| This is a child of an epic/story | `subtask of` |
|
||||||
|
| This is a parent grouping subtasks | `parent for` |
|
||||||
|
| This depends on another ticket's output | `depends on` |
|
||||||
|
|
||||||
|
If the user mentions an issue in the story description or implementation notes (e.g. "see NCS-42", "after NCS-12 is done"), auto-detect and suggest linking it — confirm before creating the link.
|
||||||
|
|
||||||
|
## Step 8 — Report
|
||||||
|
|
||||||
Display the created issue ID and URL.
|
Display the created issue ID and URL.
|
||||||
|
List any links created (relation type + linked issue ID).
|
||||||
Ask if a linked sub-task or implementation ticket is needed.
|
Ask if a linked sub-task or implementation ticket is needed.
|
||||||
|
|||||||
@@ -39,12 +39,16 @@ After root cause confirmed, assess scope:
|
|||||||
To create subtasks:
|
To create subtasks:
|
||||||
1. Break fix into discrete, independently-completable tasks (e.g. "Fix validation in RuleSet", "Add regression test for castling edge case", "Update FenParser to handle X").
|
1. Break fix into discrete, independently-completable tasks (e.g. "Fix validation in RuleSet", "Add regression test for castling edge case", "Update FenParser to handle X").
|
||||||
2. For each subtask call `mcp__youtrack__create_issue` with:
|
2. For each subtask call `mcp__youtrack__create_issue` with:
|
||||||
- `project`: same project as parent ticket
|
- `project`: based on subtask content — do **not** inherit from parent. Backend code → `NCS`; frontend/UI → `NCWF`; Kubernetes/pipelines/CI-CD/DB setup/infrastructure → `NCI`. If ambiguous, ask user.
|
||||||
- `summary`: concise action-oriented title
|
- `summary`: concise action-oriented title
|
||||||
- `type`: `Task`
|
- `type`: `Task`
|
||||||
- `description`: what to do and why
|
- `description`: what to do and why
|
||||||
3. Call `mcp__youtrack__link_issues` to link each subtask to `$ARGUMENTS` with relation `subtask of`.
|
3. Call `mcp__youtrack__link_issues` to link each subtask to `$ARGUMENTS` with relation `subtask of`.
|
||||||
4. List created subtask IDs to user.
|
4. Check if the ticket description or comments mention other issue IDs. For each mentioned ID, suggest a link and confirm with user:
|
||||||
|
- Fix depends on another fix finishing first → `is blocked by`
|
||||||
|
- This fix blocks another ticket → `blocks`
|
||||||
|
- Logically related but independent → `relates to`
|
||||||
|
5. List created subtask IDs and any additional links to user.
|
||||||
|
|
||||||
Then proceed to Step 4, implementing subtasks in order.
|
Then proceed to Step 4, implementing subtasks in order.
|
||||||
|
|
||||||
@@ -53,7 +57,10 @@ Then proceed to Step 4, implementing subtasks in order.
|
|||||||
1. Implement fix (use `scala-implementer` agent for non-trivial changes; inline edits for small ones).
|
1. Implement fix (use `scala-implementer` agent for non-trivial changes; inline edits for small ones).
|
||||||
2. Run `./compile` — must be green.
|
2. Run `./compile` — must be green.
|
||||||
3. Run `./test` — must be green.
|
3. Run `./test` — must be green.
|
||||||
4. Run `./lint` — must be green.
|
4. Run `./gradlew spotlessScalaApply` — **blocking, foreground only**. Wait for completion before continuing.
|
||||||
|
5. Run `./lint` — **blocking, foreground only** (never `run_in_background`). Wait for exit code 0. Must be green.
|
||||||
|
- If lint fails, fix all issues and re-run until exit code 0.
|
||||||
|
- **Do NOT proceed to Step 5 until `./lint` has completed and returned exit code 0.**
|
||||||
If any step fails, iterate until all pass.
|
If any step fails, iterate until all pass.
|
||||||
|
|
||||||
## Step 5 — Review
|
## Step 5 — Review
|
||||||
@@ -61,6 +68,19 @@ If any step fails, iterate until all pass.
|
|||||||
Spawn `cavecrew-reviewer` on the full diff.
|
Spawn `cavecrew-reviewer` on the full diff.
|
||||||
Display findings grouped by severity.
|
Display findings grouped by severity.
|
||||||
|
|
||||||
|
## Step 5b — Apply Review Findings
|
||||||
|
|
||||||
|
If the review produced any findings (any severity):
|
||||||
|
1. Implement all agreed fixes.
|
||||||
|
2. Run `./compile` — must be green.
|
||||||
|
3. Run `./test` — must be green.
|
||||||
|
4. Run `./gradlew spotlessScalaApply` — **blocking, foreground only**. Wait for completion.
|
||||||
|
5. Run `./lint` — **blocking, foreground only**. Wait for exit code 0.
|
||||||
|
- If lint fails, fix all issues and re-run until exit code 0.
|
||||||
|
6. Re-spawn `cavecrew-reviewer` on the updated diff to confirm all findings are resolved.
|
||||||
|
|
||||||
|
Repeat until review is clean or user explicitly accepts remaining findings.
|
||||||
|
|
||||||
## Step 6 — Confirm + Push
|
## Step 6 — Confirm + Push
|
||||||
|
|
||||||
Show summary: ticket, branch, files changed, review findings.
|
Show summary: ticket, branch, files changed, review findings.
|
||||||
@@ -98,7 +118,23 @@ Files changed:
|
|||||||
- <file2>
|
- <file2>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Step 7b — Additional Links
|
||||||
|
|
||||||
|
After commenting, ask the user if `$ARGUMENTS` should be linked to any other issues not already linked:
|
||||||
|
|
||||||
|
| Situation | Relation |
|
||||||
|
|-----------|---------|
|
||||||
|
| This fix blocks another open ticket | `blocks` |
|
||||||
|
| Another ticket must ship first | `is blocked by` |
|
||||||
|
| Related defect or story | `relates to` |
|
||||||
|
| Duplicate of another defect | `duplicates` |
|
||||||
|
|
||||||
|
Scan the ticket description and comments for any issue IDs that were mentioned but not yet linked. Suggest those automatically.
|
||||||
|
|
||||||
|
Call `mcp__youtrack__link_issues` for each confirmed link.
|
||||||
|
|
||||||
## Step 8 — Cleanup
|
## Step 8 — Cleanup
|
||||||
|
|
||||||
Call `ExitWorktree` to delete the worktree.
|
Call `ExitWorktree` with `discard_changes: true` to delete the worktree.
|
||||||
Report: branch pushed, ticket commented, worktree deleted, done.
|
(Branch was pushed in step 6 — commits are safe on remote; `discard_changes: true` bypasses the local-ahead guard.)
|
||||||
|
Report: branch pushed, ticket commented, links created, worktree deleted, done.
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ All subsequent file work happens inside this worktree.
|
|||||||
1. Implement feature (use `scala-implementer` agent for non-trivial changes; inline edits for small ones).
|
1. Implement feature (use `scala-implementer` agent for non-trivial changes; inline edits for small ones).
|
||||||
2. Run `./compile` — must be green.
|
2. Run `./compile` — must be green.
|
||||||
3. Run `./test` — must be green (add new tests for new behaviour; do not modify existing tests unless requirements changed).
|
3. Run `./test` — must be green (add new tests for new behaviour; do not modify existing tests unless requirements changed).
|
||||||
4. Run `./lint` — must be green.
|
4. Run `./gradlew spotlessScalaApply` — **blocking, foreground only**. Wait for completion before continuing.
|
||||||
|
5. Run `./lint` — **blocking, foreground only** (never `run_in_background`). Wait for exit code 0. Must be green.
|
||||||
|
- If lint fails, fix all issues and re-run until exit code 0.
|
||||||
|
- **Do NOT proceed to Step 5 until `./lint` has completed and returned exit code 0.**
|
||||||
If any step fails, iterate until all pass.
|
If any step fails, iterate until all pass.
|
||||||
|
|
||||||
## Step 5 — Review
|
## Step 5 — Review
|
||||||
@@ -41,6 +44,19 @@ If any step fails, iterate until all pass.
|
|||||||
Spawn `cavecrew-reviewer` on the full diff.
|
Spawn `cavecrew-reviewer` on the full diff.
|
||||||
Display findings grouped by severity.
|
Display findings grouped by severity.
|
||||||
|
|
||||||
|
## Step 5b — Apply Review Findings
|
||||||
|
|
||||||
|
If the review produced any findings (any severity):
|
||||||
|
1. Implement all agreed fixes.
|
||||||
|
2. Run `./compile` — must be green.
|
||||||
|
3. Run `./test` — must be green.
|
||||||
|
4. Run `./gradlew spotlessScalaApply` — **blocking, foreground only**. Wait for completion.
|
||||||
|
5. Run `./lint` — **blocking, foreground only**. Wait for exit code 0.
|
||||||
|
- If lint fails, fix all issues and re-run until exit code 0.
|
||||||
|
6. Re-spawn `cavecrew-reviewer` on the updated diff to confirm all findings are resolved.
|
||||||
|
|
||||||
|
Repeat until review is clean or user explicitly accepts remaining findings.
|
||||||
|
|
||||||
## Step 6 — Confirm + Push
|
## Step 6 — Confirm + Push
|
||||||
|
|
||||||
Show summary: ticket, branch, files changed, review findings.
|
Show summary: ticket, branch, files changed, review findings.
|
||||||
@@ -80,5 +96,6 @@ Files changed:
|
|||||||
|
|
||||||
## Step 8 — Cleanup
|
## Step 8 — Cleanup
|
||||||
|
|
||||||
Call `ExitWorktree` to delete the worktree.
|
Call `ExitWorktree` with `discard_changes: true` to delete the worktree.
|
||||||
|
(Branch was pushed in step 6 — commits are safe on remote; `discard_changes: true` bypasses the local-ahead guard.)
|
||||||
Report: branch pushed, ticket commented, worktree deleted, done.
|
Report: branch pushed, ticket commented, worktree deleted, done.
|
||||||
|
|||||||
@@ -59,11 +59,15 @@ Rules:
|
|||||||
- 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.
|
||||||
|
|
||||||
## Step 5 — Determine Project
|
## Step 5 — Determine Project per Subtask
|
||||||
|
|
||||||
Inherit project from parent story (`$ARGUMENTS`):
|
Assign each subtask's project based on its content — do **not** inherit blindly from parent:
|
||||||
- If parent is `NCWF-*` → subtasks go in `NCWF`
|
|
||||||
- If parent is `NCS-*` → subtasks go in `NCS`
|
- Backend code (game engine, bots, API, services, coordinator) → `NCS`
|
||||||
|
- Frontend code (UI, UX, web app) → `NCWF`
|
||||||
|
- Kubernetes, pipelines, CI/CD, DB setup, infrastructure → `NCI`
|
||||||
|
|
||||||
|
If a subtask's project is ambiguous, ask the user before creating it.
|
||||||
|
|
||||||
## Step 6 — Create Subtasks
|
## Step 6 — Create Subtasks
|
||||||
|
|
||||||
@@ -75,8 +79,31 @@ For each subtask call `mcp__youtrack__create_issue` with:
|
|||||||
|
|
||||||
Then call `mcp__youtrack__link_issues` to link each created subtask to `$ARGUMENTS` with relation `subtask of`.
|
Then call `mcp__youtrack__link_issues` to link each created subtask to `$ARGUMENTS` with relation `subtask of`.
|
||||||
|
|
||||||
|
## Step 6b — Inter-Subtask Links
|
||||||
|
|
||||||
|
If subtasks must be done in sequence (one depends on output of another), add ordering links:
|
||||||
|
- For each dependency pair call `mcp__youtrack__link_issues` with relation `is blocked by` (subtask B is blocked by subtask A).
|
||||||
|
|
||||||
|
Ask the user to confirm sequencing before adding these links:
|
||||||
|
|
||||||
|
> Do any subtasks have ordering dependencies? (e.g. "Implement X must come before Add tests for X")
|
||||||
|
|
||||||
|
## Step 6c — External Links
|
||||||
|
|
||||||
|
Scan `$ARGUMENTS` description and implementation notes for any referenced issue IDs not already linked. For each:
|
||||||
|
|
||||||
|
| Situation | Relation |
|
||||||
|
|-----------|---------|
|
||||||
|
| Parent story blocks another epic/story | `blocks` |
|
||||||
|
| Story depends on another epic completing | `is blocked by` |
|
||||||
|
| Related story in same domain | `relates to` |
|
||||||
|
| This story duplicates or supersedes | `duplicates` |
|
||||||
|
|
||||||
|
Suggest links to the user and call `mcp__youtrack__link_issues` on confirmation.
|
||||||
|
|
||||||
## Step 7 — Report
|
## Step 7 — Report
|
||||||
|
|
||||||
List all created subtask IDs and summaries.
|
List all created subtask IDs and summaries.
|
||||||
|
List all links created (subtask-of, blocking chains, external).
|
||||||
Display parent story link.
|
Display parent story link.
|
||||||
Ask if any subtask needs further splitting.
|
Ask if any subtask needs further splitting.
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ jobs:
|
|||||||
- official-bots
|
- official-bots
|
||||||
- rule
|
- rule
|
||||||
- store
|
- store
|
||||||
|
- tournament
|
||||||
- ws
|
- ws
|
||||||
arch:
|
arch:
|
||||||
- name: default
|
- name: default
|
||||||
|
|||||||
+10
@@ -48,3 +48,13 @@ graphify-out/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
/jacoco-reporter/.venv/
|
/jacoco-reporter/.venv/
|
||||||
/.claude/settings.local.json
|
/.claude/settings.local.json
|
||||||
|
/.claude/worktrees/
|
||||||
|
modules/tournament/src/main/resources/keys/dev-public.pem
|
||||||
|
modules/account/src/main/resources/keys/dev-private.pem
|
||||||
|
modules/account/src/main/resources/keys/dev-public.pem
|
||||||
|
modules/core/src/main/resources/keys/dev-public.pem
|
||||||
|
*.hprof
|
||||||
|
|
||||||
|
### Embedded repos (not submodules) ###
|
||||||
|
GitOps/
|
||||||
|
frontend/
|
||||||
|
|||||||
@@ -57,6 +57,14 @@ Use consistently.
|
|||||||
- **Tests are the spec.** Don't modify to pass. Fix requirements/code. Update only if requirements change.
|
- **Tests are the spec.** Don't modify to pass. Fix requirements/code. Update only if requirements change.
|
||||||
- Never read build folders. Ask permission if needed.
|
- Never read build folders. Ask permission if needed.
|
||||||
- Keep file current with decisions + conventions.
|
- Keep file current with decisions + conventions.
|
||||||
|
- **NativeReflectionConfig (mandatory):** Every new type (class, case class, enum, sealed trait — anything serialized) must be registered in the `NativeReflectionConfig` of **every module that interacts with it**. Configs live at:
|
||||||
|
- `modules/account/src/main/scala/de/nowchess/account/config/NativeReflectionConfig.scala`
|
||||||
|
- `modules/coordinator/src/main/scala/de/nowchess/coordinator/config/NativeReflectionConfig.scala`
|
||||||
|
- `modules/core/src/main/scala/de/nowchess/chess/config/NativeReflectionConfig.scala`
|
||||||
|
- `modules/io/src/main/scala/de/nowchess/io/service/config/NativeReflectionConfig.scala`
|
||||||
|
- `modules/rule/src/main/scala/de/nowchess/rules/config/NativeReflectionConfig.scala`
|
||||||
|
- `modules/store/src/main/scala/de/nowchess/store/config/NativeReflectionConfig.scala`
|
||||||
|
- `modules/ws/src/main/scala/de/nowchess/ws/config/NativeReflectionConfig.scala`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,623 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: NowChess Tournament API
|
||||||
|
description: |
|
||||||
|
Swiss-system bot tournaments, modelled after the Lichess API style.
|
||||||
|
|
||||||
|
Game moves flow through the existing board and bot endpoints — this module
|
||||||
|
handles pairings, standings, and lifecycle only.
|
||||||
|
|
||||||
|
## Streaming
|
||||||
|
Endpoints marked **NDJSON** return newline-delimited JSON objects
|
||||||
|
(`application/x-ndjson`). Each line is one complete JSON object. The
|
||||||
|
connection stays open until the tournament or round ends.
|
||||||
|
|
||||||
|
## Bot flow
|
||||||
|
```
|
||||||
|
POST /api/tournament # create
|
||||||
|
POST /api/tournament/{id}/join # each bot joins
|
||||||
|
POST /api/tournament/{id}/start # director starts
|
||||||
|
|
||||||
|
GET /api/tournament/{id}/stream (NDJSON) # open before start
|
||||||
|
|
||||||
|
-- per round --
|
||||||
|
receive gameStart { gameId, color }
|
||||||
|
GET /bot/stream/game/{gameId} (existing, NDJSON)
|
||||||
|
POST /bot/game/{gameId}/move/{uci} (existing)
|
||||||
|
-- repeat --
|
||||||
|
|
||||||
|
GET /api/tournament/{id}/results (NDJSON) # final standings
|
||||||
|
```
|
||||||
|
version: 1.0.0
|
||||||
|
|
||||||
|
servers:
|
||||||
|
- url: https://st.nowchess.janis-eccarius.de
|
||||||
|
description: Staging
|
||||||
|
- url: https://nowchess.janis-eccarius.de
|
||||||
|
description: Production
|
||||||
|
- url: http://localhost:8086
|
||||||
|
description: Local
|
||||||
|
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- name: Tournament
|
||||||
|
description: Tournament lifecycle
|
||||||
|
- name: Participation
|
||||||
|
description: Join and withdraw
|
||||||
|
- name: Results
|
||||||
|
description: Standings, pairings, and game export
|
||||||
|
- name: Stream
|
||||||
|
description: NDJSON event streams
|
||||||
|
|
||||||
|
paths:
|
||||||
|
|
||||||
|
/api/tournament:
|
||||||
|
get:
|
||||||
|
tags: [Tournament]
|
||||||
|
summary: Get current tournaments
|
||||||
|
description: Returns tournaments grouped by status. No auth required.
|
||||||
|
security: []
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Tournaments by status
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
created:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/TournamentInfo"
|
||||||
|
started:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/TournamentInfo"
|
||||||
|
finished:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/TournamentInfo"
|
||||||
|
|
||||||
|
post:
|
||||||
|
tags: [Tournament]
|
||||||
|
summary: Create a new tournament
|
||||||
|
description: The authenticated user becomes the tournament director.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/x-www-form-urlencoded:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/CreateTournamentForm"
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Tournament created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Tournament"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/BadRequest"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
|
||||||
|
/api/tournament/{id}:
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/id"
|
||||||
|
|
||||||
|
get:
|
||||||
|
tags: [Tournament]
|
||||||
|
summary: Get a tournament
|
||||||
|
description: Includes the first page of standings in the `standing` field.
|
||||||
|
security: []
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Tournament with embedded standings
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Tournament"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
|
||||||
|
delete:
|
||||||
|
tags: [Tournament]
|
||||||
|
summary: Terminate a tournament
|
||||||
|
description: Only the director may terminate. Only allowed while status is `created`.
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Terminated
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/Forbidden"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
"409":
|
||||||
|
$ref: "#/components/responses/Conflict"
|
||||||
|
|
||||||
|
/api/tournament/{id}/start:
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/id"
|
||||||
|
|
||||||
|
post:
|
||||||
|
tags: [Tournament]
|
||||||
|
summary: Start the tournament
|
||||||
|
description: |
|
||||||
|
Only the director may start. Requires at least 2 joined bots.
|
||||||
|
Computes round 1 pairings and creates games via `POST /api/board/game`.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Tournament started
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Tournament"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/Forbidden"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
"409":
|
||||||
|
$ref: "#/components/responses/Conflict"
|
||||||
|
|
||||||
|
/api/tournament/{id}/join:
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/id"
|
||||||
|
|
||||||
|
post:
|
||||||
|
tags: [Participation]
|
||||||
|
summary: Join a tournament
|
||||||
|
description: |
|
||||||
|
Register the authenticated bot for the tournament. Only allowed while
|
||||||
|
status is `created`. The token subject must be a bot account.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Ok
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Ok"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/Forbidden"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
"409":
|
||||||
|
$ref: "#/components/responses/Conflict"
|
||||||
|
|
||||||
|
/api/tournament/{id}/withdraw:
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/id"
|
||||||
|
|
||||||
|
post:
|
||||||
|
tags: [Participation]
|
||||||
|
summary: Withdraw from a tournament
|
||||||
|
description: Only allowed while status is `created`.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Ok
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Ok"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/Forbidden"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
"409":
|
||||||
|
$ref: "#/components/responses/Conflict"
|
||||||
|
|
||||||
|
/api/tournament/{id}/results:
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/id"
|
||||||
|
|
||||||
|
get:
|
||||||
|
tags: [Results]
|
||||||
|
summary: Get results as NDJSON stream
|
||||||
|
description: |
|
||||||
|
Streams one `Result` object per line, sorted by rank ascending.
|
||||||
|
Available at any point during or after the tournament.
|
||||||
|
security: []
|
||||||
|
parameters:
|
||||||
|
- name: nb
|
||||||
|
in: query
|
||||||
|
description: Max number of results to stream (default all)
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: NDJSON stream of results
|
||||||
|
content:
|
||||||
|
application/x-ndjson:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Result"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
|
||||||
|
/api/tournament/{id}/round/{round}:
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/id"
|
||||||
|
- name: round
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
|
||||||
|
get:
|
||||||
|
tags: [Results]
|
||||||
|
summary: Get pairings for a round
|
||||||
|
security: []
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Pairings for the specified round
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
round:
|
||||||
|
type: integer
|
||||||
|
example: 2
|
||||||
|
pairings:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/Pairing"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
|
||||||
|
/api/tournament/{id}/export/games:
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/id"
|
||||||
|
|
||||||
|
get:
|
||||||
|
tags: [Results]
|
||||||
|
summary: Export all games
|
||||||
|
description: |
|
||||||
|
Returns all games of the tournament. Accepts both PGN and NDJSON via
|
||||||
|
the `Accept` header.
|
||||||
|
security: []
|
||||||
|
parameters:
|
||||||
|
- name: Accept
|
||||||
|
in: header
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- application/x-chess-pgn
|
||||||
|
- application/x-ndjson
|
||||||
|
default: application/x-chess-pgn
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Games in the requested format
|
||||||
|
content:
|
||||||
|
application/x-chess-pgn:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Standard PGN, one game per block
|
||||||
|
application/x-ndjson:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/GameExport"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
|
||||||
|
/api/tournament/{id}/stream:
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/id"
|
||||||
|
|
||||||
|
get:
|
||||||
|
tags: [Stream]
|
||||||
|
summary: Stream tournament events
|
||||||
|
description: |
|
||||||
|
NDJSON stream scoped to one tournament. Keep this connection open for
|
||||||
|
the full tournament lifetime.
|
||||||
|
|
||||||
|
On `gameStart` the bot connects to the existing bot endpoints:
|
||||||
|
- `GET /bot/stream/game/{gameId}` — stream game state (existing)
|
||||||
|
- `POST /bot/game/{gameId}/move/{uci}` — submit moves (existing)
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: NDJSON event stream
|
||||||
|
content:
|
||||||
|
application/x-ndjson:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/TournamentEvent"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
|
||||||
|
components:
|
||||||
|
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
|
|
||||||
|
parameters:
|
||||||
|
id:
|
||||||
|
name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: t7kXq2
|
||||||
|
|
||||||
|
schemas:
|
||||||
|
|
||||||
|
Clock:
|
||||||
|
type: object
|
||||||
|
required: [limit, increment]
|
||||||
|
properties:
|
||||||
|
limit:
|
||||||
|
type: integer
|
||||||
|
description: Base time in seconds
|
||||||
|
example: 300
|
||||||
|
increment:
|
||||||
|
type: integer
|
||||||
|
description: Increment per move in seconds
|
||||||
|
example: 3
|
||||||
|
|
||||||
|
Variant:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
key:
|
||||||
|
type: string
|
||||||
|
example: standard
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
example: Standard
|
||||||
|
|
||||||
|
BotRef:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
example: bot_abc
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
example: StockfishClone
|
||||||
|
|
||||||
|
Standing:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
page:
|
||||||
|
type: integer
|
||||||
|
example: 1
|
||||||
|
players:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/Result"
|
||||||
|
|
||||||
|
TournamentInfo:
|
||||||
|
description: Lightweight tournament summary used in list responses.
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
example: t7kXq2
|
||||||
|
fullName:
|
||||||
|
type: string
|
||||||
|
example: Friday Night Bots Swiss
|
||||||
|
clock:
|
||||||
|
$ref: "#/components/schemas/Clock"
|
||||||
|
variant:
|
||||||
|
$ref: "#/components/schemas/Variant"
|
||||||
|
rated:
|
||||||
|
type: boolean
|
||||||
|
example: true
|
||||||
|
nbPlayers:
|
||||||
|
type: integer
|
||||||
|
example: 8
|
||||||
|
nbRounds:
|
||||||
|
type: integer
|
||||||
|
example: 5
|
||||||
|
createdBy:
|
||||||
|
type: string
|
||||||
|
example: userId
|
||||||
|
startsAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
|
||||||
|
Tournament:
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/components/schemas/TournamentInfo"
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [created, started, finished]
|
||||||
|
example: started
|
||||||
|
round:
|
||||||
|
type: integer
|
||||||
|
description: Current round number
|
||||||
|
example: 2
|
||||||
|
standing:
|
||||||
|
$ref: "#/components/schemas/Standing"
|
||||||
|
winner:
|
||||||
|
description: Present only when status is `finished`
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/components/schemas/BotRef"
|
||||||
|
nullable: true
|
||||||
|
|
||||||
|
CreateTournamentForm:
|
||||||
|
type: object
|
||||||
|
required: [name, nbRounds, clockLimit, clockIncrement]
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
example: Friday Night Bots
|
||||||
|
nbRounds:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
example: 5
|
||||||
|
clockLimit:
|
||||||
|
type: integer
|
||||||
|
description: Base time in seconds
|
||||||
|
example: 300
|
||||||
|
clockIncrement:
|
||||||
|
type: integer
|
||||||
|
description: Increment per move in seconds
|
||||||
|
example: 3
|
||||||
|
rated:
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
|
||||||
|
Result:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
rank:
|
||||||
|
type: integer
|
||||||
|
example: 1
|
||||||
|
points:
|
||||||
|
type: number
|
||||||
|
format: double
|
||||||
|
example: 3.5
|
||||||
|
tieBreak:
|
||||||
|
type: number
|
||||||
|
format: double
|
||||||
|
description: Buchholz score (sum of opponents' points)
|
||||||
|
example: 9.0
|
||||||
|
bot:
|
||||||
|
$ref: "#/components/schemas/BotRef"
|
||||||
|
nbGames:
|
||||||
|
type: integer
|
||||||
|
example: 4
|
||||||
|
wins:
|
||||||
|
type: integer
|
||||||
|
example: 3
|
||||||
|
draws:
|
||||||
|
type: integer
|
||||||
|
example: 1
|
||||||
|
losses:
|
||||||
|
type: integer
|
||||||
|
example: 0
|
||||||
|
|
||||||
|
Pairing:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
round:
|
||||||
|
type: integer
|
||||||
|
example: 2
|
||||||
|
white:
|
||||||
|
$ref: "#/components/schemas/BotRef"
|
||||||
|
black:
|
||||||
|
$ref: "#/components/schemas/BotRef"
|
||||||
|
gameId:
|
||||||
|
type: string
|
||||||
|
example: j0nPtcjl
|
||||||
|
winner:
|
||||||
|
type: string
|
||||||
|
enum: [white, black, draw]
|
||||||
|
nullable: true
|
||||||
|
description: Null while the game is ongoing
|
||||||
|
|
||||||
|
GameExport:
|
||||||
|
description: One game object per NDJSON line.
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
example: j0nPtcjl
|
||||||
|
round:
|
||||||
|
type: integer
|
||||||
|
example: 2
|
||||||
|
white:
|
||||||
|
$ref: "#/components/schemas/BotRef"
|
||||||
|
black:
|
||||||
|
$ref: "#/components/schemas/BotRef"
|
||||||
|
winner:
|
||||||
|
type: string
|
||||||
|
enum: [white, black, draw]
|
||||||
|
nullable: true
|
||||||
|
moves:
|
||||||
|
type: string
|
||||||
|
description: Space-separated UCI moves
|
||||||
|
example: e2e4 e7e5 g1f3
|
||||||
|
|
||||||
|
TournamentEvent:
|
||||||
|
description: |
|
||||||
|
One JSON object per NDJSON line. Discriminate on `type`.
|
||||||
|
|
||||||
|
| type | extra fields |
|
||||||
|
|------|-------------|
|
||||||
|
| `tournamentStarted` | — |
|
||||||
|
| `roundStarted` | `round` |
|
||||||
|
| `gameStart` | `round`, `gameId`, `color` |
|
||||||
|
| `roundFinished` | `round` |
|
||||||
|
| `tournamentFinished` | `winner` |
|
||||||
|
type: object
|
||||||
|
required: [type]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- tournamentStarted
|
||||||
|
- roundStarted
|
||||||
|
- gameStart
|
||||||
|
- roundFinished
|
||||||
|
- tournamentFinished
|
||||||
|
round:
|
||||||
|
type: integer
|
||||||
|
example: 2
|
||||||
|
gameId:
|
||||||
|
type: string
|
||||||
|
example: j0nPtcjl
|
||||||
|
color:
|
||||||
|
type: string
|
||||||
|
enum: [white, black]
|
||||||
|
winner:
|
||||||
|
$ref: "#/components/schemas/BotRef"
|
||||||
|
|
||||||
|
Ok:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
ok:
|
||||||
|
type: boolean
|
||||||
|
example: true
|
||||||
|
|
||||||
|
Error:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
example: tournament already started
|
||||||
|
|
||||||
|
responses:
|
||||||
|
BadRequest:
|
||||||
|
description: Invalid request body or parameters
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
Unauthorized:
|
||||||
|
description: Missing or invalid JWT
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
Forbidden:
|
||||||
|
description: Action not permitted for this user or bot
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
NotFound:
|
||||||
|
description: Tournament not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
Conflict:
|
||||||
|
description: Conflicting state (e.g. already started, bot already joined)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
@@ -382,3 +382,150 @@
|
|||||||
|
|
||||||
* Revert "feat: add authentication permissions for metrics endpoints in application.yml" ([a298417](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a298417b9e4d68dc73bbf40be63d9484536e9f83))
|
* Revert "feat: add authentication permissions for metrics endpoints in application.yml" ([a298417](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a298417b9e4d68dc73bbf40be63d9484536e9f83))
|
||||||
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||||
|
## (2026-06-05)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **account:** implement token pair handling for login and refresh endpoints ([9296db8](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/9296db88b7131bbda9b9b0da65c327ef9063ee31))
|
||||||
|
* add authentication permissions for metrics endpoints in application.yml ([04edd4d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/04edd4d6fd8a63196c36f6d67992832febc9bebb))
|
||||||
|
* add initialization metrics for various services ([d438e97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d438e97f32bdde0bfc63c1b4a8cc810cdd093166))
|
||||||
|
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
|
||||||
|
* **api:** define shared EventEnvelope and EventType for Redis EventBus ([#61](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/61)) ([595c172](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/595c172900da99de367c274488c3ccbeaef55882))
|
||||||
|
* **config:** add H2 database configuration for testing environment ([39c9e49](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/39c9e492cef2515368c074da9406f95e9c0c9e64))
|
||||||
|
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
|
||||||
|
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
|
||||||
|
* configure logging and add OpenTelemetry support ([#49](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/49)) ([d57c488](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d57c4886612d1d92da0e1b79209fc83e6ef537a1))
|
||||||
|
* **docker:** add .dockerignore and .gitignore files for build exclusions ([c987d8e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c987d8e258c0e6c4cfbdaa8381c64c410d7a2b83))
|
||||||
|
* **docker:** add Dockerfiles for building Quarkus application in native and JVM modes ([3f2d2bb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3f2d2bb4c97fa8cddba66e1da4427c54236dfeed))
|
||||||
|
* **docker:** add Dockerfiles for Quarkus application in JVM and native modes ([34b9933](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/34b993304670cf2aa62cd2f6460cee7b9864b08e))
|
||||||
|
* **logging:** add DEBUG/INFO/WARN logging across services (NCS-72) ([#41](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/41)) ([804a4bf](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/804a4bf179e3dfb19e2be4390e7e543caf5237c6))
|
||||||
|
* NCS-78 Add Traceability to the Applications ([#46](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/46)) ([649566e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/649566eb3fcf38f91c8896a739f74ea318af312d))
|
||||||
|
* NCS-78 Add Traceability to the Applications ([#47](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/47)) ([87dfc6c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/87dfc6c2bcce7f7d58fc641bd8d468a2e584c108))
|
||||||
|
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
|
||||||
|
* update application.yml with new API root paths and add Micrometer and OpenTelemetry dependencies ([72ce262](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/72ce262bc491f94297700e6002fb5d0812e2cc2a))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **account:** configure JDBC connection pool size to prevent exhaustion under load ([29072ef](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/29072efbfb1cfa1c3b1a85b4c1a587c971d245f9))
|
||||||
|
* **auth:** add InternalClientHeadersFactory for custom client headers management ([e279c39](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e279c39246470156bf11e745ee72204018d4229d))
|
||||||
|
* NCS-84 More Verbose Logging ([#51](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/51)) ([4ad92ab](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/4ad92ab23698267f8faa59c4e18388d4a0042cca))
|
||||||
|
* **official-bots:** NCS-70-auto-register official bots with account service ([#59](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/59)) ([7117a93](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7117a93376272094d0b1a6abf2121254ce396684))
|
||||||
|
* remove unused HTTP root-path configurations from application.yml ([3ed3e59](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ed3e59ee456d54cd3d65ece4f36623e256b9736))
|
||||||
|
* **tests:** update token path to accessToken in ChallengeResourceTest ([354db11](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/354db11972342c47a1034303c11bccfb92e60109))
|
||||||
|
|
||||||
|
### Reverts
|
||||||
|
|
||||||
|
* Revert "feat: add authentication permissions for metrics endpoints in application.yml" ([a298417](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a298417b9e4d68dc73bbf40be63d9484536e9f83))
|
||||||
|
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||||
|
## (2026-06-09)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **account:** implement token pair handling for login and refresh endpoints ([9296db8](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/9296db88b7131bbda9b9b0da65c327ef9063ee31))
|
||||||
|
* add authentication permissions for metrics endpoints in application.yml ([04edd4d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/04edd4d6fd8a63196c36f6d67992832febc9bebb))
|
||||||
|
* add initialization metrics for various services ([d438e97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d438e97f32bdde0bfc63c1b4a8cc810cdd093166))
|
||||||
|
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
|
||||||
|
* **api:** define shared EventEnvelope and EventType for Redis EventBus ([#61](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/61)) ([595c172](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/595c172900da99de367c274488c3ccbeaef55882))
|
||||||
|
* **config:** add H2 database configuration for testing environment ([39c9e49](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/39c9e492cef2515368c074da9406f95e9c0c9e64))
|
||||||
|
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
|
||||||
|
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
|
||||||
|
* configure logging and add OpenTelemetry support ([#49](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/49)) ([d57c488](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d57c4886612d1d92da0e1b79209fc83e6ef537a1))
|
||||||
|
* **docker:** add .dockerignore and .gitignore files for build exclusions ([c987d8e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c987d8e258c0e6c4cfbdaa8381c64c410d7a2b83))
|
||||||
|
* **docker:** add Dockerfiles for building Quarkus application in native and JVM modes ([3f2d2bb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3f2d2bb4c97fa8cddba66e1da4427c54236dfeed))
|
||||||
|
* **docker:** add Dockerfiles for Quarkus application in JVM and native modes ([34b9933](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/34b993304670cf2aa62cd2f6460cee7b9864b08e))
|
||||||
|
* **events:** migrate game-creation and bot flows to Redis Streams NCS-89 ([#62](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/62)) ([a24924c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a24924c23057db3d700a75dbc4333557789cd991))
|
||||||
|
* **logging:** add DEBUG/INFO/WARN logging across services (NCS-72) ([#41](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/41)) ([804a4bf](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/804a4bf179e3dfb19e2be4390e7e543caf5237c6))
|
||||||
|
* NCS-78 Add Traceability to the Applications ([#46](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/46)) ([649566e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/649566eb3fcf38f91c8896a739f74ea318af312d))
|
||||||
|
* NCS-78 Add Traceability to the Applications ([#47](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/47)) ([87dfc6c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/87dfc6c2bcce7f7d58fc641bd8d468a2e584c108))
|
||||||
|
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
|
||||||
|
* update application.yml with new API root paths and add Micrometer and OpenTelemetry dependencies ([72ce262](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/72ce262bc491f94297700e6002fb5d0812e2cc2a))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **account:** configure JDBC connection pool size to prevent exhaustion under load ([29072ef](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/29072efbfb1cfa1c3b1a85b4c1a587c971d245f9))
|
||||||
|
* **auth:** add InternalClientHeadersFactory for custom client headers management ([e279c39](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e279c39246470156bf11e745ee72204018d4229d))
|
||||||
|
* NCS-84 More Verbose Logging ([#51](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/51)) ([4ad92ab](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/4ad92ab23698267f8faa59c4e18388d4a0042cca))
|
||||||
|
* **official-bots:** NCS-70-auto-register official bots with account service ([#59](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/59)) ([7117a93](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7117a93376272094d0b1a6abf2121254ce396684))
|
||||||
|
* remove unused HTTP root-path configurations from application.yml ([3ed3e59](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ed3e59ee456d54cd3d65ece4f36623e256b9736))
|
||||||
|
* **tests:** update token path to accessToken in ChallengeResourceTest ([354db11](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/354db11972342c47a1034303c11bccfb92e60109))
|
||||||
|
|
||||||
|
### Reverts
|
||||||
|
|
||||||
|
* Revert "feat: add authentication permissions for metrics endpoints in application.yml" ([a298417](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a298417b9e4d68dc73bbf40be63d9484536e9f83))
|
||||||
|
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||||
|
## (2026-06-09)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **account:** implement token pair handling for login and refresh endpoints ([9296db8](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/9296db88b7131bbda9b9b0da65c327ef9063ee31))
|
||||||
|
* add authentication permissions for metrics endpoints in application.yml ([04edd4d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/04edd4d6fd8a63196c36f6d67992832febc9bebb))
|
||||||
|
* add initialization metrics for various services ([d438e97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d438e97f32bdde0bfc63c1b4a8cc810cdd093166))
|
||||||
|
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
|
||||||
|
* **api:** define shared EventEnvelope and EventType for Redis EventBus ([#61](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/61)) ([595c172](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/595c172900da99de367c274488c3ccbeaef55882))
|
||||||
|
* **config:** add H2 database configuration for testing environment ([39c9e49](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/39c9e492cef2515368c074da9406f95e9c0c9e64))
|
||||||
|
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
|
||||||
|
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
|
||||||
|
* configure logging and add OpenTelemetry support ([#49](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/49)) ([d57c488](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d57c4886612d1d92da0e1b79209fc83e6ef537a1))
|
||||||
|
* **docker:** add .dockerignore and .gitignore files for build exclusions ([c987d8e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c987d8e258c0e6c4cfbdaa8381c64c410d7a2b83))
|
||||||
|
* **docker:** add Dockerfiles for building Quarkus application in native and JVM modes ([3f2d2bb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3f2d2bb4c97fa8cddba66e1da4427c54236dfeed))
|
||||||
|
* **docker:** add Dockerfiles for Quarkus application in JVM and native modes ([34b9933](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/34b993304670cf2aa62cd2f6460cee7b9864b08e))
|
||||||
|
* **events:** migrate game-creation and bot flows to Redis Streams NCS-89 ([#62](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/62)) ([a24924c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a24924c23057db3d700a75dbc4333557789cd991))
|
||||||
|
* **logging:** add DEBUG/INFO/WARN logging across services (NCS-72) ([#41](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/41)) ([804a4bf](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/804a4bf179e3dfb19e2be4390e7e543caf5237c6))
|
||||||
|
* NCS-78 Add Traceability to the Applications ([#46](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/46)) ([649566e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/649566eb3fcf38f91c8896a739f74ea318af312d))
|
||||||
|
* NCS-78 Add Traceability to the Applications ([#47](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/47)) ([87dfc6c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/87dfc6c2bcce7f7d58fc641bd8d468a2e584c108))
|
||||||
|
* NCS-82 add Swiss-system tournament module ([#55](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/55)) ([c5661de](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c5661de4a0ebf4b33211f5a391840dcf744656b7))
|
||||||
|
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
|
||||||
|
* update application.yml with new API root paths and add Micrometer and OpenTelemetry dependencies ([72ce262](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/72ce262bc491f94297700e6002fb5d0812e2cc2a))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **account:** configure JDBC connection pool size to prevent exhaustion under load ([29072ef](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/29072efbfb1cfa1c3b1a85b4c1a587c971d245f9))
|
||||||
|
* **auth:** add InternalClientHeadersFactory for custom client headers management ([e279c39](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e279c39246470156bf11e745ee72204018d4229d))
|
||||||
|
* NCS-84 More Verbose Logging ([#51](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/51)) ([4ad92ab](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/4ad92ab23698267f8faa59c4e18388d4a0042cca))
|
||||||
|
* **official-bots:** NCS-70-auto-register official bots with account service ([#59](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/59)) ([7117a93](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7117a93376272094d0b1a6abf2121254ce396684))
|
||||||
|
* remove unused HTTP root-path configurations from application.yml ([3ed3e59](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ed3e59ee456d54cd3d65ece4f36623e256b9736))
|
||||||
|
* **tests:** update token path to accessToken in ChallengeResourceTest ([354db11](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/354db11972342c47a1034303c11bccfb92e60109))
|
||||||
|
|
||||||
|
### Reverts
|
||||||
|
|
||||||
|
* Revert "feat: add authentication permissions for metrics endpoints in application.yml" ([a298417](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a298417b9e4d68dc73bbf40be63d9484536e9f83))
|
||||||
|
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||||
|
## (2026-06-09)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **account:** implement token pair handling for login and refresh endpoints ([9296db8](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/9296db88b7131bbda9b9b0da65c327ef9063ee31))
|
||||||
|
* add authentication permissions for metrics endpoints in application.yml ([04edd4d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/04edd4d6fd8a63196c36f6d67992832febc9bebb))
|
||||||
|
* add initialization metrics for various services ([d438e97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d438e97f32bdde0bfc63c1b4a8cc810cdd093166))
|
||||||
|
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
|
||||||
|
* **api:** define shared EventEnvelope and EventType for Redis EventBus ([#61](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/61)) ([595c172](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/595c172900da99de367c274488c3ccbeaef55882))
|
||||||
|
* **bot-platform:** migrate BotRegistry to Redis Streams consumer group ([#63](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/63)) ([0ad2e10](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/0ad2e10999213df6dd00f0c31a088c28a4dc0083))
|
||||||
|
* **config:** add H2 database configuration for testing environment ([39c9e49](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/39c9e492cef2515368c074da9406f95e9c0c9e64))
|
||||||
|
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
|
||||||
|
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
|
||||||
|
* configure logging and add OpenTelemetry support ([#49](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/49)) ([d57c488](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d57c4886612d1d92da0e1b79209fc83e6ef537a1))
|
||||||
|
* **docker:** add .dockerignore and .gitignore files for build exclusions ([c987d8e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c987d8e258c0e6c4cfbdaa8381c64c410d7a2b83))
|
||||||
|
* **docker:** add Dockerfiles for building Quarkus application in native and JVM modes ([3f2d2bb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3f2d2bb4c97fa8cddba66e1da4427c54236dfeed))
|
||||||
|
* **docker:** add Dockerfiles for Quarkus application in JVM and native modes ([34b9933](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/34b993304670cf2aa62cd2f6460cee7b9864b08e))
|
||||||
|
* **events:** migrate game-creation and bot flows to Redis Streams NCS-89 ([#62](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/62)) ([a24924c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a24924c23057db3d700a75dbc4333557789cd991))
|
||||||
|
* **logging:** add DEBUG/INFO/WARN logging across services (NCS-72) ([#41](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/41)) ([804a4bf](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/804a4bf179e3dfb19e2be4390e7e543caf5237c6))
|
||||||
|
* NCS-78 Add Traceability to the Applications ([#46](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/46)) ([649566e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/649566eb3fcf38f91c8896a739f74ea318af312d))
|
||||||
|
* NCS-78 Add Traceability to the Applications ([#47](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/47)) ([87dfc6c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/87dfc6c2bcce7f7d58fc641bd8d468a2e584c108))
|
||||||
|
* NCS-82 add Swiss-system tournament module ([#55](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/55)) ([c5661de](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c5661de4a0ebf4b33211f5a391840dcf744656b7))
|
||||||
|
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
|
||||||
|
* update application.yml with new API root paths and add Micrometer and OpenTelemetry dependencies ([72ce262](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/72ce262bc491f94297700e6002fb5d0812e2cc2a))
|
||||||
|
* **ws:** migrate challenge notifications to Redis Streams ([#66](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/66)) ([55f102c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/55f102cbaa684be94a158b16aaa42a50b36afaf3))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **account:** configure JDBC connection pool size to prevent exhaustion under load ([29072ef](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/29072efbfb1cfa1c3b1a85b4c1a587c971d245f9))
|
||||||
|
* **auth:** add InternalClientHeadersFactory for custom client headers management ([e279c39](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e279c39246470156bf11e745ee72204018d4229d))
|
||||||
|
* NCS-84 More Verbose Logging ([#51](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/51)) ([4ad92ab](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/4ad92ab23698267f8faa59c4e18388d4a0042cca))
|
||||||
|
* **official-bots:** NCS-70-auto-register official bots with account service ([#59](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/59)) ([7117a93](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7117a93376272094d0b1a6abf2121254ce396684))
|
||||||
|
* remove unused HTTP root-path configurations from application.yml ([3ed3e59](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ed3e59ee456d54cd3d65ece4f36623e256b9736))
|
||||||
|
* **tests:** update token path to accessToken in ChallengeResourceTest ([354db11](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/354db11972342c47a1034303c11bccfb92e60109))
|
||||||
|
|
||||||
|
### Reverts
|
||||||
|
|
||||||
|
* Revert "feat: add authentication permissions for metrics endpoints in application.yml" ([a298417](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a298417b9e4d68dc73bbf40be63d9484536e9f83))
|
||||||
|
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ dependencies {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
implementation(project(":modules:api"))
|
||||||
implementation(project(":modules:security"))
|
implementation(project(":modules:security"))
|
||||||
|
|
||||||
implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
|
implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package de.nowchess.account.client
|
||||||
|
|
||||||
|
case class CorePlayerInfo(id: String, displayName: String)
|
||||||
|
case class CoreTimeControl(limitSeconds: Option[Int], incrementSeconds: Option[Int], daysPerMove: Option[Int])
|
||||||
|
case class CoreCreateGameRequest(
|
||||||
|
white: Option[CorePlayerInfo],
|
||||||
|
black: Option[CorePlayerInfo],
|
||||||
|
timeControl: Option[CoreTimeControl],
|
||||||
|
mode: Option[String],
|
||||||
|
)
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
package de.nowchess.account.client
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import de.nowchess.account.config.RedisConfig
|
||||||
|
import de.nowchess.api.dto.{GameCreationRequestDto, GameCreationResponseDto, PlayerInfoDto, TimeControlDto}
|
||||||
|
import de.nowchess.api.game.GameMode
|
||||||
|
import de.nowchess.api.player.PlayerType
|
||||||
|
import de.nowchess.api.event.{EventEnvelope, EventType}
|
||||||
|
import io.quarkus.redis.datasource.RedisDataSource
|
||||||
|
import io.quarkus.redis.datasource.stream.{StreamMessage, XAddArgs, XGroupCreateArgs, XReadGroupArgs}
|
||||||
|
import io.quarkus.runtime.StartupEvent
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import jakarta.enterprise.event.Observes
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import org.eclipse.microprofile.config.inject.ConfigProperty
|
||||||
|
import org.eclipse.microprofile.context.ManagedExecutor
|
||||||
|
import org.jboss.logging.Logger
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
import scala.jdk.CollectionConverters.*
|
||||||
|
import scala.util.{Failure, Success, Try}
|
||||||
|
import java.time.Duration
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.{CompletableFuture, ConcurrentHashMap, TimeUnit}
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
class GameCreationStreamClient:
|
||||||
|
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
@Inject var redis: RedisDataSource = uninitialized
|
||||||
|
@Inject var redisConfig: RedisConfig = uninitialized
|
||||||
|
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||||
|
@Inject var executor: ManagedExecutor = uninitialized
|
||||||
|
@ConfigProperty(name = "nowchess.game-creation-stream.enabled", defaultValue = "true")
|
||||||
|
private var streamEnabled: Boolean = true
|
||||||
|
// scalafix:on DisableSyntax.var
|
||||||
|
|
||||||
|
private val log = Logger.getLogger(classOf[GameCreationStreamClient])
|
||||||
|
private val instanceId = UUID.randomUUID().toString
|
||||||
|
private val groupName = s"account-game-creation-$instanceId"
|
||||||
|
private val consumerId = instanceId
|
||||||
|
private val maxStreamLen = 1000L
|
||||||
|
private val timeout = Duration.ofSeconds(10)
|
||||||
|
|
||||||
|
private val pending = new ConcurrentHashMap[String, CompletableFuture[GameCreationResponseDto]]()
|
||||||
|
|
||||||
|
private def requestStream: String = s"${redisConfig.prefix}:game-creation"
|
||||||
|
private def responseStream: String = s"${redisConfig.prefix}:game-creation-response"
|
||||||
|
|
||||||
|
def start(@Observes _ev: StartupEvent): Unit =
|
||||||
|
if streamEnabled then
|
||||||
|
createGroupIfAbsent()
|
||||||
|
executor.submit(
|
||||||
|
new Runnable:
|
||||||
|
def run(): Unit = pollLoop(),
|
||||||
|
)
|
||||||
|
log.infof("Game-creation response listener started (consumer=%s)", consumerId)
|
||||||
|
|
||||||
|
def createGame(req: CoreCreateGameRequest): GameCreationResponseDto =
|
||||||
|
val correlationId = UUID.randomUUID().toString
|
||||||
|
val future = new CompletableFuture[GameCreationResponseDto]()
|
||||||
|
pending.put(correlationId, future)
|
||||||
|
Try {
|
||||||
|
val payload = objectMapper.valueToTree[com.fasterxml.jackson.databind.JsonNode](toDto(req))
|
||||||
|
val envelope = EventEnvelope.of(EventType.GameCreationRequest, payload, Some(correlationId))
|
||||||
|
publish(requestStream, envelope)
|
||||||
|
future.get(timeout.toMillis, TimeUnit.MILLISECONDS)
|
||||||
|
} match
|
||||||
|
case Success(resp) =>
|
||||||
|
pending.remove(correlationId)
|
||||||
|
resp
|
||||||
|
case Failure(ex) =>
|
||||||
|
pending.remove(correlationId)
|
||||||
|
log.errorf(ex, "Game creation request %s failed", correlationId)
|
||||||
|
GameCreationResponseDto(None, Some("Game creation request timed out or failed"))
|
||||||
|
|
||||||
|
private def toDto(req: CoreCreateGameRequest): GameCreationRequestDto =
|
||||||
|
GameCreationRequestDto(
|
||||||
|
white = req.white.map(p => PlayerInfoDto(p.id, p.displayName, PlayerType.Human)),
|
||||||
|
black = req.black.map(p => PlayerInfoDto(p.id, p.displayName, PlayerType.Human)),
|
||||||
|
timeControl = req.timeControl.map(t => TimeControlDto(t.limitSeconds, t.incrementSeconds, t.daysPerMove)),
|
||||||
|
mode = req.mode.map(_ => GameMode.Authenticated),
|
||||||
|
)
|
||||||
|
|
||||||
|
private def createGroupIfAbsent(): Unit =
|
||||||
|
Try(
|
||||||
|
redis
|
||||||
|
.stream(classOf[String])
|
||||||
|
.xgroupCreate(responseStream, groupName, "0", new XGroupCreateArgs().mkstream()),
|
||||||
|
) match
|
||||||
|
case Failure(ex) if Option(ex.getMessage).exists(_.contains("BUSYGROUP")) => ()
|
||||||
|
case Failure(ex) => log.warnf(ex, "Failed to create response consumer group")
|
||||||
|
case Success(_) => ()
|
||||||
|
|
||||||
|
private def pollLoop(): Unit =
|
||||||
|
while true do
|
||||||
|
Try {
|
||||||
|
val messages = redis
|
||||||
|
.stream(classOf[String])
|
||||||
|
.xreadgroup(
|
||||||
|
groupName,
|
||||||
|
consumerId,
|
||||||
|
responseStream,
|
||||||
|
">",
|
||||||
|
new XReadGroupArgs().count(10).block(Duration.ofSeconds(2)),
|
||||||
|
)
|
||||||
|
Option(messages).foreach(_.forEach(handleResponse))
|
||||||
|
} match
|
||||||
|
case Failure(ex) => log.warnf(ex, "Error in game-creation response poll loop")
|
||||||
|
case Success(_) => ()
|
||||||
|
|
||||||
|
private def handleResponse(msg: StreamMessage[String, String, String]): Unit =
|
||||||
|
val json = msg.payload().get("data")
|
||||||
|
Try(objectMapper.readValue(json, classOf[EventEnvelope])) match
|
||||||
|
case Success(envelope) =>
|
||||||
|
envelope.correlationId.flatMap(id => Option(pending.remove(id))).foreach { future =>
|
||||||
|
Try(objectMapper.treeToValue(envelope.payload, classOf[GameCreationResponseDto])) match
|
||||||
|
case Success(resp) => future.complete(resp)
|
||||||
|
case Failure(ex) => future.completeExceptionally(ex)
|
||||||
|
}
|
||||||
|
case Failure(ex) => log.warnf(ex, "Unparseable game-creation response: %s", json)
|
||||||
|
ack(msg.id())
|
||||||
|
|
||||||
|
private def ack(id: String): Unit =
|
||||||
|
Try(redis.stream(classOf[String]).xack(responseStream, groupName, id)) match
|
||||||
|
case Failure(ex) => log.warnf(ex, "Failed to ack response %s", id)
|
||||||
|
case Success(_) => ()
|
||||||
|
|
||||||
|
private def publish(key: String, envelope: EventEnvelope): Unit =
|
||||||
|
val json = objectMapper.writeValueAsString(envelope)
|
||||||
|
redis
|
||||||
|
.stream(classOf[String])
|
||||||
|
.xadd(key, new XAddArgs().maxlen(maxStreamLen).nearlyExactTrimming(), Map("data" -> json).asJava)
|
||||||
|
()
|
||||||
+14
-2
@@ -1,6 +1,6 @@
|
|||||||
package de.nowchess.account.config
|
package de.nowchess.account.config
|
||||||
|
|
||||||
import de.nowchess.account.client.{CoreCreateGameRequest, CoreGameResponse, CorePlayerInfo, CoreTimeControl}
|
import de.nowchess.account.client.{CoreCreateGameRequest, CorePlayerInfo, CoreTimeControl}
|
||||||
import de.nowchess.account.domain.{
|
import de.nowchess.account.domain.{
|
||||||
BotAccount,
|
BotAccount,
|
||||||
Challenge,
|
Challenge,
|
||||||
@@ -12,10 +12,19 @@ import de.nowchess.account.domain.{
|
|||||||
UserAccount,
|
UserAccount,
|
||||||
}
|
}
|
||||||
import de.nowchess.account.dto.*
|
import de.nowchess.account.dto.*
|
||||||
|
import de.nowchess.api.dto.{
|
||||||
|
GameCreationRequestDto,
|
||||||
|
GameCreationResponseDto,
|
||||||
|
PlayerInfoDto as ApiPlayerInfoDto,
|
||||||
|
TimeControlDto as ApiTimeControlDto,
|
||||||
|
}
|
||||||
|
import de.nowchess.api.event.{EventEnvelope, EventType}
|
||||||
import io.quarkus.runtime.annotations.RegisterForReflection
|
import io.quarkus.runtime.annotations.RegisterForReflection
|
||||||
|
|
||||||
@RegisterForReflection(
|
@RegisterForReflection(
|
||||||
targets = Array(
|
targets = Array(
|
||||||
|
classOf[EventEnvelope],
|
||||||
|
classOf[EventType],
|
||||||
classOf[UserAccount],
|
classOf[UserAccount],
|
||||||
classOf[BotAccount],
|
classOf[BotAccount],
|
||||||
classOf[OfficialBotAccount],
|
classOf[OfficialBotAccount],
|
||||||
@@ -44,8 +53,11 @@ import io.quarkus.runtime.annotations.RegisterForReflection
|
|||||||
classOf[CorePlayerInfo],
|
classOf[CorePlayerInfo],
|
||||||
classOf[CoreTimeControl],
|
classOf[CoreTimeControl],
|
||||||
classOf[CoreCreateGameRequest],
|
classOf[CoreCreateGameRequest],
|
||||||
classOf[CoreGameResponse],
|
|
||||||
classOf[OfficialChallengeResponse],
|
classOf[OfficialChallengeResponse],
|
||||||
|
classOf[GameCreationRequestDto],
|
||||||
|
classOf[GameCreationResponseDto],
|
||||||
|
classOf[ApiPlayerInfoDto],
|
||||||
|
classOf[ApiTimeControlDto],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
class NativeReflectionConfig
|
class NativeReflectionConfig
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class BotAccount extends PanacheEntityBase:
|
|||||||
@JoinColumn(name = "owner_id", nullable = false)
|
@JoinColumn(name = "owner_id", nullable = false)
|
||||||
var owner: UserAccount = uninitialized
|
var owner: UserAccount = uninitialized
|
||||||
|
|
||||||
@Column(unique = true, nullable = false, length = 256)
|
@Column(unique = true, nullable = false, length = 1024)
|
||||||
var token: String = uninitialized
|
var token: String = uninitialized
|
||||||
|
|
||||||
var rating: Int = 1500
|
var rating: Int = 1500
|
||||||
@@ -75,4 +75,7 @@ class OfficialBotAccount extends PanacheEntityBase:
|
|||||||
var rating: Int = 1500
|
var rating: Int = 1500
|
||||||
|
|
||||||
var createdAt: Instant = uninitialized
|
var createdAt: Instant = uninitialized
|
||||||
|
|
||||||
|
@Column(length = 1024)
|
||||||
|
var token: String = uninitialized
|
||||||
// scalafix:on
|
// scalafix:on
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ case class BotAccountWithTokenDto(id: String, name: String, rating: Int, token:
|
|||||||
|
|
||||||
case class RotatedTokenDto(token: String)
|
case class RotatedTokenDto(token: String)
|
||||||
|
|
||||||
case class OfficialBotAccountDto(id: String, name: String, rating: Int, createdAt: String)
|
case class OfficialBotAccountDto(id: String, name: String, rating: Int, createdAt: String, token: Option[String] = None)
|
||||||
|
|
||||||
case class OfficialChallengeResponse(gameId: String, botName: String, difficulty: Int)
|
case class OfficialChallengeResponse(gameId: String, botName: String, difficulty: Int)
|
||||||
|
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ class AccountResource:
|
|||||||
def createOfficialBot(req: CreateBotAccountRequest): Response =
|
def createOfficialBot(req: CreateBotAccountRequest): Response =
|
||||||
accountService.createOfficialBotAccount(req.name) match
|
accountService.createOfficialBotAccount(req.name) match
|
||||||
case Right(bot) =>
|
case Right(bot) =>
|
||||||
Response.status(Response.Status.CREATED).entity(toOfficialBotDto(bot)).build()
|
Response.status(Response.Status.CREATED).entity(toOfficialBotDtoWithToken(bot)).build()
|
||||||
case Left(error) =>
|
case Left(error) =>
|
||||||
Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(ErrorDto(error.message)).build()
|
Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(ErrorDto(error.message)).build()
|
||||||
|
|
||||||
@@ -219,3 +219,12 @@ class AccountResource:
|
|||||||
rating = bot.rating,
|
rating = bot.rating,
|
||||||
createdAt = bot.createdAt.toString,
|
createdAt = bot.createdAt.toString,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private def toOfficialBotDtoWithToken(bot: OfficialBotAccount): OfficialBotAccountDto =
|
||||||
|
OfficialBotAccountDto(
|
||||||
|
id = bot.id.toString,
|
||||||
|
name = bot.name,
|
||||||
|
rating = bot.rating,
|
||||||
|
createdAt = bot.createdAt.toString,
|
||||||
|
token = Some(bot.token),
|
||||||
|
)
|
||||||
|
|||||||
+3
-5
@@ -1,6 +1,6 @@
|
|||||||
package de.nowchess.account.resource
|
package de.nowchess.account.resource
|
||||||
|
|
||||||
import de.nowchess.account.client.{CoreCreateGameRequest, CoreGameClient, CorePlayerInfo}
|
import de.nowchess.account.client.{CoreCreateGameRequest, CorePlayerInfo, GameCreationStreamClient}
|
||||||
import de.nowchess.account.dto.{ErrorDto, OfficialChallengeResponse}
|
import de.nowchess.account.dto.{ErrorDto, OfficialChallengeResponse}
|
||||||
import de.nowchess.account.service.{AccountService, EventPublisher}
|
import de.nowchess.account.service.{AccountService, EventPublisher}
|
||||||
import jakarta.annotation.security.RolesAllowed
|
import jakarta.annotation.security.RolesAllowed
|
||||||
@@ -9,7 +9,6 @@ import jakarta.inject.Inject
|
|||||||
import jakarta.ws.rs.*
|
import jakarta.ws.rs.*
|
||||||
import jakarta.ws.rs.core.{MediaType, Response}
|
import jakarta.ws.rs.core.{MediaType, Response}
|
||||||
import org.eclipse.microprofile.jwt.JsonWebToken
|
import org.eclipse.microprofile.jwt.JsonWebToken
|
||||||
import org.eclipse.microprofile.rest.client.inject.RestClient
|
|
||||||
import org.jboss.logging.Logger
|
import org.jboss.logging.Logger
|
||||||
import scala.compiletime.uninitialized
|
import scala.compiletime.uninitialized
|
||||||
|
|
||||||
@@ -29,8 +28,7 @@ class OfficialChallengeResource:
|
|||||||
@Inject var botEventPublisher: EventPublisher = uninitialized
|
@Inject var botEventPublisher: EventPublisher = uninitialized
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
@RestClient
|
var gameCreationClient: GameCreationStreamClient = uninitialized
|
||||||
var coreGameClient: CoreGameClient = uninitialized
|
|
||||||
// scalafix:on
|
// scalafix:on
|
||||||
|
|
||||||
private val log = Logger.getLogger(classOf[OfficialChallengeResource])
|
private val log = Logger.getLogger(classOf[OfficialChallengeResource])
|
||||||
@@ -72,7 +70,7 @@ class OfficialChallengeResource:
|
|||||||
(CorePlayerInfo(bot.id.toString, bot.name), CorePlayerInfo(user.id.toString, user.username), "white")
|
(CorePlayerInfo(bot.id.toString, bot.name), CorePlayerInfo(user.id.toString, user.username), "white")
|
||||||
val req = CoreCreateGameRequest(Some(white), Some(black), None, Some("Authenticated"))
|
val req = CoreCreateGameRequest(Some(white), Some(black), None, Some("Authenticated"))
|
||||||
val gameId =
|
val gameId =
|
||||||
try Right(coreGameClient.createGame(req).gameId)
|
try gameCreationClient.createGame(req).gameId.toRight("Failed to create game")
|
||||||
catch case _ => Left("Failed to create game")
|
catch case _ => Left("Failed to create game")
|
||||||
gameId match
|
gameId match
|
||||||
case Left(err) =>
|
case Left(err) =>
|
||||||
|
|||||||
@@ -153,9 +153,10 @@ class AccountService:
|
|||||||
val bot = new BotAccount()
|
val bot = new BotAccount()
|
||||||
bot.name = botName
|
bot.name = botName
|
||||||
bot.owner = owner
|
bot.owner = owner
|
||||||
bot.token = generateBotToken(bot.id)
|
bot.token = UUID.randomUUID().toString
|
||||||
bot.createdAt = Instant.now()
|
bot.createdAt = Instant.now()
|
||||||
botAccountRepository.persist(bot)
|
botAccountRepository.persist(bot)
|
||||||
|
bot.token = generateBotToken(bot.id, bot.name)
|
||||||
log.infof("Bot account %s created for owner %s", botName, ownerId.toString)
|
log.infof("Bot account %s created for owner %s", botName, ownerId.toString)
|
||||||
Right(bot)
|
Right(bot)
|
||||||
|
|
||||||
@@ -194,7 +195,7 @@ class AccountService:
|
|||||||
case Some(bot) =>
|
case Some(bot) =>
|
||||||
if bot.owner.id != ownerId then Left(AccountError.NotAuthorized)
|
if bot.owner.id != ownerId then Left(AccountError.NotAuthorized)
|
||||||
else
|
else
|
||||||
bot.token = generateBotToken(botId)
|
bot.token = generateBotToken(botId, bot.name)
|
||||||
botAccountRepository.persist(bot)
|
botAccountRepository.persist(bot)
|
||||||
Right(bot)
|
Right(bot)
|
||||||
|
|
||||||
@@ -204,6 +205,8 @@ class AccountService:
|
|||||||
bot.name = botName
|
bot.name = botName
|
||||||
bot.createdAt = Instant.now()
|
bot.createdAt = Instant.now()
|
||||||
officialBotAccountRepository.persist(bot)
|
officialBotAccountRepository.persist(bot)
|
||||||
|
bot.token = generateBotToken(bot.id, bot.name)
|
||||||
|
officialBotAccountRepository.persist(bot)
|
||||||
Right(bot)
|
Right(bot)
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -214,6 +217,8 @@ class AccountService:
|
|||||||
bot.name = name
|
bot.name = name
|
||||||
bot.createdAt = Instant.now()
|
bot.createdAt = Instant.now()
|
||||||
officialBotAccountRepository.persist(bot)
|
officialBotAccountRepository.persist(bot)
|
||||||
|
bot.token = generateBotToken(bot.id, bot.name)
|
||||||
|
officialBotAccountRepository.persist(bot)
|
||||||
log.infof("Auto-registered official bot: %s", name)
|
log.infof("Auto-registered official bot: %s", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,12 +233,13 @@ class AccountService:
|
|||||||
officialBotAccountRepository.delete(botId)
|
officialBotAccountRepository.delete(botId)
|
||||||
Right(())
|
Right(())
|
||||||
|
|
||||||
private def generateBotToken(botId: UUID): String =
|
private def generateBotToken(botId: UUID, botName: String): String =
|
||||||
Jwt
|
Jwt
|
||||||
.issuer("nowchess")
|
.issuer("nowchess")
|
||||||
.subject(botId.toString)
|
.subject(botId.toString)
|
||||||
.expiresAt(Long.MaxValue)
|
.expiresAt(Long.MaxValue)
|
||||||
.claim("type", "bot")
|
.claim("type", "bot")
|
||||||
|
.claim("name", botName)
|
||||||
.sign()
|
.sign()
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
package de.nowchess.account.service
|
package de.nowchess.account.service
|
||||||
|
|
||||||
import de.nowchess.account.client.{
|
import de.nowchess.account.client.{CoreCreateGameRequest, CorePlayerInfo, CoreTimeControl, GameCreationStreamClient}
|
||||||
CoreCreateGameRequest,
|
|
||||||
CoreGameClient,
|
|
||||||
CoreGameResponse,
|
|
||||||
CorePlayerInfo,
|
|
||||||
CoreTimeControl,
|
|
||||||
}
|
|
||||||
import de.nowchess.account.domain.{Challenge, ChallengeColor, ChallengeStatus, DeclineReason}
|
import de.nowchess.account.domain.{Challenge, ChallengeColor, ChallengeStatus, DeclineReason}
|
||||||
import de.nowchess.account.dto.{
|
import de.nowchess.account.dto.{
|
||||||
ChallengeDto,
|
ChallengeDto,
|
||||||
@@ -23,7 +17,6 @@ import jakarta.annotation.PostConstruct
|
|||||||
import jakarta.enterprise.context.ApplicationScoped
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
import jakarta.inject.Inject
|
import jakarta.inject.Inject
|
||||||
import jakarta.transaction.Transactional
|
import jakarta.transaction.Transactional
|
||||||
import org.eclipse.microprofile.rest.client.inject.RestClient
|
|
||||||
import org.jboss.logging.Logger
|
import org.jboss.logging.Logger
|
||||||
import scala.compiletime.uninitialized
|
import scala.compiletime.uninitialized
|
||||||
|
|
||||||
@@ -45,8 +38,7 @@ class ChallengeService:
|
|||||||
var challengeRepository: ChallengeRepository = uninitialized
|
var challengeRepository: ChallengeRepository = uninitialized
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
@RestClient
|
var gameCreationClient: GameCreationStreamClient = uninitialized
|
||||||
var coreGameClient: CoreGameClient = uninitialized
|
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
var eventPublisher: EventPublisher = uninitialized
|
var eventPublisher: EventPublisher = uninitialized
|
||||||
@@ -187,7 +179,7 @@ class ChallengeService:
|
|||||||
val (white, black) = assignColors(challenge)
|
val (white, black) = assignColors(challenge)
|
||||||
val tc = buildTimeControl(challenge)
|
val tc = buildTimeControl(challenge)
|
||||||
val req = CoreCreateGameRequest(Some(white), Some(black), tc, Some("Authenticated"))
|
val req = CoreCreateGameRequest(Some(white), Some(black), tc, Some("Authenticated"))
|
||||||
Right(coreGameClient.createGame(req).gameId)
|
gameCreationClient.createGame(req).gameId.toRight(ChallengeError.GameCreationFailed)
|
||||||
catch case _ => Left(ChallengeError.GameCreationFailed)
|
catch case _ => Left(ChallengeError.GameCreationFailed)
|
||||||
|
|
||||||
private def assignColors(challenge: Challenge): (CorePlayerInfo, CorePlayerInfo) =
|
private def assignColors(challenge: Challenge): (CorePlayerInfo, CorePlayerInfo) =
|
||||||
|
|||||||
@@ -1,31 +1,67 @@
|
|||||||
package de.nowchess.account.service
|
package de.nowchess.account.service
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import de.nowchess.account.config.RedisConfig
|
import de.nowchess.account.config.RedisConfig
|
||||||
|
import de.nowchess.api.event.{EventEnvelope, EventType}
|
||||||
import io.quarkus.redis.datasource.RedisDataSource
|
import io.quarkus.redis.datasource.RedisDataSource
|
||||||
|
import io.quarkus.redis.datasource.stream.XAddArgs
|
||||||
import jakarta.enterprise.context.ApplicationScoped
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
import jakarta.inject.Inject
|
import jakarta.inject.Inject
|
||||||
import scala.compiletime.uninitialized
|
import scala.compiletime.uninitialized
|
||||||
|
import scala.jdk.CollectionConverters.*
|
||||||
|
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
class EventPublisher:
|
class EventPublisher:
|
||||||
|
|
||||||
// scalafix:off DisableSyntax.var
|
// scalafix:off DisableSyntax.var
|
||||||
@Inject var redis: RedisDataSource = uninitialized
|
@Inject var redis: RedisDataSource = uninitialized
|
||||||
@Inject var redisConfig: RedisConfig = uninitialized
|
@Inject var redisConfig: RedisConfig = uninitialized
|
||||||
|
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||||
// scalafix:on DisableSyntax.var
|
// scalafix:on DisableSyntax.var
|
||||||
|
|
||||||
|
private val maxStreamLen = 1000L
|
||||||
|
|
||||||
def publishGameStart(botId: String, gameId: String, playingAs: String, difficulty: Int, botAccountId: String): Unit =
|
def publishGameStart(botId: String, gameId: String, playingAs: String, difficulty: Int, botAccountId: String): Unit =
|
||||||
val event =
|
val payload = objectMapper.createObjectNode()
|
||||||
s"""{"type":"gameStart","gameId":"$gameId","playingAs":"$playingAs","difficulty":$difficulty,"botAccountId":"$botAccountId"}"""
|
payload.put("gameId", gameId)
|
||||||
redis.pubsub(classOf[String]).publish(s"${redisConfig.prefix}:bot:$botId:events", event)
|
payload.put("playingAs", playingAs)
|
||||||
|
payload.put("difficulty", difficulty)
|
||||||
|
payload.put("botAccountId", botAccountId)
|
||||||
|
val envelope = EventEnvelope.of(EventType.BotGameStart, payload)
|
||||||
|
val json = objectMapper.writeValueAsString(envelope)
|
||||||
|
redis
|
||||||
|
.stream(classOf[String])
|
||||||
|
.xadd(
|
||||||
|
s"${redisConfig.prefix}:bot:$botId:events:stream",
|
||||||
|
new XAddArgs().maxlen(maxStreamLen).nearlyExactTrimming(),
|
||||||
|
Map("data" -> json).asJava,
|
||||||
|
)
|
||||||
()
|
()
|
||||||
|
|
||||||
def publishChallengeCreated(destUserId: String, challengeId: String, challengerName: String): Unit =
|
def publishChallengeCreated(destUserId: String, challengeId: String, challengerName: String): Unit =
|
||||||
val event = s"""{"type":"challengeCreated","challengeId":"$challengeId","challengerName":"$challengerName"}"""
|
val payload = objectMapper.createObjectNode()
|
||||||
redis.pubsub(classOf[String]).publish(s"${redisConfig.prefix}:user:$destUserId:events", event)
|
payload.put("challengeId", challengeId)
|
||||||
()
|
payload.put("challengerName", challengerName)
|
||||||
|
publishToUserStream(destUserId, EventType.ChallengeCreated, payload)
|
||||||
|
|
||||||
def publishChallengeAccepted(challengerId: String, challengeId: String, gameId: String): Unit =
|
def publishChallengeAccepted(challengerId: String, challengeId: String, gameId: String): Unit =
|
||||||
val event = s"""{"type":"challengeAccepted","challengeId":"$challengeId","gameId":"$gameId"}"""
|
val payload = objectMapper.createObjectNode()
|
||||||
redis.pubsub(classOf[String]).publish(s"${redisConfig.prefix}:user:$challengerId:events", event)
|
payload.put("challengeId", challengeId)
|
||||||
|
payload.put("gameId", gameId)
|
||||||
|
publishToUserStream(challengerId, EventType.ChallengeAccepted, payload)
|
||||||
|
|
||||||
|
private def publishToUserStream(
|
||||||
|
userId: String,
|
||||||
|
eventType: EventType,
|
||||||
|
payload: com.fasterxml.jackson.databind.node.ObjectNode,
|
||||||
|
): Unit =
|
||||||
|
val envelope = EventEnvelope.of(eventType, payload)
|
||||||
|
val json = objectMapper.writeValueAsString(envelope)
|
||||||
|
redis
|
||||||
|
.stream(classOf[String])
|
||||||
|
.xadd(
|
||||||
|
s"${redisConfig.prefix}:user:$userId:events:stream",
|
||||||
|
new XAddArgs().maxlen(maxStreamLen).nearlyExactTrimming(),
|
||||||
|
Map("data" -> json).asJava,
|
||||||
|
)
|
||||||
()
|
()
|
||||||
|
|||||||
@@ -34,3 +34,5 @@ nowchess:
|
|||||||
secret: test-secret
|
secret: test-secret
|
||||||
auth:
|
auth:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
game-creation-stream:
|
||||||
|
enabled: false
|
||||||
|
|||||||
+6
-5
@@ -1,11 +1,11 @@
|
|||||||
package de.nowchess.account.resource
|
package de.nowchess.account.resource
|
||||||
|
|
||||||
import de.nowchess.account.client.{CoreGameClient, CoreGameResponse}
|
import de.nowchess.account.client.GameCreationStreamClient
|
||||||
|
import de.nowchess.api.dto.GameCreationResponseDto
|
||||||
import io.quarkus.test.InjectMock
|
import io.quarkus.test.InjectMock
|
||||||
import io.quarkus.test.junit.QuarkusTest
|
import io.quarkus.test.junit.QuarkusTest
|
||||||
import io.restassured.RestAssured
|
import io.restassured.RestAssured
|
||||||
import io.restassured.http.ContentType
|
import io.restassured.http.ContentType
|
||||||
import org.eclipse.microprofile.rest.client.inject.RestClient
|
|
||||||
import org.hamcrest.Matchers.*
|
import org.hamcrest.Matchers.*
|
||||||
import org.junit.jupiter.api.{BeforeEach, Test}
|
import org.junit.jupiter.api.{BeforeEach, Test}
|
||||||
import org.mockito.{ArgumentMatchers, Mockito}
|
import org.mockito.{ArgumentMatchers, Mockito}
|
||||||
@@ -14,14 +14,15 @@ import org.mockito.{ArgumentMatchers, Mockito}
|
|||||||
class ChallengeResourceTest:
|
class ChallengeResourceTest:
|
||||||
|
|
||||||
@InjectMock
|
@InjectMock
|
||||||
@RestClient
|
|
||||||
// scalafix:off DisableSyntax.var
|
// scalafix:off DisableSyntax.var
|
||||||
var coreGameClient: CoreGameClient = scala.compiletime.uninitialized
|
var gameCreationClient: GameCreationStreamClient = scala.compiletime.uninitialized
|
||||||
// scalafix:on
|
// scalafix:on
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
def setup(): Unit =
|
def setup(): Unit =
|
||||||
Mockito.when(coreGameClient.createGame(ArgumentMatchers.any())).thenReturn(CoreGameResponse("test-game-id"))
|
Mockito
|
||||||
|
.when(gameCreationClient.createGame(ArgumentMatchers.any()))
|
||||||
|
.thenReturn(GameCreationResponseDto(Some("test-game-id")))
|
||||||
|
|
||||||
private def givenRequest() = RestAssured.`given`().contentType(ContentType.JSON)
|
private def givenRequest() = RestAssured.`given`().contentType(ContentType.JSON)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package de.nowchess.account.service
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
|
||||||
|
import de.nowchess.account.config.RedisConfig
|
||||||
|
import io.quarkus.redis.datasource.RedisDataSource
|
||||||
|
import io.quarkus.redis.datasource.stream.{StreamCommands, XAddArgs}
|
||||||
|
import org.junit.jupiter.api.{BeforeEach, Test}
|
||||||
|
import org.mockito.ArgumentMatchers.*
|
||||||
|
import org.mockito.Mockito.*
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
|
||||||
|
class EventPublisherTest:
|
||||||
|
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
private var redis: RedisDataSource = uninitialized
|
||||||
|
private var streamCmds: StreamCommands[String, String, Nothing] = uninitialized
|
||||||
|
private var redisConfig: RedisConfig = uninitialized
|
||||||
|
// scalafix:on DisableSyntax.var
|
||||||
|
|
||||||
|
private val objectMapper = new ObjectMapper().registerModule(new JavaTimeModule())
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
def setup(): Unit =
|
||||||
|
redis = mock(classOf[RedisDataSource])
|
||||||
|
streamCmds = mock(classOf[StreamCommands[String, String, Nothing]])
|
||||||
|
redisConfig = mock(classOf[RedisConfig])
|
||||||
|
when(redis.stream(classOf[String])).thenReturn(streamCmds)
|
||||||
|
when(redisConfig.prefix).thenReturn("nowchess")
|
||||||
|
|
||||||
|
private def publisher: EventPublisher =
|
||||||
|
val p = new EventPublisher
|
||||||
|
p.redis = redis
|
||||||
|
p.redisConfig = redisConfig
|
||||||
|
p.objectMapper = objectMapper
|
||||||
|
p
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def publishChallengeCreatedWritesToUserStream(): Unit =
|
||||||
|
publisher.publishChallengeCreated("user1", "ch1", "Alice")
|
||||||
|
verify(streamCmds).xadd(
|
||||||
|
org.mockito.ArgumentMatchers.eq("nowchess:user:user1:events:stream"),
|
||||||
|
any(classOf[XAddArgs]),
|
||||||
|
any(),
|
||||||
|
)
|
||||||
|
verify(redis, never()).pubsub(any(classOf[Class[?]]))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def publishChallengeAcceptedWritesToUserStream(): Unit =
|
||||||
|
publisher.publishChallengeAccepted("user2", "ch1", "game42")
|
||||||
|
verify(streamCmds).xadd(
|
||||||
|
org.mockito.ArgumentMatchers.eq("nowchess:user:user2:events:stream"),
|
||||||
|
any(classOf[XAddArgs]),
|
||||||
|
any(),
|
||||||
|
)
|
||||||
|
verify(redis, never()).pubsub(any(classOf[Class[?]]))
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
MAJOR=0
|
MAJOR=0
|
||||||
MINOR=19
|
MINOR=23
|
||||||
PATCH=0
|
PATCH=0
|
||||||
|
|||||||
@@ -145,3 +145,63 @@
|
|||||||
|
|
||||||
* **dependencies:** correct Jackson databind dependency group ID ([008d72d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/008d72d826707c04205bac7de25170fae5fed861))
|
* **dependencies:** correct Jackson databind dependency group ID ([008d72d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/008d72d826707c04205bac7de25170fae5fed861))
|
||||||
* IO microservice ([#38](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/38)) ([a386f57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a386f57c21d34ead6cc6f92836c52b714597e289))
|
* IO microservice ([#38](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/38)) ([a386f57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a386f57c21d34ead6cc6f92836c52b714597e289))
|
||||||
|
## (2026-06-05)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **api:** define shared EventEnvelope and EventType for Redis EventBus ([#61](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/61)) ([595c172](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/595c172900da99de367c274488c3ccbeaef55882))
|
||||||
|
* **dto:** update GameWritebackEventDto for JSON deserialization and remove unused mixin ([576e3fe](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/576e3fea9bf1082549ea53efd3288474c42be93d))
|
||||||
|
* NCS-13 Implement Threefold Repetition ([#31](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/31)) ([767d305](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/767d3051a76c266050b6335774d66e2db2273c16))
|
||||||
|
* NCS-14 implemented insufficient moves rule ([#30](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/30)) ([b0399a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0399a4e489950083066c9538df9a84dcc7a4613))
|
||||||
|
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||||
|
* NCS-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2))
|
||||||
|
* NCS-37 Quarkus integration ([#35](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/35)) ([f088c4e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f088c4e9ffcc498d3d1b6f01e8f50042d5830d55))
|
||||||
|
* NCS-41 Bot Platform ([#33](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/33)) ([8744bee](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8744bee2dd20966dae90a09c21a43d5b06f59e00))
|
||||||
|
* **rule:** Rules as a microservice ([#39](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/39)) ([093134d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/093134d36c6844ba02a36a28d5d044f09291cd1d))
|
||||||
|
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **dependencies:** correct Jackson databind dependency group ID ([008d72d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/008d72d826707c04205bac7de25170fae5fed861))
|
||||||
|
* IO microservice ([#38](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/38)) ([a386f57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a386f57c21d34ead6cc6f92836c52b714597e289))
|
||||||
|
## (2026-06-09)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **api:** define shared EventEnvelope and EventType for Redis EventBus ([#61](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/61)) ([595c172](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/595c172900da99de367c274488c3ccbeaef55882))
|
||||||
|
* **dto:** update GameWritebackEventDto for JSON deserialization and remove unused mixin ([576e3fe](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/576e3fea9bf1082549ea53efd3288474c42be93d))
|
||||||
|
* **events:** migrate game-creation and bot flows to Redis Streams NCS-89 ([#62](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/62)) ([a24924c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a24924c23057db3d700a75dbc4333557789cd991))
|
||||||
|
* NCS-13 Implement Threefold Repetition ([#31](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/31)) ([767d305](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/767d3051a76c266050b6335774d66e2db2273c16))
|
||||||
|
* NCS-14 implemented insufficient moves rule ([#30](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/30)) ([b0399a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0399a4e489950083066c9538df9a84dcc7a4613))
|
||||||
|
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||||
|
* NCS-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2))
|
||||||
|
* NCS-37 Quarkus integration ([#35](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/35)) ([f088c4e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f088c4e9ffcc498d3d1b6f01e8f50042d5830d55))
|
||||||
|
* NCS-41 Bot Platform ([#33](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/33)) ([8744bee](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8744bee2dd20966dae90a09c21a43d5b06f59e00))
|
||||||
|
* **rule:** Rules as a microservice ([#39](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/39)) ([093134d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/093134d36c6844ba02a36a28d5d044f09291cd1d))
|
||||||
|
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **dependencies:** correct Jackson databind dependency group ID ([008d72d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/008d72d826707c04205bac7de25170fae5fed861))
|
||||||
|
* IO microservice ([#38](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/38)) ([a386f57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a386f57c21d34ead6cc6f92836c52b714597e289))
|
||||||
|
## (2026-06-09)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **api:** define shared EventEnvelope and EventType for Redis EventBus ([#61](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/61)) ([595c172](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/595c172900da99de367c274488c3ccbeaef55882))
|
||||||
|
* **core:** publish GameOver event to Redis Streams ([#64](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/64)) ([676e411](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/676e4110c0893917d8bc7f836db6a19c69c5e9a5))
|
||||||
|
* **dto:** update GameWritebackEventDto for JSON deserialization and remove unused mixin ([576e3fe](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/576e3fea9bf1082549ea53efd3288474c42be93d))
|
||||||
|
* **events:** migrate game-creation and bot flows to Redis Streams NCS-89 ([#62](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/62)) ([a24924c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a24924c23057db3d700a75dbc4333557789cd991))
|
||||||
|
* NCS-13 Implement Threefold Repetition ([#31](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/31)) ([767d305](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/767d3051a76c266050b6335774d66e2db2273c16))
|
||||||
|
* NCS-14 implemented insufficient moves rule ([#30](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/30)) ([b0399a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0399a4e489950083066c9538df9a84dcc7a4613))
|
||||||
|
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||||
|
* NCS-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2))
|
||||||
|
* NCS-37 Quarkus integration ([#35](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/35)) ([f088c4e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f088c4e9ffcc498d3d1b6f01e8f50042d5830d55))
|
||||||
|
* NCS-41 Bot Platform ([#33](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/33)) ([8744bee](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8744bee2dd20966dae90a09c21a43d5b06f59e00))
|
||||||
|
* **rule:** Rules as a microservice ([#39](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/39)) ([093134d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/093134d36c6844ba02a36a28d5d044f09291cd1d))
|
||||||
|
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **dependencies:** correct Jackson databind dependency group ID ([008d72d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/008d72d826707c04205bac7de25170fae5fed861))
|
||||||
|
* IO microservice ([#38](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/38)) ([a386f57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a386f57c21d34ead6cc6f92836c52b714597e289))
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ dependencies {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
implementation("com.fasterxml.jackson.core:jackson-databind:${versions["JACKSON"]!!}")
|
implementation("com.fasterxml.jackson.core:jackson-databind:${versions["JACKSON"]!!}")
|
||||||
|
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
|
||||||
|
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${versions["JACKSON"]!!}")
|
||||||
|
|
||||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package de.nowchess.api.dto
|
||||||
|
|
||||||
|
import de.nowchess.api.game.GameMode
|
||||||
|
|
||||||
|
final case class GameCreationRequestDto(
|
||||||
|
white: Option[PlayerInfoDto],
|
||||||
|
black: Option[PlayerInfoDto],
|
||||||
|
timeControl: Option[TimeControlDto],
|
||||||
|
mode: Option[GameMode] = None,
|
||||||
|
)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package de.nowchess.api.dto
|
||||||
|
|
||||||
|
final case class GameCreationResponseDto(
|
||||||
|
gameId: Option[String],
|
||||||
|
error: Option[String] = None,
|
||||||
|
)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package de.nowchess.api.event
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
final case class EventEnvelope(
|
||||||
|
eventId: UUID,
|
||||||
|
`type`: EventType,
|
||||||
|
payload: JsonNode,
|
||||||
|
timestamp: Instant,
|
||||||
|
correlationId: Option[String],
|
||||||
|
)
|
||||||
|
|
||||||
|
object EventEnvelope:
|
||||||
|
def of(
|
||||||
|
`type`: EventType,
|
||||||
|
payload: JsonNode,
|
||||||
|
correlationId: Option[String] = None,
|
||||||
|
): EventEnvelope =
|
||||||
|
EventEnvelope(
|
||||||
|
eventId = UUID.randomUUID(),
|
||||||
|
`type` = `type`,
|
||||||
|
payload = payload,
|
||||||
|
timestamp = Instant.now(),
|
||||||
|
correlationId = correlationId,
|
||||||
|
)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package de.nowchess.api.event
|
||||||
|
|
||||||
|
enum EventType:
|
||||||
|
case GameStart, GameCreationRequest, GameCreationResponse, BotGameStart, ChallengeCreated, ChallengeAccepted, GameOver
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package de.nowchess.api.event
|
||||||
|
|
||||||
|
final case class GameOverPayload(
|
||||||
|
gameId: String,
|
||||||
|
result: String,
|
||||||
|
terminationReason: String,
|
||||||
|
whiteId: String,
|
||||||
|
blackId: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package de.nowchess.api.event
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import com.fasterxml.jackson.module.scala.DefaultScalaModule
|
||||||
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
|
class EventEnvelopeTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
|
private val mapper =
|
||||||
|
val m = new ObjectMapper()
|
||||||
|
m.registerModule(DefaultScalaModule)
|
||||||
|
m.findAndRegisterModules()
|
||||||
|
m
|
||||||
|
|
||||||
|
test("EventEnvelope round-trips through JSON") {
|
||||||
|
val payload = mapper.createObjectNode()
|
||||||
|
payload.put("gameId", "game-123")
|
||||||
|
payload.put("difficulty", 3)
|
||||||
|
|
||||||
|
val original = EventEnvelope.of(EventType.GameStart, payload, Some("corr-abc"))
|
||||||
|
|
||||||
|
val json = mapper.writeValueAsString(original)
|
||||||
|
val decoded = mapper.readValue(json, classOf[EventEnvelope])
|
||||||
|
|
||||||
|
decoded.eventId shouldBe original.eventId
|
||||||
|
decoded.`type` shouldBe original.`type`
|
||||||
|
decoded.payload shouldBe original.payload
|
||||||
|
decoded.timestamp shouldBe original.timestamp
|
||||||
|
decoded.correlationId shouldBe Some("corr-abc")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("EventEnvelope serializes without correlationId") {
|
||||||
|
val payload = mapper.createObjectNode()
|
||||||
|
payload.put("challengeId", "ch-1")
|
||||||
|
|
||||||
|
val envelope = EventEnvelope.of(EventType.ChallengeCreated, payload)
|
||||||
|
val json = mapper.writeValueAsString(envelope)
|
||||||
|
val decoded = mapper.readValue(json, classOf[EventEnvelope])
|
||||||
|
|
||||||
|
decoded.`type` shouldBe EventType.ChallengeCreated
|
||||||
|
decoded.correlationId shouldBe None
|
||||||
|
}
|
||||||
|
|
||||||
|
test("EventEnvelope.of generates unique eventIds") {
|
||||||
|
val payload = mapper.createObjectNode()
|
||||||
|
val e1 = EventEnvelope.of(EventType.BotGameStart, payload)
|
||||||
|
val e2 = EventEnvelope.of(EventType.BotGameStart, payload)
|
||||||
|
e1.eventId should not equal e2.eventId
|
||||||
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
MAJOR=0
|
MAJOR=0
|
||||||
MINOR=14
|
MINOR=17
|
||||||
PATCH=0
|
PATCH=0
|
||||||
|
|||||||
@@ -129,3 +129,22 @@
|
|||||||
### Reverts
|
### Reverts
|
||||||
|
|
||||||
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||||
|
## (2026-06-09)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
|
||||||
|
* **bot-platform:** migrate BotRegistry to Redis Streams consumer group ([#63](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/63)) ([0ad2e10](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/0ad2e10999213df6dd00f0c31a088c28a4dc0083))
|
||||||
|
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
|
||||||
|
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
|
||||||
|
* **docker:** add .dockerignore and .gitignore files for build exclusions ([c987d8e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c987d8e258c0e6c4cfbdaa8381c64c410d7a2b83))
|
||||||
|
* **docker:** add Dockerfiles for building Quarkus application in native and JVM modes ([3f2d2bb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3f2d2bb4c97fa8cddba66e1da4427c54236dfeed))
|
||||||
|
* **docker:** add Dockerfiles for Quarkus application in JVM and native modes ([34b9933](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/34b993304670cf2aa62cd2f6460cee7b9864b08e))
|
||||||
|
* **logging:** add DEBUG/INFO/WARN logging across services (NCS-72) ([#41](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/41)) ([804a4bf](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/804a4bf179e3dfb19e2be4390e7e543caf5237c6))
|
||||||
|
* NCS-78 Add Traceability to the Applications ([#46](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/46)) ([649566e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/649566eb3fcf38f91c8896a739f74ea318af312d))
|
||||||
|
* NCS-78 Add Traceability to the Applications ([#47](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/47)) ([87dfc6c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/87dfc6c2bcce7f7d58fc641bd8d468a2e584c108))
|
||||||
|
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
|
||||||
|
|
||||||
|
### Reverts
|
||||||
|
|
||||||
|
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ dependencies {
|
|||||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||||
testImplementation("io.quarkus:quarkus-junit")
|
testImplementation("io.quarkus:quarkus-junit")
|
||||||
|
testImplementation("io.quarkus:quarkus-junit5-mockito")
|
||||||
testImplementation("io.rest-assured:rest-assured")
|
testImplementation("io.rest-assured:rest-assured")
|
||||||
testImplementation("io.quarkus:quarkus-test-security")
|
testImplementation("io.quarkus:quarkus-test-security")
|
||||||
|
|
||||||
|
|||||||
+61
-20
@@ -2,14 +2,18 @@ package de.nowchess.botplatform.registry
|
|||||||
|
|
||||||
import de.nowchess.botplatform.config.RedisConfig
|
import de.nowchess.botplatform.config.RedisConfig
|
||||||
import io.quarkus.redis.datasource.RedisDataSource
|
import io.quarkus.redis.datasource.RedisDataSource
|
||||||
import io.quarkus.redis.datasource.pubsub.PubSubCommands
|
import io.quarkus.redis.datasource.stream.{XGroupCreateArgs, XReadGroupArgs}
|
||||||
import io.smallrye.mutiny.subscription.MultiEmitter
|
import io.smallrye.mutiny.subscription.MultiEmitter
|
||||||
import jakarta.enterprise.context.ApplicationScoped
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
import jakarta.inject.Inject
|
import jakarta.inject.Inject
|
||||||
|
import org.eclipse.microprofile.context.ManagedExecutor
|
||||||
import org.jboss.logging.Logger
|
import org.jboss.logging.Logger
|
||||||
import scala.compiletime.uninitialized
|
import scala.compiletime.uninitialized
|
||||||
|
import scala.jdk.CollectionConverters.*
|
||||||
|
import scala.util.{Failure, Success, Try}
|
||||||
|
import java.time.Duration
|
||||||
|
import java.util.UUID
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.function.Consumer
|
|
||||||
|
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
class BotRegistry:
|
class BotRegistry:
|
||||||
@@ -17,31 +21,68 @@ class BotRegistry:
|
|||||||
private val log = Logger.getLogger(classOf[BotRegistry])
|
private val log = Logger.getLogger(classOf[BotRegistry])
|
||||||
|
|
||||||
// scalafix:off DisableSyntax.var
|
// scalafix:off DisableSyntax.var
|
||||||
@Inject var redis: RedisDataSource = uninitialized
|
@Inject var redis: RedisDataSource = uninitialized
|
||||||
@Inject var redisConfig: RedisConfig = uninitialized
|
@Inject var redisConfig: RedisConfig = uninitialized
|
||||||
|
@Inject var executor: ManagedExecutor = uninitialized
|
||||||
// scalafix:on DisableSyntax.var
|
// scalafix:on DisableSyntax.var
|
||||||
|
|
||||||
private val connections = ConcurrentHashMap[String, (MultiEmitter[? >: String], PubSubCommands.RedisSubscriber)]()
|
private val groupName = "bot-platform-consumer"
|
||||||
|
private val consumerId = UUID.randomUUID().toString
|
||||||
|
|
||||||
|
private val emitters = ConcurrentHashMap[String, MultiEmitter[? >: String]]()
|
||||||
|
|
||||||
def register(botId: String, emitter: MultiEmitter[? >: String]): Unit =
|
def register(botId: String, emitter: MultiEmitter[? >: String]): Unit =
|
||||||
val channel = s"${redisConfig.prefix}:bot:$botId:events"
|
createGroupIfAbsent(botId)
|
||||||
val handler: Consumer[String] = msg => emitter.emit(msg)
|
emitters.put(botId, emitter)
|
||||||
val subscriber = redis.pubsub(classOf[String]).subscribe(channel, handler)
|
executor.submit(
|
||||||
connections.put(botId, (emitter, subscriber))
|
new Runnable:
|
||||||
log.infof("Bot %s registered", botId)
|
def run(): Unit = pollLoop(botId, emitter),
|
||||||
|
)
|
||||||
|
log.infof("Bot %s registered on stream consumer group", botId)
|
||||||
()
|
()
|
||||||
|
|
||||||
def unregister(botId: String): Unit =
|
def unregister(botId: String): Unit =
|
||||||
Option(connections.remove(botId)).foreach { (_, subscriber) =>
|
emitters.remove(botId)
|
||||||
subscriber.unsubscribe(s"${redisConfig.prefix}:bot:$botId:events")
|
|
||||||
}
|
|
||||||
log.infof("Bot %s unregistered", botId)
|
log.infof("Bot %s unregistered", botId)
|
||||||
|
|
||||||
def dispatch(botId: String, event: String): Unit =
|
|
||||||
log.debugf("Dispatching event to bot %s", botId)
|
|
||||||
redis.pubsub(classOf[String]).publish(s"${redisConfig.prefix}:bot:$botId:events", event)
|
|
||||||
()
|
|
||||||
|
|
||||||
def registeredBots: List[String] =
|
def registeredBots: List[String] =
|
||||||
import scala.jdk.CollectionConverters.*
|
emitters.keys().asScala.toList
|
||||||
connections.keys().asScala.toList
|
|
||||||
|
private def streamKey(botId: String): String =
|
||||||
|
s"${redisConfig.prefix}:bot:$botId:events:stream"
|
||||||
|
|
||||||
|
private def createGroupIfAbsent(botId: String): Unit =
|
||||||
|
Try(
|
||||||
|
redis
|
||||||
|
.stream(classOf[String])
|
||||||
|
.xgroupCreate(streamKey(botId), groupName, "$", new XGroupCreateArgs().mkstream()),
|
||||||
|
) match
|
||||||
|
case Failure(ex) if Option(ex.getMessage).exists(_.contains("BUSYGROUP")) => ()
|
||||||
|
case Failure(ex) => log.warnf(ex, "Failed to create consumer group for bot %s", botId)
|
||||||
|
case Success(_) => ()
|
||||||
|
|
||||||
|
private def pollLoop(botId: String, myEmitter: MultiEmitter[? >: String]): Unit =
|
||||||
|
while emitters.get(botId) eq myEmitter do
|
||||||
|
Try {
|
||||||
|
val messages = redis
|
||||||
|
.stream(classOf[String])
|
||||||
|
.xreadgroup(
|
||||||
|
groupName,
|
||||||
|
consumerId,
|
||||||
|
streamKey(botId),
|
||||||
|
">",
|
||||||
|
new XReadGroupArgs().count(10).block(Duration.ofSeconds(2)),
|
||||||
|
)
|
||||||
|
Option(messages).foreach(_.forEach { msg =>
|
||||||
|
if emitters.get(botId) eq myEmitter then
|
||||||
|
myEmitter.emit(msg.payload().get("data"))
|
||||||
|
ack(botId, msg.id())
|
||||||
|
})
|
||||||
|
} match
|
||||||
|
case Failure(ex) => log.warnf(ex, "Error in poll loop for bot %s", botId)
|
||||||
|
case Success(_) => ()
|
||||||
|
|
||||||
|
private def ack(botId: String, id: String): Unit =
|
||||||
|
Try(redis.stream(classOf[String]).xack(streamKey(botId), groupName, id)) match
|
||||||
|
case Failure(ex) => log.warnf(ex, "Failed to ack message %s for bot %s", id, botId)
|
||||||
|
case Success(_) => ()
|
||||||
|
|||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
package de.nowchess.botplatform.registry
|
||||||
|
|
||||||
|
import de.nowchess.botplatform.config.RedisConfig
|
||||||
|
import io.quarkus.redis.datasource.RedisDataSource
|
||||||
|
import io.quarkus.redis.datasource.stream.{StreamCommands, XGroupCreateArgs}
|
||||||
|
import io.smallrye.mutiny.subscription.MultiEmitter
|
||||||
|
import org.eclipse.microprofile.context.ManagedExecutor
|
||||||
|
import org.junit.jupiter.api.Assertions.*
|
||||||
|
import org.junit.jupiter.api.function.Executable
|
||||||
|
import org.junit.jupiter.api.{BeforeEach, Test}
|
||||||
|
import org.mockito.ArgumentMatchers.*
|
||||||
|
import org.mockito.Mockito.*
|
||||||
|
|
||||||
|
class BotRegistryTest:
|
||||||
|
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
private var registry: BotRegistry = scala.compiletime.uninitialized
|
||||||
|
private var redis: RedisDataSource = scala.compiletime.uninitialized
|
||||||
|
private var streamCmds: StreamCommands[String, String, Nothing] =
|
||||||
|
scala.compiletime.uninitialized
|
||||||
|
private var redisConfig: RedisConfig = scala.compiletime.uninitialized
|
||||||
|
private var executor: ManagedExecutor = scala.compiletime.uninitialized
|
||||||
|
// scalafix:on DisableSyntax.var
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
def setup(): Unit =
|
||||||
|
redis = mock(classOf[RedisDataSource])
|
||||||
|
streamCmds = mock(classOf[StreamCommands[String, String, Nothing]])
|
||||||
|
redisConfig = mock(classOf[RedisConfig])
|
||||||
|
executor = mock(classOf[ManagedExecutor])
|
||||||
|
|
||||||
|
when(redis.stream(classOf[String])).thenReturn(streamCmds)
|
||||||
|
when(redisConfig.prefix).thenReturn("nowchess")
|
||||||
|
|
||||||
|
registry = new BotRegistry
|
||||||
|
registry.redis = redis
|
||||||
|
registry.redisConfig = redisConfig
|
||||||
|
registry.executor = executor
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def registerStartsPollThread(): Unit =
|
||||||
|
val emitter = mock(classOf[MultiEmitter[String]])
|
||||||
|
registry.register("bot1", emitter)
|
||||||
|
verify(executor).submit(any(classOf[Runnable]))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def registerCreatesConsumerGroupWithMkstream(): Unit =
|
||||||
|
val emitter = mock(classOf[MultiEmitter[String]])
|
||||||
|
registry.register("bot1", emitter)
|
||||||
|
verify(streamCmds)
|
||||||
|
.xgroupCreate(
|
||||||
|
org.mockito.ArgumentMatchers.eq("nowchess:bot:bot1:events:stream"),
|
||||||
|
org.mockito.ArgumentMatchers.eq("bot-platform-consumer"),
|
||||||
|
org.mockito.ArgumentMatchers.eq("$"),
|
||||||
|
any(classOf[XGroupCreateArgs]),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def registerTracksBot(): Unit =
|
||||||
|
val emitter = mock(classOf[MultiEmitter[String]])
|
||||||
|
registry.register("bot42", emitter)
|
||||||
|
assertTrue(registry.registeredBots.contains("bot42"))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def unregisterRemovesBot(): Unit =
|
||||||
|
val emitter = mock(classOf[MultiEmitter[String]])
|
||||||
|
registry.register("botX", emitter)
|
||||||
|
registry.unregister("botX")
|
||||||
|
assertFalse(registry.registeredBots.contains("botX"))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def busyGroupExceptionIsIgnoredOnRegister(): Unit =
|
||||||
|
val emitter = mock(classOf[MultiEmitter[String]])
|
||||||
|
when(streamCmds.xgroupCreate(any(), any(), any(), any()))
|
||||||
|
.thenThrow(new RuntimeException("BUSYGROUP Consumer Group name already exists"))
|
||||||
|
val exec: Executable = () => registry.register("botBusy", emitter)
|
||||||
|
assertDoesNotThrow(exec)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def registerDoesNotInteractWithPubSub(): Unit =
|
||||||
|
val emitter = mock(classOf[MultiEmitter[String]])
|
||||||
|
registry.register("botNoPubSub", emitter)
|
||||||
|
verify(redis, never()).pubsub(any(classOf[Class[?]]))
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
MAJOR=0
|
MAJOR=0
|
||||||
MINOR=11
|
MINOR=12
|
||||||
PATCH=0
|
PATCH=0
|
||||||
|
|||||||
@@ -1827,3 +1827,213 @@
|
|||||||
|
|
||||||
* Revert "feat: add authentication permissions for metrics endpoints in application.yml" ([a298417](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a298417b9e4d68dc73bbf40be63d9484536e9f83))
|
* Revert "feat: add authentication permissions for metrics endpoints in application.yml" ([a298417](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a298417b9e4d68dc73bbf40be63d9484536e9f83))
|
||||||
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||||
|
## (2026-06-09)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add authentication permissions for metrics endpoints in application.yml ([04edd4d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/04edd4d6fd8a63196c36f6d67992832febc9bebb))
|
||||||
|
* add CORS configuration and reorder JWT settings in application.yml ([a49f9be](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a49f9be146f04c14561c305d980846a92f8c12b2))
|
||||||
|
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
|
||||||
|
* add initialization metrics for various services ([d438e97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d438e97f32bdde0bfc63c1b4a8cc810cdd093166))
|
||||||
|
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
|
||||||
|
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
|
||||||
|
* **config:** add GameWritebackEventDto to reflection targets ([87f29a7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/87f29a720422f538ef70699533500e060337b8ea))
|
||||||
|
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
|
||||||
|
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
|
||||||
|
* configure logging and add OpenTelemetry support ([#49](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/49)) ([d57c488](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d57c4886612d1d92da0e1b79209fc83e6ef537a1))
|
||||||
|
* **events:** migrate game-creation and bot flows to Redis Streams NCS-89 ([#62](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/62)) ([a24924c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a24924c23057db3d700a75dbc4333557789cd991))
|
||||||
|
* implement clock expiry scanning and handling for game records ([#53](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/53)) ([8f9eb12](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8f9eb12f663efabe4dc72b94394438652ad0ef02))
|
||||||
|
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
|
||||||
|
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
|
||||||
|
* implement periodic scaling checks and enhance instance management in AutoScaler ([3f12f69](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3f12f695f132b92f634d98df2c037292498b6e86))
|
||||||
|
* **logging:** add DEBUG/INFO/WARN logging across services (NCS-72) ([#41](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/41)) ([804a4bf](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/804a4bf179e3dfb19e2be4390e7e543caf5237c6))
|
||||||
|
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
|
||||||
|
* NCS-11 50-move rule ([#9](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/9)) ([412ed98](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/412ed986a95703a3b282276540153480ceed229d))
|
||||||
|
* NCS-13 Implement Threefold Repetition ([#31](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/31)) ([767d305](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/767d3051a76c266050b6335774d66e2db2273c16))
|
||||||
|
* NCS-14 implemented insufficient moves rule ([#30](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/30)) ([b0399a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0399a4e489950083066c9538df9a84dcc7a4613))
|
||||||
|
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
||||||
|
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
|
||||||
|
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||||
|
* NCS-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2))
|
||||||
|
* NCS-37 Quarkus integration ([#35](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/35)) ([f088c4e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f088c4e9ffcc498d3d1b6f01e8f50042d5830d55))
|
||||||
|
* NCS-40 Rework Draw System ([#34](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/34)) ([33e785d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/33e785d22af87724839b62ae91dfe74a05b398c3))
|
||||||
|
* NCS-41 Bot Platform ([#33](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/33)) ([8744bee](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8744bee2dd20966dae90a09c21a43d5b06f59e00))
|
||||||
|
* NCS-53 changed IO to MicroService for easier scaling ([#37](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/37)) ([b5a2966](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b5a2966adafa9650f0f7d601bdeb8fdd13710327))
|
||||||
|
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
|
||||||
|
* NCS-78 Add Traceability to the Applications ([#48](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/48)) ([c96a09b](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c96a09bb5cee59fc23205bb63baa8b217a7e1b00))
|
||||||
|
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
|
||||||
|
* **redis:** implement game writeback stream processing with error handling and retries ([ae3ef76](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/ae3ef766e8b7596a09e466cd4fb386119f17ca5c))
|
||||||
|
* **rule:** Rules as a microservice ([#39](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/39)) ([093134d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/093134d36c6844ba02a36a28d5d044f09291cd1d))
|
||||||
|
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
|
||||||
|
* update application.yml with new API root paths and add Micrometer and OpenTelemetry dependencies ([72ce262](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/72ce262bc491f94297700e6002fb5d0812e2cc2a))
|
||||||
|
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
|
||||||
|
* **auth:** change InternalAuthFilter to use @Singleton and add HTTP tests for secret validation ([c08d530](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c08d5303eb9e70d36c8eebf6a061ccb71e118fe5))
|
||||||
|
* **auth:** update InternalAuthFilter to use @ApplicationScoped and add index-dependency configuration ([6e0fd95](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6e0fd9523e001756ce7109e639ebb54be4fcdabf))
|
||||||
|
* **core:** add logs to trace subscribeGame call in createGame ([f5614c3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f5614c358255598ba1230e42a56b22934d79183c))
|
||||||
|
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
||||||
|
* **heartbeat:** inject ObjectMapper into InstanceHeartbeatService ([#42](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/42)) ([0c98151](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/0c981517da1f94cd10ae396e47bde2b35d0b3ba0))
|
||||||
|
* IO microservice ([#38](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/38)) ([a386f57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a386f57c21d34ead6cc6f92836c52b714597e289))
|
||||||
|
* Lints ([dc224ab](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dc224abe26acf5361c56956006e1cc51b75b0b7e))
|
||||||
|
* NCS-84 More Verbose Logging ([#51](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/51)) ([4ad92ab](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/4ad92ab23698267f8faa59c4e18388d4a0042cca))
|
||||||
|
* NCS-85 Database Writeback fails without Logs ([#52](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/52)) ([7323908](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/73239088d985f01aa6b1067ed9097a845e471d4f))
|
||||||
|
* **pgn:** add SAN disambiguation and check/checkmate suffixes [NCS-42] ([#56](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/56)) ([2579539](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2579539084152178f4482ddb7b84b7f1162f10da))
|
||||||
|
* **redis:** add max pool wait time and switch to ReactiveRedisDataSource for heartbeat updates ([33e5017](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/33e5017f51a998327b180f778f73964cc10c05d3))
|
||||||
|
* **redis:** enhance GameRedisSubscriberManager to use ReactiveRedisDataSource and improve subscription handling ([0eb752d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/0eb752d4935377f75aab710b7f4eda4b29098e6a))
|
||||||
|
* **redis:** prevent concurrent Redis heartbeat refreshes using AtomicBoolean ([847b132](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/847b13202cb909d18ca3304c27ebe17ce2312b8e))
|
||||||
|
* **redis:** simplify refreshRedisHeartbeat logic and ensure proper error handling ([1813ea1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1813ea1d2d5d093f7925f87371b5e29820bf1136))
|
||||||
|
* **redis:** update Redis configuration with max pool size and waiting parameters ([5baf6a7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5baf6a7cdbea484fc49c02e2b5a1c3919b7fa2c4))
|
||||||
|
* remove unused HTTP root-path configurations from application.yml ([3ed3e59](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ed3e59ee456d54cd3d65ece4f36623e256b9736))
|
||||||
|
* resolve 6 coordinator bugs (cache eviction, rebalance race, pod matching, lookup inefficiency) ([5619c82](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5619c8223ad7091706909eda8c907a29d215fd30))
|
||||||
|
* update documentation to reflect new functions in CoordinatorGrpcServer and InstanceRegistry ([f7ce4df](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f7ce4df595cbdc2ef84122781f4851ff140c0f44))
|
||||||
|
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
||||||
|
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
|
||||||
|
|
||||||
|
### Reverts
|
||||||
|
|
||||||
|
* Revert "feat: add authentication permissions for metrics endpoints in application.yml" ([a298417](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a298417b9e4d68dc73bbf40be63d9484536e9f83))
|
||||||
|
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||||
|
## (2026-06-09)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add authentication permissions for metrics endpoints in application.yml ([04edd4d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/04edd4d6fd8a63196c36f6d67992832febc9bebb))
|
||||||
|
* add CORS configuration and reorder JWT settings in application.yml ([a49f9be](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a49f9be146f04c14561c305d980846a92f8c12b2))
|
||||||
|
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
|
||||||
|
* add initialization metrics for various services ([d438e97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d438e97f32bdde0bfc63c1b4a8cc810cdd093166))
|
||||||
|
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
|
||||||
|
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
|
||||||
|
* **config:** add GameWritebackEventDto to reflection targets ([87f29a7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/87f29a720422f538ef70699533500e060337b8ea))
|
||||||
|
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
|
||||||
|
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
|
||||||
|
* configure logging and add OpenTelemetry support ([#49](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/49)) ([d57c488](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d57c4886612d1d92da0e1b79209fc83e6ef537a1))
|
||||||
|
* **events:** migrate game-creation and bot flows to Redis Streams NCS-89 ([#62](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/62)) ([a24924c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a24924c23057db3d700a75dbc4333557789cd991))
|
||||||
|
* implement clock expiry scanning and handling for game records ([#53](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/53)) ([8f9eb12](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8f9eb12f663efabe4dc72b94394438652ad0ef02))
|
||||||
|
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
|
||||||
|
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
|
||||||
|
* implement periodic scaling checks and enhance instance management in AutoScaler ([3f12f69](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3f12f695f132b92f634d98df2c037292498b6e86))
|
||||||
|
* **logging:** add DEBUG/INFO/WARN logging across services (NCS-72) ([#41](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/41)) ([804a4bf](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/804a4bf179e3dfb19e2be4390e7e543caf5237c6))
|
||||||
|
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
|
||||||
|
* NCS-11 50-move rule ([#9](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/9)) ([412ed98](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/412ed986a95703a3b282276540153480ceed229d))
|
||||||
|
* NCS-13 Implement Threefold Repetition ([#31](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/31)) ([767d305](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/767d3051a76c266050b6335774d66e2db2273c16))
|
||||||
|
* NCS-14 implemented insufficient moves rule ([#30](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/30)) ([b0399a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0399a4e489950083066c9538df9a84dcc7a4613))
|
||||||
|
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
||||||
|
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
|
||||||
|
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||||
|
* NCS-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2))
|
||||||
|
* NCS-37 Quarkus integration ([#35](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/35)) ([f088c4e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f088c4e9ffcc498d3d1b6f01e8f50042d5830d55))
|
||||||
|
* NCS-40 Rework Draw System ([#34](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/34)) ([33e785d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/33e785d22af87724839b62ae91dfe74a05b398c3))
|
||||||
|
* NCS-41 Bot Platform ([#33](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/33)) ([8744bee](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8744bee2dd20966dae90a09c21a43d5b06f59e00))
|
||||||
|
* NCS-53 changed IO to MicroService for easier scaling ([#37](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/37)) ([b5a2966](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b5a2966adafa9650f0f7d601bdeb8fdd13710327))
|
||||||
|
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
|
||||||
|
* NCS-78 Add Traceability to the Applications ([#48](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/48)) ([c96a09b](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c96a09bb5cee59fc23205bb63baa8b217a7e1b00))
|
||||||
|
* NCS-82 add Swiss-system tournament module ([#55](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/55)) ([c5661de](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c5661de4a0ebf4b33211f5a391840dcf744656b7))
|
||||||
|
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
|
||||||
|
* **redis:** implement game writeback stream processing with error handling and retries ([ae3ef76](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/ae3ef766e8b7596a09e466cd4fb386119f17ca5c))
|
||||||
|
* **rule:** Rules as a microservice ([#39](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/39)) ([093134d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/093134d36c6844ba02a36a28d5d044f09291cd1d))
|
||||||
|
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
|
||||||
|
* update application.yml with new API root paths and add Micrometer and OpenTelemetry dependencies ([72ce262](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/72ce262bc491f94297700e6002fb5d0812e2cc2a))
|
||||||
|
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
|
||||||
|
* **auth:** change InternalAuthFilter to use @Singleton and add HTTP tests for secret validation ([c08d530](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c08d5303eb9e70d36c8eebf6a061ccb71e118fe5))
|
||||||
|
* **auth:** update InternalAuthFilter to use @ApplicationScoped and add index-dependency configuration ([6e0fd95](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6e0fd9523e001756ce7109e639ebb54be4fcdabf))
|
||||||
|
* **core:** add logs to trace subscribeGame call in createGame ([f5614c3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f5614c358255598ba1230e42a56b22934d79183c))
|
||||||
|
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
||||||
|
* **heartbeat:** inject ObjectMapper into InstanceHeartbeatService ([#42](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/42)) ([0c98151](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/0c981517da1f94cd10ae396e47bde2b35d0b3ba0))
|
||||||
|
* IO microservice ([#38](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/38)) ([a386f57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a386f57c21d34ead6cc6f92836c52b714597e289))
|
||||||
|
* Lints ([dc224ab](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dc224abe26acf5361c56956006e1cc51b75b0b7e))
|
||||||
|
* NCS-84 More Verbose Logging ([#51](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/51)) ([4ad92ab](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/4ad92ab23698267f8faa59c4e18388d4a0042cca))
|
||||||
|
* NCS-85 Database Writeback fails without Logs ([#52](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/52)) ([7323908](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/73239088d985f01aa6b1067ed9097a845e471d4f))
|
||||||
|
* **pgn:** add SAN disambiguation and check/checkmate suffixes [NCS-42] ([#56](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/56)) ([2579539](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2579539084152178f4482ddb7b84b7f1162f10da))
|
||||||
|
* **redis:** add max pool wait time and switch to ReactiveRedisDataSource for heartbeat updates ([33e5017](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/33e5017f51a998327b180f778f73964cc10c05d3))
|
||||||
|
* **redis:** enhance GameRedisSubscriberManager to use ReactiveRedisDataSource and improve subscription handling ([0eb752d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/0eb752d4935377f75aab710b7f4eda4b29098e6a))
|
||||||
|
* **redis:** prevent concurrent Redis heartbeat refreshes using AtomicBoolean ([847b132](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/847b13202cb909d18ca3304c27ebe17ce2312b8e))
|
||||||
|
* **redis:** simplify refreshRedisHeartbeat logic and ensure proper error handling ([1813ea1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1813ea1d2d5d093f7925f87371b5e29820bf1136))
|
||||||
|
* **redis:** update Redis configuration with max pool size and waiting parameters ([5baf6a7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5baf6a7cdbea484fc49c02e2b5a1c3919b7fa2c4))
|
||||||
|
* remove unused HTTP root-path configurations from application.yml ([3ed3e59](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ed3e59ee456d54cd3d65ece4f36623e256b9736))
|
||||||
|
* resolve 6 coordinator bugs (cache eviction, rebalance race, pod matching, lookup inefficiency) ([5619c82](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5619c8223ad7091706909eda8c907a29d215fd30))
|
||||||
|
* update documentation to reflect new functions in CoordinatorGrpcServer and InstanceRegistry ([f7ce4df](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f7ce4df595cbdc2ef84122781f4851ff140c0f44))
|
||||||
|
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
||||||
|
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
|
||||||
|
|
||||||
|
### Reverts
|
||||||
|
|
||||||
|
* Revert "feat: add authentication permissions for metrics endpoints in application.yml" ([a298417](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a298417b9e4d68dc73bbf40be63d9484536e9f83))
|
||||||
|
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||||
|
## (2026-06-09)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add authentication permissions for metrics endpoints in application.yml ([04edd4d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/04edd4d6fd8a63196c36f6d67992832febc9bebb))
|
||||||
|
* add CORS configuration and reorder JWT settings in application.yml ([a49f9be](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a49f9be146f04c14561c305d980846a92f8c12b2))
|
||||||
|
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
|
||||||
|
* add initialization metrics for various services ([d438e97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d438e97f32bdde0bfc63c1b4a8cc810cdd093166))
|
||||||
|
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
|
||||||
|
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
|
||||||
|
* **config:** add GameWritebackEventDto to reflection targets ([87f29a7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/87f29a720422f538ef70699533500e060337b8ea))
|
||||||
|
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
|
||||||
|
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
|
||||||
|
* configure logging and add OpenTelemetry support ([#49](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/49)) ([d57c488](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d57c4886612d1d92da0e1b79209fc83e6ef537a1))
|
||||||
|
* **core:** publish GameOver event to Redis Streams ([#64](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/64)) ([676e411](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/676e4110c0893917d8bc7f836db6a19c69c5e9a5))
|
||||||
|
* **events:** migrate game-creation and bot flows to Redis Streams NCS-89 ([#62](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/62)) ([a24924c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a24924c23057db3d700a75dbc4333557789cd991))
|
||||||
|
* implement clock expiry scanning and handling for game records ([#53](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/53)) ([8f9eb12](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8f9eb12f663efabe4dc72b94394438652ad0ef02))
|
||||||
|
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
|
||||||
|
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
|
||||||
|
* implement periodic scaling checks and enhance instance management in AutoScaler ([3f12f69](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3f12f695f132b92f634d98df2c037292498b6e86))
|
||||||
|
* **logging:** add DEBUG/INFO/WARN logging across services (NCS-72) ([#41](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/41)) ([804a4bf](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/804a4bf179e3dfb19e2be4390e7e543caf5237c6))
|
||||||
|
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
|
||||||
|
* NCS-11 50-move rule ([#9](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/9)) ([412ed98](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/412ed986a95703a3b282276540153480ceed229d))
|
||||||
|
* NCS-13 Implement Threefold Repetition ([#31](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/31)) ([767d305](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/767d3051a76c266050b6335774d66e2db2273c16))
|
||||||
|
* NCS-14 implemented insufficient moves rule ([#30](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/30)) ([b0399a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0399a4e489950083066c9538df9a84dcc7a4613))
|
||||||
|
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
||||||
|
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
|
||||||
|
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||||
|
* NCS-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2))
|
||||||
|
* NCS-37 Quarkus integration ([#35](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/35)) ([f088c4e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f088c4e9ffcc498d3d1b6f01e8f50042d5830d55))
|
||||||
|
* NCS-40 Rework Draw System ([#34](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/34)) ([33e785d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/33e785d22af87724839b62ae91dfe74a05b398c3))
|
||||||
|
* NCS-41 Bot Platform ([#33](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/33)) ([8744bee](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8744bee2dd20966dae90a09c21a43d5b06f59e00))
|
||||||
|
* NCS-53 changed IO to MicroService for easier scaling ([#37](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/37)) ([b5a2966](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b5a2966adafa9650f0f7d601bdeb8fdd13710327))
|
||||||
|
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
|
||||||
|
* NCS-78 Add Traceability to the Applications ([#48](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/48)) ([c96a09b](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c96a09bb5cee59fc23205bb63baa8b217a7e1b00))
|
||||||
|
* NCS-82 add Swiss-system tournament module ([#55](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/55)) ([c5661de](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c5661de4a0ebf4b33211f5a391840dcf744656b7))
|
||||||
|
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
|
||||||
|
* **redis:** implement game writeback stream processing with error handling and retries ([ae3ef76](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/ae3ef766e8b7596a09e466cd4fb386119f17ca5c))
|
||||||
|
* **rule:** Rules as a microservice ([#39](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/39)) ([093134d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/093134d36c6844ba02a36a28d5d044f09291cd1d))
|
||||||
|
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
|
||||||
|
* update application.yml with new API root paths and add Micrometer and OpenTelemetry dependencies ([72ce262](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/72ce262bc491f94297700e6002fb5d0812e2cc2a))
|
||||||
|
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
|
||||||
|
* **auth:** change InternalAuthFilter to use @Singleton and add HTTP tests for secret validation ([c08d530](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c08d5303eb9e70d36c8eebf6a061ccb71e118fe5))
|
||||||
|
* **auth:** update InternalAuthFilter to use @ApplicationScoped and add index-dependency configuration ([6e0fd95](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6e0fd9523e001756ce7109e639ebb54be4fcdabf))
|
||||||
|
* **core:** add logs to trace subscribeGame call in createGame ([f5614c3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f5614c358255598ba1230e42a56b22934d79183c))
|
||||||
|
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
||||||
|
* **heartbeat:** inject ObjectMapper into InstanceHeartbeatService ([#42](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/42)) ([0c98151](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/0c981517da1f94cd10ae396e47bde2b35d0b3ba0))
|
||||||
|
* IO microservice ([#38](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/38)) ([a386f57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a386f57c21d34ead6cc6f92836c52b714597e289))
|
||||||
|
* Lints ([dc224ab](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dc224abe26acf5361c56956006e1cc51b75b0b7e))
|
||||||
|
* NCS-84 More Verbose Logging ([#51](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/51)) ([4ad92ab](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/4ad92ab23698267f8faa59c4e18388d4a0042cca))
|
||||||
|
* NCS-85 Database Writeback fails without Logs ([#52](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/52)) ([7323908](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/73239088d985f01aa6b1067ed9097a845e471d4f))
|
||||||
|
* **pgn:** add SAN disambiguation and check/checkmate suffixes [NCS-42] ([#56](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/56)) ([2579539](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2579539084152178f4482ddb7b84b7f1162f10da))
|
||||||
|
* **redis:** add max pool wait time and switch to ReactiveRedisDataSource for heartbeat updates ([33e5017](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/33e5017f51a998327b180f778f73964cc10c05d3))
|
||||||
|
* **redis:** enhance GameRedisSubscriberManager to use ReactiveRedisDataSource and improve subscription handling ([0eb752d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/0eb752d4935377f75aab710b7f4eda4b29098e6a))
|
||||||
|
* **redis:** prevent concurrent Redis heartbeat refreshes using AtomicBoolean ([847b132](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/847b13202cb909d18ca3304c27ebe17ce2312b8e))
|
||||||
|
* **redis:** simplify refreshRedisHeartbeat logic and ensure proper error handling ([1813ea1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1813ea1d2d5d093f7925f87371b5e29820bf1136))
|
||||||
|
* **redis:** update Redis configuration with max pool size and waiting parameters ([5baf6a7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5baf6a7cdbea484fc49c02e2b5a1c3919b7fa2c4))
|
||||||
|
* remove unused HTTP root-path configurations from application.yml ([3ed3e59](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ed3e59ee456d54cd3d65ece4f36623e256b9736))
|
||||||
|
* resolve 6 coordinator bugs (cache eviction, rebalance race, pod matching, lookup inefficiency) ([5619c82](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5619c8223ad7091706909eda8c907a29d215fd30))
|
||||||
|
* update documentation to reflect new functions in CoordinatorGrpcServer and InstanceRegistry ([f7ce4df](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f7ce4df595cbdc2ef84122781f4851ff140c0f44))
|
||||||
|
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
||||||
|
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
|
||||||
|
|
||||||
|
### Reverts
|
||||||
|
|
||||||
|
* Revert "feat: add authentication permissions for metrics endpoints in application.yml" ([a298417](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a298417b9e4d68dc73bbf40be63d9484536e9f83))
|
||||||
|
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||||
|
|||||||
@@ -138,6 +138,8 @@ tasks.withType(org.gradle.api.tasks.scala.ScalaCompile::class).configureEach {
|
|||||||
exclude("**/resource/GameDtoMapper.scala")
|
exclude("**/resource/GameDtoMapper.scala")
|
||||||
exclude("**/resource/GameResource.scala")
|
exclude("**/resource/GameResource.scala")
|
||||||
exclude("**/redis/GameRedis*.scala")
|
exclude("**/redis/GameRedis*.scala")
|
||||||
|
exclude("**/redis/GameCreationStreamListener.scala")
|
||||||
|
exclude("**/service/GameCreationService.scala")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package de.nowchess.chess.config
|
|||||||
|
|
||||||
import de.nowchess.api.board.{CastlingRights, Color, File, Piece, PieceType, Rank, Square}
|
import de.nowchess.api.board.{CastlingRights, Color, File, Piece, PieceType, Rank, Square}
|
||||||
import de.nowchess.api.dto.*
|
import de.nowchess.api.dto.*
|
||||||
|
import de.nowchess.api.event.{EventEnvelope, EventType, GameOverPayload}
|
||||||
import de.nowchess.api.game.{DrawReason, GameContext, GameMode, GameResult}
|
import de.nowchess.api.game.{DrawReason, GameContext, GameMode, GameResult}
|
||||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
import de.nowchess.chess.registry.GameCacheDto
|
import de.nowchess.chess.registry.GameCacheDto
|
||||||
@@ -13,6 +14,11 @@ import io.quarkus.runtime.annotations.RegisterForReflection
|
|||||||
classOf[GameCacheDto],
|
classOf[GameCacheDto],
|
||||||
classOf[ClockDto],
|
classOf[ClockDto],
|
||||||
classOf[CreateGameRequestDto],
|
classOf[CreateGameRequestDto],
|
||||||
|
classOf[GameCreationRequestDto],
|
||||||
|
classOf[GameCreationResponseDto],
|
||||||
|
classOf[EventEnvelope],
|
||||||
|
classOf[EventType],
|
||||||
|
classOf[GameOverPayload],
|
||||||
classOf[ErrorEventDto],
|
classOf[ErrorEventDto],
|
||||||
classOf[GameWritebackEventDto],
|
classOf[GameWritebackEventDto],
|
||||||
classOf[GameFullDto],
|
classOf[GameFullDto],
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package de.nowchess.chess.redis
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import de.nowchess.api.dto.{GameCreationRequestDto, GameCreationResponseDto}
|
||||||
|
import de.nowchess.api.event.{EventEnvelope, EventType}
|
||||||
|
import de.nowchess.chess.config.RedisConfig
|
||||||
|
import de.nowchess.chess.service.GameCreationService
|
||||||
|
import io.quarkus.redis.datasource.RedisDataSource
|
||||||
|
import io.quarkus.redis.datasource.stream.{StreamMessage, XAddArgs, XGroupCreateArgs, XReadGroupArgs}
|
||||||
|
import io.quarkus.runtime.StartupEvent
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import jakarta.enterprise.event.Observes
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import org.eclipse.microprofile.config.inject.ConfigProperty
|
||||||
|
import org.eclipse.microprofile.context.ManagedExecutor
|
||||||
|
import org.jboss.logging.Logger
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
import scala.jdk.CollectionConverters.*
|
||||||
|
import scala.util.{Failure, Success, Try}
|
||||||
|
import java.time.Duration
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
class GameCreationStreamListener:
|
||||||
|
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
@Inject var redis: RedisDataSource = uninitialized
|
||||||
|
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||||
|
@Inject var creationService: GameCreationService = uninitialized
|
||||||
|
@Inject var executor: ManagedExecutor = uninitialized
|
||||||
|
@Inject var redisConfig: RedisConfig = uninitialized
|
||||||
|
@ConfigProperty(name = "nowchess.game-creation-stream.enabled", defaultValue = "true")
|
||||||
|
private var streamEnabled: Boolean = true
|
||||||
|
// scalafix:on DisableSyntax.var
|
||||||
|
|
||||||
|
private val log = Logger.getLogger(classOf[GameCreationStreamListener])
|
||||||
|
private val groupName = "core-game-creation"
|
||||||
|
private val consumerId = UUID.randomUUID().toString
|
||||||
|
private val maxRetries = 3
|
||||||
|
private val maxStreamLen = 1000L
|
||||||
|
|
||||||
|
private def requestStream: String = s"${redisConfig.prefix}:game-creation"
|
||||||
|
private def responseStream: String = s"${redisConfig.prefix}:game-creation-response"
|
||||||
|
private def dlqStream: String = s"${redisConfig.prefix}:dlq"
|
||||||
|
|
||||||
|
def start(@Observes _ev: StartupEvent): Unit =
|
||||||
|
if streamEnabled then
|
||||||
|
createGroupIfAbsent()
|
||||||
|
executor.submit(
|
||||||
|
new Runnable:
|
||||||
|
def run(): Unit = pollLoop(),
|
||||||
|
)
|
||||||
|
log.infof("Game-creation request listener started (consumer=%s)", consumerId)
|
||||||
|
|
||||||
|
private def createGroupIfAbsent(): Unit =
|
||||||
|
Try(
|
||||||
|
redis
|
||||||
|
.stream(classOf[String])
|
||||||
|
.xgroupCreate(requestStream, groupName, "0", new XGroupCreateArgs().mkstream()),
|
||||||
|
) match
|
||||||
|
case Failure(ex) if Option(ex.getMessage).exists(_.contains("BUSYGROUP")) => ()
|
||||||
|
case Failure(ex) => log.warnf(ex, "Failed to create game-creation consumer group")
|
||||||
|
case Success(_) => ()
|
||||||
|
|
||||||
|
private def pollLoop(): Unit =
|
||||||
|
while true do
|
||||||
|
Try {
|
||||||
|
val messages = redis
|
||||||
|
.stream(classOf[String])
|
||||||
|
.xreadgroup(
|
||||||
|
groupName,
|
||||||
|
consumerId,
|
||||||
|
requestStream,
|
||||||
|
">",
|
||||||
|
new XReadGroupArgs().count(10).block(Duration.ofSeconds(2)),
|
||||||
|
)
|
||||||
|
Option(messages).foreach(_.forEach(handleMessage))
|
||||||
|
} match
|
||||||
|
case Failure(ex) => log.warnf(ex, "Error in game-creation poll loop")
|
||||||
|
case Success(_) => ()
|
||||||
|
|
||||||
|
private def handleMessage(msg: StreamMessage[String, String, String]): Unit =
|
||||||
|
val json = msg.payload().get("data")
|
||||||
|
val attempt = Option(msg.payload().get("attempt")).flatMap(_.toIntOption).getOrElse(0)
|
||||||
|
Try(objectMapper.readValue(json, classOf[EventEnvelope])) match
|
||||||
|
case Failure(ex) =>
|
||||||
|
log.errorf(ex, "Unparseable game-creation event, sending to DLQ: %s", json)
|
||||||
|
toDlq(EventType.GameCreationRequest.toString, json, ex, attempt)
|
||||||
|
ack(msg.id())
|
||||||
|
case Success(envelope) =>
|
||||||
|
processEnvelope(msg, envelope, json, attempt)
|
||||||
|
|
||||||
|
private def processEnvelope(
|
||||||
|
msg: StreamMessage[String, String, String],
|
||||||
|
envelope: EventEnvelope,
|
||||||
|
json: String,
|
||||||
|
attempt: Int,
|
||||||
|
): Unit =
|
||||||
|
Try {
|
||||||
|
val req = objectMapper.treeToValue(envelope.payload, classOf[GameCreationRequestDto])
|
||||||
|
val entry = creationService.createGame(req)
|
||||||
|
publishResponse(envelope.correlationId, GameCreationResponseDto(Some(entry.gameId)))
|
||||||
|
} match
|
||||||
|
case Success(_) => ack(msg.id())
|
||||||
|
case Failure(ex) if attempt + 1 < maxRetries =>
|
||||||
|
log.warnf(ex, "Game creation failed (attempt %d), retrying", attempt)
|
||||||
|
retry(json, attempt + 1)
|
||||||
|
ack(msg.id())
|
||||||
|
case Failure(ex) =>
|
||||||
|
log.errorf(ex, "Game creation failed after %d attempts, sending to DLQ", maxRetries)
|
||||||
|
publishResponse(envelope.correlationId, GameCreationResponseDto(None, Some("Game creation failed")))
|
||||||
|
toDlq(envelope.`type`.toString, json, ex, attempt)
|
||||||
|
ack(msg.id())
|
||||||
|
|
||||||
|
private def publishResponse(correlationId: Option[String], resp: GameCreationResponseDto): Unit =
|
||||||
|
val payload = objectMapper.valueToTree[com.fasterxml.jackson.databind.JsonNode](resp)
|
||||||
|
val envelope = EventEnvelope.of(EventType.GameCreationResponse, payload, correlationId)
|
||||||
|
xadd(responseStream, Map("data" -> objectMapper.writeValueAsString(envelope)))
|
||||||
|
|
||||||
|
private def retry(json: String, attempt: Int): Unit =
|
||||||
|
xadd(requestStream, Map("data" -> json, "attempt" -> attempt.toString))
|
||||||
|
|
||||||
|
private def toDlq(eventType: String, json: String, error: Throwable, attempt: Int): Unit =
|
||||||
|
xadd(
|
||||||
|
dlqStream,
|
||||||
|
Map(
|
||||||
|
"data" -> json,
|
||||||
|
"eventType" -> eventType,
|
||||||
|
"error" -> Option(error.getMessage).getOrElse(error.getClass.getName),
|
||||||
|
"attempt" -> (attempt + 1).toString,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private def ack(id: String): Unit =
|
||||||
|
Try(redis.stream(classOf[String]).xack(requestStream, groupName, id)) match
|
||||||
|
case Failure(ex) => log.warnf(ex, "Failed to ack message %s", id)
|
||||||
|
case Success(_) => ()
|
||||||
|
|
||||||
|
private def xadd(key: String, fields: Map[String, String]): Unit =
|
||||||
|
Try(
|
||||||
|
redis
|
||||||
|
.stream(classOf[String])
|
||||||
|
.xadd(key, new XAddArgs().maxlen(maxStreamLen).nearlyExactTrimming(), fields.asJava),
|
||||||
|
) match
|
||||||
|
case Failure(ex) => log.errorf(ex, "Failed to publish to stream %s", key)
|
||||||
|
case Success(_) => ()
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
package de.nowchess.chess.redis
|
package de.nowchess.chess.redis
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.{JsonNode, ObjectMapper}
|
||||||
import de.nowchess.api.dto.{GameStateDto, GameStateEventDto, GameWritebackEventDto}
|
|
||||||
import de.nowchess.api.game.{CorrespondenceClockState, DrawReason, GameResult, LiveClockState, TimeControl, WinReason}
|
|
||||||
import de.nowchess.api.board.Color
|
import de.nowchess.api.board.Color
|
||||||
|
import de.nowchess.api.dto.{GameStateDto, GameStateEventDto, GameWritebackEventDto}
|
||||||
|
import de.nowchess.api.event.{EventEnvelope, EventType, GameOverPayload}
|
||||||
|
import de.nowchess.api.game.{CorrespondenceClockState, DrawReason, GameResult, LiveClockState, TimeControl, WinReason}
|
||||||
import de.nowchess.chess.grpc.IoGrpcClientWrapper
|
import de.nowchess.chess.grpc.IoGrpcClientWrapper
|
||||||
import de.nowchess.chess.observer.{GameEvent, Observer}
|
import de.nowchess.chess.observer.{GameEvent, Observer}
|
||||||
import de.nowchess.chess.registry.{GameEntry, GameRegistry}
|
import de.nowchess.chess.registry.{GameEntry, GameRegistry}
|
||||||
import de.nowchess.chess.resource.GameDtoMapper
|
import de.nowchess.chess.resource.GameDtoMapper
|
||||||
import io.quarkus.redis.datasource.RedisDataSource
|
import io.quarkus.redis.datasource.RedisDataSource
|
||||||
|
import io.quarkus.redis.datasource.stream.XAddArgs
|
||||||
import org.jboss.logging.Logger
|
import org.jboss.logging.Logger
|
||||||
|
import scala.jdk.CollectionConverters.*
|
||||||
|
|
||||||
object GameRedisPublisher:
|
object GameRedisPublisher:
|
||||||
private val log = Logger.getLogger(classOf[GameRedisPublisher])
|
private val log = Logger.getLogger(classOf[GameRedisPublisher])
|
||||||
@@ -23,8 +26,11 @@ class GameRedisPublisher(
|
|||||||
writebackEmit: String => Unit,
|
writebackEmit: String => Unit,
|
||||||
ioClient: IoGrpcClientWrapper,
|
ioClient: IoGrpcClientWrapper,
|
||||||
onGameOver: String => Unit,
|
onGameOver: String => Unit,
|
||||||
|
redisPrefix: String,
|
||||||
) extends Observer:
|
) extends Observer:
|
||||||
|
|
||||||
|
private val maxStreamLen = 1000L
|
||||||
|
|
||||||
def emitInitialWriteback(): Unit =
|
def emitInitialWriteback(): Unit =
|
||||||
try
|
try
|
||||||
registry.get(gameId).foreach { entry =>
|
registry.get(gameId).foreach { entry =>
|
||||||
@@ -40,10 +46,39 @@ class GameRedisPublisher(
|
|||||||
val dto = GameDtoMapper.toGameStateDto(entry, ioClient)
|
val dto = GameDtoMapper.toGameStateDto(entry, ioClient)
|
||||||
redis.pubsub(classOf[String]).publish(s2cTopicName, objectMapper.writeValueAsString(GameStateEventDto(dto)))
|
redis.pubsub(classOf[String]).publish(s2cTopicName, objectMapper.writeValueAsString(GameStateEventDto(dto)))
|
||||||
writebackEmit(objectMapper.writeValueAsString(buildWriteback(entry, dto)))
|
writebackEmit(objectMapper.writeValueAsString(buildWriteback(entry, dto)))
|
||||||
if entry.engine.context.result.isDefined then onGameOver(gameId)
|
entry.engine.context.result.foreach { result =>
|
||||||
|
publishGameOver(entry, result)
|
||||||
|
onGameOver(gameId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch case ex: Exception => GameRedisPublisher.log.warnf(ex, "Failed to publish game event for game %s", gameId)
|
catch case ex: Exception => GameRedisPublisher.log.warnf(ex, "Failed to publish game event for game %s", gameId)
|
||||||
|
|
||||||
|
private def publishGameOver(entry: GameEntry, result: GameResult): Unit =
|
||||||
|
val resultStr = result match
|
||||||
|
case GameResult.Win(Color.White, _) => "white"
|
||||||
|
case GameResult.Win(Color.Black, _) => "black"
|
||||||
|
case GameResult.Draw(_) => "draw"
|
||||||
|
val terminationReason = result match
|
||||||
|
case GameResult.Win(_, WinReason.Checkmate) => "checkmate"
|
||||||
|
case GameResult.Win(_, WinReason.Resignation) => "resignation"
|
||||||
|
case GameResult.Win(_, WinReason.TimeControl) => "timeout"
|
||||||
|
case GameResult.Draw(DrawReason.Stalemate) => "stalemate"
|
||||||
|
case GameResult.Draw(DrawReason.InsufficientMaterial) => "insufficient_material"
|
||||||
|
case GameResult.Draw(DrawReason.FiftyMoveRule) => "fifty_move"
|
||||||
|
case GameResult.Draw(DrawReason.ThreefoldRepetition) => "repetition"
|
||||||
|
case GameResult.Draw(DrawReason.Agreement) => "agreement"
|
||||||
|
val payload = objectMapper.valueToTree[JsonNode](
|
||||||
|
GameOverPayload(gameId, resultStr, terminationReason, entry.white.id.value, entry.black.id.value),
|
||||||
|
)
|
||||||
|
val envelope = EventEnvelope.of(EventType.GameOver, payload)
|
||||||
|
redis
|
||||||
|
.stream(classOf[String])
|
||||||
|
.xadd(
|
||||||
|
s"$redisPrefix:game-over",
|
||||||
|
new XAddArgs().maxlen(maxStreamLen).nearlyExactTrimming(),
|
||||||
|
Map("data" -> objectMapper.writeValueAsString(envelope)).asJava,
|
||||||
|
)
|
||||||
|
|
||||||
private def buildWriteback(entry: GameEntry, dto: GameStateDto): GameWritebackEventDto =
|
private def buildWriteback(entry: GameEntry, dto: GameStateDto): GameWritebackEventDto =
|
||||||
val clock = entry.engine.currentClockState
|
val clock = entry.engine.currentClockState
|
||||||
GameWritebackEventDto(
|
GameWritebackEventDto(
|
||||||
|
|||||||
+26
-2
@@ -22,7 +22,7 @@ import org.jboss.logging.Logger
|
|||||||
import scala.compiletime.uninitialized
|
import scala.compiletime.uninitialized
|
||||||
import scala.jdk.CollectionConverters.*
|
import scala.jdk.CollectionConverters.*
|
||||||
import scala.util.Try
|
import scala.util.Try
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.{ConcurrentHashMap, ExecutorService, Executors}
|
||||||
import java.util.function.Consumer
|
import java.util.function.Consumer
|
||||||
|
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
@@ -46,6 +46,10 @@ class GameRedisSubscriberManager:
|
|||||||
|
|
||||||
private val c2sListeners = new ConcurrentHashMap[String, ReactivePubSubCommands.ReactiveRedisSubscriber]()
|
private val c2sListeners = new ConcurrentHashMap[String, ReactivePubSubCommands.ReactiveRedisSubscriber]()
|
||||||
private val s2cObservers = new ConcurrentHashMap[String, Observer]()
|
private val s2cObservers = new ConcurrentHashMap[String, Observer]()
|
||||||
|
// Per-game single-thread executor so c2s messages are handled off the Vert.x
|
||||||
|
// event loop (handleConnected/handleMove make blocking gRPC + Redis calls) while
|
||||||
|
// staying ordered per game.
|
||||||
|
private val c2sExecutors = new ConcurrentHashMap[String, ExecutorService]()
|
||||||
|
|
||||||
// scalafix:off DisableSyntax.var
|
// scalafix:off DisableSyntax.var
|
||||||
private var clockExpireSubscriber: Option[ReactivePubSubCommands.ReactiveRedisSubscriber] = None
|
private var clockExpireSubscriber: Option[ReactivePubSubCommands.ReactiveRedisSubscriber] = None
|
||||||
@@ -89,13 +93,21 @@ class GameRedisSubscriberManager:
|
|||||||
writebackFn,
|
writebackFn,
|
||||||
ioClient,
|
ioClient,
|
||||||
unsubscribeGame,
|
unsubscribeGame,
|
||||||
|
redisConfig.prefix,
|
||||||
)
|
)
|
||||||
s2cObservers.put(gameId, obs)
|
s2cObservers.put(gameId, obs)
|
||||||
registry.get(gameId).foreach(_.engine.subscribe(obs))
|
registry.get(gameId).foreach(_.engine.subscribe(obs))
|
||||||
obs.emitInitialWriteback()
|
obs.emitInitialWriteback()
|
||||||
heartbeatServiceOpt.foreach(_.addGameSubscription(gameId))
|
heartbeatServiceOpt.foreach(_.addGameSubscription(gameId))
|
||||||
|
|
||||||
val handler: Consumer[String] = msg => handleC2sMessage(gameId, msg)
|
val executor = c2sExecutors.computeIfAbsent(gameId, _ => Executors.newSingleThreadExecutor())
|
||||||
|
val handler: Consumer[String] = msg =>
|
||||||
|
val task = new Runnable:
|
||||||
|
def run(): Unit =
|
||||||
|
try handleC2sMessage(gameId, msg)
|
||||||
|
catch case ex: Exception => log.warnf(ex, "Error handling c2s message for game %s", gameId)
|
||||||
|
Try(executor.execute(task))
|
||||||
|
()
|
||||||
try
|
try
|
||||||
val subscriber = reactiveRedis
|
val subscriber = reactiveRedis
|
||||||
.pubsub(classOf[String])
|
.pubsub(classOf[String])
|
||||||
@@ -106,6 +118,16 @@ class GameRedisSubscriberManager:
|
|||||||
log.debugf("Subscribed to game %s", gameId)
|
log.debugf("Subscribed to game %s", gameId)
|
||||||
catch case ex: Exception => log.warnf(ex, "Redis subscription failed for game %s", gameId)
|
catch case ex: Exception => log.warnf(ex, "Redis subscription failed for game %s", gameId)
|
||||||
|
|
||||||
|
// Notify the official-bots service to start playing a side of a game. Mirrors
|
||||||
|
// the event the tournament service publishes; official-bots subscribes to
|
||||||
|
// "<prefix>:bot:*:events".
|
||||||
|
def publishBotGameStart(gameId: String, botId: String, playingAs: String): Unit =
|
||||||
|
val channel = s"${redisConfig.prefix}:bot:$botId:events"
|
||||||
|
val payload = s"""{"type":"gameStart","gameId":"$gameId","playingAs":"$playingAs","botAccountId":"$botId"}"""
|
||||||
|
Try(redis.pubsub(classOf[String]).publish(channel, payload)) match
|
||||||
|
case scala.util.Failure(ex) => log.warnf(ex, "Failed to publish bot gameStart for game %s", gameId)
|
||||||
|
case scala.util.Success(_) => ()
|
||||||
|
|
||||||
def unsubscribeGame(gameId: String): Unit =
|
def unsubscribeGame(gameId: String): Unit =
|
||||||
Option(c2sListeners.remove(gameId)).foreach { subscriber =>
|
Option(c2sListeners.remove(gameId)).foreach { subscriber =>
|
||||||
subscriber.unsubscribe(c2sTopic(gameId)).subscribe().`with`(_ => (), _ => ())
|
subscriber.unsubscribe(c2sTopic(gameId)).subscribe().`with`(_ => (), _ => ())
|
||||||
@@ -113,6 +135,7 @@ class GameRedisSubscriberManager:
|
|||||||
Option(s2cObservers.remove(gameId)).foreach { obs =>
|
Option(s2cObservers.remove(gameId)).foreach { obs =>
|
||||||
registry.get(gameId).foreach(_.engine.unsubscribe(obs))
|
registry.get(gameId).foreach(_.engine.unsubscribe(obs))
|
||||||
}
|
}
|
||||||
|
Option(c2sExecutors.remove(gameId)).foreach(_.shutdownNow())
|
||||||
|
|
||||||
heartbeatServiceOpt.foreach(_.removeGameSubscription(gameId))
|
heartbeatServiceOpt.foreach(_.removeGameSubscription(gameId))
|
||||||
log.debugf("Unsubscribed from game %s", gameId)
|
log.debugf("Unsubscribed from game %s", gameId)
|
||||||
@@ -187,3 +210,4 @@ class GameRedisSubscriberManager:
|
|||||||
clockExpireSubscriber.foreach(_.unsubscribe(clockExpireChannel).await().indefinitely())
|
clockExpireSubscriber.foreach(_.unsubscribe(clockExpireChannel).await().indefinitely())
|
||||||
c2sListeners.forEach((gameId, subscriber) => subscriber.unsubscribe(c2sTopic(gameId)).await().indefinitely())
|
c2sListeners.forEach((gameId, subscriber) => subscriber.unsubscribe(c2sTopic(gameId)).await().indefinitely())
|
||||||
s2cObservers.forEach((gameId, obs) => registry.get(gameId).foreach(_.engine.unsubscribe(obs)))
|
s2cObservers.forEach((gameId, obs) => registry.get(gameId).foreach(_.engine.unsubscribe(obs)))
|
||||||
|
c2sExecutors.forEach((_, executor) => executor.shutdownNow())
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import de.nowchess.chess.observer.*
|
|||||||
import de.nowchess.chess.redis.GameRedisSubscriberManager
|
import de.nowchess.chess.redis.GameRedisSubscriberManager
|
||||||
import de.nowchess.chess.registry.{GameEntry, GameRegistry}
|
import de.nowchess.chess.registry.{GameEntry, GameRegistry}
|
||||||
import de.nowchess.security.InternalOnly
|
import de.nowchess.security.InternalOnly
|
||||||
|
import jakarta.annotation.security.PermitAll
|
||||||
import jakarta.enterprise.context.ApplicationScoped
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
import jakarta.inject.Inject
|
import jakarta.inject.Inject
|
||||||
import jakarta.ws.rs.*
|
import jakarta.ws.rs.*
|
||||||
@@ -179,6 +180,32 @@ class GameResource:
|
|||||||
)
|
)
|
||||||
created(GameDtoMapper.toGameFullDto(entry, ioClient))
|
created(GameDtoMapper.toGameFullDto(entry, ioClient))
|
||||||
|
|
||||||
|
// Player-facing game creation for "play vs bot". Unlike createGame this is not
|
||||||
|
// internal-only: a logged-in (or anonymous) player creates the game directly,
|
||||||
|
// and core notifies the official-bots service to play the bot side.
|
||||||
|
@POST
|
||||||
|
@Path("/vs-bot")
|
||||||
|
@PermitAll
|
||||||
|
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||||
|
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||||
|
def createBotGame(body: CreateGameRequestDto): Response =
|
||||||
|
val req = Option(body).getOrElse(CreateGameRequestDto(None, None, None, None))
|
||||||
|
val white = playerInfoFrom(req.white, DefaultWhite)
|
||||||
|
val black = playerInfoFrom(req.black, DefaultBlack)
|
||||||
|
val tc = toTimeControl(req.timeControl)
|
||||||
|
val entry = newEntry(GameContext.initial, white, black, tc, GameMode.Open)
|
||||||
|
registry.store(entry)
|
||||||
|
subscriberManager.subscribeGame(entry.gameId)
|
||||||
|
notifyBotSide(entry)
|
||||||
|
log.infof("Bot game %s created — white=%s black=%s", entry.gameId, white.displayName, black.displayName)
|
||||||
|
created(GameDtoMapper.toGameFullDto(entry, ioClient))
|
||||||
|
|
||||||
|
private def notifyBotSide(entry: GameEntry): Unit =
|
||||||
|
if entry.black.id.value.startsWith("bot-") then
|
||||||
|
subscriberManager.publishBotGameStart(entry.gameId, entry.black.id.value, "black")
|
||||||
|
else if entry.white.id.value.startsWith("bot-") then
|
||||||
|
subscriberManager.publishBotGameStart(entry.gameId, entry.white.id.value, "white")
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("/{gameId}")
|
@Path("/{gameId}")
|
||||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package de.nowchess.chess.service
|
||||||
|
|
||||||
|
import de.nowchess.api.dto.{GameCreationRequestDto, PlayerInfoDto, TimeControlDto}
|
||||||
|
import de.nowchess.api.game.{GameContext, GameMode, TimeControl}
|
||||||
|
import de.nowchess.api.player.{PlayerId, PlayerInfo}
|
||||||
|
import de.nowchess.chess.engine.GameEngine
|
||||||
|
import de.nowchess.chess.grpc.RuleSetGrpcAdapter
|
||||||
|
import de.nowchess.chess.redis.GameRedisSubscriberManager
|
||||||
|
import de.nowchess.chess.registry.{GameEntry, GameRegistry}
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import org.jboss.logging.Logger
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
class GameCreationService:
|
||||||
|
|
||||||
|
private val log = Logger.getLogger(classOf[GameCreationService])
|
||||||
|
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
@Inject var registry: GameRegistry = uninitialized
|
||||||
|
@Inject var ruleSetAdapter: RuleSetGrpcAdapter = uninitialized
|
||||||
|
@Inject var subscriberManager: GameRedisSubscriberManager = uninitialized
|
||||||
|
// scalafix:on DisableSyntax.var
|
||||||
|
|
||||||
|
private val DefaultWhite = PlayerInfo(PlayerId("p1"), "Player 1")
|
||||||
|
private val DefaultBlack = PlayerInfo(PlayerId("p2"), "Player 2")
|
||||||
|
|
||||||
|
def createGame(req: GameCreationRequestDto): GameEntry =
|
||||||
|
val white = playerInfoFrom(req.white, DefaultWhite)
|
||||||
|
val black = playerInfoFrom(req.black, DefaultBlack)
|
||||||
|
val tc = toTimeControl(req.timeControl)
|
||||||
|
val mode = req.mode.getOrElse(GameMode.Open)
|
||||||
|
val entry = newEntry(GameContext.initial, white, black, tc, mode)
|
||||||
|
registry.store(entry)
|
||||||
|
subscriberManager.subscribeGame(entry.gameId)
|
||||||
|
log.infof(
|
||||||
|
"Game %s created — white=%s black=%s mode=%s",
|
||||||
|
entry.gameId,
|
||||||
|
white.displayName,
|
||||||
|
black.displayName,
|
||||||
|
mode.toString,
|
||||||
|
)
|
||||||
|
entry
|
||||||
|
|
||||||
|
private def playerInfoFrom(dto: Option[PlayerInfoDto], default: PlayerInfo): PlayerInfo =
|
||||||
|
dto.fold(default)(d => PlayerInfo(PlayerId(d.id), d.displayName))
|
||||||
|
|
||||||
|
private def toTimeControl(dto: Option[TimeControlDto]): TimeControl =
|
||||||
|
dto match
|
||||||
|
case None => TimeControl.Unlimited
|
||||||
|
case Some(tc) =>
|
||||||
|
tc.daysPerMove match
|
||||||
|
case Some(d) => TimeControl.Correspondence(d)
|
||||||
|
case None =>
|
||||||
|
tc.limitSeconds.fold(TimeControl.Unlimited)(l => TimeControl.Clock(l, tc.incrementSeconds.getOrElse(0)))
|
||||||
|
|
||||||
|
private def newEntry(
|
||||||
|
ctx: GameContext,
|
||||||
|
white: PlayerInfo,
|
||||||
|
black: PlayerInfo,
|
||||||
|
tc: TimeControl,
|
||||||
|
mode: GameMode,
|
||||||
|
): GameEntry =
|
||||||
|
GameEntry(
|
||||||
|
registry.generateId(),
|
||||||
|
GameEngine(initialContext = ctx, ruleSet = ruleSetAdapter, timeControl = tc),
|
||||||
|
white,
|
||||||
|
black,
|
||||||
|
mode = mode,
|
||||||
|
)
|
||||||
@@ -18,6 +18,8 @@ nowchess:
|
|||||||
enabled: false
|
enabled: false
|
||||||
coordinator:
|
coordinator:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
game-creation-stream:
|
||||||
|
enabled: false
|
||||||
redis:
|
redis:
|
||||||
host: localhost
|
host: localhost
|
||||||
port: 6379
|
port: 6379
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package de.nowchess.chess.redis
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
|
||||||
|
import de.nowchess.api.board.Color
|
||||||
|
import de.nowchess.api.game.{DrawReason, GameContext, GameResult, WinReason}
|
||||||
|
import de.nowchess.api.player.{PlayerId, PlayerInfo}
|
||||||
|
import de.nowchess.chess.client.CombinedExportResponse
|
||||||
|
import de.nowchess.chess.engine.GameEngine
|
||||||
|
import de.nowchess.chess.grpc.IoGrpcClientWrapper
|
||||||
|
import de.nowchess.chess.observer.GameEvent
|
||||||
|
import de.nowchess.chess.registry.{GameEntry, GameRegistry}
|
||||||
|
import de.nowchess.rules.sets.DefaultRules
|
||||||
|
import io.quarkus.redis.datasource.RedisDataSource
|
||||||
|
import io.quarkus.redis.datasource.pubsub.PubSubCommands
|
||||||
|
import io.quarkus.redis.datasource.stream.{StreamCommands, XAddArgs}
|
||||||
|
import org.junit.jupiter.api.Assertions.*
|
||||||
|
import org.junit.jupiter.api.{BeforeEach, Test}
|
||||||
|
import org.mockito.ArgumentMatchers.*
|
||||||
|
import org.mockito.Mockito.*
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
|
||||||
|
class GameRedisPublisherTest:
|
||||||
|
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
private var redis: RedisDataSource = uninitialized
|
||||||
|
private var streamCmds: StreamCommands[String, String, Nothing] = uninitialized
|
||||||
|
private var pubsubCmds: PubSubCommands[String] = uninitialized
|
||||||
|
private var registry: GameRegistry = uninitialized
|
||||||
|
private var ioClient: IoGrpcClientWrapper = uninitialized
|
||||||
|
private var onGameOverCalled: Boolean = false
|
||||||
|
// scalafix:on DisableSyntax.var
|
||||||
|
|
||||||
|
private val objectMapper = new ObjectMapper().registerModule(new JavaTimeModule())
|
||||||
|
private val gameId = "game1"
|
||||||
|
private val whitePlayer = PlayerInfo(PlayerId("white1"), "Alice")
|
||||||
|
private val blackPlayer = PlayerInfo(PlayerId("black1"), "Bob")
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
def setup(): Unit =
|
||||||
|
redis = mock(classOf[RedisDataSource])
|
||||||
|
streamCmds = mock(classOf[StreamCommands[String, String, Nothing]])
|
||||||
|
pubsubCmds = mock(classOf[PubSubCommands[String]])
|
||||||
|
registry = mock(classOf[GameRegistry])
|
||||||
|
ioClient = mock(classOf[IoGrpcClientWrapper])
|
||||||
|
when(redis.stream(classOf[String])).thenReturn(streamCmds)
|
||||||
|
when(redis.pubsub(classOf[String])).thenReturn(pubsubCmds)
|
||||||
|
when(ioClient.exportCombined(any()))
|
||||||
|
.thenReturn(CombinedExportResponse("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", ""))
|
||||||
|
onGameOverCalled = false
|
||||||
|
|
||||||
|
private def publisherWithResult(result: GameResult): GameRedisPublisher =
|
||||||
|
val ctx = GameContext.initial.copy(result = Some(result))
|
||||||
|
val engine = new GameEngine(initialContext = ctx, ruleSet = DefaultRules)
|
||||||
|
val entry = GameEntry(gameId, engine, whitePlayer, blackPlayer)
|
||||||
|
when(registry.get(gameId)).thenReturn(Some(entry))
|
||||||
|
new GameRedisPublisher(
|
||||||
|
gameId,
|
||||||
|
registry,
|
||||||
|
redis,
|
||||||
|
objectMapper,
|
||||||
|
s"nowchess:game:$gameId:s2c",
|
||||||
|
_ => (),
|
||||||
|
ioClient,
|
||||||
|
_ => onGameOverCalled = true,
|
||||||
|
"nowchess",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def publishesGameOverOnCheckmate(): Unit =
|
||||||
|
val publisher = publisherWithResult(GameResult.Win(Color.White, WinReason.Checkmate))
|
||||||
|
publisher.onGameEvent(mock(classOf[GameEvent]))
|
||||||
|
verify(streamCmds).xadd(
|
||||||
|
org.mockito.ArgumentMatchers.eq("nowchess:game-over"),
|
||||||
|
any(classOf[XAddArgs]),
|
||||||
|
any(),
|
||||||
|
)
|
||||||
|
assertTrue(onGameOverCalled)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def publishesGameOverOnResignation(): Unit =
|
||||||
|
val publisher = publisherWithResult(GameResult.Win(Color.Black, WinReason.Resignation))
|
||||||
|
publisher.onGameEvent(mock(classOf[GameEvent]))
|
||||||
|
verify(streamCmds).xadd(
|
||||||
|
org.mockito.ArgumentMatchers.eq("nowchess:game-over"),
|
||||||
|
any(classOf[XAddArgs]),
|
||||||
|
any(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def publishesGameOverOnDraw(): Unit =
|
||||||
|
val publisher = publisherWithResult(GameResult.Draw(DrawReason.Agreement))
|
||||||
|
publisher.onGameEvent(mock(classOf[GameEvent]))
|
||||||
|
verify(streamCmds).xadd(
|
||||||
|
org.mockito.ArgumentMatchers.eq("nowchess:game-over"),
|
||||||
|
any(classOf[XAddArgs]),
|
||||||
|
any(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def doesNotPublishGameOverWhenNoResult(): Unit =
|
||||||
|
val ctx = GameContext.initial
|
||||||
|
val engine = new GameEngine(initialContext = ctx, ruleSet = DefaultRules)
|
||||||
|
val entry = GameEntry(gameId, engine, whitePlayer, blackPlayer)
|
||||||
|
when(registry.get(gameId)).thenReturn(Some(entry))
|
||||||
|
val publisher = new GameRedisPublisher(
|
||||||
|
gameId,
|
||||||
|
registry,
|
||||||
|
redis,
|
||||||
|
objectMapper,
|
||||||
|
s"nowchess:game:$gameId:s2c",
|
||||||
|
_ => (),
|
||||||
|
ioClient,
|
||||||
|
_ => onGameOverCalled = true,
|
||||||
|
"nowchess",
|
||||||
|
)
|
||||||
|
publisher.onGameEvent(mock(classOf[GameEvent]))
|
||||||
|
verify(streamCmds, never()).xadd(
|
||||||
|
org.mockito.ArgumentMatchers.eq("nowchess:game-over"),
|
||||||
|
any(classOf[XAddArgs]),
|
||||||
|
any(),
|
||||||
|
)
|
||||||
|
assertFalse(onGameOverCalled)
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package de.nowchess.chess.service
|
||||||
|
|
||||||
|
import de.nowchess.api.dto.{GameCreationRequestDto, PlayerInfoDto, TimeControlDto}
|
||||||
|
import de.nowchess.api.game.{GameMode, TimeControl}
|
||||||
|
import de.nowchess.api.player.PlayerType
|
||||||
|
import de.nowchess.chess.client.CombinedExportResponse
|
||||||
|
import de.nowchess.chess.grpc.IoGrpcClientWrapper
|
||||||
|
import de.nowchess.chess.redis.GameRedisSubscriberManager
|
||||||
|
import io.quarkus.test.InjectMock
|
||||||
|
import io.quarkus.test.junit.QuarkusTest
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import org.junit.jupiter.api.Assertions.*
|
||||||
|
import org.junit.jupiter.api.{BeforeEach, DisplayName, Test}
|
||||||
|
import org.mockito.ArgumentMatchers.any
|
||||||
|
import org.mockito.Mockito.{verify, when}
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
|
||||||
|
// scalafix:off
|
||||||
|
@QuarkusTest
|
||||||
|
@DisplayName("GameCreationService")
|
||||||
|
class GameCreationServiceTest:
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
var service: GameCreationService = uninitialized
|
||||||
|
|
||||||
|
@InjectMock
|
||||||
|
var subscriberManager: GameRedisSubscriberManager = uninitialized
|
||||||
|
|
||||||
|
@InjectMock
|
||||||
|
var ioWrapper: IoGrpcClientWrapper = uninitialized
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
def setup(): Unit =
|
||||||
|
when(ioWrapper.exportCombined(any()))
|
||||||
|
.thenReturn(CombinedExportResponse("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", ""))
|
||||||
|
|
||||||
|
private def player(id: String, name: String): PlayerInfoDto =
|
||||||
|
PlayerInfoDto(id, name, PlayerType.Human)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def createsGameAndSubscribes(): Unit =
|
||||||
|
val req =
|
||||||
|
GameCreationRequestDto(Some(player("w", "White")), Some(player("b", "Black")), None, Some(GameMode.Authenticated))
|
||||||
|
val entry = service.createGame(req)
|
||||||
|
assertNotNull(entry.gameId)
|
||||||
|
assertEquals("White", entry.white.displayName)
|
||||||
|
assertEquals("Black", entry.black.displayName)
|
||||||
|
assertEquals(GameMode.Authenticated, entry.mode)
|
||||||
|
verify(subscriberManager).subscribeGame(entry.gameId)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def defaultsToOpenModeAndDefaultPlayers(): Unit =
|
||||||
|
val entry = service.createGame(GameCreationRequestDto(None, None, None, None))
|
||||||
|
assertEquals(GameMode.Open, entry.mode)
|
||||||
|
assertEquals("Player 1", entry.white.displayName)
|
||||||
|
assertEquals("Player 2", entry.black.displayName)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def mapsClockTimeControl(): Unit =
|
||||||
|
val tc = TimeControlDto(Some(300), Some(5), None)
|
||||||
|
val entry = service.createGame(GameCreationRequestDto(None, None, Some(tc), None))
|
||||||
|
assertEquals(TimeControl.Clock(300, 5), entry.engine.timeControl)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def mapsCorrespondenceTimeControl(): Unit =
|
||||||
|
val tc = TimeControlDto(None, None, Some(3))
|
||||||
|
val entry = service.createGame(GameCreationRequestDto(None, None, Some(tc), None))
|
||||||
|
assertEquals(TimeControl.Correspondence(3), entry.engine.timeControl)
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
MAJOR=0
|
MAJOR=0
|
||||||
MINOR=47
|
MINOR=50
|
||||||
PATCH=0
|
PATCH=0
|
||||||
|
|||||||
@@ -179,3 +179,78 @@
|
|||||||
### Reverts
|
### Reverts
|
||||||
|
|
||||||
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||||
|
## (2026-06-09)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add initialization metrics for various services ([d438e97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d438e97f32bdde0bfc63c1b4a8cc810cdd093166))
|
||||||
|
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
|
||||||
|
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
|
||||||
|
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
|
||||||
|
* configure logging and add OpenTelemetry support ([#49](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/49)) ([d57c488](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d57c4886612d1d92da0e1b79209fc83e6ef537a1))
|
||||||
|
* **docker:** add .dockerignore and .gitignore files for build exclusions ([c987d8e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c987d8e258c0e6c4cfbdaa8381c64c410d7a2b83))
|
||||||
|
* **docker:** add Dockerfiles for building Quarkus application in native and JVM modes ([3f2d2bb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3f2d2bb4c97fa8cddba66e1da4427c54236dfeed))
|
||||||
|
* **docker:** add Dockerfiles for Quarkus application in JVM and native modes ([34b9933](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/34b993304670cf2aa62cd2f6460cee7b9864b08e))
|
||||||
|
* **events:** migrate game-creation and bot flows to Redis Streams NCS-89 ([#62](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/62)) ([a24924c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a24924c23057db3d700a75dbc4333557789cd991))
|
||||||
|
* NCS-78 Add Traceability to the Applications ([#46](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/46)) ([649566e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/649566eb3fcf38f91c8896a739f74ea318af312d))
|
||||||
|
* NCS-78 Add Traceability to the Applications ([#47](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/47)) ([87dfc6c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/87dfc6c2bcce7f7d58fc641bd8d468a2e584c108))
|
||||||
|
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **official-bots:** NCS-70-auto-register official bots with account service ([#59](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/59)) ([7117a93](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7117a93376272094d0b1a6abf2121254ce396684))
|
||||||
|
|
||||||
|
### Reverts
|
||||||
|
|
||||||
|
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||||
|
## (2026-06-09)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add initialization metrics for various services ([d438e97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d438e97f32bdde0bfc63c1b4a8cc810cdd093166))
|
||||||
|
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
|
||||||
|
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
|
||||||
|
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
|
||||||
|
* configure logging and add OpenTelemetry support ([#49](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/49)) ([d57c488](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d57c4886612d1d92da0e1b79209fc83e6ef537a1))
|
||||||
|
* **docker:** add .dockerignore and .gitignore files for build exclusions ([c987d8e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c987d8e258c0e6c4cfbdaa8381c64c410d7a2b83))
|
||||||
|
* **docker:** add Dockerfiles for building Quarkus application in native and JVM modes ([3f2d2bb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3f2d2bb4c97fa8cddba66e1da4427c54236dfeed))
|
||||||
|
* **docker:** add Dockerfiles for Quarkus application in JVM and native modes ([34b9933](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/34b993304670cf2aa62cd2f6460cee7b9864b08e))
|
||||||
|
* **events:** migrate game-creation and bot flows to Redis Streams NCS-89 ([#62](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/62)) ([a24924c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a24924c23057db3d700a75dbc4333557789cd991))
|
||||||
|
* NCS-78 Add Traceability to the Applications ([#46](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/46)) ([649566e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/649566eb3fcf38f91c8896a739f74ea318af312d))
|
||||||
|
* NCS-78 Add Traceability to the Applications ([#47](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/47)) ([87dfc6c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/87dfc6c2bcce7f7d58fc641bd8d468a2e584c108))
|
||||||
|
* NCS-82 add Swiss-system tournament module ([#55](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/55)) ([c5661de](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c5661de4a0ebf4b33211f5a391840dcf744656b7))
|
||||||
|
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **official-bots:** NCS-70-auto-register official bots with account service ([#59](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/59)) ([7117a93](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7117a93376272094d0b1a6abf2121254ce396684))
|
||||||
|
|
||||||
|
### Reverts
|
||||||
|
|
||||||
|
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||||
|
## (2026-06-09)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add initialization metrics for various services ([d438e97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d438e97f32bdde0bfc63c1b4a8cc810cdd093166))
|
||||||
|
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
|
||||||
|
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
|
||||||
|
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
|
||||||
|
* configure logging and add OpenTelemetry support ([#49](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/49)) ([d57c488](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d57c4886612d1d92da0e1b79209fc83e6ef537a1))
|
||||||
|
* **docker:** add .dockerignore and .gitignore files for build exclusions ([c987d8e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c987d8e258c0e6c4cfbdaa8381c64c410d7a2b83))
|
||||||
|
* **docker:** add Dockerfiles for building Quarkus application in native and JVM modes ([3f2d2bb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3f2d2bb4c97fa8cddba66e1da4427c54236dfeed))
|
||||||
|
* **docker:** add Dockerfiles for Quarkus application in JVM and native modes ([34b9933](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/34b993304670cf2aa62cd2f6460cee7b9864b08e))
|
||||||
|
* **events:** migrate game-creation and bot flows to Redis Streams NCS-89 ([#62](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/62)) ([a24924c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a24924c23057db3d700a75dbc4333557789cd991))
|
||||||
|
* NCS-78 Add Traceability to the Applications ([#46](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/46)) ([649566e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/649566eb3fcf38f91c8896a739f74ea318af312d))
|
||||||
|
* NCS-78 Add Traceability to the Applications ([#47](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/47)) ([87dfc6c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/87dfc6c2bcce7f7d58fc641bd8d468a2e584c108))
|
||||||
|
* NCS-82 add Swiss-system tournament module ([#55](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/55)) ([c5661de](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c5661de4a0ebf4b33211f5a391840dcf744656b7))
|
||||||
|
* **official-bots:** consume GameOver stream for bot cleanup ([#67](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/67)) ([db9d153](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/db9d1533912f4b41c4d1ca80ccffdde5d23d6ff6))
|
||||||
|
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **official-bots:** NCS-70-auto-register official bots with account service ([#59](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/59)) ([7117a93](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7117a93376272094d0b1a6abf2121254ce396684))
|
||||||
|
|
||||||
|
### Reverts
|
||||||
|
|
||||||
|
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||||
|
|||||||
+180
-15
@@ -1,6 +1,7 @@
|
|||||||
package de.nowchess.bot.service
|
package de.nowchess.bot.service
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import de.nowchess.api.event.EventEnvelope
|
||||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
import de.nowchess.bot.BotController
|
import de.nowchess.bot.BotController
|
||||||
import de.nowchess.bot.BotDifficulty
|
import de.nowchess.bot.BotDifficulty
|
||||||
@@ -9,16 +10,23 @@ import de.nowchess.bot.config.RedisConfig
|
|||||||
import de.nowchess.io.fen.FenParser
|
import de.nowchess.io.fen.FenParser
|
||||||
import io.micrometer.core.instrument.MeterRegistry
|
import io.micrometer.core.instrument.MeterRegistry
|
||||||
import io.quarkus.redis.datasource.RedisDataSource
|
import io.quarkus.redis.datasource.RedisDataSource
|
||||||
|
import io.quarkus.redis.datasource.stream.{StreamMessage, XAddArgs, XGroupCreateArgs, XReadGroupArgs}
|
||||||
import io.quarkus.runtime.StartupEvent
|
import io.quarkus.runtime.StartupEvent
|
||||||
import jakarta.annotation.PostConstruct
|
import jakarta.annotation.PostConstruct
|
||||||
import jakarta.enterprise.context.ApplicationScoped
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
import jakarta.enterprise.event.Observes
|
import jakarta.enterprise.event.Observes
|
||||||
import jakarta.inject.Inject
|
import jakarta.inject.Inject
|
||||||
|
import org.eclipse.microprofile.context.ManagedExecutor
|
||||||
import org.eclipse.microprofile.rest.client.inject.RestClient
|
import org.eclipse.microprofile.rest.client.inject.RestClient
|
||||||
import org.jboss.logging.Logger
|
import org.jboss.logging.Logger
|
||||||
import scala.compiletime.uninitialized
|
import scala.compiletime.uninitialized
|
||||||
|
import scala.jdk.CollectionConverters.*
|
||||||
|
import scala.util.{Failure, Success, Try}
|
||||||
|
import java.time.Duration
|
||||||
|
import java.util.UUID
|
||||||
|
import io.quarkus.redis.datasource.pubsub.PubSubCommands
|
||||||
|
import java.util.concurrent.{ConcurrentHashMap, TimeUnit}
|
||||||
import java.util.function.Consumer
|
import java.util.function.Consumer
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
class OfficialBotService:
|
class OfficialBotService:
|
||||||
@@ -31,6 +39,7 @@ class OfficialBotService:
|
|||||||
@Inject var objectMapper: ObjectMapper = uninitialized
|
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||||
@Inject var botController: BotController = uninitialized
|
@Inject var botController: BotController = uninitialized
|
||||||
@Inject var meterRegistry: MeterRegistry = uninitialized
|
@Inject var meterRegistry: MeterRegistry = uninitialized
|
||||||
|
@Inject var executor: ManagedExecutor = uninitialized
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
@RestClient
|
@RestClient
|
||||||
@@ -40,6 +49,18 @@ class OfficialBotService:
|
|||||||
private val terminalStatuses =
|
private val terminalStatuses =
|
||||||
Set("checkmate", "resign", "timeout", "stalemate", "insufficientMaterial", "draw")
|
Set("checkmate", "resign", "timeout", "stalemate", "insufficientMaterial", "draw")
|
||||||
|
|
||||||
|
private val groupName = "official-bot"
|
||||||
|
private val gameOverGroup = "official-bots-game-over"
|
||||||
|
private val consumerId = UUID.randomUUID().toString
|
||||||
|
private val maxRetries = 3
|
||||||
|
private val maxStreamLen = 1000L
|
||||||
|
|
||||||
|
private def eventStream(botName: String): String = s"${redisConfig.prefix}:bot:$botName:events:stream"
|
||||||
|
private def gameOverStream: String = s"${redisConfig.prefix}:game-over"
|
||||||
|
private def dlqStream: String = s"${redisConfig.prefix}:dlq"
|
||||||
|
|
||||||
|
private val gameWatches = new ConcurrentHashMap[String, (String, PubSubCommands.RedisSubscriber)]()
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
def initializeMetrics(): Unit =
|
def initializeMetrics(): Unit =
|
||||||
BotController.listBots.foreach { bot =>
|
BotController.listBots.foreach { bot =>
|
||||||
@@ -52,22 +73,95 @@ class OfficialBotService:
|
|||||||
try accountServiceClient.syncBots(SyncOfficialBotsRequest(bots))
|
try accountServiceClient.syncBots(SyncOfficialBotsRequest(bots))
|
||||||
catch case ex: Exception => log.errorf(ex, "Failed to auto-register official bots with account service")
|
catch case ex: Exception => log.errorf(ex, "Failed to auto-register official bots with account service")
|
||||||
bots.foreach(subscribeToEventChannel)
|
bots.foreach(subscribeToEventChannel)
|
||||||
|
subscribeToGameOverStream()
|
||||||
|
|
||||||
private def subscribeToEventChannel(botName: String): Unit =
|
private def subscribeToEventChannel(botName: String): Unit =
|
||||||
val handler: Consumer[String] = msg => handleBotEvent(botName, msg)
|
createGroupIfAbsent(botName)
|
||||||
redis.pubsub(classOf[String]).subscribe(s"${redisConfig.prefix}:bot:$botName:events", handler)
|
executor.submit(
|
||||||
()
|
new Runnable:
|
||||||
|
def run(): Unit = pollLoop(botName),
|
||||||
|
)
|
||||||
|
log.infof("Listening to bot event stream for %s (consumer=%s)", botName, consumerId)
|
||||||
|
|
||||||
private def handleBotEvent(botName: String, msg: String): Unit =
|
private def createGroupIfAbsent(botName: String): Unit =
|
||||||
try
|
Try(
|
||||||
val node = objectMapper.readTree(msg)
|
redis
|
||||||
if node.path("type").asText() == "gameStart" then
|
.stream(classOf[String])
|
||||||
val gameId = node.path("gameId").asText()
|
.xgroupCreate(eventStream(botName), groupName, "0", new XGroupCreateArgs().mkstream()),
|
||||||
val playingAs = node.path("playingAs").asText()
|
) match
|
||||||
val difficulty = node.path("difficulty").asInt(1400)
|
case Failure(ex) if Option(ex.getMessage).exists(_.contains("BUSYGROUP")) => ()
|
||||||
val botAccountId = node.path("botAccountId").asText()
|
case Failure(ex) => log.warnf(ex, "Failed to create bot event consumer group for %s", botName)
|
||||||
watchGame(botName, gameId, playingAs, difficulty, botAccountId)
|
case Success(_) => ()
|
||||||
catch case _: Exception => ()
|
|
||||||
|
private def pollLoop(botName: String): Unit =
|
||||||
|
while true do
|
||||||
|
Try {
|
||||||
|
val messages = redis
|
||||||
|
.stream(classOf[String])
|
||||||
|
.xreadgroup(
|
||||||
|
groupName,
|
||||||
|
consumerId,
|
||||||
|
eventStream(botName),
|
||||||
|
">",
|
||||||
|
new XReadGroupArgs().count(10).block(Duration.ofSeconds(2)),
|
||||||
|
)
|
||||||
|
Option(messages).foreach(_.forEach(msg => handleStreamMessage(botName, msg)))
|
||||||
|
} match
|
||||||
|
case Failure(ex) => log.warnf(ex, "Error in bot event poll loop for %s", botName)
|
||||||
|
case Success(_) => ()
|
||||||
|
|
||||||
|
private def handleStreamMessage(botName: String, msg: StreamMessage[String, String, String]): Unit =
|
||||||
|
val json = msg.payload().get("data")
|
||||||
|
val attempt = Option(msg.payload().get("attempt")).flatMap(_.toIntOption).getOrElse(0)
|
||||||
|
Try {
|
||||||
|
val envelope = objectMapper.readValue(json, classOf[EventEnvelope])
|
||||||
|
handleBotEvent(botName, envelope)
|
||||||
|
} match
|
||||||
|
case Success(_) => ack(botName, msg.id())
|
||||||
|
case Failure(ex) if attempt + 1 < maxRetries =>
|
||||||
|
log.warnf(ex, "Bot event handling failed for %s (attempt %d), retrying", botName, attempt)
|
||||||
|
retry(botName, json, attempt + 1)
|
||||||
|
ack(botName, msg.id())
|
||||||
|
case Failure(ex) =>
|
||||||
|
log.errorf(ex, "Bot event handling failed for %s after %d attempts, sending to DLQ", botName, maxRetries)
|
||||||
|
toDlq(json, ex, attempt)
|
||||||
|
ack(botName, msg.id())
|
||||||
|
|
||||||
|
private def handleBotEvent(botName: String, envelope: EventEnvelope): Unit =
|
||||||
|
val payload = envelope.payload
|
||||||
|
val gameId = payload.path("gameId").asText()
|
||||||
|
val playingAs = payload.path("playingAs").asText()
|
||||||
|
val difficulty = payload.path("difficulty").asInt(1400)
|
||||||
|
val botAccountId = payload.path("botAccountId").asText()
|
||||||
|
watchGame(botName, gameId, playingAs, difficulty, botAccountId)
|
||||||
|
|
||||||
|
private def ack(botName: String, id: String): Unit =
|
||||||
|
Try(redis.stream(classOf[String]).xack(eventStream(botName), groupName, id)) match
|
||||||
|
case Failure(ex) => log.warnf(ex, "Failed to ack bot event %s", id)
|
||||||
|
case Success(_) => ()
|
||||||
|
|
||||||
|
private def retry(botName: String, json: String, attempt: Int): Unit =
|
||||||
|
xadd(eventStream(botName), Map("data" -> json, "attempt" -> attempt.toString))
|
||||||
|
|
||||||
|
private def toDlq(json: String, error: Throwable, attempt: Int): Unit =
|
||||||
|
xadd(
|
||||||
|
dlqStream,
|
||||||
|
Map(
|
||||||
|
"data" -> json,
|
||||||
|
"eventType" -> "BotGameStart",
|
||||||
|
"error" -> Option(error.getMessage).getOrElse(error.getClass.getName),
|
||||||
|
"attempt" -> (attempt + 1).toString,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private def xadd(key: String, fields: Map[String, String]): Unit =
|
||||||
|
Try(
|
||||||
|
redis
|
||||||
|
.stream(classOf[String])
|
||||||
|
.xadd(key, new XAddArgs().maxlen(maxStreamLen).nearlyExactTrimming(), fields.asJava),
|
||||||
|
) match
|
||||||
|
case Failure(ex) => log.errorf(ex, "Failed to publish to stream %s", key)
|
||||||
|
case Success(_) => ()
|
||||||
|
|
||||||
private def watchGame(
|
private def watchGame(
|
||||||
botName: String,
|
botName: String,
|
||||||
@@ -77,9 +171,80 @@ class OfficialBotService:
|
|||||||
botAccountId: String,
|
botAccountId: String,
|
||||||
): Unit =
|
): Unit =
|
||||||
val handler: Consumer[String] = msg => handleGameEvent(botName, gameId, playingAs, difficulty, botAccountId, msg)
|
val handler: Consumer[String] = msg => handleGameEvent(botName, gameId, playingAs, difficulty, botAccountId, msg)
|
||||||
redis.pubsub(classOf[String]).subscribe(s"${redisConfig.prefix}:game:$gameId:s2c", handler)
|
val subscriber = redis.pubsub(classOf[String]).subscribe(s"${redisConfig.prefix}:game:$gameId:s2c", handler)
|
||||||
|
gameWatches.put(gameId, (botName, subscriber))
|
||||||
()
|
()
|
||||||
|
|
||||||
|
private def subscribeToGameOverStream(): Unit =
|
||||||
|
Try(
|
||||||
|
redis
|
||||||
|
.stream(classOf[String])
|
||||||
|
.xgroupCreate(gameOverStream, gameOverGroup, "$", new XGroupCreateArgs().mkstream()),
|
||||||
|
) match
|
||||||
|
case Failure(ex) if Option(ex.getMessage).exists(_.contains("BUSYGROUP")) => ()
|
||||||
|
case Failure(ex) => log.warnf(ex, "Failed to create game-over consumer group")
|
||||||
|
case Success(_) => ()
|
||||||
|
executor.submit(
|
||||||
|
new Runnable:
|
||||||
|
def run(): Unit = gameOverPollLoop(),
|
||||||
|
)
|
||||||
|
log.infof("Listening to game-over stream (consumer=%s)", consumerId)
|
||||||
|
|
||||||
|
private def gameOverPollLoop(): Unit =
|
||||||
|
while true do
|
||||||
|
Try {
|
||||||
|
val messages = redis
|
||||||
|
.stream(classOf[String])
|
||||||
|
.xreadgroup(
|
||||||
|
gameOverGroup,
|
||||||
|
consumerId,
|
||||||
|
gameOverStream,
|
||||||
|
">",
|
||||||
|
new XReadGroupArgs().count(10).block(Duration.ofSeconds(2)),
|
||||||
|
)
|
||||||
|
Option(messages).foreach(_.forEach(msg => handleGameOverMessage(msg)))
|
||||||
|
} match
|
||||||
|
case Failure(ex) => log.warnf(ex, "Error in game-over poll loop")
|
||||||
|
case Success(_) => ()
|
||||||
|
|
||||||
|
private def handleGameOverMessage(msg: StreamMessage[String, String, String]): Unit =
|
||||||
|
val json = msg.payload().get("data")
|
||||||
|
val attempt = Option(msg.payload().get("attempt")).flatMap(_.toIntOption).getOrElse(0)
|
||||||
|
Try {
|
||||||
|
val node = objectMapper.readTree(json)
|
||||||
|
val gameId = node.path("payload").path("gameId").asText()
|
||||||
|
if gameId.nonEmpty then
|
||||||
|
Option(gameWatches.remove(gameId)).foreach { (botName, subscriber) =>
|
||||||
|
val topic = s"${redisConfig.prefix}:game:$gameId:s2c"
|
||||||
|
Try(subscriber.unsubscribe(topic)) match
|
||||||
|
case Failure(ex) => log.warnf(ex, "Failed to unsubscribe from game %s", gameId)
|
||||||
|
case Success(_) => log.infof("Bot %s cleaned up game %s after GameOver", botName, gameId)
|
||||||
|
}
|
||||||
|
} match
|
||||||
|
case Success(_) =>
|
||||||
|
ackGameOver(msg.id())
|
||||||
|
case Failure(ex) if attempt + 1 < maxRetries =>
|
||||||
|
log.warnf(ex, "GameOver handling failed (attempt %d), retrying", attempt)
|
||||||
|
xadd(gameOverStream, Map("data" -> json, "attempt" -> (attempt + 1).toString))
|
||||||
|
ackGameOver(msg.id())
|
||||||
|
case Failure(ex) =>
|
||||||
|
log.errorf(ex, "GameOver handling failed after %d attempts, sending to DLQ", maxRetries)
|
||||||
|
xadd(
|
||||||
|
dlqStream,
|
||||||
|
Map(
|
||||||
|
"data" -> json,
|
||||||
|
"eventType" -> "GameOver",
|
||||||
|
"error" -> Option(ex.getMessage).getOrElse(ex.getClass.getName),
|
||||||
|
"attempt" -> attempt.toString,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ackGameOver(msg.id())
|
||||||
|
|
||||||
|
private def ackGameOver(id: String): Unit =
|
||||||
|
Try(redis.stream(classOf[String]).xack(gameOverStream, gameOverGroup, id)) match
|
||||||
|
case Failure(ex) => log.warnf(ex, "Failed to ack game-over message %s", id)
|
||||||
|
case Success(_) => ()
|
||||||
|
|
||||||
private def handleGameEvent(
|
private def handleGameEvent(
|
||||||
botName: String,
|
botName: String,
|
||||||
gameId: String,
|
gameId: String,
|
||||||
|
|||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
package de.nowchess.bot.service
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import scala.util.Try
|
||||||
|
|
||||||
|
final case class TournamentBotConfig(
|
||||||
|
serverUrl: String,
|
||||||
|
tournamentId: String,
|
||||||
|
token: String,
|
||||||
|
botId: String,
|
||||||
|
difficulty: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
object TournamentBotConfig:
|
||||||
|
|
||||||
|
private val mapper = new ObjectMapper()
|
||||||
|
|
||||||
|
def fromEnv(env: Map[String, String]): Option[TournamentBotConfig] =
|
||||||
|
for
|
||||||
|
tournamentId <- env.get("TOURNAMENT_ID").filter(_.nonEmpty)
|
||||||
|
token <- env.get("TOURNAMENT_BOT_TOKEN").filter(_.nonEmpty)
|
||||||
|
botId <- jwtSubject(token)
|
||||||
|
serverUrl = env.getOrElse("TOURNAMENT_SERVER_URL", "http://localhost:8089")
|
||||||
|
difficulty = env.getOrElse("TOURNAMENT_BOT_DIFFICULTY", "medium")
|
||||||
|
yield TournamentBotConfig(serverUrl, tournamentId, token, botId, difficulty)
|
||||||
|
|
||||||
|
def jwtSubject(token: String): Option[String] =
|
||||||
|
Try {
|
||||||
|
val parts = token.split("\\.")
|
||||||
|
if parts.length >= 2 then
|
||||||
|
val payload = new String(java.util.Base64.getUrlDecoder.decode(parts(1)))
|
||||||
|
val sub = mapper.readTree(payload).path("sub").asText()
|
||||||
|
Option(sub).filter(_.nonEmpty)
|
||||||
|
else None
|
||||||
|
}.toOption.flatten
|
||||||
+226
@@ -0,0 +1,226 @@
|
|||||||
|
package de.nowchess.bot.service
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.{JsonNode, ObjectMapper}
|
||||||
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
|
import de.nowchess.bot.{Bot, BotController}
|
||||||
|
import de.nowchess.io.fen.FenParser
|
||||||
|
import io.quarkus.runtime.Startup
|
||||||
|
import jakarta.annotation.{PostConstruct, PreDestroy}
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import jakarta.ws.rs.client.{Client, ClientBuilder, Entity}
|
||||||
|
import jakarta.ws.rs.core.MediaType
|
||||||
|
import org.jboss.logging.Logger
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
import scala.jdk.CollectionConverters.*
|
||||||
|
import scala.util.{Failure, Success, Try}
|
||||||
|
import java.io.{BufferedReader, InputStream, InputStreamReader}
|
||||||
|
import java.util.concurrent.{ConcurrentHashMap, ExecutorService, Executors}
|
||||||
|
|
||||||
|
@Startup
|
||||||
|
@ApplicationScoped
|
||||||
|
class TournamentBotGamePlayer:
|
||||||
|
|
||||||
|
private val log = Logger.getLogger(classOf[TournamentBotGamePlayer])
|
||||||
|
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||||
|
@Inject var botController: BotController = uninitialized
|
||||||
|
// scalafix:on DisableSyntax.var
|
||||||
|
|
||||||
|
private val client: Client = ClientBuilder.newClient()
|
||||||
|
private val workers: ExecutorService = Executors.newCachedThreadPool()
|
||||||
|
private val activeGames = ConcurrentHashMap.newKeySet[String]()
|
||||||
|
|
||||||
|
private val config = TournamentBotConfig.fromEnv(System.getenv().asScala.toMap)
|
||||||
|
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
@volatile private var running = true
|
||||||
|
// scalafix:on DisableSyntax.var
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
def initialize(): Unit =
|
||||||
|
config match
|
||||||
|
case None =>
|
||||||
|
log.info("Tournament bot disabled — set TOURNAMENT_ID and TOURNAMENT_BOT_TOKEN to enable")
|
||||||
|
case Some(cfg) =>
|
||||||
|
log.infof("Tournament bot enabled — server=%s tournament=%s bot=%s", cfg.serverUrl, cfg.tournamentId, cfg.botId)
|
||||||
|
val thread = new Thread(() => connect(cfg), s"TournamentBot-${cfg.tournamentId}")
|
||||||
|
thread.setDaemon(true)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
def cleanup(): Unit =
|
||||||
|
running = false
|
||||||
|
workers.shutdownNow()
|
||||||
|
Try(client.close())
|
||||||
|
log.info("Tournament bot stopped")
|
||||||
|
|
||||||
|
private def connect(cfg: TournamentBotConfig): Unit =
|
||||||
|
if join(cfg) then
|
||||||
|
while running do
|
||||||
|
Try(streamEvents(cfg)) match
|
||||||
|
case Failure(ex) => log.warnf(ex, "Tournament event stream dropped — reconnecting"); sleep(5000)
|
||||||
|
case Success(_) => sleep(2000)
|
||||||
|
|
||||||
|
private def join(cfg: TournamentBotConfig): Boolean =
|
||||||
|
Try {
|
||||||
|
val response = authed(cfg, target(cfg).path("join"))
|
||||||
|
.post(Entity.entity("", MediaType.APPLICATION_JSON))
|
||||||
|
val ok = response.getStatus == 200
|
||||||
|
if ok then log.infof("Joined tournament %s", cfg.tournamentId)
|
||||||
|
else log.errorf("Failed to join tournament %s — status %d", cfg.tournamentId, response.getStatus)
|
||||||
|
response.close()
|
||||||
|
ok
|
||||||
|
}.getOrElse { log.error("Join request failed"); false }
|
||||||
|
|
||||||
|
private def streamEvents(cfg: TournamentBotConfig): Unit =
|
||||||
|
val response = authed(cfg, target(cfg).path("stream"))
|
||||||
|
.header("Accept", "application/x-ndjson")
|
||||||
|
.get()
|
||||||
|
if response.getStatus != 200 then
|
||||||
|
log.warnf("Tournament stream returned status %d", response.getStatus)
|
||||||
|
response.close()
|
||||||
|
sleep(5000)
|
||||||
|
else
|
||||||
|
log.infof("Listening to tournament %s event stream", cfg.tournamentId)
|
||||||
|
forEachLine(response.readEntity(classOf[InputStream])): line =>
|
||||||
|
parse(line).foreach: node =>
|
||||||
|
if node.path("type").asText() == "gameStart" then onGameStart(cfg, node.path("gameId").asText())
|
||||||
|
|
||||||
|
private def onGameStart(cfg: TournamentBotConfig, gameId: String): Unit =
|
||||||
|
if gameId.nonEmpty && activeGames.add(gameId) then
|
||||||
|
workers.submit(new Runnable { def run(): Unit = playGame(cfg, gameId) })
|
||||||
|
()
|
||||||
|
|
||||||
|
private def playGame(cfg: TournamentBotConfig, gameId: String): Unit =
|
||||||
|
Try {
|
||||||
|
colorFor(cfg, gameId) match
|
||||||
|
case None =>
|
||||||
|
log.debugf("Game %s is not ours — ignoring", gameId)
|
||||||
|
activeGames.remove(gameId)
|
||||||
|
case Some(color) =>
|
||||||
|
log.infof("Playing game %s as %s", gameId, color)
|
||||||
|
val stream = openGameStream(cfg, gameId)
|
||||||
|
maybeMoveFromCurrentState(cfg, gameId, color)
|
||||||
|
stream.foreach(consumeGameStream(cfg, gameId, color, _))
|
||||||
|
activeGames.remove(gameId)
|
||||||
|
} match
|
||||||
|
case Failure(ex) => log.errorf(ex, "Game %s crashed", gameId); activeGames.remove(gameId)
|
||||||
|
case Success(_) => ()
|
||||||
|
|
||||||
|
private def colorFor(cfg: TournamentBotConfig, gameId: String): Option[String] =
|
||||||
|
fetchGame(cfg, gameId).flatMap: game =>
|
||||||
|
val white = game.path("white").path("id").asText()
|
||||||
|
val black = game.path("black").path("id").asText()
|
||||||
|
if white == cfg.botId then Some("white")
|
||||||
|
else if black == cfg.botId then Some("black")
|
||||||
|
else None
|
||||||
|
|
||||||
|
private def maybeMoveFromCurrentState(cfg: TournamentBotConfig, gameId: String, color: String): Unit =
|
||||||
|
fetchGame(cfg, gameId).foreach: game =>
|
||||||
|
maybeMove(cfg, gameId, color, game.path("turn").asText(), game.path("status").asText(), game.path("fen").asText())
|
||||||
|
|
||||||
|
private def consumeGameStream(cfg: TournamentBotConfig, gameId: String, color: String, stream: InputStream): Unit =
|
||||||
|
val reader = new BufferedReader(new InputStreamReader(stream))
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
var done = false
|
||||||
|
// scalafix:on DisableSyntax.var
|
||||||
|
Iterator
|
||||||
|
.continually(reader.readLine())
|
||||||
|
.map(Option(_))
|
||||||
|
.takeWhile(opt => opt.isDefined && running && !done)
|
||||||
|
.flatten
|
||||||
|
.foreach { line =>
|
||||||
|
parse(line).foreach: node =>
|
||||||
|
node.path("type").asText() match
|
||||||
|
case "move" =>
|
||||||
|
maybeMove(cfg, gameId, color, node.path("turn").asText(), "ongoing", node.path("fen").asText())
|
||||||
|
case "gameEnd" => log.infof("Game %s ended — status=%s", gameId, node.path("status").asText()); done = true
|
||||||
|
case _ => ()
|
||||||
|
}
|
||||||
|
|
||||||
|
private def maybeMove(
|
||||||
|
cfg: TournamentBotConfig,
|
||||||
|
gameId: String,
|
||||||
|
color: String,
|
||||||
|
turn: String,
|
||||||
|
status: String,
|
||||||
|
fen: String,
|
||||||
|
): Unit =
|
||||||
|
if turn == color && status == "ongoing" && fen.nonEmpty then
|
||||||
|
computeUci(cfg, fen) match
|
||||||
|
case None => log.warnf("No move found for game %s (fen=%s)", gameId, fen)
|
||||||
|
case Some(uci) => submitMove(cfg, gameId, uci)
|
||||||
|
|
||||||
|
private def computeUci(cfg: TournamentBotConfig, fen: String): Option[String] =
|
||||||
|
FenParser.parseFen(fen) match
|
||||||
|
case Left(err) => log.warnf("FEN parse failed: %s (%s)", fen, err.toString); None
|
||||||
|
case Right(context) => engine(cfg).apply(context).map(toUci)
|
||||||
|
|
||||||
|
private def submitMove(cfg: TournamentBotConfig, gameId: String, uci: String): Unit =
|
||||||
|
Try {
|
||||||
|
val response = authed(cfg, target(cfg).path("game").path(gameId).path("move").path(uci))
|
||||||
|
.post(Entity.entity("", MediaType.APPLICATION_JSON))
|
||||||
|
if response.getStatus == 200 then log.infof("Played %s in game %s", uci, gameId)
|
||||||
|
else log.warnf("Move %s rejected in game %s — status %d", uci, gameId, response.getStatus)
|
||||||
|
response.close()
|
||||||
|
} match
|
||||||
|
case Failure(ex) => log.errorf(ex, "Error submitting move %s in game %s", uci, gameId)
|
||||||
|
case Success(_) => ()
|
||||||
|
|
||||||
|
private def fetchGame(cfg: TournamentBotConfig, gameId: String): Option[JsonNode] =
|
||||||
|
Try {
|
||||||
|
val response = target(cfg).path("game").path(gameId).request(MediaType.APPLICATION_JSON).get()
|
||||||
|
val node = if response.getStatus == 200 then Some(response.readEntity(classOf[JsonNode])) else None
|
||||||
|
response.close()
|
||||||
|
node
|
||||||
|
}.getOrElse(None)
|
||||||
|
|
||||||
|
private def openGameStream(cfg: TournamentBotConfig, gameId: String): Option[InputStream] =
|
||||||
|
Try {
|
||||||
|
val response = authed(cfg, target(cfg).path("game").path(gameId).path("stream"))
|
||||||
|
.header("Accept", "application/x-ndjson")
|
||||||
|
.get()
|
||||||
|
if response.getStatus == 200 then Some(response.readEntity(classOf[InputStream]))
|
||||||
|
else { log.warnf("Game stream %s returned status %d", gameId, response.getStatus); response.close(); None }
|
||||||
|
}.getOrElse(None)
|
||||||
|
|
||||||
|
private def engine(cfg: TournamentBotConfig): Bot =
|
||||||
|
botController.getBot(cfg.difficulty).orElse(botController.getBot("medium")).get
|
||||||
|
|
||||||
|
private def target(cfg: TournamentBotConfig) =
|
||||||
|
client.target(cfg.serverUrl).path("api").path("tournament").path(cfg.tournamentId)
|
||||||
|
|
||||||
|
private def authed(cfg: TournamentBotConfig, t: jakarta.ws.rs.client.WebTarget) =
|
||||||
|
t.request(MediaType.APPLICATION_JSON).header("Authorization", s"Bearer ${cfg.token}")
|
||||||
|
|
||||||
|
private def parse(line: String): Option[JsonNode] =
|
||||||
|
val trimmed = line.trim
|
||||||
|
if trimmed.isEmpty then None else Try(objectMapper.readTree(trimmed)).toOption
|
||||||
|
|
||||||
|
private def forEachLine(stream: InputStream)(handle: String => Unit): Unit =
|
||||||
|
val reader = new BufferedReader(new InputStreamReader(stream))
|
||||||
|
Iterator
|
||||||
|
.continually(reader.readLine())
|
||||||
|
.map(Option(_))
|
||||||
|
.takeWhile(opt => opt.isDefined && running)
|
||||||
|
.flatten
|
||||||
|
.foreach { line =>
|
||||||
|
Try(handle(line)).failed.foreach(ex => log.warnf(ex, "Error handling stream line"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private def toUci(move: Move): String =
|
||||||
|
val base = s"${move.from}${move.to}"
|
||||||
|
move.moveType match
|
||||||
|
case MoveType.Promotion(piece) => base + promotionChar(piece)
|
||||||
|
case _ => base
|
||||||
|
|
||||||
|
private def promotionChar(piece: PromotionPiece): String =
|
||||||
|
piece match
|
||||||
|
case PromotionPiece.Knight => "n"
|
||||||
|
case PromotionPiece.Bishop => "b"
|
||||||
|
case PromotionPiece.Rook => "r"
|
||||||
|
case PromotionPiece.Queen => "q"
|
||||||
|
|
||||||
|
private def sleep(ms: Long): Unit = Try(Thread.sleep(ms))
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
MAJOR=0
|
MAJOR=0
|
||||||
MINOR=14
|
MINOR=17
|
||||||
PATCH=0
|
PATCH=0
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
plugins {
|
||||||
|
id("scala")
|
||||||
|
id("org.scoverage") version "8.1"
|
||||||
|
id("io.quarkus")
|
||||||
|
}
|
||||||
|
|
||||||
|
group = "de.nowchess"
|
||||||
|
version = "1.0-SNAPSHOT"
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val versions = rootProject.extra["VERSIONS"] as Map<String, String>
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
scala {
|
||||||
|
scalaVersion = versions["SCALA3"]!!
|
||||||
|
}
|
||||||
|
|
||||||
|
scoverage {
|
||||||
|
scoverageVersion.set(versions["SCOVERAGE"]!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<ScalaCompile> {
|
||||||
|
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
val quarkusPlatformGroupId: String by project
|
||||||
|
val quarkusPlatformArtifactId: String by project
|
||||||
|
val quarkusPlatformVersion: String by project
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":modules:api"))
|
||||||
|
implementation(project(":modules:security"))
|
||||||
|
|
||||||
|
runtimeOnly("io.quarkus:quarkus-jdbc-h2")
|
||||||
|
|
||||||
|
compileOnly("org.scala-lang:scala3-compiler_3") {
|
||||||
|
version {
|
||||||
|
strictly(versions["SCALA3"]!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
implementation("org.scala-lang:scala3-library_3") {
|
||||||
|
version {
|
||||||
|
strictly(versions["SCALA3"]!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
|
||||||
|
implementation("io.quarkus:quarkus-rest")
|
||||||
|
implementation("io.quarkus:quarkus-rest-jackson")
|
||||||
|
implementation("io.quarkus:quarkus-rest-client-jackson")
|
||||||
|
implementation("io.quarkus:quarkus-config-yaml")
|
||||||
|
implementation("io.quarkus:quarkus-arc")
|
||||||
|
implementation("io.quarkus:quarkus-hibernate-orm-panache")
|
||||||
|
implementation("io.quarkus:quarkus-jdbc-postgresql")
|
||||||
|
implementation("io.quarkus:quarkus-smallrye-jwt")
|
||||||
|
implementation("io.quarkus:quarkus-elytron-security-common")
|
||||||
|
implementation("io.quarkus:quarkus-smallrye-health")
|
||||||
|
implementation("io.quarkus:quarkus-logging-json")
|
||||||
|
implementation("io.quarkus:quarkus-micrometer")
|
||||||
|
implementation("io.quarkus:quarkus-micrometer-registry-prometheus")
|
||||||
|
implementation("io.quarkus:quarkus-opentelemetry")
|
||||||
|
implementation("io.quarkus:quarkus-smallrye-openapi")
|
||||||
|
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
|
||||||
|
implementation("io.quarkus:quarkus-redis-client")
|
||||||
|
|
||||||
|
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||||
|
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||||
|
testImplementation("io.quarkus:quarkus-smallrye-jwt-build")
|
||||||
|
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
|
||||||
|
testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
|
||||||
|
testImplementation("io.quarkus:quarkus-junit5")
|
||||||
|
testImplementation("io.quarkus:quarkus-junit5-mockito")
|
||||||
|
testImplementation("io.rest-assured:rest-assured")
|
||||||
|
testImplementation("io.quarkus:quarkus-jdbc-h2")
|
||||||
|
testImplementation("io.quarkus:quarkus-test-security")
|
||||||
|
|
||||||
|
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||||
|
}
|
||||||
|
|
||||||
|
configurations.matching { !it.name.startsWith("scoverage") }.configureEach {
|
||||||
|
resolutionStrategy.force("org.scala-lang:scala-library:${versions["SCALA_LIBRARY"]!!}")
|
||||||
|
}
|
||||||
|
configurations.scoverage {
|
||||||
|
resolutionStrategy.eachDependency {
|
||||||
|
if (requested.group == "org.scoverage" && requested.name.startsWith("scalac-scoverage-plugin_")) {
|
||||||
|
useTarget("${requested.group}:scalac-scoverage-plugin_2.13.16:2.3.0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<JavaCompile> {
|
||||||
|
options.encoding = "UTF-8"
|
||||||
|
options.compilerArgs.add("-parameters")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<Jar>().configureEach {
|
||||||
|
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.test {
|
||||||
|
useJUnitPlatform {
|
||||||
|
includeEngines("scalatest", "junit-jupiter")
|
||||||
|
testLogging {
|
||||||
|
events("passed", "skipped", "failed")
|
||||||
|
showStandardStreams = true
|
||||||
|
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finalizedBy(tasks.reportScoverage)
|
||||||
|
}
|
||||||
|
tasks.reportScoverage {
|
||||||
|
dependsOn(tasks.test)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.jar {
|
||||||
|
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
####
|
||||||
|
# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode
|
||||||
|
#
|
||||||
|
# Before building the container image run:
|
||||||
|
#
|
||||||
|
# ./gradlew build
|
||||||
|
#
|
||||||
|
# Then, build the image with:
|
||||||
|
#
|
||||||
|
# docker build -f src/main/docker/Dockerfile.jvm -t quarkus/tournament-jvm .
|
||||||
|
#
|
||||||
|
# Then run the container using:
|
||||||
|
#
|
||||||
|
# docker run -i --rm -p 8080:8080 quarkus/tournament-jvm
|
||||||
|
#
|
||||||
|
# This image uses the `run-java.sh` script to run the application.
|
||||||
|
# You can find more information about the UBI base runtime images and their configuration here:
|
||||||
|
# https://rh-openjdk.github.io/redhat-openjdk-containers/
|
||||||
|
###
|
||||||
|
FROM registry.access.redhat.com/ubi9/openjdk-21-runtime:1.24
|
||||||
|
|
||||||
|
ENV LANGUAGE='en_US:en'
|
||||||
|
|
||||||
|
|
||||||
|
# We make four distinct layers so if there are application changes the library layers can be re-used
|
||||||
|
COPY --chown=185 build/quarkus-app/lib/ /deployments/lib/
|
||||||
|
COPY --chown=185 build/quarkus-app/*.jar /deployments/
|
||||||
|
COPY --chown=185 build/quarkus-app/app/ /deployments/app/
|
||||||
|
COPY --chown=185 build/quarkus-app/quarkus/ /deployments/quarkus/
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
USER 185
|
||||||
|
ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager"
|
||||||
|
ENV JAVA_APP_JAR="/deployments/quarkus-run.jar"
|
||||||
|
|
||||||
|
ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
####
|
||||||
|
# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode
|
||||||
|
#
|
||||||
|
# Before building the container image run:
|
||||||
|
#
|
||||||
|
# ./gradlew build -Dquarkus.package.jar.type=legacy-jar
|
||||||
|
#
|
||||||
|
# Then, build the image with:
|
||||||
|
#
|
||||||
|
# docker build -f src/main/docker/Dockerfile.legacy-jar -t quarkus/tournament-legacy-jar .
|
||||||
|
#
|
||||||
|
# Then run the container using:
|
||||||
|
#
|
||||||
|
# docker run -i --rm -p 8080:8080 quarkus/tournament-legacy-jar
|
||||||
|
#
|
||||||
|
# This image uses the `run-java.sh` script to run the application.
|
||||||
|
# You can find more information about the UBI base runtime images and their configuration here:
|
||||||
|
# https://rh-openjdk.github.io/redhat-openjdk-containers/
|
||||||
|
###
|
||||||
|
FROM registry.access.redhat.com/ubi9/openjdk-21-runtime:1.24
|
||||||
|
|
||||||
|
ENV LANGUAGE='en_US:en'
|
||||||
|
|
||||||
|
|
||||||
|
COPY build/lib/* /deployments/lib/
|
||||||
|
COPY build/*-runner.jar /deployments/quarkus-run.jar
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
USER 185
|
||||||
|
ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager"
|
||||||
|
ENV JAVA_APP_JAR="/deployments/quarkus-run.jar"
|
||||||
|
|
||||||
|
ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ]
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
####
|
||||||
|
# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode.
|
||||||
|
#
|
||||||
|
# Before building the container image run:
|
||||||
|
#
|
||||||
|
# ./gradlew :modules:tournament:build -Dquarkus.native.enabled=true
|
||||||
|
#
|
||||||
|
# Then, build the image with:
|
||||||
|
#
|
||||||
|
# docker build -f modules/tournament/src/main/docker/Dockerfile.native -t quarkus/tournament .
|
||||||
|
#
|
||||||
|
# Then run the container using:
|
||||||
|
#
|
||||||
|
# docker run -i --rm -p 8080:8080 quarkus/tournament
|
||||||
|
#
|
||||||
|
# The `registry.access.redhat.com/ubi9/ubi-minimal:9.7` base image is based on UBI 9.
|
||||||
|
# To use UBI 8, switch to `quay.io/ubi8/ubi-minimal:8.10`.
|
||||||
|
###
|
||||||
|
FROM registry.access.redhat.com/ubi9/ubi-minimal:9.7
|
||||||
|
WORKDIR /work/
|
||||||
|
RUN chown 1001 /work \
|
||||||
|
&& chmod "g+rwX" /work \
|
||||||
|
&& chown 1001:root /work
|
||||||
|
COPY --chown=1001:root --chmod=0755 modules/tournament/build/*-runner /work/application
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
USER 1001
|
||||||
|
|
||||||
|
ENTRYPOINT ["./application", "-Dquarkus.http.host=0.0.0.0"]
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
####
|
||||||
|
# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode.
|
||||||
|
# It uses a micro base image, tuned for Quarkus native executables.
|
||||||
|
# It reduces the size of the resulting container image.
|
||||||
|
# Check https://quarkus.io/guides/quarkus-runtime-base-image for further information about this image.
|
||||||
|
#
|
||||||
|
# Before building the container image run:
|
||||||
|
#
|
||||||
|
# ./gradlew build -Dquarkus.native.enabled=true
|
||||||
|
#
|
||||||
|
# Then, build the image with:
|
||||||
|
#
|
||||||
|
# docker build -f src/main/docker/Dockerfile.native-micro -t quarkus/tournament .
|
||||||
|
#
|
||||||
|
# Then run the container using:
|
||||||
|
#
|
||||||
|
# docker run -i --rm -p 8080:8080 quarkus/tournament
|
||||||
|
#
|
||||||
|
# The `quay.io/quarkus/ubi9-quarkus-micro-image:2.0` base image is based on UBI 9.
|
||||||
|
# To use UBI 8, switch to `quay.io/quarkus/quarkus-micro-image:2.0`.
|
||||||
|
###
|
||||||
|
FROM quay.io/quarkus/ubi9-quarkus-micro-image:2.0
|
||||||
|
WORKDIR /work/
|
||||||
|
RUN chown 1001 /work \
|
||||||
|
&& chmod "g+rwX" /work \
|
||||||
|
&& chown 1001:root /work
|
||||||
|
COPY --chown=1001:root --chmod=0755 build/*-runner /work/application
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
USER 1001
|
||||||
|
|
||||||
|
ENTRYPOINT ["./application", "-Dquarkus.http.host=0.0.0.0"]
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
quarkus:
|
||||||
|
http:
|
||||||
|
port: 8088
|
||||||
|
application:
|
||||||
|
name: nowchess-tournament
|
||||||
|
redis:
|
||||||
|
hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
|
||||||
|
rest-client:
|
||||||
|
core-service:
|
||||||
|
url: http://localhost:8080
|
||||||
|
datasource:
|
||||||
|
db-kind: h2
|
||||||
|
username: sa
|
||||||
|
password: ""
|
||||||
|
jdbc:
|
||||||
|
url: jdbc:h2:mem:nowchess-tournament;DB_CLOSE_DELAY=-1
|
||||||
|
hibernate-orm:
|
||||||
|
schema-management:
|
||||||
|
strategy: drop-and-create
|
||||||
|
smallrye-jwt:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
nowchess:
|
||||||
|
redis:
|
||||||
|
host: ${REDIS_HOST:localhost}
|
||||||
|
port: ${REDIS_PORT:6379}
|
||||||
|
prefix: ${REDIS_PREFIX:nowchess}
|
||||||
|
internal:
|
||||||
|
secret: ${INTERNAL_SECRET:123abc}
|
||||||
|
|
||||||
|
mp:
|
||||||
|
jwt:
|
||||||
|
verify:
|
||||||
|
publickey:
|
||||||
|
location: keys/public.pem
|
||||||
|
issuer: nowchess
|
||||||
|
|
||||||
|
"%deployed":
|
||||||
|
quarkus:
|
||||||
|
datasource:
|
||||||
|
db-kind: postgresql
|
||||||
|
username: ${DB_USER:nowchess}
|
||||||
|
password: ${DB_PASSWORD:nowchess}
|
||||||
|
jdbc:
|
||||||
|
url: ${DB_URL:jdbc:postgresql://localhost:5432/nowchess}
|
||||||
|
hibernate-orm:
|
||||||
|
schema-management:
|
||||||
|
strategy: update
|
||||||
|
|
||||||
|
"%test":
|
||||||
|
quarkus:
|
||||||
|
datasource:
|
||||||
|
jdbc:
|
||||||
|
url: jdbc:h2:mem:nowchess-tournament;DB_CLOSE_DELAY=-1
|
||||||
|
hibernate-orm:
|
||||||
|
schema-management:
|
||||||
|
strategy: drop-and-create
|
||||||
|
arc:
|
||||||
|
exclude-types: de.nowchess.tournament.redis.GameResultStreamListener
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxDsnsCAl0vQx7Vu9CLDZ
|
||||||
|
g0SG05NgUzu9T+3DTEaHGq60T2uriO8BenwyvsF3BnDqTbKf4voohZ1DNfzdbT1J
|
||||||
|
Fj8B62FrDmxcO+sp1/b5HUCJP6y2uSRCmzOHe5k7Pk1IEi72FgBpKXSRkFibRlVf
|
||||||
|
634g7mgsPZAQ9PJEsv4Qvm05T9L6+Gmq6N3bMVLKRXs4RhDhaFbYH9GtUg1eI0yH
|
||||||
|
YjGyRfqzW/nqVMstOLHt8CuPouq4p7eMzeDH3YHkxPm4GG5foCXMOd2DZrW0SCcr
|
||||||
|
7dhFeNVWzQ2m53eOhBzNQX+v3pgjVStsePhBRt2LyGfwkNzmqDgqWsMzSHRMY+cn
|
||||||
|
WQIDAQAB
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package de.nowchess.account.client
|
package de.nowchess.tournament.client
|
||||||
|
|
||||||
import de.nowchess.security.{InternalClientHeadersFactory, InternalSecretClientFilter}
|
import de.nowchess.security.{InternalClientHeadersFactory, InternalSecretClientFilter}
|
||||||
import jakarta.ws.rs.*
|
import jakarta.ws.rs.*
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package de.nowchess.tournament.config
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import com.fasterxml.jackson.module.scala.DefaultScalaModule
|
||||||
|
import io.quarkus.jackson.ObjectMapperCustomizer
|
||||||
|
import jakarta.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class JacksonConfig extends ObjectMapperCustomizer:
|
||||||
|
def customize(objectMapper: ObjectMapper): Unit =
|
||||||
|
objectMapper.registerModule(DefaultScalaModule)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package de.nowchess.tournament.config
|
||||||
|
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import org.eclipse.microprofile.config.inject.ConfigProperty
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
class RedisConfig:
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
@ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess")
|
||||||
|
var prefix: String = uninitialized
|
||||||
|
// scalafix:on DisableSyntax.var
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package de.nowchess.tournament.domain
|
||||||
|
|
||||||
|
import jakarta.persistence.*
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "tournaments")
|
||||||
|
class Tournament:
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
@Id
|
||||||
|
var id: String = uninitialized
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
var fullName: String = uninitialized
|
||||||
|
|
||||||
|
var nbRounds: Int = 0
|
||||||
|
var clockLimit: Int = 0
|
||||||
|
var clockIncrement: Int = 0
|
||||||
|
var rated: Boolean = true
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
var status: String = "created"
|
||||||
|
|
||||||
|
var currentRound: Int = 0
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
var createdBy: String = uninitialized
|
||||||
|
|
||||||
|
var startsAt: Instant = uninitialized
|
||||||
|
var winnerId: String = uninitialized
|
||||||
|
var winnerName: String = uninitialized
|
||||||
|
// scalafix:on
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
package de.nowchess.tournament.domain
|
||||||
|
|
||||||
|
import jakarta.persistence.*
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "tournament_pairings")
|
||||||
|
class TournamentPairing:
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
var id: UUID = uninitialized
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
var tournamentId: String = uninitialized
|
||||||
|
|
||||||
|
var round: Int = 0
|
||||||
|
var whiteId: String = uninitialized
|
||||||
|
var whiteName: String = uninitialized
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
var blackId: String = uninitialized
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
var blackName: String = uninitialized
|
||||||
|
|
||||||
|
var gameId: String = uninitialized
|
||||||
|
var winner: String = uninitialized
|
||||||
|
var moveList: String = uninitialized
|
||||||
|
// scalafix:on
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
package de.nowchess.tournament.domain
|
||||||
|
|
||||||
|
import jakarta.persistence.*
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "tournament_participants")
|
||||||
|
class TournamentParticipant:
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
var id: UUID = uninitialized
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
var tournamentId: String = uninitialized
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
var botId: String = uninitialized
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
var botName: String = uninitialized
|
||||||
|
|
||||||
|
var points: Double = 0.0
|
||||||
|
var tieBreak: Double = 0.0
|
||||||
|
var nbGames: Int = 0
|
||||||
|
var wins: Int = 0
|
||||||
|
var draws: Int = 0
|
||||||
|
var losses: Int = 0
|
||||||
|
var byeCount: Int = 0
|
||||||
|
// scalafix:on
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package de.nowchess.tournament.dto
|
||||||
|
|
||||||
|
case class BotRef(id: String, name: String)
|
||||||
|
|
||||||
|
case class Clock(limit: Int, increment: Int)
|
||||||
|
|
||||||
|
case class Variant(key: String, name: String)
|
||||||
|
|
||||||
|
case class CreateTournamentForm(
|
||||||
|
name: String,
|
||||||
|
nbRounds: Int,
|
||||||
|
clockLimit: Int,
|
||||||
|
clockIncrement: Int,
|
||||||
|
rated: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
case class ResultDto(
|
||||||
|
rank: Int,
|
||||||
|
points: Double,
|
||||||
|
tieBreak: Double,
|
||||||
|
bot: BotRef,
|
||||||
|
nbGames: Int,
|
||||||
|
wins: Int,
|
||||||
|
draws: Int,
|
||||||
|
losses: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
case class Standing(page: Int, players: List[ResultDto])
|
||||||
|
|
||||||
|
case class TournamentDto(
|
||||||
|
id: String,
|
||||||
|
fullName: String,
|
||||||
|
clock: Clock,
|
||||||
|
variant: Variant,
|
||||||
|
rated: Boolean,
|
||||||
|
nbPlayers: Int,
|
||||||
|
nbRounds: Int,
|
||||||
|
createdBy: String,
|
||||||
|
startsAt: Option[String],
|
||||||
|
status: String,
|
||||||
|
round: Int,
|
||||||
|
standing: Standing,
|
||||||
|
winner: Option[BotRef],
|
||||||
|
)
|
||||||
|
|
||||||
|
case class TournamentListDto(
|
||||||
|
created: List[TournamentDto],
|
||||||
|
started: List[TournamentDto],
|
||||||
|
finished: List[TournamentDto],
|
||||||
|
)
|
||||||
|
|
||||||
|
case class PairingDto(
|
||||||
|
id: String,
|
||||||
|
round: Int,
|
||||||
|
white: Option[BotRef],
|
||||||
|
black: BotRef,
|
||||||
|
gameId: Option[String],
|
||||||
|
winner: Option[String],
|
||||||
|
)
|
||||||
|
|
||||||
|
case class GameExportDto(
|
||||||
|
id: String,
|
||||||
|
round: Int,
|
||||||
|
white: BotRef,
|
||||||
|
black: BotRef,
|
||||||
|
winner: Option[String],
|
||||||
|
moves: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
case class RoundPairingsDto(round: Int, pairings: List[PairingDto])
|
||||||
|
|
||||||
|
case class ErrorDto(error: String)
|
||||||
|
|
||||||
|
case class OkDto(ok: Boolean = true)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package de.nowchess.tournament.error
|
||||||
|
|
||||||
|
enum TournamentError(val message: String):
|
||||||
|
case NotFound(id: String) extends TournamentError(s"Tournament $id not found")
|
||||||
|
case NotDirector extends TournamentError("Not the tournament director")
|
||||||
|
case WrongStatus(expected: String) extends TournamentError(s"Tournament must be in $expected status")
|
||||||
|
case AlreadyJoined extends TournamentError("Already joined this tournament")
|
||||||
|
case NotJoined extends TournamentError("Not joined this tournament")
|
||||||
|
case NotEnoughParticipants extends TournamentError("Need at least 2 participants to start")
|
||||||
|
case NotABot extends TournamentError("Only bot accounts can join tournaments")
|
||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
package de.nowchess.tournament.redis
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import de.nowchess.api.dto.GameWritebackEventDto
|
||||||
|
import de.nowchess.tournament.config.RedisConfig
|
||||||
|
import de.nowchess.tournament.service.TournamentService
|
||||||
|
import io.quarkus.redis.datasource.RedisDataSource
|
||||||
|
import io.quarkus.redis.datasource.stream.{StreamMessage, XGroupCreateArgs, XReadGroupArgs}
|
||||||
|
import io.quarkus.runtime.Startup
|
||||||
|
import jakarta.annotation.PostConstruct
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import org.eclipse.microprofile.context.ManagedExecutor
|
||||||
|
import org.jboss.logging.Logger
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
import scala.jdk.CollectionConverters.*
|
||||||
|
import scala.util.{Failure, Success, Try}
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Startup
|
||||||
|
@ApplicationScoped
|
||||||
|
class GameResultStreamListener:
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
@Inject var redis: RedisDataSource = uninitialized
|
||||||
|
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||||
|
@Inject var tournamentService: TournamentService = uninitialized
|
||||||
|
@Inject var executor: ManagedExecutor = uninitialized
|
||||||
|
@Inject var redisConfig: RedisConfig = uninitialized
|
||||||
|
// scalafix:on
|
||||||
|
|
||||||
|
private val log = Logger.getLogger(classOf[GameResultStreamListener])
|
||||||
|
private val groupName = "tournament-result"
|
||||||
|
private val consumerId = UUID.randomUUID().toString
|
||||||
|
|
||||||
|
private def streamKey = s"${redisConfig.prefix}:game-writeback"
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
def startListening(): Unit =
|
||||||
|
createGroupIfAbsent()
|
||||||
|
executor.submit(
|
||||||
|
new Runnable:
|
||||||
|
def run(): Unit = pollLoop(),
|
||||||
|
)
|
||||||
|
log.infof("Tournament result listener started (consumer=%s)", consumerId)
|
||||||
|
|
||||||
|
private def createGroupIfAbsent(): Unit =
|
||||||
|
Try(redis.stream(classOf[String]).xgroupCreate(streamKey, groupName, "0", new XGroupCreateArgs().mkstream())) match
|
||||||
|
case Failure(ex) if Option(ex.getMessage).exists(_.contains("BUSYGROUP")) => ()
|
||||||
|
case Failure(ex) => log.warnf(ex, "Failed to create consumer group")
|
||||||
|
case Success(_) => ()
|
||||||
|
|
||||||
|
private def pollLoop(): Unit =
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
var running = true
|
||||||
|
// scalafix:on DisableSyntax.var
|
||||||
|
while running do
|
||||||
|
Try {
|
||||||
|
val messages = redis
|
||||||
|
.stream(classOf[String])
|
||||||
|
.xreadgroup(
|
||||||
|
groupName,
|
||||||
|
consumerId,
|
||||||
|
streamKey,
|
||||||
|
">",
|
||||||
|
new XReadGroupArgs().count(10).block(java.time.Duration.ofSeconds(2)),
|
||||||
|
)
|
||||||
|
Option(messages).foreach(_.forEach(msg => handleMessage(msg)))
|
||||||
|
} match
|
||||||
|
case Failure(ex) if isInterrupted(ex) =>
|
||||||
|
Thread.currentThread().interrupt()
|
||||||
|
running = false
|
||||||
|
case Failure(ex) => log.warnf(ex, "Error in result poll loop")
|
||||||
|
case Success(_) => ()
|
||||||
|
|
||||||
|
private def isInterrupted(ex: Throwable): Boolean =
|
||||||
|
ex match
|
||||||
|
case _: InterruptedException => true
|
||||||
|
case _ =>
|
||||||
|
Option(ex.getCause) match
|
||||||
|
case Some(_: InterruptedException) => true
|
||||||
|
case _ => false
|
||||||
|
|
||||||
|
private def handleMessage(msg: StreamMessage[String, String, String]): Unit =
|
||||||
|
val json = msg.payload().get("data")
|
||||||
|
Try(objectMapper.readValue(json, classOf[GameWritebackEventDto])) match
|
||||||
|
case Failure(ex) =>
|
||||||
|
log.errorf(ex, "Unparseable game result event: %s", json)
|
||||||
|
ack(msg.id())
|
||||||
|
case Success(event) =>
|
||||||
|
if event.result.isDefined then
|
||||||
|
Try(tournamentService.handleGameResult(event.gameId, event.result.get, event.pgn)) match
|
||||||
|
case Failure(ex) => log.errorf(ex, "Failed to handle game result for %s", event.gameId)
|
||||||
|
case Success(_) => ()
|
||||||
|
ack(msg.id())
|
||||||
|
|
||||||
|
private def ack(id: String): Unit =
|
||||||
|
Try(redis.stream(classOf[String]).xack(streamKey, groupName, id)) match
|
||||||
|
case Failure(ex) => log.warnf(ex, "Failed to ack message %s", id)
|
||||||
|
case Success(_) => ()
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
package de.nowchess.tournament.repository
|
||||||
|
|
||||||
|
import de.nowchess.tournament.domain.TournamentPairing
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import jakarta.persistence.EntityManager
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
import scala.jdk.CollectionConverters.*
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
class PairingRepository:
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
var em: EntityManager = uninitialized
|
||||||
|
// scalafix:on
|
||||||
|
|
||||||
|
def findByTournamentId(tournamentId: String): List[TournamentPairing] =
|
||||||
|
em.createQuery("FROM TournamentPairing WHERE tournamentId = :tid", classOf[TournamentPairing])
|
||||||
|
.setParameter("tid", tournamentId)
|
||||||
|
.getResultList
|
||||||
|
.asScala
|
||||||
|
.toList
|
||||||
|
|
||||||
|
def findByTournamentIdAndRound(tournamentId: String, round: Int): List[TournamentPairing] =
|
||||||
|
em.createQuery(
|
||||||
|
"FROM TournamentPairing WHERE tournamentId = :tid AND round = :round",
|
||||||
|
classOf[TournamentPairing],
|
||||||
|
).setParameter("tid", tournamentId)
|
||||||
|
.setParameter("round", round)
|
||||||
|
.getResultList
|
||||||
|
.asScala
|
||||||
|
.toList
|
||||||
|
|
||||||
|
def findByGameId(gameId: String): Option[TournamentPairing] =
|
||||||
|
em.createQuery("FROM TournamentPairing WHERE gameId = :gid", classOf[TournamentPairing])
|
||||||
|
.setParameter("gid", gameId)
|
||||||
|
.getResultList
|
||||||
|
.asScala
|
||||||
|
.headOption
|
||||||
|
|
||||||
|
def persist(p: TournamentPairing): TournamentPairing =
|
||||||
|
if Option(p.id).isEmpty then
|
||||||
|
em.persist(p)
|
||||||
|
p
|
||||||
|
else em.merge(p)
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
package de.nowchess.tournament.repository
|
||||||
|
|
||||||
|
import de.nowchess.tournament.domain.TournamentParticipant
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import jakarta.persistence.EntityManager
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
import scala.jdk.CollectionConverters.*
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
class ParticipantRepository:
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
var em: EntityManager = uninitialized
|
||||||
|
// scalafix:on
|
||||||
|
|
||||||
|
def findByTournamentId(tournamentId: String): List[TournamentParticipant] =
|
||||||
|
em.createQuery("FROM TournamentParticipant WHERE tournamentId = :tid", classOf[TournamentParticipant])
|
||||||
|
.setParameter("tid", tournamentId)
|
||||||
|
.getResultList
|
||||||
|
.asScala
|
||||||
|
.toList
|
||||||
|
|
||||||
|
def findByTournamentIdAndBotId(tournamentId: String, botId: String): Option[TournamentParticipant] =
|
||||||
|
em.createQuery(
|
||||||
|
"FROM TournamentParticipant WHERE tournamentId = :tid AND botId = :bid",
|
||||||
|
classOf[TournamentParticipant],
|
||||||
|
).setParameter("tid", tournamentId)
|
||||||
|
.setParameter("bid", botId)
|
||||||
|
.getResultList
|
||||||
|
.asScala
|
||||||
|
.headOption
|
||||||
|
|
||||||
|
def persist(p: TournamentParticipant): TournamentParticipant =
|
||||||
|
if Option(p.id).isEmpty then
|
||||||
|
em.persist(p)
|
||||||
|
p
|
||||||
|
else em.merge(p)
|
||||||
|
|
||||||
|
def delete(p: TournamentParticipant): Unit =
|
||||||
|
em.remove(if em.contains(p) then p else em.merge(p))
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
package de.nowchess.tournament.repository
|
||||||
|
|
||||||
|
import de.nowchess.tournament.domain.Tournament
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import jakarta.persistence.EntityManager
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
import scala.jdk.CollectionConverters.*
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
class TournamentRepository:
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
var em: EntityManager = uninitialized
|
||||||
|
// scalafix:on
|
||||||
|
|
||||||
|
def findOptById(id: String): Option[Tournament] =
|
||||||
|
Option(em.find(classOf[Tournament], id))
|
||||||
|
|
||||||
|
def findByStatus(status: String): List[Tournament] =
|
||||||
|
em.createQuery("FROM Tournament WHERE status = :status", classOf[Tournament])
|
||||||
|
.setParameter("status", status)
|
||||||
|
.getResultList
|
||||||
|
.asScala
|
||||||
|
.toList
|
||||||
|
|
||||||
|
def persist(t: Tournament): Tournament =
|
||||||
|
if em.contains(t) then t else em.merge(t)
|
||||||
|
|
||||||
|
def delete(t: Tournament): Unit =
|
||||||
|
val managed = if em.contains(t) then t else em.merge(t)
|
||||||
|
em.remove(managed)
|
||||||
+188
@@ -0,0 +1,188 @@
|
|||||||
|
package de.nowchess.tournament.resource
|
||||||
|
|
||||||
|
import de.nowchess.tournament.dto.*
|
||||||
|
import de.nowchess.tournament.error.TournamentError
|
||||||
|
import de.nowchess.tournament.service.{TournamentService, TournamentStreamManager}
|
||||||
|
import io.smallrye.mutiny.Multi
|
||||||
|
import jakarta.annotation.security.{PermitAll, RolesAllowed}
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import jakarta.ws.rs.*
|
||||||
|
import jakarta.ws.rs.core.{Context, HttpHeaders, MediaType, Response}
|
||||||
|
import org.eclipse.microprofile.jwt.JsonWebToken
|
||||||
|
import org.jboss.logging.Logger
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
|
||||||
|
@Path("/api/tournament")
|
||||||
|
@ApplicationScoped
|
||||||
|
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||||
|
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||||
|
class TournamentResource:
|
||||||
|
|
||||||
|
private val log = Logger.getLogger(classOf[TournamentResource])
|
||||||
|
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
@Inject var tournamentService: TournamentService = uninitialized
|
||||||
|
@Inject var streamManager: TournamentStreamManager = uninitialized
|
||||||
|
@Inject var jwt: JsonWebToken = uninitialized
|
||||||
|
// scalafix:on
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@PermitAll
|
||||||
|
def list(): Response =
|
||||||
|
val (created, started, finished) = tournamentService.list()
|
||||||
|
val dto = TournamentListDto(
|
||||||
|
created = created.map(t => tournamentService.toDto(t)),
|
||||||
|
started = started.map(t => tournamentService.toDto(t)),
|
||||||
|
finished = finished.map(t => tournamentService.toDto(t)),
|
||||||
|
)
|
||||||
|
Response.ok(dto).build()
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@RolesAllowed(Array("**"))
|
||||||
|
@Consumes(Array(MediaType.APPLICATION_FORM_URLENCODED))
|
||||||
|
def create(
|
||||||
|
@FormParam("name") name: String,
|
||||||
|
@FormParam("nbRounds") nbRounds: Int,
|
||||||
|
@FormParam("clockLimit") clockLimit: Int,
|
||||||
|
@FormParam("clockIncrement") clockIncrement: Int,
|
||||||
|
@FormParam("rated") @DefaultValue("true") rated: Boolean,
|
||||||
|
): Response =
|
||||||
|
val userId = Option(jwt.getSubject).getOrElse("")
|
||||||
|
val form = CreateTournamentForm(name, nbRounds, clockLimit, clockIncrement, rated)
|
||||||
|
val t = tournamentService.create(userId, form)
|
||||||
|
Response.status(Response.Status.CREATED).entity(tournamentService.toDto(t)).build()
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/{id}")
|
||||||
|
@PermitAll
|
||||||
|
def get(@PathParam("id") id: String): Response =
|
||||||
|
tournamentService.get(id) match
|
||||||
|
case None => Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(s"Tournament $id not found")).build()
|
||||||
|
case Some(t) =>
|
||||||
|
val standings = tournamentService.getStandings(id)
|
||||||
|
Response.ok(tournamentService.toDto(t, standings)).build()
|
||||||
|
|
||||||
|
@DELETE
|
||||||
|
@Path("/{id}")
|
||||||
|
@RolesAllowed(Array("**"))
|
||||||
|
def terminate(@PathParam("id") id: String): Response =
|
||||||
|
val userId = Option(jwt.getSubject).getOrElse("")
|
||||||
|
tournamentService.terminate(id, userId) match
|
||||||
|
case Right(_) => Response.noContent().build()
|
||||||
|
case Left(error) => errorResponse(error)
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/{id}/start")
|
||||||
|
@RolesAllowed(Array("**"))
|
||||||
|
def start(@PathParam("id") id: String): Response =
|
||||||
|
val userId = Option(jwt.getSubject).getOrElse("")
|
||||||
|
tournamentService.start(id, userId) match
|
||||||
|
case Right(t) => Response.ok(tournamentService.toDto(t)).build()
|
||||||
|
case Left(error) => errorResponse(error)
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/{id}/join")
|
||||||
|
@RolesAllowed(Array("**"))
|
||||||
|
def join(@PathParam("id") id: String): Response =
|
||||||
|
val tokenType = Option(jwt.getClaim[AnyRef]("type")).map(_.toString).getOrElse("")
|
||||||
|
if tokenType != "bot" then
|
||||||
|
Response.status(Response.Status.FORBIDDEN).entity(ErrorDto("Only bots can join tournaments")).build()
|
||||||
|
else
|
||||||
|
val botId = Option(jwt.getSubject).getOrElse("")
|
||||||
|
val botName = Option(jwt.getClaim[AnyRef]("name")).map(_.toString).getOrElse(botId)
|
||||||
|
tournamentService.join(id, botId, botName) match
|
||||||
|
case Right(_) => Response.ok(OkDto()).build()
|
||||||
|
case Left(error) => errorResponse(error)
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/{id}/withdraw")
|
||||||
|
@RolesAllowed(Array("**"))
|
||||||
|
def withdraw(@PathParam("id") id: String): Response =
|
||||||
|
val tokenType = Option(jwt.getClaim[AnyRef]("type")).map(_.toString).getOrElse("")
|
||||||
|
if tokenType != "bot" then
|
||||||
|
Response.status(Response.Status.FORBIDDEN).entity(ErrorDto("Only bots can withdraw")).build()
|
||||||
|
else
|
||||||
|
val botId = Option(jwt.getSubject).getOrElse("")
|
||||||
|
tournamentService.withdraw(id, botId) match
|
||||||
|
case Right(_) => Response.ok(OkDto()).build()
|
||||||
|
case Left(error) => errorResponse(error)
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/{id}/results")
|
||||||
|
@Produces(Array("application/x-ndjson"))
|
||||||
|
@PermitAll
|
||||||
|
def results(
|
||||||
|
@PathParam("id") id: String,
|
||||||
|
@QueryParam("nb") @DefaultValue("100") nb: Int,
|
||||||
|
): Response =
|
||||||
|
tournamentService.get(id) match
|
||||||
|
case None => Response.status(Response.Status.NOT_FOUND).entity("").build()
|
||||||
|
case Some(_) =>
|
||||||
|
val ndjson = tournamentService
|
||||||
|
.getResults(id)
|
||||||
|
.take(nb)
|
||||||
|
.map { r =>
|
||||||
|
s"""{"rank":${r.rank},"points":${r.points},"tieBreak":${r.tieBreak},"bot":{"id":"${r.bot.id}","name":"${r.bot.name}"},"nbGames":${r.nbGames},"wins":${r.wins},"draws":${r.draws},"losses":${r.losses}}"""
|
||||||
|
}
|
||||||
|
.mkString("\n")
|
||||||
|
Response.ok(ndjson).`type`("application/x-ndjson").build()
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/{id}/round/{round}")
|
||||||
|
@PermitAll
|
||||||
|
def roundPairings(@PathParam("id") id: String, @PathParam("round") round: Int): Response =
|
||||||
|
tournamentService.get(id) match
|
||||||
|
case None => Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(s"Tournament $id not found")).build()
|
||||||
|
case Some(_) =>
|
||||||
|
val pairings = tournamentService.getPairings(id, round)
|
||||||
|
Response.ok(RoundPairingsDto(round, pairings)).build()
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/{id}/export/games")
|
||||||
|
@PermitAll
|
||||||
|
@Produces(Array(MediaType.APPLICATION_JSON, MediaType.WILDCARD, "application/x-ndjson", "application/x-chess-pgn"))
|
||||||
|
def exportGames(@PathParam("id") id: String, @Context headers: HttpHeaders): Response =
|
||||||
|
tournamentService.get(id) match
|
||||||
|
case None => Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(s"Tournament $id not found")).build()
|
||||||
|
case Some(_) =>
|
||||||
|
val acceptHeader = Option(headers.getHeaderString("Accept")).getOrElse("")
|
||||||
|
val pairings = tournamentService.getAllPairings(id)
|
||||||
|
if acceptHeader.contains("application/x-ndjson") then
|
||||||
|
val ndjson = pairings
|
||||||
|
.filter(p => Option(p.whiteId).isDefined && Option(p.gameId).isDefined)
|
||||||
|
.map { p =>
|
||||||
|
val winner = Option(p.winner).map(w => s""""$w"""").getOrElse("null")
|
||||||
|
val moves = Option(p.moveList).getOrElse("")
|
||||||
|
s"""{"id":"${p.gameId}","round":${p.round},"white":{"id":"${p.whiteId}","name":"${p.whiteName}"},"black":{"id":"${p.blackId}","name":"${p.blackName}"},"winner":$winner,"moves":"$moves"}"""
|
||||||
|
}
|
||||||
|
.mkString("\n")
|
||||||
|
Response.ok(ndjson).`type`("application/x-ndjson").build()
|
||||||
|
else
|
||||||
|
val pgn = pairings.flatMap(p => Option(p.moveList)).mkString("\n\n")
|
||||||
|
Response.ok(pgn).`type`("application/x-chess-pgn").build()
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/{id}/stream")
|
||||||
|
@RolesAllowed(Array("**"))
|
||||||
|
@Produces(Array("application/x-ndjson"))
|
||||||
|
def stream(@PathParam("id") id: String): Multi[String] =
|
||||||
|
tournamentService.get(id) match
|
||||||
|
case None => Multi.createFrom().failure(new NotFoundException(s"Tournament $id not found"))
|
||||||
|
case Some(_) =>
|
||||||
|
val botId = Option(jwt.getSubject).getOrElse("")
|
||||||
|
Multi.createFrom().emitter[String] { emitter =>
|
||||||
|
streamManager.register(id, botId, emitter)
|
||||||
|
emitter.onTermination(() => streamManager.unregister(id, botId, emitter))
|
||||||
|
}
|
||||||
|
|
||||||
|
private def errorResponse(error: TournamentError): Response =
|
||||||
|
val status = error match
|
||||||
|
case TournamentError.NotFound(_) => Response.Status.NOT_FOUND
|
||||||
|
case TournamentError.NotDirector => Response.Status.FORBIDDEN
|
||||||
|
case TournamentError.NotABot => Response.Status.FORBIDDEN
|
||||||
|
case TournamentError.WrongStatus(_) => Response.Status.CONFLICT
|
||||||
|
case TournamentError.AlreadyJoined => Response.Status.CONFLICT
|
||||||
|
case TournamentError.NotJoined => Response.Status.CONFLICT
|
||||||
|
case TournamentError.NotEnoughParticipants => Response.Status.CONFLICT
|
||||||
|
Response.status(status).entity(ErrorDto(error.message)).build()
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
package de.nowchess.tournament.service
|
||||||
|
|
||||||
|
import de.nowchess.tournament.domain.{TournamentPairing, TournamentParticipant}
|
||||||
|
import java.util.concurrent.ThreadLocalRandom
|
||||||
|
|
||||||
|
object SwissPairingService:
|
||||||
|
|
||||||
|
def computePairings(
|
||||||
|
participants: List[TournamentParticipant],
|
||||||
|
pastPairings: List[TournamentPairing],
|
||||||
|
): (List[(TournamentParticipant, TournamentParticipant)], Option[TournamentParticipant]) =
|
||||||
|
val sorted = sortParticipants(participants)
|
||||||
|
val (remaining, byeOpt) = extractByePlayer(sorted)
|
||||||
|
val pairs = buildPairs(remaining, pastPairings)
|
||||||
|
(pairs, byeOpt)
|
||||||
|
|
||||||
|
private def sortParticipants(participants: List[TournamentParticipant]): List[TournamentParticipant] =
|
||||||
|
participants.sortWith { (a, b) =>
|
||||||
|
if a.points != b.points then a.points > b.points
|
||||||
|
else if a.tieBreak != b.tieBreak then a.tieBreak > b.tieBreak
|
||||||
|
else a.botName < b.botName
|
||||||
|
}
|
||||||
|
|
||||||
|
private def extractByePlayer(
|
||||||
|
sorted: List[TournamentParticipant],
|
||||||
|
): (List[TournamentParticipant], Option[TournamentParticipant]) =
|
||||||
|
if sorted.size % 2 == 0 then (sorted, None)
|
||||||
|
else
|
||||||
|
val minByes = sorted.map(_.byeCount).min
|
||||||
|
val byeIndex = sorted.lastIndexWhere(_.byeCount == minByes)
|
||||||
|
val bye = sorted(byeIndex)
|
||||||
|
(sorted.filterNot(_ eq bye), Some(bye))
|
||||||
|
|
||||||
|
private def buildPairs(
|
||||||
|
players: List[TournamentParticipant],
|
||||||
|
pastPairings: List[TournamentPairing],
|
||||||
|
): List[(TournamentParticipant, TournamentParticipant)] =
|
||||||
|
val arr = players.toArray
|
||||||
|
resolveConflicts(arr, pastPairings)
|
||||||
|
arr
|
||||||
|
.grouped(2)
|
||||||
|
.flatMap {
|
||||||
|
case Array(a, b) => Some(assignColors(a, b))
|
||||||
|
case _ => None
|
||||||
|
}
|
||||||
|
.toList
|
||||||
|
|
||||||
|
private def resolveConflicts(arr: Array[TournamentParticipant], pastPairings: List[TournamentPairing]): Unit =
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
var i = 0
|
||||||
|
// scalafix:on DisableSyntax.var
|
||||||
|
while i < arr.length - 1 do
|
||||||
|
if havePlayedBefore(arr(i), arr(i + 1), pastPairings) && i + 2 < arr.length then
|
||||||
|
val tmp = arr(i + 1)
|
||||||
|
arr(i + 1) = arr(i + 2)
|
||||||
|
arr(i + 2) = tmp
|
||||||
|
i += 2
|
||||||
|
|
||||||
|
private def havePlayedBefore(
|
||||||
|
a: TournamentParticipant,
|
||||||
|
b: TournamentParticipant,
|
||||||
|
pastPairings: List[TournamentPairing],
|
||||||
|
): Boolean =
|
||||||
|
pastPairings.exists(p =>
|
||||||
|
(p.whiteId == a.botId && p.blackId == b.botId) ||
|
||||||
|
(p.whiteId == b.botId && p.blackId == a.botId),
|
||||||
|
)
|
||||||
|
|
||||||
|
private def assignColors(
|
||||||
|
a: TournamentParticipant,
|
||||||
|
b: TournamentParticipant,
|
||||||
|
): (TournamentParticipant, TournamentParticipant) =
|
||||||
|
if ThreadLocalRandom.current().nextBoolean() then (a, b) else (b, a)
|
||||||
+329
@@ -0,0 +1,329 @@
|
|||||||
|
package de.nowchess.tournament.service
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import de.nowchess.tournament.client.{CoreCreateGameRequest, CoreGameClient, CorePlayerInfo, CoreTimeControl}
|
||||||
|
import de.nowchess.tournament.config.RedisConfig
|
||||||
|
import de.nowchess.tournament.domain.{Tournament, TournamentPairing, TournamentParticipant}
|
||||||
|
import de.nowchess.tournament.dto.{
|
||||||
|
BotRef,
|
||||||
|
Clock,
|
||||||
|
CreateTournamentForm,
|
||||||
|
PairingDto,
|
||||||
|
ResultDto,
|
||||||
|
Standing,
|
||||||
|
TournamentDto,
|
||||||
|
Variant,
|
||||||
|
}
|
||||||
|
import de.nowchess.tournament.error.TournamentError
|
||||||
|
import de.nowchess.tournament.repository.{PairingRepository, ParticipantRepository, TournamentRepository}
|
||||||
|
import io.quarkus.redis.datasource.RedisDataSource
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import jakarta.transaction.Transactional
|
||||||
|
import org.eclipse.microprofile.rest.client.inject.RestClient
|
||||||
|
import org.jboss.logging.Logger
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
import scala.util.{Failure, Success, Try}
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
class TournamentService:
|
||||||
|
|
||||||
|
private val log = Logger.getLogger(classOf[TournamentService])
|
||||||
|
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
@Inject var tournamentRepository: TournamentRepository = uninitialized
|
||||||
|
@Inject var participantRepository: ParticipantRepository = uninitialized
|
||||||
|
@Inject var pairingRepository: PairingRepository = uninitialized
|
||||||
|
@Inject var streamManager: TournamentStreamManager = uninitialized
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
@RestClient
|
||||||
|
var coreGameClient: CoreGameClient = uninitialized
|
||||||
|
|
||||||
|
@Inject var redis: RedisDataSource = uninitialized
|
||||||
|
@Inject var redisConfig: RedisConfig = uninitialized
|
||||||
|
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||||
|
// scalafix:on
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
def create(createdBy: String, form: CreateTournamentForm): Tournament =
|
||||||
|
val t = new Tournament()
|
||||||
|
t.id = scala.util.Random.alphanumeric.take(6).mkString
|
||||||
|
t.fullName = form.name
|
||||||
|
t.nbRounds = form.nbRounds
|
||||||
|
t.clockLimit = form.clockLimit
|
||||||
|
t.clockIncrement = form.clockIncrement
|
||||||
|
t.rated = form.rated
|
||||||
|
t.status = "created"
|
||||||
|
t.currentRound = 0
|
||||||
|
t.createdBy = createdBy
|
||||||
|
tournamentRepository.persist(t)
|
||||||
|
t
|
||||||
|
|
||||||
|
def get(id: String): Option[Tournament] =
|
||||||
|
tournamentRepository.findOptById(id)
|
||||||
|
|
||||||
|
def list(): (List[Tournament], List[Tournament], List[Tournament]) =
|
||||||
|
(
|
||||||
|
tournamentRepository.findByStatus("created"),
|
||||||
|
tournamentRepository.findByStatus("started"),
|
||||||
|
tournamentRepository.findByStatus("finished"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
def terminate(id: String, userId: String): Either[TournamentError, Unit] =
|
||||||
|
for
|
||||||
|
t <- tournamentRepository.findOptById(id).toRight(TournamentError.NotFound(id))
|
||||||
|
_ <- Either.cond(t.createdBy == userId, (), TournamentError.NotDirector)
|
||||||
|
_ <- Either.cond(t.status == "created", (), TournamentError.WrongStatus("created"))
|
||||||
|
yield tournamentRepository.delete(t)
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
def join(id: String, botId: String, botName: String): Either[TournamentError, Unit] =
|
||||||
|
for
|
||||||
|
t <- tournamentRepository.findOptById(id).toRight(TournamentError.NotFound(id))
|
||||||
|
_ <- Either.cond(t.status == "created", (), TournamentError.WrongStatus("created"))
|
||||||
|
_ <- Either.cond(
|
||||||
|
participantRepository.findByTournamentIdAndBotId(id, botId).isEmpty,
|
||||||
|
(),
|
||||||
|
TournamentError.AlreadyJoined,
|
||||||
|
)
|
||||||
|
yield
|
||||||
|
val p = new TournamentParticipant()
|
||||||
|
p.tournamentId = id
|
||||||
|
p.botId = botId
|
||||||
|
p.botName = botName
|
||||||
|
participantRepository.persist(p)
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
def withdraw(id: String, botId: String): Either[TournamentError, Unit] =
|
||||||
|
for
|
||||||
|
t <- tournamentRepository.findOptById(id).toRight(TournamentError.NotFound(id))
|
||||||
|
_ <- Either.cond(t.status == "created", (), TournamentError.WrongStatus("created"))
|
||||||
|
p <- participantRepository.findByTournamentIdAndBotId(id, botId).toRight(TournamentError.NotJoined)
|
||||||
|
yield participantRepository.delete(p)
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
def start(id: String, userId: String): Either[TournamentError, Tournament] =
|
||||||
|
for
|
||||||
|
t <- tournamentRepository.findOptById(id).toRight(TournamentError.NotFound(id))
|
||||||
|
_ <- Either.cond(t.createdBy == userId, (), TournamentError.NotDirector)
|
||||||
|
_ <- Either.cond(t.status == "created", (), TournamentError.WrongStatus("created"))
|
||||||
|
participants <- validateMinParticipants(id)
|
||||||
|
yield
|
||||||
|
t.status = "started"
|
||||||
|
t.currentRound = 1
|
||||||
|
t.startsAt = Instant.now()
|
||||||
|
tournamentRepository.persist(t)
|
||||||
|
streamManager.publish(t.id, """{"type":"tournamentStarted"}""")
|
||||||
|
startRound(t, 1, participants)
|
||||||
|
t
|
||||||
|
|
||||||
|
private def validateMinParticipants(id: String): Either[TournamentError, List[TournamentParticipant]] =
|
||||||
|
val ps = participantRepository.findByTournamentId(id)
|
||||||
|
Either.cond(ps.size >= 2, ps, TournamentError.NotEnoughParticipants)
|
||||||
|
|
||||||
|
private def startRound(t: Tournament, round: Int, participants: List[TournamentParticipant]): Unit =
|
||||||
|
val pastPairings = pairingRepository.findByTournamentId(t.id)
|
||||||
|
val (pairs, byeOpt) = SwissPairingService.computePairings(participants, pastPairings)
|
||||||
|
byeOpt.foreach(bye => createByePairing(t.id, round, bye))
|
||||||
|
pairs.foreach { case (white, black) => createRealPairing(t.id, round, white, black, t) }
|
||||||
|
streamManager.publish(t.id, s"""{"type":"roundStarted","round":$round}""")
|
||||||
|
|
||||||
|
private def createByePairing(tournamentId: String, round: Int, bye: TournamentParticipant): Unit =
|
||||||
|
val pairing = new TournamentPairing()
|
||||||
|
pairing.tournamentId = tournamentId
|
||||||
|
pairing.round = round
|
||||||
|
pairing.blackId = bye.botId
|
||||||
|
pairing.blackName = bye.botName
|
||||||
|
pairing.winner = "bye"
|
||||||
|
pairingRepository.persist(pairing)
|
||||||
|
bye.points += 0.5
|
||||||
|
bye.byeCount += 1
|
||||||
|
participantRepository.persist(bye)
|
||||||
|
|
||||||
|
private def createRealPairing(
|
||||||
|
tournamentId: String,
|
||||||
|
round: Int,
|
||||||
|
white: TournamentParticipant,
|
||||||
|
black: TournamentParticipant,
|
||||||
|
t: Tournament,
|
||||||
|
): Unit =
|
||||||
|
val tc = CoreTimeControl(Some(t.clockLimit), Some(t.clockIncrement), None)
|
||||||
|
val req = CoreCreateGameRequest(
|
||||||
|
Some(CorePlayerInfo(white.botId, white.botName)),
|
||||||
|
Some(CorePlayerInfo(black.botId, black.botName)),
|
||||||
|
Some(tc),
|
||||||
|
Some("Authenticated"),
|
||||||
|
)
|
||||||
|
Try(coreGameClient.createGame(req)) match
|
||||||
|
case Failure(ex) => log.errorf(ex, "Failed to create game for round %d in tournament %s", round, tournamentId)
|
||||||
|
case Success(resp) =>
|
||||||
|
val pairing = new TournamentPairing()
|
||||||
|
pairing.tournamentId = tournamentId
|
||||||
|
pairing.round = round
|
||||||
|
pairing.whiteId = white.botId
|
||||||
|
pairing.whiteName = white.botName
|
||||||
|
pairing.blackId = black.botId
|
||||||
|
pairing.blackName = black.botName
|
||||||
|
pairing.gameId = resp.gameId
|
||||||
|
pairingRepository.persist(pairing)
|
||||||
|
streamManager.publishToBot(
|
||||||
|
tournamentId,
|
||||||
|
white.botId,
|
||||||
|
s"""{"type":"gameStart","round":$round,"gameId":"${resp.gameId}","color":"white"}""",
|
||||||
|
)
|
||||||
|
streamManager.publishToBot(
|
||||||
|
tournamentId,
|
||||||
|
black.botId,
|
||||||
|
s"""{"type":"gameStart","round":$round,"gameId":"${resp.gameId}","color":"black"}""",
|
||||||
|
)
|
||||||
|
publishBotGameStart(white.botName, resp.gameId, "white", white.botId)
|
||||||
|
publishBotGameStart(black.botName, resp.gameId, "black", black.botId)
|
||||||
|
|
||||||
|
private def publishBotGameStart(
|
||||||
|
botName: String,
|
||||||
|
gameId: String,
|
||||||
|
playingAs: String,
|
||||||
|
botAccountId: String,
|
||||||
|
): Unit =
|
||||||
|
val channel = s"${redisConfig.prefix}:bot:$botName:events"
|
||||||
|
val payload = objectMapper.writeValueAsString(
|
||||||
|
Map(
|
||||||
|
"type" -> "gameStart",
|
||||||
|
"gameId" -> gameId,
|
||||||
|
"playingAs" -> playingAs,
|
||||||
|
"difficulty" -> 1500,
|
||||||
|
"botAccountId" -> botAccountId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Try(redis.pubsub(classOf[String]).publish(channel, payload)) match
|
||||||
|
case Failure(ex) => log.warnf(ex, "Failed to publish gameStart to bot channel %s", channel)
|
||||||
|
case Success(_) => ()
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
def handleGameResult(gameId: String, result: String, pgn: String): Unit =
|
||||||
|
pairingRepository.findByGameId(gameId).foreach { pairing =>
|
||||||
|
val (winnerStr, wPts, bPts) = parseResult(result)
|
||||||
|
pairing.winner = winnerStr
|
||||||
|
pairing.moveList = pgn
|
||||||
|
pairingRepository.persist(pairing)
|
||||||
|
updateParticipantStats(pairing, wPts, bPts)
|
||||||
|
checkRoundCompletion(pairing.tournamentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private def parseResult(result: String): (String, Double, Double) = result match
|
||||||
|
case "1-0" => ("white", 1.0, 0.0)
|
||||||
|
case "0-1" => ("black", 0.0, 1.0)
|
||||||
|
case "1/2-1/2" => ("draw", 0.5, 0.5)
|
||||||
|
case _ => ("draw", 0.5, 0.5)
|
||||||
|
|
||||||
|
private def updateParticipantStats(pairing: TournamentPairing, wPts: Double, bPts: Double): Unit =
|
||||||
|
Option(pairing.whiteId).foreach { wId =>
|
||||||
|
participantRepository.findByTournamentIdAndBotId(pairing.tournamentId, wId).foreach { p =>
|
||||||
|
p.points += wPts
|
||||||
|
p.nbGames += 1
|
||||||
|
if wPts == 1.0 then p.wins += 1 else if wPts == 0.5 then p.draws += 1 else p.losses += 1
|
||||||
|
participantRepository.persist(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
participantRepository.findByTournamentIdAndBotId(pairing.tournamentId, pairing.blackId).foreach { p =>
|
||||||
|
p.points += bPts
|
||||||
|
p.nbGames += 1
|
||||||
|
if bPts == 1.0 then p.wins += 1 else if bPts == 0.5 then p.draws += 1 else p.losses += 1
|
||||||
|
participantRepository.persist(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
private def checkRoundCompletion(tournamentId: String): Unit =
|
||||||
|
tournamentRepository.findOptById(tournamentId).foreach { t =>
|
||||||
|
val roundPairings = pairingRepository.findByTournamentIdAndRound(tournamentId, t.currentRound)
|
||||||
|
val allDone = roundPairings.nonEmpty && roundPairings.forall(p => Option(p.winner).isDefined)
|
||||||
|
if allDone then onRoundComplete(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
private def onRoundComplete(t: Tournament): Unit =
|
||||||
|
streamManager.publish(t.id, s"""{"type":"roundFinished","round":${t.currentRound}}""")
|
||||||
|
recomputeBuchholz(t.id)
|
||||||
|
if t.currentRound >= t.nbRounds then finishTournament(t)
|
||||||
|
else
|
||||||
|
t.currentRound += 1
|
||||||
|
tournamentRepository.persist(t)
|
||||||
|
val participants = participantRepository.findByTournamentId(t.id)
|
||||||
|
startRound(t, t.currentRound, participants)
|
||||||
|
|
||||||
|
private def finishTournament(t: Tournament): Unit =
|
||||||
|
val participants = sortedStandings(participantRepository.findByTournamentId(t.id))
|
||||||
|
participants.headOption.foreach { winner =>
|
||||||
|
t.winnerId = winner.botId
|
||||||
|
t.winnerName = winner.botName
|
||||||
|
}
|
||||||
|
t.status = "finished"
|
||||||
|
tournamentRepository.persist(t)
|
||||||
|
val winnerInfo = participants.headOption
|
||||||
|
.map(w => s"""{"id":"${w.botId}","name":"${w.botName}"}""")
|
||||||
|
.getOrElse("null")
|
||||||
|
streamManager.publish(t.id, s"""{"type":"tournamentFinished","winner":$winnerInfo}""")
|
||||||
|
|
||||||
|
private def recomputeBuchholz(tournamentId: String): Unit =
|
||||||
|
val participants = participantRepository.findByTournamentId(tournamentId)
|
||||||
|
val pairings = pairingRepository.findByTournamentId(tournamentId)
|
||||||
|
val pointsById = participants.map(p => p.botId -> p.points).toMap
|
||||||
|
participants.foreach { p =>
|
||||||
|
val opponentIds = pairings.flatMap(pair =>
|
||||||
|
if pair.whiteId == p.botId then Some(pair.blackId)
|
||||||
|
else if pair.blackId == p.botId && Option(pair.whiteId).isDefined then Some(pair.whiteId)
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
p.tieBreak = opponentIds.flatMap(id => pointsById.get(id)).sum
|
||||||
|
participantRepository.persist(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
def getStandings(tournamentId: String): List[ResultDto] =
|
||||||
|
val participants = sortedStandings(participantRepository.findByTournamentId(tournamentId))
|
||||||
|
participants.zipWithIndex.map { case (p, idx) =>
|
||||||
|
ResultDto(idx + 1, p.points, p.tieBreak, BotRef(p.botId, p.botName), p.nbGames, p.wins, p.draws, p.losses)
|
||||||
|
}
|
||||||
|
|
||||||
|
def getPairings(tournamentId: String, round: Int): List[PairingDto] =
|
||||||
|
pairingRepository.findByTournamentIdAndRound(tournamentId, round).map(toPairingDto)
|
||||||
|
|
||||||
|
def getAllPairings(tournamentId: String): List[TournamentPairing] =
|
||||||
|
pairingRepository.findByTournamentId(tournamentId)
|
||||||
|
|
||||||
|
def getResults(tournamentId: String): List[ResultDto] = getStandings(tournamentId)
|
||||||
|
|
||||||
|
def toDto(t: Tournament, standings: List[ResultDto] = Nil): TournamentDto =
|
||||||
|
val participants = participantRepository.findByTournamentId(t.id)
|
||||||
|
TournamentDto(
|
||||||
|
id = t.id,
|
||||||
|
fullName = t.fullName,
|
||||||
|
clock = Clock(t.clockLimit, t.clockIncrement),
|
||||||
|
variant = Variant("standard", "Standard"),
|
||||||
|
rated = t.rated,
|
||||||
|
nbPlayers = participants.size,
|
||||||
|
nbRounds = t.nbRounds,
|
||||||
|
createdBy = t.createdBy,
|
||||||
|
startsAt = Option(t.startsAt).map(_.toString),
|
||||||
|
status = t.status,
|
||||||
|
round = t.currentRound,
|
||||||
|
standing = Standing(1, standings),
|
||||||
|
winner = Option(t.winnerId).map(id => BotRef(id, t.winnerName)),
|
||||||
|
)
|
||||||
|
|
||||||
|
private def toPairingDto(p: TournamentPairing): PairingDto =
|
||||||
|
PairingDto(
|
||||||
|
id = p.id.toString,
|
||||||
|
round = p.round,
|
||||||
|
white = Option(p.whiteId).map(id => BotRef(id, p.whiteName)),
|
||||||
|
black = BotRef(p.blackId, p.blackName),
|
||||||
|
gameId = Option(p.gameId),
|
||||||
|
winner = Option(p.winner),
|
||||||
|
)
|
||||||
|
|
||||||
|
private def sortedStandings(participants: List[TournamentParticipant]): List[TournamentParticipant] =
|
||||||
|
participants.sortWith { (a, b) =>
|
||||||
|
if a.points != b.points then a.points > b.points
|
||||||
|
else if a.tieBreak != b.tieBreak then a.tieBreak > b.tieBreak
|
||||||
|
else a.botName < b.botName
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package de.nowchess.tournament.service
|
||||||
|
|
||||||
|
import io.smallrye.mutiny.subscription.MultiEmitter
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import java.util.concurrent.{ConcurrentHashMap, CopyOnWriteArrayList}
|
||||||
|
import scala.jdk.CollectionConverters.*
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
class TournamentStreamManager:
|
||||||
|
|
||||||
|
private val tournamentEmitters = new ConcurrentHashMap[String, CopyOnWriteArrayList[MultiEmitter[? >: String]]]()
|
||||||
|
private val botEmitters = new ConcurrentHashMap[String, CopyOnWriteArrayList[MultiEmitter[? >: String]]]()
|
||||||
|
|
||||||
|
private def botKey(tournamentId: String, botId: String): String = s"${tournamentId}:${botId}"
|
||||||
|
|
||||||
|
def register(tournamentId: String, botId: String, emitter: MultiEmitter[? >: String]): Unit =
|
||||||
|
tournamentEmitters
|
||||||
|
.computeIfAbsent(tournamentId, _ => new CopyOnWriteArrayList[MultiEmitter[? >: String]]())
|
||||||
|
.add(emitter)
|
||||||
|
botEmitters
|
||||||
|
.computeIfAbsent(botKey(tournamentId, botId), _ => new CopyOnWriteArrayList[MultiEmitter[? >: String]]())
|
||||||
|
.add(emitter)
|
||||||
|
|
||||||
|
def unregister(tournamentId: String, botId: String, emitter: MultiEmitter[? >: String]): Unit =
|
||||||
|
Option(tournamentEmitters.get(tournamentId)).foreach(_.remove(emitter))
|
||||||
|
Option(botEmitters.get(botKey(tournamentId, botId))).foreach(_.remove(emitter))
|
||||||
|
|
||||||
|
def publish(tournamentId: String, eventJson: String): Unit =
|
||||||
|
Option(tournamentEmitters.get(tournamentId)).foreach { list =>
|
||||||
|
list.asScala.foreach(e => scala.util.Try(e.emit(eventJson)))
|
||||||
|
}
|
||||||
|
|
||||||
|
def publishToBot(tournamentId: String, botId: String, eventJson: String): Unit =
|
||||||
|
Option(botEmitters.get(botKey(tournamentId, botId))).foreach { list =>
|
||||||
|
list.asScala.foreach(e => scala.util.Try(e.emit(eventJson)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
quarkus:
|
||||||
|
http:
|
||||||
|
port: 8088
|
||||||
|
application:
|
||||||
|
name: nowchess-tournament
|
||||||
|
datasource:
|
||||||
|
db-kind: h2
|
||||||
|
username: sa
|
||||||
|
password: ""
|
||||||
|
jdbc:
|
||||||
|
url: "jdbc:h2:mem:nowchess-tournament;DB_CLOSE_DELAY=-1"
|
||||||
|
hibernate-orm:
|
||||||
|
schema-management:
|
||||||
|
strategy: drop-and-create
|
||||||
|
arc:
|
||||||
|
exclude-types: de.nowchess.tournament.redis.GameResultStreamListener
|
||||||
|
mp:
|
||||||
|
jwt:
|
||||||
|
verify:
|
||||||
|
publickey:
|
||||||
|
location: keys/test-public.pem
|
||||||
|
issuer: nowchess
|
||||||
|
smallrye:
|
||||||
|
jwt:
|
||||||
|
sign:
|
||||||
|
key:
|
||||||
|
location: keys/test-private.pem
|
||||||
|
nowchess:
|
||||||
|
internal:
|
||||||
|
secret: test-secret
|
||||||
|
auth:
|
||||||
|
enabled: false
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC4zBHgRLMez2b6
|
||||||
|
wfdvvTJVR8xxbr/kJUMiq4ot14KhtTaGikFW+77ezjoqabFWH7CNjDvASWCM2n7X
|
||||||
|
PxL4fhUwzvTbhRZ2XNM80lKB+OIjP3hoNLvgeSNHbS4CztOfk2JVtQFLQdYJ/gvB
|
||||||
|
oFPgBtZYO/SZVML28d5U92JrWRIC1e1Ht1oKwKJoOqtTJrs/RuOlKQ/du4kwY8m0
|
||||||
|
jPw05wFA1YRMUC78xKklCVYCufYewIUTdKxATK0ZKWBoPCJnxDg8gwgpnV1wHQrH
|
||||||
|
GcbZvhcVg3GWpDcYdnogV4rlssws57+uAhGRyQBkmmhVb+zT+LT7WXDPB46MnHkK
|
||||||
|
FIZaxEkHAgMBAAECggEAAvu4Zih1w8+RWAb9mZ4yS9Im6MXi7yny1YJzbp4GC9pD
|
||||||
|
ERT2TRMvV6V4puqh5EQKs55J8Ka+mkeEuLDZ+4z9hpYwucKCRFLnThoPHu4HqI4D
|
||||||
|
wZroVY1fFm4aygzQucjFU6DibnaXn/2r7upJsFor56zAHCGULCxnbHO58QW1Frqa
|
||||||
|
UrTndSkrxavBD9LL1ohPEy3saXlRCVAEM5l7jZbg52dPauIYAOv0e+EE3RETw/Xz
|
||||||
|
3EWukIZ7PKyoyuQm8Sv2u7lyISljDGlvrW5IjVRPMPqOKNOa/pV3qU4mbUY6GjbC
|
||||||
|
B4xt8kEKjVSkTeMXA+W0gnZddnQOtcQYSrYWWes+AQKBgQDzjmt1ZJktZG96M8+f
|
||||||
|
Ov9JznfzSLYxN7EboDhqjTVBOkb6flRSYrd9E6gReIIrq5Sjs9Z+toA/u8BmjQ/P
|
||||||
|
GTrrLVh6bLBicUGKcmQFKw/0D9lOlbxaMg8VO9rqSb/AslumJwjucU7DA+WAN52j
|
||||||
|
cyiLiw+EmWjL/DV51fHHI18SgQKBgQDCPRzpeP8Qox83/+tGR/6fSSRi5ec3ZVPy
|
||||||
|
aCCCZM6qqhLv3hJkV0djRruVVfe136PwUi20BW6aF0PXmxDIGRWqDLQGkvDNEhjw
|
||||||
|
ZLBv/dYtW2HBZhq4E0w8DiaNZCOWvpLQ3QCEtzmuhyHhNqYHzvmuerk+w4c/8fY6
|
||||||
|
DFyPyiAHhwKBgDrpO/zNNG/SV1SLq7CsKIvFsSXbdJY7Dk/MVVkQhs0cN4bnf6Xd
|
||||||
|
0twiIQj4ySOfAPkHyt4jbqn70/H6NNS3GZVBBqG2IIPvORcvzBmj7Nvv6XQkq8Z1
|
||||||
|
TUipja4V4JfPjHOIBZUHOzHYg26cBTk/5ZK7NCmyobKVcqnhofW1DI4BAoGAaRu4
|
||||||
|
8X5QSCh9VEhggH+lAX0K+5l9LTTf4GUIcocqbp/p73M0cKfqMYatK3qBuSF0DS/r
|
||||||
|
G2d1Gl1MkPeQdTddyc9l+8i4FcCdTjiuYWvy4kh49bbS7plCv5zIr+pod8JYoD13
|
||||||
|
clnUFOV7J+vynHccFZbDd3tHTQsaOv9Fd2nhOzECgYEA8SWBEmTuaBh+0vr6zS+E
|
||||||
|
wD+cwB3iaGo+7fP7TZ+v1kxoDlcDjPYM4ikiOB+OPGNkAfqc3MGsbhfgcxqD0+5r
|
||||||
|
kpCFyiyieyoT+7hkMpMsJCNwFO+29fc3DDqPX4Keqp26tMxtRzYea3GtVShiRXew
|
||||||
|
5i4ReFwm3/IWDn9kLmHT6Fg=
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuMwR4ESzHs9m+sH3b70y
|
||||||
|
VUfMcW6/5CVDIquKLdeCobU2hopBVvu+3s46KmmxVh+wjYw7wElgjNp+1z8S+H4V
|
||||||
|
MM7024UWdlzTPNJSgfjiIz94aDS74HkjR20uAs7Tn5NiVbUBS0HWCf4LwaBT4AbW
|
||||||
|
WDv0mVTC9vHeVPdia1kSAtXtR7daCsCiaDqrUya7P0bjpSkP3buJMGPJtIz8NOcB
|
||||||
|
QNWETFAu/MSpJQlWArn2HsCFE3SsQEytGSlgaDwiZ8Q4PIMIKZ1dcB0KxxnG2b4X
|
||||||
|
FYNxlqQ3GHZ6IFeK5bLMLOe/rgIRkckAZJpoVW/s0/i0+1lwzweOjJx5ChSGWsRJ
|
||||||
|
BwIDAQAB
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package de.nowchess.tournament.resource
|
||||||
|
|
||||||
|
import io.quarkus.test.junit.QuarkusTestProfile
|
||||||
|
import java.util.Map as JMap
|
||||||
|
|
||||||
|
class H2TestProfile extends QuarkusTestProfile:
|
||||||
|
|
||||||
|
override def getConfigOverrides(): JMap[String, String] =
|
||||||
|
JMap.of(
|
||||||
|
"quarkus.datasource.db-kind",
|
||||||
|
"h2",
|
||||||
|
"quarkus.datasource.jdbc.url",
|
||||||
|
"jdbc:h2:mem:nowchess-tournament;DB_CLOSE_DELAY=-1",
|
||||||
|
"quarkus.datasource.username",
|
||||||
|
"sa",
|
||||||
|
"quarkus.datasource.password",
|
||||||
|
"",
|
||||||
|
"quarkus.hibernate-orm.schema-management.strategy",
|
||||||
|
"drop-and-create",
|
||||||
|
)
|
||||||
+267
@@ -0,0 +1,267 @@
|
|||||||
|
package de.nowchess.tournament.resource
|
||||||
|
|
||||||
|
import de.nowchess.tournament.client.{CoreGameClient, CoreGameResponse}
|
||||||
|
import io.quarkus.test.InjectMock
|
||||||
|
import io.quarkus.test.junit.QuarkusTest
|
||||||
|
import io.restassured.RestAssured
|
||||||
|
import io.restassured.http.ContentType
|
||||||
|
import io.restassured.response.ValidatableResponse
|
||||||
|
import io.smallrye.jwt.build.Jwt
|
||||||
|
import org.eclipse.microprofile.rest.client.inject.RestClient
|
||||||
|
import org.hamcrest.Matchers.*
|
||||||
|
import org.junit.jupiter.api.{BeforeEach, Test}
|
||||||
|
import org.mockito.{ArgumentMatchers, Mockito}
|
||||||
|
|
||||||
|
@QuarkusTest
|
||||||
|
class TournamentResourceTest:
|
||||||
|
|
||||||
|
@InjectMock
|
||||||
|
@RestClient
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
var coreGameClient: CoreGameClient = scala.compiletime.uninitialized
|
||||||
|
// scalafix:on
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
def setup(): Unit =
|
||||||
|
Mockito.when(coreGameClient.createGame(ArgumentMatchers.any())).thenReturn(CoreGameResponse("game-test-123"))
|
||||||
|
|
||||||
|
private def g() = RestAssured.`given`().contentType(ContentType.JSON)
|
||||||
|
|
||||||
|
private def directorToken(userId: String = "director-1"): String =
|
||||||
|
Jwt.issuer("nowchess").subject(userId).expiresIn(3600).sign()
|
||||||
|
|
||||||
|
private def botToken(botId: String, botName: String): String =
|
||||||
|
Jwt.issuer("nowchess").subject(botId).claim("type", "bot").claim("name", botName).expiresIn(3600).sign()
|
||||||
|
|
||||||
|
private def authed(token: String) =
|
||||||
|
g().header("Authorization", s"Bearer $token")
|
||||||
|
|
||||||
|
private def formAuthed(token: String) =
|
||||||
|
RestAssured.`given`().contentType(ContentType.URLENC).header("Authorization", s"Bearer $token")
|
||||||
|
|
||||||
|
private def createTournament(token: String, name: String = "Test Tour", nbRounds: Int = 3): String =
|
||||||
|
formAuthed(token)
|
||||||
|
.formParam("name", name)
|
||||||
|
.formParam("nbRounds", nbRounds)
|
||||||
|
.formParam("clockLimit", 300)
|
||||||
|
.formParam("clockIncrement", 5)
|
||||||
|
.formParam("rated", true)
|
||||||
|
.when()
|
||||||
|
.post("/api/tournament")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(201)
|
||||||
|
.extract()
|
||||||
|
.path[String]("id")
|
||||||
|
|
||||||
|
private def postAndCheck(token: String, path: String, expectedStatus: Int): ValidatableResponse =
|
||||||
|
authed(token).when().post(path).`then`().statusCode(expectedStatus)
|
||||||
|
|
||||||
|
private def deleteAndCheck(token: String, path: String, expectedStatus: Int): ValidatableResponse =
|
||||||
|
authed(token).when().delete(path).`then`().statusCode(expectedStatus)
|
||||||
|
|
||||||
|
private def botJoin(tournamentId: String, botId: String, botName: String): ValidatableResponse =
|
||||||
|
val bt = botToken(botId, botName)
|
||||||
|
authed(bt).when().post(s"/api/tournament/$tournamentId/join").`then`().statusCode(200)
|
||||||
|
|
||||||
|
private def startTournament(token: String, tournamentId: String): ValidatableResponse =
|
||||||
|
authed(token).when().post(s"/api/tournament/$tournamentId/start").`then`().statusCode(200)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def createsTournamentWhenAuthenticated(): Unit =
|
||||||
|
formAuthed(directorToken())
|
||||||
|
.formParam("name", "Test Tour")
|
||||||
|
.formParam("nbRounds", 3)
|
||||||
|
.formParam("clockLimit", 300)
|
||||||
|
.formParam("clockIncrement", 5)
|
||||||
|
.formParam("rated", true)
|
||||||
|
.when()
|
||||||
|
.post("/api/tournament")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(201)
|
||||||
|
.body("fullName", is("Test Tour"))
|
||||||
|
.body("status", is("created"))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def returns401WhenUnauthenticated(): Unit =
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.contentType(ContentType.URLENC)
|
||||||
|
.formParam("name", "Test Tour")
|
||||||
|
.formParam("nbRounds", 3)
|
||||||
|
.formParam("clockLimit", 300)
|
||||||
|
.formParam("clockIncrement", 5)
|
||||||
|
.when()
|
||||||
|
.post("/api/tournament")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(401)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def returnsEmptyListsOnFreshStart(): Unit =
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.get("/api/tournament")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("created", notNullValue())
|
||||||
|
.body("started", notNullValue())
|
||||||
|
.body("finished", notNullValue())
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def returnsCreatedTournamentInCreatedList(): Unit =
|
||||||
|
val id = createTournament(directorToken("director-list"), "ListTour")
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.get("/api/tournament")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("created.id", hasItem(id))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def returns404ForUnknownId(): Unit =
|
||||||
|
RestAssured.`given`().when().get("/api/tournament/XXXXXX").`then`().statusCode(404)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def returnsTournamentWithStandings(): Unit =
|
||||||
|
val id = createTournament(directorToken("dir-get"), "GetTour")
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.get(s"/api/tournament/$id")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("id", is(id))
|
||||||
|
.body("standing", notNullValue())
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def directorCanTerminateCreatedTournament(): Unit =
|
||||||
|
val token = directorToken("dir-term")
|
||||||
|
val id = createTournament(token, "TermTour")
|
||||||
|
deleteAndCheck(token, s"/api/tournament/$id", 204)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def nonDirectorGets403OnTerminate(): Unit =
|
||||||
|
val id = createTournament(directorToken("dir-403"), "SecureTour")
|
||||||
|
deleteAndCheck(directorToken("other-user-403"), s"/api/tournament/$id", 403)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def cannotTerminateStartedTournament(): Unit =
|
||||||
|
val token = directorToken("dir-started")
|
||||||
|
val id = createTournament(token, "StartedTour")
|
||||||
|
botJoin(id, "sbot-1", "StartBot1")
|
||||||
|
botJoin(id, "sbot-2", "StartBot2")
|
||||||
|
startTournament(token, id)
|
||||||
|
deleteAndCheck(token, s"/api/tournament/$id", 409)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def botJoinsSuccessfully(): Unit =
|
||||||
|
val id = createTournament(directorToken("dir-join"), "JoinTour")
|
||||||
|
authed(botToken("joinbot-1", "JoinBot1"))
|
||||||
|
.when()
|
||||||
|
.post(s"/api/tournament/$id/join")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("ok", is(true))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def nonBotTokenReturns403OnJoin(): Unit =
|
||||||
|
val id = createTournament(directorToken("dir-nbjoin"), "NbJoinTour")
|
||||||
|
postAndCheck(directorToken("regular-user"), s"/api/tournament/$id/join", 403)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def alreadyJoinedReturns409(): Unit =
|
||||||
|
val id = createTournament(directorToken("dir-dbl"), "DblJoinTour")
|
||||||
|
val bt = botToken("dblbot-1", "DblBot1")
|
||||||
|
botJoin(id, "dblbot-1", "DblBot1")
|
||||||
|
authed(bt).when().post(s"/api/tournament/$id/join").`then`().statusCode(409)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def startedTournamentReturns409OnJoin(): Unit =
|
||||||
|
val token = directorToken("dir-sjoin")
|
||||||
|
val id = createTournament(token, "SjoinTour")
|
||||||
|
botJoin(id, "sjbot-1", "SjBot1")
|
||||||
|
botJoin(id, "sjbot-2", "SjBot2")
|
||||||
|
startTournament(token, id)
|
||||||
|
authed(botToken("sjbot-3", "SjBot3")).when().post(s"/api/tournament/$id/join").`then`().statusCode(409)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def joinedBotCanWithdraw(): Unit =
|
||||||
|
val id = createTournament(directorToken("dir-wd"), "WdTour")
|
||||||
|
val bt = botToken("wdbot-1", "WdBot1")
|
||||||
|
botJoin(id, "wdbot-1", "WdBot1")
|
||||||
|
authed(bt)
|
||||||
|
.when()
|
||||||
|
.post(s"/api/tournament/$id/withdraw")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("ok", is(true))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def notJoinedBotReturns409OnWithdraw(): Unit =
|
||||||
|
val id = createTournament(directorToken("dir-wdnj"), "WdnjTour")
|
||||||
|
val bt = botToken("wdnjbot-1", "WdnjBot1")
|
||||||
|
authed(bt).when().post(s"/api/tournament/$id/withdraw").`then`().statusCode(409)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def directorStartsWith2Bots(): Unit =
|
||||||
|
val token = directorToken("dir-start")
|
||||||
|
val id = createTournament(token, "StartTour2")
|
||||||
|
botJoin(id, "stbot-1", "StBot1")
|
||||||
|
botJoin(id, "stbot-2", "StBot2")
|
||||||
|
postAndCheck(token, s"/api/tournament/$id/start", 200)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def nonDirectorReturns403OnStart(): Unit =
|
||||||
|
val id = createTournament(directorToken("dir-ndstart"), "NdStartTour")
|
||||||
|
botJoin(id, "ndstbot-1", "NdstBot1")
|
||||||
|
botJoin(id, "ndstbot-2", "NdstBot2")
|
||||||
|
postAndCheck(directorToken("other-ndstart"), s"/api/tournament/$id/start", 403)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def fewerThan2BotsReturns409OnStart(): Unit =
|
||||||
|
val token = directorToken("dir-1bot")
|
||||||
|
val id = createTournament(token, "1BotTour")
|
||||||
|
botJoin(id, "onebot-1", "OneBot1")
|
||||||
|
postAndCheck(token, s"/api/tournament/$id/start", 409)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def resultsReturns200WithNdjsonContentType(): Unit =
|
||||||
|
val id = createTournament(directorToken("dir-res"), "ResTour")
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.when()
|
||||||
|
.get(s"/api/tournament/$id/results")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.contentType("application/x-ndjson")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def returnsPairingsForRoundAfterStart(): Unit =
|
||||||
|
val token = directorToken("dir-round")
|
||||||
|
val id = createTournament(token, "RoundTour")
|
||||||
|
botJoin(id, "rndbot-1", "RndBot1")
|
||||||
|
botJoin(id, "rndbot-2", "RndBot2")
|
||||||
|
startTournament(token, id)
|
||||||
|
RestAssured.`given`().when().get(s"/api/tournament/$id/round/1").`then`().statusCode(200)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def returns404ForUnknownTournamentRound(): Unit =
|
||||||
|
RestAssured.`given`().when().get("/api/tournament/XXXXXX/round/1").`then`().statusCode(404)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def returnsPgnByDefault(): Unit =
|
||||||
|
val id = createTournament(directorToken("dir-pgn"), "PgnTour")
|
||||||
|
RestAssured.`given`().when().get(s"/api/tournament/$id/export/games").`then`().statusCode(200)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def returnsNdjsonWhenAcceptApplicationXNdjson(): Unit =
|
||||||
|
val id = createTournament(directorToken("dir-ndjson"), "NdjsonTour")
|
||||||
|
RestAssured
|
||||||
|
.`given`()
|
||||||
|
.header("Accept", "application/x-ndjson")
|
||||||
|
.when()
|
||||||
|
.get(s"/api/tournament/$id/export/games")
|
||||||
|
.`then`()
|
||||||
|
.statusCode(200)
|
||||||
|
.contentType("application/x-ndjson")
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
package de.nowchess.tournament.service
|
||||||
|
|
||||||
|
import de.nowchess.tournament.domain.{TournamentPairing, TournamentParticipant}
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.Assertions.*
|
||||||
|
|
||||||
|
class SwissPairingServiceTest:
|
||||||
|
|
||||||
|
private def makeParticipant(
|
||||||
|
botId: String,
|
||||||
|
botName: String,
|
||||||
|
points: Double = 0.0,
|
||||||
|
byeCount: Int = 0,
|
||||||
|
): TournamentParticipant =
|
||||||
|
val p = new TournamentParticipant()
|
||||||
|
p.botId = botId
|
||||||
|
p.botName = botName
|
||||||
|
p.points = points
|
||||||
|
p.byeCount = byeCount
|
||||||
|
p
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def pairs2PlayersRandomlyAssignsColors(): Unit =
|
||||||
|
val p1 = makeParticipant("b1", "BotOne")
|
||||||
|
val p2 = makeParticipant("b2", "BotTwo")
|
||||||
|
val (pairs, bye) = SwissPairingService.computePairings(List(p1, p2), Nil)
|
||||||
|
assertEquals(1, pairs.size)
|
||||||
|
assertTrue(bye.isEmpty)
|
||||||
|
val (white, black) = pairs.head
|
||||||
|
val ids = Set(white.botId, black.botId)
|
||||||
|
assertEquals(Set("b1", "b2"), ids)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def pairs4PlayersTopVsEachOther(): Unit =
|
||||||
|
val p1 = makeParticipant("b1", "A", points = 2.0)
|
||||||
|
val p2 = makeParticipant("b2", "B", points = 1.5)
|
||||||
|
val p3 = makeParticipant("b3", "C", points = 1.0)
|
||||||
|
val p4 = makeParticipant("b4", "D", points = 0.0)
|
||||||
|
val (pairs, bye) = SwissPairingService.computePairings(List(p1, p2, p3, p4), Nil)
|
||||||
|
assertEquals(2, pairs.size)
|
||||||
|
assertTrue(bye.isEmpty)
|
||||||
|
val pair1Ids = Set(pairs(0)._1.botId, pairs(0)._2.botId)
|
||||||
|
val pair2Ids = Set(pairs(1)._1.botId, pairs(1)._2.botId)
|
||||||
|
assertEquals(Set("b1", "b2"), pair1Ids)
|
||||||
|
assertEquals(Set("b3", "b4"), pair2Ids)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def oddCountLowestRankedGetsBye(): Unit =
|
||||||
|
val p1 = makeParticipant("b1", "A", points = 2.0)
|
||||||
|
val p2 = makeParticipant("b2", "B", points = 1.0)
|
||||||
|
val p3 = makeParticipant("b3", "C", points = 0.0)
|
||||||
|
val (pairs, bye) = SwissPairingService.computePairings(List(p1, p2, p3), Nil)
|
||||||
|
assertEquals(1, pairs.size)
|
||||||
|
assertTrue(bye.isDefined)
|
||||||
|
assertEquals("b3", bye.get.botId)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def avoidsRematchSwapsWhenPairAlreadyPlayed(): Unit =
|
||||||
|
val p1 = makeParticipant("b1", "A", points = 2.0)
|
||||||
|
val p2 = makeParticipant("b2", "B", points = 1.5)
|
||||||
|
val p3 = makeParticipant("b3", "C", points = 1.5)
|
||||||
|
val p4 = makeParticipant("b4", "D", points = 0.0)
|
||||||
|
val pastPairing = new TournamentPairing()
|
||||||
|
pastPairing.whiteId = "b1"
|
||||||
|
pastPairing.blackId = "b2"
|
||||||
|
val (pairs, _) = SwissPairingService.computePairings(List(p1, p2, p3, p4), List(pastPairing))
|
||||||
|
val pair1Ids = Set(pairs(0)._1.botId, pairs(0)._2.botId)
|
||||||
|
assertFalse(pair1Ids == Set("b1", "b2"), "b1 and b2 should not be paired again")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def playerWithFewerByesGetsTheByeFirst(): Unit =
|
||||||
|
val p1 = makeParticipant("b1", "A", points = 1.0, byeCount = 1)
|
||||||
|
val p2 = makeParticipant("b2", "B", points = 0.5, byeCount = 0)
|
||||||
|
val p3 = makeParticipant("b3", "C", points = 0.0, byeCount = 0)
|
||||||
|
val (pairs, bye) = SwissPairingService.computePairings(List(p1, p2, p3), Nil)
|
||||||
|
assertEquals(1, pairs.size)
|
||||||
|
assertTrue(bye.isDefined)
|
||||||
|
assertEquals("b3", bye.get.botId)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
MAJOR=0
|
||||||
|
MINOR=1
|
||||||
|
PATCH=0
|
||||||
@@ -204,3 +204,30 @@
|
|||||||
### Reverts
|
### Reverts
|
||||||
|
|
||||||
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||||
|
## (2026-06-09)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add initialization metrics for various services ([d438e97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d438e97f32bdde0bfc63c1b4a8cc810cdd093166))
|
||||||
|
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
|
||||||
|
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
|
||||||
|
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
|
||||||
|
* configure logging and add OpenTelemetry support ([#49](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/49)) ([d57c488](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d57c4886612d1d92da0e1b79209fc83e6ef537a1))
|
||||||
|
* **docker:** add .dockerignore and .gitignore files for build exclusions ([c987d8e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c987d8e258c0e6c4cfbdaa8381c64c410d7a2b83))
|
||||||
|
* **docker:** add Dockerfiles for building Quarkus application in native and JVM modes ([3f2d2bb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3f2d2bb4c97fa8cddba66e1da4427c54236dfeed))
|
||||||
|
* **docker:** add Dockerfiles for Quarkus application in JVM and native modes ([34b9933](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/34b993304670cf2aa62cd2f6460cee7b9864b08e))
|
||||||
|
* **docker:** add Dockerfiles for Quarkus application in JVM and native modes ([e5fe7d0](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5fe7d07a58e018151bb24f4ee37c06e72608ded))
|
||||||
|
* **logging:** add DEBUG/INFO/WARN logging across services (NCS-72) ([#41](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/41)) ([804a4bf](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/804a4bf179e3dfb19e2be4390e7e543caf5237c6))
|
||||||
|
* NCS-78 Add Traceability to the Applications ([#46](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/46)) ([649566e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/649566eb3fcf38f91c8896a739f74ea318af312d))
|
||||||
|
* NCS-78 Add Traceability to the Applications ([#47](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/47)) ([87dfc6c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/87dfc6c2bcce7f7d58fc641bd8d468a2e584c108))
|
||||||
|
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
|
||||||
|
* update application.yml with new API root paths and add Micrometer and OpenTelemetry dependencies ([72ce262](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/72ce262bc491f94297700e6002fb5d0812e2cc2a))
|
||||||
|
* **ws:** migrate challenge notifications to Redis Streams ([#66](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/66)) ([55f102c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/55f102cbaa684be94a158b16aaa42a50b36afaf3))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* remove unused HTTP root-path configurations from application.yml ([3ed3e59](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ed3e59ee456d54cd3d65ece4f36623e256b9736))
|
||||||
|
|
||||||
|
### Reverts
|
||||||
|
|
||||||
|
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||||
|
|||||||
@@ -2,15 +2,18 @@ package de.nowchess.ws.resource
|
|||||||
|
|
||||||
import de.nowchess.ws.config.RedisConfig
|
import de.nowchess.ws.config.RedisConfig
|
||||||
import io.quarkus.redis.datasource.RedisDataSource
|
import io.quarkus.redis.datasource.RedisDataSource
|
||||||
import io.quarkus.redis.datasource.pubsub.PubSubCommands
|
import io.quarkus.redis.datasource.stream.{XAddArgs, XGroupCreateArgs, XReadGroupArgs}
|
||||||
import io.quarkus.websockets.next.*
|
import io.quarkus.websockets.next.*
|
||||||
import io.smallrye.jwt.auth.principal.JWTParser
|
import io.smallrye.jwt.auth.principal.JWTParser
|
||||||
import jakarta.inject.Inject
|
import jakarta.inject.Inject
|
||||||
|
import org.eclipse.microprofile.context.ManagedExecutor
|
||||||
import org.jboss.logging.Logger
|
import org.jboss.logging.Logger
|
||||||
import scala.compiletime.uninitialized
|
import scala.compiletime.uninitialized
|
||||||
import scala.util.Try
|
import scala.jdk.CollectionConverters.*
|
||||||
|
import scala.util.{Failure, Success, Try}
|
||||||
|
import java.time.Duration
|
||||||
|
import java.util.UUID
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.function.Consumer
|
|
||||||
|
|
||||||
@WebSocket(path = "/api/user/ws")
|
@WebSocket(path = "/api/user/ws")
|
||||||
class UserWebSocketResource:
|
class UserWebSocketResource:
|
||||||
@@ -18,20 +21,22 @@ class UserWebSocketResource:
|
|||||||
private val log = Logger.getLogger(classOf[UserWebSocketResource])
|
private val log = Logger.getLogger(classOf[UserWebSocketResource])
|
||||||
|
|
||||||
// scalafix:off DisableSyntax.var
|
// scalafix:off DisableSyntax.var
|
||||||
@Inject
|
@Inject var redis: RedisDataSource = uninitialized
|
||||||
var redis: RedisDataSource = uninitialized
|
@Inject var redisConfig: RedisConfig = uninitialized
|
||||||
|
@Inject var jwtParser: JWTParser = uninitialized
|
||||||
@Inject
|
@Inject var executor: ManagedExecutor = uninitialized
|
||||||
var redisConfig: RedisConfig = uninitialized
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
var jwtParser: JWTParser = uninitialized
|
|
||||||
// scalafix:on DisableSyntax.var
|
// scalafix:on DisableSyntax.var
|
||||||
|
|
||||||
private val connections = new ConcurrentHashMap[String, (String, PubSubCommands.RedisSubscriber)]()
|
private val consumerId = UUID.randomUUID().toString
|
||||||
|
private val maxRetries = 3
|
||||||
|
private val maxStreamLen = 1000L
|
||||||
|
|
||||||
private def userTopic(userId: String): String =
|
private val connections = new ConcurrentHashMap[String, (String, WebSocketConnection)]()
|
||||||
s"${redisConfig.prefix}:user:$userId:events"
|
|
||||||
|
private def userStreamKey(userId: String): String =
|
||||||
|
s"${redisConfig.prefix}:user:$userId:events:stream"
|
||||||
|
|
||||||
|
private def dlqKey: String = s"${redisConfig.prefix}:dlq"
|
||||||
|
|
||||||
@OnOpen
|
@OnOpen
|
||||||
def onOpen(connection: WebSocketConnection, handshake: HandshakeRequest): Unit =
|
def onOpen(connection: WebSocketConnection, handshake: HandshakeRequest): Unit =
|
||||||
@@ -45,16 +50,76 @@ class UserWebSocketResource:
|
|||||||
log.warn("WebSocket opened with no valid JWT — closing connection")
|
log.warn("WebSocket opened with no valid JWT — closing connection")
|
||||||
connection.close().subscribe().`with`(_ => (), _ => ())
|
connection.close().subscribe().`with`(_ => (), _ => ())
|
||||||
case Some(userId) =>
|
case Some(userId) =>
|
||||||
log.infof("User WebSocket opened — userId=%s", userId)
|
log.infof("User WebSocket opened — userId=%s connId=%s", userId, connection.id())
|
||||||
val handler: Consumer[String] = msg => connection.sendText(msg).subscribe().`with`(_ => (), _ => ())
|
createGroupIfAbsent(userId, connection.id())
|
||||||
val subscriber = redis.pubsub(classOf[String]).subscribe(userTopic(userId), handler)
|
connections.put(connection.id(), (userId, connection))
|
||||||
connections.put(connection.id(), (userId, subscriber))
|
executor.submit(
|
||||||
|
new Runnable:
|
||||||
|
def run(): Unit = pollLoop(connection.id(), userId, connection),
|
||||||
|
)
|
||||||
val connectedMsg = s"""{"type":"CONNECTED","userId":"$userId"}"""
|
val connectedMsg = s"""{"type":"CONNECTED","userId":"$userId"}"""
|
||||||
connection.sendText(connectedMsg).subscribe().`with`(_ => (), _ => ())
|
connection.sendText(connectedMsg).subscribe().`with`(_ => (), _ => ())
|
||||||
|
|
||||||
@OnClose
|
@OnClose
|
||||||
def onClose(connection: WebSocketConnection): Unit =
|
def onClose(connection: WebSocketConnection): Unit =
|
||||||
log.infof("User WebSocket closed — connectionId=%s", connection.id())
|
log.infof("User WebSocket closed — connectionId=%s", connection.id())
|
||||||
Option(connections.remove(connection.id())).foreach { (userId, subscriber) =>
|
connections.remove(connection.id())
|
||||||
subscriber.unsubscribe(userTopic(userId))
|
|
||||||
}
|
private def createGroupIfAbsent(userId: String, groupName: String): Unit =
|
||||||
|
Try(
|
||||||
|
redis
|
||||||
|
.stream(classOf[String])
|
||||||
|
.xgroupCreate(userStreamKey(userId), groupName, "$", new XGroupCreateArgs().mkstream()),
|
||||||
|
) match
|
||||||
|
case Failure(ex) if Option(ex.getMessage).exists(_.contains("BUSYGROUP")) => ()
|
||||||
|
case Failure(ex) => log.warnf(ex, "Failed to create consumer group for userId=%s", userId)
|
||||||
|
case Success(_) => ()
|
||||||
|
|
||||||
|
private def pollLoop(connectionId: String, userId: String, myConnection: WebSocketConnection): Unit =
|
||||||
|
while Option(connections.get(connectionId)).exists(_._2 eq myConnection) do
|
||||||
|
Try {
|
||||||
|
val messages = redis
|
||||||
|
.stream(classOf[String])
|
||||||
|
.xreadgroup(
|
||||||
|
connectionId,
|
||||||
|
consumerId,
|
||||||
|
userStreamKey(userId),
|
||||||
|
">",
|
||||||
|
new XReadGroupArgs().count(10).block(Duration.ofSeconds(2)),
|
||||||
|
)
|
||||||
|
Option(messages).foreach(_.forEach { msg =>
|
||||||
|
if Option(connections.get(connectionId)).exists(_._2 eq myConnection) then
|
||||||
|
val json = msg.payload().get("data")
|
||||||
|
val attempt = Option(msg.payload().get("attempt")).flatMap(_.toIntOption).getOrElse(0)
|
||||||
|
Try(myConnection.sendText(json).await().atMost(Duration.ofSeconds(5))) match
|
||||||
|
case Success(_) =>
|
||||||
|
ack(connectionId, userId, msg.id())
|
||||||
|
case Failure(_) if attempt + 1 < maxRetries =>
|
||||||
|
xadd(userStreamKey(userId), json, attempt + 1)
|
||||||
|
ack(connectionId, userId, msg.id())
|
||||||
|
case Failure(ex) =>
|
||||||
|
log.warnf(ex, "Delivery failed for userId=%s after %d attempts, sending to DLQ", userId, maxRetries)
|
||||||
|
xadd(dlqKey, json, attempt)
|
||||||
|
ack(connectionId, userId, msg.id())
|
||||||
|
})
|
||||||
|
} match
|
||||||
|
case Failure(ex) => log.warnf(ex, "Error in poll loop for userId=%s", userId)
|
||||||
|
case Success(_) => ()
|
||||||
|
|
||||||
|
private def ack(groupName: String, userId: String, id: String): Unit =
|
||||||
|
Try(redis.stream(classOf[String]).xack(userStreamKey(userId), groupName, id)) match
|
||||||
|
case Failure(ex) => log.warnf(ex, "Failed to ack message %s for userId=%s", id, userId)
|
||||||
|
case Success(_) => ()
|
||||||
|
|
||||||
|
private def xadd(key: String, json: String, attempt: Int): Unit =
|
||||||
|
Try(
|
||||||
|
redis
|
||||||
|
.stream(classOf[String])
|
||||||
|
.xadd(
|
||||||
|
key,
|
||||||
|
new XAddArgs().maxlen(maxStreamLen).nearlyExactTrimming(),
|
||||||
|
Map("data" -> json, "attempt" -> attempt.toString).asJava,
|
||||||
|
),
|
||||||
|
) match
|
||||||
|
case Failure(ex) => log.warnf(ex, "Failed to publish to stream %s", key)
|
||||||
|
case Success(_) => ()
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
MAJOR=0
|
MAJOR=0
|
||||||
MINOR=14
|
MINOR=15
|
||||||
PATCH=0
|
PATCH=0
|
||||||
|
|||||||
@@ -26,4 +26,5 @@ include(
|
|||||||
"modules:ws",
|
"modules:ws",
|
||||||
"modules:store",
|
"modules:store",
|
||||||
"modules:coordinator",
|
"modules:coordinator",
|
||||||
|
"modules:tournament",
|
||||||
)
|
)
|
||||||
Reference in New Issue
Block a user