Add Maia chess gameplay and deployment
This commit is contained in:
@@ -19,9 +19,17 @@ jobs:
|
|||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run backend tests
|
||||||
run: ./gradlew test --no-daemon
|
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:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: test
|
needs: test
|
||||||
@@ -38,7 +46,16 @@ jobs:
|
|||||||
- name: Build boot jar
|
- name: Build boot jar
|
||||||
run: ./gradlew clean bootJar -x test --no-daemon
|
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:
|
env:
|
||||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||||
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
|
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
|
||||||
@@ -50,8 +67,11 @@ jobs:
|
|||||||
port="${DEPLOY_PORT:-22}"
|
port="${DEPLOY_PORT:-22}"
|
||||||
remote_dir="/root/blog-backend"
|
remote_dir="/root/blog-backend"
|
||||||
service_name="blog-api.service"
|
service_name="blog-api.service"
|
||||||
|
maia_service_name="maia-engine.service"
|
||||||
jar_name="blog-backend.jar"
|
jar_name="blog-backend.jar"
|
||||||
remote_tmp="/tmp/${jar_name}.new"
|
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)"
|
jar_file="$(find build/libs -maxdepth 1 -type f -name '*.jar' ! -name '*plain.jar' | sort | head -n 1)"
|
||||||
if [ -z "$jar_file" ]; then
|
if [ -z "$jar_file" ]; then
|
||||||
@@ -65,12 +85,125 @@ jobs:
|
|||||||
ssh-keyscan -p "$port" "$DEPLOY_HOST" >> ~/.ssh/known_hosts
|
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" "$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" \
|
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
|
set -euo pipefail
|
||||||
|
|
||||||
mkdir -p "$REMOTE_DIR"
|
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"
|
cd "$REMOTE_DIR"
|
||||||
|
|
||||||
backup_path=""
|
backup_path=""
|
||||||
@@ -83,7 +216,7 @@ jobs:
|
|||||||
chown root:root "$JAR_NAME"
|
chown root:root "$JAR_NAME"
|
||||||
chmod 0644 "$JAR_NAME"
|
chmod 0644 "$JAR_NAME"
|
||||||
|
|
||||||
rollback() {
|
rollback_backend() {
|
||||||
if [ -n "$backup_path" ] && [ -f "$backup_path" ]; then
|
if [ -n "$backup_path" ] && [ -f "$backup_path" ]; then
|
||||||
cp "$backup_path" "$JAR_NAME"
|
cp "$backup_path" "$JAR_NAME"
|
||||||
systemctl restart "$SERVICE_NAME" || true
|
systemctl restart "$SERVICE_NAME" || true
|
||||||
@@ -91,7 +224,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ! systemctl restart "$SERVICE_NAME"; then
|
if ! systemctl restart "$SERVICE_NAME"; then
|
||||||
rollback
|
rollback_backend
|
||||||
journalctl -u "$SERVICE_NAME" -n 120 --no-pager
|
journalctl -u "$SERVICE_NAME" -n 120 --no-pager
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -99,10 +232,11 @@ jobs:
|
|||||||
sleep 8
|
sleep 8
|
||||||
|
|
||||||
if ! systemctl is-active --quiet "$SERVICE_NAME"; then
|
if ! systemctl is-active --quiet "$SERVICE_NAME"; then
|
||||||
rollback
|
rollback_backend
|
||||||
journalctl -u "$SERVICE_NAME" -n 120 --no-pager
|
journalctl -u "$SERVICE_NAME" -n 120 --no-pager
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
systemctl is-active "$MAIA_SERVICE_NAME"
|
||||||
systemctl is-active "$SERVICE_NAME"
|
systemctl is-active "$SERVICE_NAME"
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
HELP.md
|
HELP.md
|
||||||
.gradle
|
.gradle
|
||||||
|
.gradle-user-home/
|
||||||
.env
|
.env
|
||||||
build/
|
build/
|
||||||
!gradle/wrapper/gradle-wrapper.jar
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
@@ -40,5 +41,9 @@ out/
|
|||||||
### Kotlin ###
|
### Kotlin ###
|
||||||
.kotlin
|
.kotlin
|
||||||
|
|
||||||
|
### Python ###
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
### docker ###
|
### docker ###
|
||||||
postgres_data
|
postgres_data
|
||||||
@@ -80,3 +80,52 @@ allOpen {
|
|||||||
tasks.withType<Test> {
|
tasks.withType<Test> {
|
||||||
useJUnitPlatform()
|
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_USERNAME=${DB_USER}
|
||||||
- SPRING_DATASOURCE_PASSWORD=${DB_PASS}
|
- SPRING_DATASOURCE_PASSWORD=${DB_PASS}
|
||||||
- JWT_SECRET=${JWT_SECRET}
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
|
- MAIA_ENGINE_URL=http://maia-engine:8000
|
||||||
# Redis
|
# Redis
|
||||||
- SPRING_DATA_REDIS_HOST=redis
|
- SPRING_DATA_REDIS_HOST=redis
|
||||||
- SPRING_DATA_REDIS_PORT=6379
|
- SPRING_DATA_REDIS_PORT=6379
|
||||||
@@ -37,10 +38,31 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
maia-engine:
|
||||||
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- blog-net
|
- blog-net
|
||||||
|
|
||||||
# 2. 데이터베이스 (PostgreSQL)
|
# 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:
|
db:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
container_name: blog-db
|
container_name: blog-db
|
||||||
@@ -91,3 +113,6 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
blog-net:
|
blog-net:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
maia_model_cache:
|
||||||
|
|||||||
@@ -1,33 +1,43 @@
|
|||||||
# CI/CD 운영 메모
|
# CI/CD 운영 메모
|
||||||
|
|
||||||
이 저장소는 Gitea Actions 기준으로 `main` 브랜치에 push되면 자동 배포되도록 구성한다.
|
이 저장소는 Gitea Actions 기준으로 `main` 브랜치에 push되면 백엔드와 Maia3 체스 엔진 브리지를 함께 자동 배포하도록 구성되어 있다.
|
||||||
현재 운영 서버는 Docker Compose가 아니라 Java 21 + systemd 방식으로 실행 중이다.
|
|
||||||
|
|
||||||
## 현재 운영 방식
|
## 현재 운영 방식
|
||||||
|
|
||||||
- 운영 서비스: `blog-api.service`
|
- 백엔드 서비스: `blog-api.service`
|
||||||
- 실행 디렉터리: `/root/blog-backend`
|
- 백엔드 실행 디렉터리: `/root/blog-backend`
|
||||||
- 실행 파일: `/root/blog-backend/blog-backend.jar`
|
- 백엔드 실행 파일: `/root/blog-backend/blog-backend.jar`
|
||||||
- 실행 방식: `java -jar -Duser.timezone=Asia/Seoul -Dspring.profiles.active=prod blog-backend.jar`
|
- 백엔드 실행 방식: `java -jar -Duser.timezone=Asia/Seoul -Dspring.profiles.active=prod blog-backend.jar`
|
||||||
- 운영 환경 변수와 외부 DB/Redis/S3 설정은 systemd service에 등록되어 있다.
|
- 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`)
|
1. `main` 브랜치 push 또는 수동 실행(`workflow_dispatch`)
|
||||||
2. Java 21로 `./gradlew test --no-daemon` 실행
|
2. Java 21로 `./gradlew test --no-daemon` 실행
|
||||||
3. 테스트 성공 시 `./gradlew clean bootJar -x test --no-daemon`으로 Spring Boot jar 빌드
|
3. Python 3.11로 `maia-engine/app/main.py` 문법 검사
|
||||||
4. 빌드된 jar를 SSH/SCP로 서버의 `/tmp/blog-backend.jar.new`에 업로드
|
4. 테스트 성공 시 `./gradlew clean bootJar -x test --no-daemon`으로 Spring Boot jar 빌드
|
||||||
5. 서버에서 기존 `/root/blog-backend/blog-backend.jar`를 timestamp 백업
|
5. `maia-engine` 디렉터리를 tar로 패키징
|
||||||
6. 새 jar를 `/root/blog-backend/blog-backend.jar`로 교체
|
6. jar, Maia 엔진 tar, `deploy/systemd/maia-engine.service`를 SSH/SCP로 서버에 업로드
|
||||||
7. `systemctl restart blog-api.service` 실행 후 active 상태 확인
|
7. 서버에서 Maia 엔진 코드를 `/root/blog-backend/maia-engine`으로 교체
|
||||||
8. 재시작 실패 또는 비정상 종료 시 직전 jar로 rollback 시도
|
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 준비
|
## Gitea Actions 준비
|
||||||
|
|
||||||
- Repository Settings에서 Actions를 활성화한다.
|
- Repository Settings에서 Actions가 활성화되어 있어야 한다.
|
||||||
- Gitea Runner가 `ubuntu-latest` label을 처리할 수 있어야 한다.
|
- Gitea Runner가 `ubuntu-latest` label을 처리할 수 있어야 한다.
|
||||||
- 현재 백엔드 서버에는 `blog-backend` runner가 `ubuntu-latest` label로 등록되어 있다.
|
- `actions/checkout`, `actions/setup-java`, `actions/setup-python` 액션을 사용할 수 있어야 한다.
|
||||||
- Docker는 runner job 실행에 쓰일 수 있지만, 애플리케이션 배포 방식은 systemd jar 교체 방식이다.
|
|
||||||
|
|
||||||
## Repository Secrets
|
## Repository Secrets
|
||||||
|
|
||||||
@@ -36,14 +46,38 @@ Gitea repository secrets에 다음 값을 등록한다.
|
|||||||
| 이름 | 설명 |
|
| 이름 | 설명 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `DEPLOY_HOST` | 배포 서버 호스트 또는 IP |
|
| `DEPLOY_HOST` | 배포 서버 호스트 또는 IP |
|
||||||
| `DEPLOY_PORT` | SSH 포트, 비우면 22 |
|
| `DEPLOY_PORT` | SSH 포트. 비우면 22 |
|
||||||
| `DEPLOY_USER` | 배포 서버 SSH 사용자 |
|
| `DEPLOY_USER` | 배포 서버 SSH 사용자 |
|
||||||
| `DEPLOY_KEY` | 배포 서버 접속용 private key |
|
| `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`를 실행할 수 있어야 한다.
|
- Java 21
|
||||||
- 운영 DB는 배포 전에 백업하는 것을 권장한다. Flyway migration은 애플리케이션 시작 시 자동 적용된다.
|
- systemd
|
||||||
- `docker-compose.yml`은 현재 CI/CD 배포 경로에서 사용하지 않는다.
|
- 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) 명시
|
// 2. 신뢰할 수 있는 출처(Origin) 명시
|
||||||
// 로컬 개발 환경과 배포 환경(Production)의 도메인을 각각 등록합니다.
|
// 로컬 개발 환경과 배포 환경(Production)의 도메인을 각각 등록합니다.
|
||||||
config.addAllowedOrigin("https://blog.wypark.me")
|
config.addAllowedOrigin("https://blog.wypark.me")
|
||||||
|
config.addAllowedOrigin("http://localhost:3000")
|
||||||
|
config.addAllowedOrigin("http://localhost:5173")
|
||||||
// config.addAllowedOrigin("http://localhost:3000") // 로컬 테스트 시 주석 해제
|
// config.addAllowedOrigin("http://localhost:3000") // 로컬 테스트 시 주석 해제
|
||||||
|
|
||||||
// 3. 허용할 HTTP 메서드 및 헤더
|
// 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
|
name: blog-api
|
||||||
profiles:
|
profiles:
|
||||||
default: prod
|
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