Add Maia chess gameplay and deployment
This commit is contained in:
@@ -19,9 +19,17 @@ jobs:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Run tests
|
||||
- name: Run backend tests
|
||||
run: ./gradlew test --no-daemon
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Compile Maia engine bridge
|
||||
run: python -m py_compile maia-engine/app/main.py
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
@@ -38,7 +46,16 @@ jobs:
|
||||
- name: Build boot jar
|
||||
run: ./gradlew clean bootJar -x test --no-daemon
|
||||
|
||||
- name: Deploy jar over SSH
|
||||
- name: Package Maia engine
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tar \
|
||||
--exclude='__pycache__' \
|
||||
--exclude='*.pyc' \
|
||||
-czf maia-engine.tar.gz \
|
||||
maia-engine
|
||||
|
||||
- name: Deploy backend and Maia engine over SSH
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
|
||||
@@ -50,8 +67,11 @@ jobs:
|
||||
port="${DEPLOY_PORT:-22}"
|
||||
remote_dir="/root/blog-backend"
|
||||
service_name="blog-api.service"
|
||||
maia_service_name="maia-engine.service"
|
||||
jar_name="blog-backend.jar"
|
||||
remote_tmp="/tmp/${jar_name}.new"
|
||||
maia_archive_tmp="/tmp/maia-engine.tar.gz.new"
|
||||
maia_service_tmp="/tmp/maia-engine.service.new"
|
||||
|
||||
jar_file="$(find build/libs -maxdepth 1 -type f -name '*.jar' ! -name '*plain.jar' | sort | head -n 1)"
|
||||
if [ -z "$jar_file" ]; then
|
||||
@@ -65,12 +85,125 @@ jobs:
|
||||
ssh-keyscan -p "$port" "$DEPLOY_HOST" >> ~/.ssh/known_hosts
|
||||
|
||||
scp -i ~/.ssh/blog-backend-deploy -P "$port" "$jar_file" "$DEPLOY_USER@$DEPLOY_HOST:$remote_tmp"
|
||||
scp -i ~/.ssh/blog-backend-deploy -P "$port" maia-engine.tar.gz "$DEPLOY_USER@$DEPLOY_HOST:$maia_archive_tmp"
|
||||
scp -i ~/.ssh/blog-backend-deploy -P "$port" deploy/systemd/maia-engine.service "$DEPLOY_USER@$DEPLOY_HOST:$maia_service_tmp"
|
||||
|
||||
ssh -i ~/.ssh/blog-backend-deploy -p "$port" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"REMOTE_DIR='$remote_dir' SERVICE_NAME='$service_name' JAR_NAME='$jar_name' REMOTE_TMP='$remote_tmp' bash -s" <<'EOF'
|
||||
"REMOTE_DIR='$remote_dir' SERVICE_NAME='$service_name' MAIA_SERVICE_NAME='$maia_service_name' JAR_NAME='$jar_name' REMOTE_TMP='$remote_tmp' MAIA_ARCHIVE_TMP='$maia_archive_tmp' MAIA_SERVICE_TMP='$maia_service_tmp' bash -s" <<'EOF'
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p "$REMOTE_DIR"
|
||||
|
||||
MAIA_DIR="$REMOTE_DIR/maia-engine"
|
||||
MAIA_NEW_DIR="$REMOTE_DIR/maia-engine.new"
|
||||
MAIA_VENV_DIR="$REMOTE_DIR/maia-engine-venv"
|
||||
MAIA_CACHE_DIR="$REMOTE_DIR/.cache/huggingface"
|
||||
MAIA_REQUIREMENTS_HASH_FILE="$MAIA_VENV_DIR/.requirements.sha256"
|
||||
|
||||
python_bin=""
|
||||
if command -v python3.11 >/dev/null 2>&1; then
|
||||
python_bin="$(command -v python3.11)"
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
python_bin="$(command -v python3)"
|
||||
fi
|
||||
|
||||
if [ -z "$python_bin" ]; then
|
||||
echo "python3.11 or python3 is required on the deploy server"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "git is required on the deploy server because maia3 is installed from GitHub"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf "$MAIA_NEW_DIR"
|
||||
mkdir -p "$MAIA_NEW_DIR" "$MAIA_CACHE_DIR"
|
||||
tar -xzf "$MAIA_ARCHIVE_TMP" -C "$MAIA_NEW_DIR" --strip-components=1
|
||||
|
||||
maia_backup_path=""
|
||||
if [ -d "$MAIA_DIR" ]; then
|
||||
maia_backup_path="$MAIA_DIR.$(date +%Y%m%d%H%M%S).bak"
|
||||
mv "$MAIA_DIR" "$maia_backup_path"
|
||||
fi
|
||||
mv "$MAIA_NEW_DIR" "$MAIA_DIR"
|
||||
|
||||
rollback_maia() {
|
||||
if [ -n "$maia_backup_path" ] && [ -d "$maia_backup_path" ]; then
|
||||
rm -rf "$MAIA_DIR"
|
||||
mv "$maia_backup_path" "$MAIA_DIR"
|
||||
systemctl restart "$MAIA_SERVICE_NAME" || true
|
||||
fi
|
||||
}
|
||||
|
||||
if [ ! -d "$MAIA_VENV_DIR" ]; then
|
||||
if ! "$python_bin" -m venv "$MAIA_VENV_DIR"; then
|
||||
rollback_maia
|
||||
echo "failed to create Python venv. Install python3-venv on the deploy server."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! "$MAIA_VENV_DIR/bin/python" -m pip install --upgrade pip; then
|
||||
rollback_maia
|
||||
exit 1
|
||||
fi
|
||||
requirements_hash="$(sha256sum "$MAIA_DIR/requirements.txt" | awk '{print $1}')"
|
||||
saved_requirements_hash=""
|
||||
if [ -f "$MAIA_REQUIREMENTS_HASH_FILE" ]; then
|
||||
saved_requirements_hash="$(cat "$MAIA_REQUIREMENTS_HASH_FILE")"
|
||||
fi
|
||||
|
||||
if [ "$requirements_hash" != "$saved_requirements_hash" ]; then
|
||||
if ! "$MAIA_VENV_DIR/bin/python" -m pip install -r "$MAIA_DIR/requirements.txt"; then
|
||||
rollback_maia
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$requirements_hash" > "$MAIA_REQUIREMENTS_HASH_FILE"
|
||||
fi
|
||||
|
||||
if ! "$MAIA_VENV_DIR/bin/python" -m py_compile "$MAIA_DIR/app/main.py"; then
|
||||
rollback_maia
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp "$MAIA_SERVICE_TMP" "/etc/systemd/system/$MAIA_SERVICE_NAME"
|
||||
chmod 0644 "/etc/systemd/system/$MAIA_SERVICE_NAME"
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$MAIA_SERVICE_NAME"
|
||||
|
||||
if ! systemctl restart "$MAIA_SERVICE_NAME"; then
|
||||
rollback_maia
|
||||
journalctl -u "$MAIA_SERVICE_NAME" -n 120 --no-pager
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep 5
|
||||
|
||||
if ! systemctl is-active --quiet "$MAIA_SERVICE_NAME"; then
|
||||
rollback_maia
|
||||
journalctl -u "$MAIA_SERVICE_NAME" -n 120 --no-pager
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
if ! curl -fsS http://127.0.0.1:8000/health >/dev/null; then
|
||||
rollback_maia
|
||||
journalctl -u "$MAIA_SERVICE_NAME" -n 120 --no-pager
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if ! "$python_bin" -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5).read()"; then
|
||||
rollback_maia
|
||||
journalctl -u "$MAIA_SERVICE_NAME" -n 120 --no-pager
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$maia_backup_path" ]; then
|
||||
rm -rf "$maia_backup_path"
|
||||
fi
|
||||
|
||||
cd "$REMOTE_DIR"
|
||||
|
||||
backup_path=""
|
||||
@@ -83,7 +216,7 @@ jobs:
|
||||
chown root:root "$JAR_NAME"
|
||||
chmod 0644 "$JAR_NAME"
|
||||
|
||||
rollback() {
|
||||
rollback_backend() {
|
||||
if [ -n "$backup_path" ] && [ -f "$backup_path" ]; then
|
||||
cp "$backup_path" "$JAR_NAME"
|
||||
systemctl restart "$SERVICE_NAME" || true
|
||||
@@ -91,7 +224,7 @@ jobs:
|
||||
}
|
||||
|
||||
if ! systemctl restart "$SERVICE_NAME"; then
|
||||
rollback
|
||||
rollback_backend
|
||||
journalctl -u "$SERVICE_NAME" -n 120 --no-pager
|
||||
exit 1
|
||||
fi
|
||||
@@ -99,10 +232,11 @@ jobs:
|
||||
sleep 8
|
||||
|
||||
if ! systemctl is-active --quiet "$SERVICE_NAME"; then
|
||||
rollback
|
||||
rollback_backend
|
||||
journalctl -u "$SERVICE_NAME" -n 120 --no-pager
|
||||
exit 1
|
||||
fi
|
||||
|
||||
systemctl is-active "$MAIA_SERVICE_NAME"
|
||||
systemctl is-active "$SERVICE_NAME"
|
||||
EOF
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
HELP.md
|
||||
.gradle
|
||||
.gradle-user-home/
|
||||
.env
|
||||
build/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
@@ -40,5 +41,9 @@ out/
|
||||
### Kotlin ###
|
||||
.kotlin
|
||||
|
||||
### Python ###
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
### docker ###
|
||||
postgres_data
|
||||
@@ -80,3 +80,52 @@ allOpen {
|
||||
tasks.withType<Test> {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
val compileKotlinTask = tasks.named("compileKotlin")
|
||||
val tempBuildDir = providers.provider {
|
||||
file(System.getenv("BLOG_BACKEND_ASCII_BUILD_DIR") ?: "${System.getenv("TEMP") ?: layout.buildDirectory.get().asFile.absolutePath}/blog-backend-build")
|
||||
}
|
||||
val javacKotlinClassesDir = providers.provider {
|
||||
tempBuildDir.get().resolve("kotlin-main-classes")
|
||||
}
|
||||
|
||||
val syncKotlinClassesForJavac = tasks.register<Sync>("syncKotlinClassesForJavac") {
|
||||
dependsOn(compileKotlinTask)
|
||||
from(compileKotlinTask.map { it.outputs.files })
|
||||
into(javacKotlinClassesDir)
|
||||
}
|
||||
|
||||
tasks.named<JavaCompile>("compileJava") {
|
||||
dependsOn(syncKotlinClassesForJavac)
|
||||
classpath = files(javacKotlinClassesDir) + configurations.compileClasspath.get()
|
||||
options.isIncremental = false
|
||||
}
|
||||
|
||||
val mainRuntimeClassesDir = providers.provider {
|
||||
tempBuildDir.get().resolve("main-runtime-classes")
|
||||
}
|
||||
val testRuntimeClassesDir = providers.provider {
|
||||
tempBuildDir.get().resolve("test-runtime-classes")
|
||||
}
|
||||
|
||||
val syncMainClassesForTestRuntime = tasks.register<Sync>("syncMainClassesForTestRuntime") {
|
||||
dependsOn(tasks.named("classes"))
|
||||
from(layout.buildDirectory.dir("classes/kotlin/main"))
|
||||
from(layout.buildDirectory.dir("classes/java/main"))
|
||||
from(layout.buildDirectory.dir("resources/main"))
|
||||
into(mainRuntimeClassesDir)
|
||||
}
|
||||
|
||||
val syncTestClassesForTestRuntime = tasks.register<Sync>("syncTestClassesForTestRuntime") {
|
||||
dependsOn(tasks.named("testClasses"))
|
||||
from(layout.buildDirectory.dir("classes/kotlin/test"))
|
||||
from(layout.buildDirectory.dir("classes/java/test"))
|
||||
from(layout.buildDirectory.dir("resources/test"))
|
||||
into(testRuntimeClassesDir)
|
||||
}
|
||||
|
||||
tasks.withType<Test> {
|
||||
dependsOn(syncMainClassesForTestRuntime, syncTestClassesForTestRuntime)
|
||||
testClassesDirs = files(testRuntimeClassesDir)
|
||||
classpath = files(testRuntimeClassesDir, mainRuntimeClassesDir) + classpath
|
||||
}
|
||||
|
||||
22
deploy/systemd/maia-engine.service
Normal file
22
deploy/systemd/maia-engine.service
Normal file
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=Maia3 chess engine bridge
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
WorkingDirectory=/root/blog-backend/maia-engine
|
||||
Environment=MAIA_MODEL=maia3-5m
|
||||
Environment=MAIA_DEVICE=cpu
|
||||
Environment=MAIA_USE_AMP=false
|
||||
Environment=HF_HOME=/root/blog-backend/.cache/huggingface
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
ExecStart=/root/blog-backend/maia-engine-venv/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port 8000
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStartSec=300
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -12,6 +12,7 @@ services:
|
||||
- SPRING_DATASOURCE_USERNAME=${DB_USER}
|
||||
- SPRING_DATASOURCE_PASSWORD=${DB_PASS}
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- MAIA_ENGINE_URL=http://maia-engine:8000
|
||||
# Redis
|
||||
- SPRING_DATA_REDIS_HOST=redis
|
||||
- SPRING_DATA_REDIS_PORT=6379
|
||||
@@ -37,10 +38,31 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
maia-engine:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- blog-net
|
||||
|
||||
# 2. 데이터베이스 (PostgreSQL)
|
||||
maia-engine:
|
||||
build: ./maia-engine
|
||||
container_name: maia-engine
|
||||
restart: always
|
||||
environment:
|
||||
- MAIA_MODEL=maia3-5m
|
||||
- MAIA_DEVICE=cpu
|
||||
- MAIA_USE_AMP=false
|
||||
- HF_HOME=/models/huggingface
|
||||
volumes:
|
||||
- maia_model_cache:/models
|
||||
healthcheck:
|
||||
test: [ "CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=2).read()" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
networks:
|
||||
- blog-net
|
||||
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
container_name: blog-db
|
||||
@@ -91,3 +113,6 @@ services:
|
||||
networks:
|
||||
blog-net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
maia_model_cache:
|
||||
|
||||
@@ -1,33 +1,43 @@
|
||||
# CI/CD 운영 메모
|
||||
|
||||
이 저장소는 Gitea Actions 기준으로 `main` 브랜치에 push되면 자동 배포되도록 구성한다.
|
||||
현재 운영 서버는 Docker Compose가 아니라 Java 21 + systemd 방식으로 실행 중이다.
|
||||
이 저장소는 Gitea Actions 기준으로 `main` 브랜치에 push되면 백엔드와 Maia3 체스 엔진 브리지를 함께 자동 배포하도록 구성되어 있다.
|
||||
|
||||
## 현재 운영 방식
|
||||
|
||||
- 운영 서비스: `blog-api.service`
|
||||
- 실행 디렉터리: `/root/blog-backend`
|
||||
- 실행 파일: `/root/blog-backend/blog-backend.jar`
|
||||
- 실행 방식: `java -jar -Duser.timezone=Asia/Seoul -Dspring.profiles.active=prod blog-backend.jar`
|
||||
- 운영 환경 변수와 외부 DB/Redis/S3 설정은 systemd service에 등록되어 있다.
|
||||
- 백엔드 서비스: `blog-api.service`
|
||||
- 백엔드 실행 디렉터리: `/root/blog-backend`
|
||||
- 백엔드 실행 파일: `/root/blog-backend/blog-backend.jar`
|
||||
- 백엔드 실행 방식: `java -jar -Duser.timezone=Asia/Seoul -Dspring.profiles.active=prod blog-backend.jar`
|
||||
- Maia 엔진 서비스: `maia-engine.service`
|
||||
- Maia 엔진 실행 디렉터리: `/root/blog-backend/maia-engine`
|
||||
- Maia 엔진 venv: `/root/blog-backend/maia-engine-venv`
|
||||
- Maia 엔진 URL: `http://127.0.0.1:8000`
|
||||
|
||||
## 동작 흐름
|
||||
백엔드는 기본값으로 `MAIA_ENGINE_URL=http://localhost:8000`을 사용하므로, Maia 엔진을 같은 서버에서 띄우면 별도 환경변수 없이 연동된다.
|
||||
|
||||
## 배포 흐름
|
||||
|
||||
1. `main` 브랜치 push 또는 수동 실행(`workflow_dispatch`)
|
||||
2. Java 21로 `./gradlew test --no-daemon` 실행
|
||||
3. 테스트 성공 시 `./gradlew clean bootJar -x test --no-daemon`으로 Spring Boot jar 빌드
|
||||
4. 빌드된 jar를 SSH/SCP로 서버의 `/tmp/blog-backend.jar.new`에 업로드
|
||||
5. 서버에서 기존 `/root/blog-backend/blog-backend.jar`를 timestamp 백업
|
||||
6. 새 jar를 `/root/blog-backend/blog-backend.jar`로 교체
|
||||
7. `systemctl restart blog-api.service` 실행 후 active 상태 확인
|
||||
8. 재시작 실패 또는 비정상 종료 시 직전 jar로 rollback 시도
|
||||
3. Python 3.11로 `maia-engine/app/main.py` 문법 검사
|
||||
4. 테스트 성공 시 `./gradlew clean bootJar -x test --no-daemon`으로 Spring Boot jar 빌드
|
||||
5. `maia-engine` 디렉터리를 tar로 패키징
|
||||
6. jar, Maia 엔진 tar, `deploy/systemd/maia-engine.service`를 SSH/SCP로 서버에 업로드
|
||||
7. 서버에서 Maia 엔진 코드를 `/root/blog-backend/maia-engine`으로 교체
|
||||
8. `/root/blog-backend/maia-engine-venv`를 생성하거나 재사용
|
||||
9. `requirements.txt` 해시가 바뀌었으면 Python 의존성 재설치
|
||||
10. `maia-engine.service` 설치, `systemctl daemon-reload`, enable, restart
|
||||
11. `http://127.0.0.1:8000/health` 헬스체크
|
||||
12. Spring Boot jar를 `/root/blog-backend/blog-backend.jar`로 교체
|
||||
13. `blog-api.service` 재시작 후 active 상태 확인
|
||||
|
||||
Maia 엔진 배포 실패 시 이전 Maia 코드 디렉터리로 롤백을 시도한다. 백엔드 jar 재시작 실패 시 직전 jar 백업으로 롤백을 시도한다.
|
||||
|
||||
## Gitea Actions 준비
|
||||
|
||||
- Repository Settings에서 Actions를 활성화한다.
|
||||
- Repository Settings에서 Actions가 활성화되어 있어야 한다.
|
||||
- Gitea Runner가 `ubuntu-latest` label을 처리할 수 있어야 한다.
|
||||
- 현재 백엔드 서버에는 `blog-backend` runner가 `ubuntu-latest` label로 등록되어 있다.
|
||||
- Docker는 runner job 실행에 쓰일 수 있지만, 애플리케이션 배포 방식은 systemd jar 교체 방식이다.
|
||||
- `actions/checkout`, `actions/setup-java`, `actions/setup-python` 액션을 사용할 수 있어야 한다.
|
||||
|
||||
## Repository Secrets
|
||||
|
||||
@@ -36,14 +46,38 @@ Gitea repository secrets에 다음 값을 등록한다.
|
||||
| 이름 | 설명 |
|
||||
| --- | --- |
|
||||
| `DEPLOY_HOST` | 배포 서버 호스트 또는 IP |
|
||||
| `DEPLOY_PORT` | SSH 포트, 비우면 22 |
|
||||
| `DEPLOY_PORT` | SSH 포트. 비우면 22 |
|
||||
| `DEPLOY_USER` | 배포 서버 SSH 사용자 |
|
||||
| `DEPLOY_KEY` | 배포 서버 접속용 private key |
|
||||
|
||||
## 서버 준비 사항
|
||||
|
||||
- 서버에는 Java 21이 설치되어 있어야 한다.
|
||||
- `blog-api.service`가 `/root/blog-backend/blog-backend.jar`를 실행하도록 구성되어 있어야 한다.
|
||||
- `DEPLOY_USER`는 `/root/blog-backend`에 jar를 쓸 수 있고 `systemctl restart blog-api.service`를 실행할 수 있어야 한다.
|
||||
- 운영 DB는 배포 전에 백업하는 것을 권장한다. Flyway migration은 애플리케이션 시작 시 자동 적용된다.
|
||||
- `docker-compose.yml`은 현재 CI/CD 배포 경로에서 사용하지 않는다.
|
||||
서버에는 다음이 필요하다.
|
||||
|
||||
- Java 21
|
||||
- systemd
|
||||
- Python 3.11 또는 Python 3
|
||||
- Python venv 모듈(`python3-venv` 계열 패키지)
|
||||
- `git`
|
||||
- `sha256sum`
|
||||
- `tar`
|
||||
- 외부 네트워크 접근. `requirements.txt`가 `git+https://github.com/CSSLab/maia3.git`를 설치한다.
|
||||
- `DEPLOY_USER`가 `/root/blog-backend`에 쓸 수 있고 `/etc/systemd/system`에 서비스 파일을 설치하며 `systemctl`을 실행할 수 있어야 한다.
|
||||
|
||||
첫 설치 또는 Maia3 의존성 변경 시 Python 패키지 설치와 모델 캐시 준비 때문에 배포가 오래 걸릴 수 있다. 모델 캐시는 `/root/blog-backend/.cache/huggingface`에 저장된다.
|
||||
|
||||
## 체스 배포 확인
|
||||
|
||||
배포 후 서버에서 다음을 확인할 수 있다.
|
||||
|
||||
```bash
|
||||
systemctl status maia-engine.service
|
||||
curl -fsS http://127.0.0.1:8000/health
|
||||
systemctl status blog-api.service
|
||||
```
|
||||
|
||||
백엔드 체스 API는 로그인한 사용자만 사용할 수 있다. 퍼즐 API(`/api/chess-puzzles/**`)는 비로그인 접근이 유지된다.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
`docker-compose.yml`에도 Maia 엔진 서비스가 정의되어 있지만, 현재 CI/CD 경로는 Docker Compose가 아니라 systemd 기반 jar + Maia systemd 서비스 배포를 사용한다.
|
||||
|
||||
20
maia-engine/Dockerfile
Normal file
20
maia-engine/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV HF_HOME=/models/huggingface
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
151
maia-engine/app/main.py
Normal file
151
maia-engine/app/main.py
Normal file
@@ -0,0 +1,151 @@
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import chess
|
||||
import chess.engine
|
||||
import chess.pgn
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
DEFAULT_MODEL = os.getenv("MAIA_MODEL", "5m").removeprefix("maia3-")
|
||||
DEFAULT_DEVICE = os.getenv("MAIA_DEVICE", "cpu")
|
||||
DEFAULT_USE_AMP = os.getenv("MAIA_USE_AMP", "false").lower() == "true"
|
||||
|
||||
app = FastAPI(title="Maia Engine Bridge")
|
||||
engine_lock = threading.Lock()
|
||||
engines: dict[str, chess.engine.SimpleEngine] = {}
|
||||
|
||||
|
||||
class StateRequest(BaseModel):
|
||||
moves: list[str] = Field(default_factory=list)
|
||||
white: str | None = None
|
||||
black: str | None = None
|
||||
event: str = "Maia3"
|
||||
|
||||
|
||||
class PlayRequest(StateRequest):
|
||||
rating: int = Field(default=1500, ge=600, le=2600)
|
||||
model: str = Field(default=DEFAULT_MODEL, pattern="^(3m|5m|23m|79m)$")
|
||||
temperature: float = Field(default=0.8, ge=0.0, le=2.0)
|
||||
topP: float = Field(default=0.95, ge=0.0, le=1.0)
|
||||
|
||||
|
||||
def model_alias(model: str) -> str:
|
||||
return f"maia3-{model}"
|
||||
|
||||
|
||||
def engine_command(model: str) -> list[str]:
|
||||
command = [
|
||||
"maia3-uci",
|
||||
"--model",
|
||||
model_alias(model),
|
||||
"--use-uci-history",
|
||||
"--device",
|
||||
DEFAULT_DEVICE,
|
||||
]
|
||||
if not DEFAULT_USE_AMP:
|
||||
command.append("--no-use-amp")
|
||||
return command
|
||||
|
||||
|
||||
def get_engine(model: str) -> chess.engine.SimpleEngine:
|
||||
if model not in engines:
|
||||
try:
|
||||
engines[model] = chess.engine.SimpleEngine.popen_uci(engine_command(model))
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=503, detail="maia3-uci executable was not found") from exc
|
||||
except (chess.engine.EngineError, subprocess.SubprocessError, OSError) as exc:
|
||||
raise HTTPException(status_code=503, detail="failed to start Maia engine") from exc
|
||||
return engines[model]
|
||||
|
||||
|
||||
def build_board(moves: list[str]) -> chess.Board:
|
||||
board = chess.Board()
|
||||
for move in moves:
|
||||
try:
|
||||
board.push_uci(move)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"invalid move: {move}") from exc
|
||||
return board
|
||||
|
||||
|
||||
def pgn_payload(board: chess.Board, request: StateRequest, result: str | None) -> str:
|
||||
game = chess.pgn.Game.from_board(board)
|
||||
game.headers["Event"] = request.event
|
||||
game.headers["Site"] = "blog-backend"
|
||||
game.headers["Date"] = datetime.now(timezone.utc).strftime("%Y.%m.%d")
|
||||
if request.white:
|
||||
game.headers["White"] = request.white
|
||||
if request.black:
|
||||
game.headers["Black"] = request.black
|
||||
game.headers["Result"] = result or "*"
|
||||
|
||||
exporter = chess.pgn.StringExporter(headers=True, variations=False, comments=False)
|
||||
return game.accept(exporter)
|
||||
|
||||
|
||||
def board_payload(board: chess.Board, request: StateRequest) -> dict:
|
||||
outcome = board.outcome(claim_draw=True)
|
||||
if outcome is None:
|
||||
status = "IN_PROGRESS"
|
||||
result = None
|
||||
else:
|
||||
status = outcome.termination.name
|
||||
result = outcome.result()
|
||||
|
||||
return {
|
||||
"fen": board.fen(),
|
||||
"turn": "white" if board.turn == chess.WHITE else "black",
|
||||
"status": status,
|
||||
"result": result,
|
||||
"pgn": pgn_payload(board, request, result),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/maia/state")
|
||||
def state(request: StateRequest) -> dict:
|
||||
board = build_board(request.moves)
|
||||
return board_payload(board, request)
|
||||
|
||||
|
||||
@app.post("/maia/move")
|
||||
def move(request: PlayRequest) -> dict:
|
||||
board = build_board(request.moves)
|
||||
current = board_payload(board, request)
|
||||
if current["status"] != "IN_PROGRESS":
|
||||
return {"move": None, **current}
|
||||
|
||||
with engine_lock:
|
||||
maia = get_engine(request.model)
|
||||
try:
|
||||
maia.configure(
|
||||
{
|
||||
"Elo": request.rating,
|
||||
"Temperature": request.temperature,
|
||||
"TopP": request.topP,
|
||||
}
|
||||
)
|
||||
result = maia.play(board, chess.engine.Limit(nodes=1))
|
||||
except chess.engine.EngineError as exc:
|
||||
raise HTTPException(status_code=503, detail="Maia engine failed to select a move") from exc
|
||||
|
||||
if result.move is None:
|
||||
raise HTTPException(status_code=503, detail="Maia engine returned no move")
|
||||
|
||||
board.push(result.move)
|
||||
return {"move": result.move.uci(), **board_payload(board, request)}
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
def shutdown() -> None:
|
||||
for maia in engines.values():
|
||||
maia.quit()
|
||||
engines.clear()
|
||||
4
maia-engine/requirements.txt
Normal file
4
maia-engine/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
python-chess==1.999
|
||||
git+https://github.com/CSSLab/maia3.git
|
||||
@@ -0,0 +1,82 @@
|
||||
package me.wypark.blogbackend.api.controller
|
||||
|
||||
import jakarta.validation.Valid
|
||||
import me.wypark.blogbackend.api.common.ApiResponse
|
||||
import me.wypark.blogbackend.api.dto.ChessGameCreateRequest
|
||||
import me.wypark.blogbackend.api.dto.ChessGamePgnResponse
|
||||
import me.wypark.blogbackend.api.dto.ChessGameResponse
|
||||
import me.wypark.blogbackend.api.dto.ChessGameStatsResponse
|
||||
import me.wypark.blogbackend.api.dto.ChessGameSummaryResponse
|
||||
import me.wypark.blogbackend.api.dto.ChessMoveRequest
|
||||
import me.wypark.blogbackend.domain.auth.CustomUserDetails
|
||||
import me.wypark.blogbackend.domain.chess.ChessGameService
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.domain.Sort
|
||||
import org.springframework.data.web.PageableDefault
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/chess/games")
|
||||
class ChessGameController(
|
||||
private val chessGameService: ChessGameService
|
||||
) {
|
||||
|
||||
@PostMapping
|
||||
fun createGame(
|
||||
@AuthenticationPrincipal userDetails: CustomUserDetails,
|
||||
@Valid @RequestBody request: ChessGameCreateRequest
|
||||
): ResponseEntity<ApiResponse<ChessGameResponse>> {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.CREATED)
|
||||
.body(ApiResponse.success(chessGameService.createGame(userDetails.memberId, request)))
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
fun getGames(
|
||||
@AuthenticationPrincipal userDetails: CustomUserDetails,
|
||||
@PageableDefault(size = 20, sort = ["updatedAt"], direction = Sort.Direction.DESC) pageable: Pageable
|
||||
): ResponseEntity<ApiResponse<Page<ChessGameSummaryResponse>>> {
|
||||
return ResponseEntity.ok(ApiResponse.success(chessGameService.getGames(userDetails.memberId, pageable)))
|
||||
}
|
||||
|
||||
@GetMapping("/stats")
|
||||
fun getStats(
|
||||
@AuthenticationPrincipal userDetails: CustomUserDetails
|
||||
): ResponseEntity<ApiResponse<ChessGameStatsResponse>> {
|
||||
return ResponseEntity.ok(ApiResponse.success(chessGameService.getStats(userDetails.memberId)))
|
||||
}
|
||||
|
||||
@GetMapping("/{gameId}")
|
||||
fun getGame(
|
||||
@AuthenticationPrincipal userDetails: CustomUserDetails,
|
||||
@PathVariable gameId: String
|
||||
): ResponseEntity<ApiResponse<ChessGameResponse>> {
|
||||
return ResponseEntity.ok(ApiResponse.success(chessGameService.getGame(userDetails.memberId, gameId)))
|
||||
}
|
||||
|
||||
@GetMapping("/{gameId}/pgn")
|
||||
fun getPgn(
|
||||
@AuthenticationPrincipal userDetails: CustomUserDetails,
|
||||
@PathVariable gameId: String
|
||||
): ResponseEntity<ApiResponse<ChessGamePgnResponse>> {
|
||||
return ResponseEntity.ok(ApiResponse.success(chessGameService.getPgn(userDetails.memberId, gameId)))
|
||||
}
|
||||
|
||||
@PostMapping("/{gameId}/moves")
|
||||
fun playMove(
|
||||
@AuthenticationPrincipal userDetails: CustomUserDetails,
|
||||
@PathVariable gameId: String,
|
||||
@Valid @RequestBody request: ChessMoveRequest
|
||||
): ResponseEntity<ApiResponse<ChessGameResponse>> {
|
||||
return ResponseEntity.ok(ApiResponse.success(chessGameService.playMove(userDetails.memberId, gameId, request)))
|
||||
}
|
||||
}
|
||||
153
src/main/kotlin/me/wypark/blogbackend/api/dto/ChessGameDtos.kt
Normal file
153
src/main/kotlin/me/wypark/blogbackend/api/dto/ChessGameDtos.kt
Normal file
@@ -0,0 +1,153 @@
|
||||
package me.wypark.blogbackend.api.dto
|
||||
|
||||
import jakarta.validation.constraints.DecimalMax
|
||||
import jakarta.validation.constraints.DecimalMin
|
||||
import jakarta.validation.constraints.Max
|
||||
import jakarta.validation.constraints.Min
|
||||
import jakarta.validation.constraints.Pattern
|
||||
import me.wypark.blogbackend.domain.chess.ChessGameHistoryStats
|
||||
import me.wypark.blogbackend.domain.chess.ChessGameRecord
|
||||
import me.wypark.blogbackend.domain.chess.ChessGameSession
|
||||
import java.time.LocalDateTime
|
||||
|
||||
data class ChessGameCreateRequest(
|
||||
@field:Min(600, message = "레이팅은 600 이상이어야 합니다.")
|
||||
@field:Max(2600, message = "레이팅은 2600 이하여야 합니다.")
|
||||
val rating: Int = 1500,
|
||||
|
||||
@field:Pattern(
|
||||
regexp = "(?i)^(white|black)$",
|
||||
message = "playerColor는 white 또는 black이어야 합니다."
|
||||
)
|
||||
val playerColor: String = "white",
|
||||
|
||||
@field:Pattern(
|
||||
regexp = "^(3m|5m|23m|79m)$",
|
||||
message = "model은 3m, 5m, 23m, 79m 중 하나여야 합니다."
|
||||
)
|
||||
val model: String = "5m",
|
||||
|
||||
@field:DecimalMin(value = "0.0", message = "temperature는 0.0 이상이어야 합니다.")
|
||||
@field:DecimalMax(value = "2.0", message = "temperature는 2.0 이하여야 합니다.")
|
||||
val temperature: Double = 0.8,
|
||||
|
||||
@field:DecimalMin(value = "0.0", message = "topP는 0.0 이상이어야 합니다.")
|
||||
@field:DecimalMax(value = "1.0", message = "topP는 1.0 이하여야 합니다.")
|
||||
val topP: Double = 0.95
|
||||
)
|
||||
|
||||
data class ChessMoveRequest(
|
||||
@field:Pattern(
|
||||
regexp = "^[a-h][1-8][a-h][1-8][qrbn]?$",
|
||||
message = "move는 UCI 형식이어야 합니다. 예: e2e4, e7e8q"
|
||||
)
|
||||
val move: String
|
||||
)
|
||||
|
||||
data class ChessGameResponse(
|
||||
val gameId: String,
|
||||
val rating: Int,
|
||||
val playerColor: String,
|
||||
val model: String,
|
||||
val fen: String,
|
||||
val turn: String,
|
||||
val moves: List<String>,
|
||||
val status: String,
|
||||
val result: String?,
|
||||
val outcome: String,
|
||||
val pgn: String,
|
||||
val maiaMove: String?
|
||||
) {
|
||||
companion object {
|
||||
fun from(session: ChessGameSession, maiaMove: String? = null): ChessGameResponse {
|
||||
return ChessGameResponse(
|
||||
gameId = session.gameId,
|
||||
rating = session.rating,
|
||||
playerColor = session.playerColor.value,
|
||||
model = session.model,
|
||||
fen = session.fen,
|
||||
turn = session.turn.value,
|
||||
moves = session.moves,
|
||||
status = session.status,
|
||||
result = session.result,
|
||||
outcome = session.outcome().name,
|
||||
pgn = session.pgn,
|
||||
maiaMove = maiaMove
|
||||
)
|
||||
}
|
||||
|
||||
fun from(record: ChessGameRecord, maiaMove: String? = null): ChessGameResponse {
|
||||
return ChessGameResponse(
|
||||
gameId = record.gameId,
|
||||
rating = record.rating,
|
||||
playerColor = record.playerColor.value,
|
||||
model = record.model,
|
||||
fen = record.fen,
|
||||
turn = record.turn.value,
|
||||
moves = record.moveList(),
|
||||
status = record.status,
|
||||
result = record.result,
|
||||
outcome = record.outcome.name,
|
||||
pgn = record.pgn,
|
||||
maiaMove = maiaMove
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ChessGameSummaryResponse(
|
||||
val gameId: String,
|
||||
val rating: Int,
|
||||
val playerColor: String,
|
||||
val model: String,
|
||||
val status: String,
|
||||
val result: String?,
|
||||
val outcome: String,
|
||||
val movesCount: Int,
|
||||
val createdAt: LocalDateTime,
|
||||
val updatedAt: LocalDateTime
|
||||
) {
|
||||
companion object {
|
||||
fun from(record: ChessGameRecord): ChessGameSummaryResponse {
|
||||
return ChessGameSummaryResponse(
|
||||
gameId = record.gameId,
|
||||
rating = record.rating,
|
||||
playerColor = record.playerColor.value,
|
||||
model = record.model,
|
||||
status = record.status,
|
||||
result = record.result,
|
||||
outcome = record.outcome.name,
|
||||
movesCount = record.moveList().size,
|
||||
createdAt = record.createdAt,
|
||||
updatedAt = record.updatedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ChessGamePgnResponse(
|
||||
val gameId: String,
|
||||
val pgn: String
|
||||
)
|
||||
|
||||
data class ChessGameStatsResponse(
|
||||
val total: Long,
|
||||
val inProgress: Long,
|
||||
val wins: Long,
|
||||
val losses: Long,
|
||||
val draws: Long,
|
||||
val unknown: Long
|
||||
) {
|
||||
companion object {
|
||||
fun from(stats: ChessGameHistoryStats): ChessGameStatsResponse {
|
||||
return ChessGameStatsResponse(
|
||||
total = stats.total,
|
||||
inProgress = stats.inProgress,
|
||||
wins = stats.wins,
|
||||
losses = stats.losses,
|
||||
draws = stats.draws,
|
||||
unknown = stats.unknown
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ class CorsConfig {
|
||||
// 2. 신뢰할 수 있는 출처(Origin) 명시
|
||||
// 로컬 개발 환경과 배포 환경(Production)의 도메인을 각각 등록합니다.
|
||||
config.addAllowedOrigin("https://blog.wypark.me")
|
||||
config.addAllowedOrigin("http://localhost:3000")
|
||||
config.addAllowedOrigin("http://localhost:5173")
|
||||
// config.addAllowedOrigin("http://localhost:3000") // 로컬 테스트 시 주석 해제
|
||||
|
||||
// 3. 허용할 HTTP 메서드 및 헤더
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package me.wypark.blogbackend.core.config
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.web.client.RestClient
|
||||
import java.time.Duration
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(MaiaProperties::class)
|
||||
class MaiaConfig {
|
||||
|
||||
@Bean
|
||||
fun maiaRestClient(
|
||||
builder: RestClient.Builder,
|
||||
properties: MaiaProperties
|
||||
): RestClient {
|
||||
return builder
|
||||
.baseUrl(properties.engineUrl)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@ConfigurationProperties(prefix = "maia")
|
||||
data class MaiaProperties(
|
||||
val engineUrl: String = "http://localhost:8000",
|
||||
val gameSessionTtl: Duration = Duration.ofHours(6)
|
||||
)
|
||||
@@ -0,0 +1,69 @@
|
||||
package me.wypark.blogbackend.domain.chess
|
||||
|
||||
import me.wypark.blogbackend.domain.user.MemberRepository
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.stereotype.Repository
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
interface ChessGameHistoryStore {
|
||||
|
||||
fun save(session: ChessGameSession)
|
||||
|
||||
fun findByGameIdAndMemberId(gameId: String, memberId: Long): ChessGameRecord?
|
||||
|
||||
fun findAllByMemberId(memberId: Long, pageable: Pageable): Page<ChessGameRecord>
|
||||
|
||||
fun getStats(memberId: Long): ChessGameHistoryStats
|
||||
}
|
||||
|
||||
data class ChessGameHistoryStats(
|
||||
val total: Long,
|
||||
val inProgress: Long,
|
||||
val wins: Long,
|
||||
val losses: Long,
|
||||
val draws: Long,
|
||||
val unknown: Long
|
||||
)
|
||||
|
||||
@Repository
|
||||
class JpaChessGameHistoryStore(
|
||||
private val chessGameRecordRepository: ChessGameRecordRepository,
|
||||
private val memberRepository: MemberRepository
|
||||
) : ChessGameHistoryStore {
|
||||
|
||||
@Transactional
|
||||
override fun save(session: ChessGameSession) {
|
||||
val existing = chessGameRecordRepository.findByGameId(session.gameId)
|
||||
if (existing != null) {
|
||||
require(existing.member.id == session.memberId) { "Chess game not found." }
|
||||
existing.apply(session)
|
||||
return
|
||||
}
|
||||
|
||||
val member = memberRepository.getReferenceById(session.memberId)
|
||||
chessGameRecordRepository.save(ChessGameRecord.from(member, session))
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
override fun findByGameIdAndMemberId(gameId: String, memberId: Long): ChessGameRecord? {
|
||||
return chessGameRecordRepository.findByGameIdAndMember_Id(gameId, memberId)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
override fun findAllByMemberId(memberId: Long, pageable: Pageable): Page<ChessGameRecord> {
|
||||
return chessGameRecordRepository.findAllByMember_Id(memberId, pageable)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
override fun getStats(memberId: Long): ChessGameHistoryStats {
|
||||
return ChessGameHistoryStats(
|
||||
total = chessGameRecordRepository.countByMember_Id(memberId),
|
||||
inProgress = chessGameRecordRepository.countByMember_IdAndOutcome(memberId, ChessGameOutcome.IN_PROGRESS),
|
||||
wins = chessGameRecordRepository.countByMember_IdAndOutcome(memberId, ChessGameOutcome.WIN),
|
||||
losses = chessGameRecordRepository.countByMember_IdAndOutcome(memberId, ChessGameOutcome.LOSS),
|
||||
draws = chessGameRecordRepository.countByMember_IdAndOutcome(memberId, ChessGameOutcome.DRAW),
|
||||
unknown = chessGameRecordRepository.countByMember_IdAndOutcome(memberId, ChessGameOutcome.UNKNOWN)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package me.wypark.blogbackend.domain.chess
|
||||
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.EnumType
|
||||
import jakarta.persistence.Enumerated
|
||||
import jakarta.persistence.FetchType
|
||||
import jakarta.persistence.GeneratedValue
|
||||
import jakarta.persistence.GenerationType
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Index
|
||||
import jakarta.persistence.JoinColumn
|
||||
import jakarta.persistence.ManyToOne
|
||||
import jakarta.persistence.Table
|
||||
import jakarta.persistence.UniqueConstraint
|
||||
import me.wypark.blogbackend.domain.common.BaseTimeEntity
|
||||
import me.wypark.blogbackend.domain.user.Member
|
||||
import java.time.ZoneId
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "chess_game_record",
|
||||
uniqueConstraints = [
|
||||
UniqueConstraint(name = "uk_chess_game_record_game_id", columnNames = ["game_id"])
|
||||
],
|
||||
indexes = [
|
||||
Index(name = "idx_chess_game_record_member_updated_at", columnList = "member_id, updated_at"),
|
||||
Index(name = "idx_chess_game_record_member_outcome", columnList = "member_id, outcome")
|
||||
]
|
||||
)
|
||||
class ChessGameRecord(
|
||||
@Column(name = "game_id", nullable = false, length = 36)
|
||||
val gameId: String,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "member_id", nullable = false)
|
||||
val member: Member,
|
||||
|
||||
@Column(nullable = false)
|
||||
var rating: Int,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "player_color", nullable = false, length = 10)
|
||||
var playerColor: ChessSide,
|
||||
|
||||
@Column(nullable = false, length = 10)
|
||||
var model: String,
|
||||
|
||||
@Column(nullable = false)
|
||||
var temperature: Double,
|
||||
|
||||
@Column(name = "top_p", nullable = false)
|
||||
var topP: Double,
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TEXT")
|
||||
var fen: String,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 10)
|
||||
var turn: ChessSide,
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TEXT")
|
||||
var moves: String,
|
||||
|
||||
@Column(nullable = false, length = 40)
|
||||
var status: String,
|
||||
|
||||
@Column(length = 16)
|
||||
var result: String?,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
var outcome: ChessGameOutcome,
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TEXT")
|
||||
var pgn: String
|
||||
) : BaseTimeEntity() {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null
|
||||
|
||||
fun apply(session: ChessGameSession) {
|
||||
rating = session.rating
|
||||
playerColor = session.playerColor
|
||||
model = session.model
|
||||
temperature = session.temperature
|
||||
topP = session.topP
|
||||
fen = session.fen
|
||||
turn = session.turn
|
||||
moves = session.moves.joinToString(" ")
|
||||
status = session.status
|
||||
result = session.result
|
||||
outcome = session.outcome()
|
||||
pgn = session.pgn
|
||||
}
|
||||
|
||||
fun moveList(): List<String> {
|
||||
return moves.split(" ").filter { it.isNotBlank() }
|
||||
}
|
||||
|
||||
fun toSession(): ChessGameSession {
|
||||
val zoneId = ZoneId.systemDefault()
|
||||
val memberId = requireNotNull(member.id) { "member id is required." }
|
||||
return ChessGameSession(
|
||||
gameId = gameId,
|
||||
memberId = memberId,
|
||||
rating = rating,
|
||||
playerColor = playerColor,
|
||||
model = model,
|
||||
temperature = temperature,
|
||||
topP = topP,
|
||||
fen = fen,
|
||||
turn = turn,
|
||||
moves = moveList(),
|
||||
status = status,
|
||||
result = result,
|
||||
pgn = pgn,
|
||||
createdAt = createdAt.atZone(zoneId).toInstant(),
|
||||
updatedAt = updatedAt.atZone(zoneId).toInstant()
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(member: Member, session: ChessGameSession): ChessGameRecord {
|
||||
return ChessGameRecord(
|
||||
gameId = session.gameId,
|
||||
member = member,
|
||||
rating = session.rating,
|
||||
playerColor = session.playerColor,
|
||||
model = session.model,
|
||||
temperature = session.temperature,
|
||||
topP = session.topP,
|
||||
fen = session.fen,
|
||||
turn = session.turn,
|
||||
moves = session.moves.joinToString(" "),
|
||||
status = session.status,
|
||||
result = session.result,
|
||||
outcome = session.outcome(),
|
||||
pgn = session.pgn
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package me.wypark.blogbackend.domain.chess
|
||||
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface ChessGameRecordRepository : JpaRepository<ChessGameRecord, Long> {
|
||||
|
||||
fun findByGameId(gameId: String): ChessGameRecord?
|
||||
|
||||
fun findByGameIdAndMember_Id(gameId: String, memberId: Long): ChessGameRecord?
|
||||
|
||||
fun findAllByMember_Id(memberId: Long, pageable: Pageable): Page<ChessGameRecord>
|
||||
|
||||
fun countByMember_Id(memberId: Long): Long
|
||||
|
||||
fun countByMember_IdAndOutcome(memberId: Long, outcome: ChessGameOutcome): Long
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package me.wypark.blogbackend.domain.chess
|
||||
|
||||
import me.wypark.blogbackend.api.dto.ChessGameCreateRequest
|
||||
import me.wypark.blogbackend.api.dto.ChessGamePgnResponse
|
||||
import me.wypark.blogbackend.api.dto.ChessGameResponse
|
||||
import me.wypark.blogbackend.api.dto.ChessGameStatsResponse
|
||||
import me.wypark.blogbackend.api.dto.ChessGameSummaryResponse
|
||||
import me.wypark.blogbackend.api.dto.ChessMoveRequest
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@Service
|
||||
@Transactional(readOnly = true)
|
||||
class ChessGameService(
|
||||
private val chessGameStore: ChessGameStore,
|
||||
private val chessGameHistoryStore: ChessGameHistoryStore,
|
||||
private val maiaEngine: MaiaEngine,
|
||||
private val clock: Clock
|
||||
) {
|
||||
|
||||
@Transactional
|
||||
fun createGame(memberId: Long, request: ChessGameCreateRequest): ChessGameResponse {
|
||||
val playerColor = ChessSide.from(request.playerColor)
|
||||
val now = Instant.now(clock)
|
||||
val labels = playerLabels(playerColor, request.rating, request.model)
|
||||
|
||||
val initialState = maiaEngine.getState(
|
||||
MaiaStateRequest(
|
||||
moves = emptyList(),
|
||||
white = labels.white,
|
||||
black = labels.black
|
||||
)
|
||||
)
|
||||
var session = ChessGameSession(
|
||||
gameId = UUID.randomUUID().toString(),
|
||||
memberId = memberId,
|
||||
rating = request.rating,
|
||||
playerColor = playerColor,
|
||||
model = request.model,
|
||||
temperature = request.temperature,
|
||||
topP = request.topP,
|
||||
fen = initialState.fen,
|
||||
turn = ChessSide.from(initialState.turn),
|
||||
moves = emptyList(),
|
||||
status = initialState.status,
|
||||
result = initialState.result,
|
||||
pgn = initialState.pgn,
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
|
||||
var maiaMove: String? = null
|
||||
if (playerColor == ChessSide.BLACK) {
|
||||
val maiaResponse = maiaEngine.playMove(session.toMaiaPlayRequest())
|
||||
maiaMove = maiaResponse.move
|
||||
session = session.applyEngineResponse(maiaResponse, now)
|
||||
}
|
||||
|
||||
saveSession(session)
|
||||
return ChessGameResponse.from(session, maiaMove)
|
||||
}
|
||||
|
||||
fun getGames(memberId: Long, pageable: Pageable): Page<ChessGameSummaryResponse> {
|
||||
return chessGameHistoryStore.findAllByMemberId(memberId, pageable)
|
||||
.map { ChessGameSummaryResponse.from(it) }
|
||||
}
|
||||
|
||||
fun getStats(memberId: Long): ChessGameStatsResponse {
|
||||
return ChessGameStatsResponse.from(chessGameHistoryStore.getStats(memberId))
|
||||
}
|
||||
|
||||
fun getGame(memberId: Long, gameId: String): ChessGameResponse {
|
||||
val session = chessGameStore.findById(gameId)
|
||||
if (session != null) {
|
||||
requireOwnedBy(session, memberId)
|
||||
return ChessGameResponse.from(session)
|
||||
}
|
||||
|
||||
val record = chessGameHistoryStore.findByGameIdAndMemberId(gameId, memberId)
|
||||
?: throw IllegalArgumentException("Chess game not found.")
|
||||
return ChessGameResponse.from(record)
|
||||
}
|
||||
|
||||
fun getPgn(memberId: Long, gameId: String): ChessGamePgnResponse {
|
||||
val game = getGame(memberId, gameId)
|
||||
return ChessGamePgnResponse(gameId = game.gameId, pgn = game.pgn)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun playMove(memberId: Long, gameId: String, request: ChessMoveRequest): ChessGameResponse {
|
||||
val session = getPlayableSession(memberId, gameId)
|
||||
require(session.status == "IN_PROGRESS") { "This chess game is already finished." }
|
||||
require(session.turn == session.playerColor) { "It is not the player's turn." }
|
||||
|
||||
val movesAfterPlayer = session.moves + request.move
|
||||
val maiaResponse = maiaEngine.playMove(
|
||||
session.toMaiaPlayRequest(moves = movesAfterPlayer)
|
||||
)
|
||||
|
||||
val updated = session.applyEngineResponse(maiaResponse, Instant.now(clock), movesAfterPlayer)
|
||||
saveSession(updated)
|
||||
return ChessGameResponse.from(updated, maiaResponse.move)
|
||||
}
|
||||
|
||||
private fun getPlayableSession(memberId: Long, gameId: String): ChessGameSession {
|
||||
val session = chessGameStore.findById(gameId)
|
||||
if (session != null) {
|
||||
requireOwnedBy(session, memberId)
|
||||
return session
|
||||
}
|
||||
|
||||
val record = chessGameHistoryStore.findByGameIdAndMemberId(gameId, memberId)
|
||||
?: throw IllegalArgumentException("Chess game not found.")
|
||||
return record.toSession()
|
||||
}
|
||||
|
||||
private fun saveSession(session: ChessGameSession) {
|
||||
chessGameStore.save(session)
|
||||
chessGameHistoryStore.save(session)
|
||||
}
|
||||
|
||||
private fun requireOwnedBy(session: ChessGameSession, memberId: Long) {
|
||||
require(session.memberId == memberId) { "Chess game not found." }
|
||||
}
|
||||
|
||||
private fun playerLabels(playerColor: ChessSide, rating: Int, model: String): PlayerLabels {
|
||||
val maiaName = "Maia3-$model-$rating"
|
||||
return if (playerColor == ChessSide.WHITE) {
|
||||
PlayerLabels(white = "Player", black = maiaName)
|
||||
} else {
|
||||
PlayerLabels(white = maiaName, black = "Player")
|
||||
}
|
||||
}
|
||||
|
||||
private fun ChessGameSession.toMaiaPlayRequest(
|
||||
moves: List<String> = this.moves
|
||||
): MaiaPlayRequest {
|
||||
val labels = playerLabels(playerColor, rating, model)
|
||||
return MaiaPlayRequest(
|
||||
moves = moves,
|
||||
rating = rating,
|
||||
model = model,
|
||||
temperature = temperature,
|
||||
topP = topP,
|
||||
white = labels.white,
|
||||
black = labels.black
|
||||
)
|
||||
}
|
||||
|
||||
private fun ChessGameSession.applyEngineResponse(
|
||||
response: MaiaPlayResponse,
|
||||
now: Instant,
|
||||
movesBeforeMaia: List<String> = moves
|
||||
): ChessGameSession {
|
||||
val nextMoves = response.move?.let { movesBeforeMaia + it } ?: movesBeforeMaia
|
||||
return copy(
|
||||
fen = response.fen,
|
||||
turn = ChessSide.from(response.turn),
|
||||
moves = nextMoves,
|
||||
status = response.status,
|
||||
result = response.result,
|
||||
pgn = response.pgn,
|
||||
updatedAt = now
|
||||
)
|
||||
}
|
||||
|
||||
private data class PlayerLabels(
|
||||
val white: String,
|
||||
val black: String
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package me.wypark.blogbackend.domain.chess
|
||||
|
||||
import java.time.Instant
|
||||
|
||||
data class ChessGameSession(
|
||||
val gameId: String,
|
||||
val memberId: Long,
|
||||
val rating: Int,
|
||||
val playerColor: ChessSide,
|
||||
val model: String,
|
||||
val temperature: Double,
|
||||
val topP: Double,
|
||||
val fen: String,
|
||||
val turn: ChessSide,
|
||||
val moves: List<String>,
|
||||
val status: String,
|
||||
val result: String?,
|
||||
val pgn: String,
|
||||
val createdAt: Instant,
|
||||
val updatedAt: Instant
|
||||
) {
|
||||
fun outcome(): ChessGameOutcome {
|
||||
return ChessGameOutcome.from(result = result, playerColor = playerColor, status = status)
|
||||
}
|
||||
}
|
||||
|
||||
enum class ChessSide(val value: String) {
|
||||
WHITE("white"),
|
||||
BLACK("black");
|
||||
|
||||
fun opposite(): ChessSide = if (this == WHITE) BLACK else WHITE
|
||||
|
||||
companion object {
|
||||
fun from(value: String): ChessSide {
|
||||
return entries.firstOrNull { it.value.equals(value, ignoreCase = true) }
|
||||
?: throw IllegalArgumentException("playerColor must be white or black.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class ChessGameOutcome {
|
||||
IN_PROGRESS,
|
||||
WIN,
|
||||
LOSS,
|
||||
DRAW,
|
||||
UNKNOWN;
|
||||
|
||||
companion object {
|
||||
fun from(result: String?, playerColor: ChessSide, status: String): ChessGameOutcome {
|
||||
if (status == "IN_PROGRESS") {
|
||||
return IN_PROGRESS
|
||||
}
|
||||
|
||||
return when (result) {
|
||||
"1-0" -> if (playerColor == ChessSide.WHITE) WIN else LOSS
|
||||
"0-1" -> if (playerColor == ChessSide.BLACK) WIN else LOSS
|
||||
"1/2-1/2" -> DRAW
|
||||
null, "", "*" -> UNKNOWN
|
||||
else -> UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package me.wypark.blogbackend.domain.chess
|
||||
|
||||
interface ChessGameStore {
|
||||
|
||||
fun save(session: ChessGameSession): ChessGameSession
|
||||
|
||||
fun findById(gameId: String): ChessGameSession?
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package me.wypark.blogbackend.domain.chess
|
||||
|
||||
interface MaiaEngine {
|
||||
|
||||
fun getState(request: MaiaStateRequest): MaiaStateResponse
|
||||
|
||||
fun playMove(request: MaiaPlayRequest): MaiaPlayResponse
|
||||
}
|
||||
|
||||
data class MaiaStateRequest(
|
||||
val moves: List<String>,
|
||||
val white: String? = null,
|
||||
val black: String? = null,
|
||||
val event: String = "Maia3"
|
||||
)
|
||||
|
||||
data class MaiaPlayRequest(
|
||||
val moves: List<String>,
|
||||
val rating: Int,
|
||||
val model: String,
|
||||
val temperature: Double,
|
||||
val topP: Double,
|
||||
val white: String? = null,
|
||||
val black: String? = null,
|
||||
val event: String = "Maia3"
|
||||
)
|
||||
|
||||
data class MaiaStateResponse(
|
||||
val fen: String,
|
||||
val turn: String,
|
||||
val status: String,
|
||||
val result: String?,
|
||||
val pgn: String = ""
|
||||
)
|
||||
|
||||
data class MaiaPlayResponse(
|
||||
val move: String?,
|
||||
val fen: String,
|
||||
val turn: String,
|
||||
val status: String,
|
||||
val result: String?,
|
||||
val pgn: String = ""
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
package me.wypark.blogbackend.domain.chess
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.client.RestClient
|
||||
import org.springframework.web.client.RestClientException
|
||||
import org.springframework.web.client.RestClientResponseException
|
||||
|
||||
@Component
|
||||
class MaiaEngineClient(
|
||||
private val maiaRestClient: RestClient
|
||||
) : MaiaEngine {
|
||||
|
||||
private val log = LoggerFactory.getLogger(this.javaClass)
|
||||
|
||||
override fun getState(request: MaiaStateRequest): MaiaStateResponse {
|
||||
return exchange("/maia/state", request, MaiaStateResponse::class.java)
|
||||
}
|
||||
|
||||
override fun playMove(request: MaiaPlayRequest): MaiaPlayResponse {
|
||||
return exchange("/maia/move", request, MaiaPlayResponse::class.java)
|
||||
}
|
||||
|
||||
private fun <T : Any> exchange(path: String, request: Any, responseType: Class<T>): T {
|
||||
try {
|
||||
return maiaRestClient
|
||||
.post()
|
||||
.uri(path)
|
||||
.body(request)
|
||||
.retrieve()
|
||||
.body(responseType)
|
||||
?: throw IllegalStateException("Maia 엔진 응답이 비어 있습니다.")
|
||||
} catch (e: RestClientResponseException) {
|
||||
log.warn("Maia engine returned {}: {}", e.statusCode, e.responseBodyAsString)
|
||||
throw IllegalArgumentException("Maia 엔진이 요청을 처리하지 못했습니다.")
|
||||
} catch (e: RestClientException) {
|
||||
log.error("Failed to call Maia engine", e)
|
||||
throw IllegalStateException("Maia 엔진에 연결할 수 없습니다.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package me.wypark.blogbackend.domain.chess
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import me.wypark.blogbackend.core.config.MaiaProperties
|
||||
import org.springframework.data.redis.core.RedisTemplate
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Repository
|
||||
class RedisChessGameStore(
|
||||
private val redisTemplate: RedisTemplate<String, String>,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val maiaProperties: MaiaProperties
|
||||
) : ChessGameStore {
|
||||
|
||||
override fun save(session: ChessGameSession): ChessGameSession {
|
||||
redisTemplate.opsForValue().set(
|
||||
key(session.gameId),
|
||||
objectMapper.writeValueAsString(session),
|
||||
maiaProperties.gameSessionTtl.toMillis(),
|
||||
TimeUnit.MILLISECONDS
|
||||
)
|
||||
return session
|
||||
}
|
||||
|
||||
override fun findById(gameId: String): ChessGameSession? {
|
||||
val json = redisTemplate.opsForValue().get(key(gameId)) ?: return null
|
||||
return objectMapper.readValue(json, ChessGameSession::class.java)
|
||||
}
|
||||
|
||||
private fun key(gameId: String): String = "CHESS_GAME:$gameId"
|
||||
}
|
||||
@@ -3,3 +3,7 @@ spring:
|
||||
name: blog-api
|
||||
profiles:
|
||||
default: prod
|
||||
|
||||
maia:
|
||||
engine-url: ${MAIA_ENGINE_URL:http://localhost:8000}
|
||||
game-session-ttl: ${MAIA_GAME_SESSION_TTL:PT6H}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE chess_game_record (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
game_id VARCHAR(36) NOT NULL,
|
||||
member_id BIGINT NOT NULL,
|
||||
rating INTEGER NOT NULL,
|
||||
player_color VARCHAR(10) NOT NULL,
|
||||
model VARCHAR(10) NOT NULL,
|
||||
temperature DOUBLE PRECISION NOT NULL,
|
||||
top_p DOUBLE PRECISION NOT NULL,
|
||||
fen TEXT NOT NULL,
|
||||
turn VARCHAR(10) NOT NULL,
|
||||
moves TEXT NOT NULL DEFAULT '',
|
||||
status VARCHAR(40) NOT NULL,
|
||||
result VARCHAR(16),
|
||||
outcome VARCHAR(20) NOT NULL,
|
||||
pgn TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uk_chess_game_record_game_id UNIQUE (game_id),
|
||||
CONSTRAINT fk_chess_game_record_member
|
||||
FOREIGN KEY (member_id) REFERENCES member (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_chess_game_record_member_updated_at
|
||||
ON chess_game_record (member_id, updated_at DESC);
|
||||
|
||||
CREATE INDEX idx_chess_game_record_member_outcome
|
||||
ON chess_game_record (member_id, outcome);
|
||||
@@ -0,0 +1,37 @@
|
||||
package me.wypark.blogbackend.api.controller
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.test.context.ActiveProfiles
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
class ChessGameControllerSecurityIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
lateinit var mockMvc: MockMvc
|
||||
|
||||
@Test
|
||||
fun `chess game creation requires login`() {
|
||||
mockMvc.perform(
|
||||
post("/api/chess/games")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"rating":1500,"playerColor":"white","model":"5m"}""")
|
||||
)
|
||||
.andExpect(status().isUnauthorized)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chess game history requires login`() {
|
||||
mockMvc.perform(get("/api/chess/games"))
|
||||
.andExpect(status().isUnauthorized)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package me.wypark.blogbackend.domain.chess
|
||||
|
||||
import me.wypark.blogbackend.api.dto.ChessGameCreateRequest
|
||||
import me.wypark.blogbackend.api.dto.ChessMoveRequest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.PageImpl
|
||||
import org.springframework.data.domain.Pageable
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
class ChessGameServiceTest {
|
||||
|
||||
private lateinit var store: FakeChessGameStore
|
||||
private lateinit var historyStore: FakeChessGameHistoryStore
|
||||
private lateinit var engine: FakeMaiaEngine
|
||||
private lateinit var service: ChessGameService
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
store = FakeChessGameStore()
|
||||
historyStore = FakeChessGameHistoryStore()
|
||||
engine = FakeMaiaEngine()
|
||||
service = ChessGameService(
|
||||
chessGameStore = store,
|
||||
chessGameHistoryStore = historyStore,
|
||||
maiaEngine = engine,
|
||||
clock = Clock.fixed(Instant.parse("2026-06-19T00:00:00Z"), ZoneOffset.UTC)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `creates white game without an immediate Maia move`() {
|
||||
val response = service.createGame(
|
||||
memberId = MEMBER_ID,
|
||||
request = ChessGameCreateRequest(rating = 1500, playerColor = "white", model = "5m")
|
||||
)
|
||||
|
||||
assertEquals(1500, response.rating)
|
||||
assertEquals("white", response.playerColor)
|
||||
assertEquals(emptyList(), response.moves)
|
||||
assertEquals(null, response.maiaMove)
|
||||
assertEquals("white", response.turn)
|
||||
assertEquals("IN_PROGRESS", response.status)
|
||||
assertEquals("IN_PROGRESS", response.outcome)
|
||||
assertEquals(MEMBER_ID, historyStore.savedSessions.single().memberId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `creates black game with Maia opening move`() {
|
||||
engine.playResponses.add(
|
||||
MaiaPlayResponse(
|
||||
move = "e2e4",
|
||||
fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1",
|
||||
turn = "black",
|
||||
status = "IN_PROGRESS",
|
||||
result = null,
|
||||
pgn = "1. e4 *"
|
||||
)
|
||||
)
|
||||
|
||||
val response = service.createGame(
|
||||
memberId = MEMBER_ID,
|
||||
request = ChessGameCreateRequest(rating = 1500, playerColor = "black", model = "5m")
|
||||
)
|
||||
|
||||
assertEquals(listOf("e2e4"), response.moves)
|
||||
assertEquals("e2e4", response.maiaMove)
|
||||
assertEquals("black", response.turn)
|
||||
assertEquals("1. e4 *", response.pgn)
|
||||
assertEquals(1, engine.playRequests.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stores player move Maia reply PGN and outcome`() {
|
||||
val session = store.save(
|
||||
ChessGameSession(
|
||||
gameId = "game-1",
|
||||
memberId = MEMBER_ID,
|
||||
rating = 1500,
|
||||
playerColor = ChessSide.WHITE,
|
||||
model = "5m",
|
||||
temperature = 0.8,
|
||||
topP = 0.95,
|
||||
fen = FakeMaiaEngine.START_FEN,
|
||||
turn = ChessSide.WHITE,
|
||||
moves = emptyList(),
|
||||
status = "IN_PROGRESS",
|
||||
result = null,
|
||||
pgn = "*",
|
||||
createdAt = Instant.parse("2026-06-19T00:00:00Z"),
|
||||
updatedAt = Instant.parse("2026-06-19T00:00:00Z")
|
||||
)
|
||||
)
|
||||
engine.playResponses.add(
|
||||
MaiaPlayResponse(
|
||||
move = "c7c5",
|
||||
fen = "rnbqkbnr/pp1ppppp/8/2p5/4P3/8/PPPP1PPP/RNBQKBNR w KQkq c6 0 2",
|
||||
turn = "white",
|
||||
status = "IN_PROGRESS",
|
||||
result = null,
|
||||
pgn = "1. e4 c5 *"
|
||||
)
|
||||
)
|
||||
|
||||
val response = service.playMove(MEMBER_ID, session.gameId, ChessMoveRequest("e2e4"))
|
||||
|
||||
assertEquals(listOf("e2e4", "c7c5"), response.moves)
|
||||
assertEquals("c7c5", response.maiaMove)
|
||||
assertEquals("white", response.turn)
|
||||
assertEquals("1. e4 c5 *", response.pgn)
|
||||
assertEquals("IN_PROGRESS", response.outcome)
|
||||
assertEquals(listOf("e2e4"), engine.playRequests.single().moves)
|
||||
assertEquals("1. e4 c5 *", historyStore.savedSessions.last().pgn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `records a player win when result favors player color`() {
|
||||
val session = store.save(
|
||||
ChessGameSession(
|
||||
gameId = "game-1",
|
||||
memberId = MEMBER_ID,
|
||||
rating = 1500,
|
||||
playerColor = ChessSide.WHITE,
|
||||
model = "5m",
|
||||
temperature = 0.8,
|
||||
topP = 0.95,
|
||||
fen = FakeMaiaEngine.START_FEN,
|
||||
turn = ChessSide.WHITE,
|
||||
moves = emptyList(),
|
||||
status = "IN_PROGRESS",
|
||||
result = null,
|
||||
pgn = "*",
|
||||
createdAt = Instant.parse("2026-06-19T00:00:00Z"),
|
||||
updatedAt = Instant.parse("2026-06-19T00:00:00Z")
|
||||
)
|
||||
)
|
||||
engine.playResponses.add(
|
||||
MaiaPlayResponse(
|
||||
move = null,
|
||||
fen = "8/8/8/8/8/8/8/8 b - - 0 1",
|
||||
turn = "black",
|
||||
status = "CHECKMATE",
|
||||
result = "1-0",
|
||||
pgn = "1. e4 1-0"
|
||||
)
|
||||
)
|
||||
|
||||
val response = service.playMove(MEMBER_ID, session.gameId, ChessMoveRequest("e2e4"))
|
||||
|
||||
assertEquals("WIN", response.outcome)
|
||||
assertEquals("WIN", historyStore.savedSessions.last().outcome().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects move when it is not player's turn`() {
|
||||
store.save(
|
||||
ChessGameSession(
|
||||
gameId = "game-1",
|
||||
memberId = MEMBER_ID,
|
||||
rating = 1500,
|
||||
playerColor = ChessSide.WHITE,
|
||||
model = "5m",
|
||||
temperature = 0.8,
|
||||
topP = 0.95,
|
||||
fen = FakeMaiaEngine.START_FEN,
|
||||
turn = ChessSide.BLACK,
|
||||
moves = listOf("e2e4"),
|
||||
status = "IN_PROGRESS",
|
||||
result = null,
|
||||
pgn = "1. e4 *",
|
||||
createdAt = Instant.parse("2026-06-19T00:00:00Z"),
|
||||
updatedAt = Instant.parse("2026-06-19T00:00:00Z")
|
||||
)
|
||||
)
|
||||
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
service.playMove(MEMBER_ID, "game-1", ChessMoveRequest("d2d4"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects access to another member game`() {
|
||||
store.save(
|
||||
ChessGameSession(
|
||||
gameId = "game-1",
|
||||
memberId = OTHER_MEMBER_ID,
|
||||
rating = 1500,
|
||||
playerColor = ChessSide.WHITE,
|
||||
model = "5m",
|
||||
temperature = 0.8,
|
||||
topP = 0.95,
|
||||
fen = FakeMaiaEngine.START_FEN,
|
||||
turn = ChessSide.WHITE,
|
||||
moves = emptyList(),
|
||||
status = "IN_PROGRESS",
|
||||
result = null,
|
||||
pgn = "*",
|
||||
createdAt = Instant.parse("2026-06-19T00:00:00Z"),
|
||||
updatedAt = Instant.parse("2026-06-19T00:00:00Z")
|
||||
)
|
||||
)
|
||||
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
service.getGame(MEMBER_ID, "game-1")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MEMBER_ID = 1L
|
||||
private const val OTHER_MEMBER_ID = 2L
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeChessGameStore : ChessGameStore {
|
||||
private val sessions = mutableMapOf<String, ChessGameSession>()
|
||||
|
||||
override fun save(session: ChessGameSession): ChessGameSession {
|
||||
sessions[session.gameId] = session
|
||||
return session
|
||||
}
|
||||
|
||||
override fun findById(gameId: String): ChessGameSession? = sessions[gameId]
|
||||
}
|
||||
|
||||
private class FakeChessGameHistoryStore : ChessGameHistoryStore {
|
||||
val savedSessions = mutableListOf<ChessGameSession>()
|
||||
|
||||
override fun save(session: ChessGameSession) {
|
||||
savedSessions.add(session)
|
||||
}
|
||||
|
||||
override fun findByGameIdAndMemberId(gameId: String, memberId: Long): ChessGameRecord? = null
|
||||
|
||||
override fun findAllByMemberId(memberId: Long, pageable: Pageable): Page<ChessGameRecord> {
|
||||
return PageImpl(emptyList(), pageable, 0)
|
||||
}
|
||||
|
||||
override fun getStats(memberId: Long): ChessGameHistoryStats {
|
||||
return ChessGameHistoryStats(
|
||||
total = 0,
|
||||
inProgress = 0,
|
||||
wins = 0,
|
||||
losses = 0,
|
||||
draws = 0,
|
||||
unknown = 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeMaiaEngine : MaiaEngine {
|
||||
val playResponses = ArrayDeque<MaiaPlayResponse>()
|
||||
val playRequests = mutableListOf<MaiaPlayRequest>()
|
||||
|
||||
override fun getState(request: MaiaStateRequest): MaiaStateResponse {
|
||||
return MaiaStateResponse(
|
||||
fen = START_FEN,
|
||||
turn = "white",
|
||||
status = "IN_PROGRESS",
|
||||
result = null,
|
||||
pgn = "*"
|
||||
)
|
||||
}
|
||||
|
||||
override fun playMove(request: MaiaPlayRequest): MaiaPlayResponse {
|
||||
playRequests.add(request)
|
||||
return playResponses.removeFirst()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val START_FEN = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user