Compare commits

...

23 Commits

Author SHA1 Message Date
Lala, Shahd 9f9afd3094 feat: bot vs. player
Build & Test (NowChessSystems) TeamCity build failed
2026-06-04 20:22:38 +00:00
Lala, Shahd 3b42aea416 feat: local tour WORKS bots play
Build & Test (NowChessSystems) TeamCity build failed
2026-06-04 18:13:34 +00:00
Lala, Shahd a46cff5342 fix: Merge branch 'main' of git.janis-eccarius.de:NowChess/NowChessSystems into feat/NCS-82
Build & Test (NowChessSystems) TeamCity build failed
2026-06-04 13:46:59 +00:00
TeamCity 4c5302d525 ci: bump version with Build-111
Build & Test (NowChessSystems) TeamCity build failed
2026-06-04 11:14:24 +00:00
shosho996 a12f979023 fix(official-bots): NCS-70-auto-register official bots with account service (#59)
Co-authored-by: Janis <janis-e@gmx.de>
Reviewed-on: #59
2026-06-04 11:14:24 +00:00
TeamCity d0c09c6039 ci: bump version with Build-110 2026-06-04 11:14:24 +00:00
Janis e4011e7337 fix(store): cap game-writeback stream with MAXLEN trimming (#58)
Add approximate MAXLEN ~1000 to all xadd calls in
GameWritebackStreamListener. Without trimming, ACKed messages
accumulate in the stream indefinitely, wasting Redis memory.

Closes NCS-88
https://knockoutwhist.youtrack.cloud/issue/NCS-88

---------

Co-authored-by: Janis Eccarius <eccariusjanis@gmail.com>
Reviewed-on: #58
2026-06-04 11:14:24 +00:00
Janis 554b56ff1d docs: add automated workflows for defect fixing, user story creation, and issue estimation 2026-06-04 11:14:24 +00:00
TeamCity 38992bfa41 ci: bump version with Build-108 2026-06-04 11:14:24 +00:00
Janis 5949b4cb60 fix(pgn): add SAN disambiguation and check/checkmate suffixes [NCS-42] (#56)
Two bugs in move notation causing PGN import failures in LiChess:

1. Disambiguation: when two pieces of same type can reach same square,
   SAN requires file/rank/full-square prefix (e.g. "Ndf3" not "Nf3").
   Added disambiguate() in PgnExporter and disambiguatePiece() in
   GameEngine, both querying allLegalMoves to find competing pieces.

2. Check/checkmate suffix: "+" and "#" were never appended.
   PgnExporter now threads ctxAfter through moveToAlgebraic and
   calls DefaultRules.isCheck/isCheckmate. GameEngine passes
   PostMoveStatus to translateMoveToNotation for the same result.

Also removes dead notation code in executeMoveBody (result was never
used — not passed to MoveExecutedEvent).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Janis Eccarius <eccariusjanis@gmail.com>
Reviewed-on: #56
2026-06-04 11:14:24 +00:00
TeamCity 000afb235a ci: bump version with Build-107 2026-06-04 11:14:24 +00:00
Janis Eccarius ad2cb7431c refactor: improve code formatting and readability 2026-06-04 11:14:24 +00:00
Janis Eccarius 4b2f3a256e fix(store): replace null check with Option for stream messages 2026-06-04 11:14:24 +00:00
Janis Eccarius 65ddf55575 ci(native-image): add x86_v2 arch to build matrix
Adds x86-64-v2 microarchitecture as a second build target alongside
default. Images tagged with -x86_v2 suffix; GHA cache scoped per arch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 11:14:24 +00:00
TeamCity 4762f6c0c3 ci: bump version with Build-111 2026-06-03 11:50:50 +00:00
shosho996 7117a93376 fix(official-bots): NCS-70-auto-register official bots with account service (#59)
Build & Test (NowChessSystems) TeamCity build finished
Co-authored-by: Janis <janis-e@gmx.de>
Reviewed-on: #59
2026-06-03 13:27:03 +02:00
TeamCity 085e34f062 ci: bump version with Build-110 2026-06-03 06:04:13 +00:00
Janis 32c388760b fix(store): cap game-writeback stream with MAXLEN trimming (#58)
Build & Test (NowChessSystems) TeamCity build finished
Add approximate MAXLEN ~1000 to all xadd calls in
GameWritebackStreamListener. Without trimming, ACKed messages
accumulate in the stream indefinitely, wasting Redis memory.

Closes NCS-88
https://knockoutwhist.youtrack.cloud/issue/NCS-88

---------

Co-authored-by: Janis Eccarius <eccariusjanis@gmail.com>
Reviewed-on: #58
2026-06-03 07:44:13 +02:00
Janis 98b0bba398 docs: add automated workflows for defect fixing, user story creation, and issue estimation
Build & Test (NowChessSystems) TeamCity build finished
2026-06-02 21:42:18 +02:00
LQ63 8255731d50 feat(tournament): wire official bots into tournaments via JWT and Redis
Build & Test (NowChessSystems) TeamCity build failed
- Add token field to OfficialBotAccount and generate a non-expiring bot
  JWT on creation so official bots can authenticate
- Expose token in POST /api/account/official-bots response and open the
  endpoint to any authenticated user
- Bridge TournamentService to Redis: publish gameStart events to
  nowchess:bot:<name>:events after each pairing so OfficialBotService
  picks up games automatically

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 00:06:06 +02:00
LQ63 acd8a26685 fix(ws): revert incompatible handshake.query usage
handshake.query(String) does not exist in the current Quarkus WebSocket API version.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 00:05:48 +02:00
Lala, Shahd fd6698c7a7 fix: bot name and id return, websocket
Build & Test (NowChessSystems) TeamCity build failed
2026-05-31 21:28:04 +00:00
LQ63 5c586b9003 feat(tournament): add Swiss-system tournament module
Build & Test (NowChessSystems) TeamCity build failed
Implements the full tournament lifecycle: create, join, withdraw, start,
round progression, and finish with Buchholz tiebreak standings.

- REST resource covering all 11 endpoints from the OpenAPI spec
- Swiss pairing algorithm with bye support
- Per-bot NDJSON stream with targeted gameStart events (color field)
- Game result ingestion via Redis writeback stream
- H2-backed integration tests for resource and pairing service

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 00:46:56 +02:00
54 changed files with 3342 additions and 90 deletions
+85
View File
@@ -0,0 +1,85 @@
# Create User Story in YouTrack
Automated user-story creation workflow. Topic/hint: `$ARGUMENTS`
## Step 1 — Gather Context
Use `AskUserQuestion` tool to ask the user (max 4 questions at once):
1. **Domain** — Is this frontend (UI/UX) or backend/coordinator/systems work?
2. **User type** — Who is the actor? (e.g. player, admin, bot, system)
3. **Action** — What should the user be able to do?
4. **Goal/value** — Why? What outcome does it enable?
If `$ARGUMENTS` already answers some of these, skip those questions.
## Step 2 — Research (if needed)
If the topic involves unfamiliar domain logic, game rules, or technical constraints:
- Search the repo for relevant code (use `Grep`/`Bash` to find related files).
- Use `WebSearch` if the topic involves external standards or protocols.
- Do NOT guess. Surface findings before drafting.
## Step 3 — Draft Story
Compose the full story using this template:
```
As a [type of user]
I want to [perform an action]
So that [achieve a goal or value]
Description
[Additional context or business logic for this story.]
Acceptance Criteria
[List the specific, measurable criteria that define when this story is done:]
- Criterion 1
- Criterion 2
- Criterion 3
Implementation Notes
[Technical notes, design references, or constraints.]
```
Rules:
- User story line: plain English, present tense, from user's perspective.
- Acceptance criteria: testable, unambiguous, one condition each.
- Implementation notes: optional — only include if there are known constraints, related tickets, or design refs.
## Step 4 — Clarify Acceptance Criteria
Show the draft to the user.
**Use `AskUserQuestion` tool to ask:**
- Are the acceptance criteria complete and correct?
- Any implementation constraints to add?
- Priority (if known)?
Incorporate feedback. Repeat until user approves.
## Step 5 — Determine Project
- 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 derived from the "I want to" clause (≤72 chars, sentence case)
- `description`: full formatted story from Step 3 (Markdown)
- `type`: `Feature` (or `Task` if purely technical with no user-facing value)
## Step 7 — Report
Display the created issue ID and URL.
Ask if a linked sub-task or implementation ticket is needed.
+90
View File
@@ -0,0 +1,90 @@
# Estimate Issue Time in YouTrack
Sprint planning time estimator. Issue ID or empty for full current sprint: `$ARGUMENTS`
## Step 1 — Determine Scope
**Single-issue mode** (`$ARGUMENTS` set):
- Call `mcp__youtrack__get_issue` on `$ARGUMENTS`.
- Proceed with that issue only.
**Sprint mode** (`$ARGUMENTS` empty):
- Call `mcp__youtrack__search_issues` with query `project: NCS Sprints: {current sprint} #Unresolved`.
- If query returns 0 results, use `AskUserQuestion` to ask for the sprint name, then retry with `project: NCS Sprints: {name}`.
- Collect all returned issues.
## Step 2 — Build Issue Tree
For each top-level issue from Step 1:
1. Fetch full details via `mcp__youtrack__get_issue`: summary, description, acceptance criteria, Type, existing `Zeitschätzung`, linked issues.
2. Identify subtasks from links with relation `subtask of` (i.e. issues where the fetched issue is the parent).
3. Recursively fetch subtasks until all leaves are known.
4. Group into tree: Epic → Story → Task/Subtask.
**Leaf node** = issue with no subtask children.
**Parent node** = issue that has at least one subtask child.
## Step 3 — Estimate Leaf Nodes
For each leaf node:
1. Read: summary, description, acceptance criteria, implementation notes.
2. If scope is unclear, search codebase (`Grep`/`Bash`) for related files to gauge complexity.
3. Assign estimate using this scale:
| Size | Criteria | Estimate |
|------|----------|----------|
| Trivial | Config change, rename, 1-file tweak | 30m |
| Small | 13 files, clear scope, no unknowns | 1h2h |
| Medium | 36 files, some design needed | 3h5h |
| Large | 6+ files, cross-module, non-trivial design | 1d2d |
| XL | New subsystem, research spike, major refactor | 3d5d |
4. Record: estimate + one-line reasoning.
5. Skip leaf if it already has `Zeitschätzung` set — note it as pre-estimated.
## Step 4 — Roll Up for Display
YouTrack auto-sums `Zeitschätzung` from subtasks up to parents — **do not write estimates to parent nodes**.
Compute display-only rolled-up totals:
- Parent total = sum of all descendant leaf estimates (including pre-estimated ones).
- Flag any branch where some leaves are missing estimates (partial roll-up).
## Step 5 — Show Summary + Confirm
Display full tree with estimates. Format:
```
Epic NCS-10: Castling overhaul [4h 30m] ← rolled up
Story NCS-11: Validate castling rights [2h 30m] ← rolled up
Task NCS-12: Fix RuleSet check 1h 30m ← leaf (new)
Task NCS-13: Add regression tests 1h ← leaf (new)
Story NCS-14: UI feedback for castling [2h] ← rolled up
Task NCS-15: Highlight invalid squares 2h ← leaf (pre-set, skipped)
```
Legend: `[X]` = display-only roll-up (not written). Plain = will be written to YouTrack.
If sprint mode, show grand total at bottom:
```
Sprint total: Xd Yh Zm (N issues, M leaves to update)
```
**Use `AskUserQuestion` tool:**
- Does the breakdown look right?
- Any estimates to adjust before writing to YouTrack?
Incorporate all feedback before proceeding.
## Step 6 — Write Estimates
On user approval, write estimates **only to leaf nodes** (bottom-up order):
- For each leaf with a new estimate, call `mcp__youtrack__update_issue` with field `Zeitschätzung` = approved estimate.
- YouTrack period format: `"30m"`, `"1h 30m"`, `"1d"`, `"2d 4h"`.
- Skip leaves already pre-estimated.
## Step 7 — Report
List all updated issues with set estimates.
Show final rolled-up totals per Epic/Story (read back from YouTrack via `mcp__youtrack__get_issue` if needed).
In sprint mode, show total sprint estimate.
+104
View File
@@ -0,0 +1,104 @@
# Fix Defect from YouTrack
Automated defect-fix workflow. Ticket ID: `$ARGUMENTS`
## Step 1 — Fetch Ticket
Call `mcp__youtrack__get_issue` with ID `$ARGUMENTS`.
Extract and display: summary, description, steps to reproduce, Priority, Subsystem.
## Step 2 — Create Worktree
Derive branch name from ticket:
- `type` from YouTrack issue type: `bug``fix`, `feature`/`task``feat`, `refactor``refactor`, else `chore`
- `scope` from affected module/component (kebab-case, omit if unclear)
- `description` from ticket summary: lowercase, kebab-case, max 40 chars, drop articles
Branch format: `<type>/<ticket-id>-<description>`
Example: `fix/NOW-123-castling-validation-failure`
Call `EnterWorktree` with that branch name.
All subsequent file work happens inside this worktree.
## Step 3 — Identify Root Cause (read-only)
1. Run `./compile` — capture all errors and warnings.
2. Run `./test` — capture all failures.
3. Spawn `cavecrew-investigator` with: ticket description + compile/test output → locate root cause (files, line numbers, what's wrong).
4. **If anything is ambiguous (reproduction unclear, scope uncertain, conflicting signals), use `AskUserQuestion` tool to ask — max 4 questions at once.**
5. **Report findings to user. No file writes yet. Wait for acknowledgement before continuing.**
## Step 3b — Complexity Assessment + Subtasks
After root cause confirmed, assess scope:
**Simple** (12 files, single concern, < 1 hour estimated): proceed directly to Step 4.
**Complex** (3+ files, multiple concerns, or estimated > 1 hour): create subtasks before coding.
To create subtasks:
1. Break fix into discrete, independently-completable tasks (e.g. "Fix 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:
- `project`: same project as parent ticket
- `summary`: concise action-oriented title
- `type`: `Task`
- `description`: what to do and why
3. Call `mcp__youtrack__link_issues` to link each subtask to `$ARGUMENTS` with relation `subtask of`.
4. List created subtask IDs to user.
Then proceed to Step 4, implementing subtasks in order.
## Step 4 — Fix
1. Implement fix (use `scala-implementer` agent for non-trivial changes; inline edits for small ones).
2. Run `./compile` — must be green.
3. Run `./test` — must be green.
4. Run `./lint` — must be green.
If any step fails, iterate until all pass.
## Step 5 — Review
Spawn `cavecrew-reviewer` on the full diff.
Display findings grouped by severity.
## Step 6 — Confirm + Push
Show summary: ticket, branch, files changed, review findings.
**Use `AskUserQuestion` tool to ask for explicit approval before pushing.** Include any open questions about commit message scope or body if unclear.
On approval, commit following Conventional Commits:
```
<type>(<scope>): <short description, imperative, ≤50 chars>
<optional body: what changed and why, wrap at 72 chars>
Closes $ARGUMENTS
https://knockoutwhist.youtrack.cloud/issue/$ARGUMENTS
```
- `type`: same as branch type (`fix`, `feat`, `refactor`, `chore`, etc.)
- `scope`: affected module (`core`, `rule`, `api`, `bot`, `io`)
- Subject: imperative mood, no period, lowercase
- Footer `Closes $ARGUMENTS` and ticket URL always present
Push branch to remote.
## Step 7 — Comment on Ticket
After successful push, call `mcp__youtrack__add_issue_comment` on `$ARGUMENTS` with:
```
Branch `<branch-name>` pushed.
<one-sentence summary of what was changed and why>
Files changed:
- <file1>
- <file2>
```
## Step 8 — Cleanup
Call `ExitWorktree` to delete the worktree.
Report: branch pushed, ticket commented, worktree deleted, done.
+84
View File
@@ -0,0 +1,84 @@
# Implement Feature from YouTrack
Automated feature-implementation workflow. Ticket ID: `$ARGUMENTS`
## Step 1 — Fetch Ticket
Call `mcp__youtrack__get_issue` with ID `$ARGUMENTS`.
Extract and display: summary, description, acceptance criteria, Priority, Subsystem.
## Step 2 — Create Worktree
Derive branch name from ticket:
- `type` from YouTrack issue type: `feature`/`task``feat`, `refactor``refactor`, `bug``fix`, else `chore`
- `scope` from affected module/component (kebab-case, omit if unclear)
- `description` from ticket summary: lowercase, kebab-case, max 40 chars, drop articles
Branch format: `<type>/<ticket-id>-<description>`
Example: `feat/NOW-456-add-bot-difficulty-slider`
Call `EnterWorktree` with that branch name.
All subsequent file work happens inside this worktree.
## Step 3 — Understand Requirements (read-only)
1. Run `./compile` — confirm baseline is green.
2. Run `./test` — confirm baseline is green.
3. Spawn `cavecrew-investigator` with: ticket description + acceptance criteria → locate affected files, relevant types/interfaces, entry points, integration touch-points.
4. **If anything is ambiguous (scope unclear, acceptance criteria missing, design decisions needed), use `AskUserQuestion` tool to ask — max 4 questions at once.**
5. **Report plan to user: what will be added/changed, which files, which modules. No file writes yet. Wait for acknowledgement before continuing.**
## Step 4 — Implement
1. Implement feature (use `scala-implementer` agent for non-trivial changes; inline edits for small ones).
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).
4. Run `./lint` — must be green.
If any step fails, iterate until all pass.
## Step 5 — Review
Spawn `cavecrew-reviewer` on the full diff.
Display findings grouped by severity.
## Step 6 — Confirm + Push
Show summary: ticket, branch, files changed, review findings.
**Use `AskUserQuestion` tool to ask for explicit approval before pushing.** Include any open questions about commit message scope or body if unclear.
On approval, commit following Conventional Commits:
```
<type>(<scope>): <short description, imperative, ≤50 chars>
<optional body: what changed and why, wrap at 72 chars>
Closes $ARGUMENTS
https://knockoutwhist.youtrack.cloud/issue/$ARGUMENTS
```
- `type`: same as branch type (`feat`, `refactor`, `chore`, etc.)
- `scope`: affected module (`core`, `rule`, `api`, `bot`, `io`)
- Subject: imperative mood, no period, lowercase
- Footer `Closes $ARGUMENTS` and ticket URL always present
Push branch to remote.
## Step 7 — Comment on Ticket
After successful push, call `mcp__youtrack__add_issue_comment` on `$ARGUMENTS` with:
```
Branch `<branch-name>` pushed.
<one-sentence summary of what was added and why>
Files changed:
- <file1>
- <file2>
```
## Step 8 — Cleanup
Call `ExitWorktree` to delete the worktree.
Report: branch pushed, ticket commented, worktree deleted, done.
+82
View File
@@ -0,0 +1,82 @@
# Split Story into Subtasks in YouTrack
Split a user story into smaller, implementable subtasks. Story ID: `$ARGUMENTS`
## Step 1 — Fetch Story
Call `mcp__youtrack__get_issue` with ID `$ARGUMENTS`.
Extract and display: summary, description, acceptance criteria, implementation notes.
## Step 2 — Research (if needed)
If the story involves unfamiliar domain logic or technical constraints:
- Search repo for relevant code (`Grep`/`Bash`).
- Use `WebSearch` for external standards or protocols.
- Do NOT guess. Surface findings before proposing splits.
## Step 3 — Propose Split
Analyse the story and propose a set of subtasks. Rules:
- Each subtask = one unit of work, completable independently or in sequence.
- No subtask should exceed ~2 days of work.
- Name subtasks in imperative mood (e.g. "Implement move validation endpoint").
- Cover the full scope of the parent story — no gaps.
Show proposed subtask list to user (titles only) and ask:
**Use `AskUserQuestion` tool:**
- Does the split look right?
- Any subtasks to add, remove, or merge?
- Should any subtask be assigned to a specific person?
Incorporate feedback. Repeat until user approves the list.
## Step 4 — Draft Each Subtask
For each approved subtask, compose description using this template:
```
[Brief description of what needs to be done for this subtask.]
Steps / Tasks
- Task 1
- Task 2
- Task 3
Definition of Done
What must be true for this subtask to be considered complete:
- Code implemented
- Tests passed
- Reviewed and merged
```
Rules:
- Steps/Tasks: concrete, ordered where order matters.
- Definition of Done: adjust per subtask — not all subtasks need the same criteria (e.g. a research spike has different DoD than an implementation task).
- Keep description short — one paragraph max.
## Step 5 — Determine Project
Inherit project from parent story (`$ARGUMENTS`):
- If parent is `NCWF-*` → subtasks go in `NCWF`
- If parent is `NCS-*` → subtasks go in `NCS`
## Step 6 — Create Subtasks
For each subtask call `mcp__youtrack__create_issue` with:
- `project`: from Step 5
- `summary`: subtask title (≤72 chars, sentence case)
- `description`: full formatted description from Step 4 (Markdown)
- `type`: `Task`
Then call `mcp__youtrack__link_issues` to link each created subtask to `$ARGUMENTS` with relation `subtask of`.
## Step 7 — Report
List all created subtask IDs and summaries.
Display parent story link.
Ask if any subtask needs further splitting.
+5
View File
@@ -48,3 +48,8 @@ graphify-out/
.DS_Store .DS_Store
/jacoco-reporter/.venv/ /jacoco-reporter/.venv/
/.claude/settings.local.json /.claude/settings.local.json
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
java_pid2736.hprof
+623
View File
@@ -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"
+34
View File
@@ -348,3 +348,37 @@
* 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-03)
### 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))
* **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))
@@ -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,6 +46,8 @@ 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)
case class SyncOfficialBotsRequest(bots: List[String])
@@ -89,6 +89,13 @@ class OfficialBotAccountRepository:
def findAll(): List[OfficialBotAccount] = def findAll(): List[OfficialBotAccount] =
em.createQuery("FROM OfficialBotAccount", classOf[OfficialBotAccount]).getResultList.asScala.toList em.createQuery("FROM OfficialBotAccount", classOf[OfficialBotAccount]).getResultList.asScala.toList
def findByName(name: String): Option[OfficialBotAccount] =
em.createQuery("FROM OfficialBotAccount WHERE name = :name", classOf[OfficialBotAccount])
.setParameter("name", name)
.getResultList
.asScala
.headOption
def persist(bot: OfficialBotAccount): OfficialBotAccount = def persist(bot: OfficialBotAccount): OfficialBotAccount =
em.persist(bot) em.persist(bot)
bot bot
@@ -4,6 +4,7 @@ import de.nowchess.account.domain.{BotAccount, OfficialBotAccount, UserAccount}
import de.nowchess.account.dto.* import de.nowchess.account.dto.*
import de.nowchess.account.error.AccountError import de.nowchess.account.error.AccountError
import de.nowchess.account.service.AccountService import de.nowchess.account.service.AccountService
import de.nowchess.security.InternalOnly
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject import jakarta.inject.Inject
@@ -179,6 +180,13 @@ class AccountResource:
createdAt = bot.createdAt.toString, createdAt = bot.createdAt.toString,
) )
@POST
@Path("/official-bots/sync")
@InternalOnly
def syncOfficialBots(req: SyncOfficialBotsRequest): Response =
accountService.syncOfficialBots(req.bots)
Response.noContent().build()
@GET @GET
@Path("/official-bots") @Path("/official-bots")
def getOfficialBots: Response = def getOfficialBots: Response =
@@ -187,11 +195,11 @@ class AccountResource:
@POST @POST
@Path("/official-bots") @Path("/official-bots")
@RolesAllowed(Array("Admin")) @RolesAllowed(Array("**"))
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()
@@ -211,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),
)
@@ -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,8 +205,23 @@ 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
def syncOfficialBots(botNames: List[String]): Unit =
botNames.foreach { name =>
if officialBotAccountRepository.findByName(name).isEmpty then
val bot = new OfficialBotAccount()
bot.name = name
bot.createdAt = Instant.now()
officialBotAccountRepository.persist(bot)
bot.token = generateBotToken(bot.id, bot.name)
officialBotAccountRepository.persist(bot)
log.infof("Auto-registered official bot: %s", name)
}
def getOfficialBotAccounts(): List[OfficialBotAccount] = def getOfficialBotAccounts(): List[OfficialBotAccount] =
officialBotAccountRepository.findAll() officialBotAccountRepository.findAll()
@@ -217,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
@@ -154,3 +154,35 @@ class AccountResourceTest:
.post("/api/account/refresh") .post("/api/account/refresh")
.`then`() .`then`()
.statusCode(401) .statusCode(401)
@Test
def syncOfficialBotsCreatesNewBots(): Unit =
givenRequest()
.body("""{"bots":["sync-easy","sync-hard"]}""")
.when()
.post("/api/account/official-bots/sync")
.`then`()
.statusCode(204)
RestAssured
.`given`()
.when()
.get("/api/account/official-bots")
.`then`()
.statusCode(200)
.body("name", hasItems("sync-easy", "sync-hard"))
@Test
def syncOfficialBotsIsIdempotent(): Unit =
val body = """{"bots":["idempotent-bot"]}"""
givenRequest()
.body(body)
.when()
.post("/api/account/official-bots/sync")
.`then`()
.statusCode(204)
givenRequest()
.body(body)
.when()
.post("/api/account/official-bots/sync")
.`then`()
.statusCode(204)
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0 MAJOR=0
MINOR=18 MINOR=19
PATCH=0 PATCH=0
@@ -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
@@ -95,7 +99,14 @@ class GameRedisSubscriberManager:
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 +117,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 +134,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 +209,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))
+23
View File
@@ -156,3 +156,26 @@
### 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-03)
### 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))
* 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))
+2
View File
@@ -77,6 +77,8 @@ dependencies {
implementation(project(":modules:api")) implementation(project(":modules:api"))
implementation(project(":modules:io")) implementation(project(":modules:io"))
implementation(project(":modules:rule")) implementation(project(":modules:rule"))
implementation(project(":modules:security"))
implementation("io.quarkus:quarkus-rest-client-jackson")
implementation("com.microsoft.onnxruntime:onnxruntime:${versions["ONNXRUNTIME"]!!}") implementation("com.microsoft.onnxruntime:onnxruntime:${versions["ONNXRUNTIME"]!!}")
implementation("io.quarkus:quarkus-redis-client") implementation("io.quarkus:quarkus-redis-client")
@@ -5,6 +5,9 @@ quarkus:
name: nowchess-official-bots name: nowchess-official-bots
redis: redis:
hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379} hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
rest-client:
account-service:
url: http://localhost:8083
smallrye-jwt: smallrye-jwt:
enabled: true enabled: true
log: log:
@@ -15,6 +18,8 @@ nowchess:
host: localhost host: localhost
port: 6379 port: 6379
prefix: nowchess prefix: nowchess
internal:
secret: 123abc
"%deployed": "%deployed":
quarkus: quarkus:
@@ -28,8 +33,13 @@ nowchess:
exporter: exporter:
otlp: otlp:
endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4317} endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4317}
rest-client:
account-service:
url: ${ACCOUNT_SERVICE_URL}
nowchess: nowchess:
redis: redis:
host: ${REDIS_HOST:localhost} host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379} port: ${REDIS_PORT:6379}
prefix: ${REDIS_PREFIX:nowchess} prefix: ${REDIS_PREFIX:nowchess}
internal:
secret: ${INTERNAL_SECRET}
@@ -0,0 +1,20 @@
package de.nowchess.bot.client
import de.nowchess.security.{InternalClientHeadersFactory, InternalSecretClientFilter}
import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType
import org.eclipse.microprofile.rest.client.annotation.{RegisterClientHeaders, RegisterProvider}
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
case class SyncOfficialBotsRequest(bots: List[String])
@Path("/api/account/official-bots")
@RegisterRestClient(configKey = "account-service")
@RegisterProvider(classOf[InternalSecretClientFilter])
@RegisterClientHeaders(classOf[InternalClientHeadersFactory])
trait AccountServiceClient:
@POST
@Path("/sync")
@Consumes(Array(MediaType.APPLICATION_JSON))
def syncBots(req: SyncOfficialBotsRequest): Unit
@@ -2,112 +2,169 @@ package de.nowchess.bot.service
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
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.Bot
import de.nowchess.bot.BotDifficulty import de.nowchess.bot.BotDifficulty
import de.nowchess.bot.bots.ClassicalBot
import de.nowchess.bot.config.RedisConfig 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.ReactiveRedisDataSource
import io.quarkus.redis.datasource.pubsub.ReactivePubSubCommands
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.jboss.logging.Logger
import scala.compiletime.uninitialized import scala.compiletime.uninitialized
import java.time.Duration
import java.util.concurrent.{ConcurrentHashMap, ExecutorService, Executors}
import java.util.function.Consumer import java.util.function.Consumer
import java.util.concurrent.TimeUnit
@ApplicationScoped @ApplicationScoped
class OfficialBotService: class OfficialBotService:
private val log = Logger.getLogger(classOf[OfficialBotService])
// scalafix:off DisableSyntax.var // scalafix:off DisableSyntax.var
@Inject var redis: RedisDataSource = uninitialized @Inject var reactiveRedis: ReactiveRedisDataSource = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized @Inject var redisConfig: RedisConfig = uninitialized
@Inject var objectMapper: ObjectMapper = uninitialized @Inject var objectMapper: ObjectMapper = uninitialized
@Inject var botController: BotController = uninitialized @Inject var meterRegistry: MeterRegistry = uninitialized
@Inject var meterRegistry: MeterRegistry = uninitialized
// scalafix:on DisableSyntax.var // scalafix:on DisableSyntax.var
private val engine: Bot = ClassicalBot(BotDifficulty.Medium)
private val workers: ExecutorService = Executors.newCachedThreadPool()
// gameId -> (turn color -> bot account id we play that color as)
private val watchedGames = new ConcurrentHashMap[String, ConcurrentHashMap[String, String]]()
private val subscribers = new ConcurrentHashMap[String, ReactivePubSubCommands.ReactiveRedisSubscriber]()
private val terminalStatuses = private val terminalStatuses =
Set("checkmate", "resign", "timeout", "stalemate", "insufficientMaterial", "draw") Set("checkmate", "resign", "timeout", "stalemate", "insufficientMaterial", "draw")
@PostConstruct @PostConstruct
def initializeMetrics(): Unit = def initializeMetrics(): Unit =
BotController.listBots.foreach { bot => meterRegistry
meterRegistry.timer("nowchess.bot.move.duration", "bot", bot).record(0L, TimeUnit.MILLISECONDS) .timer("nowchess.bot.move.duration", "bot", engineName)
meterRegistry.counter("nowchess.bot.moves.computed", "bot", bot).increment(0) .record(0L, java.util.concurrent.TimeUnit.MILLISECONDS)
} meterRegistry.counter("nowchess.bot.moves.computed", "bot", engineName).increment(0)
// Wildcard subscription: handles gameStart events for *any* bot account,
// whether official (easy/medium/hard/expert) or user-created. Runs at startup
// on a context-bearing thread, so the blocking await is safe here.
def onStart(@Observes event: StartupEvent): Unit = def onStart(@Observes event: StartupEvent): Unit =
BotController.listBots.foreach(subscribeToEventChannel) val pattern = s"${redisConfig.prefix}:bot:*:events"
val handler: Consumer[String] = msg => handleBotEvent(msg)
private def subscribeToEventChannel(botName: String): Unit = reactiveRedis
val handler: Consumer[String] = msg => handleBotEvent(botName, msg) .pubsub(classOf[String])
redis.pubsub(classOf[String]).subscribe(s"${redisConfig.prefix}:bot:$botName:events", handler) .subscribeToPattern(pattern, handler)
.await()
.atMost(Duration.ofSeconds(5))
log.infof("Bot engine subscribed to pattern %s", pattern)
() ()
private def handleBotEvent(botName: String, msg: String): Unit = private def handleBotEvent(msg: String): Unit =
try try
val node = objectMapper.readTree(msg) val node = objectMapper.readTree(msg)
if node.path("type").asText() == "gameStart" then if node.path("type").asText() == "gameStart" then
val gameId = node.path("gameId").asText() val gameId = node.path("gameId").asText()
val playingAs = node.path("playingAs").asText() val playingAs = node.path("playingAs").asText()
val difficulty = node.path("difficulty").asInt(1400) val playerId = node.path("botAccountId").asText()
val botAccountId = node.path("botAccountId").asText() if gameId.nonEmpty && playerId.nonEmpty && playingAs.nonEmpty then
watchGame(botName, gameId, playingAs, difficulty, botAccountId) log.infof("gameStart: game=%s playingAs=%s bot=%s", gameId, playingAs, playerId)
catch case _: Exception => () registerColor(gameId, playingAs, playerId)
else log.warnf("gameStart missing gameId/playingAs/botAccountId: %s", msg)
catch case e: Exception => log.errorf(e, "Failed to handle bot event: %s", msg)
private def watchGame( private def registerColor(gameId: String, playingAs: String, playerId: String): Unit =
botName: String, val fresh = new ConcurrentHashMap[String, String]()
gameId: String, val existing = watchedGames.putIfAbsent(gameId, fresh)
playingAs: String, val colors = if existing == null then fresh else existing
difficulty: Int, colors.put(playingAs, playerId)
botAccountId: String, // Only the first color registered for a game subscribes + triggers the position.
): Unit = if existing == null then subscribeAndConnect(gameId)
val handler: Consumer[String] = msg => handleGameEvent(botName, gameId, playingAs, difficulty, botAccountId, msg)
redis.pubsub(classOf[String]).subscribe(s"${redisConfig.prefix}:game:$gameId:s2c", handler)
() ()
private def handleGameEvent( // Called on the Redis event-loop thread (the pattern handler's thread), which
botName: String, // carries a Vert.x context. The subscribe must be issued non-blocking from this
gameId: String, // context — issuing it from a plain worker thread (or awaiting it there) leaves
playingAs: String, // the subscribe reply with "no handler waiting" and the call times out.
difficulty: Int, private def subscribeAndConnect(gameId: String): Unit =
botAccountId: String, val s2c = s"${redisConfig.prefix}:game:$gameId:s2c"
msg: String, val handler: Consumer[String] = msg => handleGameEvent(gameId, msg)
): Unit = val pubsub = reactiveRedis.pubsub(classOf[String])
try val onSubscribed: Consumer[ReactivePubSubCommands.ReactiveRedisSubscriber] = { subscriber =>
val node = objectMapper.readTree(msg) subscribers.put(gameId, subscriber)
val status = node.path("state").path("status").asText("") sendConnected(pubsub, gameId)
if !terminalStatuses.contains(status) then
val turn = node.path("state").path("turn").asText("")
if turn == playingAs then
val fen = node.path("state").path("fen").asText()
computeAndSendMove(botName, gameId, fen, difficulty, botAccountId)
catch case _: Exception => ()
private def computeAndSendMove(
botName: String,
gameId: String,
fen: String,
difficulty: Int,
botAccountId: String,
): Unit =
val level = DifficultyMapper.fromElo(difficulty).getOrElse(BotDifficulty.Medium)
botController.getBot(botName).orElse(botController.getBot(level.toString.toLowerCase)).foreach { bot =>
FenParser.parseFen(fen).toOption.foreach { context =>
val timer = meterRegistry.timer("nowchess.bot.move.duration", "bot", botName)
val moveOpt = timer.recordCallable[Option[Move]](() => bot(context))
moveOpt.foreach { move =>
meterRegistry.counter("nowchess.bot.moves.computed", "bot", botName).increment()
val uci = toUci(move)
val c2sTopic = s"${redisConfig.prefix}:game:$gameId:c2s"
val moveMsg = s"""{"type":"MOVE","uci":"$uci","playerId":"$botAccountId"}"""
redis.pubsub(classOf[String]).publish(c2sTopic, moveMsg)
()
}
}
} }
pubsub.subscribe(s2c, handler).subscribe().`with`(onSubscribed, logFailure(s"subscribe to game $gameId"))
()
// Ask the server to emit the current position (gameFull) immediately.
private def sendConnected(pubsub: ReactivePubSubCommands[String], gameId: String): Unit =
val c2s = s"${redisConfig.prefix}:game:$gameId:c2s"
val onOk: Consumer[Void] = _ => log.infof("Watching game %s", gameId)
pubsub.publish(c2s, """{"type":"CONNECTED"}""").subscribe().`with`(onOk, logFailure(s"send CONNECTED for game $gameId"))
()
private def handleGameEvent(gameId: String, msg: String): Unit =
try
val node = objectMapper.readTree(msg)
// gameState has state at root; gameFull (CONNECTED reply) nests it under game.state
val stateNode =
if node.path("type").asText() == "gameFull" then node.path("game").path("state")
else node.path("state")
val status = stateNode.path("status").asText("")
val turn = stateNode.path("turn").asText("")
if terminalStatuses.contains(status) then stopWatching(gameId)
else
val colors = watchedGames.get(gameId)
val playerId = if colors == null then null else colors.get(turn)
if playerId != null then
val fen = stateNode.path("fen").asText()
workers.submit(new Runnable { def run(): Unit = computeAndSendMove(gameId, fen, playerId) })
()
catch case e: Exception => log.errorf(e, "Failed to handle game event for %s", gameId)
private def stopWatching(gameId: String): Unit =
watchedGames.remove(gameId)
Option(subscribers.remove(gameId)).foreach { subscriber =>
val noop: Consumer[Void] = _ => ()
subscriber
.unsubscribe(s"${redisConfig.prefix}:game:$gameId:s2c")
.subscribe()
.`with`(noop, logFailure(s"unsubscribe game $gameId"))
}
()
// Runs on a worker thread: the engine search must stay off the event loop. The
// move is published reactively (scheduled onto the Redis event loop), so the
// worker thread's lack of a Vert.x context does not matter for publishing.
private def computeAndSendMove(gameId: String, fen: String, playerId: String): Unit =
FenParser.parseFen(fen) match
case Left(err) => log.warnf("FEN parse failed for game %s: %s", gameId, err.toString)
case Right(context) =>
val timer = meterRegistry.timer("nowchess.bot.move.duration", "bot", engineName)
val moveOpt = timer.recordCallable[Option[Move]](() => engine(context))
moveOpt match
case None => log.warnf("Engine returned no move for game %s fen=%s", gameId, fen)
case Some(move) =>
meterRegistry.counter("nowchess.bot.moves.computed", "bot", engineName).increment()
val uci = toUci(move)
val moveMsg = s"""{"type":"MOVE","uci":"$uci","playerId":"$playerId"}"""
val onOk: Consumer[Void] = _ => log.infof("Bot moved %s in game %s", uci, gameId)
reactiveRedis
.pubsub(classOf[String])
.publish(s"${redisConfig.prefix}:game:$gameId:c2s", moveMsg)
.subscribe()
.`with`(onOk, logFailure(s"publish move in game $gameId"))
()
private def logFailure(what: String): Consumer[Throwable] =
err => log.errorf(err, "Failed to %s", what)
private def engineName: String = "classical"
private def toUci(move: Move): String = private def toUci(move: Move): String =
val base = s"${move.from}${move.to}" val base = s"${move.from}${move.to}"
@@ -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
@@ -0,0 +1,219 @@
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)
if stream != null then consumeGameStream(cfg, gameId, color, stream)
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
var line = reader.readLine()
// scalafix:on DisableSyntax.var
while line != null && running && !done do
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 _ => ()
line = reader.readLine()
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): 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 response.readEntity(classOf[InputStream])
else { log.warnf("Game stream %s returned status %d", gameId, response.getStatus); response.close(); null }
}.getOrElse(null)
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))
// scalafix:off DisableSyntax.var
var line: String = reader.readLine()
// scalafix:on DisableSyntax.var
while line != null && running do
Try(handle(line)).failed.foreach(ex => log.warnf(ex, "Error handling stream line"))
line = reader.readLine()
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 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0 MAJOR=0
MINOR=13 MINOR=14
PATCH=0 PATCH=0
+33
View File
@@ -453,3 +453,36 @@
* **redis:** update Redis configuration with max pool size and waiting parameters ([5baf6a7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5baf6a7cdbea484fc49c02e2b5a1c3919b7fa2c4)) * **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)) * remove unused HTTP root-path configurations from application.yml ([3ed3e59](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ed3e59ee456d54cd3d65ece4f36623e256b9736))
* **store:** replace null check with Option for stream messages ([252851d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/252851de1cd715f797847e0660ee501c3a77237e)) * **store:** replace null check with Option for stream messages ([252851d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/252851de1cd715f797847e0660ee501c3a77237e))
## (2026-06-03)
### 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:** add GameWritebackEventDtoMixin for JSON deserialization ([381161f](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/381161f00345612a1789e08243746083dff884c5))
* **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))
* **config:** update application.yml to nest HTTP port configuration ([3efebd5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3efebd5ed0493159c51f7246d18d59bac58cf875))
* 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))
* **dto:** update GameWritebackEventDto for JSON deserialization and remove unused mixin ([576e3fe](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/576e3fea9bf1082549ea53efd3288474c42be93d))
* 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))
* 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))
* **redis:** add @Startup annotation to GameWritebackStreamListener ([d61fe97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d61fe97b4c8e2db5e34b4a14d995297cc09f9435))
* **redis:** implement game writeback stream processing with error handling and retries ([ae3ef76](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/ae3ef766e8b7596a09e466cd4fb386119f17ca5c))
* **redis:** use ManagedExecutor for asynchronous writeback processing ([af6b0ed](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/af6b0ed8b73724fcc8f20dfccbe6fe8f84fd792d))
* 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
* ensure full hierarchy registration for reflection in NativeReflectionConfig ([ebba729](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/ebba729af3265df1619dfbe46fd1945b2a7e30b7))
* 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))
* **redis:** add log message for starting Writeback listener ([b610678](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b610678005de645115f48348e66aa9e6f5deb3d5))
* **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))
* **store:** cap game-writeback stream with MAXLEN trimming ([#58](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/58)) ([32c3887](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/32c388760b4f40f2937e4f3b4a04495313144fc0))
* **store:** replace null check with Option for stream messages ([252851d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/252851de1cd715f797847e0660ee501c3a77237e))
@@ -5,7 +5,7 @@ import de.nowchess.api.dto.GameWritebackEventDto
import de.nowchess.store.config.RedisConfig import de.nowchess.store.config.RedisConfig
import de.nowchess.store.service.GameWritebackService import de.nowchess.store.service.GameWritebackService
import io.quarkus.redis.datasource.RedisDataSource import io.quarkus.redis.datasource.RedisDataSource
import io.quarkus.redis.datasource.stream.{StreamMessage, XGroupCreateArgs, XReadGroupArgs} import io.quarkus.redis.datasource.stream.{StreamMessage, XAddArgs, XGroupCreateArgs, XReadGroupArgs}
import io.quarkus.runtime.Startup import io.quarkus.runtime.Startup
import jakarta.annotation.PostConstruct import jakarta.annotation.PostConstruct
import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.context.ApplicationScoped
@@ -32,10 +32,11 @@ class GameWritebackStreamListener:
private val log = Logger.getLogger(classOf[GameWritebackStreamListener]) private val log = Logger.getLogger(classOf[GameWritebackStreamListener])
private val groupName = "store-writeback" private val groupName = "store-writeback"
private def streamKey = s"${redisConfig.prefix}:game-writeback" private def streamKey = s"${redisConfig.prefix}:game-writeback"
private def dlqKey = s"${redisConfig.prefix}:game-writeback-dlq" private def dlqKey = s"${redisConfig.prefix}:game-writeback-dlq"
private val maxRetries = 3 private val maxRetries = 3
private val consumerId = UUID.randomUUID().toString private val consumerId = UUID.randomUUID().toString
private val maxStreamLen = 1000L
@PostConstruct @PostConstruct
def startListening(): Unit = def startListening(): Unit =
@@ -98,6 +99,14 @@ class GameWritebackStreamListener:
case Success(_) => () case Success(_) => ()
private def xadd(key: String, json: String, attempt: Int): Unit = private def xadd(key: String, json: String, attempt: Int): Unit =
Try(redis.stream(classOf[String]).xadd(key, Map("data" -> json, "attempt" -> attempt.toString).asJava)) match Try(
redis
.stream(classOf[String])
.xadd(
key,
new XAddArgs().maxlen(maxStreamLen).nearlyExactTrimming(),
Map("data" -> json, "attempt" -> attempt.toString).asJava,
),
) match
case Failure(ex) => log.errorf(ex, "Failed to publish to stream %s", key) case Failure(ex) => log.errorf(ex, "Failed to publish to stream %s", key)
case Success(_) => () case Success(_) => ()
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0 MAJOR=0
MINOR=23 MINOR=24
PATCH=0 PATCH=0
+120
View File
@@ -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,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-----
@@ -0,0 +1,28 @@
package de.nowchess.tournament.client
import de.nowchess.security.{InternalClientHeadersFactory, InternalSecretClientFilter}
import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType
import org.eclipse.microprofile.rest.client.annotation.{RegisterClientHeaders, RegisterProvider}
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
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],
)
case class CoreGameResponse(gameId: String)
@Path("/api/board/game")
@RegisterRestClient(configKey = "core-service")
@RegisterProvider(classOf[InternalSecretClientFilter])
@RegisterClientHeaders(classOf[InternalClientHeadersFactory])
trait CoreGameClient:
@POST
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def createGame(req: CoreCreateGameRequest): CoreGameResponse
@@ -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
@@ -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
@@ -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")
@@ -0,0 +1,90 @@
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 =
var running = true
while running do
Try {
val messages = redis.stream(classOf[String]).xreadgroup(
groupName,
consumerId,
streamKey,
">",
new XReadGroupArgs().count(10).block(java.time.Duration.ofSeconds(2)),
)
if messages != null then messages.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.isInstanceOf[InterruptedException] ||
(ex.getCause != null && ex.getCause.isInstanceOf[InterruptedException])
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(_) => ()
@@ -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 p.id == null then
em.persist(p)
p
else em.merge(p)
@@ -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 p.id == null 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))
@@ -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)
@@ -0,0 +1,184 @@
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()
@@ -0,0 +1,68 @@
package de.nowchess.tournament.service
import de.nowchess.tournament.domain.{TournamentParticipant, TournamentPairing}
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 =
var i = 0
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)
@@ -0,0 +1,303 @@
package de.nowchess.tournament.service
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
// 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 = s"""{"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 = if t.winnerId != null then Some(BotRef(t.winnerId, t.winnerName)) else None,
)
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
}
@@ -0,0 +1,32 @@
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,15 @@
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",
)
@@ -0,0 +1,233 @@
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")
@@ -0,0 +1,73 @@
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)
+1
View File
@@ -26,4 +26,5 @@ include(
"modules:ws", "modules:ws",
"modules:store", "modules:store",
"modules:coordinator", "modules:coordinator",
"modules:tournament",
) )