From 3042dc898ea343352d23174fe643a235c3c84c02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=9B=90=EC=97=BD?= Date: Fri, 19 Jun 2026 14:49:20 +0900 Subject: [PATCH] Add Maia chess gameplay and deployment --- .gitea/workflows/deploy.yml | 146 ++++++++- .gitignore | 7 +- build.gradle.kts | 49 ++++ deploy/systemd/maia-engine.service | 22 ++ docker-compose.yml | 25 ++ docs/ci-cd.md | 80 +++-- maia-engine/Dockerfile | 20 ++ maia-engine/app/main.py | 151 ++++++++++ maia-engine/requirements.txt | 4 + .../api/controller/ChessGameController.kt | 82 ++++++ .../blogbackend/api/dto/ChessGameDtos.kt | 153 ++++++++++ .../blogbackend/core/config/CorsConfig.kt | 4 +- .../blogbackend/core/config/MaiaConfig.kt | 29 ++ .../domain/chess/ChessGameHistoryStore.kt | 69 +++++ .../domain/chess/ChessGameRecord.kt | 144 +++++++++ .../domain/chess/ChessGameRecordRepository.kt | 18 ++ .../domain/chess/ChessGameService.kt | 176 +++++++++++ .../domain/chess/ChessGameSession.kt | 63 ++++ .../domain/chess/ChessGameStore.kt | 8 + .../blogbackend/domain/chess/MaiaEngine.kt | 43 +++ .../domain/chess/MaiaEngineClient.kt | 41 +++ .../domain/chess/RedisChessGameStore.kt | 32 ++ src/main/resources/application.yml | 6 +- ...V20260619_01__create_chess_game_record.sql | 28 ++ ...ssGameControllerSecurityIntegrationTest.kt | 37 +++ .../domain/chess/ChessGameServiceTest.kt | 277 ++++++++++++++++++ 26 files changed, 1682 insertions(+), 32 deletions(-) create mode 100644 deploy/systemd/maia-engine.service create mode 100644 maia-engine/Dockerfile create mode 100644 maia-engine/app/main.py create mode 100644 maia-engine/requirements.txt create mode 100644 src/main/kotlin/me/wypark/blogbackend/api/controller/ChessGameController.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/api/dto/ChessGameDtos.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/core/config/MaiaConfig.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessGameHistoryStore.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessGameRecord.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessGameRecordRepository.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessGameService.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessGameSession.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessGameStore.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/chess/MaiaEngine.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/chess/MaiaEngineClient.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/chess/RedisChessGameStore.kt create mode 100644 src/main/resources/db/migration/V20260619_01__create_chess_game_record.sql create mode 100644 src/test/kotlin/me/wypark/blogbackend/api/controller/ChessGameControllerSecurityIntegrationTest.kt create mode 100644 src/test/kotlin/me/wypark/blogbackend/domain/chess/ChessGameServiceTest.kt diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index fa18857..e5905a7 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -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 diff --git a/.gitignore b/.gitignore index 2c4af83..1a4b3d7 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file +postgres_data diff --git a/build.gradle.kts b/build.gradle.kts index 1ff8e96..10ddd43 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -80,3 +80,52 @@ allOpen { tasks.withType { 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("syncKotlinClassesForJavac") { + dependsOn(compileKotlinTask) + from(compileKotlinTask.map { it.outputs.files }) + into(javacKotlinClassesDir) +} + +tasks.named("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("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("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 { + dependsOn(syncMainClassesForTestRuntime, syncTestClassesForTestRuntime) + testClassesDirs = files(testRuntimeClassesDir) + classpath = files(testRuntimeClassesDir, mainRuntimeClassesDir) + classpath +} diff --git a/deploy/systemd/maia-engine.service b/deploy/systemd/maia-engine.service new file mode 100644 index 0000000..cfebcfb --- /dev/null +++ b/deploy/systemd/maia-engine.service @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 479d6ff..d8674b1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 7fbf1ef..86fee56 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -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 서비스 배포를 사용한다. diff --git a/maia-engine/Dockerfile b/maia-engine/Dockerfile new file mode 100644 index 0000000..c418abb --- /dev/null +++ b/maia-engine/Dockerfile @@ -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"] diff --git a/maia-engine/app/main.py b/maia-engine/app/main.py new file mode 100644 index 0000000..7ed2516 --- /dev/null +++ b/maia-engine/app/main.py @@ -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() diff --git a/maia-engine/requirements.txt b/maia-engine/requirements.txt new file mode 100644 index 0000000..6bee3dc --- /dev/null +++ b/maia-engine/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +python-chess==1.999 +git+https://github.com/CSSLab/maia3.git diff --git a/src/main/kotlin/me/wypark/blogbackend/api/controller/ChessGameController.kt b/src/main/kotlin/me/wypark/blogbackend/api/controller/ChessGameController.kt new file mode 100644 index 0000000..898b425 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/api/controller/ChessGameController.kt @@ -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> { + 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>> { + return ResponseEntity.ok(ApiResponse.success(chessGameService.getGames(userDetails.memberId, pageable))) + } + + @GetMapping("/stats") + fun getStats( + @AuthenticationPrincipal userDetails: CustomUserDetails + ): ResponseEntity> { + return ResponseEntity.ok(ApiResponse.success(chessGameService.getStats(userDetails.memberId))) + } + + @GetMapping("/{gameId}") + fun getGame( + @AuthenticationPrincipal userDetails: CustomUserDetails, + @PathVariable gameId: String + ): ResponseEntity> { + return ResponseEntity.ok(ApiResponse.success(chessGameService.getGame(userDetails.memberId, gameId))) + } + + @GetMapping("/{gameId}/pgn") + fun getPgn( + @AuthenticationPrincipal userDetails: CustomUserDetails, + @PathVariable gameId: String + ): ResponseEntity> { + 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> { + return ResponseEntity.ok(ApiResponse.success(chessGameService.playMove(userDetails.memberId, gameId, request))) + } +} diff --git a/src/main/kotlin/me/wypark/blogbackend/api/dto/ChessGameDtos.kt b/src/main/kotlin/me/wypark/blogbackend/api/dto/ChessGameDtos.kt new file mode 100644 index 0000000..91e85d8 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/api/dto/ChessGameDtos.kt @@ -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, + 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 + ) + } + } +} diff --git a/src/main/kotlin/me/wypark/blogbackend/core/config/CorsConfig.kt b/src/main/kotlin/me/wypark/blogbackend/core/config/CorsConfig.kt index 9dc32a6..cf40e1e 100644 --- a/src/main/kotlin/me/wypark/blogbackend/core/config/CorsConfig.kt +++ b/src/main/kotlin/me/wypark/blogbackend/core/config/CorsConfig.kt @@ -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 메서드 및 헤더 @@ -46,4 +48,4 @@ class CorsConfig { source.registerCorsConfiguration("/api/**", config) return CorsFilter(source) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/me/wypark/blogbackend/core/config/MaiaConfig.kt b/src/main/kotlin/me/wypark/blogbackend/core/config/MaiaConfig.kt new file mode 100644 index 0000000..4a49ed9 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/core/config/MaiaConfig.kt @@ -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) +) diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessGameHistoryStore.kt b/src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessGameHistoryStore.kt new file mode 100644 index 0000000..4cf7696 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessGameHistoryStore.kt @@ -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 + + 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 { + 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) + ) + } +} diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessGameRecord.kt b/src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessGameRecord.kt new file mode 100644 index 0000000..73fe902 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessGameRecord.kt @@ -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 { + 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 + ) + } + } +} diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessGameRecordRepository.kt b/src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessGameRecordRepository.kt new file mode 100644 index 0000000..0b8f257 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessGameRecordRepository.kt @@ -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 { + + fun findByGameId(gameId: String): ChessGameRecord? + + fun findByGameIdAndMember_Id(gameId: String, memberId: Long): ChessGameRecord? + + fun findAllByMember_Id(memberId: Long, pageable: Pageable): Page + + fun countByMember_Id(memberId: Long): Long + + fun countByMember_IdAndOutcome(memberId: Long, outcome: ChessGameOutcome): Long +} diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessGameService.kt b/src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessGameService.kt new file mode 100644 index 0000000..4a18199 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessGameService.kt @@ -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 { + 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 = 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 = 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 + ) +} diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessGameSession.kt b/src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessGameSession.kt new file mode 100644 index 0000000..fc58aaa --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessGameSession.kt @@ -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, + 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 + } + } + } +} diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessGameStore.kt b/src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessGameStore.kt new file mode 100644 index 0000000..4179500 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessGameStore.kt @@ -0,0 +1,8 @@ +package me.wypark.blogbackend.domain.chess + +interface ChessGameStore { + + fun save(session: ChessGameSession): ChessGameSession + + fun findById(gameId: String): ChessGameSession? +} diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/chess/MaiaEngine.kt b/src/main/kotlin/me/wypark/blogbackend/domain/chess/MaiaEngine.kt new file mode 100644 index 0000000..51bbad1 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/chess/MaiaEngine.kt @@ -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, + val white: String? = null, + val black: String? = null, + val event: String = "Maia3" +) + +data class MaiaPlayRequest( + val moves: List, + 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 = "" +) diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/chess/MaiaEngineClient.kt b/src/main/kotlin/me/wypark/blogbackend/domain/chess/MaiaEngineClient.kt new file mode 100644 index 0000000..81394c8 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/chess/MaiaEngineClient.kt @@ -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 exchange(path: String, request: Any, responseType: Class): 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 엔진에 연결할 수 없습니다.") + } + } +} diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/chess/RedisChessGameStore.kt b/src/main/kotlin/me/wypark/blogbackend/domain/chess/RedisChessGameStore.kt new file mode 100644 index 0000000..6a036ee --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/chess/RedisChessGameStore.kt @@ -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, + 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" +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c3cb4ed..c1c895a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,4 +2,8 @@ spring: application: name: blog-api profiles: - default: prod \ No newline at end of file + default: prod + +maia: + engine-url: ${MAIA_ENGINE_URL:http://localhost:8000} + game-session-ttl: ${MAIA_GAME_SESSION_TTL:PT6H} diff --git a/src/main/resources/db/migration/V20260619_01__create_chess_game_record.sql b/src/main/resources/db/migration/V20260619_01__create_chess_game_record.sql new file mode 100644 index 0000000..9f794fb --- /dev/null +++ b/src/main/resources/db/migration/V20260619_01__create_chess_game_record.sql @@ -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); diff --git a/src/test/kotlin/me/wypark/blogbackend/api/controller/ChessGameControllerSecurityIntegrationTest.kt b/src/test/kotlin/me/wypark/blogbackend/api/controller/ChessGameControllerSecurityIntegrationTest.kt new file mode 100644 index 0000000..6bcb112 --- /dev/null +++ b/src/test/kotlin/me/wypark/blogbackend/api/controller/ChessGameControllerSecurityIntegrationTest.kt @@ -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) + } +} diff --git a/src/test/kotlin/me/wypark/blogbackend/domain/chess/ChessGameServiceTest.kt b/src/test/kotlin/me/wypark/blogbackend/domain/chess/ChessGameServiceTest.kt new file mode 100644 index 0000000..a03366d --- /dev/null +++ b/src/test/kotlin/me/wypark/blogbackend/domain/chess/ChessGameServiceTest.kt @@ -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 { + 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 { + 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() + + 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() + + 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 { + 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() + val playRequests = mutableListOf() + + 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" + } +}